@jambonz/speech-utils 1.0.21 → 1.0.22

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.
@@ -11,11 +11,11 @@ jobs:
11
11
  id-token: write # required to request the GitHub OIDC token for AWS
12
12
  contents: read
13
13
  steps:
14
- - uses: actions/checkout@v4
15
- - uses: actions/setup-node@v4
14
+ - uses: actions/checkout@v5
15
+ - uses: actions/setup-node@v5
16
16
  with:
17
- node-version: '20'
18
- - uses: aws-actions/configure-aws-credentials@v4
17
+ node-version: '22'
18
+ - uses: aws-actions/configure-aws-credentials@v6
19
19
  with:
20
20
  role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
21
21
  aws-region: us-east-1
@@ -12,8 +12,8 @@ jobs:
12
12
  runs-on: ubuntu-latest
13
13
 
14
14
  steps:
15
- - uses: actions/checkout@v4
16
- - uses: actions/setup-node@v4
15
+ - uses: actions/checkout@v5
16
+ - uses: actions/setup-node@v5
17
17
  with:
18
18
  node-version: lts/*
19
19
  registry-url: 'https://registry.npmjs.org'
@@ -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', 'speechify']
84
+ 'resemble', 'murf', 'xai', 'fishaudio', 'speechify', 'giggy', 'svara']
85
85
  .includes(vendor) ||
86
86
  vendor.startsWith('custom'),
87
87
  `synthAudio supported vendors are google, aws, microsoft, nvidia and wellsaid ..etc, not ${vendor}`);
@@ -142,6 +142,12 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
142
142
  } else if ('speechify' === vendor) {
143
143
  assert.ok(voice, 'synthAudio requires voice when speechify is used');
144
144
  assert.ok(credentials.api_key, 'synthAudio requires api_key when speechify is used');
145
+ } else if ('giggy' === vendor) {
146
+ assert.ok(voice, 'synthAudio requires voice when giggy is used');
147
+ assert.ok(credentials.api_key, 'synthAudio requires api_key when giggy is used');
148
+ } else if ('svara' === vendor) {
149
+ assert.ok(voice, 'synthAudio requires voice when svara is used');
150
+ assert.ok(credentials.api_key, 'synthAudio requires api_key when svara is used');
145
151
  } else if ('kugelaudio' === vendor) {
146
152
  assert.ok(voice, 'synthAudio requires voice when kugelaudio is used');
147
153
  assert.ok(credentials.api_key, 'synthAudio requires api_key when kugelaudio is used');
@@ -250,6 +256,16 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
250
256
  credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
251
257
  disableTtsCache});
252
258
  break;
259
+ case 'giggy':
260
+ audioData = await synthGiggy(logger, {
261
+ credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming,
262
+ disableTtsCache});
263
+ break;
264
+ case 'svara':
265
+ audioData = await synthSvara(logger, {
266
+ credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
267
+ disableTtsCache});
268
+ break;
253
269
  case 'nineninesix':
254
270
  audioData = await synthNineninesix(logger, {
255
271
  credentials, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
@@ -1377,14 +1393,10 @@ const synthCartesia = async(logger, {
1377
1393
 
1378
1394
  // omit a control unless explicitly provided (0 is a valid value, so test for nullish only)
1379
1395
  const has = (v) => v !== null && v !== undefined;
1380
- /* Voice controls are model-family specific:
1381
- - sonic-2 takes `experimentalControls` (emotion is an array, no volume).
1382
- - sonic-3 family (sonic-3, sonic-3.5, pinned sonic-3.x snapshots) takes
1383
- `generationConfig` (emotion is a string, volume supported). Match the same
1384
- "starts with sonic-3" predicate the freeswitch streaming module uses
1385
- (mod_cartesia_tts_streaming: strncmp(model_id, "sonic-3", ...)), so cached
1386
- and streamed synthesis behave identically.
1387
- Older models (sonic, sonic-english, sonic-multilingual, sonic-2024-*) take neither. */
1396
+ /* Voice controls (speed, emotion, volume) go in `generationConfig`, which only the
1397
+ sonic-3 family (sonic-3, sonic-3.5, sonic-3.6, pinned sonic-3.x snapshots) accepts;
1398
+ other models get no controls. mediajam uses the same "starts with sonic-3" check
1399
+ (internal/tts/cartesia.go), so cached and streamed synthesis behave identically. */
1388
1400
  const isSonic3 = model_id?.startsWith('sonic-3');
