@jambonz/speech-utils 1.0.23 → 1.0.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.
@@ -1,5 +1,6 @@
1
1
  const assert = require('assert');
2
2
  const fs = require('fs');
3
+ const crypto = require('crypto');
3
4
  const bent = require('bent');
4
5
  const ttsGoogle = require('@google-cloud/text-to-speech');
5
6
  const { PollyClient, SynthesizeSpeechCommand } = require('@aws-sdk/client-polly');
@@ -79,9 +80,10 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
79
80
  let rtt;
80
81
  logger = logger || noopLogger;
81
82
 
83
+ let mistralRefAudio;
82
84
  assert.ok(['google', 'aws', 'polly', 'microsoft', 'wellsaid', 'nvidia', 'elevenlabs',
83
85
  'whisper', 'deepgram', 'deepgramflux', 'rimelabs', 'cartesia', 'gradium', 'kugelaudio', 'nineninesix', 'inworld',
84
- 'resemble', 'murf', 'xai', 'fishaudio', 'speechify', 'giggy', 'svara']
86
+ 'resemble', 'murf', 'xai', 'fishaudio', 'speechify', 'giggy', 'mistral', 'svara']
85
87
  .includes(vendor) ||
86
88
  vendor.startsWith('custom'),
87
89
  `synthAudio supported vendors are google, aws, microsoft, nvidia and wellsaid ..etc, not ${vendor}`);
@@ -145,6 +147,11 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
145
147
  } else if ('giggy' === vendor) {
146
148
  assert.ok(voice, 'synthAudio requires voice when giggy is used');
147
149
  assert.ok(credentials.api_key, 'synthAudio requires api_key when giggy is used');
150
+ } else if ('mistral' === vendor) {
151
+ mistralRefAudio = options?.ref_audio || parseCredOptions(credentials.options).ref_audio;
152
+ /* a one-off cloned voice (ref_audio) replaces the voice */
153
+ assert.ok(voice || mistralRefAudio, 'synthAudio requires voice or ref_audio when mistral is used');
154
+ assert.ok(credentials.api_key, 'synthAudio requires api_key when mistral is used');
148
155
  } else if ('svara' === vendor) {
149
156
  assert.ok(voice, 'synthAudio requires voice when svara is used');
150
157
  assert.ok(credentials.api_key, 'synthAudio requires api_key when svara is used');
@@ -171,7 +178,10 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
171
178
  account_sid,
172
179
  vendor,
173
180
  language: language || '',
174
- voice: voice || deploymentId,
181
+ /* a cloned voice is identified by its reference clip */
182
+ voice: mistralRefAudio ?
183
+ `ref_audio:${crypto.createHash('sha1').update(mistralRefAudio).digest('hex')}` :
184
+ voice || deploymentId,
175
185
  engine,
176
186
  // model or model_id is used to identify the tts cache.
177
187
  model: model || credentials.model_id,
@@ -261,6 +271,11 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
261
271
  credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming,
262
272
  disableTtsCache});
263
273
  break;
274
+ case 'mistral':
275
+ audioData = await synthMistral(logger, {
276
+ credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming,
277
+ disableTtsCache});
278
+ break;
264
279
  case 'svara':
