@jambonz/speech-utils 0.1.20 → 0.1.22
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/synth-audio.js +65 -6
- package/lib/utils.js +12 -4
- package/package.json +2 -2
- package/test/synth.js +65 -4
package/lib/synth-audio.js
CHANGED
|
@@ -170,7 +170,9 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
170
170
|
renderForCaching
|
|
171
171
|
});
|
|
172
172
|
let filePath;
|
|
173
|
-
|
|
173
|
+
// used only for custom vendor
|
|
174
|
+
let fileExtension;
|
|
175
|
+
filePath = makeFilePath({vendor, voice, key, salt, renderForCaching});
|
|
174
176
|
debug(`synth key is ${key}`);
|
|
175
177
|
let cached;
|
|
176
178
|
if (!disableTtsCache) {
|
|
@@ -192,7 +194,7 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
192
194
|
cached = await client.get(preCachekey);
|
|
193
195
|
if (cached) {
|
|
194
196
|
// Precache audio is available update filpath with precache file extension.
|
|
195
|
-
filePath = makeFilePath({vendor, key, salt, renderForCaching: true});
|
|
197
|
+
filePath = makeFilePath({vendor, voice, key, salt, renderForCaching: true});
|
|
196
198
|
}
|
|
197
199
|
}
|
|
198
200
|
}
|
|
@@ -201,7 +203,17 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
201
203
|
debug('result WAS found in cache');
|
|
202
204
|
servedFromCache = true;
|
|
203
205
|
stats.increment('tts.cache.requests', ['found:yes']);
|
|
204
|
-
|
|
206
|
+
if (vendor.startsWith('custom')) {
|
|
207
|
+
// custom vendors support multiple mime types such as: mp3, wav, r8, r16 ...etc,
|
|
208
|
+
// mime type/file extension is available when http response has header Content-type.
|
|
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
|
+
}
|
|
205
217
|
client.expire(key, EXPIRES).catch((err) => logger.info(err, 'Error setting expires'));
|
|
206
218
|
}
|
|
207
219
|
if (!cached) {
|
|
@@ -268,7 +280,7 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
268
280
|
renderForCaching, disableTtsStreaming});
|
|
269
281
|
break;
|
|
270
282
|
case vendor.startsWith('custom') ? vendor : 'cant_match_value':
|
|
271
|
-
({ audioBuffer, filePath } = await synthCustomVendor(logger,
|
|
283
|
+
({ audioBuffer, filePath, fileExtension } = await synthCustomVendor(logger,
|
|
272
284
|
{credentials, stats, language, voice, text, filePath}));
|
|
273
285
|
break;
|
|
274
286
|
default:
|
|
@@ -282,7 +294,14 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
282
294
|
debug(`tts rtt time for ${text.length} chars on ${vendorLabel}: ${rtt}`);
|
|
283
295
|
logger.info(`tts rtt time for ${text.length} chars on ${vendorLabel}: ${rtt}`);
|
|
284
296
|
|
|
285
|
-
|
|
297
|
+
const base64Audio = audioBuffer.toString('base64');
|
|
298
|
+
const cacheContent = vendor.startsWith('custom') ?
|
|
299
|
+
JSON.stringify({
|
|
300
|
+
audioBuffer: base64Audio,
|
|
301
|
+
fileExtension
|
|
302
|
+
}) : base64Audio;
|
|
303
|
+
|
|
304
|
+
client.setex(key, EXPIRES, cacheContent)
|
|
286
305
|
.catch((err) => logger.error(err, `error calling setex on key ${key}`));
|
|
287
306
|
}
|
|
288
307
|
|
|
@@ -353,6 +372,44 @@ const synthPolly = async(createHash, retrieveHash, logger,
|
|
|
353
372
|
|
|
354
373
|
const synthGoogle = async(logger, {credentials, stats, language, voice, gender, text}) => {
|
|
355
374
|
const client = new ttsGoogle.TextToSpeechClient(credentials);
|
|
375
|
+
// If google custom voice cloning is used.
|
|
376
|
+
// At this time 31 Oct 2024, google node sdk has not support voice cloning yet.
|
|
377
|
+
if (typeof voice === 'object' && voice.voice_cloning_key) {
|
|
378
|
+
try {
|
|
379
|
+
const accessToken = await client.auth.getAccessToken();
|
|
380
|
+
const projectId = await client.getProjectId();
|
|
381
|
+
|
|
382
|
+
const post = bent('https://texttospeech.googleapis.com', 'POST', 'json', {
|
|
383
|
+
'Authorization': `Bearer ${accessToken}`,
|
|
384
|
+
'x-goog-user-project': projectId,
|
|
385
|
+
'Content-Type': 'application/json; charset=utf-8'
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const payload = {
|
|
389
|
+
input: {
|
|
390
|
+
text
|
|
391
|
+
},
|
|
392
|
+
voice: {
|
|
393
|
+
language_code: language,
|
|
394
|
+
voice_clone: {
|
|
395
|
+
voice_cloning_key: voice.voice_cloning_key
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
audioConfig: {
|
|
399
|
+
// Cloning voice at this time still in v1 beta version, and it support LINEAR16 in Wav format, 24.000Hz
|
|
400
|
+
audioEncoding: 'LINEAR16',
|
|
401
|
+
sample_rate_hertz: 24000
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const wav = await post('/v1beta1/text:synthesize', payload);
|
|
406
|
+
return Buffer.from(wav.audioContent, 'base64');
|
|
407
|
+
} catch (err) {
|
|
408
|
+
logger.info({err: await err.text()}, 'synthGoogle returned error');
|
|
409
|
+
throw err;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
356
413
|
const opts = {
|
|
357
414
|
voice: {
|
|
358
415
|
...(typeof voice === 'string' && {name: voice}),
|
|
@@ -691,9 +748,11 @@ const synthCustomVendor = async(logger, {credentials, stats, language, voice, te
|
|
|
691
748
|
const regex = /\.[^\.]*$/g;
|
|
692
749
|
const mime = response.headers['content-type'];
|
|
693
750
|
const buffer = await response.arrayBuffer();
|
|
751
|
+
const fileExtension = getFileExtFromMime(mime);
|
|
694
752
|
return {
|
|
695
753
|
audioBuffer: buffer,
|
|
696
|
-
filePath: filePath.replace(regex,
|
|
754
|
+
filePath: filePath.replace(regex, fileExtension),
|
|
755
|
+
fileExtension
|
|
697
756
|
};
|
|
698
757
|
} catch (err) {
|
|
699
758
|
logger.info({err}, `Vendor ${vendor} returned error`);
|
package/lib/utils.js
CHANGED
|
@@ -23,19 +23,20 @@ function makeSynthKey({
|
|
|
23
23
|
hash.update(`${language}:${vendor}:${voice}:${engine}:${text}`);
|
|
24
24
|
const hexHashKey = hash.digest('hex');
|
|
25
25
|
const accountKey = account_sid ? `:${account_sid}` : '';
|
|
26
|
-
const namespace = vendor.startsWith('custom') ? vendor : getFileExtension({vendor, renderForCaching});
|
|
26
|
+
const namespace = vendor.startsWith('custom') ? vendor : getFileExtension({vendor, voice, renderForCaching});
|
|
27
27
|
const key = `tts${accountKey}:${namespace}:${hexHashKey}`;
|
|
28
28
|
return key;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
function makeFilePath({vendor, key, salt = '', renderForCaching = false}) {
|
|
32
|
-
const extension = getFileExtension({vendor, renderForCaching});
|
|
31
|
+
function makeFilePath({vendor, voice, key, salt = '', renderForCaching = false}) {
|
|
32
|
+
const extension = getFileExtension({vendor, renderForCaching, voice});
|
|
33
33
|
return `${TMP_FOLDER}/${key.replace('tts:', `tts-${salt}`)}.${extension}`;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
function getFileExtension({vendor, renderForCaching = false}) {
|
|
36
|
+
function getFileExtension({vendor, voice, renderForCaching = false}) {
|
|
37
37
|
const mp3Extension = 'mp3';
|
|
38
38
|
const r8Extension = 'r8';
|
|
39
|
+
const wavExtension = 'wav';
|
|
39
40
|
|
|
40
41
|
switch (vendor) {
|
|
41
42
|
case 'azure':
|
|
@@ -58,6 +59,13 @@ function getFileExtension({vendor, renderForCaching = false}) {
|
|
|
58
59
|
case 'nvidia':
|
|
59
60
|
case 'verbio':
|
|
60
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
|
+
}
|
|
61
69
|
default:
|
|
62
70
|
// If vendor is custom
|
|
63
71
|
if (vendor.startsWith('custom')) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jambonz/speech-utils",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"description": "TTS-related speech utilities for jambonz",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"author": "Dave Horton",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@aws-sdk/client-polly": "^3.496.0",
|
|
30
30
|
"@aws-sdk/client-sts": "^3.496.0",
|
|
31
|
-
"@google-cloud/text-to-speech": "^5.0
|
|
31
|
+
"@google-cloud/text-to-speech": "^5.5.0",
|
|
32
32
|
"@grpc/grpc-js": "^1.9.14",
|
|
33
33
|
"@jambonz/realtimedb-helpers": "^0.8.7",
|
|
34
34
|
"bent": "^7.3.12",
|
package/test/synth.js
CHANGED
|
@@ -91,14 +91,17 @@ test('Google speech Custom voice synth tests', async(t) => {
|
|
|
91
91
|
const fn = require('..');
|
|
92
92
|
const {synthAudio, client} = fn(opts, logger);
|
|
93
93
|
|
|
94
|
-
if (!process.env.GCP_CUSTOM_VOICE_FILE &&
|
|
95
|
-
|
|
94
|
+
if (!process.env.GCP_CUSTOM_VOICE_FILE &&
|
|
95
|
+
!process.env.GCP_CUSTOM_VOICE_JSON_KEY ||
|
|
96
|
+
!process.env.GCP_CUSTOM_VOICE_MODEL) {
|
|
97
|
+
t.pass(`skipping google speech synth tests since neither
|
|
98
|
+
GCP_CUSTOM_VOICE_FILE nor GCP_CUSTOM_VOICE_JSON_KEY provided, GCP_CUSTOM_VOICE_MODEL is not provided`);
|
|
96
99
|
return t.end();
|
|
97
100
|
}
|
|
98
101
|
try {
|
|
99
102
|
const str = process.env.GCP_CUSTOM_VOICE_JSON_KEY || fs.readFileSync(process.env.GCP_CUSTOM_VOICE_FILE);
|
|
100
103
|
const creds = JSON.parse(str);
|
|
101
|
-
|
|
104
|
+
const opts = await synthAudio(stats, {
|
|
102
105
|
vendor: 'google',
|
|
103
106
|
credentials: {
|
|
104
107
|
credentials: {
|
|
@@ -109,7 +112,7 @@ test('Google speech Custom voice synth tests', async(t) => {
|
|
|
109
112
|
language: 'en-AU',
|
|
110
113
|
text: 'This is a test. This is only a test',
|
|
111
114
|
voice: {
|
|
112
|
-
reportedUsage:
|
|
115
|
+
reportedUsage: 'REALTIME',
|
|
113
116
|
model: process.env.GCP_CUSTOM_VOICE_MODEL
|
|
114
117
|
}
|
|
115
118
|
});
|
|
@@ -121,6 +124,48 @@ test('Google speech Custom voice synth tests', async(t) => {
|
|
|
121
124
|
client.quit();
|
|
122
125
|
});
|
|
123
126
|
|
|
127
|
+
test('Google speech voice cloning synth tests', async(t) => {
|
|
128
|
+
const fn = require('..');
|
|
129
|
+
const {synthAudio, client} = fn(opts, logger);
|
|
130
|
+
|
|
131
|
+
if (!process.env.GCP_CUSTOM_VOICE_FILE &&
|
|
132
|
+
!process.env.GCP_CUSTOM_VOICE_JSON_KEY ||
|
|
133
|
+
!process.env.GCP_VOICE_CLONING_FILE &&
|
|
134
|
+
!process.env.GCP_VOICE_CLONING_JSON_KEY) {
|
|
135
|
+
t.pass(`skipping google speech synth tests since neither
|
|
136
|
+
GCP_CUSTOM_VOICE_FILE nor GCP_CUSTOM_VOICE_JSON_KEY provided,
|
|
137
|
+
GCP_VOICE_CLONING_FILE nor GCP_VOICE_CLONING_JSON_KEY is not provided`);
|
|
138
|
+
return t.end();
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
const googleKey = process.env.GCP_CUSTOM_VOICE_JSON_KEY ||
|
|
142
|
+
fs.readFileSync(process.env.GCP_CUSTOM_VOICE_FILE);
|
|
143
|
+
const voice_cloning_key = process.env.GCP_VOICE_CLONING_JSON_KEY ||
|
|
144
|
+
fs.readFileSync(process.env.GCP_VOICE_CLONING_FILE).toString();
|
|
145
|
+
const creds = JSON.parse(googleKey);
|
|
146
|
+
const opts = await synthAudio(stats, {
|
|
147
|
+
vendor: 'google',
|
|
148
|
+
credentials: {
|
|
149
|
+
credentials: {
|
|
150
|
+
client_email: creds.client_email,
|
|
151
|
+
private_key: creds.private_key,
|
|
152
|
+
project_id: creds.project_id
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
language: 'en-US',
|
|
156
|
+
text: 'This is a test. This is only a test. This is a test. This is only a test. This is a test. This is only a test',
|
|
157
|
+
voice: {
|
|
158
|
+
voice_cloning_key
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
t.ok(!opts.servedFromCache, `successfully synthesized google voice cloning audio to ${opts.filePath}`);
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error(err);
|
|
164
|
+
t.end(err);
|
|
165
|
+
}
|
|
166
|
+
client.quit();
|
|
167
|
+
});
|
|
168
|
+
|
|
124
169
|
test('AWS speech synth tests', async(t) => {
|
|
125
170
|
const fn = require('..');
|
|
126
171
|
const {synthAudio, client} = fn(opts, logger);
|
|
@@ -509,6 +554,7 @@ test('Custom Vendor speech synth tests', async(t) => {
|
|
|
509
554
|
text: 'This is a test. This is only a test',
|
|
510
555
|
});
|
|
511
556
|
t.ok(!opts.servedFromCache, `successfully synthesized custom vendor audio to ${opts.filePath}`);
|
|
557
|
+
t.ok(opts.filePath.endsWith('wav'), 'audio is cached as wav file');
|
|
512
558
|
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/somethingnew`);
|
|
513
559
|
t.ok(obj.headers.Authorization == 'Bearer some_jwt_token', 'Custom Vendor Authentication Header is correct');
|
|
514
560
|
t.ok(obj.body.language == 'en-US', 'Custom Vendor Language is correct');
|
|
@@ -516,6 +562,21 @@ test('Custom Vendor speech synth tests', async(t) => {
|
|
|
516
562
|
t.ok(obj.body.type == 'text', 'Custom Vendor type is correct');
|
|
517
563
|
t.ok(obj.body.text == 'This is a test. This is only a test', 'Custom Vendor text is correct');
|
|
518
564
|
|
|
565
|
+
// Checking if cache is stored with wav format
|
|
566
|
+
opts = await synthAudio(stats, {
|
|
567
|
+
vendor: 'custom:somethingnew',
|
|
568
|
+
credentials: {
|
|
569
|
+
use_for_tts: 1,
|
|
570
|
+
custom_tts_url: "http://127.0.0.1:3100/somethingnew",
|
|
571
|
+
auth_token: 'some_jwt_token'
|
|
572
|
+
},
|
|
573
|
+
language: 'en-US',
|
|
574
|
+
voice: 'English-US.Female-1',
|
|
575
|
+
text: 'This is a test. This is only a test',
|
|
576
|
+
});
|
|
577
|
+
t.ok(opts.servedFromCache, `successfully get custom vendor cached audio to ${opts.filePath}`);
|
|
578
|
+
t.ok(opts.filePath.endsWith('wav'), 'audio is cached as wav file');
|
|
579
|
+
|
|
519
580
|
opts = await synthAudio(stats, {
|
|
520
581
|
vendor: 'custom:somethingnew2',
|
|
521
582
|
credentials: {
|