@jambonz/speech-utils 1.0.11 → 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 +66 -1
- package/package.json +1 -1
- package/test/synth.js +29 -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,7 @@ 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', 'nineninesix', 'inworld', 'resemble',
|
|
83
|
+
'whisper', 'deepgram', 'deepgramflux', 'rimelabs', 'cartesia', 'gradium', 'nineninesix', 'inworld', 'resemble',
|
|
84
84
|
'murf', 'xai']
|
|
85
85
|
.includes(vendor) ||
|
|
86
86
|
vendor.startsWith('custom'),
|
|
@@ -136,6 +136,9 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
136
136
|
} else if ('cartesia' === vendor) {
|
|
137
137
|
assert.ok(credentials.api_key, 'synthAudio requires api_key when cartesia is used');
|
|
138
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');
|
|
139
142
|
} else if ('nineninesix' === vendor) {
|
|
140
143
|
assert.ok(voice, 'synthAudio requires voice when nineninesix is used');
|
|
141
144
|
assert.ok(credentials.api_key, 'synthAudio requires api_key when nineninesix is used');
|
|
@@ -217,6 +220,11 @@ async function synthAudio(client, createHash, retrieveHash, logger, stats, { acc
|
|
|
217
220
|
credentials, options, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
|
|
218
221
|
disableTtsCache});
|
|
219
222
|
break;
|
|
223
|
+
case 'gradium':
|
|
224
|
+
audioData = await synthGradium(logger, {
|
|
225
|
+
credentials, options, stats, voice, key, text, renderForCaching, disableTtsStreaming,
|
|
226
|
+
disableTtsCache});
|
|
227
|
+
break;
|
|
220
228
|
case 'nineninesix':
|
|
221
229
|
audioData = await synthNineninesix(logger, {
|
|
222
230
|
credentials, stats, language, voice, key, text, renderForCaching, disableTtsStreaming,
|
|
@@ -1433,6 +1441,63 @@ const synthCartesia = async(logger, {
|
|
|
1433
1441
|
|
|
1434
1442
|
};
|
|
1435
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
|
+
|
|
1436
1501
|
/* nineninesix.ai — a Cartesia-compatible API, but only raw/wav come back
|
|
1437
1502
|
(mp3 is rejected), so the cache render asks for wav rather than mp3. */
|
|
1438
1503
|
const synthNineninesix = async(logger, {
|
package/package.json
CHANGED
package/test/synth.js
CHANGED
|
@@ -1058,6 +1058,35 @@ 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
|
+
|
|
1061
1090
|
test('nineninesix speech synth tests', async(t) => {
|
|
1062
1091
|
const fn = require('..');
|
|
1063
1092
|
const {synthAudio, client} = fn(opts, logger);
|