@acpfx/tts-elevenlabs 0.2.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # @acpfx/tts-elevenlabs
2
+
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d757640: Initial release: type-safe contracts, Rust orchestrator, manifest-driven event filtering
8
+
9
+ - Rust schema crate as canonical event type source of truth with codegen to TypeScript + Zod
10
+ - Node manifests (manifest.yaml) declaring consumes/emits contracts
11
+ - Orchestrator event filtering: nodes only receive declared events
12
+ - Rust orchestrator with ratatui TUI (--ui flag)
13
+ - node-sdk with structured logging helpers
14
+ - CI/CD with GitHub Actions and changesets
15
+ - Platform-specific npm packages for Rust binaries (esbuild-style distribution)
16
+
17
+ ### Patch Changes
18
+
19
+ - Updated dependencies [d757640]
20
+ - @acpfx/core@0.2.0
21
+ - @acpfx/node-sdk@0.2.0
package/manifest.yaml ADDED
@@ -0,0 +1,12 @@
1
+ name: tts-elevenlabs
2
+ description: Text-to-speech via ElevenLabs streaming API
3
+ consumes:
4
+ - agent.delta
5
+ - agent.complete
6
+ - agent.tool_start
7
+ - control.interrupt
8
+ emits:
9
+ - audio.chunk
10
+ - lifecycle.ready
11
+ - lifecycle.done
12
+ - control.error
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@acpfx/tts-elevenlabs",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "acpfx-tts-elevenlabs": "./dist/index.js"
7
+ },
8
+ "main": "./dist/index.js",
9
+ "dependencies": {
10
+ "@acpfx/core": "0.2.0",
11
+ "@acpfx/node-sdk": "0.2.0"
12
+ },
13
+ "scripts": {
14
+ "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --packages=external"
15
+ }
16
+ }
package/src/index.ts ADDED
@@ -0,0 +1,322 @@
1
+ /**
2
+ * tts-elevenlabs node — reads agent.delta events, streams text to ElevenLabs
3
+ * WebSocket TTS, emits audio.chunk events as audio arrives.
4
+ *
5
+ * True streaming: sends each delta token to the WebSocket as it arrives,
6
+ * so audio generation starts before the full response is complete.
7
+ *
8
+ * Settings (via ACPFX_SETTINGS):
9
+ * voiceId?: string — ElevenLabs voice ID (default: Rachel)
10
+ * model?: string — TTS model (default: eleven_turbo_v2_5)
11
+ * apiKey?: string — API key (falls back to ELEVENLABS_API_KEY env)
12
+ */
13
+
14
+ import { createInterface } from "node:readline";
15
+ import { emit, log, handleManifestFlag } from "@acpfx/node-sdk";
16
+
17
+ handleManifestFlag();
18
+
19
+ const WS_BASE_URL = "wss://api.elevenlabs.io/v1/text-to-speech";
20
+ const DEFAULT_MODEL = "eleven_turbo_v2_5";
21
+ const DEFAULT_VOICE_ID = "21m00Tcm4TlvDq8ikWAM"; // Rachel
22
+ const OUTPUT_FORMAT = "pcm_16000";
23
+ const SAMPLE_RATE = 16000;
24
+ const CHANNELS = 1;
25
+ const BYTES_PER_SAMPLE = 2;
26
+ const CHUNK_DURATION_MS = 100;
27
+ const CHUNK_SIZE = Math.floor(
28
+ (SAMPLE_RATE * CHANNELS * BYTES_PER_SAMPLE * CHUNK_DURATION_MS) / 1000,
29
+ );
30
+ const TRACK_ID = "tts";
31
+
32
+ type Settings = {
33
+ voiceId?: string;
34
+ model?: string;
35
+ apiKey?: string;
36
+ };
37
+
38
+ const settings: Settings = JSON.parse(process.env.ACPFX_SETTINGS || "{}");
39
+ const API_KEY = settings.apiKey ?? process.env.ELEVENLABS_API_KEY ?? "";
40
+ const VOICE_ID = settings.voiceId ?? DEFAULT_VOICE_ID;
41
+ const MODEL = settings.model ?? DEFAULT_MODEL;
42
+
43
+ if (!API_KEY) {
44
+ log.error("No API key. Set ELEVENLABS_API_KEY or settings.apiKey");
45
+ process.exit(1);
46
+ }
47
+
48
+ let ws: WebSocket | null = null;
49
+ let connected = false;
50
+ let interrupted = false;
51
+ let pcmBuffer = Buffer.alloc(0);
52
+ let currentRequestId: string | null = null;
53
+
54
+
55
+ async function openWebSocket(): Promise<void> {
56
+ if (ws && connected) return;
57
+
58
+ const url =
59
+ `${WS_BASE_URL}/${VOICE_ID}/stream-input` +
60
+ `?model_id=${encodeURIComponent(MODEL)}` +
61
+ `&output_format=${OUTPUT_FORMAT}` +
62
+ `&xi_api_key=${encodeURIComponent(API_KEY)}`;
63
+
64
+ ws = new WebSocket(url);
65
+
66
+ await new Promise<void>((resolve, reject) => {
67
+ ws!.addEventListener(
68
+ "open",
69
+ () => {
70
+ connected = true;
71
+ log.info("Connected to ElevenLabs TTS");
72
+ resolve();
73
+ },
74
+ { once: true },
75
+ );
76
+ ws!.addEventListener(
77
+ "error",
78
+ () => reject(new Error("TTS WebSocket connection failed")),
79
+ { once: true },
80
+ );
81
+ });
82
+
83
+ // Send BOS (beginning of stream) with voice settings
84
+ ws.send(
85
+ JSON.stringify({
86
+ text: " ",
87
+ xi_api_key: API_KEY,
88
+ voice_settings: {
89
+ stability: 0.5,
90
+ similarity_boost: 0.75,
91
+ },
92
+ generation_config: {
93
+ chunk_length_schedule: [50],
94
+ },
95
+ }),
96
+ );
97
+
98
+ ws.addEventListener("message", (event: MessageEvent) => {
99
+ if (interrupted) return;
100
+ try {
101
+ const data =
102
+ typeof event.data === "string"
103
+ ? event.data
104
+ : Buffer.from(event.data as ArrayBuffer).toString("utf-8");
105
+ const msg = JSON.parse(data);
106
+
107
+ if (msg.audio) {
108
+ const rawPcm = Buffer.from(msg.audio, "base64");
109
+ pcmBuffer = Buffer.concat([pcmBuffer, rawPcm]);
110
+
111
+ // Emit fixed-size audio chunks
112
+ while (pcmBuffer.length >= CHUNK_SIZE) {
113
+ const chunk = pcmBuffer.subarray(0, CHUNK_SIZE);
114
+ pcmBuffer = pcmBuffer.subarray(CHUNK_SIZE);
115
+ emitAudioChunk(chunk);
116
+ }
117
+ }
118
+
119
+ if (msg.isFinal) {
120
+ // Flush remaining buffer
121
+ if (pcmBuffer.length > 0) {
122
+ emitAudioChunk(pcmBuffer);
123
+ pcmBuffer = Buffer.alloc(0);
124
+ }
125
+ }
126
+ } catch {
127
+ // ignore parse errors
128
+ }
129
+ });
130
+
131
+ ws.addEventListener("error", (event: Event) => {
132
+ log.error(`WebSocket error: ${(event as ErrorEvent).message ?? "unknown"}`);
133
+ emit({
134
+ type: "control.error",
135
+ component: "tts-elevenlabs",
136
+ message: "TTS WebSocket error",
137
+ fatal: false,
138
+ });
139
+ });
140
+
141
+ ws.addEventListener("close", (event: CloseEvent) => {
142
+ log.info(`WebSocket closed (code=${event.code}, reason=${event.reason || "none"})`);
143
+ connected = false;
144
+ });
145
+ }
146
+
147
+ function emitAudioChunk(pcm: Buffer): void {
148
+ const durationMs = Math.round(
149
+ (pcm.length / (SAMPLE_RATE * CHANNELS * BYTES_PER_SAMPLE)) * 1000,
150
+ );
151
+ emit({
152
+ type: "audio.chunk",
153
+ trackId: TRACK_ID,
154
+ format: "pcm_s16le",
155
+ sampleRate: SAMPLE_RATE,
156
+ channels: CHANNELS,
157
+ data: Buffer.from(pcm).toString("base64"),
158
+ durationMs,
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Strip markdown characters from streaming tokens.
164
+ * Tokens arrive fragmented, so we strip character-by-character
165
+ * and track state for URLs and code blocks.
166
+ */
167
+ let inUrl = false;
168
+ let inCodeBlock = false;
169
+
170
+ function stripMarkdown(text: string): string {
171
+ if (text.includes("```")) {
172
+ inCodeBlock = !inCodeBlock;
173
+ return "";
174
+ }
175
+ if (inCodeBlock) return "";
176
+
177
+ let result = "";
178
+ for (let i = 0; i < text.length; i++) {
179
+ const ch = text[i];
180
+ if (inUrl) {
181
+ if (ch === ")") inUrl = false;
182
+ continue;
183
+ }
184
+ if (ch === "]" && i + 1 < text.length && text[i + 1] === "(") {
185
+ inUrl = true;
186
+ i++;
187
+ continue;
188
+ }
189
+ if (ch === "[" || ch === "]") continue;
190
+ if (ch === "*" || ch === "~" || ch === "`") continue;
191
+ if (ch === "#" && (i === 0 || text[i - 1] === "\n")) continue;
192
+ result += ch;
193
+ }
194
+ return result;
195
+ }
196
+
197
+ function sendText(text: string): void {
198
+ if (!ws || !connected) {
199
+ log.warn(`sendText dropped (connected=${connected}): "${text.slice(0, 30)}"`);
200
+ return;
201
+ }
202
+ const clean = stripMarkdown(text);
203
+ if (!clean) return;
204
+ ws.send(JSON.stringify({ text: clean }));
205
+ }
206
+
207
+ function endStream(): void {
208
+ if (!ws || !connected) return;
209
+ // Send empty text to signal EOS (end of stream)
210
+ log.debug("Sending EOS");
211
+ ws.send(JSON.stringify({ text: "" }));
212
+ // Don't close the WebSocket — let ElevenLabs close it after isFinal
213
+ }
214
+
215
+ function closeWebSocket(): void {
216
+ connected = false;
217
+ pcmBuffer = Buffer.alloc(0);
218
+ if (ws) {
219
+ try {
220
+ ws.close();
221
+ } catch {
222
+ // ignore
223
+ }
224
+ ws = null;
225
+ }
226
+ }
227
+
228
+ // --- Main ---
229
+
230
+ async function main(): Promise<void> {
231
+ await openWebSocket();
232
+
233
+ // Emit lifecycle.ready after WS connected
234
+ emit({ type: "lifecycle.ready", component: "tts-elevenlabs" });
235
+
236
+ const rl = createInterface({ input: process.stdin });
237
+
238
+ // Queue events and process sequentially to avoid async races
239
+ const eventQueue: string[] = [];
240
+ let processing = false;
241
+
242
+ async function processQueue(): Promise<void> {
243
+ if (processing) return;
244
+ processing = true;
245
+
246
+ while (eventQueue.length > 0) {
247
+ const line = eventQueue.shift()!;
248
+ try {
249
+ const event = JSON.parse(line);
250
+ await handleEvent(event);
251
+ } catch {
252
+ // ignore
253
+ }
254
+ }
255
+
256
+ processing = false;
257
+ }
258
+
259
+ let afterTool = false;
260
+
261
+ async function handleEvent(event: Record<string, unknown>): Promise<void> {
262
+ if (event.type === "agent.delta") {
263
+ if (event.delta) {
264
+ // Reconnect if WebSocket is down, we were interrupted, or we're
265
+ // starting a new segment after a tool call.
266
+ if (interrupted || !connected || afterTool) {
267
+ log.info(`Opening TTS stream (interrupted=${interrupted}, connected=${connected}, afterTool=${afterTool})`);
268
+ interrupted = false;
269
+ afterTool = false;
270
+ closeWebSocket();
271
+ await openWebSocket();
272
+ }
273
+ currentRequestId = event.requestId as string;
274
+ sendText(event.delta as string);
275
+ }
276
+ } else if (event.type === "agent.tool_start" && !interrupted) {
277
+ // Tool call started — close the WebSocket to force ElevenLabs to
278
+ // finalize audio for the text sent so far. EOS alone may not work
279
+ // if the text was mid-sentence.
280
+ if (connected) {
281
+ log.info("Tool started — closing TTS stream for segment break");
282
+ endStream();
283
+ // Give ElevenLabs a moment to send final audio, then force close
284
+ setTimeout(() => {
285
+ closeWebSocket();
286
+ }, 500);
287
+ afterTool = true;
288
+ }
289
+ } else if (event.type === "agent.complete" && !interrupted) {
290
+ // Agent is done — signal end of text stream so TTS can finalize
291
+ endStream();
292
+ currentRequestId = null;
293
+ } else if (event.type === "control.interrupt") {
294
+ interrupted = true;
295
+ afterTool = false;
296
+ closeWebSocket();
297
+ currentRequestId = null;
298
+ }
299
+ }
300
+
301
+ rl.on("line", (line) => {
302
+ if (!line.trim()) return;
303
+ eventQueue.push(line);
304
+ processQueue();
305
+ });
306
+
307
+ rl.on("close", () => {
308
+ closeWebSocket();
309
+ emit({ type: "lifecycle.done", component: "tts-elevenlabs" });
310
+ process.exit(0);
311
+ });
312
+
313
+ process.on("SIGTERM", () => {
314
+ closeWebSocket();
315
+ process.exit(0);
316
+ });
317
+ }
318
+
319
+ main().catch((err) => {
320
+ log.error(`Fatal: ${err.message}`);
321
+ process.exit(1);
322
+ });