@jambonz/speech-utils 1.0.6 → 1.0.8

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.
@@ -80,7 +80,7 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
80
80
  logger = logger || noopLogger;
81
81
 
82
82
  assert.ok(['google', 'aws', 'polly', 'microsoft', 'wellsaid', 'nvidia', 'elevenlabs',
83
- 'whisper', 'deepgram', 'rimelabs', 'cartesia', 'inworld', 'resemble'].includes(vendor) ||
83
+ 'whisper', 'deepgram', 'rimelabs', 'cartesia', 'inworld', 'resemble', 'murf', 'xai'].includes(vendor) ||
84
84
  vendor.startsWith('custom'),
85
85
  `synthAudio supported vendors are google, aws, microsoft, nvidia and wellsaid ..etc, not ${vendor}`);
86
86
  if ('google' === vendor) {
@@ -124,9 +124,14 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
124
124
  if (!credentials.deepgram_tts_uri) {
125
125
  assert.ok(credentials.api_key, 'synthAudio requires api_key when deepgram is used');
126
126
  }
127
+ } else if ('xai' === vendor) {
128
+ assert.ok(credentials.api_key, 'synthAudio requires api_key when xai is used');
127
129
  } else if ('cartesia' === vendor) {
128
130
  assert.ok(credentials.api_key, 'synthAudio requires api_key when cartesia is used');
129
131
  assert.ok(credentials.model_id, 'synthAudio requires model_id when cartesia is used');
132
+ } else if ('murf' === vendor) {
133
+ assert.ok(voice, 'synthAudio requires voice when murf is used');
134
+ assert.ok(credentials.api_key, 'synthAudio requires api_key when murf is used');
130
135
  } else if (vendor === 'resemble') {
131
136
  assert.ok(voice, 'synthAudio requires voice when resemble is used');
132
137
  assert.ok(credentials.api_key, 'synthAudio requires api_key when resemble is used');
@@ -211,6 +216,11 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
211
216
  credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
212
217
  disableTtsCache});
213
218
  break;
219
+ case 'murf':
220
+ audioData = await synthMurf(logger, {
221
+ credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
222
+ disableTtsCache});
223
+ break;
214
224
  case 'whisper':
