@jambonz/speech-utils 0.2.27 → 1.0.1
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/.github/workflows/ci.yml +1 -8
- package/index.js +1 -1
- package/lib/get-tts-voices.js +1 -20
- package/lib/synth-audio.js +110 -98
- package/lib/utils.js +1 -7
- package/package.json +3 -4
- package/test/docker_start.js +1 -1
- package/test/docker_stop.js +1 -1
- package/test/index.js +1 -1
- package/test/list-voices.js +0 -65
- package/test/synth.js +376 -43
- package/lib/get-ibm-access-token.js +0 -48
- package/test/ibm.js +0 -78
package/.github/workflows/ci.yml
CHANGED
|
@@ -13,11 +13,6 @@ jobs:
|
|
|
13
13
|
with:
|
|
14
14
|
node-version: '20'
|
|
15
15
|
- run: npm install
|
|
16
|
-
- name: Install Docker Compose
|
|
17
|
-
run: |
|
|
18
|
-
sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
|
19
|
-
sudo chmod +x /usr/local/bin/docker-compose
|
|
20
|
-
docker-compose --version
|
|
21
16
|
- run: npm run jslint
|
|
22
17
|
- run: sudo apt update && sudo apt install -y squid
|
|
23
18
|
- run: sudo cp test/squid.conf /etc/squid/squid.conf
|
|
@@ -28,9 +23,7 @@ jobs:
|
|
|
28
23
|
AWS_REGION: ${{ secrets.AWS_REGION }}
|
|
29
24
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
|
30
25
|
GCP_JSON_KEY: ${{ secrets.GCP_JSON_KEY }}
|
|
31
|
-
|
|
32
|
-
IBM_TTS_API_KEY: ${{ secrets.IBM_TTS_API_KEY }}
|
|
33
|
-
IBM_TTS_REGION: ${{ secrets.IBM_TTS_REGION }}
|
|
26
|
+
|
|
34
27
|
MICROSOFT_API_KEY: ${{ secrets.MICROSOFT_API_KEY }}
|
|
35
28
|
MICROSOFT_REGION: ${{ secrets.MICROSOFT_REGION }}
|
|
36
29
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
package/index.js
CHANGED
|
@@ -16,7 +16,7 @@ module.exports = (opts, logger) => {
|
|
|
16
16
|
synthAudio: require('./lib/synth-audio').bind(null, client, createHash, retrieveHash, logger),
|
|
17
17
|
getVerbioAccessToken: require('./lib/get-verbio-token').bind(null, client, logger),
|
|
18
18
|
getNuanceAccessToken: require('./lib/get-nuance-access-token').bind(null, client, logger),
|
|
19
|
-
|
|
19
|
+
|
|
20
20
|
getAwsAuthToken: require('./lib/get-aws-sts-token').bind(null, logger, createHash, retrieveHash),
|
|
21
21
|
getTtsVoices: require('./lib/get-tts-voices').bind(null, client, createHash, retrieveHash, logger),
|
|
22
22
|
};
|
package/lib/get-tts-voices.js
CHANGED
|
@@ -3,8 +3,6 @@ const {noopLogger, createNuanceClient, createKryptonClient} = require('./utils')
|
|
|
3
3
|
const getNuanceAccessToken = require('./get-nuance-access-token');
|
|
4
4
|
const getVerbioAccessToken = require('./get-verbio-token');
|
|
5
5
|
const {GetVoicesRequest, Voice} = require('../stubs/nuance/synthesizer_pb');
|
|
6
|
-
const TextToSpeechV1 = require('ibm-watson/text-to-speech/v1');
|
|
7
|
-
const { IamAuthenticator } = require('ibm-watson/auth');
|
|
8
6
|
const ttsGoogle = require('@google-cloud/text-to-speech');
|
|
9
7
|
const { PollyClient, DescribeVoicesCommand } = require('@aws-sdk/client-polly');
|
|
10
8
|
const getAwsAuthToken = require('./get-aws-sts-token');
|
|
@@ -12,21 +10,6 @@ const {Pool} = require('undici');
|
|
|
12
10
|
const { HTTP_TIMEOUT } = require('./config');
|
|
13
11
|
const verbioVoicePool = new Pool('https://us.rest.speechcenter.verbio.com');
|
|
14
12
|
|
|
15
|
-
const getIbmVoices = async(client, logger, credentials) => {
|
|
16
|
-
const {tts_region, tts_api_key} = credentials;
|
|
17
|
-
console.log(`region: ${tts_region}, api_key: ${tts_api_key}`);
|
|
18
|
-
|
|
19
|
-
const textToSpeech = new TextToSpeechV1({
|
|
20
|
-
authenticator: new IamAuthenticator({
|
|
21
|
-
apikey: tts_api_key,
|
|
22
|
-
}),
|
|
23
|
-
serviceUrl: `https://api.${tts_region}.text-to-speech.watson.cloud.ibm.com`
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
const voices = await textToSpeech.listVoices();
|
|
27
|
-
return voices;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
13
|
const getNuanceVoices = async(client, logger, credentials) => {
|
|
31
14
|
const {client_id: clientId, secret: secret, nuance_tts_uri} = credentials;
|
|
32
15
|
|
|
@@ -165,14 +148,12 @@ const getVerbioVoices = async(client, logger, credentials) => {
|
|
|
165
148
|
async function getTtsVoices(client, createHash, retrieveHash, logger, {vendor, credentials}) {
|
|
166
149
|
logger = logger || noopLogger;
|
|
167
150
|
|
|
168
|
-
assert.ok(['nuance', '
|
|
151
|
+
assert.ok(['nuance', 'google', 'aws', 'polly', 'verbio'].includes(vendor),
|
|
169
152
|
`getTtsVoices not supported for vendor ${vendor}`);
|
|
170
153
|
|
|
171
154
|
switch (vendor) {
|
|
172
155
|
case 'nuance':
|
|
173
156
|
return getNuanceVoices(client, logger, credentials);
|
|
174
|
-
case 'ibm':
|
|
175
|
-
return getIbmVoices(client, logger, credentials);
|
|
176
157
|
case 'google':
|
|
177
158
|
return getGoogleVoices(client, logger, credentials);
|
|
178
159
|
case 'aws':
|
package/lib/synth-audio.js
CHANGED
|
@@ -6,8 +6,7 @@ const { PollyClient, SynthesizeSpeechCommand } = require('@aws-sdk/client-polly'
|
|
|
6
6
|
const { CartesiaClient } = require('@cartesia/cartesia-js');
|
|
7
7
|
|
|
8
8
|
const sdk = require('microsoft-cognitiveservices-speech-sdk');
|
|
9
|
-
|
|
10
|
-
const { IamAuthenticator } = require('ibm-watson/auth');
|
|
9
|
+
|
|
11
10
|
const {
|
|
12
11
|
ResultReason,
|
|
13
12
|
SpeechConfig,
|
|
@@ -96,7 +95,7 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
96
95
|
let rtt;
|
|
97
96
|
logger = logger || noopLogger;
|
|
98
97
|
|
|
99
|
-
assert.ok(['google', 'aws', 'polly', 'microsoft', 'wellsaid', 'nuance', 'nvidia', '
|
|
98
|
+
assert.ok(['google', 'aws', 'polly', 'microsoft', 'wellsaid', 'nuance', 'nvidia', 'elevenlabs',
|
|
100
99
|
'whisper', 'deepgram', 'playht', 'rimelabs', 'verbio', 'cartesia', 'inworld', 'resemble'].includes(vendor) ||
|
|
101
100
|
vendor.startsWith('custom'),
|
|
102
101
|
`synthAudio supported vendors are google, aws, microsoft, nuance, nvidia and wellsaid ..etc, not ${vendor}`);
|
|
@@ -122,11 +121,6 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
122
121
|
assert.ok(language, 'synthAudio requires language when nvidia is used');
|
|
123
122
|
assert.ok(credentials.riva_server_uri, 'synthAudio requires riva_server_uri in credentials when nvidia is used');
|
|
124
123
|
}
|
|
125
|
-
else if ('ibm' === vendor) {
|
|
126
|
-
assert.ok(voice, 'synthAudio requires voice when ibm is used');
|
|
127
|
-
assert.ok(credentials.tts_region, 'synthAudio requires tts_region in credentials when ibm watson is used');
|
|
128
|
-
assert.ok(credentials.tts_api_key, 'synthAudio requires tts_api_key in credentials when nuance is used');
|
|
129
|
-
}
|
|
130
124
|
else if ('wellsaid' === vendor) {
|
|
131
125
|
language = 'en-US'; // WellSaid only supports English atm
|
|
132
126
|
assert.ok(voice, 'synthAudio requires voice when wellsaid is used');
|
|
@@ -204,7 +198,10 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
204
198
|
const startAt = process.hrtime();
|
|
205
199
|
switch (vendor) {
|
|
206
200
|
case 'google':
|
|
207
|
-
audioData = await synthGoogle(logger, {
|
|
201
|
+
audioData = await synthGoogle(logger, {
|
|
202
|
+
credentials, stats, language, voice, gender, key, text, model, options, instructions,
|
|
203
|
+
renderForCaching, disableTtsStreaming, disableTtsCache
|
|
204
|
+
});
|
|
208
205
|
break;
|
|
209
206
|
case 'aws':
|
|
210
207
|
case 'polly':
|
|
@@ -227,9 +224,6 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
227
224
|
audioData = await synthNvidia(client, logger, {credentials, stats, language, voice, model, key, text,
|
|
228
225
|
renderForCaching, disableTtsStreaming, disableTtsCache});
|
|
229
226
|
break;
|
|
230
|
-
case 'ibm':
|
|
231
|
-
audioData = await synthIbm(logger, {credentials, stats, voice, key, text});
|
|
232
|
-
break;
|
|
233
227
|
case 'wellsaid':
|
|
234
228
|
audioData = await synthWellSaid(logger, {credentials, stats, language, voice, key, text});
|
|
235
229
|
break;
|
|
@@ -409,111 +403,129 @@ const synthPolly = async(createHash, retrieveHash, logger,
|
|
|
409
403
|
}
|
|
410
404
|
};
|
|
411
405
|
|
|
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
406
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
407
|
+
const synthGoogle = async(logger, {
|
|
408
|
+
credentials, stats, language, voice, gender, key, text, model, options, instructions,
|
|
409
|
+
renderForCaching, disableTtsStreaming, disableTtsCache
|
|
410
|
+
}) => {
|
|
411
|
+
const isGemini = !!model;
|
|
412
|
+
const isVoiceCloning = typeof voice === 'object' && voice.voice_cloning_key;
|
|
413
|
+
// HD voices have pattern like en-US-Chirp3-HD-Charon
|
|
414
|
+
const isHDVoice = typeof voice === 'string' && voice.includes('-HD-');
|
|
415
|
+
|
|
416
|
+
// Streaming support for Google TTS (Gemini, HD voices, and standard voices)
|
|
417
|
+
// Voice cloning does not support streaming
|
|
418
|
+
if (!isVoiceCloning && !JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
|
|
419
|
+
// Strip SSML tags for Gemini TTS (it doesn't support SSML)
|
|
420
|
+
let inputText = text;
|
|
421
|
+
if (isGemini && text.startsWith('<speak>')) {
|
|
422
|
+
inputText = text.replace(/<[^>]*>/g, '').trim();
|
|
423
|
+
logger.info('synthGoogle: Gemini TTS does not support SSML, stripped tags from input');
|
|
424
|
+
}
|
|
426
425
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
426
|
+
let params = '{';
|
|
427
|
+
params += `credentials=${Buffer.from(JSON.stringify(credentials.credentials)).toString('base64')}`;
|
|
428
|
+
params += `,playback_id=${key}`;
|
|
429
|
+
params += ',vendor=google';
|
|
430
|
+
params += `,voice=${voice}`;
|
|
431
|
+
params += `,language_code=${language || 'en-US'}`;
|
|
432
|
+
params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
|
|
433
|
+
// api_mode: tts (standard), live (HD voices), gemini (Gemini TTS)
|
|
434
|
+
const apiMode = options?.apiMode || (isGemini ? 'gemini' : (isHDVoice ? 'live' : 'tts'));
|
|
435
|
+
params += `,api_mode=${apiMode}`;
|
|
436
|
+
if (model) params += `,model_name=${model}`;
|
|
437
|
+
if (gender) params += `,gender=${gender}`;
|
|
438
|
+
// comma is used to separate parameters in freeswitch tts module
|
|
439
|
+
const prompt = options?.prompt || instructions;
|
|
440
|
+
if (prompt) params += `,prompt=${prompt.replace(/\n/g, ' ').replace(/,/g, ';')}`;
|
|
441
|
+
params += '}';
|
|
443
442
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
443
|
+
return {
|
|
444
|
+
filePath: `say:${params}${(isGemini ? inputText : text).replace(/\n/g, ' ')}`,
|
|
445
|
+
servedFromCache: false,
|
|
446
|
+
rtt: 0
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const client = new ttsGoogle.TextToSpeechClient(credentials);
|
|
451
|
+
|
|
452
|
+
// Build input based on voice type
|
|
453
|
+
let input;
|
|
454
|
+
if (isGemini) {
|
|
455
|
+
// Gemini TTS does not support SSML - strip tags if present
|
|
456
|
+
let inputText = text;
|
|
457
|
+
if (text.startsWith('<speak>')) {
|
|
458
|
+
inputText = text.replace(/<[^>]*>/g, '').trim();
|
|
459
|
+
logger.info('synthGoogle: Gemini TTS does not support SSML, stripped tags from input');
|
|
453
460
|
}
|
|
461
|
+
// Use instructions as prompt for Gemini TTS style control, options.prompt can override
|
|
462
|
+
const prompt = options?.prompt || instructions;
|
|
463
|
+
input = {
|
|
464
|
+
text: inputText,
|
|
465
|
+
...(prompt && { prompt })
|
|
466
|
+
};
|
|
467
|
+
} else {
|
|
468
|
+
input = text.startsWith('<speak>') ? { ssml: text } : { text };
|
|
454
469
|
}
|
|
455
470
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
471
|
+
// Build voice selection params based on voice type
|
|
472
|
+
let voiceParams;
|
|
473
|
+
if (isGemini) {
|
|
474
|
+
voiceParams = {
|
|
475
|
+
languageCode: language || 'en-US',
|
|
476
|
+
name: voice,
|
|
477
|
+
modelName: model
|
|
478
|
+
};
|
|
479
|
+
} else if (isVoiceCloning) {
|
|
480
|
+
voiceParams = {
|
|
481
|
+
languageCode: language,
|
|
482
|
+
voiceClone: {
|
|
483
|
+
voiceCloningKey: voice.voice_cloning_key
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
} else {
|
|
487
|
+
voiceParams = {
|
|
488
|
+
...(typeof voice === 'string' && { name: voice }),
|
|
489
|
+
...(typeof voice === 'object' && { customVoice: voice }),
|
|
460
490
|
languageCode: language,
|
|
461
491
|
ssmlGender: gender || 'SSML_VOICE_GENDER_UNSPECIFIED'
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Build audio config based on voice type
|
|
496
|
+
let audioConfig;
|
|
497
|
+
let extension;
|
|
498
|
+
let sampleRate;
|
|
499
|
+
if (isVoiceCloning) {
|
|
500
|
+
audioConfig = { audioEncoding: 'LINEAR16', sampleRateHertz: 24000 };
|
|
501
|
+
extension = 'wav';
|
|
502
|
+
sampleRate = 24000;
|
|
503
|
+
} else {
|
|
504
|
+
audioConfig = { audioEncoding: 'MP3' };
|
|
505
|
+
extension = 'mp3';
|
|
506
|
+
sampleRate = 8000;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const opts = { input, voice: voiceParams, audioConfig };
|
|
510
|
+
|
|
466
511
|
try {
|
|
467
|
-
|
|
512
|
+
logger.debug({ opts }, 'synthGoogle: request');
|
|
513
|
+
const [response] = await client.synthesizeSpeech(opts);
|
|
468
514
|
stats.increment('tts.count', ['vendor:google', 'accepted:yes']);
|
|
469
515
|
client.close();
|
|
470
516
|
return {
|
|
471
|
-
audioContent:
|
|
472
|
-
extension
|
|
473
|
-
sampleRate
|
|
517
|
+
audioContent: response.audioContent,
|
|
518
|
+
extension,
|
|
519
|
+
sampleRate
|
|
474
520
|
};
|
|
475
521
|
} catch (err) {
|
|
476
|
-
|
|
477
|
-
logger.info({err, opts}, 'synthAudio: Error synthesizing speech using google');
|
|
522
|
+
logger.info({ err, opts }, 'synthAudio: Error synthesizing speech using google');
|
|
478
523
|
stats.increment('tts.count', ['vendor:google', 'accepted:no']);
|
|
479
524
|
client && client.close();
|
|
480
525
|
throw err;
|
|
481
526
|
}
|
|
482
527
|
};
|
|
483
528
|
|
|
484
|
-
const synthIbm = async(logger, {credentials, stats, voice, text}) => {
|
|
485
|
-
const {tts_api_key, tts_region} = credentials;
|
|
486
|
-
const params = {
|
|
487
|
-
text,
|
|
488
|
-
voice,
|
|
489
|
-
accept: 'audio/mp3'
|
|
490
|
-
};
|
|
491
|
-
|
|
492
|
-
try {
|
|
493
|
-
const textToSpeech = new TextToSpeechV1({
|
|
494
|
-
authenticator: new IamAuthenticator({
|
|
495
|
-
apikey: tts_api_key,
|
|
496
|
-
}),
|
|
497
|
-
serviceUrl: `https://api.${tts_region}.text-to-speech.watson.cloud.ibm.com`
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
const r = await textToSpeech.synthesize(params);
|
|
501
|
-
const chunks = [];
|
|
502
|
-
for await (const chunk of r.result) {
|
|
503
|
-
chunks.push(chunk);
|
|
504
|
-
}
|
|
505
|
-
return {
|
|
506
|
-
audioContent: Buffer.concat(chunks),
|
|
507
|
-
extension: 'mp3',
|
|
508
|
-
sampleRate: 8000
|
|
509
|
-
};
|
|
510
|
-
} catch (err) {
|
|
511
|
-
logger.info({err, params}, 'synthAudio: Error synthesizing speech using ibm');
|
|
512
|
-
stats.increment('tts.count', ['vendor:ibm', 'accepted:no']);
|
|
513
|
-
throw new Error(err.statusText || err.message);
|
|
514
|
-
}
|
|
515
|
-
};
|
|
516
|
-
|
|
517
529
|
async function _synthOnPremMicrosoft(logger, {
|
|
518
530
|
credentials,
|
|
519
531
|
language,
|
|
@@ -915,7 +927,7 @@ const synthElevenlabs = async(logger, {
|
|
|
915
927
|
const optimize_streaming_latency = opts.optimize_streaming_latency ?
|
|
916
928
|
`?optimize_streaming_latency=${opts.optimize_streaming_latency}` : '';
|
|
917
929
|
try {
|
|
918
|
-
const post = bent(`https://${api_uri}`, 'POST', 'buffer', {
|
|
930
|
+
const post = bent(`https://${api_uri || 'api.elevenlabs.io'}`, 'POST', 'buffer', {
|
|
919
931
|
'xi-api-key': api_key,
|
|
920
932
|
'Accept': 'audio/mpeg',
|
|
921
933
|
'Content-Type': 'application/json'
|
package/lib/utils.js
CHANGED
|
@@ -54,12 +54,6 @@ function makeBasicAuthHeader(username, password) {
|
|
|
54
54
|
return {Authorization: header};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
function makeIbmKey(apiKey) {
|
|
58
|
-
const hash = crypto.createHash('sha1');
|
|
59
|
-
hash.update(apiKey);
|
|
60
|
-
return `ibm:${hash.digest('hex')}`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
57
|
function makeAwsKey(awsAccessKeyId) {
|
|
64
58
|
const hash = crypto.createHash('sha1');
|
|
65
59
|
hash.update(awsAccessKeyId);
|
|
@@ -143,7 +137,7 @@ const createRivaClient = async(rivaUri) => {
|
|
|
143
137
|
module.exports = {
|
|
144
138
|
makeSynthKey,
|
|
145
139
|
makeNuanceKey,
|
|
146
|
-
|
|
140
|
+
|
|
147
141
|
makePlayhtKey,
|
|
148
142
|
makeAwsKey,
|
|
149
143
|
makeVerbioKey,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jambonz/speech-utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"description": "TTS-related speech utilities for jambonz",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"author": "Dave Horton",
|
|
@@ -30,20 +30,19 @@
|
|
|
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",
|
|
37
37
|
"debug": "^4.3.4",
|
|
38
38
|
"form-urlencoded": "^6.1.4",
|
|
39
39
|
"google-protobuf": "^3.21.2",
|
|
40
|
-
"ibm-watson": "^11.0.0",
|
|
41
40
|
"microsoft-cognitiveservices-speech-sdk": "1.38.0",
|
|
42
41
|
"openai": "^4.98.0",
|
|
43
42
|
"undici": "^7.5.0"
|
|
44
43
|
},
|
|
45
44
|
"devDependencies": {
|
|
46
|
-
"config": "^
|
|
45
|
+
"config": "^4.2.0",
|
|
47
46
|
"eslint": "^9.3.0",
|
|
48
47
|
"eslint-plugin-promise": "^6.2.0",
|
|
49
48
|
"husky": "^9.0.11",
|
package/test/docker_start.js
CHANGED
|
@@ -2,7 +2,7 @@ const test = require('tape').test ;
|
|
|
2
2
|
const exec = require('child_process').exec ;
|
|
3
3
|
|
|
4
4
|
test('starting docker network..', (t) => {
|
|
5
|
-
exec(`docker
|
|
5
|
+
exec(`docker compose -f ${__dirname}/docker-compose-testbed.yaml up -d`, (err, stdout, stderr) => {
|
|
6
6
|
setTimeout(() => {
|
|
7
7
|
t.end(err);
|
|
8
8
|
}, 2000);
|
package/test/docker_stop.js
CHANGED
|
@@ -3,7 +3,7 @@ const exec = require('child_process').exec ;
|
|
|
3
3
|
|
|
4
4
|
test('stopping docker network..', (t) => {
|
|
5
5
|
t.timeoutAfter(10000);
|
|
6
|
-
exec(`docker
|
|
6
|
+
exec(`docker compose -f ${__dirname}/docker-compose-testbed.yaml down`, (err, stdout, stderr) => {
|
|
7
7
|
//console.log(`stderr: ${stderr}`);
|
|
8
8
|
process.exit(0);
|
|
9
9
|
});
|
package/test/index.js
CHANGED
package/test/list-voices.js
CHANGED
|
@@ -38,71 +38,6 @@ test('Verbio - get Access key and voices', async(t) => {
|
|
|
38
38
|
client.quit();
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
test('IBM - create access key', async(t) => {
|
|
42
|
-
const fn = require('..');
|
|
43
|
-
const {client, getIbmAccessToken} = fn(opts, logger);
|
|
44
|
-
|
|
45
|
-
if (!process.env.IBM_API_KEY ) {
|
|
46
|
-
t.pass('skipping IBM test since no IBM api_key provided');
|
|
47
|
-
t.end();
|
|
48
|
-
client.quit();
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
try {
|
|
52
|
-
let obj = await getIbmAccessToken(process.env.IBM_API_KEY);
|
|
53
|
-
//console.log({obj}, 'received access token from IBM');
|
|
54
|
-
t.ok(obj.access_token && !obj.servedFromCache, 'successfull received access token from IBM');
|
|
55
|
-
|
|
56
|
-
obj = await getIbmAccessToken(process.env.IBM_API_KEY);
|
|
57
|
-
//console.log({obj}, 'received access token from IBM - second request');
|
|
58
|
-
t.ok(obj.access_token && obj.servedFromCache, 'successfully received access token from cache');
|
|
59
|
-
|
|
60
|
-
await client.flushall();
|
|
61
|
-
t.end();
|
|
62
|
-
}
|
|
63
|
-
catch (err) {
|
|
64
|
-
console.error(err);
|
|
65
|
-
t.end(err);
|
|
66
|
-
}
|
|
67
|
-
client.quit();
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test('IBM - retrieve tts voices test', async(t) => {
|
|
71
|
-
const fn = require('..');
|
|
72
|
-
const {client, getTtsVoices} = fn(opts, logger);
|
|
73
|
-
|
|
74
|
-
if (!process.env.IBM_TTS_API_KEY || !process.env.IBM_TTS_REGION) {
|
|
75
|
-
t.pass('skipping IBM test since no IBM api_key and/or region provided');
|
|
76
|
-
t.end();
|
|
77
|
-
client.quit();
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
try {
|
|
81
|
-
const opts = {
|
|
82
|
-
vendor: 'ibm',
|
|
83
|
-
credentials: {
|
|
84
|
-
tts_api_key: process.env.IBM_TTS_API_KEY,
|
|
85
|
-
tts_region: process.env.IBM_TTS_REGION
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
const obj = await getTtsVoices(opts);
|
|
89
|
-
const {voices} = obj.result;
|
|
90
|
-
//console.log(JSON.stringify(voices));
|
|
91
|
-
t.ok(voices.length > 0 && voices[0].language,
|
|
92
|
-
`GetVoices: successfully retrieved ${voices.length} voices from IBM`);
|
|
93
|
-
|
|
94
|
-
await client.flushall();
|
|
95
|
-
|
|
96
|
-
t.end();
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
catch (err) {
|
|
100
|
-
console.error(err);
|
|
101
|
-
t.end(err);
|
|
102
|
-
}
|
|
103
|
-
client.quit();
|
|
104
|
-
});
|
|
105
|
-
|
|
106
41
|
test('Nuance hosted tests', async(t) => {
|
|
107
42
|
const fn = require('..');
|
|
108
43
|
const {client, getTtsVoices} = fn(opts, logger);
|
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);
|
|
@@ -499,46 +872,6 @@ test('Nvidia speech synth tests', async(t) => {
|
|
|
499
872
|
client.quit();
|
|
500
873
|
});
|
|
501
874
|
|
|
502
|
-
test('IBM watson speech synth tests', async(t) => {
|
|
503
|
-
const fn = require('..');
|
|
504
|
-
const {synthAudio, client} = fn(opts, logger);
|
|
505
|
-
|
|
506
|
-
if (!process.env.IBM_TTS_API_KEY || !process.env.IBM_TTS_REGION) {
|
|
507
|
-
t.pass('skipping IBM Watson speech synth tests since IBM_TTS_API_KEY or IBM_TTS_API_KEY not provided');
|
|
508
|
-
return t.end();
|
|
509
|
-
}
|
|
510
|
-
const text = `<speak> Hi there and welcome to jambones! jambones is the <sub alias="seapass">CPaaS</sub> designed with the needs of communication service providers in mind. This is an example of simple text-to-speech, but there is so much more you can do. Try us out!</speak>`;
|
|
511
|
-
try {
|
|
512
|
-
let opts = await synthAudio(stats, {
|
|
513
|
-
vendor: 'ibm',
|
|
514
|
-
credentials: {
|
|
515
|
-
tts_api_key: process.env.IBM_TTS_API_KEY,
|
|
516
|
-
tts_region: process.env.IBM_TTS_REGION,
|
|
517
|
-
},
|
|
518
|
-
language: 'en-US',
|
|
519
|
-
voice: 'en-US_AllisonV2Voice',
|
|
520
|
-
text,
|
|
521
|
-
});
|
|
522
|
-
t.ok(!opts.servedFromCache, `successfully synthesized ibm audio to ${opts.filePath}`);
|
|
523
|
-
|
|
524
|
-
opts = await synthAudio(stats, {
|
|
525
|
-
vendor: 'ibm',
|
|
526
|
-
credentials: {
|
|
527
|
-
tts_api_key: process.env.IBM_TTS_API_KEY,
|
|
528
|
-
tts_region: process.env.IBM_TTS_REGION,
|
|
529
|
-
},
|
|
530
|
-
language: 'en-US',
|
|
531
|
-
voice: 'en-US_AllisonV2Voice',
|
|
532
|
-
text,
|
|
533
|
-
});
|
|
534
|
-
t.ok(opts.servedFromCache, `successfully retrieved ibm audio from cache ${opts.filePath}`);
|
|
535
|
-
} catch (err) {
|
|
536
|
-
console.error(JSON.stringify(err));
|
|
537
|
-
t.end(err);
|
|
538
|
-
}
|
|
539
|
-
client.quit();
|
|
540
|
-
});
|
|
541
|
-
|
|
542
875
|
test('Custom Vendor speech synth tests', async(t) => {
|
|
543
876
|
const fn = require('..');
|
|
544
877
|
const {synthAudio, client} = fn(opts, logger);
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
const formurlencoded = require('form-urlencoded');
|
|
2
|
-
const {Pool} = require('undici');
|
|
3
|
-
const pool = new Pool('https://iam.cloud.ibm.com');
|
|
4
|
-
const {makeIbmKey, noopLogger} = require('./utils');
|
|
5
|
-
const { HTTP_TIMEOUT } = require('./config');
|
|
6
|
-
const debug = require('debug')('jambonz:realtimedb-helpers');
|
|
7
|
-
|
|
8
|
-
async function getIbmAccessToken(client, logger, apiKey) {
|
|
9
|
-
logger = logger || noopLogger;
|
|
10
|
-
try {
|
|
11
|
-
const key = makeIbmKey(apiKey);
|
|
12
|
-
const access_token = await client.get(key);
|
|
13
|
-
if (access_token) return {access_token, servedFromCache: true};
|
|
14
|
-
|
|
15
|
-
/* access token not found in cache, so fetch it from Ibm */
|
|
16
|
-
const payload = {
|
|
17
|
-
grant_type: 'urn:ibm:params:oauth:grant-type:apikey',
|
|
18
|
-
apikey: apiKey
|
|
19
|
-
};
|
|
20
|
-
const {statusCode, headers, body} = await pool.request({
|
|
21
|
-
path: '/identity/token',
|
|
22
|
-
method: 'POST',
|
|
23
|
-
headers: {
|
|
24
|
-
'Content-Type': 'application/x-www-form-urlencoded'
|
|
25
|
-
},
|
|
26
|
-
body: formurlencoded(payload),
|
|
27
|
-
timeout: HTTP_TIMEOUT,
|
|
28
|
-
followRedirects: false
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
if (200 !== statusCode) {
|
|
32
|
-
const json = await body.json();
|
|
33
|
-
logger.debug({statusCode, headers, body: json}, 'error fetching access token from Ibm');
|
|
34
|
-
const err = new Error();
|
|
35
|
-
err.statusCode = statusCode;
|
|
36
|
-
throw err;
|
|
37
|
-
}
|
|
38
|
-
const json = await body.json();
|
|
39
|
-
await client.set(key, json.access_token, 'EX', json.expires_in - 30);
|
|
40
|
-
return {...json, servedFromCache: false};
|
|
41
|
-
} catch (err) {
|
|
42
|
-
debug(err, 'getIbmAccessToken: Error retrieving Ibm access token');
|
|
43
|
-
logger.error(err, 'getIbmAccessToken: Error retrieving Ibm access token for client_id ${clientId}');
|
|
44
|
-
throw err;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
module.exports = getIbmAccessToken;
|
package/test/ibm.js
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
const test = require('tape').test ;
|
|
2
|
-
const config = require('config');
|
|
3
|
-
const opts = config.get('redis');
|
|
4
|
-
const fs = require('fs');
|
|
5
|
-
const logger = require('pino')({level: 'error'});
|
|
6
|
-
process.on('unhandledRejection', (reason, p) => {
|
|
7
|
-
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
const stats = {
|
|
11
|
-
increment: () => {},
|
|
12
|
-
histogram: () => {}
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
test('IBM - create access key', async(t) => {
|
|
16
|
-
const fn = require('..');
|
|
17
|
-
const {client, getIbmAccessToken} = fn(opts, logger);
|
|
18
|
-
|
|
19
|
-
if (!process.env.IBM_API_KEY ) {
|
|
20
|
-
t.pass('skipping IBM test since no IBM api_key provided');
|
|
21
|
-
t.end();
|
|
22
|
-
client.quit();
|
|
23
|
-
return;
|
|
24
|
-
}
|
|
25
|
-
try {
|
|
26
|
-
let obj = await getIbmAccessToken(process.env.IBM_API_KEY);
|
|
27
|
-
//console.log({obj}, 'received access token from IBM');
|
|
28
|
-
t.ok(obj.access_token && !obj.servedFromCache, 'successfull received access token from IBM');
|
|
29
|
-
|
|
30
|
-
obj = await getIbmAccessToken(process.env.IBM_API_KEY);
|
|
31
|
-
//console.log({obj}, 'received access token from IBM - second request');
|
|
32
|
-
t.ok(obj.access_token && obj.servedFromCache, 'successfully received access token from cache');
|
|
33
|
-
|
|
34
|
-
await client.flushall();
|
|
35
|
-
t.end();
|
|
36
|
-
}
|
|
37
|
-
catch (err) {
|
|
38
|
-
console.error(err);
|
|
39
|
-
t.end(err);
|
|
40
|
-
}
|
|
41
|
-
client.quit();
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test('IBM - retrieve tts voices test', async(t) => {
|
|
45
|
-
const fn = require('..');
|
|
46
|
-
const {client, getTtsVoices} = fn(opts, logger);
|
|
47
|
-
|
|
48
|
-
if (!process.env.IBM_TTS_API_KEY || !process.env.IBM_TTS_REGION) {
|
|
49
|
-
t.pass('skipping IBM test since no IBM api_key and/or region provided');
|
|
50
|
-
t.end();
|
|
51
|
-
client.quit();
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
try {
|
|
55
|
-
const opts = {
|
|
56
|
-
vendor: 'ibm',
|
|
57
|
-
credentials: {
|
|
58
|
-
tts_api_key: process.env.IBM_TTS_API_KEY,
|
|
59
|
-
tts_region: process.env.IBM_TTS_REGION
|
|
60
|
-
}
|
|
61
|
-
};
|
|
62
|
-
const obj = await getTtsVoices(opts);
|
|
63
|
-
const {voices} = obj.result;
|
|
64
|
-
//console.log(JSON.stringify(voices));
|
|
65
|
-
t.ok(voices.length > 0 && voices[0].language,
|
|
66
|
-
`GetVoices: successfully retrieved ${voices.length} voices from IBM`);
|
|
67
|
-
|
|
68
|
-
await client.flushall();
|
|
69
|
-
|
|
70
|
-
t.end();
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
catch (err) {
|
|
74
|
-
console.error(err);
|
|
75
|
-
t.end(err);
|
|
76
|
-
}
|
|
77
|
-
client.quit();
|
|
78
|
-
});
|