@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.
- package/.github/workflows/ci.yml +12 -3
- package/lib/synth-audio.js +176 -2
- package/package.json +1 -1
- package/test/aws-credentials.js +23 -0
- package/test/aws.js +11 -11
- package/test/list-voices.js +5 -7
- package/test/synth.js +89 -15
package/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
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 }}
|
package/lib/synth-audio.js
CHANGED
|
@@ -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', '
|
|
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
|
@@ -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
|
-
|
|
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
|
|
package/test/list-voices.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');
|
|
@@ -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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
672
|
-
|
|
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: '
|
|
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
|
-
|
|
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: '
|
|
1181
|
+
voice: '775e4dfd-819c-4325-92ec-250f487ef7e3',
|
|
1108
1182
|
text,
|
|
1109
1183
|
renderForCaching: true
|
|
1110
1184
|
});
|