@jambonz/speech-utils 0.1.22 → 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/get-aws-sts-token.js +31 -8
- package/lib/synth-audio.js +228 -124
- package/lib/utils.js +4 -52
- package/package.json +2 -1
- package/test/synth.js +39 -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/get-aws-sts-token.js
CHANGED
|
@@ -7,22 +7,23 @@ const CACHE_EXPIRY = process.env.AWS_STS_SESSION_RESET_EXPIRY || (EXPIRY - 600);
|
|
|
7
7
|
|
|
8
8
|
async function getAwsAuthToken(
|
|
9
9
|
logger, createHash, retrieveHash,
|
|
10
|
-
{accessKeyId, secretAccessKey, region, roleArn}) {
|
|
10
|
+
{speech_credential_sid, accessKeyId, secretAccessKey, region, roleArn}) {
|
|
11
11
|
logger = logger || noopLogger;
|
|
12
12
|
try {
|
|
13
|
-
|
|
13
|
+
// if incase instance profile is used, speech_credential_sid will be used as key to lookup cache
|
|
14
|
+
const key = makeAwsKey(roleArn || accessKeyId || speech_credential_sid);
|
|
14
15
|
const obj = await retrieveHash(key);
|
|
15
16
|
if (obj) return {...obj, servedFromCache: true};
|
|
16
|
-
|
|
17
|
+
/* access token not found in cache, so generate it using STS */
|
|
17
18
|
let data;
|
|
19
|
+
let expiry = CACHE_EXPIRY;
|
|
18
20
|
if (roleArn) {
|
|
19
21
|
const stsClient = new STSClient({ region });
|
|
20
22
|
const roleToAssume = { RoleArn: roleArn, RoleSessionName: 'Jambonz_Speech', DurationSeconds: EXPIRY};
|
|
21
23
|
const command = new AssumeRoleCommand(roleToAssume);
|
|
22
24
|
|
|
23
25
|
data = await stsClient.send(command);
|
|
24
|
-
} else {
|
|
25
|
-
/* access token not found in cache, so generate it using STS */
|
|
26
|
+
} else if (accessKeyId) {
|
|
26
27
|
const stsClient = new STSClient({
|
|
27
28
|
region,
|
|
28
29
|
credentials: {
|
|
@@ -32,6 +33,26 @@ async function getAwsAuthToken(
|
|
|
32
33
|
});
|
|
33
34
|
const command = new GetSessionTokenCommand({DurationSeconds: EXPIRY});
|
|
34
35
|
data = await stsClient.send(command);
|
|
36
|
+
} else {
|
|
37
|
+
// instance profile is used.
|
|
38
|
+
const stsClient = new STSClient({ region });
|
|
39
|
+
const cred = await stsClient.config.credentials();
|
|
40
|
+
// method in the AWS SDK automatically fetches credentials using the default credential
|
|
41
|
+
// provider chain. If the credentials come from an instance profile or an environment
|
|
42
|
+
// variable, their expiration is controlled by AWS and not explicitly by our code.
|
|
43
|
+
if (cred && cred.expiration) {
|
|
44
|
+
const currentTime = new Date();
|
|
45
|
+
const expiryTime = new Date(cred.expiration);
|
|
46
|
+
const remainingTimeInSeconds = Math.round((expiryTime - currentTime) / 1000);
|
|
47
|
+
expiry = remainingTimeInSeconds;
|
|
48
|
+
}
|
|
49
|
+
data = {
|
|
50
|
+
Credentials: {
|
|
51
|
+
AccessKeyId: cred.accessKeyId,
|
|
52
|
+
SecretAccessKey: cred.secretAccessKey,
|
|
53
|
+
SessionToken: cred.sessionToken
|
|
54
|
+
}
|
|
55
|
+
};
|
|
35
56
|
}
|
|
36
57
|
|
|
37
58
|
const credentials = {
|
|
@@ -40,9 +61,11 @@ async function getAwsAuthToken(
|
|
|
40
61
|
sessionToken: data.Credentials.SessionToken,
|
|
41
62
|
securityToken: data.Credentials.SessionToken
|
|
42
63
|
};
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
64
|
+
// Only cache if expiry is good
|
|
65
|
+
if (expiry > 0) {
|
|
66
|
+
createHash(key, credentials, expiry)
|
|
67
|
+
.catch((err) => logger.error(err, `Error saving hash for key ${key}`));
|
|
68
|
+
}
|
|
46
69
|
|
|
47
70
|
return {...credentials, servedFromCache: false};
|
|
48
71
|
} catch (err) {
|
package/lib/synth-audio.js
CHANGED
|
@@ -3,6 +3,7 @@ const fs = require('fs');
|
|
|
3
3
|
const bent = require('bent');
|
|
4
4
|
const ttsGoogle = require('@google-cloud/text-to-speech');
|
|
5
5
|
const { PollyClient, SynthesizeSpeechCommand } = require('@aws-sdk/client-polly');
|
|
6
|
+
const { CartesiaClient } = require('@cartesia/cartesia-js');
|
|
6
7
|
|
|
7
8
|
const sdk = require('microsoft-cognitiveservices-speech-sdk');
|
|
8
9
|
const TextToSpeechV1 = require('ibm-watson/text-to-speech/v1');
|
|
@@ -45,7 +46,7 @@ const {
|
|
|
45
46
|
JAMBONES_HTTP_PROXY_IP,
|
|
46
47
|
JAMBONES_HTTP_PROXY_PORT,
|
|
47
48
|
JAMBONES_TTS_CACHE_DURATION_MINS,
|
|
48
|
-
|
|
49
|
+
JAMBONES_TTS_TRIM_SILENCE,
|
|
49
50
|
} = require('./config');
|
|
50
51
|
const EXPIRES = JAMBONES_TTS_CACHE_DURATION_MINS;
|
|
51
52
|
const OpenAI = require('openai');
|
|
@@ -90,13 +91,13 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
90
91
|
vendor, language, voice, gender, text, engine, salt, model, credentials, deploymentId,
|
|
91
92
|
disableTtsCache, renderForCaching = false, disableTtsStreaming, options
|
|
92
93
|
}) {
|
|
93
|
-
let
|
|
94
|
+
let audioData;
|
|
94
95
|
let servedFromCache = false;
|
|
95
96
|
let rtt;
|
|
96
97
|
logger = logger || noopLogger;
|
|
97
98
|
|
|
98
99
|
assert.ok(['google', 'aws', 'polly', 'microsoft', 'wellsaid', 'nuance', 'nvidia', 'ibm', 'elevenlabs',
|
|
99
|
-
'whisper', 'deepgram', 'playht', 'rimelabs', 'verbio'].includes(vendor) ||
|
|
100
|
+
'whisper', 'deepgram', 'playht', 'rimelabs', 'verbio', 'cartesia'].includes(vendor) ||
|
|
100
101
|
vendor.startsWith('custom'),
|
|
101
102
|
`synthAudio supported vendors are google, aws, microsoft, nuance, nvidia and wellsaid ..etc, not ${vendor}`);
|
|
102
103
|
if ('google' === vendor) {
|
|
@@ -157,7 +158,9 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
157
158
|
if (!credentials.deepgram_tts_uri) {
|
|
158
159
|
assert.ok(credentials.api_key, 'synthAudio requires api_key when deepgram is used');
|
|
159
160
|
}
|
|
160
|
-
|
|
161
|
+
} else if ('cartesia' === vendor) {
|
|
162
|
+
assert.ok(credentials.api_key, 'synthAudio requires api_key when cartesia is used');
|
|
163
|
+
assert.ok(credentials.model_id, 'synthAudio requires model_id when cartesia is used');
|
|
161
164
|
}
|
|
162
165
|
|
|
163
166
|
const key = makeSynthKey({
|
|
@@ -166,57 +169,24 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
166
169
|
language: language || '',
|
|
167
170
|
voice: voice || deploymentId,
|
|
168
171
|
engine,
|
|
169
|
-
text
|
|
170
|
-
renderForCaching
|
|
172
|
+
text
|
|
171
173
|
});
|
|
172
|
-
|
|
173
|
-
// used only for custom vendor
|
|
174
|
-
let fileExtension;
|
|
175
|
-
filePath = makeFilePath({vendor, voice, key, salt, renderForCaching});
|
|
174
|
+
|
|
176
175
|
debug(`synth key is ${key}`);
|
|
177
176
|
let cached;
|
|
178
177
|
if (!disableTtsCache) {
|
|
179
178
|
cached = await client.get(key);
|
|
180
|
-
/**
|
|
181
|
-
* If we are using tts streaming and also precaching audio, audio could have been cached by streaming (r8)
|
|
182
|
-
* or here in speech-utils due to precaching (mp3), so we need to check for both keys.
|
|
183
|
-
*/
|
|
184
|
-
if (!cached && JAMBONES_EAGERLY_PRE_CACHE_AUDIO) {
|
|
185
|
-
const preCachekey = makeSynthKey({
|
|
186
|
-
account_sid,
|
|
187
|
-
vendor,
|
|
188
|
-
language: language || '',
|
|
189
|
-
voice: voice || deploymentId,
|
|
190
|
-
engine,
|
|
191
|
-
text,
|
|
192
|
-
renderForCaching: true
|
|
193
|
-
});
|
|
194
|
-
cached = await client.get(preCachekey);
|
|
195
|
-
if (cached) {
|
|
196
|
-
// Precache audio is available update filpath with precache file extension.
|
|
197
|
-
filePath = makeFilePath({vendor, voice, key, salt, renderForCaching: true});
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
179
|
}
|
|
201
180
|
if (cached) {
|
|
202
181
|
// found in cache - extend the expiry and use it
|
|
203
182
|
debug('result WAS found in cache');
|
|
204
183
|
servedFromCache = true;
|
|
205
184
|
stats.increment('tts.cache.requests', ['found:yes']);
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// In cache, file extension is store together with audiBuffer in a json.
|
|
210
|
-
// Normal cache audio will be base64 string
|
|
211
|
-
const payload = JSON.parse(cached);
|
|
212
|
-
filePath = filePath.replace(/\.[^\.]*$/g, payload.fileExtension);
|
|
213
|
-
audioBuffer = Buffer.from(payload.audioBuffer, 'base64');
|
|
214
|
-
} else {
|
|
215
|
-
audioBuffer = Buffer.from(cached, 'base64');
|
|
216
|
-
}
|
|
185
|
+
audioData = JSON.parse(cached);
|
|
186
|
+
// convert base64 audio to buffer
|
|
187
|
+
audioData.audioContent = Buffer.from(audioData.audioContent, 'base64');
|
|
217
188
|
client.expire(key, EXPIRES).catch((err) => logger.info(err, 'Error setting expires'));
|
|
218
|
-
}
|
|
219
|
-
if (!cached) {
|
|
189
|
+
} else {
|
|
220
190
|
// not found in cache - go get it from speech vendor and add to cache
|
|
221
191
|
debug('result was NOT found in cache');
|
|
222
192
|
stats.increment('tts.cache.requests', ['found:no']);
|
|
@@ -224,89 +194,92 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
224
194
|
const startAt = process.hrtime();
|
|
225
195
|
switch (vendor) {
|
|
226
196
|
case 'google':
|
|
227
|
-
|
|
197
|
+
audioData = await synthGoogle(logger, {credentials, stats, language, voice, gender, text});
|
|
228
198
|
break;
|
|
229
199
|
case 'aws':
|
|
230
200
|
case 'polly':
|
|
231
201
|
vendorLabel = 'aws';
|
|
232
|
-
|
|
202
|
+
audioData = await synthPolly(createHash, retrieveHash, logger,
|
|
233
203
|
{credentials, stats, language, voice, text, engine});
|
|
234
204
|
break;
|
|
235
205
|
case 'azure':
|
|
236
206
|
case 'microsoft':
|
|
237
207
|
vendorLabel = 'microsoft';
|
|
238
|
-
|
|
239
|
-
|
|
208
|
+
audioData = await synthMicrosoft(logger, {credentials, stats, language, voice, text, deploymentId,
|
|
209
|
+
renderForCaching, disableTtsStreaming});
|
|
240
210
|
break;
|
|
241
211
|
case 'nuance':
|
|
242
212
|
model = model || 'enhanced';
|
|
243
|
-
|
|
213
|
+
audioData = await synthNuance(client, logger, {credentials, stats, voice, model, text});
|
|
244
214
|
break;
|
|
245
215
|
case 'nvidia':
|
|
246
|
-
|
|
216
|
+
audioData = await synthNvidia(client, logger, {credentials, stats, language, voice, model, text});
|
|
247
217
|
break;
|
|
248
218
|
case 'ibm':
|
|
249
|
-
|
|
219
|
+
audioData = await synthIbm(logger, {credentials, stats, voice, text});
|
|
250
220
|
break;
|
|
251
221
|
case 'wellsaid':
|
|
252
|
-
|
|
222
|
+
audioData = await synthWellSaid(logger, {credentials, stats, language, voice, text});
|
|
253
223
|
break;
|
|
254
224
|
case 'elevenlabs':
|
|
255
|
-
|
|
256
|
-
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming
|
|
257
|
-
});
|
|
225
|
+
audioData = await synthElevenlabs(logger, {
|
|
226
|
+
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
|
|
258
227
|
break;
|
|
259
228
|
case 'playht':
|
|
260
|
-
|
|
261
|
-
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming
|
|
262
|
-
|
|
229
|
+
audioData = await synthPlayHT(client, logger, {
|
|
230
|
+
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
|
|
231
|
+
break;
|
|
232
|
+
case 'cartesia':
|
|
233
|
+
audioData = await synthCartesia(logger, {
|
|
234
|
+
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
|
|
263
235
|
break;
|
|
264
236
|
case 'rimelabs':
|
|
265
|
-
|
|
266
|
-
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming
|
|
267
|
-
});
|
|
237
|
+
audioData = await synthRimelabs(logger, {
|
|
238
|
+
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming});
|
|
268
239
|
break;
|
|
269
240
|
case 'whisper':
|
|
270
|
-
|
|
241
|
+
audioData = await synthWhisper(logger, {
|
|
271
242
|
credentials, stats, voice, text, renderForCaching, disableTtsStreaming});
|
|
272
243
|
break;
|
|
273
244
|
case 'verbio':
|
|
274
|
-
|
|
245
|
+
audioData = await synthVerbio(client, logger, {
|
|
275
246
|
credentials, stats, voice, text, renderForCaching, disableTtsStreaming});
|
|
276
|
-
if (
|
|
247
|
+
if (audioData?.filePath) return audioData;
|
|
277
248
|
break;
|
|
278
249
|
case 'deepgram':
|
|
279
|
-
|
|
250
|
+
audioData = await synthDeepgram(logger, {credentials, stats, model, text,
|
|
280
251
|
renderForCaching, disableTtsStreaming});
|
|
281
252
|
break;
|
|
282
253
|
case vendor.startsWith('custom') ? vendor : 'cant_match_value':
|
|
283
|
-
|
|
284
|
-
{credentials, stats, language, voice, text
|
|
254
|
+
audioData = await synthCustomVendor(logger,
|
|
255
|
+
{credentials, stats, language, voice, text});
|
|
285
256
|
break;
|
|
286
257
|
default:
|
|
287
258
|
assert(`synthAudio: unsupported speech vendor ${vendor}`);
|
|
288
259
|
}
|
|
289
|
-
if ('filePath' in
|
|
260
|
+
if ('filePath' in audioData) return audioData;
|
|
290
261
|
const diff = process.hrtime(startAt);
|
|
291
262
|
const time = diff[0] * 1e3 + diff[1] * 1e-6;
|
|
292
263
|
rtt = time.toFixed(0);
|
|
293
264
|
stats.histogram('tts.response_time', rtt, [`vendor:${vendorLabel}`]);
|
|
294
265
|
debug(`tts rtt time for ${text.length} chars on ${vendorLabel}: ${rtt}`);
|
|
295
266
|
logger.info(`tts rtt time for ${text.length} chars on ${vendorLabel}: ${rtt}`);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
fileExtension
|
|
302
|
-
}) : base64Audio;
|
|
303
|
-
|
|
304
|
-
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
|
+
}))
|
|
305
272
|
.catch((err) => logger.error(err, `error calling setex on key ${key}`));
|
|
306
273
|
}
|
|
307
274
|
|
|
308
275
|
return new Promise((resolve, reject) => {
|
|
309
|
-
|
|
276
|
+
const { audioContent, extension } = audioData;
|
|
277
|
+
const filePath = makeFilePath({
|
|
278
|
+
key,
|
|
279
|
+
salt,
|
|
280
|
+
extension
|
|
281
|
+
});
|
|
282
|
+
fs.writeFile(filePath, audioContent, (err) => {
|
|
310
283
|
if (err) return reject(err);
|
|
311
284
|
resolve({filePath, servedFromCache, rtt});
|
|
312
285
|
});
|
|
@@ -361,7 +334,13 @@ const synthPolly = async(createHash, retrieveHash, logger,
|
|
|
361
334
|
.on('data', (chunk) => {
|
|
362
335
|
chunks.push(chunk);
|
|
363
336
|
})
|
|
364
|
-
.on('end', () => resolve(
|
|
337
|
+
.on('end', () => resolve(
|
|
338
|
+
{
|
|
339
|
+
audioContent: Buffer.concat(chunks),
|
|
340
|
+
extension: 'mp3',
|
|
341
|
+
sampleRate: 8000
|
|
342
|
+
}
|
|
343
|
+
));
|
|
365
344
|
});
|
|
366
345
|
} catch (err) {
|
|
367
346
|
logger.info({err}, 'synthAudio: Error synthesizing speech using aws polly');
|
|
@@ -403,7 +382,11 @@ const synthGoogle = async(logger, {credentials, stats, language, voice, gender,
|
|
|
403
382
|
};
|
|
404
383
|
|
|
405
384
|
const wav = await post('/v1beta1/text:synthesize', payload);
|
|
406
|
-
return
|
|
385
|
+
return {
|
|
386
|
+
audioContent: Buffer.from(wav.audioContent, 'base64'),
|
|
387
|
+
extension: 'wav',
|
|
388
|
+
sampleRate: 24000
|
|
389
|
+
};
|
|
407
390
|
} catch (err) {
|
|
408
391
|
logger.info({err: await err.text()}, 'synthGoogle returned error');
|
|
409
392
|
throw err;
|
|
@@ -424,7 +407,11 @@ const synthGoogle = async(logger, {credentials, stats, language, voice, gender,
|
|
|
424
407
|
const responses = await client.synthesizeSpeech(opts);
|
|
425
408
|
stats.increment('tts.count', ['vendor:google', 'accepted:yes']);
|
|
426
409
|
client.close();
|
|
427
|
-
return
|
|
410
|
+
return {
|
|
411
|
+
audioContent: responses[0].audioContent,
|
|
412
|
+
extension: 'mp3',
|
|
413
|
+
sampleRate: 8000
|
|
414
|
+
};
|
|
428
415
|
} catch (err) {
|
|
429
416
|
console.error(err);
|
|
430
417
|
logger.info({err, opts}, 'synthAudio: Error synthesizing speech using google');
|
|
@@ -455,7 +442,11 @@ const synthIbm = async(logger, {credentials, stats, voice, text}) => {
|
|
|
455
442
|
for await (const chunk of r.result) {
|
|
456
443
|
chunks.push(chunk);
|
|
457
444
|
}
|
|
458
|
-
return
|
|
445
|
+
return {
|
|
446
|
+
audioContent: Buffer.concat(chunks),
|
|
447
|
+
extension: 'mp3',
|
|
448
|
+
sampleRate: 8000
|
|
449
|
+
};
|
|
459
450
|
} catch (err) {
|
|
460
451
|
logger.info({err, params}, 'synthAudio: Error synthesizing speech using ibm');
|
|
461
452
|
stats.increment('tts.count', ['vendor:ibm', 'accepted:no']);
|
|
@@ -465,11 +456,9 @@ const synthIbm = async(logger, {credentials, stats, voice, text}) => {
|
|
|
465
456
|
|
|
466
457
|
async function _synthOnPremMicrosoft(logger, {
|
|
467
458
|
credentials,
|
|
468
|
-
stats,
|
|
469
459
|
language,
|
|
470
460
|
voice,
|
|
471
|
-
text
|
|
472
|
-
filePath
|
|
461
|
+
text
|
|
473
462
|
}) {
|
|
474
463
|
const {use_custom_tts, custom_tts_endpoint_url, api_key} = credentials;
|
|
475
464
|
let content = text;
|
|
@@ -491,15 +480,19 @@ async function _synthOnPremMicrosoft(logger, {
|
|
|
491
480
|
}
|
|
492
481
|
|
|
493
482
|
try {
|
|
494
|
-
const trimSilence =
|
|
483
|
+
const trimSilence = JAMBONES_TTS_TRIM_SILENCE;
|
|
495
484
|
const post = bent('POST', 'buffer', {
|
|
496
485
|
'X-Microsoft-OutputFormat': trimSilence ? 'raw-8khz-16bit-mono-pcm' : 'audio-16khz-32kbitrate-mono-mp3',
|
|
497
486
|
'Content-Type': 'application/ssml+xml',
|
|
498
487
|
'User-Agent': 'Jambonz',
|
|
499
488
|
...(api_key && {'Ocp-Apim-Subscription-Key': api_key})
|
|
500
489
|
});
|
|
501
|
-
const
|
|
502
|
-
return
|
|
490
|
+
const audioContent = await post(custom_tts_endpoint_url, content);
|
|
491
|
+
return {
|
|
492
|
+
audioContent,
|
|
493
|
+
extension: trimSilence ? 'r8' : 'mp3',
|
|
494
|
+
sampleRate: 8000
|
|
495
|
+
};
|
|
503
496
|
} catch (err) {
|
|
504
497
|
logger.info({err}, '_synthMicrosoftByHttp returned error');
|
|
505
498
|
throw err;
|
|
@@ -512,7 +505,6 @@ const synthMicrosoft = async(logger, {
|
|
|
512
505
|
language,
|
|
513
506
|
voice,
|
|
514
507
|
text,
|
|
515
|
-
filePath,
|
|
516
508
|
renderForCaching,
|
|
517
509
|
disableTtsStreaming
|
|
518
510
|
}) => {
|
|
@@ -555,17 +547,18 @@ const synthMicrosoft = async(logger, {
|
|
|
555
547
|
rtt: 0
|
|
556
548
|
};
|
|
557
549
|
}
|
|
550
|
+
// Azure Onprem
|
|
558
551
|
if (use_custom_tts && custom_tts_endpoint_url) {
|
|
559
552
|
return await _synthOnPremMicrosoft(logger, {
|
|
560
553
|
credentials,
|
|
561
554
|
stats,
|
|
562
555
|
language,
|
|
563
556
|
voice,
|
|
564
|
-
text
|
|
565
|
-
filePath
|
|
557
|
+
text
|
|
566
558
|
});
|
|
567
559
|
}
|
|
568
|
-
|
|
560
|
+
// Azure hosted service
|
|
561
|
+
const trimSilence = JAMBONES_TTS_TRIM_SILENCE;
|
|
569
562
|
const speechConfig = SpeechConfig.fromSubscription(apiKey, region);
|
|
570
563
|
speechConfig.speechSynthesisLanguage = language;
|
|
571
564
|
speechConfig.speechSynthesisVoiceName = voice;
|
|
@@ -600,7 +593,11 @@ const synthMicrosoft = async(logger, {
|
|
|
600
593
|
case ResultReason.SynthesizingAudioCompleted:
|
|
601
594
|
let buffer = Buffer.from(result.audioData);
|
|
602
595
|
if (trimSilence) buffer = trimTrailingSilence(buffer);
|
|
603
|
-
resolve(
|
|
596
|
+
resolve({
|
|
597
|
+
audioContent: buffer,
|
|
598
|
+
extension: trimSilence ? 'r8' : 'mp3',
|
|
599
|
+
sampleRate: 8000
|
|
600
|
+
});
|
|
604
601
|
synthesizer.close();
|
|
605
602
|
stats.increment('tts.count', ['vendor:microsoft', 'accepted:yes']);
|
|
606
603
|
break;
|
|
@@ -630,11 +627,15 @@ const synthWellSaid = async(logger, {credentials, stats, language, voice, gender
|
|
|
630
627
|
'Accept': 'audio/mpeg',
|
|
631
628
|
'Content-Type': 'application/json'
|
|
632
629
|
});
|
|
633
|
-
const
|
|
630
|
+
const audioContent = await post('/v1/tts/stream', {
|
|
634
631
|
text,
|
|
635
632
|
speaker_id: voice
|
|
636
633
|
});
|
|
637
|
-
return
|
|
634
|
+
return {
|
|
635
|
+
audioContent,
|
|
636
|
+
extension: 'mp3',
|
|
637
|
+
sampleRate: 8000
|
|
638
|
+
};
|
|
638
639
|
} catch (err) {
|
|
639
640
|
logger.info({err}, 'testWellSaidTts returned error');
|
|
640
641
|
throw err;
|
|
@@ -671,8 +672,8 @@ const synthNuance = async(client, logger, {credentials, stats, voice, model, tex
|
|
|
671
672
|
t.setText(text);
|
|
672
673
|
input.setText(t);
|
|
673
674
|
}
|
|
674
|
-
|
|
675
|
-
pcm.setSampleRateHz(
|
|
675
|
+
const sampleRate = 8000;
|
|
676
|
+
pcm.setSampleRateHz(sampleRate);
|
|
676
677
|
f.setPcm(pcm);
|
|
677
678
|
p.setAudioFormat(f);
|
|
678
679
|
v.setName(voice);
|
|
@@ -696,7 +697,11 @@ const synthNuance = async(client, logger, {credentials, stats, voice, model, tex
|
|
|
696
697
|
const details = status.getDetails();
|
|
697
698
|
return reject({code, message, details});
|
|
698
699
|
}
|
|
699
|
-
resolve(
|
|
700
|
+
resolve({
|
|
701
|
+
audioContent: Buffer.from(response.getAudio()),
|
|
702
|
+
extension: 'r8',
|
|
703
|
+
sampleRate
|
|
704
|
+
});
|
|
700
705
|
});
|
|
701
706
|
});
|
|
702
707
|
};
|
|
@@ -704,12 +709,13 @@ const synthNuance = async(client, logger, {credentials, stats, voice, model, tex
|
|
|
704
709
|
const synthNvidia = async(client, logger, {credentials, stats, language, voice, model, text}) => {
|
|
705
710
|
const {riva_server_uri} = credentials;
|
|
706
711
|
let rivaClient, request;
|
|
712
|
+
const sampleRate = 8000;
|
|
707
713
|
try {
|
|
708
714
|
rivaClient = await createRivaClient(riva_server_uri);
|
|
709
715
|
request = new SynthesizeSpeechRequest();
|
|
710
716
|
request.setVoiceName(voice);
|
|
711
717
|
request.setLanguageCode(language);
|
|
712
|
-
request.setSampleRateHz(
|
|
718
|
+
request.setSampleRateHz(sampleRate);
|
|
713
719
|
request.setEncoding(AudioEncoding.LINEAR_PCM);
|
|
714
720
|
request.setText(text);
|
|
715
721
|
} catch (err) {
|
|
@@ -723,7 +729,11 @@ const synthNvidia = async(client, logger, {credentials, stats, language, voice,
|
|
|
723
729
|
logger.info({err, voice, language}, 'error synthesizing speech using Nvidia');
|
|
724
730
|
return reject(err);
|
|
725
731
|
}
|
|
726
|
-
resolve(
|
|
732
|
+
resolve({
|
|
733
|
+
audioContent: Buffer.from(response.getAudio()),
|
|
734
|
+
extension: 'r8',
|
|
735
|
+
sampleRate
|
|
736
|
+
});
|
|
727
737
|
});
|
|
728
738
|
});
|
|
729
739
|
};
|
|
@@ -745,14 +755,13 @@ const synthCustomVendor = async(logger, {credentials, stats, language, voice, te
|
|
|
745
755
|
text
|
|
746
756
|
});
|
|
747
757
|
|
|
748
|
-
const regex = /\.[^\.]*$/g;
|
|
749
758
|
const mime = response.headers['content-type'];
|
|
750
759
|
const buffer = await response.arrayBuffer();
|
|
751
|
-
const
|
|
760
|
+
const [extension, sampleRate] = getFileExtFromMime(mime);
|
|
752
761
|
return {
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
762
|
+
audioContent: buffer,
|
|
763
|
+
extension,
|
|
764
|
+
sampleRate
|
|
756
765
|
};
|
|
757
766
|
} catch (err) {
|
|
758
767
|
logger.info({err}, `Vendor ${vendor} returned error`);
|
|
@@ -798,7 +807,7 @@ const synthElevenlabs = async(logger, {
|
|
|
798
807
|
'Accept': 'audio/mpeg',
|
|
799
808
|
'Content-Type': 'application/json'
|
|
800
809
|
});
|
|
801
|
-
const
|
|
810
|
+
const audioContent = await post(`/v1/text-to-speech/${voice}${optimize_streaming_latency}`, {
|
|
802
811
|
text,
|
|
803
812
|
model_id,
|
|
804
813
|
voice_settings: {
|
|
@@ -807,7 +816,11 @@ const synthElevenlabs = async(logger, {
|
|
|
807
816
|
},
|
|
808
817
|
...opts
|
|
809
818
|
});
|
|
810
|
-
return
|
|
819
|
+
return {
|
|
820
|
+
audioContent,
|
|
821
|
+
extension: 'mp3',
|
|
822
|
+
sampleRate: 8000
|
|
823
|
+
};
|
|
811
824
|
} catch (err) {
|
|
812
825
|
logger.info({err}, 'synth Elevenlabs returned error');
|
|
813
826
|
stats.increment('tts.count', ['vendor:elevenlabs', 'accepted:no']);
|
|
@@ -889,7 +902,7 @@ const synthPlayHT = async(client, logger, {
|
|
|
889
902
|
'Content-Type': 'application/json'
|
|
890
903
|
});
|
|
891
904
|
|
|
892
|
-
const
|
|
905
|
+
const audioContent = await post(synthesizeUrl, {
|
|
893
906
|
text,
|
|
894
907
|
...(voice_engine === 'Play3.0' && { language }),
|
|
895
908
|
voice,
|
|
@@ -898,7 +911,11 @@ const synthPlayHT = async(client, logger, {
|
|
|
898
911
|
sample_rate: 8000,
|
|
899
912
|
...opts
|
|
900
913
|
});
|
|
901
|
-
return
|
|
914
|
+
return {
|
|
915
|
+
audioContent,
|
|
916
|
+
extension: 'mp3',
|
|
917
|
+
sampleRate: 8000
|
|
918
|
+
};
|
|
902
919
|
} catch (err) {
|
|
903
920
|
logger.info({err}, 'synth PlayHT returned error');
|
|
904
921
|
stats.increment('tts.count', ['vendor:playht', 'accepted:no']);
|
|
@@ -937,14 +954,19 @@ const synthRimelabs = async(logger, {
|
|
|
937
954
|
'Accept': 'audio/mp3',
|
|
938
955
|
'Content-Type': 'application/json'
|
|
939
956
|
});
|
|
940
|
-
const
|
|
957
|
+
const sampleRate = 8000;
|
|
958
|
+
const audioContent = await post('/v1/rime-tts', {
|
|
941
959
|
speaker: voice,
|
|
942
960
|
text,
|
|
943
961
|
modelId: model_id,
|
|
944
|
-
samplingRate:
|
|
962
|
+
samplingRate: sampleRate,
|
|
945
963
|
...opts
|
|
946
964
|
});
|
|
947
|
-
return
|
|
965
|
+
return {
|
|
966
|
+
audioContent,
|
|
967
|
+
extension: 'mp3',
|
|
968
|
+
sampleRate
|
|
969
|
+
};
|
|
948
970
|
} catch (err) {
|
|
949
971
|
logger.info({err}, 'synth rimelabs returned error');
|
|
950
972
|
stats.increment('tts.count', ['vendor:rimelabs', 'accepted:no']);
|
|
@@ -978,13 +1000,17 @@ const synthVerbio = async(client, logger, {credentials, stats, voice, text, rend
|
|
|
978
1000
|
'User-Agent': 'jambonz',
|
|
979
1001
|
'Content-Type': 'application/json'
|
|
980
1002
|
});
|
|
981
|
-
const
|
|
1003
|
+
const audioContent = await post('/api/v1/synthesize', {
|
|
982
1004
|
voice_id: voice,
|
|
983
1005
|
output_sample_rate: '8k',
|
|
984
1006
|
output_encoding: 'pcm16',
|
|
985
1007
|
text
|
|
986
1008
|
});
|
|
987
|
-
return
|
|
1009
|
+
return {
|
|
1010
|
+
audioContent,
|
|
1011
|
+
extension: 'r8',
|
|
1012
|
+
sampleRate: 8000
|
|
1013
|
+
};
|
|
988
1014
|
} catch (err) {
|
|
989
1015
|
logger.info({err}, 'synth Verbio returned error');
|
|
990
1016
|
stats.increment('tts.count', ['vendor:verbio', 'accepted:no']);
|
|
@@ -1024,7 +1050,11 @@ const synthWhisper = async(logger, {credentials, stats, voice, text, renderForCa
|
|
|
1024
1050
|
input: text,
|
|
1025
1051
|
response_format: 'mp3'
|
|
1026
1052
|
});
|
|
1027
|
-
return
|
|
1053
|
+
return {
|
|
1054
|
+
audioContent: Buffer.from(await mp3.arrayBuffer()),
|
|
1055
|
+
extension: 'mp3',
|
|
1056
|
+
sampleRate: 8000
|
|
1057
|
+
};
|
|
1028
1058
|
} catch (err) {
|
|
1029
1059
|
logger.info({err}, 'synth whisper returned error');
|
|
1030
1060
|
stats.increment('tts.count', ['vendor:openai', 'accepted:no']);
|
|
@@ -1056,10 +1086,14 @@ const synthDeepgram = async(logger, {credentials, stats, model, text, renderForC
|
|
|
1056
1086
|
'Accept': 'audio/mpeg',
|
|
1057
1087
|
'Content-Type': 'application/json'
|
|
1058
1088
|
});
|
|
1059
|
-
const
|
|
1089
|
+
const audioContent = await post(`/v1/speak?model=${model}`, {
|
|
1060
1090
|
text
|
|
1061
1091
|
});
|
|
1062
|
-
return
|
|
1092
|
+
return {
|
|
1093
|
+
audioContent,
|
|
1094
|
+
extension: 'mp3',
|
|
1095
|
+
sampleRate: 8000
|
|
1096
|
+
};
|
|
1063
1097
|
} catch (err) {
|
|
1064
1098
|
logger.info({err}, 'synth Deepgram returned error');
|
|
1065
1099
|
stats.increment('tts.count', ['vendor:deepgram', 'accepted:no']);
|
|
@@ -1067,26 +1101,96 @@ const synthDeepgram = async(logger, {credentials, stats, model, text, renderForC
|
|
|
1067
1101
|
}
|
|
1068
1102
|
};
|
|
1069
1103
|
|
|
1104
|
+
const synthCartesia = async(logger, {
|
|
1105
|
+
credentials, options, stats, voice, language, text, renderForCaching, disableTtsStreaming
|
|
1106
|
+
}) => {
|
|
1107
|
+
const {api_key, model_id, embedding, options: credOpts} = credentials;
|
|
1108
|
+
const opts = !!options && Object.keys(options).length !== 0 ? options : JSON.parse(credOpts || '{}');
|
|
1109
|
+
|
|
1110
|
+
if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
|
|
1111
|
+
let params = '';
|
|
1112
|
+
params += `{api_key=${api_key}`;
|
|
1113
|
+
params += `,model_id=${model_id}`;
|
|
1114
|
+
params += ',vendor=cartesia';
|
|
1115
|
+
params += `,voice=${voice}`;
|
|
1116
|
+
params += ',write_cache_file=1';
|
|
1117
|
+
params += `,language=${language}`;
|
|
1118
|
+
params += `,voice_mode=${embedding ? 'embedding' : 'id'}`;
|
|
1119
|
+
if (embedding) params += `,embedding=${embedding}`;
|
|
1120
|
+
if (opts.speed) params += `,speed=${opts.speed}`;
|
|
1121
|
+
if (opts.emotion) params += `,emotion=${opts.emotion}`;
|
|
1122
|
+
params += '}';
|
|
1123
|
+
|
|
1124
|
+
return {
|
|
1125
|
+
filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
|
|
1126
|
+
servedFromCache: false,
|
|
1127
|
+
rtt: 0
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
try {
|
|
1132
|
+
const client = new CartesiaClient({ apiKey: api_key });
|
|
1133
|
+
const sampleRate = 48000;
|
|
1134
|
+
const mp3 = await client.tts.bytes({
|
|
1135
|
+
modelId: model_id,
|
|
1136
|
+
transcript: text,
|
|
1137
|
+
voice: {
|
|
1138
|
+
mode: embedding ? 'embedding' : 'id',
|
|
1139
|
+
...(embedding ?
|
|
1140
|
+
{
|
|
1141
|
+
embedding: embedding.split(',').map(Number)
|
|
1142
|
+
} :
|
|
1143
|
+
{
|
|
1144
|
+
id: voice
|
|
1145
|
+
}
|
|
1146
|
+
),
|
|
1147
|
+
...(opts.speed || opts.emotion && {
|
|
1148
|
+
experimentalControls: {
|
|
1149
|
+
...(opts.speed !== null && opts.speed !== undefined && {speed: opts.speed}),
|
|
1150
|
+
...(opts.emotion && {emotion: opts.emotion}),
|
|
1151
|
+
}
|
|
1152
|
+
})
|
|
1153
|
+
},
|
|
1154
|
+
language: language,
|
|
1155
|
+
outputFormat: {
|
|
1156
|
+
container: 'mp3',
|
|
1157
|
+
bitRate: 128000,
|
|
1158
|
+
sampleRate
|
|
1159
|
+
},
|
|
1160
|
+
});
|
|
1161
|
+
return {
|
|
1162
|
+
audioContent: Buffer.from(mp3),
|
|
1163
|
+
extension: 'mp3',
|
|
1164
|
+
sampleRate
|
|
1165
|
+
};
|
|
1166
|
+
} catch (err) {
|
|
1167
|
+
logger.info({err}, 'synth Cartesia returned error');
|
|
1168
|
+
stats.increment('tts.count', ['vendor:cartesia', 'accepted:no']);
|
|
1169
|
+
throw err;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1070
1174
|
const getFileExtFromMime = (mime) => {
|
|
1071
1175
|
switch (mime) {
|
|
1072
1176
|
case 'audio/wav':
|
|
1073
1177
|
case 'audio/x-wav':
|
|
1074
|
-
return '
|
|
1178
|
+
return ['wav', 8000];
|
|
1075
1179
|
case /audio\/l16.*rate=8000/.test(mime) ? mime : 'cant match value':
|
|
1076
|
-
return '
|
|
1180
|
+
return ['r8', 8000];
|
|
1077
1181
|
case /audio\/l16.*rate=16000/.test(mime) ? mime : 'cant match value':
|
|
1078
|
-
return '
|
|
1182
|
+
return ['r16', 16000];
|
|
1079
1183
|
case /audio\/l16.*rate=24000/.test(mime) ? mime : 'cant match value':
|
|
1080
|
-
return '
|
|
1184
|
+
return ['r24', 24000];
|
|
1081
1185
|
case /audio\/l16.*rate=32000/.test(mime) ? mime : 'cant match value':
|
|
1082
|
-
return '
|
|
1186
|
+
return ['r32', 32000];
|
|
1083
1187
|
case /audio\/l16.*rate=48000/.test(mime) ? mime : 'cant match value':
|
|
1084
|
-
return '
|
|
1188
|
+
return ['r48', 48000];
|
|
1085
1189
|
case 'audio/mpeg':
|
|
1086
1190
|
case 'audio/mp3':
|
|
1087
|
-
return '
|
|
1191
|
+
return ['mp3', 8000];
|
|
1088
1192
|
default:
|
|
1089
|
-
return '
|
|
1193
|
+
return ['wav', 8000];
|
|
1090
1194
|
}
|
|
1091
1195
|
};
|
|
1092
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,67 +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
|
-
if (renderForCaching || JAMBONES_DISABLE_TTS_STREAMING) {
|
|
54
|
-
return mp3Extension;
|
|
55
|
-
} else {
|
|
56
|
-
return r8Extension;
|
|
57
|
-
}
|
|
58
|
-
case 'nuance':
|
|
59
|
-
case 'nvidia':
|
|
60
|
-
case 'verbio':
|
|
61
|
-
return r8Extension;
|
|
62
|
-
case 'google':
|
|
63
|
-
// google voice cloning just support wav.
|
|
64
|
-
if (typeof voice === 'object' && voice.voice_cloning_key) {
|
|
65
|
-
return wavExtension;
|
|
66
|
-
} else {
|
|
67
|
-
return mp3Extension;
|
|
68
|
-
}
|
|
69
|
-
default:
|
|
70
|
-
// If vendor is custom
|
|
71
|
-
if (vendor.startsWith('custom')) {
|
|
72
|
-
if (renderForCaching || JAMBONES_DISABLE_TTS_STREAMING) {
|
|
73
|
-
return mp3Extension;
|
|
74
|
-
} else {
|
|
75
|
-
return r8Extension;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return mp3Extension;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
33
|
|
|
82
34
|
const noopLogger = {
|
|
83
35
|
info: () => {},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jambonz/speech-utils",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "TTS-related speech utilities for jambonz",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"author": "Dave Horton",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@aws-sdk/client-polly": "^3.496.0",
|
|
30
30
|
"@aws-sdk/client-sts": "^3.496.0",
|
|
31
|
+
"@cartesia/cartesia-js": "^2.1.0",
|
|
31
32
|
"@google-cloud/text-to-speech": "^5.5.0",
|
|
32
33
|
"@grpc/grpc-js": "^1.9.14",
|
|
33
34
|
"@jambonz/realtimedb-helpers": "^0.8.7",
|
package/test/synth.js
CHANGED
|
@@ -686,6 +686,40 @@ test('PlayHT3.0 speech synth tests', async(t) => {
|
|
|
686
686
|
await testPlayHT(t, 'Play3.0');
|
|
687
687
|
});
|
|
688
688
|
|
|
689
|
+
test('Cartesia speech synth tests', async(t) => {
|
|
690
|
+
const fn = require('..');
|
|
691
|
+
const {synthAudio, client} = fn(opts, logger);
|
|
692
|
+
|
|
693
|
+
if (!process.env.CARTESIA_API_KEY) {
|
|
694
|
+
t.pass('skipping Cartesia speech synth tests since CARTESIA_API_KEY is not provided');
|
|
695
|
+
return t.end();
|
|
696
|
+
}
|
|
697
|
+
const text = 'Hi there and welcome to jambones! ' + Date.now();
|
|
698
|
+
try {
|
|
699
|
+
const opts = await synthAudio(stats, {
|
|
700
|
+
vendor: 'cartesia',
|
|
701
|
+
credentials: {
|
|
702
|
+
api_key: process.env.CARTESIA_API_KEY,
|
|
703
|
+
model_id: 'sonic-english',
|
|
704
|
+
options: JSON.stringify({
|
|
705
|
+
speed: 1,
|
|
706
|
+
emotion: 'female_happy',
|
|
707
|
+
})
|
|
708
|
+
},
|
|
709
|
+
language: 'en',
|
|
710
|
+
voice: '694f9389-aac1-45b6-b726-9d9369183238',
|
|
711
|
+
text,
|
|
712
|
+
renderForCaching: true
|
|
713
|
+
});
|
|
714
|
+
t.ok(!opts.servedFromCache, `successfully playht eleven audio to ${opts.filePath}`);
|
|
715
|
+
|
|
716
|
+
} catch (err) {
|
|
717
|
+
console.error(JSON.stringify(err));
|
|
718
|
+
t.end(err);
|
|
719
|
+
}
|
|
720
|
+
client.quit();
|
|
721
|
+
});
|
|
722
|
+
|
|
689
723
|
test('rimelabs speech synth tests', async(t) => {
|
|
690
724
|
const fn = require('..');
|
|
691
725
|
const {synthAudio, client} = fn(opts, logger);
|
|
@@ -854,24 +888,15 @@ test('TTS Cache tests', async(t) => {
|
|
|
854
888
|
language: 'non-existing',
|
|
855
889
|
voice: 'non-existing',
|
|
856
890
|
});
|
|
857
|
-
t.ok(purgedCountWhenErrored === 0,
|
|
858
|
-
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');
|
|
859
893
|
|
|
860
894
|
// make sure other tts keys are still there
|
|
861
|
-
const cached = await client.keys('tts:*')
|
|
862
|
-
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');
|
|
863
897
|
|
|
864
|
-
|
|
865
|
-
let key = cached[0];
|
|
866
|
-
t.ok(key.includes('mp3'), `tts cache extension shoult be part of the key and equal mp3`);
|
|
867
|
-
|
|
868
|
-
process.env.VG_TRIM_TTS_SILENCE = 'true';
|
|
898
|
+
process.env.VG_TRIM_TTS_SILENCE = 'true';
|
|
869
899
|
await client.set(makeSynthKey({ vendor: 'azure' }), 'value');
|
|
870
|
-
|
|
871
|
-
const r8Keys = await client.keys('tts:r8*');
|
|
872
|
-
key = r8Keys[0];
|
|
873
|
-
t.ok(key.includes('r8'), `tts cache extension shoult be part of the key and equal r8`);
|
|
874
|
-
|
|
875
900
|
} catch (err) {
|
|
876
901
|
console.error(JSON.stringify(err));
|
|
877
902
|
t.end(err);
|