@kuralle-syrinx/deepgram 4.0.0 → 4.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/package.json CHANGED
@@ -1,9 +1,28 @@
1
1
  {
2
2
  "name": "@kuralle-syrinx/deepgram",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "private": false,
5
- "type": "module",
5
+ "description": "Deepgram adapters for Syrinx — nova-3 STT, Flux turn-aware STT (semantic end-of-turn), and Aura TTS",
6
+ "keywords": [
7
+ "voice",
8
+ "voice-agent",
9
+ "speech",
10
+ "syrinx",
11
+ "deepgram",
12
+ "speech-to-text",
13
+ "text-to-speech"
14
+ ],
6
15
  "license": "MIT",
16
+ "homepage": "https://github.com/kuralle/syrinx#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/kuralle/syrinx.git",
20
+ "directory": "packages/deepgram"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/kuralle/syrinx/issues"
24
+ },
25
+ "type": "module",
7
26
  "main": "./src/index.ts",
8
27
  "types": "./src/index.ts",
9
28
  "exports": {
@@ -12,8 +31,8 @@
12
31
  "./tts": "./src/tts.ts"
13
32
  },
14
33
  "dependencies": {
15
- "@kuralle-syrinx/core": "4.0.0",
16
- "@kuralle-syrinx/ws": "4.0.0"
34
+ "@kuralle-syrinx/core": "4.2.0",
35
+ "@kuralle-syrinx/ws": "4.2.0"
17
36
  },