1389
1401
  const mp3Stream = await client.tts.bytes({
1390
1402
  modelId: model_id,
@@ -1399,12 +1411,6 @@ const synthCartesia = async(logger, {
1399
1411
  id: voice
1400
1412
  }
1401
1413
  ),
1402
- ...(model_id === 'sonic-2' && (opts.speed || opts.emotion) && {
1403
- experimentalControls: {
1404
- ...(has(opts.speed) && {speed: opts.speed}),
1405
- ...(opts.emotion && {emotion: [opts.emotion]}),
1406
- }
1407
- }),
1408
1414
  },
1409
1415
  ...(isSonic3 && (has(opts.speed) || has(opts.emotion) || has(opts.volume)) && {
1410
1416
  generationConfig: {
@@ -1688,6 +1694,175 @@ const synthSpeechify = async(logger, {
1688
1694
  }
1689
1695
  };
1690
1696
 
1697
+ /* language_code is never sent: Giggy rejects one that differs from the voice's own */
1698
+ const synthGiggy = async(logger, {
1699
+ credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming,
1700
+ disableTtsCache
1701
+ }) => {
1702
+ const {api_key, model_id} = credentials;
1703
+ let credOptions = credentials.options || {};
1704
+ if (typeof credOptions === 'string') {
1705
+ try {
1706
+ credOptions = JSON.parse(credOptions);
1707
+ } catch {
1708
+ credOptions = {};
1709
+ }
1710
+ }
1711
+ const {api_uri, speed, seed} = {...credOptions, ...options};
1712
+ const isSet = (v) => v !== null && v !== undefined;
1713
+ const model = options?.model_id || model_id || 'giggyspeech';
1714
+
1715
+ /* default to using the streaming interface, unless disabled by env var OR we want just a cache file */
1716
+ if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
1717
+ let params = '{';
1718
+ params += `api_key=${api_key}`;
1719
+ params += `,playback_id=${key}`;
1720
+ params += ',vendor=giggy';
1721
+ params += `,voice=${voice}`;
1722
+ params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
1723
+ params += `,model_id=${model}`;
1724
+ if (api_uri) params += `,api_uri=${api_uri}`;
1725
+ if (isSet(speed)) params += `,speed=${speed}`;
1726
+ if (isSet(seed)) params += `,seed=${seed}`;
1727
+ params += '}';
1728
+
1729
+ return {
1730
+ filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
1731
+ servedFromCache: false,
1732
+ rtt: 0
1733
+ };
1734
+ }
1735
+
1736
+ try {
1737
+ /* streaming mode is the only one that returns raw pcm (fast/batch are mp3) */
1738
+ const sampleRate = 24000;
1739
+ const host = (api_uri || 'giggy.ai').replace(/^[a-z]+:\/\//, '').replace(/\/$/, '');
1740
+ const post = bent(`https://${host}`, 'POST', 'buffer', {
1741
+ 'xi-api-key': api_key,
1742
+ 'Content-Type': 'application/json'
1743
+ });
1744
+ const audioContent = await post('/v1/text-to-speech', {
1745
+ text,
1746
+ voice_id: voice,
1747
+ model_id: model,
1748
+ mode: 'streaming',
1749
+ output_format: `pcm_${sampleRate}`,
1750
+ ...(isSet(speed) && {voice_settings: {speed: Number(speed)}}),
1751
+ ...(isSet(seed) && {seed: Number(seed)})
1752
+ });
1753
+ return {
1754
+ audioContent,
1755
+ extension: 'r24',
1756
+ sampleRate
1757
+ };
1758
+ } catch (err) {
1759
+ logger.info({err}, 'synth giggy returned error');
1760
+ stats.increment('tts.count', ['vendor:giggy', 'accepted:no']);
1761
+ throw err;
1762
+ }
1763
+ };
1764
+
1765
+ const synthSvara = async(logger, {
1766
+ credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
1767
+ disableTtsCache
1768
+ }) => {
1769
+ const {api_key, model_id} = credentials;
1770
+ let credOptions = credentials.options || {};
1771
+ if (typeof credOptions === 'string') {
1772
+ try {
1773
+ credOptions = JSON.parse(credOptions);
1774
+ } catch {
1775
+ credOptions = {};
1776
+ }
1777
+ }
1778
+ const {
1779
+ api_uri, speed, pronunciation_dictionary_id, normalize, temperature, top_p, top_k, min_p,
1780
+ repetition_penalty, presence_penalty, max_tokens, chunk_size, buffer_ms, chunk_codes
1781
+ } = {...credOptions, ...options};
1782
+ const isSet = (v) => v !== null && v !== undefined;
1783
+ const model = options?.model_id || model_id || 'svara-tts-turbo';
1784
+
1785
+ /* default to using the streaming interface, unless disabled by env var OR we want just a cache file */
1786
+ if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
1787
+ let params = '{';
1788
+ params += `api_key=${api_key}`;
1789
+ params += `,playback_id=${key}`;
1790
+ params += ',vendor=svara';
1791
+ params += `,voice=${voice}`;
1792
+ params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
1793
+ params += `,model_id=${model}`;
1794
+ if (language) params += `,language=${language}`;
1795
+ if (api_uri) params += `,endpoint=${api_uri}`;
1796
+ if (isSet(speed)) params += `,speed=${speed}`;
1797
+ if (pronunciation_dictionary_id) params += `,pronunciation_dictionary_id=${pronunciation_dictionary_id}`;
1798
+ if (isSet(normalize)) params += `,normalize=${normalize}`;
1799
+ if (isSet(temperature)) params += `,temperature=${temperature}`;
1800
+ if (isSet(top_p)) params += `,top_p=${top_p}`;
1801
+ if (isSet(top_k)) params += `,top_k=${top_k}`;
1802
+ if (isSet(min_p)) params += `,min_p=${min_p}`;
1803
+ if (isSet(repetition_penalty)) params += `,repetition_penalty=${repetition_penalty}`;
1804
+ if (isSet(presence_penalty)) params += `,presence_penalty=${presence_penalty}`;
1805
+ if (isSet(max_tokens)) params += `,max_tokens=${max_tokens}`;
1806
+ if (isSet(chunk_size)) params += `,chunk_size=${chunk_size}`;
1807
+ if (isSet(buffer_ms)) params += `,buffer_ms=${buffer_ms}`;
1808
+ if (isSet(chunk_codes)) params += `,chunk_codes=${chunk_codes}`;
1809
+ params += '}';
1810
+
1811
+ return {
1812
+ filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
1813
+ servedFromCache: false,
1814
+ rtt: 0
1815
+ };
1816
+ }
1817
+
1818
+ try {
1819
+ const host = (api_uri || 'api.kenpathlabs.com').replace(/^[a-z]+:\/\//, '').replace(/\/$/, '');
1820
+ const post = bent(`https://${host}`, 'POST', 'buffer', {
1821
+ 'Authorization': `Bearer ${api_key}`,
1822
+ 'Content-Type': 'application/json'
1823
+ });
1824
+ const audioContent = await post('/v1/audio/speech', {
1825
+ input: text,
1826
+ voice,
1827
+ model,
1828
+ response_format: 'wav',
1829
+ sample_rate: 8000,
1830
+ ...(language && {lang: language}),
1831
+ ...(isSet(speed) && {speed: Number(speed)}),
1832
+ ...(pronunciation_dictionary_id && {pronunciation_dictionary_id}),
1833
+ ...(isSet(normalize) && {normalize: normalize === true || normalize === 'true'}),
1834
+ ...(isSet(temperature) && {temperature: Number(temperature)}),
1835
+ ...(isSet(top_p) && {top_p: Number(top_p)}),
1836
+ ...(isSet(top_k) && {top_k: Number(top_k)}),
1837
+ ...(isSet(min_p) && {min_p: Number(min_p)}),
1838
+ ...(isSet(repetition_penalty) && {repetition_penalty: Number(repetition_penalty)}),
1839
+ ...(isSet(presence_penalty) && {presence_penalty: Number(presence_penalty)}),
1840
+ ...(isSet(max_tokens) && {max_tokens: Number(max_tokens)}),
1841
+ ...(isSet(chunk_size) && {chunk_size: Number(chunk_size)}),
1842
+ ...(isSet(buffer_ms) && {buffer_ms: Number(buffer_ms)}),
1843
+ ...(isSet(chunk_codes) && {chunk_codes: Number(chunk_codes)})
1844
+ });
1845
+ return {
1846
+ audioContent,
1847
+ extension: 'wav',
1848
+ sampleRate: 8000
1849
+ };
1850
+ } catch (err) {
1851
+ /* errors are JSON {detail: {status, message}}; surface the body instead of 'undefined' */
1852
+ if (err.name === 'StatusError' && typeof err.text === 'function') {
1853
+ try {
1854
+ const body = await err.text();
1855
+ if (body) err.message = body;
1856
+ } catch (readErr) {
1857
+ logger.info({readErr}, 'synth svara: failed to read error response body');
1858
+ }
1859
+ }
1860
+ logger.info({err}, 'synth svara returned error');
1861
+ stats.increment('tts.count', ['vendor:svara', 'accepted:no']);
1862
+ throw err;
1863
+ }
1864
+ };
1865
+
1691
1866
  /* nineninesix.ai — a Cartesia-compatible API, but only raw/wav come back
1692
1867
  (mp3 is rejected), so the cache render asks for wav rather than mp3. */
1693
1868
  /* fish.audio — msgpack websocket for streaming, and a POST endpoint for the cache
package/package.json CHANGED
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "@jambonz/speech-utils",
3
- "version": "1.0.21",
3
+ "version": "1.0.22",
4
+ "engines": {
5
+ "node": ">=22"
6
+ },
4
7
  "description": "TTS-related speech utilities for jambonz",
5
8
  "main": "index.js",
6
9
  "author": "Dave Horton",
@@ -28,8 +31,8 @@
28
31
  "@aws-sdk/client-polly": "^3.496.0",
29
32
  "@aws-sdk/client-sts": "^3.496.0",
30
33
  "@cartesia/cartesia-js": "^2.2.7",
31
- "@google-cloud/text-to-speech": "^6.4.0",
32
- "@grpc/grpc-js": "^1.9.14",
34
+ "@google-cloud/text-to-speech": "^7.0.0",
35
+ "@grpc/grpc-js": "^1.14.5",
33
36
  "@jambonz/realtimedb-helpers": "^0.8.7",
34
37
  "bent": "^7.3.12",
35
38
  "debug": "^4.3.4",
package/test/synth.js CHANGED
@@ -1041,10 +1041,9 @@ test('Cartesia speech synth tests', async(t) => {
1041
1041
  vendor: 'cartesia',
1042
1042
  credentials: {
1043
1043
  api_key: process.env.CARTESIA_API_KEY,
1044
- model_id: 'sonic-english',
1044
+ model_id: 'sonic-3.6',
1045
1045
  options: JSON.stringify({
1046
1046
  speed: 1,
1047
- emotion: 'female_happy',
1048
1047
  })
1049
1048
  },
1050
1049
  language: 'en',
@@ -1149,6 +1148,64 @@ test('speechify speech synth tests', async(t) => {
1149
1148
  client.quit();
1150
1149
  });
1151
1150
 
1151
+ test('giggy speech synth tests', async(t) => {
1152
+ const fn = require('..');
1153
+ const {synthAudio, client} = fn(opts, logger);
1154
+
1155
+ if (!process.env.GIGGY_API_KEY) {
1156
+ t.pass('skipping giggy speech synth tests since GIGGY_API_KEY is not provided');
1157
+ return t.end();
1158
+ }
1159
+ const text = 'Hi there and welcome to jambonz! ' + Date.now();
1160
+ try {
1161
+ const opts = await synthAudio(stats, {
1162
+ vendor: 'giggy',
1163
+ credentials: {
1164
+ api_key: process.env.GIGGY_API_KEY
1165
+ },
1166
+ language: 'en-US',
1167
+ voice: process.env.GIGGY_VOICE_ID || '8f5aee7d-dd96-41c4-b04f-19bff046b02a',
1168
+ text,
1169
+ renderForCaching: true
1170
+ });
1171
+ t.ok(!opts.servedFromCache, `successfully synthed giggy audio to ${opts.filePath}`);
1172
+
1173
+ } catch (err) {
1174
+ console.error(JSON.stringify(err));
1175
+ t.end(err);
1176
+ }
1177
+ client.quit();
1178
+ });
1179
+
1180
+ test('svara speech synth tests', async(t) => {
1181
+ const fn = require('..');
1182
+ const {synthAudio, client} = fn(opts, logger);
1183
+
1184
+ if (!process.env.SVARA_API_KEY) {
1185
+ t.pass('skipping svara speech synth tests since SVARA_API_KEY is not provided');
1186
+ return t.end();
1187
+ }
1188
+ const text = 'Hi there and welcome to jambonz! ' + Date.now();
1189
+ try {
1190
+ const opts = await synthAudio(stats, {
1191
+ vendor: 'svara',
1192
+ credentials: {
1193
+ api_key: process.env.SVARA_API_KEY
1194
+ },
1195
+ language: 'en-US',
1196
+ voice: process.env.SVARA_VOICE_ID || 'sv_a5thzx8v',
1197
+ text,
1198
+ renderForCaching: true
1199
+ });
1200
+ t.ok(!opts.servedFromCache, `successfully synthed svara audio to ${opts.filePath}`);
1201
+
1202
+ } catch (err) {
1203
+ console.error(JSON.stringify(err));
1204
+ t.end(err);
1205
+ }
1206
+ client.quit();
1207
+ });
1208
+
1152
1209
  test('fishaudio speech synth tests', async(t) => {
1153
1210
  const fn = require('..');
1154
1211
  const {synthAudio, client} = fn(opts, logger);