@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 +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 +104 -0
- package/src/stt.ts +66 -2
- package/src/tts.ts +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/deepgram",
|
|
3
|
-
"version": "
|
|
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": "
|
|
16
|
-
"@kuralle-syrinx/ws": "
|
|
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
|
@@ -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
|
|
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
|
|
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
|
|
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 = "";
|