@kuralle-syrinx/deepgram 4.2.0 → 4.4.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/src/stt.test.ts DELETED
@@ -1,1323 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { afterEach, describe, expect, it, vi } from "vitest";
4
- import { WebSocketServer, type WebSocket } from "ws";
5
- import {
6
- PipelineBusImpl,
7
- Route,
8
- type ConversationMetricPacket,
9
- type SttErrorPacket,
10
- type SttInterimPacket,
11
- type SttPartialPacket,
12
- type SttResultPacket,
13
- } from "@kuralle-syrinx/core";
14
-
15
- import { DeepgramSTTPlugin } from "./stt.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
- async function createLocalServer(onConnection: (socket: WebSocket) => void): Promise<string> {
32
- const server = await new Promise<WebSocketServer>((resolve) => {
33
- let nextServer: WebSocketServer;
34
- nextServer = new WebSocketServer({ port: 0 }, () => {
35
- resolve(nextServer);
36
- });
37
- });
38
- servers.push(server);
39
- server.on("connection", onConnection);
40
- const address = server.address();
41
- if (!address || typeof address === "string") throw new Error("Expected TCP server address");
42
- return `ws://127.0.0.1:${address.port}/listen`;
43
- }
44
-
45
- function startBus(bus: PipelineBusImpl): Promise<void> {
46
- return bus.start();
47
- }
48
-
49
- async function waitFor<T>(items: T[], count = 1): Promise<void> {
50
- for (let i = 0; i < 50; i += 1) {
51
- if (items.length >= count) return;
52
- await new Promise((resolve) => setTimeout(resolve, 10));
53
- }
54
- }
55
-
56
- async function waitForValue<T>(items: T[], value: T): Promise<void> {
57
- for (let i = 0; i < 50; i += 1) {
58
- if (items.includes(value)) return;
59
- await new Promise((resolve) => setTimeout(resolve, 10));
60
- }
61
- }
62
-
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
-
128
- it("uses provider KeepAlive while idle and CloseStream on shutdown", async () => {
129
- const controlMessages: string[] = [];
130
- const endpointUrl = await createLocalServer((socket) => {
131
- socket.on("message", (data, isBinary) => {
132
- if (isBinary) return;
133
- const msg = JSON.parse(data.toString()) as { type?: string };
134
- if (msg.type) controlMessages.push(msg.type);
135
- });
136
- });
137
- const bus = new PipelineBusImpl();
138
- const started = startBus(bus);
139
- const plugin = new DeepgramSTTPlugin();
140
-
141
- await plugin.initialize(bus, {
142
- api_key: "test",
143
- endpoint_url: endpointUrl,
144
- sample_rate: 16000,
145
- keep_alive_interval_ms: 10,
146
- });
147
- await waitFor(controlMessages);
148
- await plugin.close();
149
- await waitForValue(controlMessages, "CloseStream");
150
- bus.stop();
151
- await started;
152
-
153
- expect(controlMessages).toContain("KeepAlive");
154
- expect(controlMessages.at(-1)).toBe("CloseStream");
155
- });
156
-
157
- it("emits provider-final segments immediately while still allowing explicit finalization", async () => {
158
- let finalizeMessages = 0;
159
- const endpointUrl = await createLocalServer((socket) => {
160
- socket.on("message", (data, isBinary) => {
161
- if (isBinary) {
162
- socket.send(JSON.stringify({
163
- is_final: true,
164
- speech_final: true,
165
- channel: { alternatives: [{ transcript: "premature", confidence: 0.8 }] },
166
- }));
167
- return;
168
- }
169
- const msg = JSON.parse(data.toString()) as { type?: string };
170
- if (msg.type !== "Finalize") return;
171
- finalizeMessages += 1;
172
- socket.send(JSON.stringify({
173
- is_final: true,
174
- speech_final: false,
175
- channel: { alternatives: [{ transcript: "partial", confidence: 0.7 }] },
176
- }));
177
- setTimeout(() => {
178
- socket.send(JSON.stringify({
179
- is_final: true,
180
- speech_final: false,
181
- from_finalize: true,
182
- channel: { alternatives: [{ transcript: "confirmed", confidence: 0.95 }] },
183
- }));
184
- }, 5);
185
- });
186
- });
187
- const bus = new PipelineBusImpl();
188
- const started = startBus(bus);
189
- const plugin = new DeepgramSTTPlugin();
190
- const finals: SttResultPacket[] = [];
191
- const metrics: ConversationMetricPacket[] = [];
192
- bus.on("stt.result", (pkt) => {
193
- finals.push(pkt as SttResultPacket);
194
- });
195
- bus.on("metric.conversation", (pkt) => {
196
- metrics.push(pkt as ConversationMetricPacket);
197
- });
198
-
199
- await plugin.initialize(bus, {
200
- api_key: "test",
201
- endpoint_url: endpointUrl,
202
- sample_rate: 16000,
203
- finalize_on_speech_final: false,
204
- provider_finalize_timeout_ms: 0,
205
- emit_eos_on_final: false,
206
- });
207
- bus.push(Route.Main, {
208
- kind: "stt.audio",
209
- contextId: "turn-1",
210
- timestampMs: Date.now(),
211
- audio: new Uint8Array(640),
212
- });
213
- await new Promise((resolve) => setTimeout(resolve, 30));
214
- expect(finals).toEqual([
215
- expect.objectContaining({
216
- kind: "stt.result",
217
- contextId: "turn-1",
218
- text: "premature",
219
- confidence: 0.8,
220
- }),
221
- ]);
222
-
223
- plugin.forceFinalize("turn-1");
224
- await new Promise((resolve) => setTimeout(resolve, 30));
225
- expect(finals).toEqual([
226
- expect.objectContaining({ contextId: "turn-1", text: "premature", confidence: 0.8 }),
227
- expect.objectContaining({ contextId: "turn-1", text: "partial", confidence: 0.7 }),
228
- expect.objectContaining({ contextId: "turn-1", text: "confirmed", confidence: 0.95 }),
229
- ]);
230
- expect(finalizeMessages).toBe(1);
231
- expect(metrics).toEqual(expect.arrayContaining([
232
- expect.objectContaining({
233
- name: "stt_provider_finalize_requested",
234
- contextId: "turn-1",
235
- value: expect.stringContaining("\"bytes\":640"),
236
- }),
237
- ]));
238
-
239
- await plugin.close();
240
- bus.stop();
241
- await started;
242
- });
243
-
244
- it("completes a wedged turn via UtteranceEnd when speech_final never fires (noisy-line backstop)", async () => {
245
- // A noisy/continuous line: Deepgram sends an is_final segment but never
246
- // speech_final. UtteranceEnd (gap-based) must complete the turn so it can't wedge.
247
- const endpointUrl = await createLocalServer((socket) => {
248
- socket.on("message", (data, isBinary) => {
249
- if (isBinary) {
250
- socket.send(JSON.stringify({
251
- is_final: true,
252
- speech_final: false, // never speech_final on this noisy line
253
- channel: { alternatives: [{ transcript: "book a room", confidence: 0.9 }] },
254
- }));
255
- setTimeout(() => socket.send(JSON.stringify({ type: "UtteranceEnd", last_word_end: 1.2 })), 5);
256
- return;
257
- }
258
- });
259
- });
260
- const bus = new PipelineBusImpl();
261
- const started = startBus(bus);
262
- const plugin = new DeepgramSTTPlugin();
263
- const turnCompletes: Array<{ text: string; contextId: string }> = [];
264
- bus.on("eos.turn_complete", (pkt) => {
265
- const p = pkt as unknown as { text: string; contextId: string };
266
- turnCompletes.push({ text: p.text, contextId: p.contextId });
267
- });
268
-
269
- await plugin.initialize(bus, {
270
- api_key: "test",
271
- endpoint_url: endpointUrl,
272
- sample_rate: 16000,
273
- utterance_end_ms: 1000,
274
- });
275
- bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-1", timestampMs: Date.now(), audio: new Uint8Array(640) });
276
- await new Promise((resolve) => setTimeout(resolve, 40));
277
-
278
- expect(turnCompletes).toEqual([{ text: "book a room", contextId: "turn-1" }]);
279
-
280
- await plugin.close();
281
- bus.stop();
282
- await started;
283
- });
284
-
285
- it("emits trailing provider-final segments after Pipecat requests finalize", async () => {
286
- const endpointUrl = await createLocalServer((socket) => {
287
- socket.on("message", (data, isBinary) => {
288
- if (isBinary) {
289
- socket.send(JSON.stringify({
290
- is_final: true,
291
- speech_final: false,
292
- channel: { alternatives: [{ transcript: "first phrase", confidence: 0.8 }] },
293
- }));
294
- return;
295
- }
296
- const msg = JSON.parse(data.toString()) as { type?: string };
297
- if (msg.type !== "Finalize") return;
298
- setTimeout(() => {
299
- socket.send(JSON.stringify({
300
- is_final: true,
301
- speech_final: true,
302
- channel: { alternatives: [{ transcript: "trailing phrase", confidence: 0.9 }] },
303
- }));
304
- }, 10);
305
- });
306
- });
307
- const bus = new PipelineBusImpl();
308
- const started = startBus(bus);
309
- const plugin = new DeepgramSTTPlugin();
310
- const finals: SttResultPacket[] = [];
311
- bus.on("stt.result", (pkt) => {
312
- finals.push(pkt as SttResultPacket);
313
- });
314
-
315
- await plugin.initialize(bus, {
316
- api_key: "test",
317
- endpoint_url: endpointUrl,
318
- sample_rate: 16000,
319
- finalize_on_speech_final: false,
320
- provider_finalize_timeout_ms: 100,
321
- emit_eos_on_final: false,
322
- });
323
- bus.push(Route.Main, {
324
- kind: "stt.audio",
325
- contextId: "turn-2",
326
- timestampMs: Date.now(),
327
- audio: new Uint8Array(640),
328
- });
329
- await new Promise((resolve) => setTimeout(resolve, 20));
330
- expect(finals).toEqual([
331
- expect.objectContaining({
332
- contextId: "turn-2",
333
- text: "first phrase",
334
- confidence: 0.8,
335
- }),
336
- ]);
337
-
338
- plugin.forceFinalize("turn-2");
339
- await new Promise((resolve) => setTimeout(resolve, 30));
340
- expect(finals).toEqual([
341
- expect.objectContaining({
342
- contextId: "turn-2",
343
- text: "first phrase",
344
- confidence: 0.8,
345
- }),
346
- expect.objectContaining({
347
- contextId: "turn-2",
348
- text: "trailing phrase",
349
- confidence: 0.9,
350
- }),
351
- ]);
352
-
353
- await plugin.close();
354
- bus.stop();
355
- await started;
356
- });
357
-
358
- it("turns malformed provider messages into STT errors instead of dropping them", async () => {
359
- const endpointUrl = await createLocalServer((socket) => {
360
- socket.on("message", (_data, isBinary) => {
361
- if (isBinary) socket.send("{not-json");
362
- });
363
- });
364
- const bus = new PipelineBusImpl();
365
- const started = startBus(bus);
366
- const plugin = new DeepgramSTTPlugin();
367
- const errors: SttErrorPacket[] = [];
368
- bus.on("stt.error", (pkt) => {
369
- errors.push(pkt as SttErrorPacket);
370
- });
371
-
372
- await plugin.initialize(bus, {
373
- api_key: "test",
374
- endpoint_url: endpointUrl,
375
- sample_rate: 16000,
376
- });
377
- bus.push(Route.Main, {
378
- kind: "stt.audio",
379
- contextId: "turn-malformed",
380
- timestampMs: Date.now(),
381
- audio: new Uint8Array(640),
382
- });
383
-
384
- await waitFor(errors);
385
- expect(errors).toEqual([
386
- expect.objectContaining({
387
- kind: "stt.error",
388
- contextId: "turn-malformed",
389
- component: "stt",
390
- isRecoverable: false,
391
- cause: expect.objectContaining({
392
- message: expect.stringContaining("Deepgram STT provider sent malformed JSON"),
393
- }),
394
- }),
395
- ]);
396
-
397
- await plugin.close();
398
- bus.stop();
399
- await started;
400
- });
401
-
402
- it("surfaces Deepgram provider error frames as STT errors", async () => {
403
- const endpointUrl = await createLocalServer((socket) => {
404
- socket.on("message", (_data, isBinary) => {
405
- if (!isBinary) return;
406
- socket.send(JSON.stringify({
407
- type: "Error",
408
- code: "FAILED_TO_START_LISTENING",
409
- description: "Failed to open the listen connection.",
410
- request_id: "req-1",
411
- }));
412
- });
413
- });
414
- const bus = new PipelineBusImpl();
415
- const started = startBus(bus);
416
- const plugin = new DeepgramSTTPlugin();
417
- const errors: SttErrorPacket[] = [];
418
- bus.on("stt.error", (pkt) => {
419
- errors.push(pkt as SttErrorPacket);
420
- });
421
-
422
- await plugin.initialize(bus, {
423
- api_key: "test",
424
- endpoint_url: endpointUrl,
425
- sample_rate: 16000,
426
- });
427
- bus.push(Route.Main, {
428
- kind: "stt.audio",
429
- contextId: "turn-provider-error",
430
- timestampMs: Date.now(),
431
- audio: new Uint8Array(640),
432
- });
433
-
434
- await waitFor(errors);
435
- expect(errors).toEqual([
436
- expect.objectContaining({
437
- kind: "stt.error",
438
- contextId: "turn-provider-error",
439
- component: "stt",
440
- cause: expect.objectContaining({
441
- message: expect.stringContaining("FAILED_TO_START_LISTENING"),
442
- }),
443
- }),
444
- ]);
445
-
446
- await plugin.close();
447
- bus.stop();
448
- await started;
449
- });
450
-
451
- it("surfaces unexpected Deepgram websocket close frames as STT errors", async () => {
452
- const endpointUrl = await createLocalServer((socket) => {
453
- socket.on("message", (_data, isBinary) => {
454
- if (isBinary) socket.close(1011, "NET-0000");
455
- });
456
- });
457
- const bus = new PipelineBusImpl();
458
- const started = startBus(bus);
459
- const plugin = new DeepgramSTTPlugin();
460
- const errors: SttErrorPacket[] = [];
461
- bus.on("stt.error", (pkt) => {
462
- errors.push(pkt as SttErrorPacket);
463
- });
464
-
465
- await plugin.initialize(bus, {
466
- api_key: "test",
467
- endpoint_url: endpointUrl,
468
- sample_rate: 16000,
469
- });
470
- bus.push(Route.Main, {
471
- kind: "stt.audio",
472
- contextId: "turn-close",
473
- timestampMs: Date.now(),
474
- audio: new Uint8Array(640),
475
- });
476
-
477
- await waitFor(errors);
478
- expect(errors).toEqual(expect.arrayContaining([
479
- expect.objectContaining({
480
- kind: "stt.error",
481
- contextId: "turn-close",
482
- component: "stt",
483
- isRecoverable: true,
484
- cause: expect.objectContaining({
485
- message: expect.stringContaining("code=1011 reason=NET-0000"),
486
- }),
487
- }),
488
- ]));
489
-
490
- await plugin.close();
491
- bus.stop();
492
- await started;
493
- });
494
-
495
- it("discards unconfirmed provider transcript state across recoverable websocket reconnect", async () => {
496
- const connections: WebSocket[] = [];
497
- const endpointUrl = await createLocalServer((socket) => {
498
- connections.push(socket);
499
- if (connections.length === 1) {
500
- socket.on("message", (_data, isBinary) => {
501
- if (!isBinary) return;
502
- socket.send(JSON.stringify({
503
- is_final: true,
504
- speech_final: false,
505
- channel: { alternatives: [{ transcript: "stale pre reconnect", confidence: 0.8 }] },
506
- }));
507
- socket.close(1011, "NET-0000");
508
- });
509
- return;
510
- }
511
- socket.on("message", (_data, isBinary) => {
512
- if (!isBinary) return;
513
- socket.send(JSON.stringify({
514
- is_final: true,
515
- speech_final: true,
516
- channel: { alternatives: [{ transcript: "fresh after reconnect", confidence: 0.9 }] },
517
- }));
518
- });
519
- });
520
- const bus = new PipelineBusImpl();
521
- const started = startBus(bus);
522
- const plugin = new DeepgramSTTPlugin();
523
- const finals: SttResultPacket[] = [];
524
- const metrics: ConversationMetricPacket[] = [];
525
- bus.on("stt.result", (pkt) => {
526
- finals.push(pkt as SttResultPacket);
527
- });
528
- bus.on("metric.conversation", (pkt) => {
529
- metrics.push(pkt as ConversationMetricPacket);
530
- });
531
-
532
- await plugin.initialize(bus, {
533
- api_key: "test",
534
- endpoint_url: endpointUrl,
535
- sample_rate: 16000,
536
- });
537
- bus.push(Route.Main, {
538
- kind: "stt.audio",
539
- contextId: "turn-before-reconnect",
540
- timestampMs: Date.now(),
541
- audio: new Uint8Array(640),
542
- });
543
- await waitFor(connections, 2);
544
-
545
- bus.push(Route.Main, {
546
- kind: "stt.audio",
547
- contextId: "turn-after-reconnect",
548
- timestampMs: Date.now(),
549
- audio: new Uint8Array(640),
550
- });
551
- await waitFor(finals, 2);
552
-
553
- expect(finals).toEqual([
554
- expect.objectContaining({
555
- contextId: "turn-before-reconnect",
556
- text: "stale pre reconnect",
557
- }),
558
- expect.objectContaining({
559
- contextId: "turn-after-reconnect",
560
- text: "fresh after reconnect",
561
- }),
562
- ]);
563
- expect(metrics).toEqual(expect.arrayContaining([
564
- expect.objectContaining({
565
- name: "stt_provider_reconnect_discarded_state",
566
- contextId: "turn-before-reconnect",
567
- }),
568
- ]));
569
-
570
- await plugin.close();
571
- bus.stop();
572
- await started;
573
- });
574
-
575
- it("does not record STT audio bytes when the Deepgram websocket cannot accept the frame", async () => {
576
- const endpointUrl = await createLocalServer(() => {});
577
- const bus = new PipelineBusImpl();
578
- const started = startBus(bus);
579
- const plugin = new DeepgramSTTPlugin();
580
- const errors: SttErrorPacket[] = [];
581
- const metrics: ConversationMetricPacket[] = [];
582
- bus.on("stt.error", (pkt) => {
583
- errors.push(pkt as SttErrorPacket);
584
- });
585
- bus.on("metric.conversation", (pkt) => {
586
- metrics.push(pkt as ConversationMetricPacket);
587
- });
588
-
589
- await plugin.initialize(bus, {
590
- api_key: "test",
591
- endpoint_url: endpointUrl,
592
- sample_rate: 16000,
593
- });
594
- // Simulate a closed socket: the managed connection's send throws.
595
- const send = vi.fn(() => {
596
- throw new Error("WebSocket is not open");
597
- });
598
- Object.assign(plugin as unknown as { conn: { ensureReady: () => Promise<void>; send: typeof send; isReady: boolean; close: () => Promise<void> } }, {
599
- conn: { ensureReady: async () => undefined, send, isReady: false, close: async () => undefined },
600
- });
601
- bus.push(Route.Main, {
602
- kind: "stt.audio",
603
- contextId: "turn-unsent",
604
- timestampMs: Date.now(),
605
- audio: new Uint8Array(640),
606
- });
607
-
608
- await waitFor(errors);
609
- expect(send).toHaveBeenCalled();
610
- expect(errors).toEqual([
611
- expect.objectContaining({
612
- kind: "stt.error",
613
- contextId: "turn-unsent",
614
- component: "stt",
615
- cause: expect.objectContaining({
616
- message: "WebSocket is not open",
617
- }),
618
- }),
619
- ]);
620
-
621
- plugin.forceFinalize("turn-unsent");
622
- await waitFor(metrics);
623
- expect(metrics).toEqual(expect.arrayContaining([
624
- expect.objectContaining({
625
- name: "stt_provider_finalize_requested",
626
- contextId: "turn-unsent",
627
- value: expect.stringContaining("\"bytes\":0"),
628
- }),
629
- ]));
630
-
631
- await plugin.close();
632
- bus.stop();
633
- await started;
634
- });
635
-
636
- it("times out interim-only provider text when Finalize is not confirmed", async () => {
637
- const endpointUrl = await createLocalServer((socket) => {
638
- socket.on("message", (data, isBinary) => {
639
- if (isBinary) {
640
- socket.send(JSON.stringify({
641
- is_final: false,
642
- speech_final: false,
643
- channel: { alternatives: [{ transcript: "unconfirmed segment", confidence: 0.8 }] },
644
- }));
645
- return;
646
- }
647
- const msg = JSON.parse(data.toString()) as { type?: string };
648
- if (msg.type !== "Finalize") return;
649
- });
650
- });
651
- const bus = new PipelineBusImpl();
652
- const started = startBus(bus);
653
- const plugin = new DeepgramSTTPlugin();
654
- const finals: SttResultPacket[] = [];
655
- const errors: SttErrorPacket[] = [];
656
- const metrics: ConversationMetricPacket[] = [];
657
- bus.on("stt.result", (pkt) => {
658
- finals.push(pkt as SttResultPacket);
659
- });
660
- bus.on("stt.error", (pkt) => {
661
- errors.push(pkt as SttErrorPacket);
662
- });
663
- bus.on("metric.conversation", (pkt) => {
664
- metrics.push(pkt as ConversationMetricPacket);
665
- });
666
-
667
- await plugin.initialize(bus, {
668
- api_key: "test",
669
- endpoint_url: endpointUrl,
670
- sample_rate: 16000,
671
- finalize_on_speech_final: false,
672
- provider_finalize_timeout_ms: 10,
673
- emit_eos_on_final: false,
674
- });
675
- bus.push(Route.Main, {
676
- kind: "stt.audio",
677
- contextId: "turn-unconfirmed",
678
- timestampMs: Date.now(),
679
- audio: new Uint8Array(640),
680
- });
681
- await new Promise((resolve) => setTimeout(resolve, 20));
682
-
683
- plugin.forceFinalize("turn-unconfirmed");
684
- await waitFor(errors);
685
-
686
- expect(finals).toHaveLength(0);
687
- expect(errors).toEqual([
688
- expect.objectContaining({
689
- kind: "stt.error",
690
- contextId: "turn-unconfirmed",
691
- isRecoverable: true,
692
- cause: expect.objectContaining({
693
- message: "Deepgram STT Finalize timed out before speech_final/from_finalize confirmation",
694
- }),
695
- }),
696
- ]);
697
- expect(metrics).toEqual(expect.arrayContaining([
698
- expect.objectContaining({
699
- name: "stt_provider_finalize_timeout",
700
- contextId: "turn-unconfirmed",
701
- value: expect.stringContaining("\"bytes\":640"),
702
- }),
703
- ]));
704
-
705
- await plugin.close();
706
- bus.stop();
707
- await started;
708
- });
709
-
710
- it("emits provider is_final text without waiting for speech_final or explicit Finalize confirmation", async () => {
711
- const endpointUrl = await createLocalServer((socket) => {
712
- socket.on("message", (_data, isBinary) => {
713
- if (!isBinary) return;
714
- socket.send(JSON.stringify({
715
- is_final: true,
716
- speech_final: false,
717
- channel: { alternatives: [{ transcript: "released provider text", confidence: 0.87 }] },
718
- }));
719
- });
720
- });
721
- const bus = new PipelineBusImpl();
722
- const started = startBus(bus);
723
- const plugin = new DeepgramSTTPlugin();
724
- const finals: SttResultPacket[] = [];
725
- const errors: SttErrorPacket[] = [];
726
- const metrics: ConversationMetricPacket[] = [];
727
- bus.on("stt.result", (pkt) => { finals.push(pkt as SttResultPacket); });
728
- bus.on("stt.error", (pkt) => { errors.push(pkt as SttErrorPacket); });
729
- bus.on("metric.conversation", (pkt) => { metrics.push(pkt as ConversationMetricPacket); });
730
-
731
- await plugin.initialize(bus, {
732
- api_key: "test",
733
- endpoint_url: endpointUrl,
734
- sample_rate: 16000,
735
- emit_eos_on_final: false,
736
- finalize_on_speech_final: false,
737
- provider_finalize_timeout_ms: 10,
738
- });
739
-
740
- bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-is-final", timestampMs: Date.now(), audio: new Uint8Array(640) });
741
- await waitFor(finals);
742
- plugin.forceFinalize("turn-is-final");
743
- await new Promise((resolve) => setTimeout(resolve, 30));
744
-
745
- expect(finals).toEqual([
746
- expect.objectContaining({
747
- kind: "stt.result",
748
- contextId: "turn-is-final",
749
- text: "released provider text",
750
- confidence: 0.87,
751
- provider: expect.objectContaining({
752
- name: "deepgram",
753
- speechFinal: false,
754
- fromFinalize: false,
755
- finalizeRequested: false,
756
- }),
757
- }),
758
- ]);
759
- expect(errors).toHaveLength(0);
760
- expect(metrics).not.toEqual(expect.arrayContaining([
761
- expect.objectContaining({ name: "stt_provider_finalize_timeout" }),
762
- ]));
763
-
764
- await plugin.close();
765
- bus.stop();
766
- await started;
767
- });
768
-
769
- it("correlates a pending Finalize response to the requested context across turn.change", async () => {
770
- const endpointUrl = await createLocalServer((socket) => {
771
- socket.on("message", (data, isBinary) => {
772
- if (isBinary) return;
773
- const msg = JSON.parse(data.toString()) as { type?: string };
774
- if (msg.type !== "Finalize") return;
775
- setTimeout(() => {
776
- socket.send(JSON.stringify({
777
- is_final: true,
778
- speech_final: false,
779
- from_finalize: true,
780
- channel: { alternatives: [{ transcript: "old turn flushed", confidence: 0.91 }] },
781
- }));
782
- }, 5);
783
- });
784
- });
785
- const bus = new PipelineBusImpl();
786
- const started = startBus(bus);
787
- const plugin = new DeepgramSTTPlugin();
788
- const finals: SttResultPacket[] = [];
789
- const errors: SttErrorPacket[] = [];
790
- const metrics: ConversationMetricPacket[] = [];
791
- bus.on("stt.result", (pkt) => { finals.push(pkt as SttResultPacket); });
792
- bus.on("stt.error", (pkt) => { errors.push(pkt as SttErrorPacket); });
793
- bus.on("metric.conversation", (pkt) => { metrics.push(pkt as ConversationMetricPacket); });
794
-
795
- await plugin.initialize(bus, {
796
- api_key: "test",
797
- endpoint_url: endpointUrl,
798
- sample_rate: 16000,
799
- emit_eos_on_final: false,
800
- finalize_on_speech_final: false,
801
- provider_finalize_timeout_ms: 25,
802
- });
803
-
804
- bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-old", timestampMs: Date.now(), audio: new Uint8Array(640) });
805
- await new Promise((resolve) => setTimeout(resolve, 5));
806
- plugin.forceFinalize("turn-old");
807
- bus.push(Route.Main, {
808
- kind: "turn.change",
809
- previousContextId: "turn-old",
810
- contextId: "turn-new",
811
- reason: "websocket_audio_turn",
812
- timestampMs: Date.now(),
813
- });
814
-
815
- await waitFor(finals);
816
- await new Promise((resolve) => setTimeout(resolve, 35));
817
-
818
- expect(finals).toEqual([
819
- expect.objectContaining({
820
- kind: "stt.result",
821
- contextId: "turn-old",
822
- text: "old turn flushed",
823
- provider: expect.objectContaining({
824
- fromFinalize: true,
825
- finalizeRequested: true,
826
- }),
827
- }),
828
- ]);
829
- expect(errors).toHaveLength(0);
830
- expect(metrics).not.toEqual(expect.arrayContaining([
831
- expect.objectContaining({ name: "stt_provider_finalize_timeout", contextId: "turn-old" }),
832
- ]));
833
-
834
- await plugin.close();
835
- bus.stop();
836
- await started;
837
- });
838
-
839
- it("keeps Finalize response correlation after already released final text crosses turn.change", async () => {
840
- const endpointUrl = await createLocalServer((socket) => {
841
- socket.on("message", (data, isBinary) => {
842
- if (isBinary) {
843
- socket.send(JSON.stringify({
844
- is_final: true,
845
- speech_final: false,
846
- channel: { alternatives: [{ transcript: "old released text", confidence: 0.88 }] },
847
- }));
848
- return;
849
- }
850
- const msg = JSON.parse(data.toString()) as { type?: string };
851
- if (msg.type !== "Finalize") return;
852
- setTimeout(() => {
853
- socket.send(JSON.stringify({
854
- is_final: true,
855
- speech_final: false,
856
- from_finalize: true,
857
- channel: { alternatives: [{ transcript: "old finalize tail", confidence: 0.93 }] },
858
- }));
859
- }, 5);
860
- });
861
- });
862
- const bus = new PipelineBusImpl();
863
- const started = startBus(bus);
864
- const plugin = new DeepgramSTTPlugin();
865
- const finals: SttResultPacket[] = [];
866
- const errors: SttErrorPacket[] = [];
867
- const metrics: ConversationMetricPacket[] = [];
868
- bus.on("stt.result", (pkt) => { finals.push(pkt as SttResultPacket); });
869
- bus.on("stt.error", (pkt) => { errors.push(pkt as SttErrorPacket); });
870
- bus.on("metric.conversation", (pkt) => { metrics.push(pkt as ConversationMetricPacket); });
871
-
872
- await plugin.initialize(bus, {
873
- api_key: "test",
874
- endpoint_url: endpointUrl,
875
- sample_rate: 16000,
876
- emit_eos_on_final: false,
877
- finalize_on_speech_final: false,
878
- provider_finalize_timeout_ms: 25,
879
- });
880
-
881
- bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-old-released", timestampMs: Date.now(), audio: new Uint8Array(640) });
882
- await waitFor(finals);
883
- plugin.forceFinalize("turn-old-released");
884
- bus.push(Route.Main, {
885
- kind: "turn.change",
886
- previousContextId: "turn-old-released",
887
- contextId: "turn-new-after-release",
888
- reason: "websocket_audio_turn",
889
- timestampMs: Date.now(),
890
- });
891
-
892
- await waitFor(finals, 2);
893
- await new Promise((resolve) => setTimeout(resolve, 35));
894
-
895
- expect(finals).toEqual([
896
- expect.objectContaining({ contextId: "turn-old-released", text: "old released text" }),
897
- expect.objectContaining({
898
- contextId: "turn-old-released",
899
- text: "old finalize tail",
900
- provider: expect.objectContaining({
901
- fromFinalize: true,
902
- finalizeRequested: true,
903
- }),
904
- }),
905
- ]);
906
- expect(errors).toHaveLength(0);
907
- expect(metrics).not.toEqual(expect.arrayContaining([
908
- expect.objectContaining({ name: "stt_provider_finalize_timeout" }),
909
- ]));
910
-
911
- await plugin.close();
912
- bus.stop();
913
- await started;
914
- });
915
-
916
- it("keeps the live connection after a single unconfirmed Finalize timeout", async () => {
917
- // A lone slow finalize must NOT tear down the socket — the next turn has to stream
918
- // on the same connection, otherwise a reconnect stall cascades into more timeouts.
919
- const connections: WebSocket[] = [];
920
- let lastAudioContext = "";
921
- const endpointUrl = await createLocalServer((socket) => {
922
- connections.push(socket);
923
- socket.on("message", (data, isBinary) => {
924
- if (isBinary) {
925
- // Only the fresh turn gets final text; the stale turn's interim-only
926
- // text is intentionally never confirmed so its Finalize times out.
927
- const forFreshTurn = lastAudioContext === "turn-fresh";
928
- socket.send(JSON.stringify({
929
- is_final: forFreshTurn,
930
- speech_final: forFreshTurn,
931
- channel: { alternatives: [{ transcript: forFreshTurn ? "fresh confirmed text" : "stale interim", confidence: 0.9 }] },
932
- }));
933
- return;
934
- }
935
- // Swallow Finalize for the stale turn (no from_finalize) → provider timeout.
936
- });
937
- });
938
- const bus = new PipelineBusImpl();
939
- const started = startBus(bus);
940
- const plugin = new DeepgramSTTPlugin();
941
- const finals: SttResultPacket[] = [];
942
- const errors: SttErrorPacket[] = [];
943
- bus.on("stt.result", (pkt) => { finals.push(pkt as SttResultPacket); });
944
- bus.on("stt.error", (pkt) => { errors.push(pkt as SttErrorPacket); });
945
-
946
- await plugin.initialize(bus, {
947
- api_key: "test",
948
- endpoint_url: endpointUrl,
949
- sample_rate: 16000,
950
- provider_finalize_timeout_ms: 10,
951
- });
952
-
953
- lastAudioContext = "turn-stale";
954
- bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-stale", timestampMs: Date.now(), audio: new Uint8Array(640) });
955
- await new Promise((resolve) => setTimeout(resolve, 20));
956
- plugin.forceFinalize("turn-stale");
957
- await waitFor(errors);
958
-
959
- // The single timeout surfaced an error but the socket stayed up (no reconnect).
960
- expect(errors).toHaveLength(1);
961
- expect(connections).toHaveLength(1);
962
- expect(connections[0]?.readyState).toBe(connections[0]?.OPEN);
963
-
964
- // Next turn streams on the SAME connection and completes normally.
965
- lastAudioContext = "turn-fresh";
966
- bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-fresh", timestampMs: Date.now(), audio: new Uint8Array(640) });
967
- await waitFor(finals);
968
-
969
- expect(connections).toHaveLength(1);
970
- expect(finals).toEqual([
971
- expect.objectContaining({ contextId: "turn-fresh", text: "fresh confirmed text" }),
972
- ]);
973
-
974
- await plugin.close();
975
- bus.stop();
976
- await started;
977
- });
978
-
979
- it("reconnects only after consecutive unconfirmed Finalize timeouts", async () => {
980
- // Two finalize timeouts in a row with no confirmed final between them looks like a
981
- // genuinely wedged stream → reconnect; the fresh socket then serves the next turn.
982
- const connections: WebSocket[] = [];
983
- const endpointUrl = await createLocalServer((socket) => {
984
- connections.push(socket);
985
- const connIndex = connections.length;
986
- socket.on("message", (data, isBinary) => {
987
- if (isBinary) {
988
- if (connIndex >= 2) {
989
- socket.send(JSON.stringify({
990
- is_final: true,
991
- speech_final: true,
992
- channel: { alternatives: [{ transcript: "fresh confirmed text", confidence: 0.9 }] },
993
- }));
994
- } else {
995
- socket.send(JSON.stringify({
996
- is_final: false,
997
- speech_final: false,
998
- channel: { alternatives: [{ transcript: "wedged interim", confidence: 0.8 }] },
999
- }));
1000
- }
1001
- return;
1002
- }
1003
- // First connection never confirms any Finalize → repeated timeouts.
1004
- });
1005
- });
1006
- const bus = new PipelineBusImpl();
1007
- const started = startBus(bus);
1008
- const plugin = new DeepgramSTTPlugin();
1009
- const finals: SttResultPacket[] = [];
1010
- const errors: SttErrorPacket[] = [];
1011
- bus.on("stt.result", (pkt) => { finals.push(pkt as SttResultPacket); });
1012
- bus.on("stt.error", (pkt) => { errors.push(pkt as SttErrorPacket); });
1013
-
1014
- await plugin.initialize(bus, {
1015
- api_key: "test",
1016
- endpoint_url: endpointUrl,
1017
- sample_rate: 16000,
1018
- provider_finalize_timeout_ms: 10,
1019
- finalize_reset_threshold: 2,
1020
- });
1021
-
1022
- bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-1", timestampMs: Date.now(), audio: new Uint8Array(640) });
1023
- await new Promise((resolve) => setTimeout(resolve, 20));
1024
- plugin.forceFinalize("turn-1");
1025
- await waitFor(errors, 1);
1026
- // First timeout: still one connection (no reconnect yet).
1027
- expect(connections).toHaveLength(1);
1028
-
1029
- bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-2", timestampMs: Date.now(), audio: new Uint8Array(640) });
1030
- await new Promise((resolve) => setTimeout(resolve, 20));
1031
- plugin.forceFinalize("turn-2");
1032
- await waitFor(errors, 2);
1033
- await waitFor(connections, 2);
1034
-
1035
- // Second consecutive timeout: now it reconnects and abandons the wedged socket.
1036
- expect(connections[0]?.readyState).toBe(connections[0]?.CLOSED);
1037
-
1038
- bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-fresh", timestampMs: Date.now(), audio: new Uint8Array(640) });
1039
- await waitFor(finals);
1040
- expect(finals).toEqual([
1041
- expect.objectContaining({ contextId: "turn-fresh", text: "fresh confirmed text" }),
1042
- ]);
1043
-
1044
- await plugin.close();
1045
- bus.stop();
1046
- await started;
1047
- });
1048
-
1049
- it("clears the consecutive-timeout counter across a real socket reconnect", async () => {
1050
- // A timeout (counter->1) followed by an unrelated socket-close reconnect must not leave
1051
- // the count stale: a single timeout after reconnecting should NOT force another reset.
1052
- const connections: WebSocket[] = [];
1053
- const endpointUrl = await createLocalServer((socket) => {
1054
- connections.push(socket);
1055
- socket.on("message", (_data, isBinary) => {
1056
- if (!isBinary) return;
1057
- socket.send(JSON.stringify({
1058
- is_final: true,
1059
- speech_final: false,
1060
- channel: { alternatives: [{ transcript: "unconfirmed", confidence: 0.8 }] },
1061
- }));
1062
- });
1063
- });
1064
- const bus = new PipelineBusImpl();
1065
- const started = startBus(bus);
1066
- const plugin = new DeepgramSTTPlugin();
1067
- const timeoutErrors: SttErrorPacket[] = [];
1068
- bus.on("stt.error", (pkt) => {
1069
- const p = pkt as SttErrorPacket;
1070
- if (String((p.cause as Error | undefined)?.message ?? "").includes("Finalize timed out")) {
1071
- timeoutErrors.push(p);
1072
- }
1073
- });
1074
-
1075
- await plugin.initialize(bus, {
1076
- api_key: "test",
1077
- endpoint_url: endpointUrl,
1078
- sample_rate: 16000,
1079
- provider_finalize_timeout_ms: 10,
1080
- finalize_reset_threshold: 2,
1081
- });
1082
-
1083
- // Turn 1: one finalize timeout → counter 1 (below threshold, no reset yet).
1084
- bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-before-reconnect", timestampMs: Date.now(), audio: new Uint8Array(640) });
1085
- await new Promise((resolve) => setTimeout(resolve, 20));
1086
- plugin.forceFinalize("turn-before-reconnect");
1087
- await waitFor(timeoutErrors, 1);
1088
- expect(connections).toHaveLength(1);
1089
-
1090
- // An unrelated socket-close reconnect happens.
1091
- connections[0]!.close(1011, "NET-0000");
1092
- await waitFor(connections, 2);
1093
- await new Promise((resolve) => setTimeout(resolve, 50));
1094
-
1095
- // Turn 2: a single post-reconnect timeout must NOT reconnect again — the counter was
1096
- // cleared on the reconnect, so it is 1 (< threshold 2), not a stale 2.
1097
- bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-after-reconnect", timestampMs: Date.now(), audio: new Uint8Array(640) });
1098
- await new Promise((resolve) => setTimeout(resolve, 20));
1099
- plugin.forceFinalize("turn-after-reconnect");
1100
- await waitFor(timeoutErrors, 2);
1101
- await new Promise((resolve) => setTimeout(resolve, 60));
1102
-
1103
- // Would be 3 without clearing the counter on reconnect.
1104
- expect(connections).toHaveLength(2);
1105
- expect(connections[1]?.readyState).toBe(connections[1]?.OPEN);
1106
-
1107
- await plugin.close();
1108
- bus.stop();
1109
- await started;
1110
- });
1111
-
1112
- it("completes a timed-out turn from buffered text when finalize_timeout_fallback is on", async () => {
1113
- // Provider sends a confirmed is_final segment but never speech_final/from_finalize, so the
1114
- // turn would normally time out and be dropped. With the fallback on it must still emit an
1115
- // stt.result (which drives the turn plugin → LLM) instead of a "Finalize timed out" error.
1116
- const endpointUrl = await createLocalServer((socket) => {
1117
- socket.on("message", (_data, isBinary) => {
1118
- if (!isBinary) return; // swallow Finalize → provider never confirms
1119
- socket.send(JSON.stringify({
1120
- is_final: true,
1121
- speech_final: false,
1122
- channel: { alternatives: [{ transcript: "buffered text", confidence: 0.82 }] },
1123
- }));
1124
- });
1125
- });
1126
- const bus = new PipelineBusImpl();
1127
- const started = startBus(bus);
1128
- const plugin = new DeepgramSTTPlugin();
1129
- const finals: SttResultPacket[] = [];
1130
- const errors: SttErrorPacket[] = [];
1131
- bus.on("stt.result", (pkt) => { finals.push(pkt as SttResultPacket); });
1132
- bus.on("stt.error", (pkt) => { errors.push(pkt as SttErrorPacket); });
1133
-
1134
- await plugin.initialize(bus, {
1135
- api_key: "test",
1136
- endpoint_url: endpointUrl,
1137
- sample_rate: 16000,
1138
- emit_eos_on_final: false,
1139
- finalize_on_speech_final: false,
1140
- provider_finalize_timeout_ms: 10,
1141
- finalize_timeout_fallback: true,
1142
- });
1143
-
1144
- bus.push(Route.Main, { kind: "stt.audio", contextId: "turn-timeout-fallback", timestampMs: Date.now(), audio: new Uint8Array(640) });
1145
- await new Promise((resolve) => setTimeout(resolve, 20));
1146
- plugin.forceFinalize("turn-timeout-fallback");
1147
- await waitFor(finals);
1148
-
1149
- expect(finals).toEqual([
1150
- expect.objectContaining({ kind: "stt.result", contextId: "turn-timeout-fallback", text: "buffered text" }),
1151
- ]);
1152
- expect(
1153
- errors.filter((e) => String((e.cause as Error | undefined)?.message ?? "").includes("Finalize timed out")),
1154
- ).toHaveLength(0);
1155
-
1156
- await plugin.close();
1157
- bus.stop();
1158
- await started;
1159
- });
1160
-
1161
- it("emits stt.error for odd-length PCM16 without throwing into the bus pump", async () => {
1162
- const receivedAudio: Buffer[] = [];
1163
- const endpointUrl = await createLocalServer((socket) => {
1164
- socket.on("message", (data, isBinary) => {
1165
- if (isBinary) receivedAudio.push(data as Buffer);
1166
- });
1167
- });
1168
- const bus = new PipelineBusImpl();
1169
- const started = startBus(bus);
1170
- const plugin = new DeepgramSTTPlugin();
1171
- const errors: SttErrorPacket[] = [];
1172
- bus.on("stt.error", (pkt) => {
1173
- errors.push(pkt as SttErrorPacket);
1174
- });
1175
-
1176
- await plugin.initialize(bus, {
1177
- api_key: "test",
1178
- endpoint_url: endpointUrl,
1179
- sample_rate: 16000,
1180
- });
1181
- bus.push(Route.Main, {
1182
- kind: "stt.audio",
1183
- contextId: "turn-bad-pcm",
1184
- timestampMs: Date.now(),
1185
- audio: new Uint8Array([1, 2, 3]),
1186
- });
1187
- await waitFor(errors);
1188
-
1189
- expect(errors).toEqual([
1190
- expect.objectContaining({
1191
- kind: "stt.error",
1192
- contextId: "turn-bad-pcm",
1193
- component: "stt",
1194
- cause: expect.objectContaining({
1195
- message: expect.stringMatching(/even number of bytes/i),
1196
- }),
1197
- }),
1198
- ]);
1199
- expect(receivedAudio).toEqual([]);
1200
-
1201
- await plugin.close();
1202
- bus.stop();
1203
- await started;
1204
- });
1205
- });
1206
-
1207
- describe("DeepgramSTTPlugin provider speech-start (vad_events)", () => {
1208
- it("emits vad.speech_started for the current context when the provider sends SpeechStarted", async () => {
1209
- const endpointUrl = await createLocalServer((socket) => {
1210
- socket.send(JSON.stringify({ type: "SpeechStarted", channel: [0, 1], timestamp: 0.42 }));
1211
- });
1212
- const bus = new PipelineBusImpl();
1213
- const started = startBus(bus);
1214
- const speechStarts: Array<{ contextId: string; confidence: number }> = [];
1215
- bus.on("vad.speech_started", (pkt) => {
1216
- speechStarts.push(pkt as unknown as { contextId: string; confidence: number });
1217
- });
1218
-
1219
- const plugin = new DeepgramSTTPlugin();
1220
- await plugin.initialize(bus, {
1221
- api_key: "test",
1222
- endpoint_url: endpointUrl,
1223
- sample_rate: 16000,
1224
- vad_events: true,
1225
- });
1226
- bus.push(Route.Main, { kind: "turn.change", contextId: "turn-1", timestampMs: Date.now() });
1227
- await waitFor(speechStarts);
1228
- await plugin.close();
1229
- bus.stop();
1230
- await started;
1231
-
1232
- expect(speechStarts.length).toBeGreaterThanOrEqual(1);
1233
- expect(speechStarts[0]!.confidence).toBe(1);
1234
- });
1235
-
1236
- it("does not emit vad.speech_started by default (opt-in for VAD-less deployments)", async () => {
1237
- const endpointUrl = await createLocalServer((socket) => {
1238
- socket.send(JSON.stringify({ type: "SpeechStarted", channel: [0, 1], timestamp: 0.42 }));
1239
- });
1240
- const bus = new PipelineBusImpl();
1241
- const started = startBus(bus);
1242
- const speechStarts: unknown[] = [];
1243
- bus.on("vad.speech_started", (pkt) => {
1244
- speechStarts.push(pkt);
1245
- });
1246
-
1247
- const plugin = new DeepgramSTTPlugin();
1248
- await plugin.initialize(bus, {
1249
- api_key: "test",
1250
- endpoint_url: endpointUrl,
1251
- sample_rate: 16000,
1252
- });
1253
- await new Promise((resolve) => setTimeout(resolve, 150));
1254
- await plugin.close();
1255
- bus.stop();
1256
- await started;
1257
-
1258
- expect(speechStarts).toHaveLength(0);
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
- });
1323
- });