@jambonz/speech-utils 0.1.24 → 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;
@@ -46,7 +46,7 @@ const {
46
46
  JAMBONES_HTTP_PROXY_IP,
47
47
  JAMBONES_HTTP_PROXY_PORT,
48
48
  JAMBONES_TTS_CACHE_DURATION_MINS,
49
- JAMBONES_EAGERLY_PRE_CACHE_AUDIO,
49
+ JAMBONES_TTS_TRIM_SILENCE,
50
50
  } = require('./config');
51
51
  const EXPIRES = JAMBONES_TTS_CACHE_DURATION_MINS;
52
52
  const OpenAI = require('openai');
@@ -91,7 +91,7 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
91
91
  vendor, language, voice, gender, text, engine, salt, model, credentials, deploymentId,
92
92
  disableTtsCache, renderForCaching = false, disableTtsStreaming, options
93
93
  }) {
94
- let audioBuffer;
94
+ let audioData;
95
95
  let servedFromCache = false;
96
96
  let rtt;
97
97
  logger = logger || noopLogger;
@@ -169,57 +169,24 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
169
169
  language: language || '',
170
170
  voice: voice || deploymentId,
171
171
  engine,
172
- text,
173
- renderForCaching
172
+ text
174
173
  });
175
- let filePath;
176
- // used only for custom vendor
177
- let fileExtension;
178
- filePath = makeFilePath({vendor, voice, key, salt, renderForCaching});
174
+
179
175
  debug(`synth key is ${key}`);
180
176
  let cached;
181
177
  if (!disableTtsCache) {
182
178
  cached = await client.get(key);
183
- /**
184
- * If we are using tts streaming and also precaching audio, audio could have been cached by streaming (r8)
185
- * or here in speech-utils due to precaching (mp3), so we need to check for both keys.
186
- */
187
- if (!cached && JAMBONES_EAGERLY_PRE_CACHE_AUDIO) {
188
- const preCachekey = makeSynthKey({
189
- account_sid,
190
- vendor,
191
- language: language || '',
192
- voice: voice || deploymentId,
193
- engine,
194
- text,
195
- renderForCaching: true
196
- });
197
- cached = await client.get(preCachekey);
198
- if (cached) {
199
- // Precache audio is available update filpath with precache file extension.
200
- filePath = makeFilePath({vendor, voice, key, salt, renderForCaching: true});
201
- }
202
- }
203
179
  }