265
280
  audioData = await synthSvara(logger, {
266
281
  credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
@@ -1863,6 +1878,102 @@ const synthSvara = async(logger, {
1863
1878
  }
1864
1879
  };
1865
1880
 
1881
+ /* Mistral Voxtral: no language field, the voice carries it; output is mono 24 kHz */
1882
+ /* credential Extra Options are stored as a JSON string or an object */
1883
+ const parseCredOptions = (opts) => {
1884
+ if (typeof opts !== 'string') return opts || {};
1885
+ try {
1886
+ return JSON.parse(opts);
1887
+ } catch {
1888
+ return {};
1889
+ }
1890
+ };
1891
+
1892
+ const synthMistral = async(logger, {
1893
+ credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming,
1894
+ disableTtsCache
1895
+ }) => {
1896
+ const {api_key, model_id} = credentials;
1897
+ const {
1898
+ api_uri, expressiveness, prompt_cache_key, ref_audio, phoneme_hints, conversation_history
1899
+ } = {...parseCredOptions(credentials.options), ...options};
1900
+ const isSet = (v) => v !== null && v !== undefined;
1901
+ const asJson = (v) => (typeof v === 'string' ? v : JSON.stringify(v));
1902
+ const model = options?.model_id || model_id || 'voxtral-mini-tts-3-0';
1903
+
1904
+ /* default to using the streaming interface, unless disabled by env var OR we want just a cache file */
1905
+ if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
1906
+ let params = '{';
1907
+ params += `api_key=${api_key}`;
1908
+ params += `,playback_id=${key}`;
1909
+ params += ',vendor=mistral';
1910
+ if (voice) params += `,voice=${voice}`;
1911
+ params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
1912
+ params += `,model_id=${model}`;
1913
+ if (api_uri) params += `,api_uri=${api_uri}`;
1914
+ if (isSet(expressiveness)) params += `,expressiveness=${expressiveness}`;
1915
+ if (prompt_cache_key) params += `,prompt_cache_key=${prompt_cache_key}`;
1916
+ if (ref_audio) params += `,ref_audio=${ref_audio}`;
1917
+ if (isSet(phoneme_hints)) params += `,phoneme_hints=${asJson(phoneme_hints)}`;
1918
+ if (isSet(conversation_history)) params += `,conversation_history=${asJson(conversation_history)}`;
1919
+ params += '}';
1920
+
1921
+ return {
1922
+ filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
1923
+ servedFromCache: false,
1924
+ rtt: 0
1925
+ };
1926
+ }
1927
+
1928
+ try {
1929
+ const sampleRate = 24000;
1930
+ const host = (api_uri || 'api.mistral.ai').replace(/^[a-z]+:\/\//, '').replace(/\/$/, '');
1931
+ /* http:// or ws:// in api_uri means a plain-text proxy, as mediajam reads it */
1932
+ const scheme = /^(http|ws):\/\//.test(api_uri || '') ? 'http' : 'https';
1933
+ const post = bent(`${scheme}://${host}`, 'POST', 'json', {
1934
+ 'Authorization': `Bearer ${api_key}`,
1935
+ 'Content-Type': 'application/json'
1936
+ });
1937
+ /* invalid JSON is dropped, as on the streaming path */
1938
+ const parseJson = (v) => {
1939
+ if (typeof v !== 'string') return v;
1940
+ try {
1941
+ return JSON.parse(v);
1942
+ } catch {
1943
+ return undefined;
1944
+ }
1945
+ };
1946
+ const hints = isSet(phoneme_hints) ? parseJson(phoneme_hints) : undefined;
1947
+ const history = isSet(conversation_history) && !ref_audio ? parseJson(conversation_history) : undefined;
1948
+ const {audio_data} = await post('/v1/audio/speech', {
1949
+ model,
1950
+ input: text,
1951
+ response_format: 'pcm',
1952
+ ...(ref_audio ? {ref_audio} : {voice_id: voice}),
1953
+ ...(isSet(expressiveness) && {expressiveness: Number(expressiveness)}),
1954
+ ...(prompt_cache_key && {prompt_cache_key}),
1955
+ ...(isSet(hints) && {phoneme_hints: hints}),
1956
+ ...(isSet(history) && {conversation_history: history})
1957
+ });
1958
+ /* Voxtral pcm is float32; the r24 cache is s16 */
1959
+ const f32 = Buffer.from(audio_data, 'base64');
1960
+ const audioContent = Buffer.alloc(Math.floor(f32.length / 4) * 2);
1961
+ for (let i = 0; i < audioContent.length / 2; i++) {
1962
+ const v = Math.max(-1, Math.min(1, f32.readFloatLE(i * 4)));
1963
+ audioContent.writeInt16LE(Math.round(v * 32767), i * 2);
1964
+ }
1965
+ return {
1966
+ audioContent,
1967
+ extension: 'r24',
1968
+ sampleRate
1969
+ };
1970
+ } catch (err) {
1971
+ logger.info({err}, 'synth mistral returned error');
1972
+ stats.increment('tts.count', ['vendor:mistral', 'accepted:no']);
1973
+ throw err;
1974
+ }
1975
+ };
1976
+
1866
1977
  /* nineninesix.ai — a Cartesia-compatible API, but only raw/wav come back
1867
1978
  (mp3 is rejected), so the cache render asks for wav rather than mp3. */
1868
1979
  /* fish.audio — msgpack websocket for streaming, and a POST endpoint for the cache
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jambonz/speech-utils",
3
- "version": "1.0.23",
3
+ "version": "1.0.24",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },
package/test/synth.js CHANGED
@@ -1206,6 +1206,35 @@ test('svara speech synth tests', async(t) => {
1206
1206
  client.quit();
1207
1207
  });
1208
1208
 
1209
+ test('mistral speech synth tests', async(t) => {
1210
+ const fn = require('..');
1211
+ const {synthAudio, client} = fn(opts, logger);
1212
+
1213
+ if (!process.env.MISTRAL_API_KEY) {
1214
+ t.pass('skipping mistral speech synth tests since MISTRAL_API_KEY is not provided');
1215
+ return t.end();
1216
+ }
1217
+ const text = 'Hi there and welcome to jambonz! ' + Date.now();
1218
+ try {
1219
+ const opts = await synthAudio(stats, {
1220
+ vendor: 'mistral',
1221
+ credentials: {
1222
+ api_key: process.env.MISTRAL_API_KEY
1223
+ },
1224
+ language: 'en-US',
1225
+ voice: process.env.MISTRAL_VOICE_ID || 'voice_478',
1226
+ text,
1227
+ renderForCaching: true
1228
+ });
1229
+ t.ok(!opts.servedFromCache, `successfully synthed mistral audio to ${opts.filePath}`);
1230
+
1231
+ } catch (err) {
1232
+ console.error(JSON.stringify(err));
1233
+ t.end(err);
1234
+ }
1235
+ client.quit();
1236
+ });
1237
+
1209
1238
  test('fishaudio speech synth tests', async(t) => {
1210
1239
  const fn = require('..');
1211
1240
  const {synthAudio, client} = fn(opts, logger);