@jambonz/speech-utils 0.1.22 → 0.2.1

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.
@@ -3,6 +3,27 @@ const {noopLogger, makeSynthKey} = require('./utils');
3
3
  const {JAMBONES_TTS_CACHE_DURATION_MINS} = require('./config');
4
4
  const EXPIRES = JAMBONES_TTS_CACHE_DURATION_MINS;
5
5
 
6
+ function getExtensionAndSampleRate(path) {
7
+ const match = path.match(/\.([^.]*)$/);
8
+ if (!match) {
9
+ //default should be wav file.
10
+ return ['wav', 8000];
11
+ }
12
+
13
+ const extension = match[1];
14
+ const sampleRateMap = {
15
+ r8: 8000,
16
+ r16: 16000,
17
+ r24: 24000,
18
+ r44: 44100,
19
+ r48: 48000,
20
+ r96: 96000,
21
+ };
22
+
23
+ const sampleRate = sampleRateMap[extension] || 8000;
24
+ return [extension, sampleRate];
25
+ }
26
+
6
27
  async function addFileToCache(client, logger, path,
7
28
  {account_sid, vendor, language, voice, deploymentId, engine, text}) {
8
29
  let key;
@@ -17,8 +38,15 @@ async function addFileToCache(client, logger, path,
17
38
  engine,
18
39
  text,
19
40
  });
41
+ const [extension, sampleRate] = getExtensionAndSampleRate(path);
20
42
  const audioBuffer = await fs.readFile(path);
