@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.
@@ -170,7 +170,9 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
170
170
  renderForCaching
171
171
  });
172
172
  let filePath;
173
- filePath = makeFilePath({vendor, key, salt, renderForCaching});
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
- audioBuffer = Buffer.from(cached, 'base64');
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
- client.setex(key, EXPIRES, audioBuffer.toString('base64'))
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, getFileExtFromMime(mime))
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.20",
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.2",
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 && !process.env.GCP_CUSTOM_VOICE_JSON_KEY || !process.env.GCP_CUSTOM_VOICE_MODEL) {
95
- t.pass('skipping google speech synth tests since neither GCP_CUSTOM_VOICE_FILE nor GCP_CUSTOM_VOICE_JSON_KEY provided, GCP_CUSTOM_VOICE_MODEL is not provided');
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
- let opts = await synthAudio(stats, {
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:"REALTIME",
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: {