@jambonz/speech-utils 1.0.8 → 1.0.10

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.
@@ -80,7 +80,8 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
80
80
  logger = logger || noopLogger;
81
81
 
82
82
  assert.ok(['google', 'aws', 'polly', 'microsoft', 'wellsaid', 'nvidia', 'elevenlabs',
83
- 'whisper', 'deepgram', 'rimelabs', 'cartesia', 'inworld', 'resemble', 'murf', 'xai'].includes(vendor) ||
83
+ 'whisper', 'deepgram', 'deepgramflux', 'rimelabs', 'cartesia', 'inworld', 'resemble', 'murf', 'xai']
84
+ .includes(vendor) ||
84
85
  vendor.startsWith('custom'),
85
86
  `synthAudio supported vendors are google, aws, microsoft, nvidia and wellsaid ..etc, not ${vendor}`);
86
87
  if ('google' === vendor) {
@@ -124,6 +125,11 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
124
125
  if (!credentials.deepgram_tts_uri) {
125
126
  assert.ok(credentials.api_key, 'synthAudio requires api_key when deepgram is used');
126
127
  }
128
+ } else if ('deepgramflux' === vendor) {
129
+ // Deepgram Flux TTS (/v2/speak); the flux model rides on `voice`/`model`
130
+ if (!credentials.deepgram_tts_uri) {
131
+ assert.ok(credentials.api_key, 'synthAudio requires api_key when deepgramflux is used');
132
+ }
127
133
  } else if ('xai' === vendor) {
128
134
  assert.ok(credentials.api_key, 'synthAudio requires api_key when xai is used');
129
135
  } else if ('cartesia' === vendor) {
@@ -230,6 +236,10 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
230
236
  audioData = await synthDeepgram(logger, {credentials, stats, model, key, text,
231
237
  renderForCaching, disableTtsStreaming, disableTtsCache});
232
238
  break;
239
+ case 'deepgramflux':
240
+ audioData = await synthDeepgramFlux(logger, {credentials, stats, model: model || voice, key, text,
241
+ renderForCaching, disableTtsStreaming, disableTtsCache});
242
+ break;
233
243
  case 'xai':
234
244
  audioData = await synthXai(logger, {
235
245
  credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
@@ -373,6 +383,32 @@ const synthPolly = async(createHash, retrieveHash, logger,
373
383
  }
374
384
  };
375
385
 
386
+ /* google AudioConfig settings we support, as [google camelCase name, freeswitch param name] */
387
+ const GOOGLE_AUDIO_SETTINGS = [
388
+ ['speakingRate', 'speaking_rate'],
389
+ ['pitch', 'pitch'],
390
+ ['volumeGainDb', 'volume_gain_db']
391
+ ];
392
+
393
+ /**
394
+ * Extract google AudioConfig settings from the synthesizer options. They may be supplied
395
+ * nested under an audioConfig property (mirroring google's AudioConfig object) or flat at
396
+ * the top level, and either google's camelCase or snake_case names are accepted.
397
+ * @see https://cloud.google.com/text-to-speech/docs/reference/rest/v1/text/synthesize#AudioConfig
398
+ * @returns object keyed by google's camelCase names, holding only valid numeric settings
399
+ */
400
+ const googleAudioConfig = (options) => {
401
+ const provided = {...options, ...(options?.audioConfig || {})};
402
+ const audioConfig = {};
403
+ for (const [name, snakeName] of GOOGLE_AUDIO_SETTINGS) {
404
+ const value = provided[name] ?? provided[snakeName];
405
+ /* note: 0 is meaningful for pitch and volumeGainDb, so check for absence explicitly */
406
+ if (value === undefined || value === null || value === '') continue;
407
+ const num = Number(value);
408
+ if (Number.isFinite(num)) audioConfig[name] = num;
409
+ }
410
+ return audioConfig;
411
+ };
376
412
 
377
413
  const synthGoogle = async(logger, {
378
414
  credentials, stats, language, voice, gender, key, text, model, options, instructions,
@@ -408,6 +444,15 @@ const synthGoogle = async(logger, {
408
444
  // comma is used to separate parameters in freeswitch tts module
409
445
  const prompt = options?.prompt || instructions;
410
446
  if (prompt) params += `,prompt=${prompt.replace(/\n/g, ' ').replace(/,/g, ';')}`;
447
+ /**
448
+ * AudioConfig settings. Note google only honors these in some api modes:
449
+ * tts applies all of them, live (HD voices) applies speakingRate only,
450
+ * and gemini ignores them entirely (use prompt instead for style control).
451
+ */
452
+ const audioSettings = googleAudioConfig(options);
453
+ for (const [name, snakeName] of GOOGLE_AUDIO_SETTINGS) {
454
+ if (name in audioSettings) params += `,${snakeName}=${audioSettings[name]}`;
455
+ }
411
456
  params += '}';
412
457
 
413
458
  return {
@@ -476,6 +521,9 @@ const synthGoogle = async(logger, {
476
521
  sampleRate = 8000;
477
522
  }
478
523
 
524
+ /* gemini voices do not support the AudioConfig settings; they use prompt for style control */
525
+ if (!isGemini) Object.assign(audioConfig, googleAudioConfig(options));
526
+
479
527
  const opts = { input, voice: voiceParams, audioConfig };
480
528
 
481
529
  try {
@@ -1140,6 +1188,50 @@ const synthDeepgram = async(logger, {credentials, stats, model, key, text, rende
1140
1188
  }
1141
1189
  };
1142
1190
 
1191
+ // Deepgram Flux TTS — the conversation-native model served from /v2/speak.
1192
+ // Streaming rides the mediajam deepgramflux dialect via a say: filePath; the
1193
+ // batch/cache path POSTs to /v2/speak (mp3 is batch-only for Flux).
1194
+ const synthDeepgramFlux = async(logger, {credentials, stats, model, key, text, renderForCaching,
1195
+ disableTtsStreaming, disableTtsCache}) => {
1196
+ const {api_key, deepgram_tts_uri} = credentials;
1197
+ if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
1198
+ let params = '{';
1199
+ params += `api_key=${api_key}`;
1200
+ params += `,playback_id=${key}`;
1201
+ params += ',vendor=deepgramflux';
1202
+ params += `,voice=${model}`;
1203
+ params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
1204
+ if (deepgram_tts_uri) params += `,endpoint=${deepgram_tts_uri}`;
1205
+ params += '}';
1206
+
1207
+ return {
1208
+ filePath: `say:${params}${text.replace(/\n/g, ' ')}`,
1209
+ servedFromCache: false,
1210
+ rtt: 0
1211
+ };
1212
+ }
1213
+ try {
1214
+ const post = bent(deepgram_tts_uri || 'https://api.deepgram.com', 'POST', 'buffer', {
1215
+ // on-premise deepgram does not require to have api_key
1216
+ ...(api_key && {'Authorization': `Token ${api_key}`}),
1217
+ 'Accept': 'audio/mpeg',
1218
+ 'Content-Type': 'application/json'
1219
+ });
1220
+ const audioContent = await post(`/v2/speak?model=${model}`, {
1221
+ text
1222
+ });
1223
+ return {
1224
+ audioContent,
1225
+ extension: 'mp3',
1226
+ sampleRate: 8000
1227
+ };
1228
+ } catch (err) {
1229
+ logger.info({err}, 'synth Deepgram Flux returned error');
1230
+ stats.increment('tts.count', ['vendor:deepgramflux', 'accepted:no']);
1231
+ throw err;
1232
+ }
1233
+ };
1234
+
1143
1235
  const synthXai = async(logger, {
1144
1236
  credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming, disableTtsCache
1145
1237
  }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jambonz/speech-utils",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "TTS-related speech utilities for jambonz",
5
5
  "main": "index.js",
6
6
  "author": "Dave Horton",
package/test/synth.js CHANGED
@@ -442,6 +442,95 @@ test('Google TTS streaming tests (!JAMBONES_DISABLE_TTS_STREAMING)', async(t) =>
442
442
  });
443
443
  t.ok(result.filePath.includes('api_mode=tts'), 'options.apiMode=tts overrides HD voice default');
444
444
 
445
+ /* AudioConfig settings (speakingRate, pitch, volumeGainDb) */
446
+ const googleCreds = {
447
+ credentials: {
448
+ client_email: creds.client_email,
449
+ private_key: creds.private_key,
450
+ },
451
+ };
452
+
453
+ // Test 9: AudioConfig nested under options.audioConfig, as in google's API docs
454
+ result = await synthAudio(stats, {
455
+ vendor: 'google',
456
+ credentials: googleCreds,
457
+ language: 'en-US',
458
+ voice: 'en-US-Wavenet-D',
459
+ text: 'Testing nested audioConfig settings.',
460
+ options: { audioConfig: { speakingRate: 1.4, pitch: -2.5, volumeGainDb: 6 } },
461
+ disableTtsCache: true
462
+ });
463
+ t.ok(result.filePath.includes(',speaking_rate=1.4'), 'nested audioConfig sets speaking_rate');
464
+ t.ok(result.filePath.includes(',pitch=-2.5'), 'nested audioConfig sets pitch');
465
+ t.ok(result.filePath.includes(',volume_gain_db=6'), 'nested audioConfig sets volume_gain_db');
466
+
467
+ // Test 10: AudioConfig flat at the top level of options, in camelCase or snake_case
468
+ result = await synthAudio(stats, {
469
+ vendor: 'google',
470
+ credentials: googleCreds,
471
+ language: 'en-US',
472
+ voice: 'en-US-Wavenet-D',
473
+ text: 'Testing flat audioConfig settings.',
474
+ options: { speaking_rate: '0.8', volumeGainDb: 3 },
475
+ disableTtsCache: true
476
+ });
477
+ t.ok(result.filePath.includes(',speaking_rate=0.8'), 'flat snake_case speaking_rate is honored');
478
+ t.ok(result.filePath.includes(',volume_gain_db=3'), 'flat camelCase volumeGainDb is honored');
479
+ t.ok(!result.filePath.includes(',pitch='), 'unspecified audioConfig setting is omitted');
480
+
481
+ // Test 11: zero is a meaningful value for pitch and volumeGainDb, not an absent one
482
+ result = await synthAudio(stats, {
483
+ vendor: 'google',
484
+ credentials: googleCreds,
485
+ language: 'en-US',
486
+ voice: 'en-US-Wavenet-D',
487
+ text: 'Testing zero audioConfig settings.',
488
+ options: { audioConfig: { pitch: 0, volumeGainDb: 0 } },
489
+ disableTtsCache: true
490
+ });
491
+ t.ok(result.filePath.includes(',pitch=0'), 'pitch=0 is passed through rather than dropped');
492
+ t.ok(result.filePath.includes(',volume_gain_db=0'), 'volume_gain_db=0 is passed through rather than dropped');
493
+
494
+ // Test 12: non-numeric values are ignored, so they cannot corrupt the freeswitch param string
495
+ result = await synthAudio(stats, {
496
+ vendor: 'google',
497
+ credentials: googleCreds,
498
+ language: 'en-US',
499
+ voice: 'en-US-Wavenet-D',
500
+ text: 'Testing invalid audioConfig settings.',
501
+ options: { audioConfig: { speakingRate: 'fast,evil=1', pitch: null, volumeGainDb: '' } },
502
+ disableTtsCache: true
503
+ });
504
+ t.ok(!result.filePath.includes('speaking_rate'), 'non-numeric speakingRate is ignored');
505
+ t.ok(!result.filePath.includes('evil=1'), 'non-numeric value cannot inject extra params');
506
+ t.ok(!result.filePath.includes('pitch='), 'null pitch is ignored');
507
+ t.ok(!result.filePath.includes('volume_gain_db'), 'empty volumeGainDb is ignored');
508
+
509
+ // Test 13: no audioConfig supplied leaves the param string untouched
510
+ result = await synthAudio(stats, {
511
+ vendor: 'google',
512
+ credentials: googleCreds,
513
+ language: 'en-US',
514
+ voice: 'en-US-Wavenet-D',
515
+ text: 'Testing absent audioConfig settings.',
516
+ disableTtsCache: true
517
+ });
518
+ t.ok(!/speaking_rate|pitch=|volume_gain_db/.test(result.filePath),
519
+ 'no audioConfig params are added when none are supplied');
520
+
521
+ // Test 14: HD voice (api_mode=live) also carries speaking_rate, the one setting google streams support
522
+ result = await synthAudio(stats, {
523
+ vendor: 'google',
524
+ credentials: googleCreds,
525
+ language: 'en-US',
526
+ voice: 'en-US-Chirp3-HD-Charon',
527
+ text: 'Testing audioConfig on an HD voice.',
528
+ options: { audioConfig: { speakingRate: 1.25 } },
529
+ disableTtsCache: true
530
+ });
531
+ t.ok(result.filePath.includes('api_mode=live'), 'HD voice with audioConfig still uses api_mode=live');
532
+ t.ok(result.filePath.includes(',speaking_rate=1.25'), 'HD voice streaming path carries speaking_rate');
533
+
445
534
  } catch (err) {
446
535
  console.error(err);
447
536
  t.end(err);
@@ -526,6 +615,42 @@ test('Google TTS non-streaming tests (JAMBONES_DISABLE_TTS_STREAMING=true)', asy
526
615
  t.ok(!result.filePath.startsWith('say:'), 'Gemini TTS does NOT return streaming say: path when disabled');
527
616
  t.ok(result.filePath.endsWith('.mp3'), 'Gemini TTS returns mp3 file path');
528
617
 
618
+ const googleCreds = {
619
+ credentials: {
620
+ client_email: creds.client_email,
621
+ private_key: creds.private_key,
622
+ },
623
+ };
624
+
625
+ /**
626
+ * Test 4: AudioConfig settings are accepted by the synthesize API.
627
+ * Google rejects out-of-range values with a 400, so a successful render also confirms
628
+ * the settings reached the request rather than being silently dropped.
629
+ */
630
+ result = await synthAudio(stats, {
631
+ vendor: 'google',
632
+ credentials: googleCreds,
633
+ language: 'en-US',
634
+ voice: 'en-US-Wavenet-D',
635
+ text: 'This is a test of audioConfig with streaming disabled.',
636
+ options: { audioConfig: { speakingRate: 1.4, pitch: -2.5, volumeGainDb: 6 } },
637
+ disableTtsCache: true
638
+ });
639
+ t.ok(result.filePath.endsWith('.mp3'), 'standard voice renders mp3 with audioConfig settings applied');
640
+
641
+ /* Test 5: gemini voices ignore the AudioConfig settings rather than failing on them */
642
+ result = await synthAudio(stats, {
643
+ vendor: 'google',
644
+ credentials: googleCreds,
645
+ language: 'en-US',
646
+ voice: 'Kore',
647
+ model: geminiModel,
648
+ text: 'This is a test of audioConfig on Gemini TTS.',
649
+ options: { audioConfig: { speakingRate: 1.4, pitch: -2.5, volumeGainDb: 6 } },
650
+ disableTtsCache: true
651
+ });
652
+ t.ok(result.filePath.endsWith('.mp3'), 'gemini voice renders mp3 with audioConfig settings skipped');
653
+
529
654
  } catch (err) {
530
655
  console.error(err);
531
656
  t.end(err);
@@ -1118,6 +1243,34 @@ test('Deepgram speech synth tests', async(t) => {
1118
1243
  client.quit();
1119
1244
  })
1120
1245
 
1246
+ test('Deepgram Flux speech synth tests', async(t) => {
1247
+ const fn = require('..');
1248
+ const {synthAudio, client} = fn(opts, logger);
1249
+
1250
+ if (!process.env.DEEPGRAM_API_KEY) {
1251
+ t.pass('skipping Deepgram Flux speech synth tests since DEEPGRAM_API_KEY');
1252
+ return t.end();
1253
+ }
1254
+ const text = 'Hi there and welcome to jambones!';
1255
+ try {
1256
+ const opts = await synthAudio(stats, {
1257
+ vendor: 'deepgramflux',
1258
+ credentials: {
1259
+ api_key: process.env.DEEPGRAM_API_KEY
1260
+ },
1261
+ model: process.env.DEEPGRAM_FLUX_MODEL || 'flux-alexis-en',
1262
+ text,
1263
+ renderForCaching: true
1264
+ });
1265
+ t.ok(!opts.servedFromCache, `successfully synthesized deepgramflux audio to ${opts.filePath}`);
1266
+
1267
+ } catch (err) {
1268
+ console.error(JSON.stringify(err));
1269
+ t.end(err);
1270
+ }
1271
+ client.quit();
1272
+ });
1273
+
1121
1274
  test('xai speech synth tests', async(t) => {
1122
1275
  const fn = require('..');
1123
1276
  const {synthAudio, client} = fn(opts, logger);