@jambonz/speech-utils 1.0.19 → 1.0.21

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', 'kugelaudio', 'nineninesix', 'inworld',
84
- 'resemble', 'murf', 'xai', 'fishaudio']
84
+ 'resemble', 'murf', 'xai', 'fishaudio', 'speechify']
85
85
  .includes(vendor) ||
86
86
  vendor.startsWith('custom'),
87
87
  `synthAudio supported vendors are google, aws, microsoft, nvidia and wellsaid ..etc, not ${vendor}`);
@@ -139,6 +139,9 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
139
139
  } else if ('gradium' === vendor) {
140
140
  assert.ok(voice, 'synthAudio requires voice when gradium is used');
141
141
  assert.ok(credentials.api_key, 'synthAudio requires api_key when gradium is used');
142
+ } else if ('speechify' === vendor) {
143
+ assert.ok(voice, 'synthAudio requires voice when speechify is used');
144
+ assert.ok(credentials.api_key, 'synthAudio requires api_key when speechify is used');
142
145
  } else if ('kugelaudio' === vendor) {
143
146
  assert.ok(voice, 'synthAudio requires voice when kugelaudio is used');
144
147
  assert.ok(credentials.api_key, 'synthAudio requires api_key when kugelaudio is used');
@@ -242,6 +245,11 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
242
245
  credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
243
246
  disableTtsCache});
244
247
  break;
248
+ case 'speechify':
249
+ audioData = await synthSpeechify(logger, {
250
+ credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
251
+ disableTtsCache});
252
+ break;
245
253
  case 'nineninesix':
246
254
  audioData = await synthNineninesix(logger, {
247
255
  credentials, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
@@ -1602,6 +1610,84 @@ const synthKugelaudio = async(logger, {
1602
1610
  }
1603
1611
  };
1604
1612
 
1613
+ /* simba-3.2 is English only; with no model set, other languages get simba-3.0 */
1614
+ const speechifyModel = (model_id, language) => {
1615
+ if (model_id) return model_id;
1616
+ return !language || /^en/i.test(language) ? 'simba-3.2' : 'simba-3.0';
1617
+ };
1618
+ const synthSpeechify = async(logger, {
1619
+ credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
1620
+ disableTtsCache
1621
+ }) => {
1622
+ const {api_key, model_id} = credentials;
1623
+ let credOptions = credentials.options || {};
1624
+ if (typeof credOptions === 'string') {
1625
+ try {
1626
+ credOptions = JSON.parse(credOptions);
1627
+ } catch {
1628
+ credOptions = {};
1629
+ }
1630
+ }
1631
+ const {api_uri, loudness_normalization, text_normalization} = {...credOptions, ...options};
1632
+ const isSet = (v) => v !== null && v !== undefined;
1633
+ const model = speechifyModel(options?.model_id || model_id, language);
1634
+
1635
+ /* default to using the streaming interface, unless disabled by env var OR we want just a cache file */
1636
+ if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
1637
+ let params = '{';
1638
+ params += `api_key=${api_key}`;
1639
+ params += `,playback_id=${key}`;
1640
+ params += ',vendor=speechify';
1641
+ params += `,voice=${voice}`;
1642
+ params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
1643
+ params += `,model_id=${model}`;
1644
+ if (language) params += `,language=${language}`;
1645
+ if (api_uri) params += `,api_uri=${api_uri}`;
1646
+ if (isSet(loudness_normalization)) params += `,loudness_normalization=${loudness_normalization}`;
1647
+ if (isSet(text_normalization)) params += `,text_normalization=${text_normalization}`;
1648
+ params += '}';
1649
+
1650
+ return {
1651
+ filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
1652
+ servedFromCache: false,
1653
+ rtt: 0
1654
+ };
1655
+ }
1656
+
1657
+ try {
1658
+ /* other pcm rates are mislabelled 24 kHz on workspaces pinned before 2026-09-30 */
1659
+ const sampleRate = 24000;
1660
+ const host = (api_uri || 'api.speechify.ai').replace(/^[a-z]+:\/\//, '').replace(/\/$/, '');
1661
+ const post = bent(`https://${host}`, 'POST', 'buffer', {
1662
+ 'Authorization': `Bearer ${api_key}`,
1663
+ 'Content-Type': 'application/json',
1664
+ 'Speechify-Caller': 'jambonz'
1665
+ });
1666
+ const toBool = (v) => v === true || v === 'true';
1667
+ const speechOptions = {
1668
+ ...(isSet(loudness_normalization) && {loudness_normalization: toBool(loudness_normalization)}),
1669
+ ...(isSet(text_normalization) && {text_normalization: toBool(text_normalization)})
1670
+ };
1671
+ const audioContent = await post('/v1/audio/stream', {
1672
+ input: text,
1673
+ voice_id: voice,
1674
+ model,
1675
+ output_format: `pcm_${sampleRate}`,
1676
+ ...(language && {language}),
1677
+ ...(Object.keys(speechOptions).length && {options: speechOptions})
1678
+ });
1679
+ return {
1680
+ audioContent,
1681
+ extension: 'r24',
1682
+ sampleRate
1683
+ };
1684
+ } catch (err) {
1685
+ logger.info({err}, 'synth speechify returned error');
1686
+ stats.increment('tts.count', ['vendor:speechify', 'accepted:no']);
1687
+ throw err;
1688
+ }
1689
+ };
1690
+
1605
1691
  /* nineninesix.ai — a Cartesia-compatible API, but only raw/wav come back
1606
1692
  (mp3 is rejected), so the cache render asks for wav rather than mp3. */
1607
1693
  /* 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.19",
3
+ "version": "1.0.21",
4
4
  "description": "TTS-related speech utilities for jambonz",
5
5
  "main": "index.js",
6
6
  "author": "Dave Horton",
package/test/synth.js CHANGED
@@ -1120,6 +1120,35 @@ test('kugelaudio speech synth tests', async(t) => {
1120
1120
  client.quit();
1121
1121
  });
1122
1122
 
1123
+ test('speechify speech synth tests', async(t) => {
1124
+ const fn = require('..');
1125
+ const {synthAudio, client} = fn(opts, logger);
1126
+
1127
+ if (!process.env.SPEECHIFY_API_KEY) {
1128
+ t.pass('skipping speechify speech synth tests since SPEECHIFY_API_KEY is not provided');
1129
+ return t.end();
1130
+ }
1131
+ const text = 'Hi there and welcome to jambonz! ' + Date.now();
1132
+ try {
1133
+ const opts = await synthAudio(stats, {
1134
+ vendor: 'speechify',
1135
+ credentials: {
1136
+ api_key: process.env.SPEECHIFY_API_KEY
1137
+ },
1138
+ language: 'en-US',
1139
+ voice: 'geffen_32',
1140
+ text,
1141
+ renderForCaching: true
1142
+ });
1143
+ t.ok(!opts.servedFromCache, `successfully synthed speechify audio to ${opts.filePath}`);
1144
+
1145
+ } catch (err) {
1146
+ console.error(JSON.stringify(err));
1147
+ t.end(err);
1148
+ }
1149
+ client.quit();
1150
+ });
1151
+
1123
1152
  test('fishaudio speech synth tests', async(t) => {
1124
1153
  const fn = require('..');
1125
1154
  const {synthAudio, client} = fn(opts, logger);