215
225
  audioData = await synthWhisper(logger, {
216
226
  credentials, stats, voice, key, text, instructions, renderForCaching, disableTtsStreaming,
@@ -220,6 +230,11 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
220
230
  audioData = await synthDeepgram(logger, {credentials, stats, model, key, text,
221
231
  renderForCaching, disableTtsStreaming, disableTtsCache});
222
232
  break;
233
+ case 'xai':
234
+ audioData = await synthXai(logger, {
235
+ credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
236
+ disableTtsCache});
237
+ break;
223
238
  case 'resemble':
224
239
  audioData = await synthResemble(logger, {
225
240
  credentials, stats, voice, key, text, options, renderForCaching, disableTtsStreaming, disableTtsCache});
@@ -969,6 +984,72 @@ const synthRimelabs = async(logger, {
969
984
  throw err;
970
985
  }
971
986
  };
987
+
988
+ const synthMurf = async(logger, {
989
+ credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming, disableTtsCache
990
+ }) => {
991
+ const {api_key, model_id, api_uri, options: credOpts} = credentials;
992
+ const opts = !!options && Object.keys(options).length !== 0 ? options : JSON.parse(credOpts || '{}');
993
+
994
+ /* default to using the streaming interface, unless disabled by env var OR we want just a cache file */
995
+ if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
996
+ /* param keys here must match mod_murf_tts's text_param handler */
997
+ let params = '{';
998
+ params += `api_key=${api_key}`;
999
+ params += `,playback_id=${key}`;
1000
+ params += ',vendor=murf';
1001
+ params += `,voice=${voice}`;
1002
+ if (model_id) params += `,model_id=${model_id}`;
1003
+ if (language) params += `,language=${language}`;
1004
+ if (api_uri) params += `,api_uri=${api_uri}`;
1005
+ if (opts.style) params += `,style=${opts.style}`;
1006
+ if (opts.rate !== undefined && opts.rate !== null) params += `,rate=${opts.rate}`;
1007
+ if (opts.pitch !== undefined && opts.pitch !== null) params += `,pitch=${opts.pitch}`;
1008
+ if (opts.variation !== undefined && opts.variation !== null) params += `,variation=${opts.variation}`;
1009
+ params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
1010
+ params += '}';
1011
+
1012
+ return {
1013
+ filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
1014
+ servedFromCache: false,
1015
+ rtt: 0
1016
+ };
1017
+ }
1018
+
1019
+ try {
1020
+ const sampleRate = 8000;
1021
+ /* no Accept header: murf returns 406 if it doesn't match; the response
1022
+ container is selected by the `format` field in the body instead */
1023
+ const post = bent(api_uri || 'https://global.api.murf.ai', 'POST', 'buffer', {
1024
+ 'api-key': api_key,
1025
+ 'Content-Type': 'application/json'
1026
+ });
1027
+ /* murf REST schema is documented loosely; field names follow the SDK params
1028
+ (voice_id/model/format/sample_rate) plus the websocket voice fields. */
1029
+ const audioContent = await post('/v1/speech/stream', {
1030
+ text,
1031
+ voice_id: voice,
1032
+ ...(model_id && {model: model_id}),
1033
+ ...(language && {locale: language}),
1034
+ ...(opts.style && {style: opts.style}),
1035
+ ...(opts.rate !== undefined && opts.rate !== null && {rate: opts.rate}),
1036
+ ...(opts.pitch !== undefined && opts.pitch !== null && {pitch: opts.pitch}),
1037
+ ...(opts.variation !== undefined && opts.variation !== null && {variation: opts.variation}),
1038
+ format: 'WAV',
1039
+ sample_rate: sampleRate,
1040
+ channel_type: 'MONO'
1041
+ });
1042
+ return {
1043
+ audioContent,
1044
+ extension: 'wav',
1045
+ sampleRate
1046
+ };
1047
+ } catch (err) {
1048
+ logger.info({err}, 'synth murf returned error');
1049
+ stats.increment('tts.count', ['vendor:murf', 'accepted:no']);
1050
+ throw err;
1051
+ }
1052
+ };
972
1053
  const synthWhisper = async(logger, {credentials, stats, voice, key, text, instructions,
973
1054
  renderForCaching, disableTtsStreaming, disableTtsCache}) => {
974
1055
  const {api_key, model_id, baseURL, timeout, speed} = credentials;
@@ -1059,6 +1140,72 @@ const synthDeepgram = async(logger, {credentials, stats, model, key, text, rende
1059
1140
  }
1060
1141
  };
1061
1142
 
1143
+ const synthXai = async(logger, {
1144
+ credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming, disableTtsCache
1145
+ }) => {
1146
+ const {api_key, api_uri, options: credOpts} = credentials;
1147
+ const opts = !!options && Object.keys(options).length !== 0 ? options : JSON.parse(credOpts || '{}');
1148
+ const speed = opts.speed;
1149
+ if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
1150
+ let params = '{';
1151
+ params += `api_key=${api_key}`;
1152
+ params += `,playback_id=${key}`;
1153
+ params += ',vendor=xai';
1154
+ if (voice) params += `,voice=${voice}`;
1155
+ if (language) params += `,language=${language}`;
1156
+ if (speed !== null && speed !== undefined) params += `,speed=${speed}`;
1157
+ if (opts.optimize_streaming_latency != null) {
1158
+ params += `,optimize_streaming_latency=${opts.optimize_streaming_latency}`;
1159
+ }
1160
+ if (opts.text_normalization != null) params += `,text_normalization=${opts.text_normalization}`;
1161
+ params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
1162
+ if (api_uri) params += `,endpoint=${api_uri}`;
1163
+ params += '}';
1164
+
1165
+ return {
1166
+ filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
1167
+ servedFromCache: false,
1168
+ rtt: 0
1169
+ };
1170
+ }
1171
+ try {
1172
+ const post = bent(`https://${api_uri || 'api.x.ai'}`, 'POST', 'buffer', {
1173
+ 'Authorization': `Bearer ${api_key}`,
1174
+ 'Content-Type': 'application/json'
1175
+ });
1176
+ const audioContent = await post('/v1/tts', {
1177
+ text,
1178
+ language: language || 'auto',
1179
+ ...(voice && {voice_id: voice}),
1180
+ ...(speed !== null && speed !== undefined && {speed}),
1181
+ ...(opts.optimize_streaming_latency != null && {optimize_streaming_latency: opts.optimize_streaming_latency}),
1182
+ ...(opts.text_normalization != null && {text_normalization: opts.text_normalization}),
1183
+ output_format: {
1184
+ codec: 'wav',
1185
+ sample_rate: 8000
1186
+ }
1187
+ });
1188
+ return {
1189
+ audioContent,
1190
+ extension: 'wav',
1191
+ sampleRate: 8000
1192
+ };
1193
+ } catch (err) {
1194
+ // xAI errors are JSON {code, error} - read the body so the surfaced message isn't 'undefined'
1195
+ if (err.name === 'StatusError' && typeof err.text === 'function') {
1196
+ try {
1197
+ const body = await err.text();
1198
+ if (body) err.message = body;
1199
+ } catch (readErr) {
1200
+ logger.info({readErr}, 'synth xai: failed to read error response body');
1201
+ }
1202
+ }
1203
+ logger.info({err}, 'synth xai returned error');
1204
+ stats.increment('tts.count', ['vendor:xai', 'accepted:no']);
1205
+ throw err;
1206
+ }
1207
+ };
1208
+
1062
1209
  const synthCartesia = async(logger, {
1063
1210
  credentials, options, stats, voice, language, key, text, renderForCaching, disableTtsStreaming, disableTtsCache
1064
1211
  }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jambonz/speech-utils",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "TTS-related speech utilities for jambonz",
5
5
  "main": "index.js",
6
6
  "author": "Dave Horton",
package/test/synth.js CHANGED
@@ -1118,6 +1118,41 @@ test('Deepgram speech synth tests', async(t) => {
1118
1118
  client.quit();
1119
1119
  })
1120
1120
 
1121
+ test('xai speech synth tests', async(t) => {
1122
+ const fn = require('..');
1123
+ const {synthAudio, client} = fn(opts, logger);
1124
+
1125
+ if (!process.env.XAI_API_KEY) {
1126
+ t.pass('skipping xai speech synth tests - no XAI_API_KEY');
1127
+ return t.end();
1128
+ }
1129
+ const text = 'Hi there and welcome to jambones!';
1130
+ try {
1131
+ const opts = await synthAudio(stats, {
1132
+ vendor: 'xai',
1133
+ credentials: {
1134
+ api_key: process.env.XAI_API_KEY,
1135
+ options: JSON.stringify({
1136
+ voice: process.env.XAI_VOICE || 'eve',
1137
+ speed: 1.0,
1138
+ optimize_streaming_latency: 1,
1139
+ text_normalization: true
1140
+ })
1141
+ },
1142
+ language: 'en',
1143
+ voice: process.env.XAI_VOICE || 'eve',
1144
+ text,
1145
+ renderForCaching: true
1146
+ });
1147
+ t.ok(!opts.servedFromCache && opts.filePath, `successfully synthesized xai audio to ${opts.filePath}`);
1148
+
1149
+ } catch (err) {
1150
+ console.error(JSON.stringify(err));
1151
+ t.end(err);
1152
+ }
1153
+ client.quit();
1154
+ });
1155
+
1121
1156
  test('TTS Cache tests', async(t) => {
1122
1157
  const fn = require('..');
1123
1158
  const {purgeTtsCache, getTtsSize, client} = fn(opts, logger);