@jambonz/speech-utils 0.2.26 → 0.2.30
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 +117 -55
- package/package.json +3 -3
- package/test/synth.js +377 -4
package/lib/synth-audio.js
CHANGED
|
@@ -204,7 +204,10 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
204
204
|
const startAt = process.hrtime();
|
|
205
205
|
switch (vendor) {
|
|
206
206
|
case 'google':
|
|
207
|
-
audioData = await synthGoogle(logger, {
|
|
207
|
+
audioData = await synthGoogle(logger, {
|
|
208
|
+
credentials, stats, language, voice, gender, key, text, model, options, instructions,
|
|
209
|
+
renderForCaching, disableTtsStreaming, disableTtsCache
|
|
210
|
+
});
|
|
208
211
|
break;
|
|
209
212
|
case 'aws':
|
|
210
213
|
case 'polly':
|
|
@@ -409,72 +412,123 @@ const synthPolly = async(createHash, retrieveHash, logger,
|
|
|
409
412
|
}
|
|
410
413
|
};
|
|
411
414
|
|
|
412
|
-
const synthGoogle = async(logger, {credentials, stats, language, voice, gender, text}) => {
|
|
413
|
-
const client = new ttsGoogle.TextToSpeechClient(credentials);
|
|
414
|
-
// If google custom voice cloning is used.
|
|
415
|
-
// At this time 31 Oct 2024, google node sdk has not support voice cloning yet.
|
|
416
|
-
if (typeof voice === 'object' && voice.voice_cloning_key) {
|
|
417
|
-
try {
|
|
418
|
-
const accessToken = await client.auth.getAccessToken();
|
|
419
|
-
const projectId = await client.getProjectId();
|
|
420
415
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
416
|
+
const synthGoogle = async(logger, {
|
|
417
|
+
credentials, stats, language, voice, gender, key, text, model, options, instructions,
|
|
418
|
+
renderForCaching, disableTtsStreaming, disableTtsCache
|
|
419
|
+
}) => {
|
|
420
|
+
const isGemini = !!model;
|
|
421
|
+
const isVoiceCloning = typeof voice === 'object' && voice.voice_cloning_key;
|
|
422
|
+
// HD voices have pattern like en-US-Chirp3-HD-Charon
|
|
423
|
+
const isHDVoice = typeof voice === 'string' && voice.includes('-HD-');
|
|
424
|
+
|
|
425
|
+
// Streaming support for Google TTS (Gemini, HD voices, and standard voices)
|
|
426
|
+
// Voice cloning does not support streaming
|
|
427
|
+
if (!isVoiceCloning && !JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
|
|
428
|
+
// Strip SSML tags for Gemini TTS (it doesn't support SSML)
|
|
429
|
+
let inputText = text;
|
|
430
|
+
if (isGemini && text.startsWith('<speak>')) {
|
|
431
|
+
inputText = text.replace(/<[^>]*>/g, '').trim();
|
|
432
|
+
logger.info('synthGoogle: Gemini TTS does not support SSML, stripped tags from input');
|
|
433
|
+
}
|
|
426
434
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
435
|
+
let params = '{';
|
|
436
|
+
params += `credentials=${Buffer.from(JSON.stringify(credentials.credentials)).toString('base64')}`;
|
|
437
|
+
params += `,playback_id=${key}`;
|
|
438
|
+
params += ',vendor=google';
|
|
439
|
+
params += `,voice=${voice}`;
|
|
440
|
+
params += `,language_code=${language || 'en-US'}`;
|
|
441
|
+
params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
|
|
442
|
+
// api_mode: tts (standard), live (HD voices), gemini (Gemini TTS)
|
|
443
|
+
const apiMode = options?.apiMode || (isGemini ? 'gemini' : (isHDVoice ? 'live' : 'tts'));
|
|
444
|
+
params += `,api_mode=${apiMode}`;
|
|
445
|
+
if (model) params += `,model_name=${model}`;
|
|
446
|
+
if (gender) params += `,gender=${gender}`;
|
|
447
|
+
// comma is used to separate parameters in freeswitch tts module
|
|
448
|
+
const prompt = options?.prompt || instructions;
|
|
449
|
+
if (prompt) params += `,prompt=${prompt.replace(/\n/g, ' ').replace(/,/g, ';')}`;
|
|
450
|
+
params += '}';
|
|
443
451
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
452
|
+
return {
|
|
453
|
+
filePath: `say:${params}${(isGemini ? inputText : text).replace(/\n/g, ' ')}`,
|
|
454
|
+
servedFromCache: false,
|
|
455
|
+
rtt: 0
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const client = new ttsGoogle.TextToSpeechClient(credentials);
|
|
460
|
+
|
|
461
|
+
// Build input based on voice type
|
|
462
|
+
let input;
|
|
463
|
+
if (isGemini) {
|
|
464
|
+
// Gemini TTS does not support SSML - strip tags if present
|
|
465
|
+
let inputText = text;
|
|
466
|
+
if (text.startsWith('<speak>')) {
|
|
467
|
+
inputText = text.replace(/<[^>]*>/g, '').trim();
|
|
468
|
+
logger.info('synthGoogle: Gemini TTS does not support SSML, stripped tags from input');
|
|
453
469
|
}
|
|
470
|
+
// Use instructions as prompt for Gemini TTS style control, options.prompt can override
|
|
471
|
+
const prompt = options?.prompt || instructions;
|
|
472
|
+
input = {
|
|
473
|
+
text: inputText,
|
|
474
|
+
...(prompt && { prompt })
|
|
475
|
+
};
|
|
476
|
+
} else {
|
|
477
|
+
input = text.startsWith('<speak>') ? { ssml: text } : { text };
|
|
454
478
|
}
|
|
455
479
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
480
|
+
// Build voice selection params based on voice type
|
|
481
|
+
let voiceParams;
|
|
482
|
+
if (isGemini) {
|
|
483
|
+
voiceParams = {
|
|
484
|
+
languageCode: language || 'en-US',
|
|
485
|
+
name: voice,
|
|
486
|
+
modelName: model
|
|
487
|
+
};
|
|
488
|
+
} else if (isVoiceCloning) {
|
|
489
|
+
voiceParams = {
|
|
490
|
+
languageCode: language,
|
|
491
|
+
voiceClone: {
|
|
492
|
+
voiceCloningKey: voice.voice_cloning_key
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
} else {
|
|
496
|
+
voiceParams = {
|
|
497
|
+
...(typeof voice === 'string' && { name: voice }),
|
|
498
|
+
...(typeof voice === 'object' && { customVoice: voice }),
|
|
460
499
|
languageCode: language,
|
|
461
500
|
ssmlGender: gender || 'SSML_VOICE_GENDER_UNSPECIFIED'
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Build audio config based on voice type
|
|
505
|
+
let audioConfig;
|
|
506
|
+
let extension;
|
|
507
|
+
let sampleRate;
|
|
508
|
+
if (isVoiceCloning) {
|
|
509
|
+
audioConfig = { audioEncoding: 'LINEAR16', sampleRateHertz: 24000 };
|
|
510
|
+
extension = 'wav';
|
|
511
|
+
sampleRate = 24000;
|
|
512
|
+
} else {
|
|
513
|
+
audioConfig = { audioEncoding: 'MP3' };
|
|
514
|
+
extension = 'mp3';
|
|
515
|
+
sampleRate = 8000;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const opts = { input, voice: voiceParams, audioConfig };
|
|
519
|
+
|
|
466
520
|
try {
|
|
467
|
-
|
|
521
|
+
logger.debug({ opts }, 'synthGoogle: request');
|
|
522
|
+
const [response] = await client.synthesizeSpeech(opts);
|
|
468
523
|
stats.increment('tts.count', ['vendor:google', 'accepted:yes']);
|
|
469
524
|
client.close();
|
|
470
525
|
return {
|
|
471
|
-
audioContent:
|
|
472
|
-
extension
|
|
473
|
-
sampleRate
|
|
526
|
+
audioContent: response.audioContent,
|
|
527
|
+
extension,
|
|
528
|
+
sampleRate
|
|
474
529
|
};
|
|
475
530
|
} catch (err) {
|
|
476
|
-
|
|
477
|
-
logger.info({err, opts}, 'synthAudio: Error synthesizing speech using google');
|
|
531
|
+
logger.info({ err, opts }, 'synthAudio: Error synthesizing speech using google');
|
|
478
532
|
stats.increment('tts.count', ['vendor:google', 'accepted:no']);
|
|
479
533
|
client && client.close();
|
|
480
534
|
throw err;
|
|
@@ -1329,7 +1383,7 @@ const synthCartesia = async(logger, {
|
|
|
1329
1383
|
try {
|
|
1330
1384
|
const client = new CartesiaClient({ apiKey: api_key });
|
|
1331
1385
|
const sampleRate = 48000;
|
|
1332
|
-
const
|
|
1386
|
+
const mp3Stream = await client.tts.bytes({
|
|
1333
1387
|
modelId: model_id,
|
|
1334
1388
|
transcript: text,
|
|
1335
1389
|
voice: {
|
|
@@ -1363,8 +1417,16 @@ const synthCartesia = async(logger, {
|
|
|
1363
1417
|
sampleRate
|
|
1364
1418
|
},
|
|
1365
1419
|
});
|
|
1420
|
+
|
|
1421
|
+
// bytes() returns a ReadableStream - collect all chunks
|
|
1422
|
+
const chunks = [];
|
|
1423
|
+
for await (const chunk of mp3Stream) {
|
|
1424
|
+
chunks.push(chunk);
|
|
1425
|
+
}
|
|
1426
|
+
const audioBuffer = Buffer.concat(chunks);
|
|
1427
|
+
|
|
1366
1428
|
return {
|
|
1367
|
-
audioContent:
|
|
1429
|
+
audioContent: audioBuffer,
|
|
1368
1430
|
extension: 'mp3',
|
|
1369
1431
|
sampleRate
|
|
1370
1432
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jambonz/speech-utils",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.30",
|
|
4
4
|
"description": "TTS-related speech utilities for jambonz",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"author": "Dave Horton",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"@aws-sdk/client-polly": "^3.496.0",
|
|
31
31
|
"@aws-sdk/client-sts": "^3.496.0",
|
|
32
32
|
"@cartesia/cartesia-js": "^2.2.7",
|
|
33
|
-
"@google-cloud/text-to-speech": "^
|
|
33
|
+
"@google-cloud/text-to-speech": "^6.4.0",
|
|
34
34
|
"@grpc/grpc-js": "^1.9.14",
|
|
35
35
|
"@jambonz/realtimedb-helpers": "^0.8.7",
|
|
36
36
|
"bent": "^7.3.12",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"undici": "^7.5.0"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"config": "^
|
|
46
|
+
"config": "^4.2.0",
|
|
47
47
|
"eslint": "^9.3.0",
|
|
48
48
|
"eslint-plugin-promise": "^6.2.0",
|
|
49
49
|
"husky": "^9.0.11",
|
package/test/synth.js
CHANGED
|
@@ -41,6 +41,7 @@ test('Google speech synth tests', async(t) => {
|
|
|
41
41
|
gender: 'FEMALE',
|
|
42
42
|
text: 'This is a test. This is only a test',
|
|
43
43
|
salt: 'foo.bar',
|
|
44
|
+
renderForCaching: true,
|
|
44
45
|
});
|
|
45
46
|
t.ok(!opts.servedFromCache, `successfully synthesized google audio to ${opts.filePath}`);
|
|
46
47
|
|
|
@@ -55,6 +56,7 @@ test('Google speech synth tests', async(t) => {
|
|
|
55
56
|
language: 'en-GB',
|
|
56
57
|
gender: 'FEMALE',
|
|
57
58
|
text: 'This is a test. This is only a test',
|
|
59
|
+
renderForCaching: true,
|
|
58
60
|
});
|
|
59
61
|
t.ok(opts.servedFromCache, `successfully retrieved cached google audio from ${opts.filePath}`);
|
|
60
62
|
|
|
@@ -78,6 +80,7 @@ test('Google speech synth tests', async(t) => {
|
|
|
78
80
|
language: 'en-GB',
|
|
79
81
|
gender: 'FEMALE',
|
|
80
82
|
text: 'This is a test. This is only a test',
|
|
83
|
+
renderForCaching: true,
|
|
81
84
|
});
|
|
82
85
|
t.ok(!opts.servedFromCache, `successfully synthesized google audio regardless of current cache to ${opts.filePath}`);
|
|
83
86
|
} catch (err) {
|
|
@@ -114,7 +117,8 @@ GCP_CUSTOM_VOICE_FILE nor GCP_CUSTOM_VOICE_JSON_KEY provided, GCP_CUSTOM_VOICE_M
|
|
|
114
117
|
voice: {
|
|
115
118
|
reportedUsage: 'REALTIME',
|
|
116
119
|
model: process.env.GCP_CUSTOM_VOICE_MODEL
|
|
117
|
-
}
|
|
120
|
+
},
|
|
121
|
+
renderForCaching: true,
|
|
118
122
|
});
|
|
119
123
|
t.ok(!opts.servedFromCache, `successfully synthesized google custom voice audio to ${opts.filePath}`);
|
|
120
124
|
} catch (err) {
|
|
@@ -132,7 +136,7 @@ test('Google speech voice cloning synth tests', async(t) => {
|
|
|
132
136
|
!process.env.GCP_CUSTOM_VOICE_JSON_KEY ||
|
|
133
137
|
!process.env.GCP_VOICE_CLONING_FILE &&
|
|
134
138
|
!process.env.GCP_VOICE_CLONING_JSON_KEY) {
|
|
135
|
-
t.pass(`skipping google speech synth tests since neither
|
|
139
|
+
t.pass(`skipping google speech synth tests since neither
|
|
136
140
|
GCP_CUSTOM_VOICE_FILE nor GCP_CUSTOM_VOICE_JSON_KEY provided,
|
|
137
141
|
GCP_VOICE_CLONING_FILE nor GCP_VOICE_CLONING_JSON_KEY is not provided`);
|
|
138
142
|
return t.end();
|
|
@@ -156,7 +160,8 @@ GCP_VOICE_CLONING_FILE nor GCP_VOICE_CLONING_JSON_KEY is not provided`);
|
|
|
156
160
|
text: 'This is a test. This is only a test. This is a test. This is only a test. This is a test. This is only a test',
|
|
157
161
|
voice: {
|
|
158
162
|
voice_cloning_key
|
|
159
|
-
}
|
|
163
|
+
},
|
|
164
|
+
renderForCaching: true,
|
|
160
165
|
});
|
|
161
166
|
t.ok(!opts.servedFromCache, `successfully synthesized google voice cloning audio to ${opts.filePath}`);
|
|
162
167
|
} catch (err) {
|
|
@@ -166,6 +171,374 @@ GCP_VOICE_CLONING_FILE nor GCP_VOICE_CLONING_JSON_KEY is not provided`);
|
|
|
166
171
|
client.quit();
|
|
167
172
|
});
|
|
168
173
|
|
|
174
|
+
test('Google Gemini TTS synth tests', async(t) => {
|
|
175
|
+
const fn = require('..');
|
|
176
|
+
const {synthAudio, client} = fn(opts, logger);
|
|
177
|
+
|
|
178
|
+
if (!process.env.GCP_FILE && !process.env.GCP_JSON_KEY) {
|
|
179
|
+
t.pass('skipping Google Gemini TTS synth tests since neither GCP_FILE nor GCP_JSON_KEY provided');
|
|
180
|
+
return t.end();
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const str = process.env.GCP_JSON_KEY || fs.readFileSync(process.env.GCP_FILE);
|
|
184
|
+
const creds = JSON.parse(str);
|
|
185
|
+
const geminiModel = process.env.GCP_GEMINI_TTS_MODEL || 'gemini-2.5-flash-tts';
|
|
186
|
+
|
|
187
|
+
// Test Gemini TTS with model and instructions (both required for Gemini)
|
|
188
|
+
let result = await synthAudio(stats, {
|
|
189
|
+
vendor: 'google',
|
|
190
|
+
credentials: {
|
|
191
|
+
credentials: {
|
|
192
|
+
client_email: creds.client_email,
|
|
193
|
+
private_key: creds.private_key,
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
language: 'en-US',
|
|
197
|
+
voice: 'Kore',
|
|
198
|
+
model: geminiModel,
|
|
199
|
+
text: 'Hello, this is a test of Google Gemini text to speech.',
|
|
200
|
+
instructions: 'Speak clearly and naturally.',
|
|
201
|
+
renderForCaching: true,
|
|
202
|
+
});
|
|
203
|
+
t.ok(!result.servedFromCache, `successfully synthesized Google Gemini TTS audio to ${result.filePath}`);
|
|
204
|
+
t.ok(result.filePath.endsWith('.mp3'), 'Gemini TTS audio file has correct extension');
|
|
205
|
+
|
|
206
|
+
// Test Gemini TTS with different voice and instructions
|
|
207
|
+
result = await synthAudio(stats, {
|
|
208
|
+
vendor: 'google',
|
|
209
|
+
credentials: {
|
|
210
|
+
credentials: {
|
|
211
|
+
client_email: creds.client_email,
|
|
212
|
+
private_key: creds.private_key,
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
language: 'en-US',
|
|
216
|
+
voice: 'Charon',
|
|
217
|
+
model: geminiModel,
|
|
218
|
+
text: 'Welcome to our service. How can I help you today?',
|
|
219
|
+
instructions: 'Speak in a warm, friendly and professional tone.',
|
|
220
|
+
renderForCaching: true,
|
|
221
|
+
});
|
|
222
|
+
t.ok(!result.servedFromCache, `successfully synthesized Gemini TTS with instructions to ${result.filePath}`);
|
|
223
|
+
|
|
224
|
+
// Test cache retrieval
|
|
225
|
+
result = await synthAudio(stats, {
|
|
226
|
+
vendor: 'google',
|
|
227
|
+
credentials: {
|
|
228
|
+
credentials: {
|
|
229
|
+
client_email: creds.client_email,
|
|
230
|
+
private_key: creds.private_key,
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
language: 'en-US',
|
|
234
|
+
voice: 'Kore',
|
|
235
|
+
model: geminiModel,
|
|
236
|
+
text: 'Hello, this is a test of Google Gemini text to speech.',
|
|
237
|
+
instructions: 'Speak clearly and naturally.',
|
|
238
|
+
renderForCaching: true,
|
|
239
|
+
});
|
|
240
|
+
t.ok(result.servedFromCache, `successfully retrieved Gemini TTS audio from cache ${result.filePath}`);
|
|
241
|
+
|
|
242
|
+
// Test SSML stripping (Gemini doesn't support SSML)
|
|
243
|
+
result = await synthAudio(stats, {
|
|
244
|
+
vendor: 'google',
|
|
245
|
+
credentials: {
|
|
246
|
+
credentials: {
|
|
247
|
+
client_email: creds.client_email,
|
|
248
|
+
private_key: creds.private_key,
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
language: 'en-US',
|
|
252
|
+
voice: 'Leda',
|
|
253
|
+
model: geminiModel,
|
|
254
|
+
text: '<speak>This SSML should be stripped for Gemini TTS.</speak>',
|
|
255
|
+
instructions: 'Speak naturally.',
|
|
256
|
+
disableTtsCache: true,
|
|
257
|
+
renderForCaching: true,
|
|
258
|
+
});
|
|
259
|
+
t.ok(!result.servedFromCache, `successfully synthesized Gemini TTS with SSML stripped to ${result.filePath}`);
|
|
260
|
+
|
|
261
|
+
} catch (err) {
|
|
262
|
+
console.error(err);
|
|
263
|
+
t.end(err);
|
|
264
|
+
}
|
|
265
|
+
client.quit();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('Google TTS streaming tests (!JAMBONES_DISABLE_TTS_STREAMING)', async(t) => {
|
|
269
|
+
// Ensure streaming is enabled (default behavior)
|
|
270
|
+
delete process.env.JAMBONES_DISABLE_TTS_STREAMING;
|
|
271
|
+
|
|
272
|
+
// Clear require cache to reload config with new env var
|
|
273
|
+
delete require.cache[require.resolve('../lib/config')];
|
|
274
|
+
delete require.cache[require.resolve('../lib/synth-audio')];
|
|
275
|
+
delete require.cache[require.resolve('..')];
|
|
276
|
+
|
|
277
|
+
const fn = require('..');
|
|
278
|
+
const {synthAudio, client} = fn(opts, logger);
|
|
279
|
+
|
|
280
|
+
if (!process.env.GCP_FILE && !process.env.GCP_JSON_KEY) {
|
|
281
|
+
t.pass('skipping Google TTS streaming tests since neither GCP_FILE nor GCP_JSON_KEY provided');
|
|
282
|
+
return t.end();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
const str = process.env.GCP_JSON_KEY || fs.readFileSync(process.env.GCP_FILE);
|
|
287
|
+
const creds = JSON.parse(str);
|
|
288
|
+
const geminiModel = process.env.GCP_GEMINI_TTS_MODEL || 'gemini-2.5-flash-tts';
|
|
289
|
+
|
|
290
|
+
// Test 1: Standard voice streaming (use_live_api=0)
|
|
291
|
+
let result = await synthAudio(stats, {
|
|
292
|
+
vendor: 'google',
|
|
293
|
+
credentials: {
|
|
294
|
+
credentials: {
|
|
295
|
+
client_email: creds.client_email,
|
|
296
|
+
private_key: creds.private_key,
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
language: 'en-US',
|
|
300
|
+
voice: 'en-US-Wavenet-D',
|
|
301
|
+
gender: 'MALE',
|
|
302
|
+
text: 'This is a test of standard voice streaming.',
|
|
303
|
+
disableTtsCache: true
|
|
304
|
+
});
|
|
305
|
+
t.ok(result.filePath.startsWith('say:'), 'Standard voice returns streaming say: path');
|
|
306
|
+
t.ok(result.filePath.includes('vendor=google'), 'Standard voice streaming path contains vendor=google');
|
|
307
|
+
t.ok(result.filePath.includes('api_mode=tts'), 'Standard voice uses api_mode=tts');
|
|
308
|
+
t.ok(result.filePath.includes('voice=en-US-Wavenet-D'), 'Standard voice streaming path contains voice');
|
|
309
|
+
// Verify credentials are base64 encoded (no raw JSON braces that would break FreeSWitch parsing)
|
|
310
|
+
t.ok(result.filePath.includes('credentials='), 'Standard voice streaming path contains credentials');
|
|
311
|
+
t.ok(!result.filePath.includes('credentials={'), 'Credentials are not raw JSON (base64 encoded)');
|
|
312
|
+
|
|
313
|
+
// Test 2: HD voice streaming (api_mode=live)
|
|
314
|
+
result = await synthAudio(stats, {
|
|
315
|
+
vendor: 'google',
|
|
316
|
+
credentials: {
|
|
317
|
+
credentials: {
|
|
318
|
+
client_email: creds.client_email,
|
|
319
|
+
private_key: creds.private_key,
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
language: 'en-US',
|
|
323
|
+
voice: 'en-US-Chirp3-HD-Charon',
|
|
324
|
+
text: 'This is a test of HD voice streaming.',
|
|
325
|
+
disableTtsCache: true
|
|
326
|
+
});
|
|
327
|
+
t.ok(result.filePath.startsWith('say:'), 'HD voice returns streaming say: path');
|
|
328
|
+
t.ok(result.filePath.includes('vendor=google'), 'HD voice streaming path contains vendor=google');
|
|
329
|
+
t.ok(result.filePath.includes('api_mode=live'), 'HD voice uses api_mode=live');
|
|
330
|
+
t.ok(result.filePath.includes('voice=en-US-Chirp3-HD-Charon'), 'HD voice streaming path contains voice');
|
|
331
|
+
|
|
332
|
+
// Test 3: Gemini TTS streaming (api_mode=gemini)
|
|
333
|
+
result = await synthAudio(stats, {
|
|
334
|
+
vendor: 'google',
|
|
335
|
+
credentials: {
|
|
336
|
+
credentials: {
|
|
337
|
+
client_email: creds.client_email,
|
|
338
|
+
private_key: creds.private_key,
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
language: 'en-US',
|
|
342
|
+
voice: 'Kore',
|
|
343
|
+
model: geminiModel,
|
|
344
|
+
text: 'This is a test of Gemini TTS streaming.',
|
|
345
|
+
instructions: 'Speak naturally.',
|
|
346
|
+
disableTtsCache: true
|
|
347
|
+
});
|
|
348
|
+
t.ok(result.filePath.startsWith('say:'), 'Gemini TTS returns streaming say: path');
|
|
349
|
+
t.ok(result.filePath.includes('vendor=google'), 'Gemini TTS streaming path contains vendor=google');
|
|
350
|
+
t.ok(result.filePath.includes('api_mode=gemini'), 'Gemini TTS uses api_mode=gemini');
|
|
351
|
+
t.ok(result.filePath.includes(`model_name=${geminiModel}`), 'Gemini TTS streaming path contains model_name');
|
|
352
|
+
t.ok(result.filePath.includes('prompt=Speak naturally.'), 'Gemini TTS streaming path contains prompt');
|
|
353
|
+
|
|
354
|
+
// Test 4: Gemini TTS with SSML stripping in streaming mode
|
|
355
|
+
result = await synthAudio(stats, {
|
|
356
|
+
vendor: 'google',
|
|
357
|
+
credentials: {
|
|
358
|
+
credentials: {
|
|
359
|
+
client_email: creds.client_email,
|
|
360
|
+
private_key: creds.private_key,
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
language: 'en-US',
|
|
364
|
+
voice: 'Leda',
|
|
365
|
+
model: geminiModel,
|
|
366
|
+
text: '<speak>This SSML should be stripped.</speak>',
|
|
367
|
+
instructions: 'Speak naturally.',
|
|
368
|
+
disableTtsCache: true
|
|
369
|
+
});
|
|
370
|
+
t.ok(result.filePath.startsWith('say:'), 'Gemini TTS with SSML returns streaming say: path');
|
|
371
|
+
t.ok(!result.filePath.includes('<speak>'), 'SSML tags are stripped from streaming path');
|
|
372
|
+
t.ok(result.filePath.includes('This SSML should be stripped.'), 'Text content is preserved after SSML stripping');
|
|
373
|
+
|
|
374
|
+
// Test 5: Gemini TTS with prompt containing special characters
|
|
375
|
+
result = await synthAudio(stats, {
|
|
376
|
+
vendor: 'google',
|
|
377
|
+
credentials: {
|
|
378
|
+
credentials: {
|
|
379
|
+
client_email: creds.client_email,
|
|
380
|
+
private_key: creds.private_key,
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
language: 'en-US',
|
|
384
|
+
voice: 'Kore',
|
|
385
|
+
model: geminiModel,
|
|
386
|
+
text: 'Testing special characters in prompt.',
|
|
387
|
+
options: { prompt: 'Speak in a warm, friendly tone' },
|
|
388
|
+
disableTtsCache: true
|
|
389
|
+
});
|
|
390
|
+
t.ok(result.filePath.startsWith('say:'), 'Gemini TTS with special chars returns streaming say: path');
|
|
391
|
+
// Commas in prompt should be replaced with semicolons
|
|
392
|
+
t.ok(result.filePath.includes('prompt=Speak in a warm; friendly tone'), 'Commas in prompt are escaped to semicolons');
|
|
393
|
+
|
|
394
|
+
// Test 6: options.apiMode override (force live on standard voice)
|
|
395
|
+
result = await synthAudio(stats, {
|
|
396
|
+
vendor: 'google',
|
|
397
|
+
credentials: {
|
|
398
|
+
credentials: {
|
|
399
|
+
client_email: creds.client_email,
|
|
400
|
+
private_key: creds.private_key,
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
language: 'en-US',
|
|
404
|
+
voice: 'en-US-Wavenet-D',
|
|
405
|
+
text: 'Testing apiMode option override to live.',
|
|
406
|
+
options: { apiMode: 'live' },
|
|
407
|
+
disableTtsCache: true
|
|
408
|
+
});
|
|
409
|
+
t.ok(result.filePath.includes('api_mode=live'), 'options.apiMode=live overrides default for standard voice');
|
|
410
|
+
|
|
411
|
+
// Test 7: options.apiMode override (force gemini without model)
|
|
412
|
+
result = await synthAudio(stats, {
|
|
413
|
+
vendor: 'google',
|
|
414
|
+
credentials: {
|
|
415
|
+
credentials: {
|
|
416
|
+
client_email: creds.client_email,
|
|
417
|
+
private_key: creds.private_key,
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
language: 'en-US',
|
|
421
|
+
voice: 'Kore',
|
|
422
|
+
text: 'Testing apiMode option override to gemini.',
|
|
423
|
+
options: { apiMode: 'gemini' },
|
|
424
|
+
disableTtsCache: true
|
|
425
|
+
});
|
|
426
|
+
t.ok(result.filePath.includes('api_mode=gemini'), 'options.apiMode=gemini overrides default');
|
|
427
|
+
|
|
428
|
+
// Test 8: options.apiMode override (force tts on HD voice)
|
|
429
|
+
result = await synthAudio(stats, {
|
|
430
|
+
vendor: 'google',
|
|
431
|
+
credentials: {
|
|
432
|
+
credentials: {
|
|
433
|
+
client_email: creds.client_email,
|
|
434
|
+
private_key: creds.private_key,
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
language: 'en-US',
|
|
438
|
+
voice: 'en-US-Chirp3-HD-Charon',
|
|
439
|
+
text: 'Testing apiMode option override to tts on HD voice.',
|
|
440
|
+
options: { apiMode: 'tts' },
|
|
441
|
+
disableTtsCache: true
|
|
442
|
+
});
|
|
443
|
+
t.ok(result.filePath.includes('api_mode=tts'), 'options.apiMode=tts overrides HD voice default');
|
|
444
|
+
|
|
445
|
+
} catch (err) {
|
|
446
|
+
console.error(err);
|
|
447
|
+
t.end(err);
|
|
448
|
+
}
|
|
449
|
+
client.quit();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test('Google TTS non-streaming tests (JAMBONES_DISABLE_TTS_STREAMING=true)', async(t) => {
|
|
453
|
+
// Enable streaming disable flag
|
|
454
|
+
process.env.JAMBONES_DISABLE_TTS_STREAMING = 'true';
|
|
455
|
+
|
|
456
|
+
// Clear require cache to reload config with new env var
|
|
457
|
+
delete require.cache[require.resolve('../lib/config')];
|
|
458
|
+
delete require.cache[require.resolve('../lib/synth-audio')];
|
|
459
|
+
delete require.cache[require.resolve('..')];
|
|
460
|
+
|
|
461
|
+
const fn = require('..');
|
|
462
|
+
const {synthAudio, client} = fn(opts, logger);
|
|
463
|
+
|
|
464
|
+
if (!process.env.GCP_FILE && !process.env.GCP_JSON_KEY) {
|
|
465
|
+
t.pass('skipping Google TTS non-streaming tests since neither GCP_FILE nor GCP_JSON_KEY provided');
|
|
466
|
+
delete process.env.JAMBONES_DISABLE_TTS_STREAMING;
|
|
467
|
+
return t.end();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
const str = process.env.GCP_JSON_KEY || fs.readFileSync(process.env.GCP_FILE);
|
|
472
|
+
const creds = JSON.parse(str);
|
|
473
|
+
const geminiModel = process.env.GCP_GEMINI_TTS_MODEL || 'gemini-2.5-flash-tts';
|
|
474
|
+
|
|
475
|
+
// Test 1: Standard voice falls back to non-streaming API
|
|
476
|
+
let result = await synthAudio(stats, {
|
|
477
|
+
vendor: 'google',
|
|
478
|
+
credentials: {
|
|
479
|
+
credentials: {
|
|
480
|
+
client_email: creds.client_email,
|
|
481
|
+
private_key: creds.private_key,
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
language: 'en-US',
|
|
485
|
+
voice: 'en-US-Wavenet-D',
|
|
486
|
+
gender: 'MALE',
|
|
487
|
+
text: 'This is a test with streaming disabled.',
|
|
488
|
+
disableTtsCache: true
|
|
489
|
+
});
|
|
490
|
+
t.ok(!result.filePath.startsWith('say:'), 'Standard voice does NOT return streaming say: path when disabled');
|
|
491
|
+
t.ok(result.filePath.endsWith('.mp3'), 'Standard voice returns mp3 file path');
|
|
492
|
+
|
|
493
|
+
// Test 2: HD voice falls back to non-streaming API
|
|
494
|
+
result = await synthAudio(stats, {
|
|
495
|
+
vendor: 'google',
|
|
496
|
+
credentials: {
|
|
497
|
+
credentials: {
|
|
498
|
+
client_email: creds.client_email,
|
|
499
|
+
private_key: creds.private_key,
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
language: 'en-US',
|
|
503
|
+
voice: 'en-US-Chirp3-HD-Charon',
|
|
504
|
+
text: 'This is a test of HD voice with streaming disabled.',
|
|
505
|
+
disableTtsCache: true
|
|
506
|
+
});
|
|
507
|
+
t.ok(!result.filePath.startsWith('say:'), 'HD voice does NOT return streaming say: path when disabled');
|
|
508
|
+
t.ok(result.filePath.endsWith('.mp3'), 'HD voice returns mp3 file path');
|
|
509
|
+
|
|
510
|
+
// Test 3: Gemini TTS falls back to non-streaming API
|
|
511
|
+
result = await synthAudio(stats, {
|
|
512
|
+
vendor: 'google',
|
|
513
|
+
credentials: {
|
|
514
|
+
credentials: {
|
|
515
|
+
client_email: creds.client_email,
|
|
516
|
+
private_key: creds.private_key,
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
language: 'en-US',
|
|
520
|
+
voice: 'Kore',
|
|
521
|
+
model: geminiModel,
|
|
522
|
+
text: 'This is a test of Gemini TTS with streaming disabled.',
|
|
523
|
+
instructions: 'Speak naturally.',
|
|
524
|
+
disableTtsCache: true
|
|
525
|
+
});
|
|
526
|
+
t.ok(!result.filePath.startsWith('say:'), 'Gemini TTS does NOT return streaming say: path when disabled');
|
|
527
|
+
t.ok(result.filePath.endsWith('.mp3'), 'Gemini TTS returns mp3 file path');
|
|
528
|
+
|
|
529
|
+
} catch (err) {
|
|
530
|
+
console.error(err);
|
|
531
|
+
t.end(err);
|
|
532
|
+
} finally {
|
|
533
|
+
// Clean up: restore default behavior
|
|
534
|
+
delete process.env.JAMBONES_DISABLE_TTS_STREAMING;
|
|
535
|
+
delete require.cache[require.resolve('../lib/config')];
|
|
536
|
+
delete require.cache[require.resolve('../lib/synth-audio')];
|
|
537
|
+
delete require.cache[require.resolve('..')];
|
|
538
|
+
}
|
|
539
|
+
client.quit();
|
|
540
|
+
});
|
|
541
|
+
|
|
169
542
|
test('AWS speech synth tests', async(t) => {
|
|
170
543
|
const fn = require('..');
|
|
171
544
|
const {synthAudio, client} = fn(opts, logger);
|
|
@@ -716,7 +1089,7 @@ test('Cartesia speech synth tests', async(t) => {
|
|
|
716
1089
|
text,
|
|
717
1090
|
renderForCaching: true
|
|
718
1091
|
});
|
|
719
|
-
t.ok(!opts.servedFromCache, `successfully
|
|
1092
|
+
t.ok(!opts.servedFromCache, `successfully cartesia eleven audio to ${opts.filePath}`);
|
|
720
1093
|
|
|
721
1094
|
} catch (err) {
|
|
722
1095
|
console.error(JSON.stringify(err));
|