@jambonz/speech-utils 0.1.24 → 0.2.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/lib/add-file-to-cache.js +29 -1
- package/lib/synth-audio.js +159 -128
- package/lib/utils.js +4 -53
- package/package.json +1 -1
- package/test/synth.js +5 -14
package/lib/add-file-to-cache.js
CHANGED
|
@@ -3,6 +3,27 @@ const {noopLogger, makeSynthKey} = require('./utils');
|
|
|
3
3
|
const {JAMBONES_TTS_CACHE_DURATION_MINS} = require('./config');
|
|
4
4
|
const EXPIRES = JAMBONES_TTS_CACHE_DURATION_MINS;
|
|
5
5
|
|
|
6
|
+
function getExtensionAndSampleRate(path) {
|
|
7
|
+
const match = path.match(/\.([^.]*)$/);
|
|
8
|
+
if (!match) {
|
|
9
|
+
//default should be wav file.
|
|
10
|
+
return ['wav', 8000];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const extension = match[1];
|
|
14
|
+
const sampleRateMap = {
|
|
15
|
+
r8: 8000,
|
|
16
|
+
r16: 16000,
|
|
17
|
+
r24: 24000,
|
|
18
|
+
r44: 44100,
|
|
19
|
+
r48: 48000,
|
|
20
|
+
r96: 96000,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const sampleRate = sampleRateMap[extension] || 8000;
|
|
24
|
+
return [extension, sampleRate];
|
|
25
|
+
}
|
|
26
|
+
|
|
6
27
|
async function addFileToCache(client, logger, path,
|
|
7
28
|
{account_sid, vendor, language, voice, deploymentId, engine, text}) {
|
|
8
29
|
let key;
|
|
@@ -17,8 +38,15 @@ async function addFileToCache(client, logger, path,
|
|
|
17
38
|
engine,
|
|
18
39
|
text,
|
|
19
40
|
});
|
|
41
|
+
const [extension, sampleRate] = getExtensionAndSampleRate(path);
|
|
20
42
|
const audioBuffer = await fs.readFile(path);
|
|
21
|
-
await client.setex(key, EXPIRES,
|
|
43
|
+
await client.setex(key, EXPIRES, JSON.stringify(
|
|
44
|
+
{
|
|
45
|
+
audioContent: audioBuffer.toString('base64'),
|
|
46
|
+
extension,
|
|
47
|
+
sampleRate
|
|
48
|
+
}
|
|
49
|
+
));
|
|
22
50
|
} catch (err) {
|
|
23
51
|
logger.error(err, 'addFileToCache: Error');
|
|
24
52
|
return;
|
package/lib/synth-audio.js
CHANGED
|
@@ -46,7 +46,7 @@ const {
|
|
|
46
46
|
JAMBONES_HTTP_PROXY_IP,
|
|
47
47
|
JAMBONES_HTTP_PROXY_PORT,
|
|
48
48
|
JAMBONES_TTS_CACHE_DURATION_MINS,
|
|
49
|
-
|
|
49
|
+
JAMBONES_TTS_TRIM_SILENCE,
|
|
50
50
|
} = require('./config');
|
|
51
51
|
const EXPIRES = JAMBONES_TTS_CACHE_DURATION_MINS;
|
|
52
52
|
const OpenAI = require('openai');
|
|
@@ -91,7 +91,7 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
91
91
|
vendor, language, voice, gender, text, engine, salt, model, credentials, deploymentId,
|
|
92
92
|
disableTtsCache, renderForCaching = false, disableTtsStreaming, options
|
|
93
93
|
}) {
|
|
94
|
-
let
|
|
94
|
+
let audioData;
|
|
95
95
|
let servedFromCache = false;
|
|
96
96
|
let rtt;
|
|
97
97
|
logger = logger || noopLogger;
|
|
@@ -169,57 +169,24 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
169
169
|
language: language || '',
|
|
170
170
|
voice: voice || deploymentId,
|
|
171
171
|
engine,
|
|
172
|
-
text
|
|
173
|
-
renderForCaching
|
|
172
|
+
text
|
|
174
173
|
});
|
|
175
|
-
|
|
176
|
-
// used only for custom vendor
|
|
177
|
-
let fileExtension;
|
|
178
|
-
filePath = makeFilePath({vendor, voice, key, salt, renderForCaching});
|
|
174
|
+
|
|
179
175
|
debug(`synth key is ${key}`);
|
|
180
176
|
let cached;
|
|
181
177
|
if (!disableTtsCache) {
|
|
182
178
|
cached = await client.get(key);
|
|
183
|
-
/**
|
|
184
|
-
* If we are using tts streaming and also precaching audio, audio could have been cached by streaming (r8)
|
|
185
|
-
* or here in speech-utils due to precaching (mp3), so we need to check for both keys.
|
|
186
|
-
*/
|
|
187
|
-
if (!cached && JAMBONES_EAGERLY_PRE_CACHE_AUDIO) {
|
|
188
|
-
const preCachekey = makeSynthKey({
|
|
189
|
-
account_sid,
|
|
190
|
-
vendor,
|
|
191
|
-
language: language || '',
|
|
192
|
-
voice: voice || deploymentId,
|
|
193
|
-
engine,
|
|
194
|
-
text,
|
|
195
|
-
renderForCaching: true
|
|
196
|
-
});
|
|
197
|
-
cached = await client.get(preCachekey);
|
|
198
|
-
if (cached) {
|
|
199
|
-
// Precache audio is available update filpath with precache file extension.
|
|
200
|
-
filePath = makeFilePath({vendor, voice, key, salt, renderForCaching: true});
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
179
|
}
|
|
204
180
|
if (cached) {
|
|
205
181
|
// found in cache - extend the expiry and use it
|
|
206
182
|
debug('result WAS found in cache');
|
|
207
183
|
servedFromCache = true;
|
|
208
184
|
stats.increment('tts.cache.requests', ['found:yes']);
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
// In cache, file extension is store together with audiBuffer in a json.
|
|
213
|
-
// Normal cache audio will be base64 string
|
|
214
|
-
const payload = JSON.parse(cached);
|
|
215
|
-
filePath = filePath.replace(/\.[^\.]*$/g, payload.fileExtension);
|
|
216
|
-
audioBuffer = Buffer.from(payload.audioBuffer, 'base64');
|
|
217
|
-
} else {
|
|
218
|
-
audioBuffer = Buffer.from(cached, 'base64');
|
|
219
|
-
}
|
|
185
|
+
audioData = JSON.parse(cached);
|
|
186
|
+
// convert base64 audio to buffer
|
|
187
|
+
audioData.audioContent = Buffer.from(audioData.audioContent, 'base64');
|
|
220
188
|
client.expire(key, EXPIRES).catch((err) => logger.info(err, 'Error setting expires'));
|
|
221
|
-
}
|
|
222
|
-
if (!cached) {
|
|
189
|
+
} else {
|
|
223
190
|
// not found in cache - go get it from speech vendor and add to cache
|
|
224
191
|
debug('result was NOT found in cache');
|
|
225
192
|
stats.increment('tts.cache.requests', ['found:no']);
|
|
@@ -227,94 +194,92 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
227
194
|
const startAt = process.hrtime();
|
|
228
195
|
switch (vendor) {
|
|
229
196
|
case 'google':
|
|
230
|
-
|
|
197
|
+
audioData = await synthGoogle(logger, {credentials, stats, language, voice, gender, text});
|
|
231
198
|
break;
|
|
232
199
|
case 'aws':
|
|
233
200
|
case 'polly':
|
|
234
201
|
vendorLabel = 'aws';
|
|
235
|
-
|
|
202
|
+
audioData = await synthPolly(createHash, retrieveHash, logger,
|
|
236
203
|
{credentials, stats, language, voice, text, engine});
|
|
237
204
|
break;
|
|
238
205
|
case 'azure':
|
|
239
206
|
case 'microsoft':
|
|
240
207
|
vendorLabel = 'microsoft';
|
|
241
|
-
|
|
242
|
-
|
|
208
|
+
audioData = await synthMicrosoft(logger, {credentials, stats, language, voice, text, deploymentId,
|
|
209
|
+
renderForCaching, disableTtsStreaming});
|
|
243
210
|
break;
|
|
244
211
|
case 'nuance':
|
|
245
212
|
model = model || 'enhanced';
|
|
246
|
-
|
|
213
|
+
audioData = await synthNuance(client, logger, {credentials, stats, voice, model, text});
|
|
247
214
|
break;
|
|
248
215
|
case 'nvidia':
|
|
249
|
-
|
|
216
|
+
audioData = await synthNvidia(client, logger, {credentials, stats, language, voice, model, text});
|
|
250
217
|
break;
|
|
251
218
|
case 'ibm':
|
|
252
|
-
|
|
219
|
+
audioData = await synthIbm(logger, {credentials, stats, voice, text});
|
|
253
220
|
break;
|
|
254
221
|
case 'wellsaid':
|
|
255
|
-
|
|
222
|
+
audioData = await synthWellSaid(logger, {credentials, stats, language, voice, text});
|
|
256
223
|
break;
|
|
257
224
|
case 'elevenlabs':
|
|
258
|
-
|
|
259
|
-
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming
|
|
260
|
-
});
|
|
225
|
+
audioData = await synthElevenlabs(logger, {
|
|
226
|
+
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
|
|
261
227
|
break;
|
|
262
228
|
case 'playht':
|
|
263
|
-
|
|
264
|
-
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming
|
|
265
|
-
});
|
|
229
|
+
audioData = await synthPlayHT(client, logger, {
|
|
230
|
+
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
|
|
266
231
|
break;
|
|
267
232
|
case 'cartesia':
|
|
268
|
-
|
|
269
|
-
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming
|
|
270
|
-
});
|
|
233
|
+
audioData = await synthCartesia(logger, {
|
|
234
|
+
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
|
|
271
235
|
break;
|
|
272
236
|
case 'rimelabs':
|
|
273
|
-
|
|
274
|
-
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming
|
|
275
|
-
});
|
|
237
|
+
audioData = await synthRimelabs(logger, {
|
|
238
|
+
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
|
|
276
239
|
break;
|
|
277
240
|
case 'whisper':
|
|
278
|
-
|
|
241
|
+
audioData = await synthWhisper(logger, {
|
|
279
242
|
credentials, stats, voice, text, renderForCaching, disableTtsStreaming});
|
|
280
243
|
break;
|
|
281
244
|
case 'verbio':
|
|
282
|
-
|
|
245
|
+
audioData = await synthVerbio(client, logger, {
|
|
283
246
|
credentials, stats, voice, text, renderForCaching, disableTtsStreaming});
|
|
284
|
-
if (
|
|
247
|
+
if (audioData?.filePath) return audioData;
|
|
285
248
|
break;
|
|
286
249
|
case 'deepgram':
|
|
287
|
-
|
|
250
|
+
audioData = await synthDeepgram(logger, {credentials, stats, model, text,
|
|
288
251
|
renderForCaching, disableTtsStreaming});
|
|
289
252
|
break;
|
|
290
253
|
case vendor.startsWith('custom') ? vendor : 'cant_match_value':
|
|
291
|
-
|
|
292
|
-
{credentials, stats, language, voice, text
|
|
254
|
+
audioData = await synthCustomVendor(logger,
|
|
255
|
+
{credentials, stats, language, voice, text});
|
|
293
256
|
break;
|
|
294
257
|
default:
|
|
295
258
|
assert(`synthAudio: unsupported speech vendor ${vendor}`);
|
|
296
259
|
}
|
|
297
|
-
if ('filePath' in
|
|
260
|
+
if ('filePath' in audioData) return audioData;
|
|
298
261
|
const diff = process.hrtime(startAt);
|
|
299
262
|
const time = diff[0] * 1e3 + diff[1] * 1e-6;
|
|
300
263
|
rtt = time.toFixed(0);
|
|
301
264
|
stats.histogram('tts.response_time', rtt, [`vendor:${vendorLabel}`]);
|
|
302
265
|
debug(`tts rtt time for ${text.length} chars on ${vendorLabel}: ${rtt}`);
|
|
303
266
|
logger.info(`tts rtt time for ${text.length} chars on ${vendorLabel}: ${rtt}`);
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
fileExtension
|
|
310
|
-
}) : base64Audio;
|
|
311
|
-
|
|
312
|
-
client.setex(key, EXPIRES, cacheContent)
|
|
267
|
+
// Save audio json to cache
|
|
268
|
+
client.setex(key, EXPIRES, JSON.stringify({
|
|
269
|
+
...audioData,
|
|
270
|
+
audioContent: audioData.audioContent?.toString('base64')
|
|
271
|
+
}))
|
|
313
272
|
.catch((err) => logger.error(err, `error calling setex on key ${key}`));
|
|
314
273
|
}
|
|
315
274
|
|
|
316
275
|
return new Promise((resolve, reject) => {
|
|
317
|
-
|
|
276
|
+
const { audioContent, extension } = audioData;
|
|
277
|
+
const filePath = makeFilePath({
|
|
278
|
+
key,
|
|
279
|
+
salt,
|
|
280
|
+
extension
|
|
281
|
+
});
|
|
282
|
+
fs.writeFile(filePath, audioContent, (err) => {
|
|
318
283
|
if (err) return reject(err);
|
|
319
284
|
resolve({filePath, servedFromCache, rtt});
|
|
320
285
|
});
|
|
@@ -369,7 +334,13 @@ const synthPolly = async(createHash, retrieveHash, logger,
|
|
|
369
334
|
.on('data', (chunk) => {
|
|
370
335
|
chunks.push(chunk);
|
|
371
336
|
})
|
|
372
|
-
.on('end', () => resolve(
|
|
337
|
+
.on('end', () => resolve(
|
|
338
|
+
{
|
|
339
|
+
audioContent: Buffer.concat(chunks),
|
|
340
|
+
extension: 'mp3',
|
|
341
|
+
sampleRate: 8000
|
|
342
|
+
}
|
|
343
|
+
));
|
|
373
344
|
});
|
|
374
345
|
} catch (err) {
|
|
375
346
|
logger.info({err}, 'synthAudio: Error synthesizing speech using aws polly');
|
|
@@ -411,7 +382,11 @@ const synthGoogle = async(logger, {credentials, stats, language, voice, gender,
|
|
|
411
382
|
};
|
|
412
383
|
|
|
413
384
|
const wav = await post('/v1beta1/text:synthesize', payload);
|
|
414
|
-
return
|
|
385
|
+
return {
|
|
386
|
+
audioContent: Buffer.from(wav.audioContent, 'base64'),
|
|
387
|
+
extension: 'wav',
|
|
388
|
+
sampleRate: 24000
|
|
389
|
+
};
|
|
415
390
|
} catch (err) {
|
|
416
391
|
logger.info({err: await err.text()}, 'synthGoogle returned error');
|
|
417
392
|
throw err;
|
|
@@ -432,7 +407,11 @@ const synthGoogle = async(logger, {credentials, stats, language, voice, gender,
|
|
|
432
407
|
const responses = await client.synthesizeSpeech(opts);
|
|
433
408
|
stats.increment('tts.count', ['vendor:google', 'accepted:yes']);
|
|
434
409
|
client.close();
|
|
435
|
-
return
|
|
410
|
+
return {
|
|
411
|
+
audioContent: responses[0].audioContent,
|
|
412
|
+
extension: 'mp3',
|
|
413
|
+
sampleRate: 8000
|
|
414
|
+
};
|
|
436
415
|
} catch (err) {
|
|
437
416
|
console.error(err);
|
|
438
417
|
logger.info({err, opts}, 'synthAudio: Error synthesizing speech using google');
|
|
@@ -463,7 +442,11 @@ const synthIbm = async(logger, {credentials, stats, voice, text}) => {
|
|
|
463
442
|
for await (const chunk of r.result) {
|
|
464
443
|
chunks.push(chunk);
|
|
465
444
|
}
|
|
466
|
-
return
|
|
445
|
+
return {
|
|
446
|
+
audioContent: Buffer.concat(chunks),
|
|
447
|
+
extension: 'mp3',
|
|
448
|
+
sampleRate: 8000
|
|
449
|
+
};
|
|
467
450
|
} catch (err) {
|
|
468
451
|
logger.info({err, params}, 'synthAudio: Error synthesizing speech using ibm');
|
|
469
452
|
stats.increment('tts.count', ['vendor:ibm', 'accepted:no']);
|
|
@@ -473,11 +456,9 @@ const synthIbm = async(logger, {credentials, stats, voice, text}) => {
|
|
|
473
456
|
|
|
474
457
|
async function _synthOnPremMicrosoft(logger, {
|
|
475
458
|
credentials,
|
|
476
|
-
stats,
|
|
477
459
|
language,
|
|
478
460
|
voice,
|
|
479
|
-
text
|
|
480
|
-
filePath
|
|
461
|
+
text
|
|
481
462
|
}) {
|
|
482
463
|
const {use_custom_tts, custom_tts_endpoint_url, api_key} = credentials;
|
|
483
464
|
let content = text;
|
|
@@ -499,15 +480,19 @@ async function _synthOnPremMicrosoft(logger, {
|
|
|
499
480
|
}
|
|
500
481
|
|
|
501
482
|
try {
|
|
502
|
-
const trimSilence =
|
|
483
|
+
const trimSilence = JAMBONES_TTS_TRIM_SILENCE;
|
|
503
484
|
const post = bent('POST', 'buffer', {
|
|
504
485
|
'X-Microsoft-OutputFormat': trimSilence ? 'raw-8khz-16bit-mono-pcm' : 'audio-16khz-32kbitrate-mono-mp3',
|
|
505
486
|
'Content-Type': 'application/ssml+xml',
|
|
506
487
|
'User-Agent': 'Jambonz',
|
|
507
488
|
...(api_key && {'Ocp-Apim-Subscription-Key': api_key})
|
|
508
489
|
});
|
|
509
|
-
const
|
|
510
|
-
return
|
|
490
|
+
const audioContent = await post(custom_tts_endpoint_url, content);
|
|
491
|
+
return {
|
|
492
|
+
audioContent,
|
|
493
|
+
extension: trimSilence ? 'r8' : 'mp3',
|
|
494
|
+
sampleRate: 8000
|
|
495
|
+
};
|
|
511
496
|
} catch (err) {
|
|
512
497
|
logger.info({err}, '_synthMicrosoftByHttp returned error');
|
|
513
498
|
throw err;
|
|
@@ -520,7 +505,6 @@ const synthMicrosoft = async(logger, {
|
|
|
520
505
|
language,
|
|
521
506
|
voice,
|
|
522
507
|
text,
|
|
523
|
-
filePath,
|
|
524
508
|
renderForCaching,
|
|
525
509
|
disableTtsStreaming
|
|
526
510
|
}) => {
|
|
@@ -563,17 +547,18 @@ const synthMicrosoft = async(logger, {
|
|
|
563
547
|
rtt: 0
|
|
564
548
|
};
|
|
565
549
|
}
|
|
550
|
+
// Azure Onprem
|
|
566
551
|
if (use_custom_tts && custom_tts_endpoint_url) {
|
|
567
552
|
return await _synthOnPremMicrosoft(logger, {
|
|
568
553
|
credentials,
|
|
569
554
|
stats,
|
|
570
555
|
language,
|
|
571
556
|
voice,
|
|
572
|
-
text
|
|
573
|
-
filePath
|
|
557
|
+
text
|
|
574
558
|
});
|
|
575
559
|
}
|
|
576
|
-
|
|
560
|
+
// Azure hosted service
|
|
561
|
+
const trimSilence = JAMBONES_TTS_TRIM_SILENCE;
|
|
577
562
|
const speechConfig = SpeechConfig.fromSubscription(apiKey, region);
|
|
578
563
|
speechConfig.speechSynthesisLanguage = language;
|
|
579
564
|
speechConfig.speechSynthesisVoiceName = voice;
|
|
@@ -608,7 +593,11 @@ const synthMicrosoft = async(logger, {
|
|
|
608
593
|
case ResultReason.SynthesizingAudioCompleted:
|
|
609
594
|
let buffer = Buffer.from(result.audioData);
|
|
610
595
|
if (trimSilence) buffer = trimTrailingSilence(buffer);
|
|
611
|
-
resolve(
|
|
596
|
+
resolve({
|
|
597
|
+
audioContent: buffer,
|
|
598
|
+
extension: trimSilence ? 'r8' : 'mp3',
|
|
599
|
+
sampleRate: 8000
|
|
600
|
+
});
|
|
612
601
|
synthesizer.close();
|
|
613
602
|
stats.increment('tts.count', ['vendor:microsoft', 'accepted:yes']);
|
|
614
603
|
break;
|
|
@@ -638,11 +627,15 @@ const synthWellSaid = async(logger, {credentials, stats, language, voice, gender
|
|
|
638
627
|
'Accept': 'audio/mpeg',
|
|
639
628
|
'Content-Type': 'application/json'
|
|
640
629
|
});
|
|
641
|
-
const
|
|
630
|
+
const audioContent = await post('/v1/tts/stream', {
|
|
642
631
|
text,
|
|
643
632
|
speaker_id: voice
|
|
644
633
|
});
|
|
645
|
-
return
|
|
634
|
+
return {
|
|
635
|
+
audioContent,
|
|
636
|
+
extension: 'mp3',
|
|
637
|
+
sampleRate: 8000
|
|
638
|
+
};
|
|
646
639
|
} catch (err) {
|
|
647
640
|
logger.info({err}, 'testWellSaidTts returned error');
|
|
648
641
|
throw err;
|
|
@@ -679,8 +672,8 @@ const synthNuance = async(client, logger, {credentials, stats, voice, model, tex
|
|
|
679
672
|
t.setText(text);
|
|
680
673
|
input.setText(t);
|
|
681
674
|
}
|
|
682
|
-
|
|
683
|
-
pcm.setSampleRateHz(
|
|
675
|
+
const sampleRate = 8000;
|
|
676
|
+
pcm.setSampleRateHz(sampleRate);
|
|
684
677
|
f.setPcm(pcm);
|
|
685
678
|
p.setAudioFormat(f);
|
|
686
679
|
v.setName(voice);
|
|
@@ -704,7 +697,11 @@ const synthNuance = async(client, logger, {credentials, stats, voice, model, tex
|
|
|
704
697
|
const details = status.getDetails();
|
|
705
698
|
return reject({code, message, details});
|
|
706
699
|
}
|
|
707
|
-
resolve(
|
|
700
|
+
resolve({
|
|
701
|
+
audioContent: Buffer.from(response.getAudio()),
|
|
702
|
+
extension: 'r8',
|
|
703
|
+
sampleRate
|
|
704
|
+
});
|
|
708
705
|
});
|
|
709
706
|
});
|
|
710
707
|
};
|
|
@@ -712,12 +709,13 @@ const synthNuance = async(client, logger, {credentials, stats, voice, model, tex
|
|
|
712
709
|
const synthNvidia = async(client, logger, {credentials, stats, language, voice, model, text}) => {
|
|
713
710
|
const {riva_server_uri} = credentials;
|
|
714
711
|
let rivaClient, request;
|
|
712
|
+
const sampleRate = 8000;
|
|
715
713
|
try {
|
|
716
714
|
rivaClient = await createRivaClient(riva_server_uri);
|
|
717
715
|
request = new SynthesizeSpeechRequest();
|
|
718
716
|
request.setVoiceName(voice);
|
|
719
717
|
request.setLanguageCode(language);
|
|
720
|
-
request.setSampleRateHz(
|
|
718
|
+
request.setSampleRateHz(sampleRate);
|
|
721
719
|
request.setEncoding(AudioEncoding.LINEAR_PCM);
|
|
722
720
|
request.setText(text);
|
|
723
721
|
} catch (err) {
|
|
@@ -731,7 +729,11 @@ const synthNvidia = async(client, logger, {credentials, stats, language, voice,
|
|
|
731
729
|
logger.info({err, voice, language}, 'error synthesizing speech using Nvidia');
|
|
732
730
|
return reject(err);
|
|
733
731
|
}
|
|
734
|
-
resolve(
|
|
732
|
+
resolve({
|
|
733
|
+
audioContent: Buffer.from(response.getAudio()),
|
|
734
|
+
extension: 'r8',
|
|
735
|
+
sampleRate
|
|
736
|
+
});
|
|
735
737
|
});
|
|
736
738
|
});
|
|
737
739
|
};
|
|
@@ -753,14 +755,13 @@ const synthCustomVendor = async(logger, {credentials, stats, language, voice, te
|
|
|
753
755
|
text
|
|
754
756
|
});
|
|
755
757
|
|
|
756
|
-
const regex = /\.[^\.]*$/g;
|
|
757
758
|
const mime = response.headers['content-type'];
|
|
758
759
|
const buffer = await response.arrayBuffer();
|
|
759
|
-
const
|
|
760
|
+
const [extension, sampleRate] = getFileExtFromMime(mime);
|
|
760
761
|
return {
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
762
|
+
audioContent: buffer,
|
|
763
|
+
extension,
|
|
764
|
+
sampleRate
|
|
764
765
|
};
|
|
765
766
|
} catch (err) {
|
|
766
767
|
logger.info({err}, `Vendor ${vendor} returned error`);
|
|
@@ -806,7 +807,7 @@ const synthElevenlabs = async(logger, {
|
|
|
806
807
|
'Accept': 'audio/mpeg',
|
|
807
808
|
'Content-Type': 'application/json'
|
|
808
809
|
});
|
|
809
|
-
const
|
|
810
|
+
const audioContent = await post(`/v1/text-to-speech/${voice}${optimize_streaming_latency}`, {
|
|
810
811
|
text,
|
|
811
812
|
model_id,
|
|
812
813
|
voice_settings: {
|
|
@@ -815,7 +816,11 @@ const synthElevenlabs = async(logger, {
|
|
|
815
816
|
},
|
|
816
817
|
...opts
|
|
817
818
|
});
|
|
818
|
-
return
|
|
819
|
+
return {
|
|
820
|
+
audioContent,
|
|
821
|
+
extension: 'mp3',
|
|
822
|
+
sampleRate: 8000
|
|
823
|
+
};
|
|
819
824
|
} catch (err) {
|
|
820
825
|
logger.info({err}, 'synth Elevenlabs returned error');
|
|
821
826
|
stats.increment('tts.count', ['vendor:elevenlabs', 'accepted:no']);
|
|
@@ -897,7 +902,7 @@ const synthPlayHT = async(client, logger, {
|
|
|
897
902
|
'Content-Type': 'application/json'
|
|
898
903
|
});
|
|
899
904
|
|
|
900
|
-
const
|
|
905
|
+
const audioContent = await post(synthesizeUrl, {
|
|
901
906
|
text,
|
|
902
907
|
...(voice_engine === 'Play3.0' && { language }),
|
|
903
908
|
voice,
|
|
@@ -906,7 +911,11 @@ const synthPlayHT = async(client, logger, {
|
|
|
906
911
|
sample_rate: 8000,
|
|
907
912
|
...opts
|
|
908
913
|
});
|
|
909
|
-
return
|
|
914
|
+
return {
|
|
915
|
+
audioContent,
|
|
916
|
+
extension: 'mp3',
|
|
917
|
+
sampleRate: 8000
|
|
918
|
+
};
|
|
910
919
|
} catch (err) {
|
|
911
920
|
logger.info({err}, 'synth PlayHT returned error');
|
|
912
921
|
stats.increment('tts.count', ['vendor:playht', 'accepted:no']);
|
|
@@ -945,14 +954,19 @@ const synthRimelabs = async(logger, {
|
|
|
945
954
|
'Accept': 'audio/mp3',
|
|
946
955
|
'Content-Type': 'application/json'
|
|
947
956
|
});
|
|
948
|
-
const
|
|
957
|
+
const sampleRate = 8000;
|
|
958
|
+
const audioContent = await post('/v1/rime-tts', {
|
|
949
959
|
speaker: voice,
|
|
950
960
|
text,
|
|
951
961
|
modelId: model_id,
|
|
952
|
-
samplingRate:
|
|
962
|
+
samplingRate: sampleRate,
|
|
953
963
|
...opts
|
|
954
964
|
});
|
|
955
|
-
return
|
|
965
|
+
return {
|
|
966
|
+
audioContent,
|
|
967
|
+
extension: 'mp3',
|
|
968
|
+
sampleRate
|
|
969
|
+
};
|
|
956
970
|
} catch (err) {
|
|
957
971
|
logger.info({err}, 'synth rimelabs returned error');
|
|
958
972
|
stats.increment('tts.count', ['vendor:rimelabs', 'accepted:no']);
|
|
@@ -986,13 +1000,17 @@ const synthVerbio = async(client, logger, {credentials, stats, voice, text, rend
|
|
|
986
1000
|
'User-Agent': 'jambonz',
|
|
987
1001
|
'Content-Type': 'application/json'
|
|
988
1002
|
});
|
|
989
|
-
const
|
|
1003
|
+
const audioContent = await post('/api/v1/synthesize', {
|
|
990
1004
|
voice_id: voice,
|
|
991
1005
|
output_sample_rate: '8k',
|
|
992
1006
|
output_encoding: 'pcm16',
|
|
993
1007
|
text
|
|
994
1008
|
});
|
|
995
|
-
return
|
|
1009
|
+
return {
|
|
1010
|
+
audioContent,
|
|
1011
|
+
extension: 'r8',
|
|
1012
|
+
sampleRate: 8000
|
|
1013
|
+
};
|
|
996
1014
|
} catch (err) {
|
|
997
1015
|
logger.info({err}, 'synth Verbio returned error');
|
|
998
1016
|
stats.increment('tts.count', ['vendor:verbio', 'accepted:no']);
|
|
@@ -1032,7 +1050,11 @@ const synthWhisper = async(logger, {credentials, stats, voice, text, renderForCa
|
|
|
1032
1050
|
input: text,
|
|
1033
1051
|
response_format: 'mp3'
|
|
1034
1052
|
});
|
|
1035
|
-
return
|
|
1053
|
+
return {
|
|
1054
|
+
audioContent: Buffer.from(await mp3.arrayBuffer()),
|
|
1055
|
+
extension: 'mp3',
|
|
1056
|
+
sampleRate: 8000
|
|
1057
|
+
};
|
|
1036
1058
|
} catch (err) {
|
|
1037
1059
|
logger.info({err}, 'synth whisper returned error');
|
|
1038
1060
|
stats.increment('tts.count', ['vendor:openai', 'accepted:no']);
|
|
@@ -1064,10 +1086,14 @@ const synthDeepgram = async(logger, {credentials, stats, model, text, renderForC
|
|
|
1064
1086
|
'Accept': 'audio/mpeg',
|
|
1065
1087
|
'Content-Type': 'application/json'
|
|
1066
1088
|
});
|
|
1067
|
-
const
|
|
1089
|
+
const audioContent = await post(`/v1/speak?model=${model}`, {
|
|
1068
1090
|
text
|
|
1069
1091
|
});
|
|
1070
|
-
return
|
|
1092
|
+
return {
|
|
1093
|
+
audioContent,
|
|
1094
|
+
extension: 'mp3',
|
|
1095
|
+
sampleRate: 8000
|
|
1096
|
+
};
|
|
1071
1097
|
} catch (err) {
|
|
1072
1098
|
logger.info({err}, 'synth Deepgram returned error');
|
|
1073
1099
|
stats.increment('tts.count', ['vendor:deepgram', 'accepted:no']);
|
|
@@ -1104,6 +1130,7 @@ const synthCartesia = async(logger, {
|
|
|
1104
1130
|
|
|
1105
1131
|
try {
|
|
1106
1132
|
const client = new CartesiaClient({ apiKey: api_key });
|
|
1133
|
+
const sampleRate = 48000;
|
|
1107
1134
|
const mp3 = await client.tts.bytes({
|
|
1108
1135
|
modelId: model_id,
|
|
1109
1136
|
transcript: text,
|
|
@@ -1119,7 +1146,7 @@ const synthCartesia = async(logger, {
|
|
|
1119
1146
|
),
|
|
1120
1147
|
...(opts.speed || opts.emotion && {
|
|
1121
1148
|
experimentalControls: {
|
|
1122
|
-
...(opts.speed && {speed: opts.speed}),
|
|
1149
|
+
...(opts.speed !== null && opts.speed !== undefined && {speed: opts.speed}),
|
|
1123
1150
|
...(opts.emotion && {emotion: opts.emotion}),
|
|
1124
1151
|
}
|
|
1125
1152
|
})
|
|
@@ -1128,10 +1155,14 @@ const synthCartesia = async(logger, {
|
|
|
1128
1155
|
outputFormat: {
|
|
1129
1156
|
container: 'mp3',
|
|
1130
1157
|
bitRate: 128000,
|
|
1131
|
-
sampleRate
|
|
1158
|
+
sampleRate
|
|
1132
1159
|
},
|
|
1133
1160
|
});
|
|
1134
|
-
return
|
|
1161
|
+
return {
|
|
1162
|
+
audioContent: Buffer.from(mp3),
|
|
1163
|
+
extension: 'mp3',
|
|
1164
|
+
sampleRate
|
|
1165
|
+
};
|
|
1135
1166
|
} catch (err) {
|
|
1136
1167
|
logger.info({err}, 'synth Cartesia returned error');
|
|
1137
1168
|
stats.increment('tts.count', ['vendor:cartesia', 'accepted:no']);
|
|
@@ -1144,22 +1175,22 @@ const getFileExtFromMime = (mime) => {
|
|
|
1144
1175
|
switch (mime) {
|
|
1145
1176
|
case 'audio/wav':
|
|
1146
1177
|
case 'audio/x-wav':
|
|
1147
|
-
return '
|
|
1178
|
+
return ['wav', 8000];
|
|
1148
1179
|
case /audio\/l16.*rate=8000/.test(mime) ? mime : 'cant match value':
|
|
1149
|
-
return '
|
|
1180
|
+
return ['r8', 8000];
|
|
1150
1181
|
case /audio\/l16.*rate=16000/.test(mime) ? mime : 'cant match value':
|
|
1151
|
-
return '
|
|
1182
|
+
return ['r16', 16000];
|
|
1152
1183
|
case /audio\/l16.*rate=24000/.test(mime) ? mime : 'cant match value':
|
|
1153
|
-
return '
|
|
1184
|
+
return ['r24', 24000];
|
|
1154
1185
|
case /audio\/l16.*rate=32000/.test(mime) ? mime : 'cant match value':
|
|
1155
|
-
return '
|
|
1186
|
+
return ['r32', 32000];
|
|
1156
1187
|
case /audio\/l16.*rate=48000/.test(mime) ? mime : 'cant match value':
|
|
1157
|
-
return '
|
|
1188
|
+
return ['r48', 48000];
|
|
1158
1189
|
case 'audio/mpeg':
|
|
1159
1190
|
case 'audio/mp3':
|
|
1160
|
-
return '
|
|
1191
|
+
return ['mp3', 8000];
|
|
1161
1192
|
default:
|
|
1162
|
-
return '
|
|
1193
|
+
return ['wav', 8000];
|
|
1163
1194
|
}
|
|
1164
1195
|
};
|
|
1165
1196
|
|
package/lib/utils.js
CHANGED
|
@@ -6,7 +6,7 @@ const pool = new Pool('https://auth.crt.nuance.com');
|
|
|
6
6
|
const NUANCE_AUTH_ENDPOINT = 'tts.api.nuance.com:443';
|
|
7
7
|
const grpc = require('@grpc/grpc-js');
|
|
8
8
|
const formurlencoded = require('form-urlencoded');
|
|
9
|
-
const {
|
|
9
|
+
const { TMP_FOLDER, HTTP_TIMEOUT } = require('./config');
|
|
10
10
|
|
|
11
11
|
const debug = require('debug')('jambonz:realtimedb-helpers');
|
|
12
12
|
/**
|
|
@@ -17,68 +17,19 @@ const debug = require('debug')('jambonz:realtimedb-helpers');
|
|
|
17
17
|
//const nuanceClientMap = new Map();
|
|
18
18
|
|
|
19
19
|
function makeSynthKey({
|
|
20
|
-
account_sid = '', vendor, language, voice, engine = '', text
|
|
21
|
-
renderForCaching = false}) {
|
|
20
|
+
account_sid = '', vendor, language, voice, engine = '', text}) {
|
|
22
21
|
const hash = crypto.createHash('sha1');
|
|
23
22
|
hash.update(`${language}:${vendor}:${voice}:${engine}:${text}`);
|
|
24
23
|
const hexHashKey = hash.digest('hex');
|
|
25
24
|
const accountKey = account_sid ? `:${account_sid}` : '';
|
|
26
|
-
const
|
|
27
|
-
const key = `tts${accountKey}:${namespace}:${hexHashKey}`;
|
|
25
|
+
const key = `tts${accountKey}:${hexHashKey}`;
|
|
28
26
|
return key;
|
|
29
27
|
}
|
|
30
28
|
|
|
31
|
-
function makeFilePath({
|
|
32
|
-
const extension = getFileExtension({vendor, renderForCaching, voice});
|
|
29
|
+
function makeFilePath({key, salt = '', extension}) {
|
|
33
30
|
return `${TMP_FOLDER}/${key.replace('tts:', `tts-${salt}`)}.${extension}`;
|
|
34
31
|
}
|
|
35
32
|
|
|
36
|
-
function getFileExtension({vendor, voice, renderForCaching = false}) {
|
|
37
|
-
const mp3Extension = 'mp3';
|
|
38
|
-
const r8Extension = 'r8';
|
|
39
|
-
const wavExtension = 'wav';
|
|
40
|
-
|
|
41
|
-
switch (vendor) {
|
|
42
|
-
case 'azure':
|
|
43
|
-
case 'microsoft':
|
|
44
|
-
if (!renderForCaching && !JAMBONES_DISABLE_TTS_STREAMING || JAMBONES_TTS_TRIM_SILENCE) {
|
|
45
|
-
return r8Extension;
|
|
46
|
-
} else {
|
|
47
|
-
return mp3Extension;
|
|
48
|
-
}
|
|
49
|
-
case 'deepgram':
|
|
50
|
-
case 'elevenlabs':
|
|
51
|
-
case 'rimelabs':
|
|
52
|
-
case 'playht':
|
|
53
|
-
case 'cartesia':
|
|
54
|
-
if (renderForCaching || JAMBONES_DISABLE_TTS_STREAMING) {
|
|
55
|
-
return mp3Extension;
|
|
56
|
-
} else {
|
|
57
|
-
return r8Extension;
|
|
58
|
-
}
|
|
59
|
-
case 'nuance':
|
|
60
|
-
case 'nvidia':
|
|
61
|
-
case 'verbio':
|
|
62
|
-
return r8Extension;
|
|
63
|
-
case 'google':
|
|
64
|
-
// google voice cloning just support wav.
|
|
65
|
-
if (typeof voice === 'object' && voice.voice_cloning_key) {
|
|
66
|
-
return wavExtension;
|
|
67
|
-
} else {
|
|
68
|
-
return mp3Extension;
|
|
69
|
-
}
|
|
70
|
-
default:
|
|
71
|
-
// If vendor is custom
|
|
72
|
-
if (vendor.startsWith('custom')) {
|
|
73
|
-
if (renderForCaching || JAMBONES_DISABLE_TTS_STREAMING) {
|
|
74
|
-
return mp3Extension;
|
|
75
|
-
} else {
|
|
76
|
-
return r8Extension;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return mp3Extension;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
33
|
|
|
83
34
|
const noopLogger = {
|
|
84
35
|
info: () => {},
|
package/package.json
CHANGED
package/test/synth.js
CHANGED
|
@@ -888,24 +888,15 @@ test('TTS Cache tests', async(t) => {
|
|
|
888
888
|
language: 'non-existing',
|
|
889
889
|
voice: 'non-existing',
|
|
890
890
|
});
|
|
891
|
-
t.ok(purgedCountWhenErrored === 0,
|
|
892
|
-
t.ok(error,
|
|
891
|
+
t.ok(purgedCountWhenErrored === 0, 'purged no records when specified key was not found');
|
|
892
|
+
t.ok(error, 'error returned when specified key was not found');
|
|
893
893
|
|
|
894
894
|
// make sure other tts keys are still there
|
|
895
|
-
const cached = await client.keys('tts:*')
|
|
896
|
-
t.ok(cached.length >= 1,
|
|
895
|
+
const cached = await client.keys('tts:*');
|
|
896
|
+
t.ok(cached.length >= 1, 'successfully kept all non-specified tts records in cache');
|
|
897
897
|
|
|
898
|
-
|
|
899
|
-
let key = cached[0];
|
|
900
|
-
t.ok(key.includes('mp3'), `tts cache extension shoult be part of the key and equal mp3`);
|
|
901
|
-
|
|
902
|
-
process.env.VG_TRIM_TTS_SILENCE = 'true';
|
|
898
|
+
process.env.VG_TRIM_TTS_SILENCE = 'true';
|
|
903
899
|
await client.set(makeSynthKey({ vendor: 'azure' }), 'value');
|
|
904
|
-
|
|
905
|
-
const r8Keys = await client.keys('tts:r8*');
|
|
906
|
-
key = r8Keys[0];
|
|
907
|
-
t.ok(key.includes('r8'), `tts cache extension shoult be part of the key and equal r8`);
|
|
908
|
-
|
|
909
900
|
} catch (err) {
|
|
910
901
|
console.error(JSON.stringify(err));
|
|
911
902
|
t.end(err);
|