@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.
- package/.github/workflows/ci.yml +4 -4
- package/.github/workflows/publish.yml +2 -2
- package/lib/synth-audio.js +190 -15
- package/package.json +6 -3
- package/test/synth.js +59 -2
package/.github/workflows/ci.yml
CHANGED
|
@@ -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@
|
|
15
|
-
- uses: actions/setup-node@
|
|
14
|
+
- uses: actions/checkout@v5
|
|
15
|
+
- uses: actions/setup-node@v5
|
|
16
16
|
with:
|
|
17
|
-
node-version: '
|
|
18
|
-
- uses: aws-actions/configure-aws-credentials@
|
|
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
|
package/lib/synth-audio.js
CHANGED
|
@@ -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
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
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.
|
|
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": "^
|
|
32
|
-
"@grpc/grpc-js": "^1.
|
|
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-
|
|
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);
|