@jambonz/speech-utils 0.1.24 → 0.2.2

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,8 +3,29 @@ 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
- {account_sid, vendor, language, voice, deploymentId, engine, text}) {
28
+ {account_sid, vendor, language, voice, deploymentId, engine, model, text}) {
8
29
  let key;
9
30
  logger = logger || noopLogger;
10
31
 
@@ -15,10 +36,18 @@ async function addFileToCache(client, logger, path,
15
36
  language: language || '',
16
37
  voice: voice || deploymentId,
17
38
  engine,
39
+ model,
18
40
  text,
19
41
  });
42
+ const [extension, sampleRate] = getExtensionAndSampleRate(path);
20
43
  const audioBuffer = await fs.readFile(path);
21
- await client.setex(key, EXPIRES, audioBuffer.toString('base64'));
44
+ await client.setex(key, EXPIRES, JSON.stringify(
45
+ {
46
+ audioContent: audioBuffer.toString('base64'),
47
+ extension,
48
+ sampleRate
49
+ }
50
+ ));
22
51
  } catch (err) {
23
52
  logger.error(err, 'addFileToCache: Error');
24
53
  return;
@@ -12,7 +12,7 @@ const debug = require('debug')('jambonz:realtimedb-helpers');
12
12
  * @returns {object} result - {error, purgedCount}
13
13
  */
14
14
  async function purgeTtsCache(client, logger, {all, account_sid, vendor,
15
- language, voice, deploymentId, engine, text} = {all: true}) {
15
+ language, voice, deploymentId, engine, model, text} = {all: true}) {
16
16
  logger = logger || noopLogger;
17
17
 
18
18
  let purgedCount = 0, error;
@@ -33,6 +33,7 @@ async function purgeTtsCache(client, logger, {all, account_sid, vendor,
33
33
  language: language || '',
34
34
  voice: voice || deploymentId,
35
35
  engine,
36
+ model,
36
37
  text,
37
38
  });
38
39
  purgedCount = await client.del(key);
@@ -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,26 @@ 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
+ // model or model_id is used to identify the tts cache.
173
+ model: model || credentials.model_id,
174
+ text
174
175
  });
175
- let filePath;
176
- // used only for custom vendor
177
- let fileExtension;
178
- filePath = makeFilePath({vendor, voice, key, salt, renderForCaching});
176
+
179
177
  debug(`synth key is ${key}`);
180
178
  let cached;
181
179
  if (!disableTtsCache) {
182
180
  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
181
  }
204
182
  if (cached) {
205
183
  // found in cache - extend the expiry and use it
206
184
  debug('result WAS found in cache');
207
185
  servedFromCache = true;
208
186
  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
- }
187
+ audioData = JSON.parse(cached);
188
+ // convert base64 audio to buffer
189
+ audioData.audioContent = Buffer.from(audioData.audioContent, 'base64');
220
190
  client.expire(key, EXPIRES).catch((err) => logger.info(err, 'Error setting expires'));
