@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.
- package/lib/synth-audio.js +113 -2
- package/package.json +1 -1
- package/test/synth.js +29 -0
package/lib/synth-audio.js
CHANGED
|
@@ -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
|
-
|
|
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
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);
|