@kuralle-syrinx/deepgram 2.1.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/src/tts.ts ADDED
@@ -0,0 +1,359 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Syrinx Kernel v2 — Deepgram Aura TTS Plugin (streaming)
4
+ //
5
+ // Streaming synthesis over Deepgram's WebSocket /v1/speak: each sentence the
6
+ // LLM produces is sent as a `Speak` message and raw linear16 PCM streams back
7
+ // immediately, so audio starts before the full turn is generated (low latency).
8
+ // `Flush` ends a turn (acked by `Flushed`); `Clear` stops a turn on barge-in.
9
+ //
10
+ // The socket is held open across turns by the shared WebSocketConnection
11
+ // (exponential-backoff reconnect, ping-verify, quick-failure guard, KeepAlive).
12
+ //
13
+ // Reference: LiveKit agents-js plugins/deepgram/src/tts.ts (SynthesizeStream).
14
+ // Protocol verified against the live API: Speak/Flush/Clear/Close in;
15
+ // Metadata/Flushed/Cleared/Warning/Error + binary PCM out; container=none gives
16
+ // raw linear16 (no WAV header); auth is `Authorization: Token <key>`.
17
+
18
+ import type { PipelineBus } from "@kuralle-syrinx/core";
19
+ import {
20
+ Route,
21
+ type AudioFormat,
22
+ type PluginConfig,
23
+ type RetryConfig,
24
+ type TextToSpeechAudioPacket,
25
+ type TextToSpeechEndPacket,
26
+ type TtsErrorPacket,
27
+ type VoicePlugin,
28
+ assertAudioFormat,
29
+ assertAudioPayload,
30
+ categorizeTtsError,
31
+ isRecoverable,
32
+ optionalStringConfig,
33
+ readProviderRetryConfig,
34
+ requireStringConfig,
35
+ } from "@kuralle-syrinx/core";
36
+ import { WebSocketConnection, type SocketData, type SocketFactory } from "@kuralle-syrinx/ws";
37
+
38
+ const SPEAK = (text: string): string => JSON.stringify({ type: "Speak", text });
39
+ const FLUSH_MSG = JSON.stringify({ type: "Flush" });
40
+ const CLEAR_MSG = JSON.stringify({ type: "Clear" });
41
+ const EMPTY = new Uint8Array(0);
42
+ const KEEP_ALIVE_INTERVAL_MS = 10_000;
43
+
44
+ export class DeepgramTTSPlugin implements VoicePlugin {
45
+ // socketFactory is injectable so the same plugin runs on Node (default) or
46
+ // Cloudflare Workers (pass createWorkersSocket).
47
+ constructor(private readonly socketFactory?: SocketFactory) {}
48
+
49
+ private bus: PipelineBus | null = null;
50
+ private conn: WebSocketConnection | null = null;
51
+ private apiKey = "";
52
+ private model = "aura-2-thalia-en";
53
+ private endpointUrl = "wss://api.deepgram.com/v1/speak";
54
+ private sampleRate = 24000;
55
+ private retryConfig: RetryConfig = readProviderRetryConfig({});
56
+ // Deepgram's speak socket has no per-message context id, but the engine
57
+ // synthesizes one turn at a time, so the audio streaming back belongs to the
58
+ // turn currently being spoken. carry holds an odd trailing PCM byte across
59
+ // binary frames so every emitted chunk stays 16-bit sample aligned.
60
+ private currentContextId = "";
61
+ private carry: Uint8Array = EMPTY;
62
+ private activeContexts = new Set<string>();
63
+ private cancelledContexts = new Set<string>();
64
+ // True between sending a Clear and receiving the Cleared ack: audio in that
65
+ // window is the interrupted turn's trailing PCM and must be dropped, never
66
+ // attributed to the next turn (Deepgram acks Cleared before the next turn's audio).
67
+ private clearedPending = false;
68
+ private finishTimers = new Map<string, ReturnType<typeof setTimeout>>();
69
+ private disposers: Array<() => void> = [];
70
+ private audioFormat: AudioFormat = { encoding: "pcm_s16le", sampleRateHz: 24000, channels: 1 };
71
+
72
+ async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
73
+ this.bus = bus;
74
+ this.apiKey = requireStringConfig(config, "api_key");
75
+ this.model = optionalStringConfig(config, "model") ?? this.model;
76
+ this.endpointUrl = optionalStringConfig(config, "endpoint_url") ?? this.endpointUrl;
77
+ this.sampleRate = readPositiveInteger(config["sample_rate"], this.sampleRate);
78
+ this.retryConfig = readProviderRetryConfig(config);
79
+ const finishTimeoutMs = readNonNegativeInteger(config["finish_timeout_ms"], 2000);
80
+ this.audioFormat = { encoding: "pcm_s16le", sampleRateHz: this.sampleRate, channels: 1 };
81
+ assertAudioFormat(this.audioFormat);
82
+
83
+ this.conn = new WebSocketConnection({
84
+ url: () => {
85
+ const params = new URLSearchParams({
86
+ model: this.model,
87
+ encoding: "linear16",
88
+ sample_rate: String(this.sampleRate),
89
+ container: "none",
90
+ });
91
+ const separator = this.endpointUrl.includes("?") ? "&" : "?";
92
+ return `${this.endpointUrl}${separator}${params.toString()}`;
93
+ },
94
+ headers: { Authorization: `Token ${this.apiKey}` },
95
+ retry: this.retryConfig,
96
+ socketFactory: this.socketFactory ?? await defaultSocketFactory(),
97
+ replayBufferSize: (config["replay_buffer_size"] as number) ?? 32,
98
+ onReplay: (event, count) => {
99
+ this.emitMetric(this.currentContextId, `tts.deepgram.reconnect_replay_${event}`, String(count));
100
+ },
101
+ keepAliveIntervalMs: KEEP_ALIVE_INTERVAL_MS,
102
+ onMessage: (data, isBinary) => this.handleProviderMessage(data, isBinary),
103
+ onConnectionLost: (err) => this.failActiveContexts(err),
104
+ onUnrecoverable: (err) => this.failActiveContexts(err),
105
+ });
106
+ await this.conn.connect();
107
+
108
+ this.disposers.push(
109
+ bus.on("tts.text", async (pkt: unknown) => {
110
+ const textPkt = pkt as { text: string; contextId: string };
111
+ await this.speak(textPkt.text, textPkt.contextId);
112
+ }),
113
+ bus.on("tts.done", async (pkt: unknown) => {
114
+ const donePkt = pkt as { contextId: string };
115
+ if (finishTimeoutMs > 0 && this.activeContexts.has(donePkt.contextId)) {
116
+ this.scheduleFinishTimeout(donePkt.contextId, finishTimeoutMs);
117
+ }
118
+ await this.finishContext(donePkt.contextId);
119
+ }),
120
+ bus.on("interrupt.tts", () => {
121
+ this.cancelActiveContexts().catch(() => {
122
+ // Best-effort interruption.
123
+ });
124
+ }),
125
+ );
126
+ }
127
+
128
+ private async speak(text: string, contextId: string): Promise<void> {
129
+ if (!text.trim()) return;
130
+ if (this.cancelledContexts.has(contextId)) return;
131
+ this.activeContexts.add(contextId);
132
+ this.currentContextId = contextId;
133
+ if (!(await this.trySend(SPEAK(text), contextId))) this.activeContexts.delete(contextId);
134
+ }
135
+
136
+ private async finishContext(contextId: string): Promise<void> {
137
+ if (this.cancelledContexts.has(contextId)) return;
138
+ // No text was synthesized for this turn (e.g. a tool-only turn): end it now.
139
+ if (!this.activeContexts.has(contextId)) {
140
+ this.emitEnd(contextId);
141
+ return;
142
+ }
143
+ if (!(await this.trySend(FLUSH_MSG, contextId))) this.activeContexts.delete(contextId);
144
+ // tts.end is emitted when the matching `Flushed` acknowledgement arrives.
145
+ }
146
+
147
+ /** Flush/cancel current synthesis (called on interrupt). */
148
+ async flush(): Promise<void> {
149
+ await this.cancelActiveContexts();
150
+ }
151
+
152
+ async close(): Promise<void> {
153
+ for (const dispose of this.disposers.splice(0)) dispose();
154
+ this.activeContexts.clear();
155
+ this.cancelledContexts.clear();
156
+ for (const timer of this.finishTimers.values()) clearTimeout(timer);
157
+ this.finishTimers.clear();
158
+ this.currentContextId = "";
159
+ this.carry = EMPTY;
160
+ this.clearedPending = false;
161
+ await this.conn?.close();
162
+ this.conn = null;
163
+ this.bus = null;
164
+ }
165
+
166
+ private async cancelActiveContexts(): Promise<void> {
167
+ const contextIds = [...this.activeContexts];
168
+ for (const contextId of contextIds) this.cancelledContexts.add(contextId);
169
+ this.activeContexts.clear();
170
+ for (const contextId of contextIds) this.clearFinishTimeout(contextId);
171
+ this.currentContextId = "";
172
+ this.carry = EMPTY;
173
+ if (contextIds.length === 0) return;
174
+ // Clear stops Deepgram from streaming the rest of the interrupted turn while
175
+ // keeping the socket open for the next turn. Arm clearedPending only if the
176
+ // Clear actually went out, so a failed send cannot wedge the audio path.
177
+ this.clearedPending = await this.trySend(CLEAR_MSG, contextIds[contextIds.length - 1] ?? "");
178
+ }
179
+
180
+ /** Send a frame, ensuring the socket is ready; emit a typed error if it cannot be sent. */
181
+ private async trySend(payload: string, contextId: string): Promise<boolean> {
182
+ try {
183
+ await this.conn?.ensureReady();
184
+ this.conn?.send(payload);
185
+ return true;
186
+ } catch (err) {
187
+ this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
188
+ return false;
189
+ }
190
+ }
191
+
192
+ private handleProviderMessage(data: SocketData, isBinary: boolean): void {
193
+ if (isBinary && typeof data !== "string") {
194
+ this.handleAudio(data);
195
+ return;
196
+ }
197
+ if (typeof data !== "string") return;
198
+
199
+ let msg: Record<string, unknown>;
200
+ try {
201
+ msg = JSON.parse(data) as Record<string, unknown>;
202
+ } catch {
203
+ return; // non-JSON, non-binary control frame — ignore
204
+ }
205
+
206
+ switch (msg["type"]) {
207
+ case "Flushed": {
208
+ const contextId = this.currentContextId;
209
+ this.carry = EMPTY;
210
+ if (contextId && this.activeContexts.has(contextId)) {
211
+ this.activeContexts.delete(contextId);
212
+ this.clearFinishTimeout(contextId);
213
+ this.emitEnd(contextId);
214
+ }
215
+ this.currentContextId = "";
216
+ return;
217
+ }
218
+ case "Cleared":
219
+ this.carry = EMPTY;
220
+ this.clearedPending = false;
221
+ return;
222
+ case "Warning":
223
+ return;
224
+ case "Error":
225
+ case "error":
226
+ this.emitError(this.currentContextId, deepgramProviderError(msg));
227
+ return;
228
+ default:
229
+ return; // Metadata and unknown control frames
230
+ }
231
+ }
232
+
233
+ private handleAudio(frame: Uint8Array): void {
234
+ const contextId = this.currentContextId;
235
+ if (!contextId || this.clearedPending || this.cancelledContexts.has(contextId)) return;
236
+ if (frame.byteLength === 0) return;
237
+
238
+ const buf = this.carry.byteLength === 0 ? frame : concatBytes(this.carry, frame);
239
+ const evenLen = buf.byteLength - (buf.byteLength % 2);
240
+ if (evenLen > 0) {
241
+ const audio = buf.subarray(0, evenLen);
242
+ try {
243
+ assertAudioPayload(this.audioFormat, audio);
244
+ } catch (err) {
245
+ this.emitError(contextId, err instanceof Error ? err : new Error(String(err)));
246
+ return;
247
+ }
248
+ const packet: TextToSpeechAudioPacket = {
249
+ kind: "tts.audio",
250
+ contextId,
251
+ timestampMs: Date.now(),
252
+ audio,
253
+ sampleRateHz: this.sampleRate,
254
+ provider: { name: "deepgram", model: this.model, region: "global", cancelled: false },
255
+ };
256
+ this.bus?.push(Route.Main, packet);
257
+ }
258
+ this.carry = evenLen < buf.byteLength ? buf.subarray(evenLen) : EMPTY;
259
+ }
260
+
261
+ private emitError(contextId: string, err: Error): void {
262
+ const category = categorizeTtsError(err);
263
+ const packet: TtsErrorPacket = {
264
+ kind: "tts.error",
265
+ contextId,
266
+ timestampMs: Date.now(),
267
+ component: "tts" as const,
268
+ category,
269
+ cause: err,
270
+ isRecoverable: isRecoverable(category),
271
+ };
272
+ this.bus?.push(Route.Critical, packet);
273
+ }
274
+
275
+ private emitMetric(contextId: string, name: string, value: string): void {
276
+ this.bus?.push(Route.Background, {
277
+ kind: "metric.conversation",
278
+ contextId,
279
+ timestampMs: Date.now(),
280
+ name,
281
+ value,
282
+ });
283
+ }
284
+
285
+ private failActiveContexts(err: Error): void {
286
+ const contextIds = [...this.activeContexts];
287
+ this.activeContexts.clear();
288
+ for (const contextId of contextIds) this.clearFinishTimeout(contextId);
289
+ this.currentContextId = "";
290
+ this.carry = EMPTY;
291
+ // A dropped socket voids any in-flight Clear/Cleared handshake: the server
292
+ // resets on reconnect and will never ack the old Clear, so disarm here or
293
+ // the next turn's audio would be dropped forever.
294
+ this.clearedPending = false;
295
+ for (const contextId of contextIds) this.emitError(contextId, err);
296
+ }
297
+
298
+ private scheduleFinishTimeout(contextId: string, timeoutMs: number): void {
299
+ this.clearFinishTimeout(contextId);
300
+ const timer = setTimeout(() => {
301
+ this.finishTimers.delete(contextId);
302
+ if (!this.activeContexts.has(contextId)) return;
303
+ this.emitMetric(contextId, "tts.deepgram.finish_timeout", String(timeoutMs));
304
+ this.activeContexts.delete(contextId);
305
+ if (this.currentContextId === contextId) this.currentContextId = "";
306
+ this.carry = EMPTY;
307
+ this.emitEnd(contextId);
308
+ }, timeoutMs);
309
+ this.finishTimers.set(contextId, timer);
310
+ }
311
+
312
+ private clearFinishTimeout(contextId: string): void {
313
+ const timer = this.finishTimers.get(contextId);
314
+ if (!timer) return;
315
+ clearTimeout(timer);
316
+ this.finishTimers.delete(contextId);
317
+ }
318
+
319
+ private emitEnd(contextId: string): void {
320
+ this.bus?.push(Route.Main, {
321
+ kind: "tts.end",
322
+ contextId,
323
+ timestampMs: Date.now(),
324
+ } satisfies TextToSpeechEndPacket);
325
+ }
326
+ }
327
+
328
+ async function defaultSocketFactory(): Promise<SocketFactory> {
329
+ const mod = await import("@kuralle-syrinx/ws/node");
330
+ return mod.createNodeWsSocket;
331
+ }
332
+
333
+ function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
334
+ const out = new Uint8Array(a.byteLength + b.byteLength);
335
+ out.set(a, 0);
336
+ out.set(b, a.byteLength);
337
+ return out;
338
+ }
339
+
340
+ function deepgramProviderError(msg: Record<string, unknown>): Error {
341
+ const description =
342
+ (typeof msg["description"] === "string" && msg["description"]) ||
343
+ (typeof msg["message"] === "string" && msg["message"]) ||
344
+ (typeof msg["err_msg"] === "string" && msg["err_msg"]) ||
345
+ "Deepgram TTS provider error";
346
+ return new Error(description);
347
+ }
348
+
349
+ function readPositiveInteger(value: unknown, fallback: number): number {
350
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
351
+ const integer = Math.floor(value);
352
+ return integer > 0 ? integer : fallback;
353
+ }
354
+
355
+ function readNonNegativeInteger(value: unknown, fallback: number): number {
356
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
357
+ const integer = Math.floor(value);
358
+ return integer >= 0 ? integer : fallback;
359
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022"],
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "noImplicitReturns": true,
10
+ "declaration": true,
11
+ "declarationMap": true,
12
+ "sourceMap": true,
13
+ "outDir": "./dist",
14
+ "rootDir": "./src",
15
+ "esModuleInterop": true,
16
+ "skipLibCheck": true,
17
+ "forceConsistentCasingInFileNames": true
18
+ },
19
+ "include": ["src/**/*.ts"],
20
+ "exclude": ["node_modules", "dist"]
21
+ }