@kuralle-syrinx/deepgram 3.1.0 → 4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/deepgram",
3
- "version": "3.1.0",
3
+ "version": "4.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -12,8 +12,8 @@
12
12
  "./tts": "./src/tts.ts"
13
13
  },
14
14
  "dependencies": {
15
- "@kuralle-syrinx/core": "3.1.0",
16
- "@kuralle-syrinx/ws": "3.1.0"
15
+ "@kuralle-syrinx/core": "4.1.0",
16
+ "@kuralle-syrinx/ws": "4.1.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@types/ws": "^8.5.0",
@@ -0,0 +1,227 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { WebSocketServer, type WebSocket } from "ws";
5
+ import {
6
+ PipelineBusImpl,
7
+ Route,
8
+ type EndOfSpeechPacket,
9
+ type EndOfSpeechRetractedPacket,
10
+ type InterimEndOfSpeechPacket,
11
+ type SttInterimPacket,
12
+ type SttResultPacket,
13
+ } from "@kuralle-syrinx/core";
14
+
15
+ import { DeepgramFluxSTTPlugin } from "./flux.js";
16
+
17
+ let servers: WebSocketServer[] = [];
18
+
19
+ afterEach(async () => {
20
+ await Promise.all(
21
+ servers.splice(0).map(
22
+ (server) =>
23
+ new Promise<void>((resolve) => {
24
+ for (const client of server.clients) client.terminate();
25
+ server.close(() => resolve());
26
+ }),
27
+ ),
28
+ );
29
+ });
30
+
31
+ interface LocalServer {
32
+ endpointUrl: string;
33
+ connectionUrls: string[];
34
+ sockets: WebSocket[];
35
+ }
36
+
37
+ async function createLocalServer(): Promise<LocalServer> {
38
+ const server = await new Promise<WebSocketServer>((resolve) => {
39
+ let nextServer: WebSocketServer;
40
+ nextServer = new WebSocketServer({ port: 0 }, () => resolve(nextServer));
41
+ });
42
+ servers.push(server);
43
+ const state: LocalServer = { endpointUrl: "", connectionUrls: [], sockets: [] };
44
+ server.on("connection", (socket, req) => {
45
+ state.connectionUrls.push(req.url ?? "");
46
+ state.sockets.push(socket);
47
+ });
48
+ const address = server.address();
49
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
50
+ state.endpointUrl = `ws://127.0.0.1:${address.port}/v2/listen`;
51
+ return state;
52
+ }
53
+
54
+ async function waitFor<T>(items: T[], count = 1): Promise<void> {
55
+ for (let i = 0; i < 100; i += 1) {
56
+ if (items.length >= count) return;
57
+ await new Promise((resolve) => setTimeout(resolve, 10));
58
+ }
59
+ }
60
+
61
+ function turnInfo(event: string, extra: Record<string, unknown> = {}): string {
62
+ return JSON.stringify({
63
+ type: "TurnInfo",
64
+ event,
65
+ turn_index: 0,
66
+ audio_window_start: 0,
67
+ audio_window_end: 0.6,
68
+ transcript: "",
69
+ words: [],
70
+ end_of_turn_confidence: 0.5,
71
+ ...extra,
72
+ });
73
+ }
74
+
75
+ async function startPlugin(
76
+ local: LocalServer,
77
+ config: Record<string, unknown> = {},
78
+ ): Promise<{ bus: PipelineBusImpl; plugin: DeepgramFluxSTTPlugin; started: Promise<void> }> {
79
+ const bus = new PipelineBusImpl();
80
+ const started = bus.start();
81
+ const plugin = new DeepgramFluxSTTPlugin();
82
+ await plugin.initialize(bus, {
83
+ api_key: "test",
84
+ endpoint_url: local.endpointUrl,
85
+ sample_rate: 16000,
86
+ ...config,
87
+ });
88
+ await waitFor(local.sockets);
89
+ // Feed one audio packet so the plugin learns the current contextId.
90
+ bus.push(Route.Main, {
91
+ kind: "stt.audio",
92
+ contextId: "turn-1",
93
+ timestampMs: Date.now(),
94
+ audio: new Uint8Array(320),
95
+ });
96
+ await new Promise((resolve) => setTimeout(resolve, 20));
97
+ return { bus, plugin, started };
98
+ }
99
+
100
+ describe("DeepgramFluxSTTPlugin", () => {
101
+ it("connects with Flux params and forwards keyterm + eager threshold", async () => {
102
+ const local = await createLocalServer();
103
+ const { bus, plugin, started } = await startPlugin(local, {
104
+ eot_threshold: 0.85,
105
+ eager_eot_threshold: 0.4,
106
+ eot_timeout_ms: 7000,
107
+ keyterm: ["Syrinx"],
108
+ });
109
+
110
+ await plugin.close();
111
+ bus.stop();
112
+ await started;
113
+
114
+ const url = local.connectionUrls[0]!;
115
+ expect(url).toContain("model=flux-general-en");
116
+ expect(url).toContain("encoding=linear16");
117
+ expect(url).toContain("sample_rate=16000");
118
+ expect(url).toContain("eot_threshold=0.85");
119
+ expect(url).toContain("eager_eot_threshold=0.4");
120
+ expect(url).toContain("eot_timeout_ms=7000");
121
+ expect(url).toContain("keyterm=Syrinx");
122
+ });
123
+
124
+ it("omits eager_eot_threshold by default (eager mode is opt-in)", async () => {
125
+ const local = await createLocalServer();
126
+ const { bus, plugin, started } = await startPlugin(local);
127
+
128
+ await plugin.close();
129
+ bus.stop();
130
+ await started;
131
+
132
+ const url = local.connectionUrls[0]!;
133
+ expect(url).toContain("eot_threshold=0.7");
134
+ expect(url).not.toContain("eager_eot_threshold=");
135
+ });
136
+
137
+ it("maps TurnInfo events onto the bus: Update→stt.interim, EndOfTurn→stt.result+eos.turn_complete", async () => {
138
+ const local = await createLocalServer();
139
+ const { bus, plugin, started } = await startPlugin(local);
140
+
141
+ const interims: SttInterimPacket[] = [];
142
+ const results: SttResultPacket[] = [];
143
+ const turnCompletes: EndOfSpeechPacket[] = [];
144
+ bus.on("stt.interim", (pkt) => {
145
+ interims.push(pkt as SttInterimPacket);
146
+ });
147
+ bus.on("stt.result", (pkt) => {
148
+ results.push(pkt as SttResultPacket);
149
+ });
150
+ bus.on("eos.turn_complete", (pkt) => {
151
+ turnCompletes.push(pkt as EndOfSpeechPacket);
152
+ });
153
+
154
+ const socket = local.sockets[0]!;
155
+ socket.send(turnInfo("Update", { transcript: "what are" }));
156
+ socket.send(
157
+ turnInfo("EndOfTurn", {
158
+ transcript: "what are the lab fees",
159
+ words: [
160
+ { word: "what", confidence: 0.99 },
161
+ { word: "are", confidence: 0.97 },
162
+ ],
163
+ end_of_turn_confidence: 0.91,
164
+ }),
165
+ );
166
+ await waitFor(turnCompletes);
167
+
168
+ await plugin.close();
169
+ bus.stop();
170
+ await started;
171
+
172
+ expect(interims.map((p) => p.text)).toContain("what are");
173
+ expect(results).toHaveLength(1);
174
+ expect(results[0]!.text).toBe("what are the lab fees");
175
+ expect(results[0]!.confidence).toBeCloseTo(0.98, 2);
176
+ expect(turnCompletes).toEqual([
177
+ expect.objectContaining({ contextId: "turn-1", text: "what are the lab fees" }),
178
+ ]);
179
+ });
180
+
181
+ it("emits eos.interim on EagerEndOfTurn and eos.retracted on TurnResumed", async () => {
182
+ const local = await createLocalServer();
183
+ const { bus, plugin, started } = await startPlugin(local, { eager_eot_threshold: 0.4 });
184
+
185
+ const eagers: InterimEndOfSpeechPacket[] = [];
186
+ const retractions: EndOfSpeechRetractedPacket[] = [];
187
+ bus.on("eos.interim", (pkt) => {
188
+ eagers.push(pkt as InterimEndOfSpeechPacket);
189
+ });
190
+ bus.on("eos.retracted", (pkt) => {
191
+ retractions.push(pkt as EndOfSpeechRetractedPacket);
192
+ });
193
+
194
+ const socket = local.sockets[0]!;
195
+ socket.send(turnInfo("EagerEndOfTurn", { transcript: "book a room", end_of_turn_confidence: 0.55 }));
196
+ await waitFor(eagers);
197
+ socket.send(turnInfo("TurnResumed"));
198
+ await waitFor(retractions);
199
+
200
+ await plugin.close();
201
+ bus.stop();
202
+ await started;
203
+
204
+ expect(eagers).toEqual([expect.objectContaining({ contextId: "turn-1", text: "book a room" })]);
205
+ expect(retractions).toEqual([expect.objectContaining({ contextId: "turn-1" })]);
206
+ });
207
+
208
+ it("emits vad.speech_started on StartOfTurn (Flux owns barge-in signalling)", async () => {
209
+ const local = await createLocalServer();
210
+ const { bus, plugin, started } = await startPlugin(local);
211
+
212
+ const speechStarts: unknown[] = [];
213
+ bus.on("vad.speech_started", (pkt) => {
214
+ speechStarts.push(pkt);
215
+ });
216
+
217
+ const socket = local.sockets[0]!;
218
+ socket.send(turnInfo("StartOfTurn", { transcript: "hello" }));
219
+ await waitFor(speechStarts);
220
+
221
+ await plugin.close();
222
+ bus.stop();
223
+ await started;
224
+
225
+ expect(speechStarts.length).toBe(1);
226
+ });
227
+ });
package/src/flux.ts ADDED
@@ -0,0 +1,294 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Deepgram Flux — turn-aware conversational STT (v2 listen API).
4
+ //
5
+ // One model produces transcripts AND owns turn detection, replacing the
6
+ // VAD + silence-endpointing stack. Runs on Workers (plain WebSocket), so this
7
+ // is the semantic end-of-turn path for the edge cascade, where local ONNX
8
+ // endpointers (smart-turn) cannot run.
9
+ //
10
+ // TurnInfo state machine → bus mapping:
11
+ // StartOfTurn → vad.speech_started (barge-in signal; Flux recommends it)
12
+ // Update → stt.interim
13
+ // EagerEndOfTurn → eos.interim (speculative-generation trigger)
14
+ // TurnResumed → eos.retracted (cancel speculative work)
15
+ // EndOfTurn → stt.result + eos.turn_complete
16
+ //
17
+ // Eager mode is enabled by setting `eager_eot_threshold` (Deepgram: fires
18
+ // 150–250ms before EndOfTurn at the cost of extra speculative LLM calls). The
19
+ // EndOfTurn transcript exactly matches the preceding EagerEndOfTurn transcript
20
+ // when no TurnResumed intervened, so a speculative result keyed on the eager
21
+ // transcript can be committed as-is.
22
+
23
+ import type { PipelineBus } from "@kuralle-syrinx/core";
24
+ import {
25
+ Route,
26
+ type PluginConfig,
27
+ type SttErrorPacket,
28
+ type VoicePlugin,
29
+ categorizeSttError,
30
+ isRecoverable,
31
+ optionalStringConfig,
32
+ readProviderRetryConfig,
33
+ requireStringConfig,
34
+ } from "@kuralle-syrinx/core";
35
+ import { WebSocketConnection, type SocketFactory } from "@kuralle-syrinx/ws";
36
+
37
+ interface TurnInfoMessage {
38
+ readonly type: "TurnInfo";
39
+ readonly event: "StartOfTurn" | "Update" | "EagerEndOfTurn" | "TurnResumed" | "EndOfTurn";
40
+ readonly transcript?: string;
41
+ readonly words?: ReadonlyArray<{ readonly word: string; readonly confidence: number }>;
42
+ readonly end_of_turn_confidence?: number;
43
+ }
44
+
45
+ function meanWordConfidence(words: TurnInfoMessage["words"]): number {
46
+ if (!words || words.length === 0) return 1;
47
+ let sum = 0;
48
+ for (const w of words) sum += w.confidence;
49
+ return sum / words.length;
50
+ }
51
+
52
+ export class DeepgramFluxSTTPlugin implements VoicePlugin {
53
+ readonly endpointingCapability = {
54
+ owner: "provider_stt" as const,
55
+ disableConfig: {
56
+ emit_eos_on_final: false,
57
+ },
58
+ };
59
+
60
+ private bus: PipelineBus | null = null;
61
+ private apiKey = "";
62
+ private model = "flux-general-en";
63
+ private endpointUrl = "wss://api.deepgram.com/v2/listen";
64
+ private sampleRate = 16000;
65
+ private eotThreshold = 0.7;
66
+ private eagerEotThreshold: number | undefined;
67
+ private eotTimeoutMs = 5000;
68
+ private keyterms: readonly string[] = [];
69
+ private languageHints: readonly string[] = [];
70
+ private speechStartedEvents = true;
71
+ private emitEosOnFinal = true;
72
+
73
+ private conn: WebSocketConnection | null = null;
74
+ private currentContextId = "";
75
+ private disposers: Array<() => void> = [];
76
+
77
+ constructor(private readonly socketFactory?: SocketFactory) {}
78
+
79
+ async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
80
+ this.bus = bus;
81
+ this.apiKey = requireStringConfig(config, "api_key");
82
+ this.model = optionalStringConfig(config, "model") ?? "flux-general-en";
83
+ this.endpointUrl = optionalStringConfig(config, "endpoint_url") ?? "wss://api.deepgram.com/v2/listen";
84
+ this.sampleRate = (config["sample_rate"] as number) ?? 16000;
85
+ this.eotThreshold = (config["eot_threshold"] as number) ?? 0.7;
86
+ this.eagerEotThreshold = config["eager_eot_threshold"] as number | undefined;
87
+ this.eotTimeoutMs = (config["eot_timeout_ms"] as number) ?? 5000;
88
+ this.speechStartedEvents = (config["speech_started_events"] as boolean) ?? true;
89
+ this.emitEosOnFinal = (config["emit_eos_on_final"] as boolean) ?? true;
90
+ {
91
+ const raw = config["keyterm"];
92
+ this.keyterms = Array.isArray(raw)
93
+ ? raw.filter((t): t is string => typeof t === "string" && t.length > 0)
94
+ : typeof raw === "string" && raw.length > 0
95
+ ? [raw]
96
+ : [];
97
+ }
98
+ {
99
+ const raw = config["language_hint"];
100
+ this.languageHints = Array.isArray(raw)
101
+ ? raw.filter((t): t is string => typeof t === "string" && t.length > 0)
102
+ : typeof raw === "string" && raw.length > 0
103
+ ? [raw]
104
+ : [];
105
+ }
106
+
107
+ const socketFactory = this.socketFactory ?? (await defaultSocketFactory());
108
+ this.conn = new WebSocketConnection({
109
+ url: () => {
110
+ const params = new URLSearchParams({
111
+ model: this.model,
112
+ encoding: "linear16",
113
+ sample_rate: String(this.sampleRate),
114
+ eot_threshold: String(this.eotThreshold),
115
+ eot_timeout_ms: String(this.eotTimeoutMs),
116
+ ...(this.eagerEotThreshold !== undefined
117
+ ? { eager_eot_threshold: String(this.eagerEotThreshold) }
118
+ : {}),
119
+ });
120
+ for (const term of this.keyterms) params.append("keyterm", term);
121
+ for (const hint of this.languageHints) params.append("language_hint", hint);
122
+ const separator = this.endpointUrl.includes("?") ? "&" : "?";
123
+ return `${this.endpointUrl}${separator}${params.toString()}`;
124
+ },
125
+ headers: { Authorization: `Token ${this.apiKey}` },
126
+ socketFactory,
127
+ retry: readProviderRetryConfig(config),
128
+ replayBufferSize: (config["replay_buffer_size"] as number) ?? 64,
129
+ onReplay: (event, count) => {
130
+ this.pushMetric(this.currentContextId, `stt.flux.reconnect_replay_${event}`, String(count));
131
+ },
132
+ // No provider keepalive text message: the Flux v2 protocol keeps the turn
133
+ // model fed by continuous audio; transports stream silence frames between
134
+ // utterances, so an idle socket means the call itself has gone quiet.
135
+ onMessage: (data) => {
136
+ if (typeof data === "string") this.handleProviderMessage(data);
137
+ },
138
+ onConnectionLost: (err) => {
139
+ this.emitError(this.currentContextId, err);
140
+ },
141
+ });
142
+ await this.conn.connect();
143
+
144
+ this.disposers.push(
145
+ bus.on("stt.audio", async (pkt: unknown) => {
146
+ const audioPkt = pkt as { audio: Uint8Array; contextId?: string };
147
+ if (audioPkt.contextId) this.currentContextId = audioPkt.contextId;
148
+ if (!this.conn) return;
149
+ try {
150
+ await this.conn.ensureReady();
151
+ this.conn.send(audioPkt.audio);
152
+ } catch (err) {
153
+ this.emitError(this.currentContextId, err instanceof Error ? err : new Error(String(err)));
154
+ }
155
+ }),
156
+ bus.on("turn.change", (pkt: unknown) => {
157
+ const tc = pkt as { contextId: string };
158
+ this.currentContextId = tc.contextId;
159
+ }),
160
+ );
161
+ }
162
+
163
+ private handleProviderMessage(data: string): void {
164
+ let msg: Record<string, unknown>;
165
+ try {
166
+ msg = JSON.parse(data) as Record<string, unknown>;
167
+ } catch (err) {
168
+ this.emitError(
169
+ this.currentContextId,
170
+ new Error(`Deepgram Flux sent malformed JSON: ${err instanceof Error ? err.message : String(err)}`),
171
+ );
172
+ return;
173
+ }
174
+
175
+ if (msg["type"] === "Error") {
176
+ const description = typeof msg["description"] === "string" ? msg["description"] : JSON.stringify(msg);
177
+ this.emitError(this.currentContextId, new Error(`Deepgram Flux provider error: ${description}`));
178
+ return;
179
+ }
180
+ if (msg["type"] !== "TurnInfo") return;
181
+
182
+ const info = msg as unknown as TurnInfoMessage;
183
+ const contextId = this.currentContextId;
184
+ const transcript = (info.transcript ?? "").trim();
185
+
186
+ switch (info.event) {
187
+ case "StartOfTurn": {
188
+ if (!this.speechStartedEvents) return;
189
+ this.bus?.push(Route.Main, {
190
+ kind: "vad.speech_started",
191
+ contextId,
192
+ timestampMs: Date.now(),
193
+ confidence: 1,
194
+ });
195
+ return;
196
+ }
197
+ case "Update": {
198
+ if (!transcript) return;
199
+ this.bus?.push(Route.Main, {
200
+ kind: "stt.interim",
201
+ contextId,
202
+ timestampMs: Date.now(),
203
+ text: transcript,
204
+ });
205
+ return;
206
+ }
207
+ case "EagerEndOfTurn": {
208
+ if (!transcript) return;
209
+ this.bus?.push(Route.Main, {
210
+ kind: "eos.interim",
211
+ contextId,
212
+ timestampMs: Date.now(),
213
+ text: transcript,
214
+ });
215
+ return;
216
+ }
217
+ case "TurnResumed": {
218
+ this.bus?.push(Route.Main, {
219
+ kind: "eos.retracted",
220
+ contextId,
221
+ timestampMs: Date.now(),
222
+ });
223
+ return;
224
+ }
225
+ case "EndOfTurn": {
226
+ if (!transcript) return;
227
+ this.bus?.push(Route.Main, {
228
+ kind: "stt.result",
229
+ contextId,
230
+ timestampMs: Date.now(),
231
+ text: transcript,
232
+ confidence: meanWordConfidence(info.words),
233
+ language: "en",
234
+ provider: { name: "deepgram", model: this.model, region: "global" },
235
+ });
236
+ if (this.emitEosOnFinal) {
237
+ this.bus?.push(Route.Main, {
238
+ kind: "eos.turn_complete",
239
+ contextId,
240
+ timestampMs: Date.now(),
241
+ text: transcript,
242
+ transcripts: [],
243
+ });
244
+ }
245
+ return;
246
+ }
247
+ }
248
+ }
249
+
250
+ private pushMetric(contextId: string, name: string, value: string): void {
251
+ this.bus?.push(Route.Background, {
252
+ kind: "metric.conversation",
253
+ contextId,
254
+ timestampMs: Date.now(),
255
+ name,
256
+ value,
257
+ });
258
+ }
259
+
260
+ private emitError(contextId: string, err: Error): void {
261
+ const category = categorizeSttError(err);
262
+ const packet: SttErrorPacket = {
263
+ kind: "stt.error",
264
+ contextId,
265
+ timestampMs: Date.now(),
266
+ component: "stt" as const,
267
+ category,
268
+ cause: err,
269
+ isRecoverable: isRecoverable(category),
270
+ };
271
+ this.bus?.push(Route.Critical, packet);
272
+ }
273
+
274
+ async close(): Promise<void> {
275
+ for (const dispose of this.disposers.splice(0)) dispose();
276
+ if (this.conn) {
277
+ if (this.conn.isReady) {
278
+ try {
279
+ this.conn.send(JSON.stringify({ type: "CloseStream" }));
280
+ } catch {
281
+ // Socket already going away — CloseStream is best-effort.
282
+ }
283
+ }
284
+ await this.conn.close();
285
+ this.conn = null;
286
+ }
287
+ this.bus = null;
288
+ }
289
+ }
290
+
291
+ async function defaultSocketFactory(): Promise<SocketFactory> {
292
+ const mod = await import("@kuralle-syrinx/ws/node");
293
+ return mod.createNodeWsSocket;
294
+ }
package/src/index.ts CHANGED
@@ -4,4 +4,5 @@
4
4
  // Import from the barrel for both, or the `./stt` / `./tts` subpaths for one.
