@kuralle-syrinx/deepgram 4.4.1 → 4.5.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/dist/flux.d.ts +43 -0
- package/dist/flux.d.ts.map +1 -0
- package/dist/flux.js +308 -0
- package/dist/flux.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/stt.d.ts +49 -0
- package/dist/stt.d.ts.map +1 -0
- package/dist/stt.js +733 -0
- package/dist/stt.js.map +1 -0
- package/dist/tts.d.ts +44 -0
- package/dist/tts.d.ts.map +1 -0
- package/dist/tts.js +375 -0
- package/dist/tts.js.map +1 -0
- package/package.json +26 -10
- package/src/flux.test.ts +355 -0
- package/src/stt.test.ts +1784 -0
- package/src/tts.test.ts +454 -0
package/src/flux.test.ts
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
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
|
+
type UsageRecordedPacket,
|
|
14
|
+
} from "@kuralle-syrinx/core";
|
|
15
|
+
|
|
16
|
+
import { DeepgramFluxSTTPlugin } from "./flux.js";
|
|
17
|
+
|
|
18
|
+
let servers: WebSocketServer[] = [];
|
|
19
|
+
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
await Promise.all(
|
|
22
|
+
servers.splice(0).map(
|
|
23
|
+
(server) =>
|
|
24
|
+
new Promise<void>((resolve) => {
|
|
25
|
+
for (const client of server.clients) client.terminate();
|
|
26
|
+
server.close(() => resolve());
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
interface LocalServer {
|
|
33
|
+
endpointUrl: string;
|
|
34
|
+
connectionUrls: string[];
|
|
35
|
+
sockets: WebSocket[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function createLocalServer(): Promise<LocalServer> {
|
|
39
|
+
const server = await new Promise<WebSocketServer>((resolve) => {
|
|
40
|
+
let nextServer: WebSocketServer;
|
|
41
|
+
nextServer = new WebSocketServer({ port: 0 }, () => resolve(nextServer));
|
|
42
|
+
});
|
|
43
|
+
servers.push(server);
|
|
44
|
+
const state: LocalServer = { endpointUrl: "", connectionUrls: [], sockets: [] };
|
|
45
|
+
server.on("connection", (socket, req) => {
|
|
46
|
+
state.connectionUrls.push(req.url ?? "");
|
|
47
|
+
state.sockets.push(socket);
|
|
48
|
+
});
|
|
49
|
+
const address = server.address();
|
|
50
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP server address");
|
|
51
|
+
state.endpointUrl = `ws://127.0.0.1:${address.port}/v2/listen`;
|
|
52
|
+
return state;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function waitFor<T>(items: T[], count = 1): Promise<void> {
|
|
56
|
+
for (let i = 0; i < 100; i += 1) {
|
|
57
|
+
if (items.length >= count) return;
|
|
58
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function turnInfo(event: string, extra: Record<string, unknown> = {}): string {
|
|
63
|
+
return JSON.stringify({
|
|
64
|
+
type: "TurnInfo",
|
|
65
|
+
event,
|
|
66
|
+
turn_index: 0,
|
|
67
|
+
audio_window_start: 0,
|
|
68
|
+
audio_window_end: 0.6,
|
|
69
|
+
transcript: "",
|
|
70
|
+
words: [],
|
|
71
|
+
end_of_turn_confidence: 0.5,
|
|
72
|
+
...extra,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function startPlugin(
|
|
77
|
+
local: LocalServer,
|
|
78
|
+
config: Record<string, unknown> = {},
|
|
79
|
+
): Promise<{ bus: PipelineBusImpl; plugin: DeepgramFluxSTTPlugin; started: Promise<void> }> {
|
|
80
|
+
const bus = new PipelineBusImpl();
|
|
81
|
+
const started = bus.start();
|
|
82
|
+
const plugin = new DeepgramFluxSTTPlugin();
|
|
83
|
+
await plugin.initialize(bus, {
|
|
84
|
+
api_key: "test",
|
|
85
|
+
endpoint_url: local.endpointUrl,
|
|
86
|
+
sample_rate: 16000,
|
|
87
|
+
...config,
|
|
88
|
+
});
|
|
89
|
+
await waitFor(local.sockets);
|
|
90
|
+
// Feed one audio packet so the plugin learns the current contextId.
|
|
91
|
+
bus.push(Route.Main, {
|
|
92
|
+
kind: "stt.audio",
|
|
93
|
+
contextId: "turn-1",
|
|
94
|
+
timestampMs: Date.now(),
|
|
95
|
+
audio: new Uint8Array(320),
|
|
96
|
+
});
|
|
97
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
98
|
+
return { bus, plugin, started };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("DeepgramFluxSTTPlugin", () => {
|
|
102
|
+
it("connects with Flux params and forwards keyterm + eager threshold", async () => {
|
|
103
|
+
const local = await createLocalServer();
|
|
104
|
+
const { bus, plugin, started } = await startPlugin(local, {
|
|
105
|
+
eot_threshold: 0.85,
|
|
106
|
+
eager_eot_threshold: 0.4,
|
|
107
|
+
eot_timeout_ms: 7000,
|
|
108
|
+
keyterm: ["Syrinx"],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await plugin.close();
|
|
112
|
+
bus.stop();
|
|
113
|
+
await started;
|
|
114
|
+
|
|
115
|
+
const url = local.connectionUrls[0]!;
|
|
116
|
+
expect(url).toContain("model=flux-general-en");
|
|
117
|
+
expect(url).toContain("encoding=linear16");
|
|
118
|
+
expect(url).toContain("sample_rate=16000");
|
|
119
|
+
expect(url).toContain("eot_threshold=0.85");
|
|
120
|
+
expect(url).toContain("eager_eot_threshold=0.4");
|
|
121
|
+
expect(url).toContain("eot_timeout_ms=7000");
|
|
122
|
+
expect(url).toContain("keyterm=Syrinx");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("reconfigure sends a Flux Configure control message with only the supplied fields", async () => {
|
|
126
|
+
const local = await createLocalServer();
|
|
127
|
+
const { bus, plugin, started } = await startPlugin(local, { keyterm: ["Syrinx"] });
|
|
128
|
+
const received: string[] = [];
|
|
129
|
+
local.sockets[0]!.on("message", (data, isBinary) => {
|
|
130
|
+
if (!isBinary) received.push(data.toString());
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
plugin.reconfigure({ keyterms: ["account number", "Syrinx"], eotThreshold: 0.85, eotTimeoutMs: 3000 });
|
|
134
|
+
await waitFor(received);
|
|
135
|
+
|
|
136
|
+
await plugin.close();
|
|
137
|
+
bus.stop();
|
|
138
|
+
await started;
|
|
139
|
+
|
|
140
|
+
expect(received).toHaveLength(1);
|
|
141
|
+
expect(JSON.parse(received[0]!)).toEqual({
|
|
142
|
+
type: "Configure",
|
|
143
|
+
thresholds: { eot_threshold: 0.85, eot_timeout_ms: 3000 },
|
|
144
|
+
keyterms: ["account number", "Syrinx"],
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("surfaces ConfigureSuccess / ConfigureFailure acks as metrics", async () => {
|
|
149
|
+
const local = await createLocalServer();
|
|
150
|
+
const { bus, plugin, started } = await startPlugin(local);
|
|
151
|
+
const metrics: string[] = [];
|
|
152
|
+
bus.on("metric.conversation", (pkt) => {
|
|
153
|
+
metrics.push((pkt as unknown as { name: string }).name);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
local.sockets[0]!.send(JSON.stringify({ type: "ConfigureSuccess", keyterms: ["account number"] }));
|
|
157
|
+
await waitFor(metrics);
|
|
158
|
+
local.sockets[0]!.send(JSON.stringify({ type: "ConfigureFailure", code: "INVALID_THRESHOLD", description: "bad" }));
|
|
159
|
+
await waitFor(metrics, 2);
|
|
160
|
+
|
|
161
|
+
await plugin.close();
|
|
162
|
+
bus.stop();
|
|
163
|
+
await started;
|
|
164
|
+
|
|
165
|
+
expect(metrics).toContain("stt.flux.configure_success");
|
|
166
|
+
expect(metrics).toContain("stt.flux.configure_failure");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("omits eager_eot_threshold by default (eager mode is opt-in)", async () => {
|
|
170
|
+
const local = await createLocalServer();
|
|
171
|
+
const { bus, plugin, started } = await startPlugin(local);
|
|
172
|
+
|
|
173
|
+
await plugin.close();
|
|
174
|
+
bus.stop();
|
|
175
|
+
await started;
|
|
176
|
+
|
|
177
|
+
const url = local.connectionUrls[0]!;
|
|
178
|
+
expect(url).toContain("eot_threshold=0.7");
|
|
179
|
+
expect(url).not.toContain("eager_eot_threshold=");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("maps TurnInfo events onto the bus: Update→stt.interim, EndOfTurn→stt.result+eos.turn_complete", async () => {
|
|
183
|
+
const local = await createLocalServer();
|
|
184
|
+
const { bus, plugin, started } = await startPlugin(local);
|
|
185
|
+
|
|
186
|
+
const interims: SttInterimPacket[] = [];
|
|
187
|
+
const results: SttResultPacket[] = [];
|
|
188
|
+
const turnCompletes: EndOfSpeechPacket[] = [];
|
|
189
|
+
bus.on("stt.interim", (pkt) => {
|
|
190
|
+
interims.push(pkt as SttInterimPacket);
|
|
191
|
+
});
|
|
192
|
+
bus.on("stt.result", (pkt) => {
|
|
193
|
+
results.push(pkt as SttResultPacket);
|
|
194
|
+
});
|
|
195
|
+
bus.on("eos.turn_complete", (pkt) => {
|
|
196
|
+
turnCompletes.push(pkt as EndOfSpeechPacket);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const socket = local.sockets[0]!;
|
|
200
|
+
socket.send(turnInfo("Update", { transcript: "what are" }));
|
|
201
|
+
socket.send(
|
|
202
|
+
turnInfo("EndOfTurn", {
|
|
203
|
+
transcript: "what are the lab fees",
|
|
204
|
+
words: [
|
|
205
|
+
{ word: "what", confidence: 0.99 },
|
|
206
|
+
{ word: "are", confidence: 0.97 },
|
|
207
|
+
],
|
|
208
|
+
end_of_turn_confidence: 0.91,
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
await waitFor(turnCompletes);
|
|
212
|
+
|
|
213
|
+
await plugin.close();
|
|
214
|
+
bus.stop();
|
|
215
|
+
await started;
|
|
216
|
+
|
|
217
|
+
expect(interims.map((p) => p.text)).toContain("what are");
|
|
218
|
+
expect(results).toHaveLength(1);
|
|
219
|
+
expect(results[0]!.text).toBe("what are the lab fees");
|
|
220
|
+
expect(results[0]!.confidence).toBeCloseTo(0.98, 2);
|
|
221
|
+
expect(turnCompletes).toEqual([
|
|
222
|
+
expect.objectContaining({ contextId: "turn-1", text: "what are the lab fees" }),
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("emits eos.interim on EagerEndOfTurn and eos.retracted on TurnResumed", async () => {
|
|
227
|
+
const local = await createLocalServer();
|
|
228
|
+
const { bus, plugin, started } = await startPlugin(local, { eager_eot_threshold: 0.4 });
|
|
229
|
+
|
|
230
|
+
const eagers: InterimEndOfSpeechPacket[] = [];
|
|
231
|
+
const retractions: EndOfSpeechRetractedPacket[] = [];
|
|
232
|
+
bus.on("eos.interim", (pkt) => {
|
|
233
|
+
eagers.push(pkt as InterimEndOfSpeechPacket);
|
|
234
|
+
});
|
|
235
|
+
bus.on("eos.retracted", (pkt) => {
|
|
236
|
+
retractions.push(pkt as EndOfSpeechRetractedPacket);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const socket = local.sockets[0]!;
|
|
240
|
+
socket.send(turnInfo("EagerEndOfTurn", { transcript: "book a room", end_of_turn_confidence: 0.55 }));
|
|
241
|
+
await waitFor(eagers);
|
|
242
|
+
socket.send(turnInfo("TurnResumed"));
|
|
243
|
+
await waitFor(retractions);
|
|
244
|
+
|
|
245
|
+
await plugin.close();
|
|
246
|
+
bus.stop();
|
|
247
|
+
await started;
|
|
248
|
+
|
|
249
|
+
expect(eagers).toEqual([expect.objectContaining({ contextId: "turn-1", text: "book a room" })]);
|
|
250
|
+
expect(retractions).toEqual([expect.objectContaining({ contextId: "turn-1" })]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("emits vad.speech_started on StartOfTurn (Flux owns barge-in signalling)", async () => {
|
|
254
|
+
const local = await createLocalServer();
|
|
255
|
+
const { bus, plugin, started } = await startPlugin(local);
|
|
256
|
+
|
|
257
|
+
const speechStarts: unknown[] = [];
|
|
258
|
+
bus.on("vad.speech_started", (pkt) => {
|
|
259
|
+
speechStarts.push(pkt);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const socket = local.sockets[0]!;
|
|
263
|
+
socket.send(turnInfo("StartOfTurn", { transcript: "hello" }));
|
|
264
|
+
await waitFor(speechStarts);
|
|
265
|
+
|
|
266
|
+
await plugin.close();
|
|
267
|
+
bus.stop();
|
|
268
|
+
await started;
|
|
269
|
+
|
|
270
|
+
expect(speechStarts.length).toBe(1);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("emits usage.recorded with stage stt and audioSeconds on EndOfTurn", async () => {
|
|
274
|
+
const local = await createLocalServer();
|
|
275
|
+
const { bus, plugin, started } = await startPlugin(local);
|
|
276
|
+
// startPlugin already sent 320 bytes @ 16kHz → 320/2/16000 = 0.01 s
|
|
277
|
+
|
|
278
|
+
const usage: UsageRecordedPacket[] = [];
|
|
279
|
+
bus.on("usage.recorded", (pkt) => {
|
|
280
|
+
usage.push(pkt as UsageRecordedPacket);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const socket = local.sockets[0]!;
|
|
284
|
+
socket.send(
|
|
285
|
+
turnInfo("EndOfTurn", {
|
|
286
|
+
transcript: "hello flux",
|
|
287
|
+
words: [{ word: "hello", confidence: 0.9 }],
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
await waitFor(usage);
|
|
291
|
+
|
|
292
|
+
await plugin.close();
|
|
293
|
+
bus.stop();
|
|
294
|
+
await started;
|
|
295
|
+
|
|
296
|
+
expect(usage).toEqual([
|
|
297
|
+
expect.objectContaining({
|
|
298
|
+
kind: "usage.recorded",
|
|
299
|
+
contextId: "turn-1",
|
|
300
|
+
stage: "stt",
|
|
301
|
+
provider: "deepgram",
|
|
302
|
+
model: "flux-general-en",
|
|
303
|
+
audioSeconds: 0.01,
|
|
304
|
+
}),
|
|
305
|
+
]);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("defaults encoding=linear16 and allows encoding override", async () => {
|
|
309
|
+
const local = await createLocalServer();
|
|
310
|
+
const { bus, plugin, started } = await startPlugin(local, {
|
|
311
|
+
encoding: "mulaw",
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await plugin.close();
|
|
315
|
+
bus.stop();
|
|
316
|
+
await started;
|
|
317
|
+
|
|
318
|
+
expect(local.connectionUrls[0]!).toContain("encoding=mulaw");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("merges query_params into the Flux listen URL", async () => {
|
|
322
|
+
const local = await createLocalServer();
|
|
323
|
+
const { bus, plugin, started } = await startPlugin(local, {
|
|
324
|
+
query_params: {
|
|
325
|
+
profanity_filter: true,
|
|
326
|
+
tag: "flux-prod",
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
await plugin.close();
|
|
331
|
+
bus.stop();
|
|
332
|
+
await started;
|
|
333
|
+
|
|
334
|
+
const url = local.connectionUrls[0]!;
|
|
335
|
+
expect(url).toContain("encoding=linear16");
|
|
336
|
+
expect(url).toContain("eot_threshold=0.7");
|
|
337
|
+
expect(url).toContain("profanity_filter=true");
|
|
338
|
+
expect(url).toContain("tag=flux-prod");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("forwards language_hint as a repeatable query param", async () => {
|
|
342
|
+
const local = await createLocalServer();
|
|
343
|
+
const { bus, plugin, started } = await startPlugin(local, {
|
|
344
|
+
language_hint: ["en", "es"],
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await plugin.close();
|
|
348
|
+
bus.stop();
|
|
349
|
+
await started;
|
|
350
|
+
|
|
351
|
+
const url = local.connectionUrls[0]!;
|
|
352
|
+
expect(url).toContain("language_hint=en");
|
|
353
|
+
expect(url).toContain("language_hint=es");
|
|
354
|
+
});
|
|
355
|
+
});
|