@acpfx/tts-deepgram 0.2.2 → 0.2.4

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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2024-2026 acpfx contributors
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @acpfx/tts-deepgram
2
+
3
+ Text-to-speech via Deepgram streaming API. Converts agent text deltas into audio chunks in real time.
4
+
5
+ ## Usage
6
+
7
+ This package is a pipeline node for [@acpfx/cli](../orchestrator/README.md). See the CLI package for installation and usage.
8
+
9
+ Requires a `DEEPGRAM_API_KEY` environment variable.
10
+
11
+ ## Manifest
12
+
13
+ - **Consumes:** `agent.delta`, `agent.complete`, `agent.tool_start`, `control.interrupt`
14
+ - **Emits:** `audio.chunk`, `lifecycle.ready`, `lifecycle.done`, `control.error`
15
+
16
+ ## Settings
17
+
18
+ | Name | Type | Default | Description |
19
+ |------|------|---------|-------------|
20
+ | `voice` | string | `aura-2-apollo-en` | Deepgram voice model name |
21
+ | `sampleRate` | number | `16000` | Audio sample rate in Hz |
22
+ | `apiKey` | string | | Overrides `DEEPGRAM_API_KEY` env var |
23
+
24
+ ## Pipeline Example
25
+
26
+ ```yaml
27
+ nodes:
28
+ tts:
29
+ use: "@acpfx/tts-deepgram"
30
+ settings: { voice: aura-2-aries-en }
31
+ outputs: [player]
32
+ env:
33
+ DEEPGRAM_API_KEY: ${DEEPGRAM_API_KEY}
34
+ ```
35
+
36
+ ## External Links
37
+
38
+ - [Deepgram](https://deepgram.com) -- Speech AI platform
39
+ - [Deepgram Developer Docs](https://developers.deepgram.com) -- API reference and guides
40
+
41
+ ## License
42
+
43
+ ISC
package/dist/index.js ADDED
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { createInterface } from "node:readline";
5
+
6
+ // ../core/src/config.ts
7
+ import { parse as parseYaml } from "yaml";
8
+
9
+ // ../core/src/manifest.ts
10
+ import { readFileSync } from "node:fs";
11
+ import { join, dirname } from "node:path";
12
+ import { z as z2 } from "zod";
13
+
14
+ // ../core/src/acpfx-flags.ts
15
+ import { z } from "zod";
16
+ var SetupCheckResponseSchema = z.object({
17
+ needed: z.boolean(),
18
+ description: z.string().optional()
19
+ });
20
+ var SetupProgressSchema = z.discriminatedUnion("type", [
21
+ z.object({
22
+ type: z.literal("progress"),
23
+ message: z.string(),
24
+ pct: z.number().optional()
25
+ }),
26
+ z.object({ type: z.literal("complete"), message: z.string() }),
27
+ z.object({ type: z.literal("error"), message: z.string() })
28
+ ]);
29
+ var UnsupportedFlagResponseSchema = z.object({
30
+ unsupported: z.boolean(),
31
+ flag: z.string()
32
+ });
33
+
34
+ // ../core/src/manifest.ts
35
+ var ArgumentTypeSchema = z2.enum(["string", "number", "boolean"]);
36
+ var ManifestArgumentSchema = z2.object({
37
+ type: ArgumentTypeSchema,
38
+ default: z2.unknown().optional(),
39
+ description: z2.string().optional(),
40
+ required: z2.boolean().optional(),
41
+ enum: z2.array(z2.unknown()).optional()
42
+ });
43
+ var ManifestEnvFieldSchema = z2.object({
44
+ required: z2.boolean().optional(),
45
+ description: z2.string().optional()
46
+ });
47
+ var NodeManifestSchema = z2.object({
48
+ name: z2.string(),
49
+ description: z2.string().optional(),
50
+ consumes: z2.array(z2.string()),
51
+ emits: z2.array(z2.string()),
52
+ arguments: z2.record(z2.string(), ManifestArgumentSchema).optional(),
53
+ additional_arguments: z2.boolean().optional(),
54
+ env: z2.record(z2.string(), ManifestEnvFieldSchema).optional()
55
+ });
56
+ function handleAcpfxFlags(manifestPath) {
57
+ const acpfxFlag = process.argv.find((a) => a.startsWith("--acpfx-"));
58
+ const legacyManifest = process.argv.includes("--manifest");
59
+ if (!acpfxFlag && !legacyManifest) return;
60
+ const flag = acpfxFlag ?? "--acpfx-manifest";
61
+ switch (flag) {
62
+ case "--acpfx-manifest":
63
+ printManifest(manifestPath);
64
+ break;
65
+ case "--acpfx-setup-check":
66
+ process.stdout.write(JSON.stringify({ needed: false }) + "\n");
67
+ process.exit(0);
68
+ break;
69
+ default:
70
+ process.stdout.write(
71
+ JSON.stringify({ unsupported: true, flag }) + "\n"
72
+ );
73
+ process.exit(0);
74
+ }
75
+ }
76
+ function handleManifestFlag(manifestPath) {
77
+ handleAcpfxFlags(manifestPath);
78
+ }
79
+ function printManifest(manifestPath) {
80
+ if (!manifestPath) {
81
+ const script = process.argv[1];
82
+ const scriptDir = dirname(script);
83
+ const scriptBase = script.replace(/\.[^.]+$/, "");
84
+ const colocated = `${scriptBase}.manifest.json`;
85
+ try {
86
+ readFileSync(colocated);
87
+ manifestPath = colocated;
88
+ } catch {
89
+ manifestPath = join(scriptDir, "manifest.json");
90
+ }
91
+ }
92
+ try {
93
+ const content = readFileSync(manifestPath, "utf8");
94
+ process.stdout.write(content.trim() + "\n");
95
+ process.exit(0);
96
+ } catch (err) {
97
+ process.stderr.write(`Failed to read manifest: ${err}
98
+ `);
99
+ process.exit(1);
100
+ }
101
+ }
102
+
103
+ // ../node-sdk/src/index.ts
104
+ var NODE_NAME = process.env.ACPFX_NODE_NAME ?? "unknown";
105
+ function emit(event) {
106
+ process.stdout.write(JSON.stringify(event) + "\n");
107
+ }
108
+ function log(level, message) {
109
+ emit({ type: "log", level, component: NODE_NAME, message });
110
+ }
111
+ log.info = (message) => log("info", message);
112
+ log.warn = (message) => log("warn", message);
113
+ log.error = (message) => log("error", message);
114
+ log.debug = (message) => log("debug", message);
115
+
116
+ // src/index.ts
117
+ handleManifestFlag();
118
+ var WS_URL = "wss://api.deepgram.com/v1/speak";
119
+ var DEFAULT_VOICE = "aura-2-apollo-en";
120
+ var TRACK_ID = "tts";
121
+ var settings = JSON.parse(process.env.ACPFX_SETTINGS || "{}");
122
+ var API_KEY = settings.apiKey ?? process.env.DEEPGRAM_API_KEY ?? "";
123
+ var VOICE = settings.voice ?? DEFAULT_VOICE;
124
+ var SAMPLE_RATE = settings.sampleRate ?? 16e3;
125
+ var CHANNELS = 1;
126
+ var BYTES_PER_SAMPLE = 2;
127
+ var CHUNK_DURATION_MS = 100;
128
+ var CHUNK_SIZE = Math.floor(
129
+ SAMPLE_RATE * CHANNELS * BYTES_PER_SAMPLE * CHUNK_DURATION_MS / 1e3
130
+ );
131
+ if (!API_KEY) {
132
+ log.error("No API key. Set DEEPGRAM_API_KEY or settings.apiKey");
133
+ process.exit(1);
134
+ }
135
+ var ws = null;
136
+ var connected = false;
137
+ var interrupted = false;
138
+ var pcmBuffer = Buffer.alloc(0);
139
+ var currentRequestId = null;
140
+ async function openWebSocket() {
141
+ if (ws && connected) return;
142
+ const url = `${WS_URL}?model=${encodeURIComponent(VOICE)}&encoding=linear16&sample_rate=${SAMPLE_RATE}`;
143
+ ws = new WebSocket(url, ["token", API_KEY]);
144
+ await new Promise((resolve, reject) => {
145
+ ws.addEventListener(
146
+ "open",
147
+ () => {
148
+ connected = true;
149
+ log.info("Connected to Deepgram TTS");
150
+ resolve();
151
+ },
152
+ { once: true }
153
+ );
154
+ ws.addEventListener(
155
+ "error",
156
+ () => reject(new Error("TTS WebSocket connection failed")),
157
+ { once: true }
158
+ );
159
+ });
160
+ ws.addEventListener("message", (event) => {
161
+ if (interrupted) return;
162
+ const data = event.data;
163
+ if (typeof data === "object" && data !== null && typeof data.arrayBuffer === "function") {
164
+ data.arrayBuffer().then((ab) => {
165
+ if (interrupted) return;
166
+ handleAudioData(Buffer.from(ab));
167
+ });
168
+ return;
169
+ }
170
+ if (data instanceof ArrayBuffer || Buffer.isBuffer(data)) {
171
+ handleAudioData(Buffer.from(data));
172
+ return;
173
+ }
174
+ if (typeof data === "string") {
175
+ try {
176
+ const msg = JSON.parse(data);
177
+ if (msg.type === "Flushed") {
178
+ if (pcmBuffer.length > 0) {
179
+ emitAudioChunk(pcmBuffer);
180
+ pcmBuffer = Buffer.alloc(0);
181
+ }
182
+ } else if (msg.type === "Warning") {
183
+ log.warn(`Deepgram warning: ${msg.description ?? msg.code ?? "unknown"}`);
184
+ }
185
+ } catch {
186
+ }
187
+ }
188
+ });
189
+ function handleAudioData(rawPcm) {
190
+ pcmBuffer = Buffer.concat([pcmBuffer, rawPcm]);
191
+ while (pcmBuffer.length >= CHUNK_SIZE) {
192
+ const chunk = pcmBuffer.subarray(0, CHUNK_SIZE);
193
+ pcmBuffer = pcmBuffer.subarray(CHUNK_SIZE);
194
+ emitAudioChunk(chunk);
195
+ }
196
+ }
197
+ ws.addEventListener("error", (event) => {
198
+ log.error(`WebSocket error: ${event.message ?? "unknown"}`);
199
+ emit({
200
+ type: "control.error",
201
+ component: "tts-deepgram",
202
+ message: "TTS WebSocket error",
203
+ fatal: false
204
+ });
205
+ });
206
+ ws.addEventListener("close", (event) => {
207
+ log.info(`WebSocket closed (code=${event.code})`);
208
+ connected = false;
209
+ });
210
+ }
211
+ function emitAudioChunk(pcm) {
212
+ const durationMs = Math.round(
213
+ pcm.length / (SAMPLE_RATE * CHANNELS * BYTES_PER_SAMPLE) * 1e3
214
+ );
215
+ emit({
216
+ type: "audio.chunk",
217
+ trackId: TRACK_ID,
218
+ format: "pcm_s16le",
219
+ sampleRate: SAMPLE_RATE,
220
+ channels: CHANNELS,
221
+ data: Buffer.from(pcm).toString("base64"),
222
+ durationMs
223
+ });
224
+ }
225
+ var inUrl = false;
226
+ var inCodeBlock = false;
227
+ function stripMarkdown(text) {
228
+ if (text.includes("```")) {
229
+ inCodeBlock = !inCodeBlock;
230
+ return "";
231
+ }
232
+ if (inCodeBlock) return "";
233
+ let result = "";
234
+ for (let i = 0; i < text.length; i++) {
235
+ const ch = text[i];
236
+ if (inUrl) {
237
+ if (ch === ")") inUrl = false;
238
+ continue;
239
+ }
240
+ if (ch === "]" && i + 1 < text.length && text[i + 1] === "(") {
241
+ inUrl = true;
242
+ i++;
243
+ continue;
244
+ }
245
+ if (ch === "[" || ch === "]") continue;
246
+ if (ch === "*" || ch === "~" || ch === "`") continue;
247
+ if (ch === "#" && (i === 0 || text[i - 1] === "\n")) continue;
248
+ result += ch;
249
+ }
250
+ return result;
251
+ }
252
+ function sendText(text) {
253
+ if (!ws || !connected) {
254
+ log.warn(`sendText dropped (connected=${connected}): "${text.slice(0, 30)}"`);
255
+ return;
256
+ }
257
+ const clean = stripMarkdown(text);
258
+ if (!clean) return;
259
+ ws.send(JSON.stringify({ type: "Speak", text: clean }));
260
+ }
261
+ function flushStream() {
262
+ if (!ws || !connected) return;
263
+ log.debug("Sending Flush");
264
+ ws.send(JSON.stringify({ type: "Flush" }));
265
+ }
266
+ function clearStream() {
267
+ if (!ws || !connected) return;
268
+ log.debug("Sending Clear");
269
+ ws.send(JSON.stringify({ type: "Clear" }));
270
+ }
271
+ function closeWebSocket() {
272
+ connected = false;
273
+ pcmBuffer = Buffer.alloc(0);
274
+ if (ws) {
275
+ try {
276
+ ws.send(JSON.stringify({ type: "Close" }));
277
+ ws.close();
278
+ } catch {
279
+ }
280
+ ws = null;
281
+ }
282
+ }
283
+ async function main() {
284
+ await openWebSocket();
285
+ emit({ type: "lifecycle.ready", component: "tts-deepgram" });
286
+ const rl = createInterface({ input: process.stdin });
287
+ const eventQueue = [];
288
+ let processing = false;
289
+ async function processQueue() {
290
+ if (processing) return;
291
+ processing = true;
292
+ while (eventQueue.length > 0) {
293
+ const line = eventQueue.shift();
294
+ try {
295
+ const event = JSON.parse(line);
296
+ await handleEvent(event);
297
+ } catch {
298
+ }
299
+ }
300
+ processing = false;
301
+ }
302
+ async function handleEvent(event) {
303
+ if (event.type === "agent.delta") {
304
+ if (event.delta) {
305
+ if (interrupted || !connected) {
306
+ log.info(`Reconnecting (interrupted=${interrupted}, connected=${connected})`);
307
+ interrupted = false;
308
+ closeWebSocket();
309
+ await openWebSocket();
310
+ }
311
+ currentRequestId = event.requestId;
312
+ sendText(event.delta);
313
+ }
314
+ } else if (event.type === "agent.tool_start" && !interrupted) {
315
+ if (connected) {
316
+ log.info("Tool started \u2014 flushing TTS segment");
317
+ flushStream();
318
+ }
319
+ } else if (event.type === "agent.complete" && !interrupted) {
320
+ flushStream();
321
+ currentRequestId = null;
322
+ } else if (event.type === "control.interrupt") {
323
+ interrupted = true;
324
+ clearStream();
325
+ closeWebSocket();
326
+ currentRequestId = null;
327
+ }
328
+ }
329
+ rl.on("line", (line) => {
330
+ if (!line.trim()) return;
331
+ eventQueue.push(line);
332
+ processQueue();
333
+ });
334
+ rl.on("close", () => {
335
+ closeWebSocket();
336
+ emit({ type: "lifecycle.done", component: "tts-deepgram" });
337
+ process.exit(0);
338
+ });
339
+ process.on("SIGTERM", () => {
340
+ closeWebSocket();
341
+ process.exit(0);
342
+ });
343
+ }
344
+ main().catch((err) => {
345
+ log.error(`Fatal: ${err.message}`);
346
+ process.exit(1);
347
+ });
@@ -0,0 +1 @@
1
+ {"name":"tts-deepgram","description":"Text-to-speech via Deepgram streaming API","consumes":["agent.delta","agent.complete","agent.tool_start","control.interrupt"],"emits":["audio.chunk","lifecycle.ready","lifecycle.done","control.error"],"arguments":{"voice":{"type":"string","default":"aura-2-apollo-en","description":"Deepgram voice model name"},"sampleRate":{"type":"number","default":16000,"description":"Audio sample rate in Hz"},"apiKey":{"type":"string","description":"Deepgram API key (overrides DEEPGRAM_API_KEY env var)"}},"env":{"DEEPGRAM_API_KEY":{"required":true,"description":"Deepgram API key for TTS"}}}
package/package.json CHANGED
@@ -1,16 +1,20 @@
1
1
  {
2
2
  "name": "@acpfx/tts-deepgram",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "acpfx-tts-deepgram": "./dist/index.js"
7
7
  },
8
8
  "main": "./dist/index.js",
9
+ "files": [
10
+ "dist",
11
+ "manifest.yaml"
12
+ ],
9
13
  "dependencies": {
10
14
  "@acpfx/core": "0.4.0",
11
15
  "@acpfx/node-sdk": "0.3.0"
12
16
  },
13
17
  "scripts": {
14
- "build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js --packages=external"
18
+ "build": "esbuild src/index.ts --bundle --banner:js=\"#!/usr/bin/env node\" --platform=node --format=esm --outfile=dist/index.js --packages=external && node ../../scripts/copy-manifest.js"
15
19
  }
16
20
  }
package/CHANGELOG.md DELETED
@@ -1,38 +0,0 @@
1
- # @acpfx/tts-deepgram
2
-
3
- ## 0.2.2
4
-
5
- ### Patch Changes
6
-
7
- - Updated dependencies [0e6838e]
8
- - @acpfx/core@0.4.0
9
- - @acpfx/node-sdk@0.3.0
10
-
11
- ## 0.2.1
12
-
13
- ### Patch Changes
14
-
15
- - Updated dependencies [79c6694]
16
- - Updated dependencies [a0320a1]
17
- - @acpfx/core@0.3.0
18
- - @acpfx/node-sdk@0.2.1
19
-
20
- ## 0.2.0
21
-
22
- ### Minor Changes
23
-
24
- - d757640: Initial release: type-safe contracts, Rust orchestrator, manifest-driven event filtering
25
-
26
- - Rust schema crate as canonical event type source of truth with codegen to TypeScript + Zod
27
- - Node manifests (manifest.yaml) declaring consumes/emits contracts
28
- - Orchestrator event filtering: nodes only receive declared events
29
- - Rust orchestrator with ratatui TUI (--ui flag)
30
- - node-sdk with structured logging helpers
31
- - CI/CD with GitHub Actions and changesets
32
- - Platform-specific npm packages for Rust binaries (esbuild-style distribution)
33
-
34
- ### Patch Changes
35
-
36
- - Updated dependencies [d757640]
37
- - @acpfx/core@0.2.0
38
- - @acpfx/node-sdk@0.2.0
package/src/index.ts DELETED
@@ -1,316 +0,0 @@
1
- /**
2
- * tts-deepgram node — Deepgram Aura streaming TTS via WebSocket.
3
- *
4
- * Reads agent.delta events, streams text tokens to Deepgram WebSocket,
5
- * emits audio.chunk events as audio arrives.
6
- *
7
- * True streaming: sends each delta token as it arrives via {"type":"Speak","text":"..."}.
8
- * Explicit segment control:
9
- * - Flush on agent.tool_start (finalize current segment)
10
- * - Clear on control.interrupt (discard buffered text)
11
- *
12
- * Settings (via ACPFX_SETTINGS):
13
- * voice?: string — Deepgram voice model (default: aura-2-apollo-en)
14
- * apiKey?: string — API key (falls back to DEEPGRAM_API_KEY env)
15
- * sampleRate?: number — output sample rate (default: 16000)
16
- */
17
-
18
- import { createInterface } from "node:readline";
19
- import { emit, log, handleManifestFlag } from "@acpfx/node-sdk";
20
-
21
- handleManifestFlag();
22
-
23
- const WS_URL = "wss://api.deepgram.com/v1/speak";
24
- const DEFAULT_VOICE = "aura-2-apollo-en";
25
- const TRACK_ID = "tts";
26
-
27
- type Settings = {
28
- voice?: string;
29
- apiKey?: string;
30
- sampleRate?: number;
31
- };
32
-
33
- const settings: Settings = JSON.parse(process.env.ACPFX_SETTINGS || "{}");
34
- const API_KEY = settings.apiKey ?? process.env.DEEPGRAM_API_KEY ?? "";
35
- const VOICE = settings.voice ?? DEFAULT_VOICE;
36
- const SAMPLE_RATE = settings.sampleRate ?? 16000;
37
- const CHANNELS = 1;
38
- const BYTES_PER_SAMPLE = 2;
39
- const CHUNK_DURATION_MS = 100;
40
- const CHUNK_SIZE = Math.floor(
41
- (SAMPLE_RATE * CHANNELS * BYTES_PER_SAMPLE * CHUNK_DURATION_MS) / 1000,
42
- );
43
-
44
- if (!API_KEY) {
45
- log.error("No API key. Set DEEPGRAM_API_KEY or settings.apiKey");
46
- process.exit(1);
47
- }
48
-
49
- let ws: WebSocket | null = null;
50
- let connected = false;
51
- let interrupted = false;
52
- let pcmBuffer = Buffer.alloc(0);
53
- let currentRequestId: string | null = null;
54
-
55
-
56
- async function openWebSocket(): Promise<void> {
57
- if (ws && connected) return;
58
-
59
- const url =
60
- `${WS_URL}?model=${encodeURIComponent(VOICE)}` +
61
- `&encoding=linear16` +
62
- `&sample_rate=${SAMPLE_RATE}`;
63
-
64
- ws = new WebSocket(url, ["token", API_KEY]);
65
-
66
- await new Promise<void>((resolve, reject) => {
67
- ws!.addEventListener(
68
- "open",
69
- () => {
70
- connected = true;
71
- log.info("Connected to Deepgram 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
- ws.addEventListener("message", (event: MessageEvent) => {
84
- if (interrupted) return;
85
-
86
- const data = event.data;
87
-
88
- // Handle Blob (browser-style WebSocket returns Blobs for binary)
89
- if (typeof data === "object" && data !== null && typeof (data as any).arrayBuffer === "function") {
90
- (data as Blob).arrayBuffer().then((ab) => {
91
- if (interrupted) return;
92
- handleAudioData(Buffer.from(ab));
93
- });
94
- return;
95
- }
96
-
97
- // Handle ArrayBuffer / Buffer
98
- if (data instanceof ArrayBuffer || Buffer.isBuffer(data)) {
99
- handleAudioData(Buffer.from(data as ArrayBuffer));
100
- return;
101
- }
102
-
103
- // Text frame — metadata/control message
104
- if (typeof data === "string") {
105
- try {
106
- const msg = JSON.parse(data);
107
- if (msg.type === "Flushed") {
108
- if (pcmBuffer.length > 0) {
109
- emitAudioChunk(pcmBuffer);
110
- pcmBuffer = Buffer.alloc(0);
111
- }
112
- } else if (msg.type === "Warning") {
113
- log.warn(`Deepgram warning: ${msg.description ?? msg.code ?? "unknown"}`);
114
- }
115
- } catch {
116
- // ignore
117
- }
118
- }
119
- });
120
-
121
- function handleAudioData(rawPcm: Buffer): void {
122
- pcmBuffer = Buffer.concat([pcmBuffer, rawPcm]);
123
- while (pcmBuffer.length >= CHUNK_SIZE) {
124
- const chunk = pcmBuffer.subarray(0, CHUNK_SIZE);
125
- pcmBuffer = pcmBuffer.subarray(CHUNK_SIZE);
126
- emitAudioChunk(chunk);
127
- }
128
- }
129
-
130
- ws.addEventListener("error", (event: Event) => {
131
- log.error(`WebSocket error: ${(event as ErrorEvent).message ?? "unknown"}`);
132
- emit({
133
- type: "control.error",
134
- component: "tts-deepgram",
135
- message: "TTS WebSocket error",
136
- fatal: false,
137
- });
138
- });
139
-
140
- ws.addEventListener("close", (event: CloseEvent) => {
141
- log.info(`WebSocket closed (code=${event.code})`);
142
- connected = false;
143
- });
144
- }
145
-
146
- function emitAudioChunk(pcm: Buffer): void {
147
- const durationMs = Math.round(
148
- (pcm.length / (SAMPLE_RATE * CHANNELS * BYTES_PER_SAMPLE)) * 1000,
149
- );
150
- emit({
151
- type: "audio.chunk",
152
- trackId: TRACK_ID,
153
- format: "pcm_s16le",
154
- sampleRate: SAMPLE_RATE,
155
- channels: CHANNELS,
156
- data: Buffer.from(pcm).toString("base64"),
157
- durationMs,
158
- });
159
- }
160
-
161
- /**
162
- * Strip markdown characters from streaming tokens.
163
- * Since tokens arrive fragmented (e.g., "**" then "bold" then "**"),
164
- * we can't use pattern-based regex. Instead, just remove markdown
165
- * syntax characters and track URL state to skip link targets.
166
- */
167
- let inUrl = false;
168
- let inCodeBlock = false;
169
-
170
- function stripMarkdown(text: string): string {
171
- // Track code block state across tokens
172
- if (text.includes("```")) {
173
- inCodeBlock = !inCodeBlock;
174
- return "";
175
- }
176
- if (inCodeBlock) return "";
177
-
178
- // Track markdown link URL: after "](" skip until ")"
179
- let result = "";
180
- for (let i = 0; i < text.length; i++) {
181
- const ch = text[i];
182
- if (inUrl) {
183
- if (ch === ")") inUrl = false;
184
- continue; // skip URL characters
185
- }
186
- if (ch === "]" && i + 1 < text.length && text[i + 1] === "(") {
187
- inUrl = true;
188
- i++; // skip the "("
189
- continue;
190
- }
191
- if (ch === "[" || ch === "]") continue; // link brackets
192
- if (ch === "*" || ch === "~" || ch === "`") continue;
193
- if (ch === "#" && (i === 0 || text[i - 1] === "\n")) continue;
194
- result += ch;
195
- }
196
- return result;
197
- }
198
-
199
- function sendText(text: string): void {
200
- if (!ws || !connected) {
201
- log.warn(`sendText dropped (connected=${connected}): "${text.slice(0, 30)}"`);
202
- return;
203
- }
204
- const clean = stripMarkdown(text);
205
- if (!clean) return;
206
- ws.send(JSON.stringify({ type: "Speak", text: clean }));
207
- }
208
-
209
- function flushStream(): void {
210
- if (!ws || !connected) return;
211
- log.debug("Sending Flush");
212
- ws.send(JSON.stringify({ type: "Flush" }));
213
- }
214
-
215
- function clearStream(): void {
216
- if (!ws || !connected) return;
217
- log.debug("Sending Clear");
218
- ws.send(JSON.stringify({ type: "Clear" }));
219
- }
220
-
221
- function closeWebSocket(): void {
222
- connected = false;
223
- pcmBuffer = Buffer.alloc(0);
224
- if (ws) {
225
- try {
226
- ws.send(JSON.stringify({ type: "Close" }));
227
- ws.close();
228
- } catch {
229
- // ignore
230
- }
231
- ws = null;
232
- }
233
- }
234
-
235
- // --- Main ---
236
-
237
- async function main(): Promise<void> {
238
- await openWebSocket();
239
-
240
- emit({ type: "lifecycle.ready", component: "tts-deepgram" });
241
-
242
- const rl = createInterface({ input: process.stdin });
243
-
244
- const eventQueue: string[] = [];
245
- let processing = false;
246
-
247
- async function processQueue(): Promise<void> {
248
- if (processing) return;
249
- processing = true;
250
-
251
- while (eventQueue.length > 0) {
252
- const line = eventQueue.shift()!;
253
- try {
254
- const event = JSON.parse(line);
255
- await handleEvent(event);
256
- } catch {
257
- // ignore
258
- }
259
- }
260
-
261
- processing = false;
262
- }
263
-
264
- async function handleEvent(event: Record<string, unknown>): Promise<void> {
265
- if (event.type === "agent.delta") {
266
- if (event.delta) {
267
- if (interrupted || !connected) {
268
- log.info(`Reconnecting (interrupted=${interrupted}, connected=${connected})`);
269
- interrupted = 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 — flush current segment
278
- if (connected) {
279
- log.info("Tool started — flushing TTS segment");
280
- flushStream();
281
- }
282
- } else if (event.type === "agent.complete" && !interrupted) {
283
- // Agent done — flush remaining text
284
- flushStream();
285
- currentRequestId = null;
286
- } else if (event.type === "control.interrupt") {
287
- interrupted = true;
288
- // Clear discards buffered text immediately
289
- clearStream();
290
- closeWebSocket();
291
- currentRequestId = null;
292
- }
293
- }
294
-
295
- rl.on("line", (line) => {
296
- if (!line.trim()) return;
297
- eventQueue.push(line);
298
- processQueue();
299
- });
300
-
301
- rl.on("close", () => {
302
- closeWebSocket();
303
- emit({ type: "lifecycle.done", component: "tts-deepgram" });
304
- process.exit(0);
305
- });
306
-
307
- process.on("SIGTERM", () => {
308
- closeWebSocket();
309
- process.exit(0);
310
- });
311
- }
312
-
313
- main().catch((err) => {
314
- log.error(`Fatal: ${err.message}`);
315
- process.exit(1);
316
- });