@jambonz/speech-utils 1.0.11 → 1.0.13

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 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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jambonz/speech-utils",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "TTS-related speech utilities for jambonz",
5
5
  "main": "index.js",
6
6
  "author": "Dave Horton",
@@ -35,7 +35,7 @@
35
35
  "bent": "^7.3.12",
36
36
  "debug": "^4.3.4",
37
37
  "google-protobuf": "^3.21.2",
38
- "microsoft-cognitiveservices-speech-sdk": "1.38.0",
38
+ "microsoft-cognitiveservices-speech-sdk": "^1.51.0",
39
39
  "openai": "^4.98.0",
40
40
  "undici": "^7.5.0"
41
41
  },
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);