@jambonz/speech-utils 0.0.50 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,8 +8,8 @@ jobs:
8
8
  build:
9
9
  runs-on: ubuntu-latest
10
10
  steps:
11
- - uses: actions/checkout@v3
12
- - uses: actions/setup-node@v3
11
+ - uses: actions/checkout@v4
12
+ - uses: actions/setup-node@v4
13
13
  with:
14
14
  node-version: lts/*
15
15
  - run: npm install
@@ -32,5 +32,7 @@ jobs:
32
32
  ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }}
33
33
  ELEVENLABS_VOICE_ID: ${{ secrets.ELEVENLABS_VOICE_ID }}
34
34
  ELEVENLABS_MODEL_ID: ${{ secrets.ELEVENLABS_MODEL_ID }}
35
+ PLAYHT_USER_ID: ${{ secrets.PLAYHT_USER_ID }}
36
+ PLAYHT_API_KEY: ${{ secrets.PLAYHT_API_KEY }}
35
37
  JAMBONES_HTTP_PROXY_IP: 127.0.0.1
36
38
  JAMBONES_HTTP_PROXY_PORT: 3128
package/index.js CHANGED
@@ -13,10 +13,10 @@ module.exports = (opts, logger) => {
13
13
  getTtsSize: require('./lib/get-tts-size').bind(null, client, logger),
14
14
  purgeTtsCache: require('./lib/purge-tts-cache').bind(null, client, logger),
15
15
  addFileToCache: require('./lib/add-file-to-cache').bind(null, client, logger),
16
- synthAudio: require('./lib/synth-audio').bind(null, client, logger),
16
+ synthAudio: require('./lib/synth-audio').bind(null, client, createHash, retrieveHash, logger),
17
17
  getNuanceAccessToken: require('./lib/get-nuance-access-token').bind(null, client, logger),
18
18
  getIbmAccessToken: require('./lib/get-ibm-access-token').bind(null, client, logger),
19
19
  getAwsAuthToken: require('./lib/get-aws-sts-token').bind(null, logger, createHash, retrieveHash),
20
- getTtsVoices: require('./lib/get-tts-voices').bind(null, client, logger),
20
+ getTtsVoices: require('./lib/get-tts-voices').bind(null, client, createHash, retrieveHash, logger),
21
21
  };
22
22
  };
@@ -1,32 +1,41 @@
1
- const { STSClient, GetSessionTokenCommand } = require('@aws-sdk/client-sts');
1
+ const { STSClient, GetSessionTokenCommand, AssumeRoleCommand } = require('@aws-sdk/client-sts');
2
2
  const {makeAwsKey, noopLogger} = require('./utils');
3
3
  const debug = require('debug')('jambonz:speech-utils');
4
4
  const EXPIRY = 3600;
5
5
 
6
6
  async function getAwsAuthToken(
7
- logger,
8
- createHash, retrieveHash,
9
- awsAccessKeyId, awsSecretAccessKey, awsRegion) {
7
+ logger, createHash, retrieveHash,
8
+ awsAccessKeyId, awsSecretAccessKey, awsRegion, roleArn = null) {
10
9
  logger = logger || noopLogger;
11
10
  try {
12
- const key = makeAwsKey(awsAccessKeyId);
11
+ const key = makeAwsKey(roleArn || awsAccessKeyId);
13
12
  const obj = await retrieveHash(key);
14
13
  if (obj) return {...obj, servedFromCache: true};
15
14
 
16
- /* access token not found in cache, so generate it using STS */
17
- const stsClient = new STSClient({
18
- region: awsRegion,
19
- credentials: {
20
- accessKeyId: awsAccessKeyId,
21
- secretAccessKey: awsSecretAccessKey,
22
- }
23
- });
24
- const command = new GetSessionTokenCommand({DurationSeconds: EXPIRY});
25
- const data = await stsClient.send(command);
15
+ let data;
16
+ if (roleArn) {
17
+ const stsClient = new STSClient({ region: awsRegion});
18
+ const roleToAssume = { RoleArn: roleArn, RoleSessionName: 'Jambonz_Speech', DurationSeconds: EXPIRY};
19
+ const command = new AssumeRoleCommand(roleToAssume);
20
+
21
+ data = await stsClient.send(command);
22
+ } else {
23
+ /* access token not found in cache, so generate it using STS */
24
+ const stsClient = new STSClient({
25
+ region: awsRegion,
26
+ credentials: {
27
+ accessKeyId: awsAccessKeyId,
28
+ secretAccessKey: awsSecretAccessKey,
29
+ }
30
+ });
31
+ const command = new GetSessionTokenCommand({DurationSeconds: EXPIRY});
32
+ data = await stsClient.send(command);
33
+ }
26
34
 
