@jambonz/speech-utils 0.2.27 → 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.
@@ -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, {credentials, stats, language, voice, gender, key, text});
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
- const post = bent('https://texttospeech.googleapis.com', 'POST', 'json', {
422
- 'Authorization': `Bearer ${accessToken}`,
423
- 'x-goog-user-project': projectId,
424
- 'Content-Type': 'application/json; charset=utf-8'
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
- const payload = {
428
- input: {
429
- text
430
- },
431
- voice: {
432
- language_code: language,
433
- voice_clone: {
434
- voice_cloning_key: voice.voice_cloning_key
435
- }
436
- },
437
- audioConfig: {
438
- // Cloning voice at this time still in v1 beta version, and it support LINEAR16 in Wav format, 24.000Hz
439
- audioEncoding: 'LINEAR16',
440
- sample_rate_hertz: 24000
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
- const wav = await post('/v1beta1/text:synthesize', payload);
445
- return {
446
- audioContent: Buffer.from(wav.audioContent, 'base64'),
447
- extension: 'wav',
448
- sampleRate: 24000
449
- };
450
- } catch (err) {
451
- logger.info({err: await err.text()}, 'synthGoogle returned error');
452
- throw err;
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
- const opts = {
457
- voice: {
458
- ...(typeof voice === 'string' && {name: voice}),
459
- ...(typeof voice === 'object' && {customVoice: voice}),
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
- audioConfig: {audioEncoding: 'MP3'}
464
- };
465
- Object.assign(opts, {input: text.startsWith('<speak>') ? {ssml: text} : {text}});
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
- const responses = await client.synthesizeSpeech(opts);
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: responses[0].audioContent,
472
- extension: 'mp3',
473
- sampleRate: 8000
526
+ audioContent: response.audioContent,
527
+ extension,
528
+ sampleRate
474
529
  };
475
530
  } catch (err) {
476
- console.error(err);
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jambonz/speech-utils",
3
- "version": "0.2.27",
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": "^5.5.0",
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": "^3.3.11",
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);