@jambonz/speech-utils 1.0.19 → 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']
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}`);
@@ -139,6 +139,15 @@ 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');
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');
142
151
  } else if ('kugelaudio' === vendor) {
143
152
  assert.ok(voice, 'synthAudio requires voice when kugelaudio is used');
144
153
  assert.ok(credentials.api_key, 'synthAudio requires api_key when kugelaudio is used');
@@ -242,6 +251,21 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
242
251
  credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
243
252
  disableTtsCache});
244
253
  break;
254
+ case 'speechify':
255
+ audioData = await synthSpeechify(logger, {
256
+ credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
257
+ disableTtsCache});
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;
245
269
  case 'nineninesix':
246
270
  audioData = await synthNineninesix(logger, {
247
271
  credentials, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
@@ -1369,14 +1393,10 @@ const synthCartesia = async(logger, {
1369
1393
 
1370
1394
  // omit a control unless explicitly provided (0 is a valid value, so test for nullish only)
1371
1395
  const has = (v) => v !== null && v !== undefined;
1372
- /* Voice controls are model-family specific:
1373
- - sonic-2 takes `experimentalControls` (emotion is an array, no volume).
1374
- - sonic-3 family (sonic-3, sonic-3.5, pinned sonic-3.x snapshots) takes
1375
- `generationConfig` (emotion is a string, volume supported). Match the same
1376
- "starts with sonic-3" predicate the freeswitch streaming module uses
1377
- (mod_cartesia_tts_streaming: strncmp(model_id, "sonic-3", ...)), so cached
1378
- and streamed synthesis behave identically.
1379
- 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. */
1380
1400
  const isSonic3 = model_id?.startsWith('sonic-3');
1381
1401
  const mp3Stream = await client.tts.bytes({
1382
1402
  modelId: model_id,
@@ -1391,12 +1411,6 @@ const synthCartesia = async(logger, {
1391
1411
  id: voice
1392
1412
  }
1393
1413
  ),
1394
- ...(model_id === 'sonic-2' && (opts.speed || opts.emotion) && {
1395
- experimentalControls: {
1396
- ...(has(opts.speed) && {speed: opts.speed}),
1397
- ...(opts.emotion && {emotion: [opts.emotion]}),
1398
- }
1399
- }),
1400
1414
  },
1401
1415
  ...(isSonic3 && (has(opts.speed) || has(opts.emotion) || has(opts.volume)) && {
1402
1416
  generationConfig: {
@@ -1602,6 +1616,253 @@ const synthKugelaudio = async(logger, {
1602
1616
  }
1603
1617
  };
1604
1618
 
1619
+ /* simba-3.2 is English only; with no model set, other languages get simba-3.0 */
1620
+ const speechifyModel = (model_id, language) => {
1621
+ if (model_id) return model_id;
1622
+ return !language || /^en/i.test(language) ? 'simba-3.2' : 'simba-3.0';
1623
+ };
1624
+ const synthSpeechify = async(logger, {
1625
+ credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
1626
+ disableTtsCache
1627
+ }) => {
1628
+ const {api_key, model_id} = credentials;
1629
+ let credOptions = credentials.options || {};
1630
+ if (typeof credOptions === 'string') {
1631
+ try {
1632
+ credOptions = JSON.parse(credOptions);
1633
+ } catch {
1634
+ credOptions = {};
1635
+ }
1636
+ }
1637
+ const {api_uri, loudness_normalization, text_normalization} = {...credOptions, ...options};
1638
+ const isSet = (v) => v !== null && v !== undefined;
1639
+ const model = speechifyModel(options?.model_id || model_id, language);
1640
+
1641
+ /* default to using the streaming interface, unless disabled by env var OR we want just a cache file */
1642
+ if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
1643
+ let params = '{';
1644
+ params += `api_key=${api_key}`;
1645
+ params += `,playback_id=${key}`;
1646
+ params += ',vendor=speechify';
1647
+ params += `,voice=${voice}`;
1648
+ params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
1649
+ params += `,model_id=${model}`;
1650
+ if (language) params += `,language=${language}`;
1651
+ if (api_uri) params += `,api_uri=${api_uri}`;
1652
+ if (isSet(loudness_normalization)) params += `,loudness_normalization=${loudness_normalization}`;
1653
+ if (isSet(text_normalization)) params += `,text_normalization=${text_normalization}`;
1654
+ params += '}';
1655
+
1656
+ return {
1657
+ filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
1658
+ servedFromCache: false,
1659
+ rtt: 0
1660
+ };
1661
+ }
1662
+
1663
+ try {
1664
+ /* other pcm rates are mislabelled 24 kHz on workspaces pinned before 2026-09-30 */
1665
+ const sampleRate = 24000;
1666
+ const host = (api_uri || 'api.speechify.ai').replace(/^[a-z]+:\/\//, '').replace(/\/$/, '');
1667
+ const post = bent(`https://${host}`, 'POST', 'buffer', {
1668
+ 'Authorization': `Bearer ${api_key}`,
1669
+ 'Content-Type': 'application/json',
1670
+ 'Speechify-Caller': 'jambonz'
1671
+ });
1672
+ const toBool = (v) => v === true || v === 'true';
1673
+ const speechOptions = {
1674
+ ...(isSet(loudness_normalization) && {loudness_normalization: toBool(loudness_normalization)}),
1675
+ ...(isSet(text_normalization) && {text_normalization: toBool(text_normalization)})
1676
+ };
1677
+ const audioContent = await post('/v1/audio/stream', {
1678
+ input: text,
1679
+ voice_id: voice,
1680
+ model,
1681
+ output_format: `pcm_${sampleRate}`,
1682
+ ...(language && {language}),
1683
+ ...(Object.keys(speechOptions).length && {options: speechOptions})
1684
+ });
1685
+ return {
1686
+ audioContent,
1687
+ extension: 'r24',
1688
+ sampleRate
1689
+ };
1690
+ } catch (err) {
1691
+ logger.info({err}, 'synth speechify returned error');
1692
+ stats.increment('tts.count', ['vendor:speechify', 'accepted:no']);
1693
+ throw err;
1694
+ }
1695
+ };
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
+
1605
1866
  /* nineninesix.ai — a Cartesia-compatible API, but only raw/wav come back
1606
1867
  (mp3 is rejected), so the cache render asks for wav rather than mp3. */