27
35
  const credentials = {
28
36
  accessKeyId: data.Credentials.AccessKeyId,
29
37
  secretAccessKey: data.Credentials.SecretAccessKey,
38
+ sessionToken: data.Credentials.SessionToken,
30
39
  securityToken: data.Credentials.SessionToken
31
40
  };
32
41
 
@@ -6,6 +6,7 @@ const TextToSpeechV1 = require('ibm-watson/text-to-speech/v1');
6
6
  const { IamAuthenticator } = require('ibm-watson/auth');
7
7
  const ttsGoogle = require('@google-cloud/text-to-speech');
8
8
  const { PollyClient, DescribeVoicesCommand } = require('@aws-sdk/client-polly');
9
+ const getAwsAuthToken = require('./get-aws-sts-token');
9
10
 
10
11
  const getIbmVoices = async(client, logger, credentials) => {
11
12
  const {tts_region, tts_api_key} = credentials;
@@ -87,16 +88,26 @@ const getGoogleVoices = async(_client, logger, credentials) => {
87
88
  return await client.listVoices();
88
89
  };
89
90
 
90
- const getAwsVoices = async(_client, logger, credentials) => {
91
+ const getAwsVoices = async(_client, createHash, retrieveHash, logger, credentials) => {
91
92
  try {
92
- const {region, accessKeyId, secretAccessKey} = credentials;
93
- const client = new PollyClient({
94
- region,
95
- credentials: {
96
- accessKeyId,
97
- secretAccessKey
98
- }
99
- });
93
+ const {region, accessKeyId, secretAccessKey, roleArn} = credentials;
94
+ let client = null;
95
+ if (accessKeyId && secretAccessKey) {
96
+ client = new PollyClient({
97
+ region,
98
+ credentials: {
99
+ accessKeyId,
100
+ secretAccessKey
101
+ }
102
+ });
103
+ } else if (roleArn) {
104
+ client = new PollyClient({
105
+ region,
106
+ credentials: await getAwsAuthToken(logger, createHash, retrieveHash, null, null, region, roleArn),
107
+ });
108
+ } else {
109
+ client = new PollyClient({region});
110
+ }
100
111
  const command = new DescribeVoicesCommand({});
101
112
  const response = await client.send(command);
102
113
  return response;
@@ -122,7 +133,7 @@ const getAwsVoices = async(_client, logger, credentials) => {
122
133
  * @returns object containing filepath to an mp3 file in the /tmp folder containing
123
134
  * the synthesized audio, and a variable indicating whether it was served from cache
124
135
  */
125
- async function getTtsVoices(client, logger, {vendor, credentials}) {
136
+ async function getTtsVoices(client, createHash, retrieveHash, logger, {vendor, credentials}) {
126
137
  logger = logger || noopLogger;
127
138
 
128
139
  assert.ok(['nuance', 'ibm', 'google', 'aws', 'polly'].includes(vendor),
@@ -137,7 +148,7 @@ async function getTtsVoices(client, logger, {vendor, credentials}) {
137
148
  return getGoogleVoices(client, logger, credentials);
138
149
  case 'aws':
139
150
  case 'polly':
140
- return getAwsVoices(client, logger, credentials);
151
+ return getAwsVoices(client, createHash, retrieveHash, logger, credentials);
141
152
  default:
142
153
  break;
143
154
  }
@@ -39,6 +39,7 @@ const debug = require('debug')('jambonz:realtimedb-helpers');
39
39
  const EXPIRES = (process.env.JAMBONES_TTS_CACHE_DURATION_MINS || 4 * 60) * 60; // cache tts for 4 hours
40
40
  const TMP_FOLDER = '/tmp';
41
41
  const OpenAI = require('openai');
42
+ const getAwsAuthToken = require('./get-aws-sts-token');
42
43
 
43
44
 
44
45
  const trimTrailingSilence = (buffer) => {
@@ -75,7 +76,7 @@ const trimTrailingSilence = (buffer) => {
75
76
  * @returns object containing filepath to an mp3 file in the /tmp folder containing
76
77
  * the synthesized audio, and a variable indicating whether it was served from cache
77
78
  */
78
- async function synthAudio(client, logger, stats, { account_sid,
79
+ async function synthAudio(client, createHash, retrieveHash, logger, stats, { account_sid,
79
80
  vendor, language, voice, gender, text, engine, salt, model, credentials, deploymentId,
80
81
  disableTtsCache, renderForCaching, disableTtsStreaming, options
81
82
  }) {
@@ -84,10 +85,10 @@ async function synthAudio(client, logger, stats, { account_sid,
84
85
  let rtt;
85
86
  logger = logger || noopLogger;
86
87
 
87
- assert.ok(['google', 'aws', 'polly', 'microsoft',
88
- 'wellsaid', 'nuance', 'nvidia', 'ibm', 'elevenlabs', 'whisper', 'deepgram', 'playht'].includes(vendor) ||
88
+ assert.ok(['google', 'aws', 'polly', 'microsoft', 'wellsaid', 'nuance', 'nvidia', 'ibm', 'elevenlabs',
89
+ 'whisper', 'deepgram', 'playht', 'rimelabs'].includes(vendor) ||
89
90
  vendor.startsWith('custom'),
90
- `synthAudio supported vendors are google, aws, microsoft, nuance, nvidia and wellsaid, not ${vendor}`);
91
+ `synthAudio supported vendors are google, aws, microsoft, nuance, nvidia and wellsaid ..etc, not ${vendor}`);
91
92
  if ('google' === vendor) {
92
93
  assert.ok(language, 'synthAudio requires language when google is used');
93
94
  }
@@ -123,11 +124,15 @@ async function synthAudio(client, logger, stats, { account_sid,
123
124
  assert.ok(voice, 'synthAudio requires voice when elevenlabs is used');
124
125
  assert.ok(credentials.api_key, 'synthAudio requires api_key when elevenlabs is used');
125
126
  assert.ok(credentials.model_id, 'synthAudio requires model_id when elevenlabs is used');
126
- } else if ('playht' === vendor) {
127
+ } else if ('playht' === vendor) {
127
128
  assert.ok(voice, 'synthAudio requires voice when playht is used');
128
129
  assert.ok(credentials.api_key, 'synthAudio requires api_key when playht is used');
129
130
  assert.ok(credentials.user_id, 'synthAudio requires user_id when playht is used');
130
131
  assert.ok(credentials.voice_engine, 'synthAudio requires voice_engine when playht is used');
132
+ } else if ('rimelabs' === vendor) {
133
+ assert.ok(voice, 'synthAudio requires voice when rimelabs is used');
134
+ assert.ok(credentials.api_key, 'synthAudio requires api_key when rimelabs is used');
135
+ assert.ok(credentials.model_id, 'synthAudio requires model_id when rimelabs is used');
131
136
  } else if ('whisper' === vendor) {
132
137
  assert.ok(voice, 'synthAudio requires voice when whisper is used');
133
138
  assert.ok(credentials.model_id, 'synthAudio requires model when whisper is used');
@@ -151,7 +156,7 @@ async function synthAudio(client, logger, stats, { account_sid,
151
156
  ) ||
152
157
  (
153
158
  !process.env.JAMBONES_DISABLE_TTS_STREAMING &&
154
- ['elevenlabs', 'deepgram'].includes(vendor)
159
+ ['elevenlabs', 'deepgram', 'rimelabs'].includes(vendor)
155
160
  )
156
161
  ) {
157
162
  filePath = `${TMP_FOLDER}/${key.replace('tts:', `tts-${salt || ''}`)}.r8`;
@@ -183,7 +188,8 @@ async function synthAudio(client, logger, stats, { account_sid,
183
188
  case 'aws':
184
189
  case 'polly':
185
190
  vendorLabel = 'aws';
186
- audioBuffer = await synthPolly(logger, {credentials, stats, language, voice, text, engine});
191
+ audioBuffer = await synthPolly(createHash, retrieveHash, logger,
192
+ {credentials, stats, language, voice, text, engine});
187
193
  break;
188
194
  case 'azure':
189
195
  case 'microsoft':
@@ -217,6 +223,12 @@ async function synthAudio(client, logger, stats, { account_sid,
217
223
  });
218
224
  if (audioBuffer?.filePath) return audioBuffer;
219
225
  break;
226
+ case 'rimelabs':
227
+ audioBuffer = await synthRimelabs(logger, {
228
+ credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming, filePath
229
+ });
230
+ if (audioBuffer?.filePath) return audioBuffer;
231
+ break;
220
232
  case 'whisper':
221
233
  audioBuffer = await synthWhisper(logger, {
222
234
  credentials, stats, voice, text, renderForCaching, disableTtsStreaming});
@@ -253,16 +265,28 @@ async function synthAudio(client, logger, stats, { account_sid,
253
265
  });
254
266
  }
255
267
 
256
- const synthPolly = async(logger, {credentials, stats, language, voice, engine, text}) => {
268
+ const synthPolly = async(createHash, retrieveHash, logger,
269
+ {credentials, stats, language, voice, engine, text}) => {
257
270
  try {
258
- const {region, accessKeyId, secretAccessKey} = credentials;
259
- const polly = new PollyClient({
260
- region,
261
- credentials: {
262
- accessKeyId,
263
- secretAccessKey
264
- }
265
- });
271
+ const {region, accessKeyId, secretAccessKey, roleArn} = credentials;
272
+ let polly;
273
+ if (accessKeyId && secretAccessKey) {
274
+ polly = new PollyClient({
275
+ region,
276
+ credentials: {
277
+ accessKeyId,
278
+ secretAccessKey
279
+ }
280
+ });
281
+ } else if (roleArn) {
282
+ polly = new PollyClient({
283
+ region,
284
+ credentials: await getAwsAuthToken(logger, createHash, retrieveHash, null, null, region, roleArn),
285
+ });
286
+ } else {
287
+ // AWS RoleArn assigned to Instance profile
288
+ polly = new PollyClient({region});
289
+ }
266
290
  const opts = {
267
291
  Engine: engine,
268
292
  OutputFormat: 'mp3',
@@ -748,6 +772,52 @@ const synthPlayHT = async(logger, {
748
772
  }
749
773
  };
750
774
 
775
+ const synthRimelabs = async(logger, {
776
+ credentials, options, stats, voice, text, renderForCaching, disableTtsStreaming
777
+ }) => {
778
+ const {api_key, model_id, options: credOpts} = credentials;
779
+ const opts = !!options && Object.keys(options).length !== 0 ? options : JSON.parse(credOpts || '{}');
780
+
781
+ /* default to using the streaming interface, unless disabled by env var OR we want just a cache file */
782
+ if (!process.env.JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
783
+ let params = '';
784
+ params += `{api_key=${api_key}`;
785
+ params += `,model_id=${model_id}`;
786
+ params += ',vendor=rimelabs';
787
+ params += `,voice=${voice}`;
788
+ params += ',write_cache_file=1';
789
+ if (opts.speedAlpha) params += `,speed_alpha=${opts.speedAlpha}`;
790
+ if (opts.reduceLatency) params += `,reduce_latency=${opts.reduceLatency}`;
791
+ params += '}';
792
+
793
+ return {
794
+ filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
795
+ servedFromCache: false,
796
+ rtt: 0
797
+ };
798
+ }
799
+
800
+ try {
801
+ const post = bent('https://users.rime.ai', 'POST', 'buffer', {
802
+ 'Authorization': `Bearer ${api_key}`,
803
+ 'Accept': 'audio/mp3',
804
+ 'Content-Type': 'application/json'
805
+ });
806
+ const mp3 = await post('/v1/rime-tts', {
807
+ speaker: voice,
808
+ text,
809
+ modelId: model_id,
810
+ samplingRate: 8000,
811
+ ...opts
812
+ });
813
+ return mp3;
814
+ } catch (err) {
815
+ logger.info({err}, 'synth rimelabs returned error');
816
+ stats.increment('tts.count', ['vendor:rimelabs', 'accepted:no']);
817
+ throw err;
818
+ }
819
+ };
820
+
751
821
  const synthWhisper = async(logger, {credentials, stats, voice, text, renderForCaching, disableTtsStreaming}) => {
752
822
  const {api_key, model_id, baseURL, timeout, speed} = credentials;
753
823
  /* if the env is set to stream then bag out, unless we are specifically rendering to generate a cache file */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jambonz/speech-utils",
3
- "version": "0.0.50",
3
+ "version": "0.1.0",
4
4
  "description": "TTS-related speech utilities for jambonz",
5
5
  "main": "index.js",
6
6
  "author": "Dave Horton",
package/test/synth.js CHANGED
@@ -162,6 +162,33 @@ test('AWS speech synth tests', async(t) => {
162
162
  client.quit();
163
163
  });
164
164
 
165
+ test('AWS speech synth tests by RoleArn', async(t) => {
166
+ const fn = require('..');
167
+ const {synthAudio, client} = fn(opts, logger);
168
+
169
+ if (!process.env.AWS_ROLE_ARN || !process.env.AWS_REGION) {
170
+ t.pass('skipping AWS speech synth tests by RoleArn since AWS_ROLE_ARN or AWS_REGION not provided');
171
+ return t.end();
172
+ }
173
+ try {
174
+ let opts = await synthAudio(stats, {
175
+ vendor: 'aws',
176
+ credentials: {
177
+ roleArn: process.env.AWS_ROLE_ARN,
178
+ region: process.env.AWS_REGION,
179
+ },
180
+ language: 'en-US',
181
+ voice: 'Joey',
182
+ text: 'This is a test. This is only a test',
183
+ });
184
+ t.ok(!opts.servedFromCache, `successfully synthesized aws by roleArn audio to ${opts.filePath}`);
185
+ } catch (err) {
186
+ console.error(err);
187
+ t.end(err);
188
+ }
189
+ client.quit();
190
+ });
191
+
165
192
  test('Azure speech synth tests', async(t) => {
166
193
  const fn = require('..');
167
194
  const {synthAudio, client} = fn(opts, logger);
@@ -581,14 +608,48 @@ test('PlayHT speech synth tests', async(t) => {
581
608
  text,
582
609
  renderForCaching: true
583
610
  });
584
- t.ok(!opts.servedFromCache, `successfully synthesized eleven audio to ${opts.filePath}`);
611
+ t.ok(!opts.servedFromCache, `successfully playht eleven audio to ${opts.filePath}`);
585
612
 
586
613
  } catch (err) {
587
614
  console.error(JSON.stringify(err));
588
615
  t.end(err);
589
616
  }
590
617
  client.quit();
591
- })
618
+ });
619
+
620
+ test('rimelabs speech synth tests', async(t) => {
621
+ const fn = require('..');
622
+ const {synthAudio, client} = fn(opts, logger);
623
+
624
+ if (!process.env.RIMELABS_API_KEY) {
625
+ t.pass('skipping rimelabs speech synth tests since RIMELABS_API_KEY is not provided');
626
+ return t.end();
627
+ }
628
+ const text = 'Hi there and welcome to jambones!';
629
+ try {
630
+ let opts = await synthAudio(stats, {
631
+ vendor: 'rimelabs',
632
+ credentials: {
633
+ api_key: process.env.RIMELABS_API_KEY,
634
+ model_id: 'mist',
635
+ options: JSON.stringify({
636
+ speedAlpha: 1.0,
637
+ reduceLatency: false
638
+ })
639
+ },
640
+ language: 'en-US',
641
+ voice: 'amber',
642
+ text,
643
+ renderForCaching: true
644
+ });
645
+ t.ok(!opts.servedFromCache, `successfully synthesized rimelabs audio to ${opts.filePath}`);
646
+
647
+ } catch (err) {
648
+ console.error(JSON.stringify(err));
649
+ t.end(err);
650
+ }
651
+ client.quit();
652
+ });
592
653
 
593
654
  test('whisper speech synth tests', async(t) => {
594
655
  const fn = require('..');