204
180
  if (cached) {
205
181
  // found in cache - extend the expiry and use it
206
182
  debug('result WAS found in cache');
207
183
  servedFromCache = true;
208
184
  stats.increment('tts.cache.requests', ['found:yes']);
209
- if (vendor.startsWith('custom')) {
210
- // custom vendors support multiple mime types such as: mp3, wav, r8, r16 ...etc,
211
- // mime type/file extension is available when http response has header Content-type.
212
- // In cache, file extension is store together with audiBuffer in a json.
213
- // Normal cache audio will be base64 string
214
- const payload = JSON.parse(cached);
215
- filePath = filePath.replace(/\.[^\.]*$/g, payload.fileExtension);
216
- audioBuffer = Buffer.from(payload.audioBuffer, 'base64');
217
- } else {
218
- audioBuffer = Buffer.from(cached, 'base64');
219
- }
185
+ audioData = JSON.parse(cached);
186
+ // convert base64 audio to buffer
187
+ audioData.audioContent = Buffer.from(audioData.audioContent, 'base64');
220
188
  client.expire(key, EXPIRES).catch((err) => logger.info(err, 'Error setting expires'));
221
- }
222
- if (!cached) {
189
+ } else {
223
190
  // not found in cache - go get it from speech vendor and add to cache
224
191
  debug('result was NOT found in cache');
225
192
  stats.increment('tts.cache.requests', ['found:no']);
@@ -227,94 +194,92 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
227
194
  const startAt = process.hrtime();
228
195
  switch (vendor) {
229
196
  case 'google':
230
- audioBuffer = await synthGoogle(logger, {credentials, stats, language, voice, gender, text});
197
+ audioData = await synthGoogle(logger, {credentials, stats, language, voice, gender, text});
231
198
  break;
232
199
  case 'aws':
233
200
  case 'polly':
234
201
  vendorLabel = 'aws';
235
- audioBuffer = await synthPolly(createHash, retrieveHash, logger,
202
+ audioData = await synthPolly(createHash, retrieveHash, logger,
236
203
  {credentials, stats, language, voice, text, engine});
237
204
  break;
238
205
  case 'azure':
239
206
  case 'microsoft':
240
207
  vendorLabel = 'microsoft';
241
- audioBuffer = await synthMicrosoft(logger, {credentials, stats, language, voice, text, deploymentId,
242
- filePath, renderForCaching, disableTtsStreaming});
208
+ audioData = await synthMicrosoft(logger, {credentials, stats, language, voice, text, deploymentId,
209
+ renderForCaching, disableTtsStreaming});
243
210
  break;
244
211
  case 'nuance':
245
212
  model = model || 'enhanced';
246
- audioBuffer = await synthNuance(client, logger, {credentials, stats, voice, model, text});
213
+ audioData = await synthNuance(client, logger, {credentials, stats, voice, model, text});
247
214
  break;
248
215
  case 'nvidia':
249
- audioBuffer = await synthNvidia(client, logger, {credentials, stats, language, voice, model, text});
216
+ audioData = await synthNvidia(client, logger, {credentials, stats, language, voice, model, text});
250
217
  break;
251
218
  case 'ibm':
252
- audioBuffer = await synthIbm(logger, {credentials, stats, voice, text});
219
+ audioData = await synthIbm(logger, {credentials, stats, voice, text});
253
220
  break;
254
221
  case 'wellsaid':
255
- audioBuffer = await synthWellSaid(logger, {credentials, stats, language, voice, text, filePath});
222
+ audioData = await synthWellSaid(logger, {credentials, stats, language, voice, text});
256
223
  break;
257
224
  case 'elevenlabs':
258
- audioBuffer = await synthElevenlabs(logger, {
259
- credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming, filePath
260
- });
225
+ audioData = await synthElevenlabs(logger, {
226
+ credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
261
227
  break;
262
228
  case 'playht':
263
- audioBuffer = await synthPlayHT(client, logger, {
264
- credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming, filePath
265
- });
229
+ audioData = await synthPlayHT(client, logger, {
230
+ credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
266
231
  break;
267
232
  case 'cartesia':
268
- audioBuffer = await synthCartesia(logger, {
269
- credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming, filePath
270
- });
233
+ audioData = await synthCartesia(logger, {
234
+ credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
271
235
  break;
272
236
  case 'rimelabs':
273
- audioBuffer = await synthRimelabs(logger, {
274
- credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming, filePath
275
- });
237
+ audioData = await synthRimelabs(logger, {
238
+ credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
276
239
  break;
277
240
  case 'whisper':
278
- audioBuffer = await synthWhisper(logger, {
241
+ audioData = await synthWhisper(logger, {
279
242
  credentials, stats, voice, text, renderForCaching, disableTtsStreaming});
280
243
  break;
281
244
  case 'verbio':
282
- audioBuffer = await synthVerbio(client, logger, {
245
+ audioData = await synthVerbio(client, logger, {
283
246
  credentials, stats, voice, text, renderForCaching, disableTtsStreaming});
284
- if (audioBuffer?.filePath) return audioBuffer;
247
+ if (audioData?.filePath) return audioData;
285
248
  break;
286
249
  case 'deepgram':
287
- audioBuffer = await synthDeepgram(logger, {credentials, stats, model, text,
250
+ audioData = await synthDeepgram(logger, {credentials, stats, model, text,
288
251
  renderForCaching, disableTtsStreaming});
289
252
  break;
290
253
  case vendor.startsWith('custom') ? vendor : 'cant_match_value':
291
- ({ audioBuffer, filePath, fileExtension } = await synthCustomVendor(logger,
292
- {credentials, stats, language, voice, text, filePath}));
254
+ audioData = await synthCustomVendor(logger,
255
+ {credentials, stats, language, voice, text});
293
256
  break;
294
257
  default:
295
258
  assert(`synthAudio: unsupported speech vendor ${vendor}`);
296
259
  }
297
- if ('filePath' in audioBuffer) return audioBuffer;
260
+ if ('filePath' in audioData) return audioData;
298
261
  const diff = process.hrtime(startAt);
299
262
  const time = diff[0] * 1e3 + diff[1] * 1e-6;
300
263
  rtt = time.toFixed(0);
301
264
  stats.histogram('tts.response_time', rtt, [`vendor:${vendorLabel}`]);
302
265
  debug(`tts rtt time for ${text.length} chars on ${vendorLabel}: ${rtt}`);
303
266
  logger.info(`tts rtt time for ${text.length} chars on ${vendorLabel}: ${rtt}`);
304
-
305
- const base64Audio = audioBuffer.toString('base64');
306
- const cacheContent = vendor.startsWith('custom') ?
307
- JSON.stringify({
308
- audioBuffer: base64Audio,
309
- fileExtension
310
- }) : base64Audio;
311
-
312
- 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
+ }))
313
272
  .catch((err) => logger.error(err, `error calling setex on key ${key}`));
314
273
  }
315
274
 
316
275
  return new Promise((resolve, reject) => {
317
- 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) => {
318
283
  if (err) return reject(err);
319
284
  resolve({filePath, servedFromCache, rtt});
320
285
  });
@@ -369,7 +334,13 @@ const synthPolly = async(createHash, retrieveHash, logger,
369
334
  .on('data', (chunk) => {
370
335
  chunks.push(chunk);
371
336
  })
372
- .on('end', () => resolve(Buffer.concat(chunks)));
337
+ .on('end', () => resolve(
338
+ {
339
+ audioContent: Buffer.concat(chunks),
340
+ extension: 'mp3',
341
+ sampleRate: 8000
342
+ }
343
+ ));
373
344
  });
374
345
  } catch (err) {
375
346
  logger.info({err}, 'synthAudio: Error synthesizing speech using aws polly');
@@ -411,7 +382,11 @@ const synthGoogle = async(logger, {credentials, stats, language, voice, gender,
411
382
  };
412
383
 
413
384
  const wav = await post('/v1beta1/text:synthesize', payload);
414
- return Buffer.from(wav.audioContent, 'base64');
385
+ return {
386
+ audioContent: Buffer.from(wav.audioContent, 'base64'),
387
+ extension: 'wav',
388
+ sampleRate: 24000
389
+ };
415
390
  } catch (err) {
416
391
  logger.info({err: await err.text()}, 'synthGoogle returned error');
417
392
  throw err;
@@ -432,7 +407,11 @@ const synthGoogle = async(logger, {credentials, stats, language, voice, gender,
432
407
  const responses = await client.synthesizeSpeech(opts);
433
408
  stats.increment('tts.count', ['vendor:google', 'accepted:yes']);
434
409
  client.close();
435
- return responses[0].audioContent;
410
+ return {
411
+ audioContent: responses[0].audioContent,
412
+ extension: 'mp3',
413
+ sampleRate: 8000
414
+ };
436
415
  } catch (err) {
437
416
  console.error(err);
438
417
  logger.info({err, opts}, 'synthAudio: Error synthesizing speech using google');
@@ -463,7 +442,11 @@ const synthIbm = async(logger, {credentials, stats, voice, text}) => {
463
442
  for await (const chunk of r.result) {
464
443
  chunks.push(chunk);
465
444
  }
466
- return Buffer.concat(chunks);
445
+ return {
446
+ audioContent: Buffer.concat(chunks),
447
+ extension: 'mp3',
448
+ sampleRate: 8000
449
+ };
467
450
  } catch (err) {
468
451
  logger.info({err, params}, 'synthAudio: Error synthesizing speech using ibm');
469
452
  stats.increment('tts.count', ['vendor:ibm', 'accepted:no']);
@@ -473,11 +456,9 @@ const synthIbm = async(logger, {credentials, stats, voice, text}) => {
473
456
 
474
457
  async function _synthOnPremMicrosoft(logger, {
475
458
  credentials,
476
- stats,
477
459
  language,
478
460
  voice,
479
- text,
480
- filePath
461
+ text
481
462
  }) {
482
463
  const {use_custom_tts, custom_tts_endpoint_url, api_key} = credentials;
483
464
  let content = text;
@@ -499,15 +480,19 @@ async function _synthOnPremMicrosoft(logger, {
499
480
  }
500
481
 
501
482
  try {
502
- const trimSilence = filePath.endsWith('.r8');
483
+ const trimSilence = JAMBONES_TTS_TRIM_SILENCE;
503
484
  const post = bent('POST', 'buffer', {
504
485
  'X-Microsoft-OutputFormat': trimSilence ? 'raw-8khz-16bit-mono-pcm' : 'audio-16khz-32kbitrate-mono-mp3',
505
486
  'Content-Type': 'application/ssml+xml',
506
487
  'User-Agent': 'Jambonz',
507
488
  ...(api_key && {'Ocp-Apim-Subscription-Key': api_key})
508
489
  });
509
- const mp3 = await post(custom_tts_endpoint_url, content);
510
- 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
+ };
511
496
  } catch (err) {
512
497
  logger.info({err}, '_synthMicrosoftByHttp returned error');
513
498
  throw err;
@@ -520,7 +505,6 @@ const synthMicrosoft = async(logger, {
520
505
  language,
521
506
  voice,
522
507
  text,
523
- filePath,
524
508
  renderForCaching,
525
509
  disableTtsStreaming
526
510
  }) => {
@@ -563,17 +547,18 @@ const synthMicrosoft = async(logger, {
563
547
  rtt: 0
564
548
  };
565
549
  }
550
+ // Azure Onprem
566
551
  if (use_custom_tts && custom_tts_endpoint_url) {
567
552
  return await _synthOnPremMicrosoft(logger, {
568
553
  credentials,
569
554
  stats,
570
555
  language,
571
556
  voice,
572
- text,
573
- filePath
557
+ text
574
558
  });
575
559
  }
576
- const trimSilence = filePath.endsWith('.r8');
560
+ // Azure hosted service
561
+ const trimSilence = JAMBONES_TTS_TRIM_SILENCE;
577
562
  const speechConfig = SpeechConfig.fromSubscription(apiKey, region);
578
563
  speechConfig.speechSynthesisLanguage = language;
579
564
  speechConfig.speechSynthesisVoiceName = voice;
@@ -608,7 +593,11 @@ const synthMicrosoft = async(logger, {
608
593
  case ResultReason.SynthesizingAudioCompleted:
609
594
  let buffer = Buffer.from(result.audioData);
610
595
  if (trimSilence) buffer = trimTrailingSilence(buffer);
611
- resolve(buffer);
596
+ resolve({
597
+ audioContent: buffer,
598
+ extension: trimSilence ? 'r8' : 'mp3',
599
+ sampleRate: 8000
600
+ });
612
601
  synthesizer.close();
613
602
  stats.increment('tts.count', ['vendor:microsoft', 'accepted:yes']);
614
603
  break;
@@ -638,11 +627,15 @@ const synthWellSaid = async(logger, {credentials, stats, language, voice, gender
638
627
  'Accept': 'audio/mpeg',
639
628
  'Content-Type': 'application/json'
640
629
  });
641
- const mp3 = await post('/v1/tts/stream', {
630
+ const audioContent = await post('/v1/tts/stream', {
642
631
  text,
643
632
  speaker_id: voice
644
633
  });
645
- return mp3;
634
+ return {
635
+ audioContent,
636
+ extension: 'mp3',
637
+ sampleRate: 8000
638
+ };
646
639
  } catch (err) {
647
640
  logger.info({err}, 'testWellSaidTts returned error');
648
641
  throw err;
@@ -679,8 +672,8 @@ const synthNuance = async(client, logger, {credentials, stats, voice, model, tex
679
672
  t.setText(text);
680
673
  input.setText(t);
681
674
  }
682
-
683
- pcm.setSampleRateHz(8000);
675
+ const sampleRate = 8000;
676
+ pcm.setSampleRateHz(sampleRate);
684
677
  f.setPcm(pcm);
685
678
  p.setAudioFormat(f);
686
679
  v.setName(voice);
@@ -704,7 +697,11 @@ const synthNuance = async(client, logger, {credentials, stats, voice, model, tex
704
697
  const details = status.getDetails();
705
698
  return reject({code, message, details});
706
699
  }
707
- resolve(Buffer.from(response.getAudio()));
700
+ resolve({
701
+ audioContent: Buffer.from(response.getAudio()),
702
+ extension: 'r8',
703
+ sampleRate
704
+ });
708
705
  });
709
706
  });
710
707
  };
@@ -712,12 +709,13 @@ const synthNuance = async(client, logger, {credentials, stats, voice, model, tex
712
709
  const synthNvidia = async(client, logger, {credentials, stats, language, voice, model, text}) => {
713
710
  const {riva_server_uri} = credentials;
714
711
  let rivaClient, request;
712
+ const sampleRate = 8000;
715
713
  try {
716
714
  rivaClient = await createRivaClient(riva_server_uri);
717
715
  request = new SynthesizeSpeechRequest();
718
716
  request.setVoiceName(voice);
719
717
  request.setLanguageCode(language);
720
- request.setSampleRateHz(8000);
718
+ request.setSampleRateHz(sampleRate);
721
719
  request.setEncoding(AudioEncoding.LINEAR_PCM);
722
720
  request.setText(text);
723
721
  } catch (err) {
@@ -731,7 +729,11 @@ const synthNvidia = async(client, logger, {credentials, stats, language, voice,
731
729
  logger.info({err, voice, language}, 'error synthesizing speech using Nvidia');
732
730
  return reject(err);
733
731
  }
734
- resolve(Buffer.from(response.getAudio()));
732
+ resolve({
733
+ audioContent: Buffer.from(response.getAudio()),
734
+ extension: 'r8',
735
+ sampleRate
736
+ });
735
737
  });
736
738
  });
737
739
  };
@@ -753,14 +755,13 @@ const synthCustomVendor = async(logger, {credentials, stats, language, voice, te
753
755
  text
754
756
  });
755
757
 
756
- const regex = /\.[^\.]*$/g;
757
758
  const mime = response.headers['content-type'];
758
759
  const buffer = await response.arrayBuffer();
759
- const fileExtension = getFileExtFromMime(mime);
760
+ const [extension, sampleRate] = getFileExtFromMime(mime);
760
761
  return {
761
- audioBuffer: buffer,
762
- filePath: filePath.replace(regex, fileExtension),
763
- fileExtension
762
+ audioContent: buffer,
763
+ extension,
764
+ sampleRate
764
765
  };
765
766
  } catch (err) {
766
767
  logger.info({err}, `Vendor ${vendor} returned error`);
@@ -806,7 +807,7 @@ const synthElevenlabs = async(logger, {
806
807
  'Accept': 'audio/mpeg',
807
808
  'Content-Type': 'application/json'
808
809
  });
809
- 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}`, {
810
811
  text,
811
812
  model_id,
812
813
  voice_settings: {
@@ -815,7 +816,11 @@ const synthElevenlabs = async(logger, {
815
816
  },
816
817
  ...opts
817
818
  });
818
- return mp3;
819
+ return {
820
+ audioContent,
821
+ extension: 'mp3',
822
+ sampleRate: 8000
823
+ };
819
824
  } catch (err) {
820
825
  logger.info({err}, 'synth Elevenlabs returned error');
821
826
  stats.increment('tts.count', ['vendor:elevenlabs', 'accepted:no']);
@@ -897,7 +902,7 @@ const synthPlayHT = async(client, logger, {
897
902
  'Content-Type': 'application/json'
898
903
  });
899
904
 
900
- const mp3 = await post(synthesizeUrl, {
905
+ const audioContent = await post(synthesizeUrl, {
901
906
  text,
902
907
  ...(voice_engine === 'Play3.0' && { language }),
903
908
  voice,
@@ -906,7 +911,11 @@ const synthPlayHT = async(client, logger, {
906
911
  sample_rate: 8000,
907
912
  ...opts
908
913
  });
909
- return mp3;
914
+ return {
915
+ audioContent,
916
+ extension: 'mp3',
917
+ sampleRate: 8000
918
+ };
910
919
  } catch (err) {
911
920
  logger.info({err}, 'synth PlayHT returned error');
912
921
  stats.increment('tts.count', ['vendor:playht', 'accepted:no']);
@@ -945,14 +954,19 @@ const synthRimelabs = async(logger, {
945
954
  'Accept': 'audio/mp3',
946
955
  'Content-Type': 'application/json'
947
956
  });
948
- const mp3 = await post('/v1/rime-tts', {
957
+ const sampleRate = 8000;
958
+ const audioContent = await post('/v1/rime-tts', {
949
959
  speaker: voice,
950
960
  text,
951
961
  modelId: model_id,
952
- samplingRate: 8000,
962
+ samplingRate: sampleRate,
953
963
  ...opts
954
964
  });
955
- return mp3;
965
+ return {
966
+ audioContent,
967
+ extension: 'mp3',
968
+ sampleRate
969
+ };
956
970
  } catch (err) {
957
971
  logger.info({err}, 'synth rimelabs returned error');
958
972
  stats.increment('tts.count', ['vendor:rimelabs', 'accepted:no']);
@@ -986,13 +1000,17 @@ const synthVerbio = async(client, logger, {credentials, stats, voice, text, rend
986
1000
  'User-Agent': 'jambonz',
987
1001
  'Content-Type': 'application/json'
988
1002
  });
989
- const r8 = await post('/api/v1/synthesize', {
1003
+ const audioContent = await post('/api/v1/synthesize', {
990
1004
  voice_id: voice,
991
1005
  output_sample_rate: '8k',
992
1006
  output_encoding: 'pcm16',
993
1007
  text
994
1008
  });
995
- return r8;
1009
+ return {
1010
+ audioContent,
1011
+ extension: 'r8',
1012
+ sampleRate: 8000
1013
+ };
996
1014
  } catch (err) {
997
1015
  logger.info({err}, 'synth Verbio returned error');
998
1016
  stats.increment('tts.count', ['vendor:verbio', 'accepted:no']);
@@ -1032,7 +1050,11 @@ const synthWhisper = async(logger, {credentials, stats, voice, text, renderForCa
1032
1050
  input: text,
1033
1051
  response_format: 'mp3'
1034
1052
  });
1035
- return Buffer.from(await mp3.arrayBuffer());
1053
+ return {
1054
+ audioContent: Buffer.from(await mp3.arrayBuffer()),
1055
+ extension: 'mp3',
1056
+ sampleRate: 8000
1057
+ };
1036
1058
  } catch (err) {
1037
1059
  logger.info({err}, 'synth whisper returned error');
1038
1060
  stats.increment('tts.count', ['vendor:openai', 'accepted:no']);
@@ -1064,10 +1086,14 @@ const synthDeepgram = async(logger, {credentials, stats, model, text, renderForC
1064
1086
  'Accept': 'audio/mpeg',
1065
1087
  'Content-Type': 'application/json'
1066
1088
  });
1067
- const mp3 = await post(`/v1/speak?model=${model}`, {
1089
+ const audioContent = await post(`/v1/speak?model=${model}`, {
1068
1090
  text
1069
1091
  });
1070
- return mp3;
1092
+ return {
1093
+ audioContent,
1094
+ extension: 'mp3',
1095
+ sampleRate: 8000
1096
+ };
1071
1097
  } catch (err) {
1072
1098
  logger.info({err}, 'synth Deepgram returned error');
1073
1099
  stats.increment('tts.count', ['vendor:deepgram', 'accepted:no']);
@@ -1104,6 +1130,7 @@ const synthCartesia = async(logger, {
1104
1130
 
1105
1131
  try {
1106
1132
  const client = new CartesiaClient({ apiKey: api_key });
1133
+ const sampleRate = 48000;
1107
1134
  const mp3 = await client.tts.bytes({
1108
1135
  modelId: model_id,
1109
1136
  transcript: text,
@@ -1119,7 +1146,7 @@ const synthCartesia = async(logger, {
1119
1146
  ),
1120
1147
  ...(opts.speed || opts.emotion && {
1121
1148
  experimentalControls: {
1122
- ...(opts.speed && {speed: opts.speed}),
1149
+ ...(opts.speed !== null && opts.speed !== undefined && {speed: opts.speed}),
1123
1150
  ...(opts.emotion && {emotion: opts.emotion}),
1124
1151
  }
1125
1152
  })
@@ -1128,10 +1155,14 @@ const synthCartesia = async(logger, {
1128
1155
  outputFormat: {
1129
1156
  container: 'mp3',
1130
1157
  bitRate: 128000,
1131
- sampleRate: 8000
1158
+ sampleRate
1132
1159
  },
1133
1160
  });
1134
- return Buffer.from(mp3);
1161
+ return {
1162
+ audioContent: Buffer.from(mp3),
1163
+ extension: 'mp3',
1164
+ sampleRate
1165
+ };
1135
1166
  } catch (err) {
1136
1167
  logger.info({err}, 'synth Cartesia returned error');
1137
1168
  stats.increment('tts.count', ['vendor:cartesia', 'accepted:no']);
@@ -1144,22 +1175,22 @@ const getFileExtFromMime = (mime) => {
1144
1175
  switch (mime) {
1145
1176
  case 'audio/wav':
1146
1177
  case 'audio/x-wav':
1147
- return '.wav';
1178
+ return ['wav', 8000];
1148
1179
  case /audio\/l16.*rate=8000/.test(mime) ? mime : 'cant match value':
1149
- return '.r8';
1180
+ return ['r8', 8000];
1150
1181
  case /audio\/l16.*rate=16000/.test(mime) ? mime : 'cant match value':
1151
- return '.r16';
1182
+ return ['r16', 16000];
1152
1183
  case /audio\/l16.*rate=24000/.test(mime) ? mime : 'cant match value':
1153
- return '.r24';
1184
+ return ['r24', 24000];
1154
1185
  case /audio\/l16.*rate=32000/.test(mime) ? mime : 'cant match value':
1155
- return '.r32';
1186
+ return ['r32', 32000];
1156
1187
  case /audio\/l16.*rate=48000/.test(mime) ? mime : 'cant match value':
1157
- return '.r48';
1188
+ return ['r48', 48000];
1158
1189
  case 'audio/mpeg':
1159
1190
  case 'audio/mp3':
1160
- return '.mp3';
1191
+ return ['mp3', 8000];
1161
1192
  default:
1162
- return '.wav';
1193
+ return ['wav', 8000];
1163
1194
  }
1164
1195
  };
1165
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,68 +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
- case 'cartesia':
54
- if (renderForCaching || JAMBONES_DISABLE_TTS_STREAMING) {
55
- return mp3Extension;
56
- } else {
57
- return r8Extension;
58
- }
59
- case 'nuance':
60
- case 'nvidia':
61
- case 'verbio':
62
- return r8Extension;
63
- case 'google':
64
- // google voice cloning just support wav.
65
- if (typeof voice === 'object' && voice.voice_cloning_key) {
66
- return wavExtension;
67
- } else {
68
- return mp3Extension;
69
- }
70
- default:
71
- // If vendor is custom
72
- if (vendor.startsWith('custom')) {
73
- if (renderForCaching || JAMBONES_DISABLE_TTS_STREAMING) {
74
- return mp3Extension;
75
- } else {
76
- return r8Extension;
77
- }
78
- }
79
- return mp3Extension;
80
- }
81
- }
82
33
 
83
34
  const noopLogger = {
84
35
  info: () => {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jambonz/speech-utils",
3
- "version": "0.1.24",
3
+ "version": "0.2.1",
4
4
  "description": "TTS-related speech utilities for jambonz",
5
5
  "main": "index.js",
6
6
  "author": "Dave Horton",
package/test/synth.js CHANGED
@@ -888,24 +888,15 @@ test('TTS Cache tests', async(t) => {
888
888
  language: 'non-existing',
889
889
  voice: 'non-existing',
890
890
  });
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`);
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');
893
893
 
894
894
  // make sure other tts keys are still there
895
- const cached = await client.keys('tts:*')
896
- 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');
897
897
 
898
- // retrieve keys from cache and check the key contains the file extension
899
- let key = cached[0];
900
- t.ok(key.includes('mp3'), `tts cache extension shoult be part of the key and equal mp3`);
901
-
902
- process.env.VG_TRIM_TTS_SILENCE = 'true';
898
+ process.env.VG_TRIM_TTS_SILENCE = 'true';
903
899
  await client.set(makeSynthKey({ vendor: 'azure' }), 'value');
904
-
905
- const r8Keys = await client.keys('tts:r8*');
906
- key = r8Keys[0];
907
- t.ok(key.includes('r8'), `tts cache extension shoult be part of the key and equal r8`);
908
-
909
900
  } catch (err) {
910
901
  console.error(JSON.stringify(err));
911
902
  t.end(err);