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