@jambonz/speech-utils 1.0.17 → 1.0.19

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.
@@ -7,11 +7,18 @@ on:
7
7
  jobs:
8
8
  build:
9
9
  runs-on: ubuntu-latest
10
+ permissions:
11
+ id-token: write # required to request the GitHub OIDC token for AWS
12
+ contents: read
10
13
  steps:
11
14
  - uses: actions/checkout@v4
12
15
  - uses: actions/setup-node@v4
13
16
  with:
14
17
  node-version: '20'
18
+ - uses: aws-actions/configure-aws-credentials@v4
19
+ with:
20
+ role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
21
+ aws-region: us-east-1
15
22
  - run: npm install
16
23
  - run: npm run jslint
17
24
  - run: sudo apt update && sudo apt install -y squid
@@ -19,10 +26,12 @@ jobs:
19
26
  - run: sudo systemctl start squid
20
27
  - run: npm test
21
28
  env:
22
- AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
23
- AWS_REGION: ${{ secrets.AWS_REGION }}
24
- AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
29
+ # AWS_* are exported by configure-aws-credentials above as short-lived
30
+ # OIDC credentials; no AWS keys are stored as repository secrets.
25
31
  GCP_JSON_KEY: ${{ secrets.GCP_JSON_KEY }}
32
+ # Enables the "AWS speech synth tests by RoleArn" test, which exercises the
33
+ # AssumeRole credential path used in production.
34
+ AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}
26
35
 
27
36
  MICROSOFT_API_KEY: ${{ secrets.MICROSOFT_API_KEY }}
28
37
  MICROSOFT_REGION: ${{ secrets.MICROSOFT_REGION }}
@@ -80,8 +80,8 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
80
80
  logger = logger || noopLogger;
81
81
 
