@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.
@@ -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
- const key = makeAwsKey(roleArn || accessKeyId);
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
- createHash(key, credentials, CACHE_EXPIRY)
45
- .catch((err) => logger.error(err, `Error saving hash for key ${key}`));
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) {
@@ -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
- audioBuffer = Buffer.from(cached, 'base64');
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
- client.setex(key, EXPIRES, audioBuffer.toString('base64'))
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, getFileExtFromMime(mime))
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
@@ -50,6 +50,7 @@ function getFileExtension({vendor, voice, renderForCaching = false}) {
50
50
  case 'elevenlabs':
51
51
  case 'rimelabs':
52
52
  case 'playht':
53
+ case 'cartesia':
53
54
  if (renderForCaching || JAMBONES_DISABLE_TTS_STREAMING) {
54
55
  return mp3Extension;
55
56
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jambonz/speech-utils",
3
- "version": "0.1.21",
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);