@jambonz/speech-utils 1.0.16 → 1.0.18
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 +89 -1
- package/package.json +1 -1
- package/test/synth.js +42 -1
package/lib/synth-audio.js
CHANGED
|
@@ -81,7 +81,7 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
81
81
|
|
|
82
82
|
assert.ok(['google', 'aws', 'polly', 'microsoft', 'wellsaid', 'nvidia', 'elevenlabs',
|
|
83
83
|
'whisper', 'deepgram', 'deepgramflux', 'rimelabs', 'cartesia', 'gradium', 'nineninesix', 'inworld', 'resemble',
|
|
84
|
-
'murf', 'xai']
|
|
84
|
+
'murf', 'xai', 'fishaudio']
|
|
85
85
|
.includes(vendor) ||
|
|
86
86
|
vendor.startsWith('custom'),
|
|
87
87
|
`synthAudio supported vendors are google, aws, microsoft, nvidia and wellsaid ..etc, not ${vendor}`);
|
|
@@ -149,6 +149,10 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
149
149
|
} else if (vendor === 'resemble') {
|
|
150
150
|
assert.ok(voice, 'synthAudio requires voice when resemble is used');
|
|
151
151
|
assert.ok(credentials.api_key, 'synthAudio requires api_key when resemble is used');
|
|
152
|
+
} else if ('fishaudio' === vendor) {
|
|
153
|
+
/* no voice assert: fish synthesizes with its own default voice when
|
|
154
|
+
reference_id is omitted, which is what the 'default' selection means */
|
|
155
|
+
assert.ok(credentials.api_key, 'synthAudio requires api_key when fishaudio is used');
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
const key = makeSynthKey({
|
|
@@ -220,6 +224,11 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
220
224
|
credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
|
|
221
225
|
disableTtsCache});
|
|
222
226
|
break;
|
|
227
|
+
case 'fishaudio':
|
|
228
|
+
audioData = await synthFishaudio(logger, {
|
|
229
|
+
credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming,
|
|
230
|
+
disableTtsCache});
|
|
231
|
+
break;
|
|
223
232
|
case 'gradium':
|
|
224
233
|
audioData = await synthGradium(logger, {
|
|
225
234
|
credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming,
|
|
@@ -1463,6 +1472,10 @@ const synthGradium = async(logger, {
|
|
|
1463
1472
|
params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
|
|
1464
1473
|
if (model_id) params += `,model_id=${model_id}`;
|
|
1465
1474
|
if (pronunciation_id) params += `,pronunciation_id=${pronunciation_id}`;
|
|
1475
|
+
/* the say: param parser is brace-aware, so a nested json object survives intact */
|
|
1476
|
+
if (json_config) {
|
|
1477
|
+
params += `,json_config=${typeof json_config === 'string' ? json_config : JSON.stringify(json_config)}`;
|
|
1478
|
+
}
|
|
1466
1479
|
params += '}';
|
|
1467
1480
|
|
|
1468
1481
|
return {
|
|
@@ -1501,6 +1514,81 @@ const synthGradium = async(logger, {
|
|
|
1501
1514
|
|
|
1502
1515
|
/* nineninesix.ai — a Cartesia-compatible API, but only raw/wav come back
|
|
1503
1516
|
(mp3 is rejected), so the cache render asks for wav rather than mp3. */
|
|
1517
|
+
/* fish.audio — msgpack websocket for streaming, and a POST endpoint for the cache
|
|
1518
|
+
render. format:pcm + sample_rate:8000 returns bare little-endian 16-bit samples,
|
|
1519
|
+
which is exactly the r8 container. we avoid fish's wav output because its RIFF
|
|
1520
|
+
header carries a placeholder size (0xffffff24) — length is unknown up front, as
|
|
1521
|
+
with gradium.
|
|
1522
|
+
|
|
1523
|
+
fish is a voice-cloning vendor: the "voice" is a reference_id returned by
|
|
1524
|
+
POST /model, and omitting it entirely synthesizes with fish's default voice.
|
|
1525
|
+
the sentinel value 'default' (the bundled fallback entry in the portal) means
|
|
1526
|
+
exactly that — send no reference_id.
|
|
1527
|
+
*/
|
|
1528
|
+
const synthFishaudio = async(logger, {
|
|
1529
|
+
credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming, disableTtsCache
|
|
1530
|
+
}) => {
|
|
1531
|
+
const {api_key, model_id, fishaudio_tts_uri} = credentials;
|
|
1532
|
+
const {reference_id, latency, chunk_length, speed, volume} = options || {};
|
|
1533
|
+
|
|
1534
|
+
/* free-text reference_id in the vendor options wins over the voice selector */
|
|
1535
|
+
const refId = reference_id || (voice && voice !== 'default' ? voice : null);
|
|
1536
|
+
|
|
1537
|
+
/* default to using the streaming interface, unless disabled by env var OR we want just a cache file */
|
|
1538
|
+
if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
|
|
1539
|
+
let params = '{';
|
|
1540
|
+
params += `api_key=${api_key}`;
|
|
1541
|
+
params += `,playback_id=${key}`;
|
|
1542
|
+
params += ',vendor=fishaudio';
|
|
1543
|
+
params += `,voice=${refId || 'default'}`;
|
|
1544
|
+
params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
|
|
1545
|
+
if (model_id) params += `,model_id=${model_id}`;
|
|
1546
|
+
if (latency) params += `,latency=${latency}`;
|
|
1547
|
+
if (chunk_length) params += `,chunk_length=${chunk_length}`;
|
|
1548
|
+
if (speed) params += `,speed=${speed}`;
|
|
1549
|
+
if (volume) params += `,volume=${volume}`;
|
|
1550
|
+
if (fishaudio_tts_uri) params += `,endpoint=${fishaudio_tts_uri}`;
|
|
1551
|
+
params += '}';
|
|
1552
|
+
|
|
1553
|
+
return {
|
|
1554
|
+
filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
|
|
1555
|
+
servedFromCache: false,
|
|
1556
|
+
rtt: 0
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
try {
|
|
1561
|
+
const sampleRate = 8000;
|
|
1562
|
+
const post = bent(fishaudio_tts_uri || 'https://api.fish.audio', 'POST', 'buffer', {
|
|
1563
|
+
'Authorization': `Bearer ${api_key}`,
|
|
1564
|
+
'Content-Type': 'application/json',
|
|
1565
|
+
/* the model is selected by header, not in the body */
|
|
1566
|
+
'model': model_id || 's2.1-pro'
|
|
1567
|
+
});
|
|
1568
|
+
const audioContent = await post('/v1/tts', {
|
|
1569
|
+
text,
|
|
1570
|
+
format: 'pcm',
|
|
1571
|
+
sample_rate: sampleRate,
|
|
1572
|
+
...(refId && {reference_id: refId}),
|
|
1573
|
+
...(latency && {latency}),
|
|
1574
|
+
...(chunk_length && {chunk_length: parseInt(chunk_length, 10)}),
|
|
1575
|
+
...((speed || volume) && {prosody: {
|
|
1576
|
+
...(speed && {speed: parseFloat(speed)}),
|
|
1577
|
+
...(volume && {volume: parseFloat(volume)})
|
|
1578
|
+
}})
|
|
1579
|
+
});
|
|
1580
|
+
return {
|
|
1581
|
+
audioContent,
|
|
1582
|
+
extension: 'r8',
|
|
1583
|
+
sampleRate
|
|
1584
|
+
};
|
|
1585
|
+
} catch (err) {
|
|
1586
|
+
logger.info({err}, 'synth fishaudio returned error');
|
|
1587
|
+
stats.increment('tts.count', ['vendor:fishaudio', 'accepted:no']);
|
|
1588
|
+
throw err;
|
|
1589
|
+
}
|
|
1590
|
+
};
|
|
1591
|
+
|
|
1504
1592
|
const synthNineninesix = async(logger, {
|
|
1505
1593
|
credentials, stats, voice, language, key, text, renderForCaching, disableTtsStreaming, disableTtsCache
|
|
1506
1594
|
}) => {
|
package/package.json
CHANGED
package/test/synth.js
CHANGED
|
@@ -1087,6 +1087,47 @@ test('gradium speech synth tests', async(t) => {
|
|
|
1087
1087
|
client.quit();
|
|
1088
1088
|
});
|
|
1089
1089
|
|
|
1090
|
+
test('fishaudio speech synth tests', async(t) => {
|
|
1091
|
+
const fn = require('..');
|
|
1092
|
+
const {synthAudio, client} = fn(opts, logger);
|
|
1093
|
+
|
|
1094
|
+
if (!process.env.FISHAUDIO_API_KEY) {
|
|
1095
|
+
t.pass('skipping fishaudio speech synth tests since FISHAUDIO_API_KEY is not provided');
|
|
1096
|
+
return t.end();
|
|
1097
|
+
}
|
|
1098
|
+
const text = 'Hi there and welcome to jambones! ' + Date.now();
|
|
1099
|
+
try {
|
|
1100
|
+
/* voice 'default' means "send no reference_id" — fish's own default voice */
|
|
1101
|
+
const o = await synthAudio(stats, {
|
|
1102
|
+
vendor: 'fishaudio',
|
|
1103
|
+
credentials: {
|
|
1104
|
+
api_key: process.env.FISHAUDIO_API_KEY,
|
|
1105
|
+
model_id: 's2.1-pro'
|
|
1106
|
+
},
|
|
1107
|
+
voice: 'default',
|
|
1108
|
+
text,
|
|
1109
|
+
renderForCaching: true
|
|
1110
|
+
});
|
|
1111
|
+
t.ok(!o.servedFromCache, `successfully synthed fishaudio audio to ${o.filePath}`);
|
|
1112
|
+
|
|
1113
|
+
/* the cache render must be raw 8k pcm (r8): fish's wav header carries a
|
|
1114
|
+
placeholder RIFF size, so we never ask for wav */
|
|
1115
|
+
const o2 = await synthAudio(stats, {
|
|
1116
|
+
vendor: 'fishaudio',
|
|
1117
|
+
credentials: {api_key: process.env.FISHAUDIO_API_KEY},
|
|
1118
|
+
voice: 'default',
|
|
1119
|
+
text: text + ' two',
|
|
1120
|
+
renderForCaching: true,
|
|
1121
|
+
disableTtsCache: true
|
|
1122
|
+
});
|
|
1123
|
+
t.ok(!o2.servedFromCache, 'fishaudio synthed a second uncached render');
|
|
1124
|
+
} catch (err) {
|
|
1125
|
+
console.error(JSON.stringify(err));
|
|
1126
|
+
t.end(err);
|
|
1127
|
+
}
|
|
1128
|
+
client.quit();
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1090
1131
|
test('nineninesix speech synth tests', async(t) => {
|
|
1091
1132
|
const fn = require('..');
|
|
1092
1133
|
const {synthAudio, client} = fn(opts, logger);
|
|
@@ -1104,7 +1145,7 @@ test('nineninesix speech synth tests', async(t) => {
|
|
|
1104
1145
|
model_id: 'gepard-1.0'
|
|
1105
1146
|
},
|
|
1106
1147
|
language: 'en',
|
|
1107
|
-
voice: '
|
|
1148
|
+
voice: '775e4dfd-819c-4325-92ec-250f487ef7e3',
|
|
1108
1149
|
text,
|
|
1109
1150
|
renderForCaching: true
|
|
1110
1151
|
});
|