@jambonz/speech-utils 0.0.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/.eslintignore +1 -0
- package/.eslintrc.json +126 -0
- package/.github/workflows/ci.yml +27 -0
- package/.github/workflows/publish.yml +23 -0
- package/LICENSE +21 -0
- package/README.md +2 -0
- package/build_stubs.sh +11 -0
- package/config/test.json +13 -0
- package/index.js +32 -0
- package/lib/get-ibm-access-token.js +48 -0
- package/lib/get-nuance-access-token.js +49 -0
- package/lib/get-tts-voices.js +111 -0
- package/lib/purge-tts-cache.js +46 -0
- package/lib/synth-audio.js +469 -0
- package/lib/utils.js +115 -0
- package/package.json +48 -0
- package/protos/nuance/synthesizer.proto +332 -0
- package/protos/riva/proto/riva_audio.proto +36 -0
- package/protos/riva/proto/riva_tts.proto +77 -0
- package/stubs/nuance/synthesizer_grpc_pb.js +104 -0
- package/stubs/nuance/synthesizer_pb.js +6815 -0
- package/stubs/riva/proto/riva_audio_grpc_pb.js +1 -0
- package/stubs/riva/proto/riva_audio_pb.js +31 -0
- package/stubs/riva/proto/riva_tts_grpc_pb.js +99 -0
- package/stubs/riva/proto/riva_tts_pb.js +1278 -0
- package/test/docker-compose-testbed.yaml +39 -0
- package/test/docker_start.js +12 -0
- package/test/docker_stop.js +12 -0
- package/test/ibm.js +78 -0
- package/test/index.js +5 -0
- package/test/nuance.js +50 -0
- package/test/synth.js +431 -0
- package/test/tmp/redis.conf +2054 -0
- package/test/webhook/Dockerfile +23 -0
- package/test/webhook/app.js +125 -0
- package/test/webhook/package-lock.json +1045 -0
- package/test/webhook/package.json +15 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const bent = require('bent');
|
|
4
|
+
const ttsGoogle = require('@google-cloud/text-to-speech');
|
|
5
|
+
//const Polly = require('aws-sdk/clients/polly');
|
|
6
|
+
const { PollyClient, SynthesizeSpeechCommand } = require('@aws-sdk/client-polly');
|
|
7
|
+
|
|
8
|
+
const sdk = require('microsoft-cognitiveservices-speech-sdk');
|
|
9
|
+
const TextToSpeechV1 = require('ibm-watson/text-to-speech/v1');
|
|
10
|
+
const { IamAuthenticator } = require('ibm-watson/auth');
|
|
11
|
+
const {
|
|
12
|
+
AudioConfig,
|
|
13
|
+
ResultReason,
|
|
14
|
+
SpeechConfig,
|
|
15
|
+
SpeechSynthesizer,
|
|
16
|
+
CancellationDetails,
|
|
17
|
+
SpeechSynthesisOutputFormat
|
|
18
|
+
} = sdk;
|
|
19
|
+
const {makeSynthKey, createNuanceClient, noopLogger, createRivaClient} = require('./utils');
|
|
20
|
+
const getNuanceAccessToken = require('./get-nuance-access-token');
|
|
21
|
+
const {
|
|
22
|
+
SynthesisRequest,
|
|
23
|
+
Voice,
|
|
24
|
+
AudioFormat,
|
|
25
|
+
AudioParameters,
|
|
26
|
+
PCM,
|
|
27
|
+
Input,
|
|
28
|
+
Text,
|
|
29
|
+
SSML,
|
|
30
|
+
EventParameters
|
|
31
|
+
} = require('../stubs/nuance/synthesizer_pb');
|
|
32
|
+
const {SynthesizeSpeechRequest} = require('../stubs/riva/proto/riva_tts_pb');
|
|
33
|
+
const {AudioEncoding} = require('../stubs/riva/proto/riva_audio_pb');
|
|
34
|
+
const debug = require('debug')('jambonz:realtimedb-helpers');
|
|
35
|
+
const EXPIRES = 3600 * 24; // cache tts for 24 hours
|
|
36
|
+
const TMP_FOLDER = '/tmp';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Synthesize speech to an mp3 file, and also cache the generated speech
|
|
40
|
+
* in redis (base64 format) for 24 hours so as to avoid unnecessarily paying
|
|
41
|
+
* time and again for speech synthesis of the same text.
|
|
42
|
+
* It is the responsibility of the caller to unlink the mp3 file after use.
|
|
43
|
+
*
|
|
44
|
+
* @param {*} client - redis client
|
|
45
|
+
* @param {*} logger - pino logger
|
|
46
|
+
* @param {object} opts - options
|
|
47
|
+
* @param {string} opts.vendor - 'google' or 'aws' ('polly' is an alias for 'aws')
|
|
48
|
+
* @param {string} opt.language - language code
|
|
49
|
+
* @param {string} opts.voice - voice identifier
|
|
50
|
+
* @param {string} opts.text - text or ssml to synthesize
|
|
51
|
+
* @param {boolean} opts.disableTtsCache - disable TTS Cache retrieval
|
|
52
|
+
* @returns object containing filepath to an mp3 file in the /tmp folder containing
|
|
53
|
+
* the synthesized audio, and a variable indicating whether it was served from cache
|
|
54
|
+
*/
|
|
55
|
+
async function synthAudio(client, logger, stats, {
|
|
56
|
+
vendor, language, voice, gender, text, engine, salt, model, credentials, deploymentId, disableTtsCache
|
|
57
|
+
}) {
|
|
58
|
+
let audioBuffer;
|
|
59
|
+
let servedFromCache = false;
|
|
60
|
+
let rtt;
|
|
61
|
+
logger = logger || noopLogger;
|
|
62
|
+
|
|
63
|
+
assert.ok(['google', 'aws', 'polly', 'microsoft', 'wellsaid', 'nuance', 'nvidia', 'ibm'].includes(vendor) ||
|
|
64
|
+
vendor.startsWith('custom'),
|
|
65
|
+
`synthAudio supported vendors are google, aws, microsoft, nuance, nvidia and wellsaid, not ${vendor}`);
|
|
66
|
+
if ('google' === vendor) {
|
|
67
|
+
assert.ok(language, 'synthAudio requires language when google is used');
|
|
68
|
+
}
|
|
69
|
+
else if (['aws', 'polly'].includes(vendor)) {
|
|
70
|
+
assert.ok(voice, 'synthAudio requires voice when aws polly is used');
|
|
71
|
+
}
|
|
72
|
+
else if ('microsoft' === vendor) {
|
|
73
|
+
assert.ok(language || deploymentId, 'synthAudio requires language when microsoft is used');
|
|
74
|
+
assert.ok(voice || deploymentId, 'synthAudio requires voice when microsoft is used');
|
|
75
|
+
}
|
|
76
|
+
else if ('nuance' === vendor) {
|
|
77
|
+
assert.ok(voice, 'synthAudio requires voice when nuance is used');
|
|
78
|
+
assert.ok(credentials.client_id, 'synthAudio requires client_id in credentials when nuance is used');
|
|
79
|
+
assert.ok(credentials.secret, 'synthAudio requires client_id in credentials when nuance is used');
|
|
80
|
+
}
|
|
81
|
+
else if ('nvidia' === vendor) {
|
|
82
|
+
assert.ok(voice, 'synthAudio requires voice when nvidia is used');
|
|
83
|
+
assert.ok(language, 'synthAudio requires language when nvidia is used');
|
|
84
|
+
assert.ok(credentials.riva_uri, 'synthAudio requires riva_uri in credentials when nuance is used');
|
|
85
|
+
}
|
|
86
|
+
else if ('ibm' === vendor) {
|
|
87
|
+
assert.ok(voice, 'synthAudio requires voice when ibm is used');
|
|
88
|
+
assert.ok(credentials.tts_region, 'synthAudio requires tts_region in credentials when ibm watson is used');
|
|
89
|
+
assert.ok(credentials.tts_api_key, 'synthAudio requires tts_api_key in credentials when nuance is used');
|
|
90
|
+
}
|
|
91
|
+
else if ('wellsaid' === vendor) {
|
|
92
|
+
language = 'en-US'; // WellSaid only supports English atm
|
|
93
|
+
assert.ok(voice, 'synthAudio requires voice when wellsaid is used');
|
|
94
|
+
assert.ok(!text.startsWith('<speak'), 'wellsaid does not support SSML tags');
|
|
95
|
+
} else if (vendor.startsWith('custom')) {
|
|
96
|
+
assert.ok(credentials.custom_tts_url, `synthAudio requires custom_tts_url in credentials when ${vendor} is used`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const key = makeSynthKey({
|
|
100
|
+
vendor,
|
|
101
|
+
language: language || '',
|
|
102
|
+
voice: voice || deploymentId,
|
|
103
|
+
engine,
|
|
104
|
+
text
|
|
105
|
+
});
|
|
106
|
+
let filePath;
|
|
107
|
+
if (['nuance', 'nvidia'].includes(vendor)) {
|
|
108
|
+
filePath = `${TMP_FOLDER}/${key.replace('tts:', `tts-${salt || ''}`)}.r8`;
|
|
109
|
+
}
|
|
110
|
+
else filePath = `${TMP_FOLDER}/${key.replace('tts:', `tts-${salt || ''}`)}.mp3`;
|
|
111
|
+
debug(`synth key is ${key}`);
|
|
112
|
+
let cached;
|
|
113
|
+
if (!disableTtsCache) {
|
|
114
|
+
cached = await client.getAsync(key);
|
|
115
|
+
}
|
|
116
|
+
if (cached) {
|
|
117
|
+
// found in cache - extend the expiry and use it
|
|
118
|
+
debug('result WAS found in cache');
|
|
119
|
+
servedFromCache = true;
|
|
120
|
+
stats.increment('tts.cache.requests', ['found:yes']);
|
|
121
|
+
audioBuffer = Buffer.from(cached, 'base64');
|
|
122
|
+
client.expireAsync(key, EXPIRES).catch((err) => logger.info(err, 'Error setting expires'));
|
|
123
|
+
}
|
|
124
|
+
if (!cached) {
|
|
125
|
+
// not found in cache - go get it from speech vendor and add to cache
|
|
126
|
+
debug('result was NOT found in cache');
|
|
127
|
+
stats.increment('tts.cache.requests', ['found:no']);
|
|
128
|
+
let vendorLabel = vendor;
|
|
129
|
+
const startAt = process.hrtime();
|
|
130
|
+
switch (vendor) {
|
|
131
|
+
case 'google':
|
|
132
|
+
audioBuffer = await synthGoogle(logger, {credentials, stats, language, voice, gender, text});
|
|
133
|
+
break;
|
|
134
|
+
case 'aws':
|
|
135
|
+
case 'polly':
|
|
136
|
+
vendorLabel = 'aws';
|
|
137
|
+
audioBuffer = await synthPolly(logger, {credentials, stats, language, voice, text, engine});
|
|
138
|
+
break;
|
|
139
|
+
case 'azure':
|
|
140
|
+
case 'microsoft':
|
|
141
|
+
vendorLabel = 'microsoft';
|
|
142
|
+
audioBuffer = await synthMicrosoft(logger, {credentials, stats, language, voice, text, deploymentId, filePath});
|
|
143
|
+
break;
|
|
144
|
+
case 'nuance':
|
|
145
|
+
model = model || 'enhanced';
|
|
146
|
+
audioBuffer = await synthNuance(client, logger, {credentials, stats, voice, model, text});
|
|
147
|
+
break;
|
|
148
|
+
case 'nvidia':
|
|
149
|
+
audioBuffer = await synthNvidia(client, logger, {credentials, stats, language, voice, model, text});
|
|
150
|
+
break;
|
|
151
|
+
case 'ibm':
|
|
152
|
+
audioBuffer = await synthIbm(logger, {credentials, stats, voice, text});
|
|
153
|
+
break;
|
|
154
|
+
case 'wellsaid':
|
|
155
|
+
audioBuffer = await synthWellSaid(logger, {credentials, stats, language, voice, text, filePath});
|
|
156
|
+
break;
|
|
157
|
+
case vendor.startsWith('custom') ? vendor : 'cant_match_value':
|
|
158
|
+
audioBuffer = await synthCustomVendor(logger, {credentials, stats, language, voice, text});
|
|
159
|
+
break;
|
|
160
|
+
default:
|
|
161
|
+
assert(`synthAudio: unsupported speech vendor ${vendor}`);
|
|
162
|
+
}
|
|
163
|
+
const diff = process.hrtime(startAt);
|
|
164
|
+
const time = diff[0] * 1e3 + diff[1] * 1e-6;
|
|
165
|
+
rtt = time.toFixed(0);
|
|
166
|
+
stats.histogram('tts.response_time', rtt, [`vendor:${vendorLabel}`]);
|
|
167
|
+
debug(`tts rtt time for ${text.length} chars on ${vendorLabel}: ${rtt}`);
|
|
168
|
+
logger.info(`tts rtt time for ${text.length} chars on ${vendorLabel}: ${rtt}`);
|
|
169
|
+
|
|
170
|
+
client.setexAsync(key, EXPIRES, audioBuffer.toString('base64'))
|
|
171
|
+
.catch((err) => logger.error(err, `error calling setex on key ${key}`));
|
|
172
|
+
|
|
173
|
+
if (['microsoft'].includes(vendor)) return {filePath, servedFromCache, rtt};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
fs.writeFile(filePath, audioBuffer, (err) => {
|
|
178
|
+
if (err) return reject(err);
|
|
179
|
+
resolve({filePath, servedFromCache, rtt});
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const synthPolly = async(logger, {credentials, stats, language, voice, engine, text}) => {
|
|
185
|
+
try {
|
|
186
|
+
const polly = new PollyClient(credentials);
|
|
187
|
+
const opts = {
|
|
188
|
+
Engine: engine,
|
|
189
|
+
OutputFormat: 'mp3',
|
|
190
|
+
Text: text,
|
|
191
|
+
LanguageCode: language,
|
|
192
|
+
TextType: text.startsWith('<speak>') ? 'ssml' : 'text',
|
|
193
|
+
VoiceId: voice
|
|
194
|
+
};
|
|
195
|
+
const command = new SynthesizeSpeechCommand(opts);
|
|
196
|
+
const data = await polly.send(command);
|
|
197
|
+
const chunks = [];
|
|
198
|
+
return new Promise((resolve, reject) => {
|
|
199
|
+
data.AudioStream
|
|
200
|
+
.on('error', (err) => {
|
|
201
|
+
logger.info({err}, 'synthAudio: Error synthesizing speech using aws polly');
|
|
202
|
+
stats.increment('tts.count', ['vendor:aws', 'accepted:no']);
|
|
203
|
+
reject(err);
|
|
204
|
+
})
|
|
205
|
+
.on('data', (chunk) => {
|
|
206
|
+
chunks.push(chunk);
|
|
207
|
+
})
|
|
208
|
+
.on('end', () => resolve(Buffer.concat(chunks)));
|
|
209
|
+
});
|
|
210
|
+
} catch (err) {
|
|
211
|
+
logger.info({err}, 'synthAudio: Error synthesizing speech using aws polly');
|
|
212
|
+
stats.increment('tts.count', ['vendor:aws', 'accepted:no']);
|
|
213
|
+
throw err;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const synthGoogle = async(logger, {credentials, stats, language, voice, gender, text}) => {
|
|
218
|
+
const client = new ttsGoogle.TextToSpeechClient(credentials);
|
|
219
|
+
const opts = {
|
|
220
|
+
voice: {
|
|
221
|
+
name: voice,
|
|
222
|
+
languageCode: language,
|
|
223
|
+
ssmlGender: gender || 'SSML_VOICE_GENDER_UNSPECIFIED'
|
|
224
|
+
},
|
|
225
|
+
audioConfig: {audioEncoding: 'MP3'}
|
|
226
|
+
};
|
|
227
|
+
Object.assign(opts, {input: text.startsWith('<speak>') ? {ssml: text} : {text}});
|
|
228
|
+
try {
|
|
229
|
+
const responses = await client.synthesizeSpeech(opts);
|
|
230
|
+
stats.increment('tts.count', ['vendor:google', 'accepted:yes']);
|
|
231
|
+
client.close();
|
|
232
|
+
return responses[0].audioContent;
|
|
233
|
+
} catch (err) {
|
|
234
|
+
console.error(err);
|
|
235
|
+
logger.info({err, opts}, 'synthAudio: Error synthesizing speech using google');
|
|
236
|
+
stats.increment('tts.count', ['vendor:google', 'accepted:no']);
|
|
237
|
+
client && client.close();
|
|
238
|
+
throw err;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const synthIbm = async(logger, {credentials, stats, voice, text}) => {
|
|
243
|
+
const {tts_api_key, tts_region} = credentials;
|
|
244
|
+
const params = {
|
|
245
|
+
text,
|
|
246
|
+
voice,
|
|
247
|
+
accept: 'audio/mp3'
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const textToSpeech = new TextToSpeechV1({
|
|
252
|
+
authenticator: new IamAuthenticator({
|
|
253
|
+
apikey: tts_api_key,
|
|
254
|
+
}),
|
|
255
|
+
serviceUrl: `https://api.${tts_region}.text-to-speech.watson.cloud.ibm.com`
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const r = await textToSpeech.synthesize(params);
|
|
259
|
+
const chunks = [];
|
|
260
|
+
for await (const chunk of r.result) {
|
|
261
|
+
chunks.push(chunk);
|
|
262
|
+
}
|
|
263
|
+
return Buffer.concat(chunks);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
logger.info({err, params}, 'synthAudio: Error synthesizing speech using ibm');
|
|
266
|
+
stats.increment('tts.count', ['vendor:ibm', 'accepted:no']);
|
|
267
|
+
throw new Error(err.statusText || err.message);
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const synthMicrosoft = async(logger, {
|
|
272
|
+
credentials,
|
|
273
|
+
stats,
|
|
274
|
+
language,
|
|
275
|
+
voice,
|
|
276
|
+
text,
|
|
277
|
+
filePath
|
|
278
|
+
}) => {
|
|
279
|
+
try {
|
|
280
|
+
const {api_key: apiKey, region, use_custom_tts, custom_tts_endpoint} = credentials;
|
|
281
|
+
let content = text;
|
|
282
|
+
const speechConfig = SpeechConfig.fromSubscription(apiKey, region);
|
|
283
|
+
speechConfig.speechSynthesisLanguage = language;
|
|
284
|
+
speechConfig.speechSynthesisVoiceName = voice;
|
|
285
|
+
if (use_custom_tts && custom_tts_endpoint) {
|
|
286
|
+
speechConfig.endpointId = custom_tts_endpoint;
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Note: it seems that to use custom voice ssml is required with the voice attribute
|
|
290
|
+
* Otherwise sending plain text we get "Voice does not match"
|
|
291
|
+
*/
|
|
292
|
+
if (!content.startsWith('<speak')) content = `<speak>${text}</speak>`;
|
|
293
|
+
}
|
|
294
|
+
speechConfig.speechSynthesisOutputFormat = SpeechSynthesisOutputFormat.Audio16Khz32KBitRateMonoMp3;
|
|
295
|
+
const config = AudioConfig.fromAudioFileOutput(filePath);
|
|
296
|
+
const synthesizer = new SpeechSynthesizer(speechConfig, config);
|
|
297
|
+
|
|
298
|
+
if (content.startsWith('<speak>')) {
|
|
299
|
+
/* microsoft enforces some properties and uses voice xml element so if the user did not supply do it for them */
|
|
300
|
+
const words = content.slice(7, -8).trim().replace(/(\r\n|\n|\r)/gm, ' ');
|
|
301
|
+
// eslint-disable-next-line max-len
|
|
302
|
+
content = `<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts" xml:lang="${language}"><voice name="${voice}">${words}</voice></speak>`;
|
|
303
|
+
logger.info({content}, 'synthMicrosoft');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return new Promise((resolve, reject) => {
|
|
307
|
+
const speakAsync = content.startsWith('<speak') ?
|
|
308
|
+
synthesizer.speakSsmlAsync.bind(synthesizer) :
|
|
309
|
+
synthesizer.speakTextAsync.bind(synthesizer);
|
|
310
|
+
speakAsync(
|
|
311
|
+
content,
|
|
312
|
+
async(result) => {
|
|
313
|
+
switch (result.reason) {
|
|
314
|
+
case ResultReason.Canceled:
|
|
315
|
+
const cancellation = CancellationDetails.fromResult(result);
|
|
316
|
+
logger.info({reason: cancellation.errorDetails}, 'synthAudio: (Microsoft) synthesis canceled');
|
|
317
|
+
synthesizer.close();
|
|
318
|
+
reject(cancellation.errorDetails);
|
|
319
|
+
break;
|
|
320
|
+
case ResultReason.SynthesizingAudioCompleted:
|
|
321
|
+
stats.increment('tts.count', ['vendor:microsoft', 'accepted:yes']);
|
|
322
|
+
synthesizer.close();
|
|
323
|
+
fs.readFile(filePath, (err, data) => {
|
|
324
|
+
if (err) return reject(err);
|
|
325
|
+
resolve(data);
|
|
326
|
+
});
|
|
327
|
+
break;
|
|
328
|
+
default:
|
|
329
|
+
logger.info({result}, 'synthAudio: (Microsoft) unexpected result');
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
(err) => {
|
|
334
|
+
logger.info({err}, 'synthAudio: (Microsoft) error synthesizing');
|
|
335
|
+
stats.increment('tts.count', ['vendor:microsoft', 'accepted:no']);
|
|
336
|
+
synthesizer.close();
|
|
337
|
+
reject(err);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
} catch (err) {
|
|
341
|
+
logger.info({err}, 'synthAudio: Error synthesizing speech using Microsoft');
|
|
342
|
+
stats.increment('tts.count', ['vendor:google', 'accepted:no']);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const synthWellSaid = async(logger, {credentials, stats, language, voice, gender, text}) => {
|
|
347
|
+
const {api_key} = credentials;
|
|
348
|
+
try {
|
|
349
|
+
const post = bent('https://api.wellsaidlabs.com', 'POST', 'buffer', {
|
|
350
|
+
'X-Api-Key': api_key,
|
|
351
|
+
'Accept': 'audio/mpeg',
|
|
352
|
+
'Content-Type': 'application/json'
|
|
353
|
+
});
|
|
354
|
+
const mp3 = await post('/v1/tts/stream', {
|
|
355
|
+
text,
|
|
356
|
+
speaker_id: voice
|
|
357
|
+
});
|
|
358
|
+
return mp3;
|
|
359
|
+
} catch (err) {
|
|
360
|
+
logger.info({err}, 'testWellSaidTts returned error');
|
|
361
|
+
throw err;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
const synthNuance = async(client, logger, {credentials, stats, voice, model, text}) => {
|
|
366
|
+
/* get a nuance access token */
|
|
367
|
+
const {client_id, secret} = credentials;
|
|
368
|
+
const {access_token} = await getNuanceAccessToken(client, logger, client_id, secret, 'tts');
|
|
369
|
+
const nuanceClient = await createNuanceClient(access_token);
|
|
370
|
+
|
|
371
|
+
const v = new Voice();
|
|
372
|
+
const p = new AudioParameters();
|
|
373
|
+
const f = new AudioFormat();
|
|
374
|
+
const pcm = new PCM();
|
|
375
|
+
const params = new EventParameters();
|
|
376
|
+
const request = new SynthesisRequest();
|
|
377
|
+
const input = new Input();
|
|
378
|
+
|
|
379
|
+
if (text.startsWith('<speak')) {
|
|
380
|
+
const ssml = new SSML();
|
|
381
|
+
ssml.setText(text);
|
|
382
|
+
input.setSsml(ssml);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
const t = new Text();
|
|
386
|
+
t.setText(text);
|
|
387
|
+
input.setText(t);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
pcm.setSampleRateHz(8000);
|
|
391
|
+
f.setPcm(pcm);
|
|
392
|
+
p.setAudioFormat(f);
|
|
393
|
+
v.setName(voice);
|
|
394
|
+
v.setModel(model);
|
|
395
|
+
request.setVoice(v);
|
|
396
|
+
request.setAudioParams(p);
|
|
397
|
+
request.setInput(input);
|
|
398
|
+
request.setEventParams(params);
|
|
399
|
+
request.setUserId('jambonz');
|
|
400
|
+
|
|
401
|
+
return new Promise((resolve, reject) => {
|
|
402
|
+
nuanceClient.unarySynthesize(request, (err, response) => {
|
|
403
|
+
if (err) {
|
|
404
|
+
console.error(err);
|
|
405
|
+
return reject(err);
|
|
406
|
+
}
|
|
407
|
+
const status = response.getStatus();
|
|
408
|
+
const code = status.getCode();
|
|
409
|
+
if (code !== 200) {
|
|
410
|
+
const message = status.getMessage();
|
|
411
|
+
const details = status.getDetails();
|
|
412
|
+
return reject({code, message, details});
|
|
413
|
+
}
|
|
414
|
+
resolve(Buffer.from(response.getAudio()));
|
|
415
|
+
});
|
|
416
|
+
});
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const synthNvidia = async(client, logger, {credentials, stats, language, voice, model, text}) => {
|
|
420
|
+
const {riva_uri} = credentials;
|
|
421
|
+
const rivaClient = await createRivaClient(riva_uri);
|
|
422
|
+
|
|
423
|
+
const request = new SynthesizeSpeechRequest();
|
|
424
|
+
request.setVoiceName(voice);
|
|
425
|
+
request.setLanguageCode(language);
|
|
426
|
+
request.setSampleRateHz(8000);
|
|
427
|
+
request.setEncoding(AudioEncoding.LINEAR_PCM);
|
|
428
|
+
request.setText(text);
|
|
429
|
+
|
|
430
|
+
return new Promise((resolve, reject) => {
|
|
431
|
+
console.log(`language ${language} voice ${voice} model ${model} text ${text}`);
|
|
432
|
+
rivaClient.synthesize(request, (err, response) => {
|
|
433
|
+
if (err) {
|
|
434
|
+
console.error(err);
|
|
435
|
+
return reject(err);
|
|
436
|
+
}
|
|
437
|
+
resolve(Buffer.from(response.getAudio()));
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
// CustomVendor accept only mp3
|
|
444
|
+
const synthCustomVendor = async(logger, {credentials, stats, language, voice, text}) => {
|
|
445
|
+
const {vendor, auth_token, custom_tts_url} = credentials;
|
|
446
|
+
|
|
447
|
+
try {
|
|
448
|
+
const post = bent('POST', 'buffer', {
|
|
449
|
+
'Authorization': `Bearer ${auth_token}`,
|
|
450
|
+
'Accept': 'audio/mpeg',
|
|
451
|
+
'Content-Type': 'application/json'
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
const mp3 = await post(custom_tts_url, {
|
|
455
|
+
language,
|
|
456
|
+
format: 'audio/mpeg',
|
|
457
|
+
voice,
|
|
458
|
+
type: text.startsWith('<speak>') ? 'ssml' : 'text',
|
|
459
|
+
text
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
return mp3;
|
|
463
|
+
} catch (err) {
|
|
464
|
+
logger.info({err}, `Vendor ${vendor} returned error`);
|
|
465
|
+
throw err;
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
module.exports = synthAudio;
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const {SynthesizerClient} = require('../stubs/nuance/synthesizer_grpc_pb');
|
|
3
|
+
const {RivaSpeechSynthesisClient} = require('../stubs/riva/proto/riva_tts_grpc_pb');
|
|
4
|
+
const {Pool} = require('undici');
|
|
5
|
+
const pool = new Pool('https://auth.crt.nuance.com');
|
|
6
|
+
const HTTP_TIMEOUT = 5000;
|
|
7
|
+
const NUANCE_AUTH_ENDPOINT = 'tts.api.nuance.com:443';
|
|
8
|
+
const grpc = require('@grpc/grpc-js');
|
|
9
|
+
const formurlencoded = require('form-urlencoded');
|
|
10
|
+
|
|
11
|
+
const debug = require('debug')('jambonz:realtimedb-helpers');
|
|
12
|
+
/**
|
|
13
|
+
* Future TODO: cache recently used connections to providers
|
|
14
|
+
* to avoid connection overhead during a call.
|
|
15
|
+
* Will need to periodically age them out to avoid memory leaks.
|
|
16
|
+
*/
|
|
17
|
+
//const nuanceClientMap = new Map();
|
|
18
|
+
|
|
19
|
+
function makeSynthKey({vendor, language, voice, engine = '', text}) {
|
|
20
|
+
const hash = crypto.createHash('sha1');
|
|
21
|
+
hash.update(`${language}:${vendor}:${voice}:${engine}:${text}`);
|
|
22
|
+
return `tts:${hash.digest('hex')}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const noopLogger = {
|
|
26
|
+
info: () => {},
|
|
27
|
+
debug: () => {},
|
|
28
|
+
error: () => {}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
|
|
32
|
+
|
|
33
|
+
function makeBasicAuthHeader(username, password) {
|
|
34
|
+
if (!username || !password) return {};
|
|
35
|
+
const creds = `${encodeURIComponent(username)}:${password || ''}`;
|
|
36
|
+
const header = `Basic ${toBase64(creds)}`;
|
|
37
|
+
return {Authorization: header};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeIbmKey(apiKey) {
|
|
41
|
+
const hash = crypto.createHash('sha1');
|
|
42
|
+
hash.update(apiKey);
|
|
43
|
+
return `ibm:${hash.digest('hex')}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeNuanceKey(clientId, secret, scope) {
|
|
47
|
+
const hash = crypto.createHash('sha1');
|
|
48
|
+
hash.update(`${clientId}:${secret}:${scope}`);
|
|
49
|
+
return `nuance:${hash.digest('hex')}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const getNuanceAccessToken = async(clientId, secret, scope = 'asr tts') => {
|
|
53
|
+
const payload = {
|
|
54
|
+
grant_type: 'client_credentials',
|
|
55
|
+
scope
|
|
56
|
+
};
|
|
57
|
+
const auth = makeBasicAuthHeader(clientId, secret);
|
|
58
|
+
const {statusCode, headers, body} = await pool.request({
|
|
59
|
+
path: '/oauth2/token',
|
|
60
|
+
method: 'POST',
|
|
61
|
+
headers: {
|
|
62
|
+
...auth,
|
|
63
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
64
|
+
},
|
|
65
|
+
body: formurlencoded(payload),
|
|
66
|
+
timeout: HTTP_TIMEOUT,
|
|
67
|
+
followRedirects: false
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (200 !== statusCode) {
|
|
71
|
+
debug({statusCode, headers, body: body.text()}, 'error fetching access token from Nuance');
|
|
72
|
+
const err = new Error();
|
|
73
|
+
err.statusCode = statusCode;
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
const json = await body.json();
|
|
77
|
+
return json.access_token;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const createNuanceClient = async(access_token) => {
|
|
81
|
+
|
|
82
|
+
//if (nuanceClientMap.has(access_token)) return nuanceClientMap.get(access_token);
|
|
83
|
+
|
|
84
|
+
const generateMetadata = (params, callback) => {
|
|
85
|
+
var metadata = new grpc.Metadata();
|
|
86
|
+
metadata.add('authorization', `Bearer ${access_token}`);
|
|
87
|
+
callback(null, metadata);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const sslCreds = grpc.credentials.createSsl();
|
|
91
|
+
const authCreds = grpc.credentials.createFromMetadataGenerator(generateMetadata);
|
|
92
|
+
const combined_creds = grpc.credentials.combineChannelCredentials(sslCreds, authCreds);
|
|
93
|
+
const client = new SynthesizerClient(NUANCE_AUTH_ENDPOINT, combined_creds);
|
|
94
|
+
|
|
95
|
+
//if (process.env.NUANCE_CACHE_TTS_CONNECTIONS) nuanceClientMap.set(access_token, client);
|
|
96
|
+
return client;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const createRivaClient = async(rivaUri) => {
|
|
100
|
+
const client = new RivaSpeechSynthesisClient(rivaUri, grpc.credentials.createInsecure());
|
|
101
|
+
return client;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
makeSynthKey,
|
|
107
|
+
makeNuanceKey,
|
|
108
|
+
makeIbmKey,
|
|
109
|
+
getNuanceAccessToken,
|
|
110
|
+
createNuanceClient,
|
|
111
|
+
createRivaClient,
|
|
112
|
+
makeBasicAuthHeader,
|
|
113
|
+
NUANCE_AUTH_ENDPOINT,
|
|
114
|
+
noopLogger
|
|
115
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jambonz/speech-utils",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "TTS-related speech utilities for jambonz",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"author": "Dave Horton",
|
|
7
|
+
"directories": {
|
|
8
|
+
"lib": "lib",
|
|
9
|
+
"test": "test"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "NODE_ENV=test JAMBONES_REDIS_USERNAME=daveh JAMBONES_REDIS_PASSWORD=foobarbazzle node test/ ",
|
|
13
|
+
"coverage": "nyc --reporter html --report-dir ./coverage npm run test",
|
|
14
|
+
"jslint": "eslint index.js lib",
|
|
15
|
+
"build": "./build_stubs.sh"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/jambonz/speech-utils.git"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/jambonz/speech-utils/issues"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/jambonz/speech-utils#readme",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@aws-sdk/client-polly": "^3.276.0",
|
|
28
|
+
"@google-cloud/text-to-speech": "^4.2.0",
|
|
29
|
+
"@grpc/grpc-js": "^1.8.7",
|
|
30
|
+
"@jambonz/realtimedb-helpers": "^0.6.3",
|
|
31
|
+
"aws-sdk": "^2.1310.0",
|
|
32
|
+
"bent": "^7.3.12",
|
|
33
|
+
"debug": "^4.3.4",
|
|
34
|
+
"google-protobuf": "^3.21.2",
|
|
35
|
+
"ibm-watson": "^7.1.2",
|
|
36
|
+
"form-urlencoded": "^6.1.0",
|
|
37
|
+
"microsoft-cognitiveservices-speech-sdk": "^1.25.0",
|
|
38
|
+
"undici": "^5.19.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"config": "^3.3.9",
|
|
42
|
+
"eslint": "^8.33.0",
|
|
43
|
+
"eslint-plugin-promise": "^6.1.1",
|
|
44
|
+
"nyc": "^15.1.0",
|
|
45
|
+
"pino": "^7.2.0",
|
|
46
|
+
"tape": "^5.1.1"
|
|
47
|
+
}
|
|
48
|
+
}
|