@kuralle-syrinx/deepgram 4.0.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 +3 -3
- package/src/flux.test.ts +227 -0
- package/src/flux.ts +294 -0
- package/src/index.ts +1 -0
- package/src/stt.test.ts +63 -0
- package/src/stt.ts +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/deepgram",
|
|
3
|
-
"version": "4.
|
|
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": "4.
|
|
16
|
-
"@kuralle-syrinx/ws": "4.
|
|
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",
|
package/src/flux.test.ts
ADDED
|
@@ -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
package/src/stt.test.ts
CHANGED
|
@@ -1191,4 +1191,67 @@ describe("DeepgramSTTPlugin provider speech-start (vad_events)", () => {
|
|
|
1191
1191
|
|
|
1192
1192
|
expect(speechStarts).toHaveLength(0);
|
|
1193
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
|
+
});
|
|
1194
1257
|
});
|
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
|
},
|