@jambonz/speech-utils 1.0.9 → 1.0.11
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.
- package/lib/synth-audio.js +104 -1
- package/package.json +1 -1
- package/test/synth.js +155 -0
package/lib/synth-audio.js
CHANGED
|
@@ -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', 'deepgramflux', 'rimelabs', 'cartesia', '
|
|
83
|
+
'whisper', 'deepgram', 'deepgramflux', 'rimelabs', 'cartesia', 'nineninesix', 'inworld', 'resemble',
|
|
84
|
+
'murf', 'xai']
|
|
84
85
|
.includes(vendor) ||
|
|
85
86
|
vendor.startsWith('custom'),
|
|
86
87
|
`synthAudio supported vendors are google, aws, microsoft, nvidia and wellsaid ..etc, not ${vendor}`);
|
|
@@ -135,6 +136,10 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
135
136
|
} else if ('cartesia' === vendor) {
|
|
136
137
|
assert.ok(credentials.api_key, 'synthAudio requires api_key when cartesia is used');
|
|
137
138
|
assert.ok(credentials.model_id, 'synthAudio requires model_id when cartesia is used');
|
|
139
|
+
} else if ('nineninesix' === vendor) {
|
|
140
|
+
assert.ok(voice, 'synthAudio requires voice when nineninesix is used');
|
|
141
|
+
assert.ok(credentials.api_key, 'synthAudio requires api_key when nineninesix is used');
|
|
142
|
+
assert.ok(credentials.model_id, 'synthAudio requires model_id when nineninesix is used');
|
|
138
143
|
} else if ('murf' === vendor) {
|
|
139
144
|
assert.ok(voice, 'synthAudio requires voice when murf is used');
|
|
140
145
|
assert.ok(credentials.api_key, 'synthAudio requires api_key when murf is used');
|
|
@@ -212,6 +217,11 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
212
217
|
credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
|
|
213
218
|
disableTtsCache});
|
|
214
219
|
break;
|
|
220
|
+
case 'nineninesix':
|
|
221
|
+
audioData = await synthNineninesix(logger, {
|
|
222
|
+
credentials, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
|
|
223
|
+
disableTtsCache});
|
|
224
|
+
break;
|
|
215
225
|
case 'inworld':
|
|
216
226
|
audioData = await synthInworld(logger, {
|
|
217
227
|
credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
|
|
@@ -383,6 +393,32 @@ const synthPolly = async(createHash, retrieveHash, logger,
|
|
|
383
393
|
}
|
|
384
394
|
};
|
|
385
395
|
|
|
396
|
+
/* google AudioConfig settings we support, as [google camelCase name, freeswitch param name] */
|
|
397
|
+
const GOOGLE_AUDIO_SETTINGS = [
|
|
398
|
+
['speakingRate', 'speaking_rate'],
|
|
399
|
+
['pitch', 'pitch'],
|
|
400
|
+
['volumeGainDb', 'volume_gain_db']
|
|
401
|
+
];
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Extract google AudioConfig settings from the synthesizer options. They may be supplied
|
|
405
|
+
* nested under an audioConfig property (mirroring google's AudioConfig object) or flat at
|
|
406
|
+
* the top level, and either google's camelCase or snake_case names are accepted.
|
|
407
|
+
* @see https://cloud.google.com/text-to-speech/docs/reference/rest/v1/text/synthesize#AudioConfig
|
|
408
|
+
* @returns object keyed by google's camelCase names, holding only valid numeric settings
|
|
409
|
+
*/
|
|
410
|
+
const googleAudioConfig = (options) => {
|
|
411
|
+
const provided = {...options, ...(options?.audioConfig || {})};
|
|
412
|
+
const audioConfig = {};
|
|
413
|
+
for (const [name, snakeName] of GOOGLE_AUDIO_SETTINGS) {
|
|
414
|
+
const value = provided[name] ?? provided[snakeName];
|
|
415
|
+
/* note: 0 is meaningful for pitch and volumeGainDb, so check for absence explicitly */
|
|
416
|
+
if (value === undefined || value === null || value === '') continue;
|
|
417
|
+
const num = Number(value);
|
|
418
|
+
if (Number.isFinite(num)) audioConfig[name] = num;
|
|
419
|
+
}
|
|
420
|
+
return audioConfig;
|
|
421
|
+
};
|
|
386
422
|
|
|
387
423
|
const synthGoogle = async(logger, {
|
|
388
424
|
credentials, stats, language, voice, gender, key, text, model, options, instructions,
|
|
@@ -418,6 +454,15 @@ const synthGoogle = async(logger, {
|
|
|
418
454
|
// comma is used to separate parameters in freeswitch tts module
|
|
419
455
|
const prompt = options?.prompt || instructions;
|
|
420
456
|
if (prompt) params += `,prompt=${prompt.replace(/\n/g, ' ').replace(/,/g, ';')}`;
|
|
457
|
+
/**
|
|
458
|
+
* AudioConfig settings. Note google only honors these in some api modes:
|
|
459
|
+
* tts applies all of them, live (HD voices) applies speakingRate only,
|
|
460
|
+
* and gemini ignores them entirely (use prompt instead for style control).
|
|
461
|
+
*/
|
|
462
|
+
const audioSettings = googleAudioConfig(options);
|
|
463
|
+
for (const [name, snakeName] of GOOGLE_AUDIO_SETTINGS) {
|
|
464
|
+
if (name in audioSettings) params += `,${snakeName}=${audioSettings[name]}`;
|
|
465
|
+
}
|
|
421
466
|
params += '}';
|
|
422
467
|
|
|
423
468
|
return {
|
|
@@ -486,6 +531,9 @@ const synthGoogle = async(logger, {
|
|
|
486
531
|
sampleRate = 8000;
|
|
487
532
|
}
|
|
488
533
|
|
|
534
|
+
/* gemini voices do not support the AudioConfig settings; they use prompt for style control */
|
|
535
|
+
if (!isGemini) Object.assign(audioConfig, googleAudioConfig(options));
|
|
536
|
+
|
|
489
537
|
const opts = { input, voice: voiceParams, audioConfig };
|
|
490
538
|
|
|
491
539
|
try {
|
|
@@ -1385,6 +1433,61 @@ const synthCartesia = async(logger, {
|
|
|
1385
1433
|
|
|
1386
1434
|
};
|
|
1387
1435
|
|
|
1436
|
+
/* nineninesix.ai — a Cartesia-compatible API, but only raw/wav come back
|
|
1437
|
+
(mp3 is rejected), so the cache render asks for wav rather than mp3. */
|
|
1438
|
+
const synthNineninesix = async(logger, {
|
|
1439
|
+
credentials, stats, voice, language, key, text, renderForCaching, disableTtsStreaming, disableTtsCache
|
|
1440
|
+
}) => {
|
|
1441
|
+
const {api_key, model_id} = credentials;
|
|
1442
|
+
|
|
1443
|
+
/* default to using the streaming interface, unless disabled by env var OR we want just a cache file */
|
|
1444
|
+
if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
|
|
1445
|
+
let params = '{';
|
|
1446
|
+
params += `api_key=${api_key}`;
|
|
1447
|
+
params += `,playback_id=${key}`;
|
|
1448
|
+
params += `,model_id=${model_id}`;
|
|
1449
|
+
params += ',vendor=nineninesix';
|
|
1450
|
+
params += `,voice=${voice}`;
|
|
1451
|
+
params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
|
|
1452
|
+
if (language) params += `,language=${language}`;
|
|
1453
|
+
params += '}';
|
|
1454
|
+
|
|
1455
|
+
return {
|
|
1456
|
+
filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
|
|
1457
|
+
servedFromCache: false,
|
|
1458
|
+
rtt: 0
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
try {
|
|
1463
|
+
const sampleRate = 8000;
|
|
1464
|
+
const post = bent('https://api.nineninesix.ai', 'POST', 'buffer', {
|
|
1465
|
+
'Authorization': `Bearer ${api_key}`,
|
|
1466
|
+
'Content-Type': 'application/json'
|
|
1467
|
+
});
|
|
1468
|
+
const audioContent = await post('/tts/bytes', {
|
|
1469
|
+
model_id,
|
|
1470
|
+
transcript: text,
|
|
1471
|
+
voice: {mode: 'id', id: voice},
|
|
1472
|
+
...(language && {language}),
|
|
1473
|
+
output_format: {
|
|
1474
|
+
container: 'wav',
|
|
1475
|
+
encoding: 'pcm_s16le',
|
|
1476
|
+
sample_rate: sampleRate
|
|
1477
|
+
}
|
|
1478
|
+
});
|
|
1479
|
+
return {
|
|
1480
|
+
audioContent,
|
|
1481
|
+
extension: 'wav',
|
|
1482
|
+
sampleRate
|
|
1483
|
+
};
|
|
1484
|
+
} catch (err) {
|
|
1485
|
+
logger.info({err}, 'synth nineninesix returned error');
|
|
1486
|
+
stats.increment('tts.count', ['vendor:nineninesix', 'accepted:no']);
|
|
1487
|
+
throw err;
|
|
1488
|
+
}
|
|
1489
|
+
};
|
|
1490
|
+
|
|
1388
1491
|
const synthResemble = async(logger, {
|
|
1389
1492
|
credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming, disableTtsCache
|
|
1390
1493
|
}) => {
|
package/package.json
CHANGED
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);
|
|
@@ -933,6 +1058,36 @@ test('Cartesia speech synth tests', async(t) => {
|
|
|
933
1058
|
client.quit();
|
|
934
1059
|
});
|
|
935
1060
|
|
|
1061
|
+
test('nineninesix speech synth tests', async(t) => {
|
|
1062
|
+
const fn = require('..');
|
|
1063
|
+
const {synthAudio, client} = fn(opts, logger);
|
|
1064
|
+
|
|
1065
|
+
if (!process.env.NINENINESIX_API_KEY) {
|
|
1066
|
+
t.pass('skipping nineninesix speech synth tests since NINENINESIX_API_KEY is not provided');
|
|
1067
|
+
return t.end();
|
|
1068
|
+
}
|
|
1069
|
+
const text = 'Hi there and welcome to jambones! ' + Date.now();
|
|
1070
|
+
try {
|
|
1071
|
+
const opts = await synthAudio(stats, {
|
|
1072
|
+
vendor: 'nineninesix',
|
|
1073
|
+
credentials: {
|
|
1074
|
+
api_key: process.env.NINENINESIX_API_KEY,
|
|
1075
|
+
model_id: 'gepard-1.0'
|
|
1076
|
+
},
|
|
1077
|
+
language: 'en',
|
|
1078
|
+
voice: '3ad7a827-7fd1-4954-bf35-47d4cc33d9ed',
|
|
1079
|
+
text,
|
|
1080
|
+
renderForCaching: true
|
|
1081
|
+
});
|
|
1082
|
+
t.ok(!opts.servedFromCache, `successfully synthed nineninesix audio to ${opts.filePath}`);
|
|
1083
|
+
|
|
1084
|
+
} catch (err) {
|
|
1085
|
+
console.error(JSON.stringify(err));
|
|
1086
|
+
t.end(err);
|
|
1087
|
+
}
|
|
1088
|
+
client.quit();
|
|
1089
|
+
});
|
|
1090
|
+
|
|
936
1091
|
test('inworld speech synth', async(t) => {
|
|
937
1092
|
const fn = require('..');
|
|
938
1093
|
const {synthAudio, client} = fn(opts, logger);
|