82
82
  assert.ok(['google', 'aws', 'polly', 'microsoft', 'wellsaid', 'nvidia', 'elevenlabs',
83
- 'whisper', 'deepgram', 'deepgramflux', 'rimelabs', 'cartesia', 'gradium', 'nineninesix', 'inworld', 'resemble',
84
- 'murf', 'xai']
83
+ 'whisper', 'deepgram', 'deepgramflux', 'rimelabs', 'cartesia', 'gradium', 'kugelaudio', 'nineninesix', 'inworld',
84
+ 'resemble', '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}`);
@@ -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 ('kugelaudio' === vendor) {
143
+ assert.ok(voice, 'synthAudio requires voice when kugelaudio is used');
144
+ assert.ok(credentials.api_key, 'synthAudio requires api_key when kugelaudio is used');
142
145
  } else if ('nineninesix' === vendor) {
143
146
  assert.ok(voice, 'synthAudio requires voice when nineninesix is used');
144
147
  assert.ok(credentials.api_key, 'synthAudio requires api_key when nineninesix is used');
@@ -149,6 +152,10 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
149
152
  } else if (vendor === 'resemble') {
150
153
  assert.ok(voice, 'synthAudio requires voice when resemble is used');
151
154
  assert.ok(credentials.api_key, 'synthAudio requires api_key when resemble is used');
155
+ } else if ('fishaudio' === vendor) {
156
+ /* no voice assert: fish synthesizes with its own default voice when
157
+ reference_id is omitted, which is what the 'default' selection means */
158
+ assert.ok(credentials.api_key, 'synthAudio requires api_key when fishaudio is used');
152
159
  }
153
160
 
154
161
  const key = makeSynthKey({
@@ -220,11 +227,21 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
220
227
  credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
221
228
  disableTtsCache});
222
229
  break;
230
+ case 'fishaudio':
231
+ audioData = await synthFishaudio(logger, {
232
+ credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming,
233
+ disableTtsCache});
234
+ break;
223
235
  case 'gradium':
224
236
  audioData = await synthGradium(logger, {
225
237
  credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming,
226
238
  disableTtsCache});
227
239
  break;
240
+ case 'kugelaudio':
241
+ audioData = await synthKugelaudio(logger, {
242
+ credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
243
+ disableTtsCache});
244
+ break;
228
245
  case 'nineninesix':
229
246
  audioData = await synthNineninesix(logger, {
230
247
  credentials, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
@@ -1503,8 +1520,165 @@ const synthGradium = async(logger, {
1503
1520
  }
1504
1521
  };
1505
1522
 
1523
+ /* kugelaudio — json websocket (/ws/tts/stream) for streaming, and POST /v1/tts/generate
1524
+ for the cache render. the POST streams back bare little-endian 16-bit samples at the
1525
+ requested sample_rate, which is exactly the r8 container at 8000.
1526
+
1527
+ voices are numeric ids (or public handles). language is an ISO 639-1 code that drives
1528
+ text normalization; jambonz carries BCP-47, so only the primary subtag is sent. the
1529
+ api rejects codes outside its list, so an unsupported or unset language is omitted
1530
+ and the voice's own language applies. options.api_uri pins a region
1531
+ (e.g. api.eu.kugelaudio.com).
1532
+ */
1533
+ const KUGELAUDIO_LANGUAGES = ['ar', 'bg', 'bn', 'cs', 'da', 'de', 'el', 'en', 'es', 'fa', 'fi', 'fr', 'he', 'hi',
1534
+ 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'ms', 'nl', 'no', 'pl', 'pt', 'ro', 'ru', 'sk', 'sl', 'sr', 'sv', 'ta', 'th',
1535
+ 'tr', 'uk', 'ur', 'vi', 'yue', 'zh'];
1536
+ const synthKugelaudio = async(logger, {
1537
+ credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
1538
+ disableTtsCache
1539
+ }) => {
1540
+ const {api_key, model_id} = credentials;
1541
+ const {api_uri, speed, cfg_scale, temperature, normalize, project_id, dictionary_ids} = options || {};
1542
+ const isSet = (v) => v !== null && v !== undefined;
1543
+
1544
+ /* default to using the streaming interface, unless disabled by env var OR we want just a cache file */
1545
+ if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
1546
+ let params = '{';
1547
+ params += `api_key=${api_key}`;
1548
+ params += `,playback_id=${key}`;
1549
+ params += ',vendor=kugelaudio';
1550
+ params += `,voice=${voice}`;
1551
+ params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
1552
+ params += `,model_id=${model_id || 'kugel-3'}`;
1553
+ if (language) params += `,language=${language}`;
1554
+ if (api_uri) params += `,api_uri=${api_uri}`;
1555
+ if (isSet(speed)) params += `,speed=${speed}`;
1556
+ if (isSet(cfg_scale)) params += `,cfg_scale=${cfg_scale}`;
1557
+ if (isSet(temperature)) params += `,temperature=${temperature}`;
1558
+ if (isSet(normalize)) params += `,normalize=${normalize}`;
1559
+ if (isSet(project_id)) params += `,project_id=${project_id}`;
1560
+ /* the say: param parser is bracket-aware, so the json array survives intact */
1561
+ if (Array.isArray(dictionary_ids)) params += `,dictionary_ids=${JSON.stringify(dictionary_ids)}`;
1562
+ params += '}';
1563
+
1564
+ return {
1565
+ filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
1566
+ servedFromCache: false,
1567
+ rtt: 0
1568
+ };
1569
+ }
1570
+
1571
+ try {
1572
+ const sampleRate = 8000;
1573
+ const host = (api_uri || 'api.kugelaudio.com').replace(/^[a-z]+:\/\//, '').replace(/\/$/, '');
1574
+ const post = bent(`https://${host}`, 'POST', 'buffer', {
1575
+ 'Authorization': `Bearer ${api_key}`,
1576
+ 'Content-Type': 'application/json; charset=utf-8'
1577
+ });
1578
+ const voiceId = /^\d+$/.test(`${voice}`) ? Number(voice) : voice;
1579
+ const lang = language && language.split('-')[0].toLowerCase();
1580
+ const audioContent = await post('/v1/tts/generate', {
1581
+ text,
1582
+ voice_id: voiceId,
1583
+ model_id: model_id || 'kugel-3',
1584
+ sample_rate: sampleRate,
1585
+ ...(KUGELAUDIO_LANGUAGES.includes(lang) && {language: lang}),
1586
+ ...(isSet(speed) && {speed: Number(speed)}),
1587
+ ...(isSet(cfg_scale) && {cfg_scale: Number(cfg_scale)}),
1588
+ ...(isSet(temperature) && {temperature: Number(temperature)}),
1589
+ ...(isSet(normalize) && {normalize: normalize === true || normalize === 'true'}),
1590
+ ...(isSet(project_id) && {project_id: Number(project_id)}),
1591
+ ...(Array.isArray(dictionary_ids) && {dictionary_ids})
1592
+ });
1593
+ return {
1594
+ audioContent,
1595
+ extension: 'r8',
1596
+ sampleRate
1597
+ };
1598
+ } catch (err) {
1599
+ logger.info({err}, 'synth kugelaudio returned error');
1600
+ stats.increment('tts.count', ['vendor:kugelaudio', 'accepted:no']);
1601
+ throw err;
1602
+ }
1603
+ };
1604
+
1506
1605
  /* nineninesix.ai — a Cartesia-compatible API, but only raw/wav come back
1507
1606
  (mp3 is rejected), so the cache render asks for wav rather than mp3. */
1607
+ /* fish.audio — msgpack websocket for streaming, and a POST endpoint for the cache
1608
+ render. format:pcm + sample_rate:8000 returns bare little-endian 16-bit samples,
1609
+ which is exactly the r8 container. we avoid fish's wav output because its RIFF
1610
+ header carries a placeholder size (0xffffff24) — length is unknown up front, as
1611
+ with gradium.
1612
+
1613
+ fish is a voice-cloning vendor: the "voice" is a reference_id returned by
1614
+ POST /model, and omitting it entirely synthesizes with fish's default voice.
1615
+ the sentinel value 'default' (the bundled fallback entry in the portal) means
1616
+ exactly that — send no reference_id.
1617
+ */
1618
+ const synthFishaudio = async(logger, {
1619
+ credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming, disableTtsCache
1620
+ }) => {
1621
+ const {api_key, model_id, fishaudio_tts_uri} = credentials;
1622
+ const {reference_id, latency, chunk_length, speed, volume} = options || {};
1623
+
1624
+ /* free-text reference_id in the vendor options wins over the voice selector */
1625
+ const refId = reference_id || (voice && voice !== 'default' ? voice : null);
1626
+
1627
+ /* default to using the streaming interface, unless disabled by env var OR we want just a cache file */
1628
+ if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
1629
+ let params = '{';
1630
+ params += `api_key=${api_key}`;
1631
+ params += `,playback_id=${key}`;
1632
+ params += ',vendor=fishaudio';
1633
+ params += `,voice=${refId || 'default'}`;
1634
+ params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
1635
+ if (model_id) params += `,model_id=${model_id}`;
1636
+ if (latency) params += `,latency=${latency}`;
1637
+ if (chunk_length) params += `,chunk_length=${chunk_length}`;
1638
+ if (speed) params += `,speed=${speed}`;
1639
+ if (volume) params += `,volume=${volume}`;
1640
+ if (fishaudio_tts_uri) params += `,endpoint=${fishaudio_tts_uri}`;
1641
+ params += '}';
1642
+
1643
+ return {
1644
+ filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
1645
+ servedFromCache: false,
1646
+ rtt: 0
1647
+ };
1648
+ }
1649
+
1650
+ try {
1651
+ const sampleRate = 8000;
1652
+ const post = bent(fishaudio_tts_uri || 'https://api.fish.audio', 'POST', 'buffer', {
1653
+ 'Authorization': `Bearer ${api_key}`,
1654
+ 'Content-Type': 'application/json',
1655
+ /* the model is selected by header, not in the body */
1656
+ 'model': model_id || 's2.1-pro'
1657
+ });
1658
+ const audioContent = await post('/v1/tts', {
1659
+ text,
1660
+ format: 'pcm',
1661
+ sample_rate: sampleRate,
1662
+ ...(refId && {reference_id: refId}),
1663
+ ...(latency && {latency}),
1664
+ ...(chunk_length && {chunk_length: parseInt(chunk_length, 10)}),
1665
+ ...((speed || volume) && {prosody: {
1666
+ ...(speed && {speed: parseFloat(speed)}),
1667
+ ...(volume && {volume: parseFloat(volume)})
1668
+ }})
1669
+ });
1670
+ return {
1671
+ audioContent,
1672
+ extension: 'r8',
1673
+ sampleRate
1674
+ };
1675
+ } catch (err) {
1676
+ logger.info({err}, 'synth fishaudio returned error');
1677
+ stats.increment('tts.count', ['vendor:fishaudio', 'accepted:no']);
1678
+ throw err;
1679
+ }
1680
+ };
1681
+
1508
1682
  const synthNineninesix = async(logger, {
1509
1683
  credentials, stats, voice, language, key, text, renderForCaching, disableTtsStreaming, disableTtsCache
1510
1684
  }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jambonz/speech-utils",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
4
4
  "description": "TTS-related speech utilities for jambonz",
5
5
  "main": "index.js",
6
6
  "author": "Dave Horton",
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Resolve AWS credentials for the test suite.
3
+ *
4
+ * Returns null when AWS is not configured, so the caller skips.
5
+ *
6
+ * When AWS_SESSION_TOKEN is set the credentials are temporary -- GitHub OIDC in CI, or
7
+ * `aws sso login` locally -- and the key pair must not be passed through. Doing so sends
8
+ * lib/get-aws-sts-token.js down its accessKeyId branch, which calls GetSessionToken, and
9
+ * AWS rejects GetSessionToken when it is called with session credentials. Returning the
10
+ * region alone routes to the SDK's default credential provider chain instead, which
11
+ * handles temporary credentials correctly.
12
+ */
13
+ module.exports = () => {
14
+ const region = process.env.AWS_REGION;
15
+ if (!region) return null;
16
+
17
+ const accessKeyId = process.env.AWS_ACCESS_KEY_ID;
18
+ const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY;
19
+
20
+ if (process.env.AWS_SESSION_TOKEN) return {region};
21
+ if (accessKeyId && secretAccessKey) return {accessKeyId, secretAccessKey, region};
22
+ return {region};
23
+ };
package/test/aws.js CHANGED
@@ -6,33 +6,33 @@ process.on('unhandledRejection', (reason, p) => {
6
6
  console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
7
7
  });
8
8
 
9
+ const awsCredentials = require('./aws-credentials');
10
+
9
11
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
12
 
11
13
  test('AWS - create and cache auth token', async(t) => {
12
14
  const fn = require('..');
13
15
  const {client, getAwsAuthToken} = fn(opts, logger);
14
16
 
15
- if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY || !process.env.AWS_REGION) {
17
+ const credentials = awsCredentials();
18
+ if (!credentials) {
16
19
  t.pass('skipping AWS auth token tests since no AWS credentials provided');
17
20
  t.end();
18
21
  client.quit();
19
22
  return;
20
23
  }
24
+ // getAwsAuthToken derives its cache key from roleArn || accessKeyId || speech_credential_sid.
25
+ // With temporary credentials none of the first two are passed, so supply a stable sid --
26
+ // which is what production does for instance-profile credentials.
27
+ const args = {...credentials, speech_credential_sid: 'test-aws-speech-credential'};
28
+
21
29
  try {
22
- let obj = await getAwsAuthToken({
23
- accessKeyId: process.env.AWS_ACCESS_KEY_ID,
24
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
25
- region: process.env.AWS_REGION
26
- });
30
+ let obj = await getAwsAuthToken(args);
27
31
  //console.log({obj}, 'received auth token from AWS');
28
32
  t.ok(obj.securityToken && !obj.servedFromCache, 'successfullY generated auth token from AWS');
29
33
 
30
34
  await sleep(250);
31
- obj = await getAwsAuthToken({
32
- accessKeyId: process.env.AWS_ACCESS_KEY_ID,
33
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
34
- region: process.env.AWS_REGION
35
- });
35
+ obj = await getAwsAuthToken(args);
36
36
  //console.log({obj}, 'received auth token from AWS - second request');
37
37
  t.ok(obj.securityToken && obj.servedFromCache, 'successfully received access token from cache');
38
38
 
@@ -1,4 +1,5 @@
1
1
  const test = require('tape').test ;
2
+ const awsCredentials = require('./aws-credentials');
2
3
  const config = require('config');
3
4
  const opts = config.get('redis');
4
5
  const fs = require('fs');
@@ -45,18 +46,15 @@ test('AWS tests', async(t) => {
45
46
  const fn = require('..');
46
47
  const {client, getTtsVoices} = fn(opts, logger);
47
48
 
48
- if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY || !process.env.AWS_REGION) {
49
- t.pass('skipping AWS speech synth tests since AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, or AWS_REGION not provided');
49
+ const credentials = awsCredentials();
50
+ if (!credentials) {
51
+ t.pass('skipping AWS speech synth tests since AWS_REGION not provided');
50
52
  return t.end();
51
53
  }
52
54
  try {
53
55
  const opts = {
54
56
  vendor: 'aws',
55
- credentials: {
56
- accessKeyId: process.env.AWS_ACCESS_KEY_ID,
57
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
58
- region: process.env.AWS_REGION,
59
- }
57
+ credentials
60
58
  };
61
59
  let result = await getTtsVoices(opts);
62
60
  t.ok(result?.Voices?.length > 0, `GetVoices: successfully retrieved ${result.Voices.length} voices from AWS`);
package/test/synth.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const test = require('tape').test;
2
+ const awsCredentials = require('./aws-credentials');
2
3
  const config = require('config');
3
4
  const opts = config.get('redis');
4
5
  const fs = require('fs');
@@ -668,18 +669,15 @@ test('AWS speech synth tests', async(t) => {
668
669
  const fn = require('..');
669
670
  const {synthAudio, client} = fn(opts, logger);
670
671
 
671
- if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY || !process.env.AWS_REGION) {
672
- t.pass('skipping AWS speech synth tests since AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, or AWS_REGION not provided');
672
+ const credentials = awsCredentials();
673
+ if (!credentials) {
674
+ t.pass('skipping AWS speech synth tests since AWS_REGION not provided');
673
675
  return t.end();
674
676
  }
675
677
  try {
676
678
  let opts = await synthAudio(stats, {
677
679
  vendor: 'aws',
678
- credentials: {
679
- accessKeyId: process.env.AWS_ACCESS_KEY_ID,
680
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
681
- region: process.env.AWS_REGION,
682
- },
680
+ credentials,
683
681
  language: 'en-US',
684
682
  voice: 'Joey',
685
683
  text: 'This is a test. This is only a test',
@@ -689,11 +687,7 @@ test('AWS speech synth tests', async(t) => {
689
687
 
690
688
  opts = await synthAudio(stats, {
691
689
  vendor: 'aws',
692
- credentials: {
693
- accessKeyId: process.env.AWS_ACCESS_KEY_ID,
694
- secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
695
- region: process.env.AWS_REGION,
696
- },
690
+ credentials,
697
691
  language: 'en-US',
698
692
  voice: 'Joey',
699
693
  text: 'This is a test. This is only a test',
@@ -724,9 +718,18 @@ test('AWS speech synth tests by RoleArn', async(t) => {
724
718
  },
725
719
  language: 'en-US',
726
720
  voice: 'Joey',
727
- text: 'This is a test. This is only a test',
721
+ // Distinct text on purpose: the 'AWS speech synth tests' above cache audio for
722
+ // the same vendor/voice/language, and identical text would hit that cache entry,
723
+ // making servedFromCache true and this assertion fail.
724
+ text: 'This is a roleArn test. This is only a roleArn test',
725
+ // Without this, synthPolly returns the mediajam streaming params string, which
726
+ // embeds accessKeyId/secretAccessKey/sessionToken (see lib/synth-audio.js).
727
+ // Every other synth test in this file renders for caching; this one did not,
728
+ // so it printed live credentials into a public CI log.
729
+ renderForCaching: true,
728
730
  });
729
- t.ok(!opts.servedFromCache, `successfully synthesized aws by roleArn audio to ${opts.filePath}`);
731
+ // Deliberately does not interpolate opts.filePath -- it can carry credentials.
732
+ t.ok(!opts.servedFromCache, 'successfully synthesized aws by roleArn audio');
730
733
  } catch (err) {
731
734
  console.error(err);
732
735
  t.end(err);
@@ -1087,6 +1090,77 @@ test('gradium speech synth tests', async(t) => {
1087
1090
  client.quit();
1088
1091
  });
1089
1092
 
1093
+ test('kugelaudio speech synth tests', async(t) => {
1094
+ const fn = require('..');
1095
+ const {synthAudio, client} = fn(opts, logger);
1096
+
1097
+ if (!process.env.KUGELAUDIO_API_KEY) {
1098
+ t.pass('skipping kugelaudio speech synth tests since KUGELAUDIO_API_KEY is not provided');
1099
+ return t.end();
1100
+ }
1101
+ const text = 'Guten Tag und willkommen bei jambonz! Ihre Bestellung kostet 12,99 Euro. ' + Date.now();
1102
+ try {
1103
+ const opts = await synthAudio(stats, {
1104
+ vendor: 'kugelaudio',
1105
+ credentials: {
1106
+ api_key: process.env.KUGELAUDIO_API_KEY,
1107
+ model_id: 'kugel-3'
1108
+ },
1109
+ language: 'de-DE',
1110
+ voice: '1930',
1111
+ text,
1112
+ renderForCaching: true
1113
+ });
1114
+ t.ok(!opts.servedFromCache, `successfully synthed kugelaudio audio to ${opts.filePath}`);
1115
+
1116
+ } catch (err) {
1117
+ console.error(JSON.stringify(err));
1118
+ t.end(err);
1119
+ }
1120
+ client.quit();
1121
+ });
1122
+
1123
+ test('fishaudio speech synth tests', async(t) => {
1124
+ const fn = require('..');
1125
+ const {synthAudio, client} = fn(opts, logger);
1126
+
1127
+ if (!process.env.FISHAUDIO_API_KEY) {
1128
+ t.pass('skipping fishaudio speech synth tests since FISHAUDIO_API_KEY is not provided');
1129
+ return t.end();
1130
+ }
1131
+ const text = 'Hi there and welcome to jambones! ' + Date.now();
1132
+ try {
1133
+ /* voice 'default' means "send no reference_id" — fish's own default voice */
1134
+ const o = await synthAudio(stats, {
1135
+ vendor: 'fishaudio',
1136
+ credentials: {
1137
+ api_key: process.env.FISHAUDIO_API_KEY,
1138
+ model_id: 's2.1-pro'
1139
+ },
1140
+ voice: 'default',
1141
+ text,
1142
+ renderForCaching: true
1143
+ });
1144
+ t.ok(!o.servedFromCache, `successfully synthed fishaudio audio to ${o.filePath}`);
1145
+
1146
+ /* the cache render must be raw 8k pcm (r8): fish's wav header carries a
1147
+ placeholder RIFF size, so we never ask for wav */
1148
+ const o2 = await synthAudio(stats, {
1149
+ vendor: 'fishaudio',
1150
+ credentials: {api_key: process.env.FISHAUDIO_API_KEY},
1151
+ voice: 'default',
1152
+ text: text + ' two',
1153
+ renderForCaching: true,
1154
+ disableTtsCache: true
1155
+ });
1156
+ t.ok(!o2.servedFromCache, 'fishaudio synthed a second uncached render');
1157
+ } catch (err) {
1158
+ console.error(JSON.stringify(err));
1159
+ t.end(err);
1160
+ }
1161
+ client.quit();
1162
+ });
1163
+
1090
1164
  test('nineninesix speech synth tests', async(t) => {
1091
1165
  const fn = require('..');
1092
1166
  const {synthAudio, client} = fn(opts, logger);
@@ -1104,7 +1178,7 @@ test('nineninesix speech synth tests', async(t) => {
1104
1178
  model_id: 'gepard-1.0'
1105
1179
  },
1106
1180
  language: 'en',
1107
- voice: '3ad7a827-7fd1-4954-bf35-47d4cc33d9ed',
1181
+ voice: '775e4dfd-819c-4325-92ec-250f487ef7e3',
1108
1182
  text,
1109
1183
  renderForCaching: true
1110
1184
  });