1607
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.19",
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',
@@ -1120,6 +1119,93 @@ test('kugelaudio speech synth tests', async(t) => {
1120
1119
  client.quit();
1121
1120
  });
1122
1121
 
1122
+ test('speechify speech synth tests', async(t) => {
1123
+ const fn = require('..');
1124
+ const {synthAudio, client} = fn(opts, logger);
1125
+
1126
+ if (!process.env.SPEECHIFY_API_KEY) {
1127
+ t.pass('skipping speechify speech synth tests since SPEECHIFY_API_KEY is not provided');
1128
+ return t.end();
1129
+ }
1130
+ const text = 'Hi there and welcome to jambonz! ' + Date.now();
1131
+ try {
1132
+ const opts = await synthAudio(stats, {
1133
+ vendor: 'speechify',
1134
+ credentials: {
1135
+ api_key: process.env.SPEECHIFY_API_KEY
1136
+ },
1137
+ language: 'en-US',
1138
+ voice: 'geffen_32',
1139
+ text,
1140
+ renderForCaching: true
1141
+ });
1142
+ t.ok(!opts.servedFromCache, `successfully synthed speechify audio to ${opts.filePath}`);
1143
+
1144
+ } catch (err) {
1145
+ console.error(JSON.stringify(err));
1146
+ t.end(err);
1147
+ }
1148
+ client.quit();
1149
+ });
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
+
1123
1209
  test('fishaudio speech synth tests', async(t) => {
1124
1210
  const fn = require('..');
1125
1211
  const {synthAudio, client} = fn(opts, logger);