@jambonz/speech-utils 0.1.21 → 0.1.24
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/get-aws-sts-token.js +31 -8
- package/lib/synth-audio.js +100 -6
- package/lib/utils.js +1 -0
- package/package.json +2 -1
- package/test/synth.js +50 -0
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');
|
|
@@ -96,7 +97,7 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
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({
|
|
@@ -170,6 +173,8 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
170
173
|
renderForCaching
|
|
171
174
|
});
|
|
172
175
|
let filePath;
|
|
176
|
+
// used only for custom vendor
|
|
177
|
+
let fileExtension;
|
|
173
178
|
filePath = makeFilePath({vendor, voice, key, salt, renderForCaching});
|
|
174
179
|
debug(`synth key is ${key}`);
|
|
175
180
|
let cached;
|
|
@@ -201,7 +206,17 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
201
206
|
debug('result WAS found in cache');
|
|
202
207
|
servedFromCache = true;
|
|
203
208
|
stats.increment('tts.cache.requests', ['found:yes']);
|
|
204
|
-
|
|
209
|
+
if (vendor.startsWith('custom')) {
|
|
210
|
+
// custom vendors support multiple mime types such as: mp3, wav, r8, r16 ...etc,
|
|
211
|
+
// mime type/file extension is available when http response has header Content-type.
|
|
212
|
+
// In cache, file extension is store together with audiBuffer in a json.
|
|
213
|
+
// Normal cache audio will be base64 string
|
|
214
|
+
const payload = JSON.parse(cached);
|
|
215
|
+
filePath = filePath.replace(/\.[^\.]*$/g, payload.fileExtension);
|
|
216
|
+
audioBuffer = Buffer.from(payload.audioBuffer, 'base64');
|
|
217
|
+
} else {
|
|
218
|
+
audioBuffer = Buffer.from(cached, 'base64');
|
|
219
|
+
}
|
|
205
220
|
client.expire(key, EXPIRES).catch((err) => logger.info(err, 'Error setting expires'));
|
|
206
221
|
}
|
|
207
222
|
if (!cached) {
|
|
@@ -249,6 +264,11 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
249
264
|
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming, filePath
|
|
250
265
|
});
|
|
251
266
|
break;
|
|
267
|
+
case 'cartesia':
|
|
268
|
+
audioBuffer = await synthCartesia(logger, {
|
|
269
|
+
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming, filePath
|
|
270
|
+
});
|
|
271
|
+
break;
|
|
252
272
|
case 'rimelabs':
|
|
253
273
|
audioBuffer = await synthRimelabs(logger, {
|
|
254
274
|
credentials, options, stats, language, voice, text, renderForCaching, disableTtsStreaming, filePath
|
|
@@ -268,7 +288,7 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
268
288
|
renderForCaching, disableTtsStreaming});
|
|
269
289
|
break;
|
|
270
290
|
case vendor.startsWith('custom') ? vendor : 'cant_match_value':
|
|
271
|
-
({ audioBuffer, filePath } = await synthCustomVendor(logger,
|
|
291
|
+
({ audioBuffer, filePath, fileExtension } = await synthCustomVendor(logger,
|
|
272
292
|
{credentials, stats, language, voice, text, filePath}));
|
|
273
293
|
break;
|
|
274
294
|
default:
|
|
@@ -282,7 +302,14 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
282
302
|
debug(`tts rtt time for ${text.length} chars on ${vendorLabel}: ${rtt}`);
|
|
283
303
|
logger.info(`tts rtt time for ${text.length} chars on ${vendorLabel}: ${rtt}`);
|
|
284
304
|
|
|
285
|
-
|
|
305
|
+
const base64Audio = audioBuffer.toString('base64');
|
|
306
|
+
const cacheContent = vendor.startsWith('custom') ?
|
|
307
|
+
JSON.stringify({
|
|
308
|
+
audioBuffer: base64Audio,
|
|
309
|
+
fileExtension
|
|
310
|
+
}) : base64Audio;
|
|
311
|
+
|
|
312
|
+
client.setex(key, EXPIRES, cacheContent)
|
|
286
313
|
.catch((err) => logger.error(err, `error calling setex on key ${key}`));
|
|
287
314
|
}
|
|
288
315
|
|
|
@@ -729,9 +756,11 @@ const synthCustomVendor = async(logger, {credentials, stats, language, voice, te
|
|
|
729
756
|
const regex = /\.[^\.]*$/g;
|
|
730
757
|
const mime = response.headers['content-type'];
|
|
731
758
|
const buffer = await response.arrayBuffer();
|
|
759
|
+
const fileExtension = getFileExtFromMime(mime);
|
|
732
760
|
return {
|
|
733
761
|
audioBuffer: buffer,
|
|
734
|
-
filePath: filePath.replace(regex,
|
|
762
|
+
filePath: filePath.replace(regex, fileExtension),
|
|
763
|
+
fileExtension
|
|
735
764
|
};
|
|
736
765
|
} catch (err) {
|
|
737
766
|
logger.info({err}, `Vendor ${vendor} returned error`);
|
|
@@ -1046,6 +1075,71 @@ const synthDeepgram = async(logger, {credentials, stats, model, text, renderForC
|
|
|
1046
1075
|
}
|
|
1047
1076
|
};
|
|
1048
1077
|
|
|
1078
|
+
const synthCartesia = async(logger, {
|
|
1079
|
+
credentials, options, stats, voice, language, text, renderForCaching, disableTtsStreaming
|
|
1080
|
+
}) => {
|
|
1081
|
+
const {api_key, model_id, embedding, options: credOpts} = credentials;
|
|
1082
|
+
const opts = !!options && Object.keys(options).length !== 0 ? options : JSON.parse(credOpts || '{}');
|
|
1083
|
+
|
|
1084
|
+
if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
|
|
1085
|
+
let params = '';
|
|
1086
|
+
params += `{api_key=${api_key}`;
|
|
1087
|
+
params += `,model_id=${model_id}`;
|
|
1088
|
+
params += ',vendor=cartesia';
|
|
1089
|
+
params += `,voice=${voice}`;
|
|
1090
|
+
params += ',write_cache_file=1';
|
|
1091
|
+
params += `,language=${language}`;
|
|
1092
|
+
params += `,voice_mode=${embedding ? 'embedding' : 'id'}`;
|
|
1093
|
+
if (embedding) params += `,embedding=${embedding}`;
|
|
1094
|
+
if (opts.speed) params += `,speed=${opts.speed}`;
|
|
1095
|
+
if (opts.emotion) params += `,emotion=${opts.emotion}`;
|
|
1096
|
+
params += '}';
|
|
1097
|
+
|
|
1098
|
+
return {
|
|
1099
|
+
filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
|
|
1100
|
+
servedFromCache: false,
|
|
1101
|
+
rtt: 0
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
try {
|
|
1106
|
+
const client = new CartesiaClient({ apiKey: api_key });
|
|
1107
|
+
const mp3 = await client.tts.bytes({
|
|
1108
|
+
modelId: model_id,
|
|
1109
|
+
transcript: text,
|
|
1110
|
+
voice: {
|
|
1111
|
+
mode: embedding ? 'embedding' : 'id',
|
|
1112
|
+
...(embedding ?
|
|
1113
|
+
{
|
|
1114
|
+
embedding: embedding.split(',').map(Number)
|
|
1115
|
+
} :
|
|
1116
|
+
{
|
|
1117
|
+
id: voice
|
|
1118
|
+
}
|
|
1119
|
+
),
|
|
1120
|
+
...(opts.speed || opts.emotion && {
|
|
1121
|
+
experimentalControls: {
|
|
1122
|
+
...(opts.speed && {speed: opts.speed}),
|
|
1123
|
+
...(opts.emotion && {emotion: opts.emotion}),
|
|
1124
|
+
}
|
|
1125
|
+
})
|
|
1126
|
+
},
|
|
1127
|
+
language: language,
|
|
1128
|
+
outputFormat: {
|
|
1129
|
+
container: 'mp3',
|
|
1130
|
+
bitRate: 128000,
|
|
1131
|
+
sampleRate: 8000
|
|
1132
|
+
},
|
|
1133
|
+
});
|
|
1134
|
+
return Buffer.from(mp3);
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
logger.info({err}, 'synth Cartesia returned error');
|
|
1137
|
+
stats.increment('tts.count', ['vendor:cartesia', 'accepted:no']);
|
|
1138
|
+
throw err;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1049
1143
|
const getFileExtFromMime = (mime) => {
|
|
1050
1144
|
switch (mime) {
|
|
1051
1145
|
case 'audio/wav':
|
package/lib/utils.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jambonz/speech-utils",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
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
|
@@ -554,6 +554,7 @@ test('Custom Vendor speech synth tests', async(t) => {
|
|
|
554
554
|
text: 'This is a test. This is only a test',
|
|
555
555
|
});
|
|
556
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');
|
|
557
558
|
let obj = await getJSON(`http://127.0.0.1:3100/lastRequest/somethingnew`);
|
|
558
559
|
t.ok(obj.headers.Authorization == 'Bearer some_jwt_token', 'Custom Vendor Authentication Header is correct');
|
|
559
560
|
t.ok(obj.body.language == 'en-US', 'Custom Vendor Language is correct');
|
|
@@ -561,6 +562,21 @@ test('Custom Vendor speech synth tests', async(t) => {
|
|
|
561
562
|
t.ok(obj.body.type == 'text', 'Custom Vendor type is correct');
|
|
562
563
|
t.ok(obj.body.text == 'This is a test. This is only a test', 'Custom Vendor text is correct');
|
|
563
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
|
+
|
|
564
580
|
opts = await synthAudio(stats, {
|
|
565
581
|
vendor: 'custom:somethingnew2',
|
|
566
582
|
credentials: {
|
|
@@ -670,6 +686,40 @@ test('PlayHT3.0 speech synth tests', async(t) => {
|
|
|
670
686
|
await testPlayHT(t, 'Play3.0');
|
|
671
687
|
});
|
|
672
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
|
+
|
|
673
723
|
test('rimelabs speech synth tests', async(t) => {
|
|
674
724
|
const fn = require('..');
|
|
675
725
|
const {synthAudio, client} = fn(opts, logger);
|