@jambonz/speech-utils 1.0.22 → 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 +135 -13
- 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
|
|
@@ -1879,8 +1990,18 @@ const synthSvara = async(logger, {
|
|
|
1879
1990
|
const synthFishaudio = async(logger, {
|
|
1880
1991
|
credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming, disableTtsCache
|
|
1881
1992
|
}) => {
|
|
1882
|
-
const {api_key, model_id
|
|
1883
|
-
|
|
1993
|
+
const {api_key, model_id} = credentials;
|
|
1994
|
+
let credOptions = credentials.options || {};
|
|
1995
|
+
if (typeof credOptions === 'string') {
|
|
1996
|
+
try {
|
|
1997
|
+
credOptions = JSON.parse(credOptions);
|
|
1998
|
+
} catch {
|
|
1999
|
+
credOptions = {};
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
/* Extra Options on the credential apply to every request; the verb's options win */
|
|
2003
|
+
const {reference_id, latency, chunk_length, speed, volume, api_uri} = {...credOptions, ...options};
|
|
2004
|
+
const isSet = (v) => v !== null && v !== undefined && v !== '';
|
|
1884
2005
|
|
|
1885
2006
|
/* free-text reference_id in the vendor options wins over the voice selector */
|
|
1886
2007
|
const refId = reference_id || (voice && voice !== 'default' ? voice : null);
|
|
@@ -1895,10 +2016,10 @@ const synthFishaudio = async(logger, {
|
|
|
1895
2016
|
params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
|
|
1896
2017
|
if (model_id) params += `,model_id=${model_id}`;
|
|
1897
2018
|
if (latency) params += `,latency=${latency}`;
|
|
1898
|
-
if (chunk_length) params += `,chunk_length=${chunk_length}`;
|
|
1899
|
-
if (speed) params += `,speed=${speed}`;
|
|
1900
|
-
if (volume) params += `,volume=${volume}`;
|
|
1901
|
-
if (
|
|
2019
|
+
if (isSet(chunk_length)) params += `,chunk_length=${chunk_length}`;
|
|
2020
|
+
if (isSet(speed)) params += `,speed=${speed}`;
|
|
2021
|
+
if (isSet(volume)) params += `,volume=${volume}`;
|
|
2022
|
+
if (api_uri) params += `,api_uri=${api_uri}`;
|
|
1902
2023
|
params += '}';
|
|
1903
2024
|
|
|
1904
2025
|
return {
|
|
@@ -1910,7 +2031,8 @@ const synthFishaudio = async(logger, {
|
|
|
1910
2031
|
|
|
1911
2032
|
try {
|
|
1912
2033
|
const sampleRate = 8000;
|
|
1913
|
-
const
|
|
2034
|
+
const host = (api_uri || 'api.fish.audio').replace(/^[a-z]+:\/\//, '').replace(/\/$/, '');
|
|
2035
|
+
const post = bent(`https://${host}`, 'POST', 'buffer', {
|
|
1914
2036
|
'Authorization': `Bearer ${api_key}`,
|
|
1915
2037
|
'Content-Type': 'application/json',
|
|
1916
2038
|
/* the model is selected by header, not in the body */
|
|
@@ -1922,10 +2044,10 @@ const synthFishaudio = async(logger, {
|
|
|
1922
2044
|
sample_rate: sampleRate,
|
|
1923
2045
|
...(refId && {reference_id: refId}),
|
|
1924
2046
|
...(latency && {latency}),
|
|
1925
|
-
...(chunk_length && {chunk_length: parseInt(chunk_length, 10)}),
|
|
1926
|
-
...((speed || volume) && {prosody: {
|
|
1927
|
-
...(speed && {speed: parseFloat(speed)}),
|
|
1928
|
-
...(volume && {volume: parseFloat(volume)})
|
|
2047
|
+
...(isSet(chunk_length) && {chunk_length: parseInt(chunk_length, 10)}),
|
|
2048
|
+
...((isSet(speed) || isSet(volume)) && {prosody: {
|
|
2049
|
+
...(isSet(speed) && {speed: parseFloat(speed)}),
|
|
2050
|
+
...(isSet(volume) && {volume: parseFloat(volume)})
|
|
1929
2051
|
}})
|
|
1930
2052
|
});
|
|
1931
2053
|
return {
|
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);
|