5
5
 
6
6
  export * from "./stt.js";
7
+ export * from "./flux.js";
7
8
  export * from "./tts.js";
package/src/stt.test.ts CHANGED
@@ -175,6 +175,47 @@ describe("DeepgramSTTPlugin", () => {
175
175
  await started;
176
176
  });
177
177
 
178
+ it("completes a wedged turn via UtteranceEnd when speech_final never fires (noisy-line backstop)", async () => {
179
+ // A noisy/continuous line: Deepgram sends an is_final segment but never
180
+ // speech_final. UtteranceEnd (gap-based) must complete the turn so it can't wedge.
181
+ const endpointUrl = await createLocalServer((socket) => {
182
+ socket.on("message", (data, isBinary) => {
183
+ if (isBinary) {
184
+ socket.send(JSON.stringify({
185
+ is_final: true,
186
+ speech_final: false, // never speech_final on this noisy line
187
+ channel: { alternatives: [{ transcript: "book a room", confidence: 0.9 }] },
188
+ }));
189
+ setTimeout(() => socket.send(JSON.stringify({ type: "UtteranceEnd", last_word_end: 1.2 })), 5);
190
+ return;
191
+ }
192
+ });
193
+ });
194
+ const bus = new PipelineBusImpl();
195
+ const started = startBus(bus);
196
+ const plugin = new DeepgramSTTPlugin();
197
+ const turnCompletes: Array<{ text: string; contextId: string }> = [];
198
+ bus.on("eos.turn_complete", (pkt) => {
199
+ const p = pkt as unknown as { text: string; contextId: string };
200
+ turnCompletes.push({ text: p.text, contextId: p.contextId });
201
+ });
202
+
203
+ await plugin.initialize(bus, {
204
+ api_key: "test",
205
+ endpoint_url: endpointUrl,
206
+ sample_rate: 16000,
207
+ utterance_end_ms: 1000,
208
+ });
209
+ bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-1", timestampMs: Date.now(), audio: new Uint8Array(640) });
210
+ await new Promise((resolve) => setTimeout(resolve, 40));
211
+
212
+ expect(turnCompletes).toEqual([{ text: "book a room", contextId: "turn-1" }]);
213
+
214
+ await plugin.close();
215
+ bus.stop();
216
+ await started;
217
+ });
218
+
178
219
  it("emits trailing provider-final segments after Pipecat requests finalize", async () => {
179
220
  const endpointUrl = await createLocalServer((socket) => {
180
221
  socket.on("message", (data, isBinary) => {
@@ -1150,4 +1191,67 @@ describe("DeepgramSTTPlugin provider speech-start (vad_events)", () => {
1150
1191
 
1151
1192
  expect(speechStarts).toHaveLength(0);
1152
1193
  });
1194
+
1195
+ it("passes keyterm config as repeatable query params (nova-3 keyterm prompting)", async () => {
1196
+ const connectionUrls: string[] = [];
1197
+ const server = await new Promise<WebSocketServer>((resolve) => {
1198
+ let nextServer: WebSocketServer;
1199
+ nextServer = new WebSocketServer({ port: 0 }, () => resolve(nextServer));
1200
+ });
1201
+ servers.push(server);
1202
+ server.on("connection", (_socket, req) => {
1203
+ connectionUrls.push(req.url ?? "");
1204
+ });
1205
+ const address = server.address();
1206
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
1207
+ const endpointUrl = `ws://127.0.0.1:${address.port}/listen`;
1208
+
1209
+ const bus = new PipelineBusImpl();
1210
+ const started = startBus(bus);
1211
+ const plugin = new DeepgramSTTPlugin();
1212
+ await plugin.initialize(bus, {
1213
+ api_key: "test",
1214
+ endpoint_url: endpointUrl,
1215
+ sample_rate: 16000,
1216
+ keyterm: ["Syrinx", "Kuralle Suite"],
1217
+ });
1218
+ await waitFor(connectionUrls);
1219
+ await plugin.close();
1220
+ bus.stop();
1221
+ await started;
1222
+
1223
+ const url = connectionUrls[0]!;
1224
+ expect(url).toContain("keyterm=Syrinx");
1225
+ expect(url).toContain("keyterm=Kuralle+Suite");
1226
+ });
1227
+
1228
+ it("omits keyterm from the URL when not configured", async () => {
1229
+ const connectionUrls: string[] = [];
1230
+ const server = await new Promise<WebSocketServer>((resolve) => {
1231
+ let nextServer: WebSocketServer;
1232
+ nextServer = new WebSocketServer({ port: 0 }, () => resolve(nextServer));
1233
+ });
1234
+ servers.push(server);
1235
+ server.on("connection", (_socket, req) => {
1236
+ connectionUrls.push(req.url ?? "");
1237
+ });
1238
+ const address = server.address();
1239
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
1240
+ const endpointUrl = `ws://127.0.0.1:${address.port}/listen`;
1241
+
1242
+ const bus = new PipelineBusImpl();
1243
+ const started = startBus(bus);
1244
+ const plugin = new DeepgramSTTPlugin();
1245
+ await plugin.initialize(bus, {
1246
+ api_key: "test",
1247
+ endpoint_url: endpointUrl,
1248
+ sample_rate: 16000,
1249
+ });
1250
+ await waitFor(connectionUrls);
1251
+ await plugin.close();
1252
+ bus.stop();
1253
+ await started;
1254
+
1255
+ expect(connectionUrls[0]!).not.toContain("keyterm=");
1256
+ });
1153
1257
  });
package/src/stt.ts CHANGED
@@ -42,6 +42,24 @@ interface ProviderTranscriptState {
42
42
  finalConfidence: number;
43
43
  }
44
44
 
45
+ /**
46
+ * A retired-contextId set (finalized turns) must stay bounded: contextIds are
47
+ * per-turn on every transport (telephony rotates `<callSid>-t<n>`), so an
48
+ * unbounded set would grow one entry per turn for the life of a call. Keeping a
49
+ * recent-turns window is enough to reject the late/duplicate provider finals the
50
+ * set exists to guard against, without leaking memory over a long conversation.
51
+ */
52
+ const MAX_RETIRED_CONTEXTS = 256;
53
+
54
+ function boundedAdd(set: Set<string>, value: string, cap: number): void {
55
+ set.add(value);
56
+ while (set.size > cap) {
57
+ const oldest = set.values().next().value;
58
+ if (oldest === undefined) break;
59
+ set.delete(oldest);
60
+ }
61
+ }
62
+
45
63
  export class DeepgramSTTPlugin implements VoicePlugin {
46
64
  readonly endpointingCapability = {
47
65
  owner: "provider_stt" as const,
@@ -61,6 +79,10 @@ export class DeepgramSTTPlugin implements VoicePlugin {
61
79
  private smartFormat: boolean = true;
62
80
  private interimResults: boolean = true;
63
81
  private vadEvents: boolean = true;
82
+ // Deepgram gap-based utterance boundary (ms). When > 0 (requires interim_results),
83
+ // Deepgram emits UtteranceEnd even when `speech_final` never fires — the documented
84
+ // backstop for noisy/continuous lines (telephony) where a turn would otherwise wedge.
85
+ private utteranceEndMs: number = 0;
64
86
  private confidenceThreshold: number = 0;
65
87
  private finalizeOnSpeechFinal: boolean = true;
66
88
  private emitEosOnFinal: boolean = true;
@@ -77,6 +99,9 @@ export class DeepgramSTTPlugin implements VoicePlugin {
77
99
  // "never promote unconfirmed" behavior for callers that need it).
78
100
  private finalizeTimeoutFallback: boolean = false;
79
101
  private keepAliveIntervalMs: number = 3000;
102
+ // nova-3 keyterm prompting: bias recognition toward domain terms (names, products,
103
+ // codes) — the #1 production voice failure is mishearing exactly these.
104
+ private keyterms: readonly string[] = [];
80
105
 
81
106
  // Session-long WebSocket, managed by the shared connection (reconnect, keepalive).
82
107
  private conn: WebSocketConnection | null = null;
@@ -117,6 +142,11 @@ export class DeepgramSTTPlugin implements VoicePlugin {
117
142
  // laggier speech-start signal corrupts VAD/EOS-owned turn-taking (the turn
118
143
  // never completes) — proven by the Fly telephony spike.
119
144
  this.vadEvents = (config["vad_events"] as boolean) ?? false;
145
+ // Deepgram requires utterance_end_ms >= 1000 and interim_results; clamp to that.
146
+ {
147
+ const raw = (config["utterance_end_ms"] as number) ?? 0;
148
+ this.utteranceEndMs = raw > 0 ? Math.max(1000, raw) : 0;
149
+ }
120
150
  this.confidenceThreshold =
121
151
  (config["confidence_threshold"] as number) ?? 0;
122
152
  this.finalizeOnSpeechFinal = (config["finalize_on_speech_final"] as boolean) ?? true;
@@ -125,6 +155,14 @@ export class DeepgramSTTPlugin implements VoicePlugin {
125
155
  this.finalizeResetThreshold = (config["finalize_reset_threshold"] as number) ?? 2;
126
156
  this.finalizeTimeoutFallback = (config["finalize_timeout_fallback"] as boolean) ?? false;
127
157
  this.keepAliveIntervalMs = (config["keep_alive_interval_ms"] as number) ?? 3000;
158
+ {
159
+ const raw = config["keyterm"];
160
+ this.keyterms = Array.isArray(raw)
161
+ ? raw.filter((t): t is string => typeof t === "string" && t.length > 0)
162
+ : typeof raw === "string" && raw.length > 0
163
+ ? [raw]
164
+ : [];
165
+ }
128
166
  this.audioFormat = { encoding: "pcm_s16le", sampleRateHz: this.sampleRate, channels: 1 };
129
167
  assertAudioFormat(this.audioFormat);
130
168
 
@@ -142,7 +180,9 @@ export class DeepgramSTTPlugin implements VoicePlugin {
142
180
  channels: "1",
143
181
  no_delay: "true",
144
182
  vad_events: String(this.vadEvents),
183
+ ...(this.utteranceEndMs > 0 ? { utterance_end_ms: String(this.utteranceEndMs) } : {}),
145
184
  });
185
+ for (const term of this.keyterms) params.append("keyterm", term);
146
186
  const separator = this.endpointUrl.includes("?") ? "&" : "?";
147
187
  return `${this.endpointUrl}${separator}${params.toString()}`;
148
188
  },
@@ -231,6 +271,30 @@ export class DeepgramSTTPlugin implements VoicePlugin {
231
271
  return;
232
272
  }
233
273
 
274
+ // Deepgram's gap-based utterance boundary (utterance_end_ms). This fires even
275
+ // when `speech_final` is missed on a noisy/continuous line, so it's the backstop
276
+ // that stops a turn from wedging forever. If we hold un-finalized transcript for
277
+ // the current turn, complete it.
278
+ if (msg["type"] === "UtteranceEnd") {
279
+ const ctxId = this.currentContextId;
280
+ if (
281
+ this.emitEosOnFinal &&
282
+ ctxId &&
283
+ !this.finalizedContextIds.has(ctxId) &&
284
+ this.combinedFinalTranscript(ctxId)
285
+ ) {
286
+ this.bus?.push(Route.Background, {
287
+ kind: "metric.conversation",
288
+ contextId: ctxId,
289
+ timestampMs: Date.now(),
290
+ name: "deepgram.utterance_end_backstop",
291
+ value: "1",
292
+ });
293
+ this.pushTurnComplete(ctxId);
294
+ }
295
+ return;
296
+ }
297
+
234
298
  const alt = providerAlternative(msg);
235
299
  if (!alt || typeof alt["transcript"] !== "string") return;
236
300
 
@@ -395,7 +459,7 @@ export class DeepgramSTTPlugin implements VoicePlugin {
395
459
  private pushFinal(transcript: string, confidence: number, contextId = this.currentContextId): void {
396
460
  const ctxId = contextId;
397
461
  this.resolveProviderFinalize(ctxId);
398
- this.finalizedContextIds.add(ctxId);
462
+ boundedAdd(this.finalizedContextIds, ctxId, MAX_RETIRED_CONTEXTS);
399
463
  this.consecutiveFinalizeTimeouts = 0;
400
464
  this.pushMetric(ctxId, "stt_audio_sent", this.audioStats(ctxId));
401
465
  this.pushResult(transcript, confidence, ctxId);
@@ -416,7 +480,7 @@ export class DeepgramSTTPlugin implements VoicePlugin {
416
480
  const transcript = this.combinedFinalTranscript(contextId);
417
481
  if (!transcript || !this.bus) return;
418
482
  this.resolveProviderFinalize(contextId);
419
- this.finalizedContextIds.add(contextId);
483
+ boundedAdd(this.finalizedContextIds, contextId, MAX_RETIRED_CONTEXTS);
420
484
  this.consecutiveFinalizeTimeouts = 0;
421
485
  this.pushMetric(contextId, "stt_audio_sent", this.audioStats(contextId));
422
486
  this.bus.push(Route.Main, {
package/src/tts.ts CHANGED
@@ -40,6 +40,19 @@ const FLUSH_MSG = JSON.stringify({ type: "Flush" });
40
40
  const CLEAR_MSG = JSON.stringify({ type: "Clear" });
41
41
  const EMPTY = new Uint8Array(0);
42
42
  const KEEP_ALIVE_INTERVAL_MS = 10_000;
43
+ // Retired-turn guard: contextIds are per-turn (telephony rotates), so cancelled
44
+ // ids accumulate one per barge-in. Keep a recent-turns window to reject late
45
+ // post-cancel audio without leaking over a long call.
46
+ const MAX_CANCELLED_CONTEXTS = 256;
47
+
48
+ function boundedAdd(set: Set<string>, value: string, cap: number): void {
49
+ set.add(value);
50
+ while (set.size > cap) {
51
+ const oldest = set.values().next().value;
52
+ if (oldest === undefined) break;
53
+ set.delete(oldest);
54
+ }
55
+ }
43
56
 
44
57
  export class DeepgramTTSPlugin implements VoicePlugin {
45
58
  // socketFactory is injectable so the same plugin runs on Node (default) or
@@ -165,7 +178,7 @@ export class DeepgramTTSPlugin implements VoicePlugin {
165
178
 
166
179
  private async cancelActiveContexts(): Promise<void> {
167
180
  const contextIds = [...this.activeContexts];
168
- for (const contextId of contextIds) this.cancelledContexts.add(contextId);
181
+ for (const contextId of contextIds) boundedAdd(this.cancelledContexts, contextId, MAX_CANCELLED_CONTEXTS);
169
182
  this.activeContexts.clear();
170
183
  for (const contextId of contextIds) this.clearFinishTimeout(contextId);
171
184
  this.currentContextId = "";