221
- }
222
- if (!cached) {
191
+ } else {
223
192
  // not found in cache - go get it from speech vendor and add to cache
224
193
  debug('result was NOT found in cache');
225
194
  stats.increment('tts.cache.requests', ['found:no']);
@@ -227,94 +196,92 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
227
196
  const startAt = process.hrtime();
228
197
  switch (vendor) {
229
198
  case 'google':
230
- audioBuffer = await synthGoogle(logger, {credentials, stats, language, voice, gender, text});
199
+ audioData = await synthGoogle(logger, {credentials, stats, language, voice, gender, text});
231
200
  break;
232
201
  case 'aws':
233
202
  case 'polly':
234
203
  vendorLabel = 'aws';
235
- audioBuffer = await synthPolly(createHash, retrieveHash, logger,
204
+ audioData = await synthPolly(createHash, retrieveHash, logger,
236
205
  {credentials, stats, language, voice, text, engine});
237
206
  break;
238
207
  case 'azure':
239
208
  case 'microsoft':
240
209
  vendorLabel = 'microsoft';
241
- audioBuffer = await synthMicrosoft(logger, {credentials, stats, language, voice, text, deploymentId,
242
- filePath, renderForCaching, disableTtsStreaming});
210
+ audioData = await synthMicrosoft(logger, {credentials, stats, language, voice, text, deploymentId,
211
+ renderForCaching, disableTtsStreaming});
243
212
  break;
244
213
  case 'nuance':
245
214
  model = model || 'enhanced';
246
- audioBuffer = await synthNuance(client, logger, {credentials, stats, voice, model, text});
215
+ audioData = await synthNuance(client, logger, {credentials, stats, voice, model, text});
247
216
  break;
248
217
  case 'nvidia':
249
- audioBuffer = await synthNvidia(client, logger, {credentials, stats, language, voice, model, text});
218
+ audioData = await synthNvidia(client, logger, {credentials, stats, language, voice, model, text});
250
219
  break;
251
220
  case 'ibm':
252
- audioBuffer = await synthIbm(logger, {credentials, stats, voice, text});
221
+ audioData = await synthIbm(logger, {credentials, stats, voice, text});
253
222
  break;
254
223
  case 'wellsaid':
255
- audioBuffer = await synthWellSaid(logger, {credentials, stats, language, voice, text, filePath});
224
+ audioData = await synthWellSaid(logger, {credentials, stats, language, voice, text});
256
225
  break;
257
226
  case 'elevenlabs':
258
- audioBuffer = await synthElevenlabs(logger, {
259
- credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming, filePath
260
- });
227
+ audioData = await synthElevenlabs(logger, {
228
+ credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
261
229
  break;
262
230
  case 'playht':
263
- audioBuffer = await synthPlayHT(client, logger, {
264
- credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming, filePath
265
- });
231
+ audioData = await synthPlayHT(client, logger, {
232
+ credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
266
233
  break;
267
234
  case 'cartesia':
268
- audioBuffer = await synthCartesia(logger, {
269
- credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming, filePath
270
- });
235
+ audioData = await synthCartesia(logger, {
236
+ credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
271
237
  break;
272
238
  case 'rimelabs':
273
- audioBuffer = await synthRimelabs(logger, {
274
- credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming, filePath
275
- });
239
+ audioData = await synthRimelabs(logger, {
240
+ credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
276
241
  break;
277
242
  case 'whisper':
278
- audioBuffer = await synthWhisper(logger, {
243
+ audioData = await synthWhisper(logger, {
279
244
  credentials, stats, voice, text, renderForCaching, disableTtsStreaming});
280
245
  break;
281
246
  case 'verbio':
282
- audioBuffer = await synthVerbio(client, logger, {
247
+ audioData = await synthVerbio(client, logger, {
283
248
  credentials, stats, voice, text, renderForCaching, disableTtsStreaming});
284
- if (audioBuffer?.filePath) return audioBuffer;
249
+ if (audioData?.filePath) return audioData;
285
250
  break;
286
251
  case 'deepgram':
287
- audioBuffer = await synthDeepgram(logger, {credentials, stats, model, text,
252
+ audioData = await synthDeepgram(logger, {credentials, stats, model, text,
288
253
  renderForCaching, disableTtsStreaming});
289
254
  break;
290
255
  case vendor.startsWith('custom') ? vendor : 'cant_match_value':
291
- ({ audioBuffer, filePath, fileExtension } = await synthCustomVendor(logger,
292
- {credentials, stats, language, voice, text, filePath}));
256
+ audioData = await synthCustomVendor(logger,
257
+ {credentials, stats, language, voice, text});
293
258
  break;
294
259
  default:
295
260
  assert(`synthAudio: unsupported speech vendor ${vendor}`);
296
261
  }
297
- if ('filePath' in audioBuffer) return audioBuffer;
262
+ if ('filePath' in audioData) return audioData;
298
263
  const diff = process.hrtime(startAt);
299
264
  const time = diff[0] * 1e3 + diff[1] * 1e-6;
300
265
  rtt = time.toFixed(0);
301
266
  stats.histogram('tts.response_time', rtt, [`vendor:${vendorLabel}`]);
302
267
  debug(`tts rtt time for ${text.length} chars on ${vendorLabel}: ${rtt}`);
303
268
  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)
269
+ // Save audio json to cache
270
+ client.setex(key, EXPIRES, JSON.stringify({
271
+ ...audioData,
272
+ audioContent: audioData.audioContent?.toString('base64')
273
+ }))
313
274
  .catch((err) => logger.error(err, `error calling setex on key ${key}`));
314
275
  }
315
276
 
316
277
  return new Promise((resolve, reject) => {
317
- fs.writeFile(filePath, audioBuffer, (err) => {
278
+ const { audioContent, extension } = audioData;
279
+ const filePath = makeFilePath({
280
+ key,
281
+ salt,
282
+ extension
283
+ });
284
+ fs.writeFile(filePath, audioContent, (err) => {
318
285
  if (err) return reject(err);
319
286
  resolve({filePath, servedFromCache, rtt});
320
287
  });
@@ -369,7 +336,13 @@ const synthPolly = async(createHash, retrieveHash, logger,
369
336
  .on('data', (chunk) => {
370
337
  chunks.push(chunk);
371
338
  })
372
- .on('end', () => resolve(Buffer.concat(chunks)));
339
+ .on('end', () => resolve(
340
+ {
341
+ audioContent: Buffer.concat(chunks),
342
+ extension: 'mp3',
343
+ sampleRate: 8000
344
+ }
345
+ ));
373
346
  });
374
347
  } catch (err) {
375
348
  logger.info({err}, 'synthAudio: Error synthesizing speech using aws polly');
@@ -411,7 +384,11 @@ const synthGoogle = async(logger, {credentials, stats, language, voice, gender,
411
384
  };
412
385
 
413
386
  const wav = await post('/v1beta1/text:synthesize', payload);
414
- return Buffer.from(wav.audioContent, 'base64');
387
+ return {
388
+ audioContent: Buffer.from(wav.audioContent, 'base64'),
389
+ extension: 'wav',
390
+ sampleRate: 24000
391
+ };
415
392
  } catch (err) {
416
393
  logger.info({err: await err.text()}, 'synthGoogle returned error');
417
394
  throw err;
@@ -432,7 +409,11 @@ const synthGoogle = async(logger, {credentials, stats, language, voice, gender,
432
409
  const responses = await client.synthesizeSpeech(opts);
433
410
  stats.increment('tts.count', ['vendor:google', 'accepted:yes']);
434
411
  client.close();
435
- return responses[0].audioContent;
412
+ return {
413
+ audioContent: responses[0].audioContent,
414
+ extension: 'mp3',
415
+ sampleRate: 8000
416
+ };
436
417
  } catch (err) {
437
418
  console.error(err);
438
419
  logger.info({err, opts}, 'synthAudio: Error synthesizing speech using google');
@@ -463,7 +444,11 @@ const synthIbm = async(logger, {credentials, stats, voice, text}) => {
463
444
  for await (const chunk of r.result) {
464
445
  chunks.push(chunk);
465
446
  }
466
- return Buffer.concat(chunks);
447
+ return {
448
+ audioContent: Buffer.concat(chunks),
449
+ extension: 'mp3',
450
+ sampleRate: 8000
451
+ };
467
452
  } catch (err) {
468
453
  logger.info({err, params}, 'synthAudio: Error synthesizing speech using ibm');
469
454
  stats.increment('tts.count', ['vendor:ibm', 'accepted:no']);
@@ -473,11 +458,9 @@ const synthIbm = async(logger, {credentials, stats, voice, text}) => {
473
458
 
474
459
  async function _synthOnPremMicrosoft(logger, {
475
460
  credentials,
476
- stats,
477
461
  language,
478
462
  voice,
479
- text,
480
- filePath
463
+ text
481
464
  }) {
482
465
  const {use_custom_tts, custom_tts_endpoint_url, api_key} = credentials;
483
466
  let content = text;
@@ -499,15 +482,19 @@ async function _synthOnPremMicrosoft(logger, {
499
482
  }
500
483
 
501
484
  try {
502
- const trimSilence = filePath.endsWith('.r8');
485
+ const trimSilence = JAMBONES_TTS_TRIM_SILENCE;
503
486
  const post = bent('POST', 'buffer', {
504
487
  'X-Microsoft-OutputFormat': trimSilence ? 'raw-8khz-16bit-mono-pcm' : 'audio-16khz-32kbitrate-mono-mp3',
505
488
  'Content-Type': 'application/ssml+xml',
506
489
  'User-Agent': 'Jambonz',
507
490
  ...(api_key && {'Ocp-Apim-Subscription-Key': api_key})
508
491
  });
509
- const mp3 = await post(custom_tts_endpoint_url, content);
510
- return mp3;
492
+ const audioContent = await post(custom_tts_endpoint_url, content);
493
+ return {
494
+ audioContent,
495
+ extension: trimSilence ? 'r8' : 'mp3',
496
+ sampleRate: 8000
497
+ };
511
498
  } catch (err) {
512
499
  logger.info({err}, '_synthMicrosoftByHttp returned error');
513
500
  throw err;
@@ -520,7 +507,6 @@ const synthMicrosoft = async(logger, {
520
507
  language,
521
508
  voice,
522
509
  text,
523
- filePath,
524
510
  renderForCaching,
525
511
  disableTtsStreaming
526
512
  }) => {
@@ -563,17 +549,18 @@ const synthMicrosoft = async(logger, {
563
549
  rtt: 0
564
550
  };
565
551
  }
552
+ // Azure Onprem
566
553
  if (use_custom_tts && custom_tts_endpoint_url) {
567
554
  return await _synthOnPremMicrosoft(logger, {
568
555
  credentials,
569
556
  stats,
570
557
  language,
571
558
  voice,
572
- text,
573
- filePath
559
+ text
574
560
  });
575
561
  }
576
- const trimSilence = filePath.endsWith('.r8');
562
+ // Azure hosted service
563
+ const trimSilence = JAMBONES_TTS_TRIM_SILENCE;
577
564
  const speechConfig = SpeechConfig.fromSubscription(apiKey, region);
578
565
  speechConfig.speechSynthesisLanguage = language;
579
566
  speechConfig.speechSynthesisVoiceName = voice;
@@ -608,7 +595,11 @@ const synthMicrosoft = async(logger, {
608
595
  case ResultReason.SynthesizingAudioCompleted:
609
596
  let buffer = Buffer.from(result.audioData);
610
597
  if (trimSilence) buffer = trimTrailingSilence(buffer);
611
- resolve(buffer);
598
+ resolve({
599
+ audioContent: buffer,
600
+ extension: trimSilence ? 'r8' : 'mp3',
601
+ sampleRate: 8000
602
+ });
612
603
  synthesizer.close();
613
604
  stats.increment('tts.count', ['vendor:microsoft', 'accepted:yes']);
614
605
  break;
@@ -638,11 +629,15 @@ const synthWellSaid = async(logger, {credentials, stats, language, voice, gender
638
629
  'Accept': 'audio/mpeg',
639
630
  'Content-Type': 'application/json'
640
631
  });
641
- const mp3 = await post('/v1/tts/stream', {
632
+ const audioContent = await post('/v1/tts/stream', {
642
633
  text,
643
634
  speaker_id: voice
644
635
  });
645
- return mp3;
636
+ return {
637
+ audioContent,
638
+ extension: 'mp3',
639
+ sampleRate: 8000
640
+ };
646
641
  } catch (err) {
647
642
  logger.info({err}, 'testWellSaidTts returned error');
648
643
  throw err;
@@ -679,8 +674,8 @@ const synthNuance = async(client, logger, {credentials, stats, voice, model, tex
679
674
  t.setText(text);
680
675
  input.setText(t);
681
676
  }
682
-
683
- pcm.setSampleRateHz(8000);
677
+ const sampleRate = 8000;
678
+ pcm.setSampleRateHz(sampleRate);
684
679
  f.setPcm(pcm);
685
680
  p.setAudioFormat(f);
686
681
  v.setName(voice);
@@ -704,7 +699,11 @@ const synthNuance = async(client, logger, {credentials, stats, voice, model, tex
704
699
  const details = status.getDetails();
705
700
  return reject({code, message, details});
706
701
  }
707
- resolve(Buffer.from(response.getAudio()));
702
+ resolve({
703
+ audioContent: Buffer.from(response.getAudio()),
704
+ extension: 'r8',
705
+ sampleRate
706
+ });
708
707
  });
709
708
  });
710
709
  };
@@ -712,12 +711,13 @@ const synthNuance = async(client, logger, {credentials, stats, voice, model, tex
712
711
  const synthNvidia = async(client, logger, {credentials, stats, language, voice, model, text}) => {
713
712
  const {riva_server_uri} = credentials;
714
713
  let rivaClient, request;
714
+ const sampleRate = 8000;
715
715
  try {
716
716
  rivaClient = await createRivaClient(riva_server_uri);
717
717
  request = new SynthesizeSpeechRequest();
718
718
  request.setVoiceName(voice);
719
719
  request.setLanguageCode(language);
720
- request.setSampleRateHz(8000);
720
+ request.setSampleRateHz(sampleRate);
721
721
  request.setEncoding(AudioEncoding.LINEAR_PCM);
722
722
  request.setText(text);
723
723
  } catch (err) {
@@ -731,7 +731,11 @@ const synthNvidia = async(client, logger, {credentials, stats, language, voice,
731
731
  logger.info({err, voice, language}, 'error synthesizing speech using Nvidia');
732
732
  return reject(err);
733
733
  }
734
- resolve(Buffer.from(response.getAudio()));
734
+ resolve({
735
+ audioContent: Buffer.from(response.getAudio()),
736
+ extension: 'r8',
737
+ sampleRate
738
+ });
735
739
  });
736
740
  });
737
741
  };
@@ -753,14 +757,13 @@ const synthCustomVendor = async(logger, {credentials, stats, language, voice, te
753
757
  text
754
758
  });
755
759
 
756
- const regex = /\.[^\.]*$/g;
757
760
  const mime = response.headers['content-type'];
758
761
  const buffer = await response.arrayBuffer();
759
- const fileExtension = getFileExtFromMime(mime);
762
+ const [extension, sampleRate] = getFileExtFromMime(mime);
760
763
  return {
761
- audioBuffer: buffer,
762
- filePath: filePath.replace(regex, fileExtension),
763
- fileExtension
764
+ audioContent: buffer,
765
+ extension,
766
+ sampleRate
764
767
  };
765
768
  } catch (err) {
766
769
  logger.info({err}, `Vendor ${vendor} returned error`);
@@ -806,7 +809,7 @@ const synthElevenlabs = async(logger, {
806
809
  'Accept': 'audio/mpeg',
807
810
  'Content-Type': 'application/json'
808
811
  });
809
- const mp3 = await post(`/v1/text-to-speech/${voice}${optimize_streaming_latency}`, {
812
+ const audioContent = await post(`/v1/text-to-speech/${voice}${optimize_streaming_latency}`, {
810
813
  text,
811
814
  model_id,
812
815
  voice_settings: {
@@ -815,7 +818,11 @@ const synthElevenlabs = async(logger, {
815
818
  },
816
819
  ...opts
817
820
  });
818
- return mp3;
821
+ return {
822
+ audioContent,
823
+ extension: 'mp3',
824
+ sampleRate: 8000
825
+ };
819
826
  } catch (err) {
820
827
  logger.info({err}, 'synth Elevenlabs returned error');
821
828
  stats.increment('tts.count', ['vendor:elevenlabs', 'accepted:no']);
@@ -897,7 +904,7 @@ const synthPlayHT = async(client, logger, {
897
904
  'Content-Type': 'application/json'
898
905
  });
899
906
 
900
- const mp3 = await post(synthesizeUrl, {
907
+ const audioContent = await post(synthesizeUrl, {
901
908
  text,
902
909
  ...(voice_engine === 'Play3.0' && { language }),
903
910
  voice,
@@ -906,7 +913,11 @@ const synthPlayHT = async(client, logger, {
906
913
  sample_rate: 8000,
907
914
  ...opts
908
915
  });
909
- return mp3;
916
+ return {
917
+ audioContent,
918
+ extension: 'mp3',
919
+ sampleRate: 8000
920
+ };
910
921
  } catch (err) {
911
922
  logger.info({err}, 'synth PlayHT returned error');
912
923
  stats.increment('tts.count', ['vendor:playht', 'accepted:no']);
@@ -945,14 +956,19 @@ const synthRimelabs = async(logger, {
945
956
  'Accept': 'audio/mp3',
946
957
  'Content-Type': 'application/json'
947
958
  });
948
- const mp3 = await post('/v1/rime-tts', {
959
+ const sampleRate = 8000;
960
+ const audioContent = await post('/v1/rime-tts', {
949
961
  speaker: voice,
950
962
  text,
951
963
  modelId: model_id,
952
- samplingRate: 8000,
964
+ samplingRate: sampleRate,
953
965
  ...opts
954
966
  });
955
- return mp3;
967
+ return {
968
+ audioContent,
969
+ extension: 'mp3',
970
+ sampleRate
971
+ };
956
972
  } catch (err) {
957
973
  logger.info({err}, 'synth rimelabs returned error');
958
974
  stats.increment('tts.count', ['vendor:rimelabs', 'accepted:no']);
@@ -986,13 +1002,17 @@ const synthVerbio = async(client, logger, {credentials, stats, voice, text, rend
986
1002
  'User-Agent': 'jambonz',
987
1003
  'Content-Type': 'application/json'
988
1004
  });
989
- const r8 = await post('/api/v1/synthesize', {
1005
+ const audioContent = await post('/api/v1/synthesize', {
990
1006
  voice_id: voice,
991
1007
  output_sample_rate: '8k',
992
1008
  output_encoding: 'pcm16',
993
1009
  text
994
1010
  });
995
- return r8;
1011
+ return {
1012
+ audioContent,
1013
+ extension: 'r8',
1014
+ sampleRate: 8000
1015
+ };
996
1016
  } catch (err) {
997
1017
  logger.info({err}, 'synth Verbio returned error');
998
1018
  stats.increment('tts.count', ['vendor:verbio', 'accepted:no']);
@@ -1032,7 +1052,11 @@ const synthWhisper = async(logger, {credentials, stats, voice, text, renderForCa
1032
1052
  input: text,
1033
1053
  response_format: 'mp3'
1034
1054
  });
1035
- return Buffer.from(await mp3.arrayBuffer());
1055
+ return {
1056
+ audioContent: Buffer.from(await mp3.arrayBuffer()),
1057
+ extension: 'mp3',
1058
+ sampleRate: 8000
1059
+ };
1036
1060
  } catch (err) {
1037
1061
  logger.info({err}, 'synth whisper returned error');
1038
1062
  stats.increment('tts.count', ['vendor:openai', 'accepted:no']);
@@ -1064,10 +1088,14 @@ const synthDeepgram = async(logger, {credentials, stats, model, text, renderForC
1064
1088
  'Accept': 'audio/mpeg',
1065
1089
  'Content-Type': 'application/json'
1066
1090
  });
1067
- const mp3 = await post(`/v1/speak?model=${model}`, {
1091
+ const audioContent = await post(`/v1/speak?model=${model}`, {
1068
1092
  text
1069
1093
  });
1070
- return mp3;
1094
+ return {
1095
+ audioContent,
1096
+ extension: 'mp3',
1097
+ sampleRate: 8000
1098
+ };
1071
1099
  } catch (err) {
1072
1100
  logger.info({err}, 'synth Deepgram returned error');
1073
1101
  stats.increment('tts.count', ['vendor:deepgram', 'accepted:no']);
@@ -1104,6 +1132,7 @@ const synthCartesia = async(logger, {
1104
1132
 
1105
1133
  try {
1106
1134
  const client = new CartesiaClient({ apiKey: api_key });
1135
+ const sampleRate = 48000;
1107
1136
  const mp3 = await client.tts.bytes({
1108
1137
  modelId: model_id,
1109
1138
  transcript: text,
@@ -1119,7 +1148,7 @@ const synthCartesia = async(logger, {
1119
1148
  ),
1120
1149
  ...(opts.speed || opts.emotion && {
1121
1150
  experimentalControls: {
1122
- ...(opts.speed && {speed: opts.speed}),
1151
+ ...(opts.speed !== null && opts.speed !== undefined && {speed: opts.speed}),
1123
1152
  ...(opts.emotion && {emotion: opts.emotion}),
1124
1153
  }
1125
1154
  })
@@ -1128,10 +1157,14 @@ const synthCartesia = async(logger, {
1128
1157
  outputFormat: {
1129
1158
  container: 'mp3',
1130
1159
  bitRate: 128000,
1131
- sampleRate: 8000
1160
+ sampleRate
1132
1161
  },
1133
1162
  });
1134
- return Buffer.from(mp3);
1163
+ return {
1164
+ audioContent: Buffer.from(mp3),
1165
+ extension: 'mp3',
1166
+ sampleRate
1167
+ };
1135
1168
  } catch (err) {
1136
1169
  logger.info({err}, 'synth Cartesia returned error');
1137
1170
  stats.increment('tts.count', ['vendor:cartesia', 'accepted:no']);
@@ -1144,22 +1177,22 @@ const getFileExtFromMime = (mime) => {
1144
1177
  switch (mime) {
1145
1178
  case 'audio/wav':
1146
1179
  case 'audio/x-wav':
1147
- return '.wav';
1180
+ return ['wav', 8000];
1148
1181
  case /audio\/l16.*rate=8000/.test(mime) ? mime : 'cant match value':
1149
- return '.r8';
1182
+ return ['r8', 8000];
1150
1183
  case /audio\/l16.*rate=16000/.test(mime) ? mime : 'cant match value':
1151
- return '.r16';
1184
+ return ['r16', 16000];
1152
1185
  case /audio\/l16.*rate=24000/.test(mime) ? mime : 'cant match value':
1153
- return '.r24';
1186
+ return ['r24', 24000];
1154
1187
  case /audio\/l16.*rate=32000/.test(mime) ? mime : 'cant match value':
1155
- return '.r32';
1188
+ return ['r32', 32000];
1156
1189
  case /audio\/l16.*rate=48000/.test(mime) ? mime : 'cant match value':
1157
- return '.r48';
1190
+ return ['r48', 48000];
1158
1191
  case 'audio/mpeg':
1159
1192
  case 'audio/mp3':
1160
- return '.mp3';
1193
+ return ['mp3', 8000];
1161
1194
  default:
1162
- return '.wav';
1195
+ return ['wav', 8000];
1163
1196
  }
1164
1197
  };
1165
1198
 
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,26 @@ 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 = '',
21
+ vendor,
22
+ language,
23
+ voice,
24
+ engine = '',
25
+ model = '',
26
+ text
27
+ }) {
22
28
  const hash = crypto.createHash('sha1');
23
- hash.update(`${language}:${vendor}:${voice}:${engine}:${text}`);
29
+ hash.update(`${language}:${vendor}:${voice}:${engine}:${model}:${text}`);
24
30
  const hexHashKey = hash.digest('hex');
25
31
  const accountKey = account_sid ? `:${account_sid}` : '';
26
- const namespace = vendor.startsWith('custom') ? vendor : getFileExtension({vendor, voice, renderForCaching});
27
- const key = `tts${accountKey}:${namespace}:${hexHashKey}`;
32
+ const key = `tts${accountKey}:${hexHashKey}`;
28
33
  return key;
29
34
  }
30
35
 
31
- function makeFilePath({vendor, voice, key, salt = '', renderForCaching = false}) {
32
- const extension = getFileExtension({vendor, renderForCaching, voice});
36
+ function makeFilePath({key, salt = '', extension}) {
33
37
  return `${TMP_FOLDER}/${key.replace('tts:', `tts-${salt}`)}.${extension}`;
34
38
  }
35
39
 
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
40
 
83
41
  const noopLogger = {
84
42
  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.2",
4
4
  "description": "TTS-related speech utilities for jambonz",
5
5
  "main": "index.js",
6
6
  "author": "Dave Horton",
package/test/synth.js CHANGED
@@ -5,7 +5,7 @@ const fs = require('fs');
5
5
  const {makeSynthKey} = require('../lib/utils');
6
6
  const logger = require('pino')();
7
7
  const bent = require('bent');
8
- const getJSON = bent('json')
8
+ const getJSON = bent('json');
9
9
 
10
10
  process.on('unhandledRejection', (reason, p) => {
11
11
  console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
@@ -853,11 +853,11 @@ test('TTS Cache tests', async(t) => {
853
853
  // save some random tts keys to cache
854
854
  const minRecords = 8;
855
855
  for (const i in Array(minRecords).fill(0)) {
856
- await client.set(makeSynthKey({vendor: i, language: i, voice: i, engine: i, text: i}), i);
856
+ await client.set(makeSynthKey({vendor: i, language: i, voice: i, engine: i, model: i, text: i}), i);
857
857
  }
858
858
  const count = await getTtsSize();
859
859
  t.ok(count >= minRecords, 'getTtsSize worked.');
860
-
860
+
861
861
  const {purgedCount} = await purgeTtsCache();
862
862
  t.ok(purgedCount >= minRecords, `successfully purged at least ${minRecords} tts records from cache`);
863
863
 
@@ -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`);
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`);
895
+ const cached = await client.keys('tts:*');
896
+ t.ok(cached.length >= 1, 'successfully kept all non-specified tts records in cache');
901
897
 
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);