18
37
  "devDependencies": {
19
38
  "@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
@@ -7,6 +7,8 @@ import {
7
7
  Route,
8
8
  type ConversationMetricPacket,
9
9
  type SttErrorPacket,
10
+ type SttInterimPacket,
11
+ type SttPartialPacket,
10
12
  type SttResultPacket,
11
13
  } from "@kuralle-syrinx/core";
12
14
 
@@ -59,6 +61,70 @@ async function waitForValue<T>(items: T[], value: T): Promise<void> {
59
61
  }
60
62
 
61
63
  describe("DeepgramSTTPlugin", () => {
64
+ it("emits stt.partial with word timings alongside unchanged stt.interim", async () => {
65
+ const endpointUrl = await createLocalServer((socket) => {
66
+ socket.on("message", (data, isBinary) => {
67
+ if (!isBinary) return;
68
+ socket.send(JSON.stringify({
69
+ is_final: false,
70
+ channel: {
71
+ alternatives: [{
72
+ transcript: "hello there",
73
+ confidence: 0.91,
74
+ words: [
75
+ { word: "hello", start: 0.12, end: 0.48, confidence: 0.95 },
76
+ { word: "there", start: 0.5, end: 0.82, confidence: 0.88 },
77
+ ],
78
+ }],
79
+ },
80
+ }));
81
+ });
82
+ });
83
+ const bus = new PipelineBusImpl();
84
+ const started = startBus(bus);
85
+ const plugin = new DeepgramSTTPlugin();
86
+ const interims: SttInterimPacket[] = [];
87
+ const partials: SttPartialPacket[] = [];
88
+ bus.on("stt.interim", (pkt) => {
89
+ interims.push(pkt as SttInterimPacket);
90
+ });
91
+ bus.on("stt.partial", (pkt) => {
92
+ partials.push(pkt as SttPartialPacket);
93
+ });
94
+
95
+ await plugin.initialize(bus, {
96
+ api_key: "test",
97
+ endpoint_url: endpointUrl,
98
+ sample_rate: 16000,
99
+ });
100
+ bus.push(Route.Main, {
101
+ kind: "stt.audio",
102
+ contextId: "turn-1",
103
+ timestampMs: Date.now(),
104
+ audio: new Uint8Array(640),
105
+ });
106
+ await waitFor(partials);
107
+
108
+ expect(interims).toEqual([
109
+ expect.objectContaining({ kind: "stt.interim", contextId: "turn-1", text: "hello there" }),
110
+ ]);
111
+ expect(partials).toEqual([
112
+ expect.objectContaining({
113
+ kind: "stt.partial",
114
+ contextId: "turn-1",
115
+ text: "hello there",
116
+ wordTimings: [
117
+ { word: "hello", startMs: 120, endMs: 480, confidence: 0.95 },
118
+ { word: "there", startMs: 500, endMs: 820, confidence: 0.88 },
119
+ ],
120
+ }),
121
+ ]);
122
+
123
+ await plugin.close();
124
+ bus.stop();
125
+ await started;
126
+ });
127
+
62
128
  it("uses provider KeepAlive while idle and CloseStream on shutdown", async () => {
63
129
  const controlMessages: string[] = [];
64
130
  const endpointUrl = await createLocalServer((socket) => {
@@ -1191,4 +1257,67 @@ describe("DeepgramSTTPlugin provider speech-start (vad_events)", () => {
1191
1257
 
1192
1258
  expect(speechStarts).toHaveLength(0);
1193
1259
  });
1260
+
1261
+ it("passes keyterm config as repeatable query params (nova-3 keyterm prompting)", async () => {
1262
+ const connectionUrls: string[] = [];
1263
+ const server = await new Promise<WebSocketServer>((resolve) => {
1264
+ let nextServer: WebSocketServer;
1265
+ nextServer = new WebSocketServer({ port: 0 }, () => resolve(nextServer));
1266
+ });
1267
+ servers.push(server);
1268
+ server.on("connection", (_socket, req) => {
1269
+ connectionUrls.push(req.url ?? "");
1270
+ });
1271
+ const address = server.address();
1272
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
1273
+ const endpointUrl = `ws://127.0.0.1:${address.port}/listen`;
1274
+
1275
+ const bus = new PipelineBusImpl();
1276
+ const started = startBus(bus);
1277
+ const plugin = new DeepgramSTTPlugin();
1278
+ await plugin.initialize(bus, {
1279
+ api_key: "test",
1280
+ endpoint_url: endpointUrl,
1281
+ sample_rate: 16000,
1282
+ keyterm: ["Syrinx", "Kuralle Suite"],
1283
+ });
1284
+ await waitFor(connectionUrls);
1285
+ await plugin.close();
1286
+ bus.stop();
1287
+ await started;
1288
+
1289
+ const url = connectionUrls[0]!;
1290
+ expect(url).toContain("keyterm=Syrinx");
1291
+ expect(url).toContain("keyterm=Kuralle+Suite");
1292
+ });
1293
+
1294
+ it("omits keyterm from the URL when not configured", async () => {
1295
+ const connectionUrls: string[] = [];
1296
+ const server = await new Promise<WebSocketServer>((resolve) => {
1297
+ let nextServer: WebSocketServer;
1298
+ nextServer = new WebSocketServer({ port: 0 }, () => resolve(nextServer));
1299
+ });
1300
+ servers.push(server);
1301
+ server.on("connection", (_socket, req) => {
1302
+ connectionUrls.push(req.url ?? "");
1303
+ });
1304
+ const address = server.address();
1305
+ if (!address || typeof address === "string") throw new Error("Expected TCP server address");
1306
+ const endpointUrl = `ws://127.0.0.1:${address.port}/listen`;
1307
+
1308
+ const bus = new PipelineBusImpl();
1309
+ const started = startBus(bus);
1310
+ const plugin = new DeepgramSTTPlugin();
1311
+ await plugin.initialize(bus, {
1312
+ api_key: "test",
1313
+ endpoint_url: endpointUrl,
1314
+ sample_rate: 16000,
1315
+ });
1316
+ await waitFor(connectionUrls);
1317
+ await plugin.close();
1318
+ bus.stop();
1319
+ await started;
1320
+
1321
+ expect(connectionUrls[0]!).not.toContain("keyterm=");
1322
+ });
1194
1323
  });
package/src/stt.ts CHANGED
@@ -99,6 +99,9 @@ export class DeepgramSTTPlugin implements VoicePlugin {
99
99
  // "never promote unconfirmed" behavior for callers that need it).
100
100
  private finalizeTimeoutFallback: boolean = false;
101
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[] = [];
102
105
 
103
106
  // Session-long WebSocket, managed by the shared connection (reconnect, keepalive).
104
107
  private conn: WebSocketConnection | null = null;
@@ -152,6 +155,14 @@ export class DeepgramSTTPlugin implements VoicePlugin {
152
155
  this.finalizeResetThreshold = (config["finalize_reset_threshold"] as number) ?? 2;
153
156
  this.finalizeTimeoutFallback = (config["finalize_timeout_fallback"] as boolean) ?? false;
154
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
+ }
155
166
  this.audioFormat = { encoding: "pcm_s16le", sampleRateHz: this.sampleRate, channels: 1 };
156
167
  assertAudioFormat(this.audioFormat);
157
168
 
@@ -171,6 +182,7 @@ export class DeepgramSTTPlugin implements VoicePlugin {
171
182
  vad_events: String(this.vadEvents),
172
183
  ...(this.utteranceEndMs > 0 ? { utterance_end_ms: String(this.utteranceEndMs) } : {}),
173
184
  });
185
+ for (const term of this.keyterms) params.append("keyterm", term);
174
186
  const separator = this.endpointUrl.includes("?") ? "&" : "?";
175
187
  return `${this.endpointUrl}${separator}${params.toString()}`;
176
188
  },
@@ -343,7 +355,7 @@ export class DeepgramSTTPlugin implements VoicePlugin {
343
355
  this.pushTurnComplete(providerContextId);
344
356
  }
345
357
  } else {
346
- this.pushInterim(transcript, providerContextId);
358
+ this.pushInterim(transcript, providerContextId, alt);
347
359
  }
348
360
  }
349
361
 
@@ -500,12 +512,25 @@ export class DeepgramSTTPlugin implements VoicePlugin {
500
512
  }
501
513
 
502
514
  /** Emit interim transcript for real-time display. */
503
- private pushInterim(transcript: string, contextId = this.currentContextId): void {
515
+ private pushInterim(
516
+ transcript: string,
517
+ contextId = this.currentContextId,
518
+ alt: Record<string, unknown> | null = null,
519
+ ): void {
520
+ const timestampMs = Date.now();
504
521
  this.bus?.push(Route.Main, {
505
522
  kind: "stt.interim",
506
523
  contextId,
507
- timestampMs: Date.now(),
524
+ timestampMs,
525
+ text: transcript,
526
+ });
527
+ const wordTimings = mapProviderWordTimings(alt);
528
+ this.bus?.push(Route.Main, {
529
+ kind: "stt.partial",
530
+ contextId,
531
+ timestampMs,
508
532
  text: transcript,
533
+ ...(wordTimings ? { wordTimings } : {}),
509
534
  });
510
535
  }
511
536
 
@@ -739,6 +764,31 @@ async function defaultSocketFactory(): Promise<SocketFactory> {
739
764
  return mod.createNodeWsSocket;
740
765
  }
741
766
 
767
+ function mapProviderWordTimings(
768
+ alt: Record<string, unknown> | null,
769
+ ): ReadonlyArray<{ word: string; startMs: number; endMs: number; confidence: number }> | undefined {
770
+ if (!alt) return undefined;
771
+ const words = alt["words"];
772
+ if (!Array.isArray(words)) return undefined;
773
+ const timings: Array<{ word: string; startMs: number; endMs: number; confidence: number }> = [];
774
+ for (const entry of words) {
775
+ if (!entry || typeof entry !== "object") continue;
776
+ const row = entry as Record<string, unknown>;
777
+ const word = row["word"];
778
+ const start = row["start"];
779
+ const end = row["end"];
780
+ const confidence = row["confidence"];
781
+ if (typeof word !== "string" || typeof start !== "number" || typeof end !== "number") continue;
782
+ timings.push({
783
+ word,
784
+ startMs: start * 1000,
785
+ endMs: end * 1000,
786
+ confidence: typeof confidence === "number" ? confidence : 0,
787
+ });
788
+ }
789
+ return timings.length > 0 ? timings : undefined;
790
+ }
791
+
742
792
  function providerAlternative(msg: Record<string, unknown>): Record<string, unknown> | null {
743
793
  const channel = msg["channel"];
744
794
  if (!channel || typeof channel !== "object") return null;