@jambonz/speech-utils 1.0.10 → 1.0.12
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/CLAUDE.md +84 -0
- package/lib/synth-audio.js +131 -1
- package/package.json +1 -1
- package/test/synth.js +59 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`@jambonz/speech-utils` is a Node.js library providing TTS (Text-to-Speech) utilities for the jambonz CPaaS platform. It handles speech synthesis with caching through Redis and supports multiple TTS vendors.
|
|
8
|
+
|
|
9
|
+
## Commands
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Run tests (requires Docker for Redis)
|
|
13
|
+
npm test
|
|
14
|
+
|
|
15
|
+
# Run linter
|
|
16
|
+
npm run jslint
|
|
17
|
+
|
|
18
|
+
# Auto-fix lint issues
|
|
19
|
+
npm run jslint:fix
|
|
20
|
+
|
|
21
|
+
# Generate coverage report
|
|
22
|
+
npm run coverage
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Testing
|
|
26
|
+
|
|
27
|
+
Tests use `tape` and require Redis. The test harness automatically starts/stops Redis via Docker Compose (`test/docker-compose-testbed.yaml`).
|
|
28
|
+
|
|
29
|
+
Most tests are conditional based on environment variables for vendor credentials:
|
|
30
|
+
- `GCP_FILE` or `GCP_JSON_KEY` - Google TTS
|
|
31
|
+
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION` - AWS Polly
|
|
32
|
+
- `MICROSOFT_API_KEY`, `MICROSOFT_REGION` - Azure TTS
|
|
33
|
+
- `ELEVENLABS_API_KEY`, `ELEVENLABS_VOICE_ID`, `ELEVENLABS_MODEL_ID` - ElevenLabs
|
|
34
|
+
- `OPENAI_API_KEY` - OpenAI Whisper TTS
|
|
35
|
+
- And others per vendor
|
|
36
|
+
|
|
37
|
+
Redis config is in `config/test.json` (port 3379).
|
|
38
|
+
|
|
39
|
+
## Architecture
|
|
40
|
+
|
|
41
|
+
### Entry Point
|
|
42
|
+
|
|
43
|
+
`index.js` exports a factory function that takes Redis options and a logger, returning an object with these methods:
|
|
44
|
+
- `synthAudio` - Main synthesis function
|
|
45
|
+
- `getTtsVoices` - List available voices for a vendor
|
|
46
|
+
- `purgeTtsCache` / `getTtsSize` / `addFileToCache` - Cache management
|
|
47
|
+
- `getAwsAuthToken` - Token management
|
|
48
|
+
|
|
49
|
+
### Core Module: `lib/synth-audio.js`
|
|
50
|
+
|
|
51
|
+
The `synthAudio` function handles synthesis for all vendors. Key behaviors:
|
|
52
|
+
1. **Cache check**: Generates SHA1 hash key from (vendor, language, voice, engine, model, text, instructions)
|
|
53
|
+
2. **Streaming vs non-streaming**: When `JAMBONES_DISABLE_TTS_STREAMING` is not set and `renderForCaching=false`, returns `say:{params}text` format for FreeSWITCH streaming playback instead of generating files
|
|
54
|
+
3. **Vendor dispatch**: Switch statement routes to vendor-specific synth functions (`synthGoogle`, `synthPolly`, `synthMicrosoft`, etc.)
|
|
55
|
+
4. **Caching**: Stores audio as base64 JSON in Redis with configurable TTL (default 4 hours)
|
|
56
|
+
|
|
57
|
+
### Supported Vendors
|
|
58
|
+
|
|
59
|
+
google, aws/polly, microsoft/azure, nvidia (Riva), wellsaid, elevenlabs, cartesia, inworld, rimelabs, whisper (OpenAI), deepgram, resemble, custom:*
|
|
60
|
+
|
|
61
|
+
### gRPC Stubs
|
|
62
|
+
|
|
63
|
+
`stubs/riva/` contains generated protobuf/gRPC code for NVIDIA Riva.
|
|
64
|
+
|
|
65
|
+
## Environment Variables
|
|
66
|
+
|
|
67
|
+
Key configuration via env vars (see `lib/config.js`):
|
|
68
|
+
- `JAMBONES_DISABLE_TTS_STREAMING` - Force non-streaming mode
|
|
69
|
+
- `JAMBONES_DISABLE_AZURE_TTS_STREAMING` - Azure-specific streaming disable
|
|
70
|
+
- `JAMBONES_TTS_CACHE_DURATION_MINS` - Cache TTL in minutes (default: 240)
|
|
71
|
+
- `JAMBONES_TTS_TRIM_SILENCE` - Trim trailing silence from audio
|
|
72
|
+
- `JAMBONES_TMP_FOLDER` - Temp folder for audio files (default: /tmp)
|
|
73
|
+
- `JAMBONES_HTTP_PROXY_IP`, `JAMBONES_HTTP_PROXY_PORT` - HTTP proxy for Azure
|
|
74
|
+
- `JAMBONES_AZURE_ENABLE_SSML` - Force SSML wrapper for Azure plain text
|
|
75
|
+
|
|
76
|
+
## Key Dependencies
|
|
77
|
+
|
|
78
|
+
- `@jambonz/realtimedb-helpers` - Redis client and hash utilities
|
|
79
|
+
- `@google-cloud/text-to-speech` - Google TTS
|
|
80
|
+
- `@aws-sdk/client-polly` - AWS Polly
|
|
81
|
+
- `microsoft-cognitiveservices-speech-sdk` - Azure TTS
|
|
82
|
+
- `@grpc/grpc-js` - gRPC for Riva
|
|
83
|
+
- `openai` - OpenAI Whisper TTS
|
|
84
|
+
- `bent` - HTTP client for REST-based vendors
|
package/lib/synth-audio.js
CHANGED
|
@@ -80,7 +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', '
|
|
83
|
+
'whisper', 'deepgram', 'deepgramflux', 'rimelabs', 'cartesia', 'gradium', 'nineninesix', 'inworld', 'resemble',
|
|
84
|
+
'murf', 'xai']
|
|
84
85
|
.includes(vendor) ||
|
|
85
86
|
vendor.startsWith('custom'),
|
|
86
87
|
`synthAudio supported vendors are google, aws, microsoft, nvidia and wellsaid ..etc, not ${vendor}`);
|
|
@@ -135,6 +136,13 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
135
136
|
} else if ('cartesia' === vendor) {
|
|
136
137
|
assert.ok(credentials.api_key, 'synthAudio requires api_key when cartesia is used');
|
|
137
138
|
assert.ok(credentials.model_id, 'synthAudio requires model_id when cartesia is used');
|
|
139
|
+
} else if ('gradium' === vendor) {
|
|
140
|
+
assert.ok(voice, 'synthAudio requires voice when gradium is used');
|
|
141
|
+
assert.ok(credentials.api_key, 'synthAudio requires api_key when gradium is used');
|
|
142
|
+
} else if ('nineninesix' === vendor) {
|
|
143
|
+
assert.ok(voice, 'synthAudio requires voice when nineninesix is used');
|
|
144
|
+
assert.ok(credentials.api_key, 'synthAudio requires api_key when nineninesix is used');
|
|
145
|
+
assert.ok(credentials.model_id, 'synthAudio requires model_id when nineninesix is used');
|
|
138
146
|
} else if ('murf' === vendor) {
|
|
139
147
|
assert.ok(voice, 'synthAudio requires voice when murf is used');
|
|
140
148
|
assert.ok(credentials.api_key, 'synthAudio requires api_key when murf is used');
|
|
@@ -212,6 +220,16 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
212
220
|
credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
|
|
213
221
|
disableTtsCache});
|
|
214
222
|
break;
|
|
223
|
+
case 'gradium':
|
|
224
|
+
audioData = await synthGradium(logger, {
|
|
225
|
+
credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming,
|
|
226
|
+
disableTtsCache});
|
|
227
|
+
break;
|
|
228
|
+
case 'nineninesix':
|
|
229
|
+
audioData = await synthNineninesix(logger, {
|
|
230
|
+
credentials, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
|
|
231
|
+
disableTtsCache});
|
|
232
|
+
break;
|
|
215
233
|
case 'inworld':
|
|
216
234
|
audioData = await synthInworld(logger, {
|
|
217
235
|
credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
|
|
@@ -1423,6 +1441,118 @@ const synthCartesia = async(logger, {
|
|
|
1423
1441
|
|
|
1424
1442
|
};
|
|
1425
1443
|
|
|
1444
|
+
/* gradium.ai — json websocket for streaming, and a POST endpoint for the cache
|
|
1445
|
+
render. only_audio:true + pcm_8000 returns bare little-endian 16-bit samples,
|
|
1446
|
+
which is exactly the r8 container, so we avoid gradium's streaming wav header
|
|
1447
|
+
(it carries 0xffffffff as the RIFF size, since length is unknown up front).
|
|
1448
|
+
*/
|
|
1449
|
+
const synthGradium = async(logger, {
|
|
1450
|
+
credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming, disableTtsCache
|
|
1451
|
+
}) => {
|
|
1452
|
+
const {api_key, model_id} = credentials;
|
|
1453
|
+
const {json_config, pronunciation_id} = options || {};
|
|
1454
|
+
|
|
1455
|
+
/* default to using the streaming interface, unless disabled by env var OR we want just a cache file */
|
|
1456
|
+
if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
|
|
1457
|
+
let params = '{';
|
|
1458
|
+
params += `api_key=${api_key}`;
|
|
1459
|
+
params += `,playback_id=${key}`;
|
|
1460
|
+
params += ',vendor=gradium';
|
|
1461
|
+
params += `,voice=${voice}`;
|
|
1462
|
+
params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
|
|
1463
|
+
if (model_id) params += `,model_id=${model_id}`;
|
|
1464
|
+
if (pronunciation_id) params += `,pronunciation_id=${pronunciation_id}`;
|
|
1465
|
+
params += '}';
|
|
1466
|
+
|
|
1467
|
+
return {
|
|
1468
|
+
filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
|
|
1469
|
+
servedFromCache: false,
|
|
1470
|
+
rtt: 0
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
try {
|
|
1475
|
+
const sampleRate = 8000;
|
|
1476
|
+
const post = bent('https://api.gradium.ai', 'POST', 'buffer', {
|
|
1477
|
+
'x-api-key': api_key,
|
|
1478
|
+
'Content-Type': 'application/json'
|
|
1479
|
+
});
|
|
1480
|
+
const audioContent = await post('/api/post/speech/tts', {
|
|
1481
|
+
text,
|
|
1482
|
+
voice_id: voice,
|
|
1483
|
+
model_name: model_id || 'default',
|
|
1484
|
+
output_format: `pcm_${sampleRate}`,
|
|
1485
|
+
only_audio: true,
|
|
1486
|
+
...(json_config && {json_config}),
|
|
1487
|
+
...(pronunciation_id && {pronunciation_id})
|
|
1488
|
+
});
|
|
1489
|
+
return {
|
|
1490
|
+
audioContent,
|
|
1491
|
+
extension: 'r8',
|
|
1492
|
+
sampleRate
|
|
1493
|
+
};
|
|
1494
|
+
} catch (err) {
|
|
1495
|
+
logger.info({err}, 'synth gradium returned error');
|
|
1496
|
+
stats.increment('tts.count', ['vendor:gradium', 'accepted:no']);
|
|
1497
|
+
throw err;
|
|
1498
|
+
}
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
/* nineninesix.ai — a Cartesia-compatible API, but only raw/wav come back
|
|
1502
|
+
(mp3 is rejected), so the cache render asks for wav rather than mp3. */
|
|
1503
|
+
const synthNineninesix = async(logger, {
|
|
1504
|
+
credentials, stats, voice, language, key, text, renderForCaching, disableTtsStreaming, disableTtsCache
|
|
1505
|
+
}) => {
|
|
1506
|
+
const {api_key, model_id} = credentials;
|
|
1507
|
+
|
|
1508
|
+
/* default to using the streaming interface, unless disabled by env var OR we want just a cache file */
|
|
1509
|
+
if (!JAMBONES_DISABLE_TTS_STREAMING && !renderForCaching && !disableTtsStreaming) {
|
|
1510
|
+
let params = '{';
|
|
1511
|
+
params += `api_key=${api_key}`;
|
|
1512
|
+
params += `,playback_id=${key}`;
|
|
1513
|
+
params += `,model_id=${model_id}`;
|
|
1514
|
+
params += ',vendor=nineninesix';
|
|
1515
|
+
params += `,voice=${voice}`;
|
|
1516
|
+
params += `,write_cache_file=${disableTtsCache ? 0 : 1}`;
|
|
1517
|
+
if (language) params += `,language=${language}`;
|
|
1518
|
+
params += '}';
|
|
1519
|
+
|
|
1520
|
+
return {
|
|
1521
|
+
filePath: `say:${params}${text.replace(/\n/g, ' ').replace(/\r/g, ' ')}`,
|
|
1522
|
+
servedFromCache: false,
|
|
1523
|
+
rtt: 0
|
|
1524
|
+
};
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
try {
|
|
1528
|
+
const sampleRate = 8000;
|
|
1529
|
+
const post = bent('https://api.nineninesix.ai', 'POST', 'buffer', {
|
|
1530
|
+
'Authorization': `Bearer ${api_key}`,
|
|
1531
|
+
'Content-Type': 'application/json'
|
|
1532
|
+
});
|
|
1533
|
+
const audioContent = await post('/tts/bytes', {
|
|
1534
|
+
model_id,
|
|
1535
|
+
transcript: text,
|
|
1536
|
+
voice: {mode: 'id', id: voice},
|
|
1537
|
+
...(language && {language}),
|
|
1538
|
+
output_format: {
|
|
1539
|
+
container: 'wav',
|
|
1540
|
+
encoding: 'pcm_s16le',
|
|
1541
|
+
sample_rate: sampleRate
|
|
1542
|
+
}
|
|
1543
|
+
});
|
|
1544
|
+
return {
|
|
1545
|
+
audioContent,
|
|
1546
|
+
extension: 'wav',
|
|
1547
|
+
sampleRate
|
|
1548
|
+
};
|
|
1549
|
+
} catch (err) {
|
|
1550
|
+
logger.info({err}, 'synth nineninesix returned error');
|
|
1551
|
+
stats.increment('tts.count', ['vendor:nineninesix', 'accepted:no']);
|
|
1552
|
+
throw err;
|
|
1553
|
+
}
|
|
1554
|
+
};
|
|
1555
|
+
|
|
1426
1556
|
const synthResemble = async(logger, {
|
|
1427
1557
|
credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming, disableTtsCache
|
|
1428
1558
|
}) => {
|
package/package.json
CHANGED
package/test/synth.js
CHANGED
|
@@ -1058,6 +1058,65 @@ test('Cartesia speech synth tests', async(t) => {
|
|
|
1058
1058
|
client.quit();
|
|
1059
1059
|
});
|
|
1060
1060
|
|
|
1061
|
+
test('gradium speech synth tests', async(t) => {
|
|
1062
|
+
const fn = require('..');
|
|
1063
|
+
const {synthAudio, client} = fn(opts, logger);
|
|
1064
|
+
|
|
1065
|
+
if (!process.env.GRADIUM_API_KEY) {
|
|
1066
|
+
t.pass('skipping gradium speech synth tests since GRADIUM_API_KEY is not provided');
|
|
1067
|
+
return t.end();
|
|
1068
|
+
}
|
|
1069
|
+
const text = 'Hi there and welcome to jambones! ' + Date.now();
|
|
1070
|
+
try {
|
|
1071
|
+
const opts = await synthAudio(stats, {
|
|
1072
|
+
vendor: 'gradium',
|
|
1073
|
+
credentials: {
|
|
1074
|
+
api_key: process.env.GRADIUM_API_KEY,
|
|
1075
|
+
model_id: 'default'
|
|
1076
|
+
},
|
|
1077
|
+
voice: 'YTpq7expH9539ERJ',
|
|
1078
|
+
text,
|
|
1079
|
+
renderForCaching: true
|
|
1080
|
+
});
|
|
1081
|
+
t.ok(!opts.servedFromCache, `successfully synthed gradium audio to ${opts.filePath}`);
|
|
1082
|
+
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
console.error(JSON.stringify(err));
|
|
1085
|
+
t.end(err);
|
|
1086
|
+
}
|
|
1087
|
+
client.quit();
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
test('nineninesix speech synth tests', async(t) => {
|
|
1091
|
+
const fn = require('..');
|
|
1092
|
+
const {synthAudio, client} = fn(opts, logger);
|
|
1093
|
+
|
|
1094
|
+
if (!process.env.NINENINESIX_API_KEY) {
|
|
1095
|
+
t.pass('skipping nineninesix speech synth tests since NINENINESIX_API_KEY is not provided');
|
|
1096
|
+
return t.end();
|
|
1097
|
+
}
|
|
1098
|
+
const text = 'Hi there and welcome to jambones! ' + Date.now();
|
|
1099
|
+
try {
|
|
1100
|
+
const opts = await synthAudio(stats, {
|
|
1101
|
+
vendor: 'nineninesix',
|
|
1102
|
+
credentials: {
|
|
1103
|
+
api_key: process.env.NINENINESIX_API_KEY,
|
|
1104
|
+
model_id: 'gepard-1.0'
|
|
1105
|
+
},
|
|
1106
|
+
language: 'en',
|
|
1107
|
+
voice: '3ad7a827-7fd1-4954-bf35-47d4cc33d9ed',
|
|
1108
|
+
text,
|
|
1109
|
+
renderForCaching: true
|
|
1110
|
+
});
|
|
1111
|
+
t.ok(!opts.servedFromCache, `successfully synthed nineninesix audio to ${opts.filePath}`);
|
|
1112
|
+
|
|
1113
|
+
} catch (err) {
|
|
1114
|
+
console.error(JSON.stringify(err));
|
|
1115
|
+
t.end(err);
|
|
1116
|
+
}
|
|
1117
|
+
client.quit();
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1061
1120
|
test('inworld speech synth', async(t) => {
|
|
1062
1121
|
const fn = require('..');
|
|
1063
1122
|
const {synthAudio, client} = fn(opts, logger);
|