21
- await client.setex(key, EXPIRES, audioBuffer.toString('base64'));
43
+ await client.setex(key, EXPIRES, JSON.stringify(
44
+ {
45
+ audioContent: audioBuffer.toString('base64'),
46
+ extension,
47
+ sampleRate
48
+ }
49
+ ));
22
50
  } catch (err) {
23
51
  logger.error(err, 'addFileToCache: Error');
24
52
  return;
@@ -7,22 +7,23 @@ const CACHE_EXPIRY = process.env.AWS_STS_SESSION_RESET_EXPIRY || (EXPIRY - 600);
7
7
 
8
8
  async function getAwsAuthToken(
9
9
  logger, createHash, retrieveHash,
10
- {accessKeyId, secretAccessKey, region, roleArn}) {
10
+ {speech_credential_sid, accessKeyId, secretAccessKey, region, roleArn}) {
11
11
  logger = logger || noopLogger;
12
12
  try {
13
- const key = makeAwsKey(roleArn || accessKeyId);
13
+ // if incase instance profile is used, speech_credential_sid will be used as key to lookup cache
14
+ const key = makeAwsKey(roleArn || accessKeyId || speech_credential_sid);
14
15
  const obj = await retrieveHash(key);
15
16
  if (obj) return {...obj, servedFromCache: true};
16
-
17
+ /* access token not found in cache, so generate it using STS */
17
18
  let data;
19
+ let expiry = CACHE_EXPIRY;
18
20
  if (roleArn) {
19
21
  const stsClient = new STSClient({ region });
20
22
  const roleToAssume = { RoleArn: roleArn, RoleSessionName: 'Jambonz_Speech', DurationSeconds: EXPIRY};
21
23
  const command = new AssumeRoleCommand(roleToAssume);
22
24
 
23
25
  data = await stsClient.send(command);
24
- } else {
25
- /* access token not found in cache, so generate it using STS */
26
+ } else if (accessKeyId) {
26
27
  const stsClient = new STSClient({
27
28
  region,
28
29
  credentials: {
@@ -32,6 +33,26 @@ async function getAwsAuthToken(
32
33
  });
33
34
  const command = new GetSessionTokenCommand({DurationSeconds: EXPIRY});
34
35
  data = await stsClient.send(command);
36
+ } else {
37
+ // instance profile is used.
38
+ const stsClient = new STSClient({ region });
39
+ const cred = await stsClient.config.credentials();
40
+ // method in the AWS SDK automatically fetches credentials using the default credential
41
+ // provider chain. If the credentials come from an instance profile or an environment
42
+ // variable, their expiration is controlled by AWS and not explicitly by our code.
43
+ if (cred && cred.expiration) {
44
+ const currentTime = new Date();
45
+ const expiryTime = new Date(cred.expiration);
46
+ const remainingTimeInSeconds = Math.round((expiryTime - currentTime) / 1000);
47
+ expiry = remainingTimeInSeconds;
48
+ }
49
+ data = {
50
+ Credentials: {
51
+ AccessKeyId: cred.accessKeyId,
52
+ SecretAccessKey: cred.secretAccessKey,
53
+ SessionToken: cred.sessionToken
54
+ }
55
+ };
35
56
  }
36
57
 
37
58
  const credentials = {
@@ -40,9 +61,11 @@ async function getAwsAuthToken(
40
61
  sessionToken: data.Credentials.SessionToken,
41
62
  securityToken: data.Credentials.SessionToken
42
63
  };
43
-
44
- createHash(key, credentials, CACHE_EXPIRY)
45
- .catch((err) => logger.error(err, `Error saving hash for key ${key}`));
64
+ // Only cache if expiry is good
65
+ if (expiry > 0) {
66
+ createHash(key, credentials, expiry)
67
+ .catch((err) => logger.error(err, `Error saving hash for key ${key}`));
68
+ }
46
69
 
47
70
  return {...credentials, servedFromCache: false};
48
71
  } catch (err) {
@@ -3,6 +3,7 @@ const fs = require('fs');
3
3
  const bent = require('bent');
4
4
  const ttsGoogle = require('@google-cloud/text-to-speech');
5
5
  const { PollyClient, SynthesizeSpeechCommand } = require('@aws-sdk/client-polly');
6
+ const { CartesiaClient } = require('@cartesia/cartesia-js');
6
7
 
7
8
  const sdk = require('microsoft-cognitiveservices-speech-sdk');
8
9
  const TextToSpeechV1 = require('ibm-watson/text-to-speech/v1');
@@ -45,7 +46,7 @@ const {
45
46
  JAMBONES_HTTP_PROXY_IP,
46
47
  JAMBONES_HTTP_PROXY_PORT,
47
48
  JAMBONES_TTS_CACHE_DURATION_MINS,
48
- JAMBONES_EAGERLY_PRE_CACHE_AUDIO,
49
+ JAMBONES_TTS_TRIM_SILENCE,
49
50
  } = require('./config');
50
51
  const EXPIRES = JAMBONES_TTS_CACHE_DURATION_MINS;
51
52
  const OpenAI = require('openai');
@@ -90,13 +91,13 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
90
91
  vendor, language, voice, gender, text, engine, salt, model, credentials, deploymentId,
91
92
  disableTtsCache, renderForCaching = false, disableTtsStreaming, options
92
93
  }) {
93
- let audioBuffer;
94
+ let audioData;
94
95
  let servedFromCache = false;
95
96
  let rtt;
96
97
  logger = logger || noopLogger;
97
98
 
98
99
  assert.ok(['google', 'aws', 'polly', 'microsoft', 'wellsaid', 'nuance', 'nvidia', 'ibm', 'elevenlabs',
99
- 'whisper', 'deepgram', 'playht', 'rimelabs', 'verbio'].includes(vendor) ||
100
+ 'whisper', 'deepgram', 'playht', 'rimelabs', 'verbio', 'cartesia'].includes(vendor) ||
100
101
  vendor.startsWith('custom'),
101
102
  `synthAudio supported vendors are google, aws, microsoft, nuance, nvidia and wellsaid ..etc, not ${vendor}`);
102
103
  if ('google' === vendor) {
@@ -157,7 +158,9 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
157
158
  if (!credentials.deepgram_tts_uri) {
158
159
  assert.ok(credentials.api_key, 'synthAudio requires api_key when deepgram is used');
159
160
  }
160
-
161
+ } else if ('cartesia' === vendor) {
162
+ assert.ok(credentials.api_key, 'synthAudio requires api_key when cartesia is used');
163
+ assert.ok(credentials.model_id, 'synthAudio requires model_id when cartesia is used');
161
164
  }
162
165
 
163
166
  const key = makeSynthKey({
@@ -166,57 +169,24 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
166
169
  language: language || '',
167
170
  voice: voice || deploymentId,
168
171
  engine,
169
- text,
170
- renderForCaching
172
+ text
171
173
  });
172
- let filePath;
173
- // used only for custom vendor
174
- let fileExtension;
175
- filePath = makeFilePath({vendor, voice, key, salt, renderForCaching});
174
+
176
175
  debug(`synth key is ${key}`);
177
176
  let cached;
178
177
  if (!disableTtsCache) {
179
178
  cached = await client.get(key);
180
- /**
181
- * If we are using tts streaming and also precaching audio, audio could have been cached by streaming (r8)
182
- * or here in speech-utils due to precaching (mp3), so we need to check for both keys.
183
- */
184
- if (!cached && JAMBONES_EAGERLY_PRE_CACHE_AUDIO) {
185
- const preCachekey = makeSynthKey({
186
- account_sid,
187
- vendor,
188
- language: language || '',
189
- voice: voice || deploymentId,
190
- engine,
191
- text,
192
- renderForCaching: true
193
- });
194
- cached = await client.get(preCachekey);
195
- if (cached) {
196
- // Precache audio is available update filpath with precache file extension.
197
- filePath = makeFilePath({vendor, voice, key, salt, renderForCaching: true});
198
- }
199
- }
200
179
  }
201
180
  if (cached) {
202
181
  // found in cache - extend the expiry and use it
203
182
  debug('result WAS found in cache');
204
183
  servedFromCache = true;
205
184
  stats.increment('tts.cache.requests', ['found:yes']);
206
- if (vendor.startsWith('custom')) {
207
- // custom vendors support multiple mime types such as: mp3, wav, r8, r16 ...etc,
208
- // mime type/file extension is available when http response has header Content-type.
209
- // In cache, file extension is store together with audiBuffer in a json.
210
- // Normal cache audio will be base64 string
211
- const payload = JSON.parse(cached);
212
- filePath = filePath.replace(/\.[^\.]*$/g, payload.fileExtension);
213
- audioBuffer = Buffer.from(payload.audioBuffer, 'base64');
214
- } else {
215
- audioBuffer = Buffer.from(cached, 'base64');
216
- }
185
+ audioData = JSON.parse(cached);
186
+ // convert base64 audio to buffer
187
+ audioData.audioContent = Buffer.from(audioData.audioContent, 'base64');
217
188
  client.expire(key, EXPIRES).catch((err) => logger.info(err, 'Error setting expires'));
218
- }
219
- if (!cached) {
189
+ } else {
220
190
  // not found in cache - go get it from speech vendor and add to cache
221
191
  debug('result was NOT found in cache');
222
192
  stats.increment('tts.cache.requests', ['found:no']);
@@ -224,89 +194,92 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
224
194
  const startAt = process.hrtime();
225
195
  switch (vendor) {
226
196
  case 'google':
227
- audioBuffer = await synthGoogle(logger, {credentials, stats, language, voice, gender, text});
197
+ audioData = await synthGoogle(logger, {credentials, stats, language, voice, gender, text});
228
198
  break;
229
199
  case 'aws':
230
200
  case 'polly':
231
201
  vendorLabel = 'aws';
232
- audioBuffer = await synthPolly(createHash, retrieveHash, logger,
202
+ audioData = await synthPolly(createHash, retrieveHash, logger,
233
203
  {credentials, stats, language, voice, text, engine});
234
204
  break;
235
205
  case 'azure':
236
206
  case 'microsoft':
237
207
  vendorLabel = 'microsoft';
238
- audioBuffer = await synthMicrosoft(logger, {credentials, stats, language, voice, text, deploymentId,
239
- filePath, renderForCaching, disableTtsStreaming});
208
+ audioData = await synthMicrosoft(logger, {credentials, stats, language, voice, text, deploymentId,
209
+ renderForCaching, disableTtsStreaming});
240
210
  break;
241
211
  case 'nuance':
242
212
  model = model || 'enhanced';
243
- audioBuffer = await synthNuance(client, logger, {credentials, stats, voice, model, text});
213
+ audioData = await synthNuance(client, logger, {credentials, stats, voice, model, text});
244
214
  break;
245
215
  case 'nvidia':
246
- audioBuffer = await synthNvidia(client, logger, {credentials, stats, language, voice, model, text});
216
+ audioData = await synthNvidia(client, logger, {credentials, stats, language, voice, model, text});
247
217
  break;
248
218
  case 'ibm':
249
- audioBuffer = await synthIbm(logger, {credentials, stats, voice, text});
219
+ audioData = await synthIbm(logger, {credentials, stats, voice, text});
250
220
  break;
251
221
  case 'wellsaid':
252
- audioBuffer = await synthWellSaid(logger, {credentials, stats, language, voice, text, filePath});
222
+ audioData = await synthWellSaid(logger, {credentials, stats, language, voice, text});
253
223
  break;
254
224
  case 'elevenlabs':
255
- audioBuffer = await synthElevenlabs(logger, {
256
- credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming, filePath
257
- });
225
+ audioData = await synthElevenlabs(logger, {
226
+ credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
258
227
  break;
259
228
  case 'playht':
260
- audioBuffer = await synthPlayHT(client, logger, {
261
- credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming, filePath
262
- });
229
+ audioData = await synthPlayHT(client, logger, {
230
+ credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
231
+ break;
232
+ case 'cartesia':
233
+ audioData = await synthCartesia(logger, {
234
+ credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
263
235
  break;
264
236
  case 'rimelabs':
265
- audioBuffer = await synthRimelabs(logger, {
266
- credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming, filePath
267
- });
237
+ audioData = await synthRimelabs(logger, {
238
+ credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
268
239
  break;
269
240
  case 'whisper':
270
- audioBuffer = await synthWhisper(logger, {
241
+ audioData = await synthWhisper(logger, {
271
242
  credentials, stats, voice, text, renderForCaching, disableTtsStreaming});
272
243
  break;
273
244
  case 'verbio':
274
- audioBuffer = await synthVerbio(client, logger, {
245
+ audioData = await synthVerbio(client, logger, {
275
246
  credentials, stats, voice, text, renderForCaching, disableTtsStreaming});
276
- if (audioBuffer?.filePath) return audioBuffer;
247
+ if (audioData?.filePath) return audioData;
277
248
  break;
278
249
  case 'deepgram':
279
- audioBuffer = await synthDeepgram(logger, {credentials, stats, model, text,
250
+ audioData = await synthDeepgram(logger, {credentials, stats, model, text,
280
251
  renderForCaching, disableTtsStreaming});
281
252
  break;
282
253
  case vendor.startsWith('custom') ? vendor : 'cant_match_value':
283
- ({ audioBuffer, filePath, fileExtension } = await synthCustomVendor(logger,
284
- {credentials, stats, language, voice, text, filePath}));
254
+ audioData = await synthCustomVendor(logger,
255
+ {credentials, stats, language, voice, text});
285
256
  break;
286
257
  default:
287
258
  assert(`synthAudio: unsupported speech vendor ${vendor}`);
288
259
  }
289
- if ('filePath' in audioBuffer) return audioBuffer;
260
+ if ('filePath' in audioData) return audioData;
290
261
  const diff = process.hrtime(startAt);
291
262
  const time = diff[0] * 1e3 + diff[1] * 1e-6;
292
263
  rtt = time.toFixed(0);
293
264
  stats.histogram('tts.response_time', rtt, [`vendor:${vendorLabel}`]);
294
265
  debug(`tts rtt time for ${text.length} chars on ${vendorLabel}: ${rtt}`);
295
266
  logger.info(`tts rtt time for ${text.length} chars on ${vendorLabel}: ${rtt}`);
296
-
297
- const base64Audio = audioBuffer.toString('base64');
298
- const cacheContent = vendor.startsWith('custom') ?
299
- JSON.stringify({
300
- audioBuffer: base64Audio,
301
- fileExtension
302
- }) : base64Audio;
303
-
304
- client.setex(key, EXPIRES, cacheContent)
267
+ // Save audio json to cache
268
+ client.setex(key, EXPIRES, JSON.stringify({
269
+ ...audioData,
270
+ audioContent: audioData.audioContent?.toString('base64')
271
+ }))
305
272
  .catch((err) => logger.error(err, `error calling setex on key ${key}`));
306
273
  }
307
274
 
308
275
  return new Promise((resolve, reject) => {
309
- fs.writeFile(filePath, audioBuffer, (err) => {
276
+ const { audioContent, extension } = audioData;
277
+ const filePath = makeFilePath({
278
+ key,
279
+ salt,
280
+ extension
281
+ });
282
+ fs.writeFile(filePath, audioContent, (err) => {
310
283
  if (err) return reject(err);
311
284
  resolve({filePath, servedFromCache, rtt});
312
285
  });
@@ -361,7 +334,13 @@ const synthPolly = async(createHash, retrieveHash, logger,
361
334
  .on('data', (chunk) => {
362
335
  chunks.push(chunk);
363
336
  })
364
- .on('end', () => resolve(Buffer.concat(chunks)));
337
+ .on('end', () => resolve(
338
+ {
339
+ audioContent: Buffer.concat(chunks),
340
+ extension: 'mp3',
341
+ sampleRate: 8000
342
+ }
343
+ ));
365
344
  });
366
345
  } catch (err) {
367
346
  logger.info({err}, 'synthAudio: Error synthesizing speech using aws polly');
@@ -403,7 +382,11 @@ const synthGoogle = async(logger, {credentials, stats, language, voice, gender,
403
382
  };
404
383
 
405
384
  const wav = await post('/v1beta1/text:synthesize', payload);
406
- return Buffer.from(wav.audioContent, 'base64');
385
+ return {
386
+ audioContent: Buffer.from(wav.audioContent, 'base64'),
387
+ extension: 'wav',
388
+ sampleRate: 24000
389
+ };
407
390
  } catch (err) {
408
391
  logger.info({err: await err.text()}, 'synthGoogle returned error');
409
392
  throw err;
@@ -424,7 +407,11 @@ const synthGoogle = async(logger, {credentials, stats, language, voice, gender,
424
407
  const responses = await client.synthesizeSpeech(opts);
425
408
  stats.increment('tts.count', ['vendor:google', 'accepted:yes']);
426
409
  client.close();
427
- return responses[0].audioContent;
410
+ return {
411
+ audioContent: responses[0].audioContent,
412
+ extension: 'mp3',
413
+ sampleRate: 8000
414
+ };
428
415
  } catch (err) {
429
416
  console.error(err);
430
417
  logger.info({err, opts}, 'synthAudio: Error synthesizing speech using google');
@@ -455,7 +442,11 @@ const synthIbm = async(logger, {credentials, stats, voice, text}) => {
455
442
  for await (const chunk of r.result) {
456
443
  chunks.push(chunk);
457
444
  }
458
- return Buffer.concat(chunks);
445
+ return {
446
+ audioContent: Buffer.concat(chunks),
447
+ extension: 'mp3',
448
+ sampleRate: 8000
449
+ };
459
450
  } catch (err) {
460
451
  logger.info({err, params}, 'synthAudio: Error synthesizing speech using ibm');
461
452
  stats.increment('tts.count', ['vendor:ibm', 'accepted:no']);
@@ -465,11 +456,9 @@ const synthIbm = async(logger, {credentials, stats, voice, text}) => {
465
456
 
466
457
  async function _synthOnPremMicrosoft(logger, {
467
458
  credentials,
468
- stats,
469
459
  language,
470
460
  voice,
471
- text,
472
- filePath
461
+ text
473
462
  }) {
474
463
  const {use_custom_tts, custom_tts_endpoint_url, api_key} = credentials;
475
464
  let content = text;
@@ -491,15 +480,19 @@ async function _synthOnPremMicrosoft(logger, {
491
480
  }
492
481
 
493
482
  try {
494
- const trimSilence = filePath.endsWith('.r8');
483
+ const trimSilence = JAMBONES_TTS_TRIM_SILENCE;
495
484
  const post = bent('POST', 'buffer', {
496
485
  'X-Microsoft-OutputFormat': trimSilence ? 'raw-8khz-16bit-mono-pcm' : 'audio-16khz-32kbitrate-mono-mp3',
497
486
  'Content-Type': 'application/ssml+xml',
498
487
  'User-Agent': 'Jambonz',
499
488
  ...(api_key && {'Ocp-Apim-Subscription-Key': api_key})
500
489
  });
501
- const mp3 = await post(custom_tts_endpoint_url, content);
502
- return mp3;
490
+ const audioContent = await post(custom_tts_endpoint_url, content);
491
+ return {
492
+ audioContent,
493
+ extension: trimSilence ? 'r8' : 'mp3',
494
+ sampleRate: 8000
495
+ };
503
496
  } catch (err) {
504
497
  logger.info({err}, '_synthMicrosoftByHttp returned error');
505
498
  throw err;
@@ -512,7 +505,6 @@ const synthMicrosoft = async(logger, {
512
505
  language,
513
506
  voice,
514
507
  text,
515
- filePath,
516
508
  renderForCaching,
517
509
  disableTtsStreaming
518
510
  }) => {
@@ -555,17 +547,18 @@ const synthMicrosoft = async(logger, {
555
547
  rtt: 0
556
548
  };
557
549
  }
550
+ // Azure Onprem
558
551
  if (use_custom_tts && custom_tts_endpoint_url) {
559
552
  return await _synthOnPremMicrosoft(logger, {
560
553
  credentials,
561
554
  stats,
562
555
  language,
563
556
  voice,
564
- text,
565
- filePath
557
+ text
566
558
  });
567
559
  }
568
- const trimSilence = filePath.endsWith('.r8');
560
+ // Azure hosted service
561
+ const trimSilence = JAMBONES_TTS_TRIM_SILENCE;
569
562
  const speechConfig = SpeechConfig.fromSubscription(apiKey, region);
570
563
  speechConfig.speechSynthesisLanguage = language;
571
564
  speechConfig.speechSynthesisVoiceName = voice;
@@ -600,7 +593,11 @@ const synthMicrosoft = async(logger, {
600
593
  case ResultReason.SynthesizingAudioCompleted:
601
594
  let buffer = Buffer.from(result.audioData);
602
595
  if (trimSilence) buffer = trimTrailingSilence(buffer);
603
- resolve(buffer);
596
+ resolve({
597
+ audioContent: buffer,
598
+ extension: trimSilence ? 'r8' : 'mp3',
599
+ sampleRate: 8000
600
+ });
604
601
  synthesizer.close();
605
602
  stats.increment('tts.count', ['vendor:microsoft', 'accepted:yes']);
606
603
  break;
@@ -630,11 +627,15 @@ const synthWellSaid = async(logger, {credentials, stats, language, voice, gender
630
627
  'Accept': 'audio/mpeg',
631
628
  'Content-Type': 'application/json'
632
629
  });
633
- const mp3 = await post('/v1/tts/stream', {
630
+ const audioContent = await post('/v1/tts/stream', {
634
631
  text,
635
632
  speaker_id: voice
636
633
  });
637
- return mp3;
634
+ return {
635
+ audioContent,
636
+ extension: 'mp3',
637
+ sampleRate: 8000
638
+ };
638
639
  } catch (err) {
639
640
  logger.info({err}, 'testWellSaidTts returned error');
640
641
  throw err;
@@ -671,8 +672,8 @@ const synthNuance = async(client, logger, {credentials, stats, voice, model, tex
671
672
  t.setText(text);
672
673
  input.setText(t);
673
674
  }
674
-
675
- pcm.setSampleRateHz(8000);
675
+ const sampleRate = 8000;
676
+ pcm.setSampleRateHz(sampleRate);
676
677
  f.setPcm(pcm);
677
678
  p.setAudioFormat(f);
678
679
  v.setName(voice);
@@ -696,7 +697,11 @@ const synthNuance = async(client, logger, {credentials, stats, voice, model, tex
696
697
  const details = status.getDetails();
697
698
  return reject({code, message, details});
698
699
  }
699
- resolve(Buffer.from(response.getAudio()));
700
+ resolve({
701
+ audioContent: Buffer.from(response.getAudio()),
702
+ extension: 'r8',
703
+ sampleRate
704
+ });
700
705
  });
701
706
  });
702
707
  };
@@ -704,12 +709,13 @@ const synthNuance = async(client, logger, {credentials, stats, voice, model, tex
704
709
  const synthNvidia = async(client, logger, {credentials, stats, language, voice, model, text}) => {
705
710
  const {riva_server_uri} = credentials;
706
711
  let rivaClient, request;
712
+ const sampleRate = 8000;
707
713
  try {
708
714
  rivaClient = await createRivaClient(riva_server_uri);
709
715
  request = new SynthesizeSpeechRequest();
710
716
  request.setVoiceName(voice);
711
717
  request.setLanguageCode(language);
712
- request.setSampleRateHz(8000);
718
+ request.setSampleRateHz(sampleRate);
713
719
  request.setEncoding(AudioEncoding.LINEAR_PCM);
714
720
  request.setText(text);
715
721
  } catch (err) {
@@ -723,7 +729,11 @@ const synthNvidia = async(client, logger, {credentials, stats, language, voice,
723
729
  logger.info({err, voice, language}, 'error synthesizing speech using Nvidia');
724
730
  return reject(err);
725
731
  }
726
- resolve(Buffer.from(response.getAudio()));
732
+ resolve({
733
+ audioContent: Buffer.from(response.getAudio()),
734
+ extension: 'r8',
735
+ sampleRate
736
+ });
727
737
  });
728
738
  });
729
739
  };
@@ -745,14 +755,13 @@ const synthCustomVendor = async(logger, {credentials, stats, language, voice, te
745
755
  text
746
756
  });
747
757
 
748
- const regex = /\.[^\.]*$/g;
749
758
  const mime = response.headers['content-type'];
750
759
  const buffer = await response.arrayBuffer();
751
- const fileExtension = getFileExtFromMime(mime);
760
+ const [extension, sampleRate] = getFileExtFromMime(mime);
752
761
  return {
753
- audioBuffer: buffer,
754
- filePath: filePath.replace(regex, fileExtension),
755
- fileExtension
762
+ audioContent: buffer,
763
+ extension,
764
+ sampleRate
756
765
  };
757
766
  } catch (err) {
758
767
  logger.info({err}, `Vendor ${vendor} returned error`);
@@ -798,7 +807,7 @@ const synthElevenlabs = async(logger, {
798
807
  'Accept': 'audio/mpeg',
799
808
  'Content-Type': 'application/json'
800
809
  });
801
- const mp3 = await post(`/v1/text-to-speech/${voice}${optimize_streaming_latency}`, {
810
+ const audioContent = await post(`/v1/text-to-speech/${voice}${optimize_streaming_latency}`, {
802
811
  text,
803
812
  model_id,
804
813
  voice_settings: {
@@ -807,7 +816,11 @@ const synthElevenlabs = async(logger, {
807
816
  },
808
817
  ...opts
809
818
  });
810
- return mp3;
819
+ return {
820
+ audioContent,
821
+ extension: 'mp3',
822
+ sampleRate: 8000
823
+ };
811
824
  } catch (err) {
812
825
  logger.info({err}, 'synth Elevenlabs returned error');
813
826
  stats.increment('tts.count', ['vendor:elevenlabs', 'accepted:no']);
@@ -889,7 +902,7 @@ const synthPlayHT = async(client, logger, {
889
902
  'Content-Type': 'application/json'
890
903
  });
891
904
 
892
- const mp3 = await post(synthesizeUrl, {
905
+ const audioContent = await post(synthesizeUrl, {
893
906
  text,
894
907
  ...(voice_engine === 'Play3.0' && { language }),
895
908
  voice,
@@ -898,7 +911,11 @@ const synthPlayHT = async(client, logger, {
898
911
  sample_rate: 8000,
899
912
  ...opts
900
913
  });
901
- return mp3;
914
+ return {
915
+ audioContent,
916
+ extension: 'mp3',
917
+ sampleRate: 8000
918
+ };
902
919
  } catch (err) {
903
920
  logger.info({err}, 'synth PlayHT returned error');
904
921
  stats.increment('tts.count', ['vendor:playht', 'accepted:no']);
@@ -937,14 +954,19 @@ const synthRimelabs = async(logger, {
937
954
  'Accept': 'audio/mp3',
938
955
  'Content-Type': 'application/json'
939
956
  });
940
- const mp3 = await post('/v1/rime-tts', {
957
+ const sampleRate = 8000;
958
+ const audioContent = await post('/v1/rime-tts', {
941
959
  speaker: voice,
942
960
  text,
943
961
  modelId: model_id,
944
- samplingRate: 8000,
962
+ samplingRate: sampleRate,
945
963
  ...opts
946
964
  });
947
- return mp3;
965
+ return {
966
+ audioContent,
967
+ extension: 'mp3',
968
+ sampleRate
969
+ };
948
970
  } catch (err) {
949
971
  logger.info({err}, 'synth rimelabs returned error');
950
972
  stats.increment('tts.count', ['vendor:rimelabs', 'accepted:no']);
@@ -978,13 +1000,17 @@ const synthVerbio = async(client, logger, {credentials, stats, voice, text, rend
978
1000
  'User-Agent': 'jambonz',
979
1001
  'Content-Type': 'application/json'
980
1002
  });
981
- const r8 = await post('/api/v1/synthesize', {
1003
+ const audioContent = await post('/api/v1/synthesize', {
982
1004
  voice_id: voice,
983
1005
  output_sample_rate: '8k',
984
1006
  output_encoding: 'pcm16',
985
1007
  text
986
1008
  });
987
- return r8;
1009
+ return {
1010
+ audioContent,
1011
+ extension: 'r8',
1012
+ sampleRate: 8000
1013
+ };
988
1014
  } catch (err) {
989
1015
  logger.info({err}, 'synth Verbio returned error');
990
1016
  stats.increment('tts.count', ['vendor:verbio', 'accepted:no']);
@@ -1024,7 +1050,11 @@ const synthWhisper = async(logger, {credentials, stats, voice, text, renderForCa
1024
1050
  input: text,
1025
1051
  response_format: 'mp3'
1026
1052
  });
1027
- return Buffer.from(await mp3.arrayBuffer());
1053
+ return {
1054
+ audioContent: Buffer.from(await mp3.arrayBuffer()),
1055
+ extension: 'mp3',
1056
+ sampleRate: 8000
1057
+ };
1028
1058
  } catch (err) {
1029
1059
  logger.info({err}, 'synth whisper returned error');
1030
1060
  stats.increment('tts.count', ['vendor:openai', 'accepted:no']);
@@ -1056,10 +1086,14 @@ const synthDeepgram = async(logger, {credentials, stats, model, text, renderForC
1056
1086
  'Accept': 'audio/mpeg',
1057
1087
  'Content-Type': 'application/json'
1058
1088
  });
1059
- const mp3 = await post(`/v1/speak?model=${model}`, {
1089
+ const audioContent = await post(`/v1/speak?model=${model}`, {
1060
1090
  text
1061
1091
  });
1062
- return mp3;
1092
+ return {
1093
+ audioContent,
1094
+ extension: 'mp3',
1095
+ sampleRate: 8000
1096
+ };
1063
1097
  } catch (err) {
1064
1098
  logger.info({err}, 'synth Deepgram returned error');
1065
1099
  stats.increment('tts.count', ['vendor:deepgram', 'accepted:no']);
@@ -1067,26 +1101,96 @@ const synthDeepgram = async(logger, {credentials, stats, model, text, renderForC
1067
1101
  }
1068
1102
  };
1069
1103
 
1104
+ const synthCartesia = async(logger, {
1105
+ credentials, options, stats, voice, language, text, renderForCaching, disableTtsStreaming
1106
+ }) => {
1107
+ const {api_key, model_id, embedding, options: credOpts} = credentials;
1108
+ const opts = !!options && Object.keys(options).length !== 0 ? options : JSON.parse(credOpts || '{}');
1109
+
1110
+ if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
1111
+ let params = '';
1112
+ params += `{api_key=${api_key}`;
1113
+ params += `,model_id=${model_id}`;
1114
+ params += ',vendor=cartesia';
1115
+ params += `,voice=${voice}`;
1116
+ params += ',write_cache_file=1';
1117
+ params += `,language=${language}`;
1118
+ params += `,voice_mode=${embedding ? 'embedding' : 'id'}`;
1119
+ if (embedding) params += `,embedding=${embedding}`;
1120
+ if (opts.speed) params += `,speed=${opts.speed}`;
1121
+ if (opts.emotion) params += `,emotion=${opts.emotion}`;
1122
+ params += '}';
1123
+
1124
+ return {
1125
+ filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
1126
+ servedFromCache: false,
1127
+ rtt: 0
1128
+ };
1129
+ }
1130
+
1131
+ try {
1132
+ const client = new CartesiaClient({ apiKey: api_key });
1133
+ const sampleRate = 48000;
1134
+ const mp3 = await client.tts.bytes({
1135
+ modelId: model_id,
1136
+ transcript: text,
1137
+ voice: {
1138
+ mode: embedding ? 'embedding' : 'id',
1139
+ ...(embedding ?
1140
+ {
1141
+ embedding: embedding.split(',').map(Number)
1142
+ } :
1143
+ {
1144
+ id: voice
1145
+ }
1146
+ ),
1147
+ ...(opts.speed || opts.emotion && {
1148
+ experimentalControls: {
1149
+ ...(opts.speed !== null && opts.speed !== undefined && {speed: opts.speed}),
1150
+ ...(opts.emotion && {emotion: opts.emotion}),
1151
+ }
1152
+ })
1153
+ },
1154
+ language: language,
1155
+ outputFormat: {
1156
+ container: 'mp3',
1157
+ bitRate: 128000,
1158
+ sampleRate
1159
+ },
1160
+ });
1161
+ return {
1162
+ audioContent: Buffer.from(mp3),
1163
+ extension: 'mp3',
1164
+ sampleRate
1165
+ };
1166
+ } catch (err) {
1167
+ logger.info({err}, 'synth Cartesia returned error');
1168
+ stats.increment('tts.count', ['vendor:cartesia', 'accepted:no']);
1169
+ throw err;
1170
+ }
1171
+
1172
+ };
1173
+
1070
1174
  const getFileExtFromMime = (mime) => {
1071
1175
  switch (mime) {
1072
1176
  case 'audio/wav':
1073
1177
  case 'audio/x-wav':
1074
- return '.wav';
1178
+ return ['wav', 8000];
1075
1179
  case /audio\/l16.*rate=8000/.test(mime) ? mime : 'cant match value':
1076
- return '.r8';
1180
+ return ['r8', 8000];
1077
1181
  case /audio\/l16.*rate=16000/.test(mime) ? mime : 'cant match value':
1078
- return '.r16';
1182
+ return ['r16', 16000];
1079
1183
  case /audio\/l16.*rate=24000/.test(mime) ? mime : 'cant match value':
1080
- return '.r24';
1184
+ return ['r24', 24000];
1081
1185
  case /audio\/l16.*rate=32000/.test(mime) ? mime : 'cant match value':
1082
- return '.r32';
1186
+ return ['r32', 32000];
1083
1187
  case /audio\/l16.*rate=48000/.test(mime) ? mime : 'cant match value':
1084
- return '.r48';
1188
+ return ['r48', 48000];
1085
1189
  case 'audio/mpeg':
1086
1190
  case 'audio/mp3':
1087
- return '.mp3';
1191
+ return ['mp3', 8000];
1088
1192
  default:
1089
- return '.wav';
1193
+ return ['wav', 8000];
1090
1194
  }
1091
1195
  };
1092
1196
 
package/lib/utils.js CHANGED
@@ -6,7 +6,7 @@ const pool = new Pool('https://auth.crt.nuance.com');
6
6
  const NUANCE_AUTH_ENDPOINT = 'tts.api.nuance.com:443';
7
7
  const grpc = require('@grpc/grpc-js');
8
8
  const formurlencoded = require('form-urlencoded');
9
- const { JAMBONES_DISABLE_TTS_STREAMING, JAMBONES_TTS_TRIM_SILENCE, TMP_FOLDER, HTTP_TIMEOUT } = require('./config');
9
+ const { TMP_FOLDER, HTTP_TIMEOUT } = require('./config');
10
10
 
11
11
  const debug = require('debug')('jambonz:realtimedb-helpers');
12
12
  /**
@@ -17,67 +17,19 @@ const debug = require('debug')('jambonz:realtimedb-helpers');
17
17
  //const nuanceClientMap = new Map();
18
18
 
19
19
  function makeSynthKey({
20
- account_sid = '', vendor, language, voice, engine = '', text,
21
- renderForCaching = false}) {
20
+ account_sid = '', vendor, language, voice, engine = '', text}) {
22
21
  const hash = crypto.createHash('sha1');
23
22
  hash.update(`${language}:${vendor}:${voice}:${engine}:${text}`);
24
23
  const hexHashKey = hash.digest('hex');
25
24
  const accountKey = account_sid ? `:${account_sid}` : '';
26
- const namespace = vendor.startsWith('custom') ? vendor : getFileExtension({vendor, voice, renderForCaching});
27
- const key = `tts${accountKey}:${namespace}:${hexHashKey}`;
25
+ const key = `tts${accountKey}:${hexHashKey}`;
28
26
  return key;
29
27
  }
30
28
 
31
- function makeFilePath({vendor, voice, key, salt = '', renderForCaching = false}) {
32
- const extension = getFileExtension({vendor, renderForCaching, voice});
29
+ function makeFilePath({key, salt = '', extension}) {
33
30
  return `${TMP_FOLDER}/${key.replace('tts:', `tts-${salt}`)}.${extension}`;
34
31
  }
35
32
 
36
- function getFileExtension({vendor, voice, renderForCaching = false}) {
37
- const mp3Extension = 'mp3';
38
- const r8Extension = 'r8';
39
- const wavExtension = 'wav';
40
-
41
- switch (vendor) {
42
- case 'azure':
43
- case 'microsoft':
44
- if (!renderForCaching && !JAMBONES_DISABLE_TTS_STREAMING || JAMBONES_TTS_TRIM_SILENCE) {
45
- return r8Extension;
46
- } else {
47
- return mp3Extension;
48
- }
49
- case 'deepgram':
50
- case 'elevenlabs':
51
- case 'rimelabs':
52
- case 'playht':
53
- if (renderForCaching || JAMBONES_DISABLE_TTS_STREAMING) {
54
- return mp3Extension;
55
- } else {
56
- return r8Extension;
57
- }
58
- case 'nuance':
59
- case 'nvidia':
60
- case 'verbio':
61
- return r8Extension;
62
- case 'google':
63
- // google voice cloning just support wav.
64
- if (typeof voice === 'object' && voice.voice_cloning_key) {
65
- return wavExtension;
66
- } else {
67
- return mp3Extension;
68
- }
69
- default:
70
- // If vendor is custom
71
- if (vendor.startsWith('custom')) {
72
- if (renderForCaching || JAMBONES_DISABLE_TTS_STREAMING) {
73
- return mp3Extension;
74
- } else {
75
- return r8Extension;
76
- }
77
- }
78
- return mp3Extension;
79
- }
80
- }
81
33
 
82
34
  const noopLogger = {
83
35
  info: () => {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jambonz/speech-utils",
3
- "version": "0.1.22",
3
+ "version": "0.2.1",
4
4
  "description": "TTS-related speech utilities for jambonz",
5
5
  "main": "index.js",
6
6
  "author": "Dave Horton",
@@ -28,6 +28,7 @@
28
28
  "dependencies": {
29
29
  "@aws-sdk/client-polly": "^3.496.0",
30
30
  "@aws-sdk/client-sts": "^3.496.0",
31
+ "@cartesia/cartesia-js": "^2.1.0",
31
32
  "@google-cloud/text-to-speech": "^5.5.0",
32
33
  "@grpc/grpc-js": "^1.9.14",
33
34
  "@jambonz/realtimedb-helpers": "^0.8.7",
package/test/synth.js CHANGED
@@ -686,6 +686,40 @@ test('PlayHT3.0 speech synth tests', async(t) => {
686
686
  await testPlayHT(t, 'Play3.0');
687
687
  });
688
688
 
689
+ test('Cartesia speech synth tests', async(t) => {
690
+ const fn = require('..');
691
+ const {synthAudio, client} = fn(opts, logger);
692
+
693
+ if (!process.env.CARTESIA_API_KEY) {
694
+ t.pass('skipping Cartesia speech synth tests since CARTESIA_API_KEY is not provided');
695
+ return t.end();
696
+ }
697
+ const text = 'Hi there and welcome to jambones! ' + Date.now();
698
+ try {
699
+ const opts = await synthAudio(stats, {
700
+ vendor: 'cartesia',
701
+ credentials: {
702
+ api_key: process.env.CARTESIA_API_KEY,
703
+ model_id: 'sonic-english',
704
+ options: JSON.stringify({
705
+ speed: 1,
706
+ emotion: 'female_happy',
707
+ })
708
+ },
709
+ language: 'en',
710
+ voice: '694f9389-aac1-45b6-b726-9d9369183238',
711
+ text,
712
+ renderForCaching: true
713
+ });
714
+ t.ok(!opts.servedFromCache, `successfully playht eleven audio to ${opts.filePath}`);
715
+
716
+ } catch (err) {
717
+ console.error(JSON.stringify(err));
718
+ t.end(err);
719
+ }
720
+ client.quit();
721
+ });
722
+
689
723
  test('rimelabs speech synth tests', async(t) => {
690
724
  const fn = require('..');
691
725
  const {synthAudio, client} = fn(opts, logger);
@@ -854,24 +888,15 @@ test('TTS Cache tests', async(t) => {
854
888
  language: 'non-existing',
855
889
  voice: 'non-existing',
856
890
  });
857
- t.ok(purgedCountWhenErrored === 0, `purged no records when specified key was not found`);
858
- t.ok(error, `error returned when specified key was not found`);
891
+ t.ok(purgedCountWhenErrored === 0, 'purged no records when specified key was not found');
892
+ t.ok(error, 'error returned when specified key was not found');
859
893
 
860
894
  // make sure other tts keys are still there
861
- const cached = await client.keys('tts:*')
862
- t.ok(cached.length >= 1, `successfully kept all non-specified tts records in cache`);
895
+ const cached = await client.keys('tts:*');
896
+ t.ok(cached.length >= 1, 'successfully kept all non-specified tts records in cache');
863
897
 
864
- // retrieve keys from cache and check the key contains the file extension
865
- let key = cached[0];
866
- t.ok(key.includes('mp3'), `tts cache extension shoult be part of the key and equal mp3`);
867
-
868
- process.env.VG_TRIM_TTS_SILENCE = 'true';
898
+ process.env.VG_TRIM_TTS_SILENCE = 'true';
869
899
  await client.set(makeSynthKey({ vendor: 'azure' }), 'value');
870
-
871
- const r8Keys = await client.keys('tts:r8*');
872
- key = r8Keys[0];
873
- t.ok(key.includes('r8'), `tts cache extension shoult be part of the key and equal r8`);
874
-
875
900
  } catch (err) {
876
901
  console.error(JSON.stringify(err));
877
902
  t.end(err);