@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jambonz/speech-utils",
3
- "version": "1.0.16",
3
+ "version": "1.0.18",
4
4
  "description": "TTS-related speech utilities for jambonz",
5
5
  "main": "index.js",
6
6
  "author": "Dave Horton",
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: '3ad7a827-7fd1-4954-bf35-47d4cc33d9ed',
1148
+ voice: '775e4dfd-819c-4325-92ec-250f487ef7e3',
1108
1149
  text,
1109
1150
  renderForCaching: true
1110
1151
  });