@kuralle-syrinx/server-websocket 4.2.0 → 4.3.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/edge.test.ts DELETED
@@ -1,591 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, expect, it } from "vitest";
4
- import {
5
- PipelineBusImpl,
6
- Route,
7
- VoiceAgentSession as VoiceAgentSessionImpl,
8
- encodeSyrinxAudioEnvelope,
9
- type Scheduler,
10
- type ScheduledCallback,
11
- type UserAudioReceivedPacket,
12
- type VoiceAgentSession,
13
- } from "@kuralle-syrinx/core";
14
- import { pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
15
- import type { ManagedSocket, SocketData } from "@kuralle-syrinx/ws";
16
- import { InMemorySessionStore } from "./session-store.js";
17
- import { runVoiceEdgeWebSocketConnection, type EdgeRecorder } from "./edge.js";
18
-
19
- const KEEP_ALIVE_KEY = "voice.edge.keep_alive";
20
-
21
- /** Scheduler whose tasks fire only when the test calls fire() — no real timers. */
22
- class ManualScheduler implements Scheduler {
23
- readonly tasks = new Map<string, ScheduledCallback>();
24
- schedule(key: string, _delayMs: number, cb: ScheduledCallback): void {
25
- this.tasks.set(key, cb);
26
- }
27
- cancel(key: string): void {
28
- this.tasks.delete(key);
29
- }
30
- async fire(key: string): Promise<void> {
31
- const cb = this.tasks.get(key);
32
- this.tasks.delete(key);
33
- if (cb) await cb();
34
- }
35
- }
36
-
37
- class FakeSocket implements ManagedSocket {
38
- isOpen = true;
39
- disposed = false;
40
- readonly sent: SocketData[] = [];
41
- #onMessage?: (data: SocketData, isBinary: boolean) => void;
42
- #onClose?: (code: number, reason: string) => void;
43
- #onError?: (err: Error) => void;
44
- get isOpenValue(): boolean {
45
- return this.isOpen;
46
- }
47
- send(data: SocketData): void {
48
- this.sent.push(data);
49
- }
50
- keepAlivePing(): void {}
51
- async verify(): Promise<boolean> {
52
- return this.isOpen;
53
- }
54
- dispose(): void {
55
- this.disposed = true;
56
- this.isOpen = false;
57
- this.#onClose?.(1000, "disposed");
58
- }
59
- onOpen(): void {}
60
- onMessage(handler: (data: SocketData, isBinary: boolean) => void): void {
61
- this.#onMessage = handler;
62
- }
63
- onClose(handler: (code: number, reason: string) => void): void {
64
- this.#onClose = handler;
65
- }
66
- onError(handler: (err: Error) => void): void {
67
- this.#onError = handler;
68
- }
69
- emit(data: SocketData, isBinary = false): void {
70
- this.#onMessage?.(data, isBinary);
71
- }
72
- emitError(err: Error = new Error("socket error")): void {
73
- this.#onError?.(err);
74
- }
75
- json(): Array<Record<string, unknown>> {
76
- return this.sent
77
- .filter((s): s is string => typeof s === "string")
78
- .map((s) => JSON.parse(s) as Record<string, unknown>);
79
- }
80
- }
81
-
82
- function fakeSession(received: UserAudioReceivedPacket[] = []): VoiceAgentSession {
83
- const bus = new PipelineBusImpl();
84
- bus.on("user.audio_received", (pkt) => {
85
- received.push(pkt as UserAudioReceivedPacket);
86
- });
87
- return {
88
- bus,
89
- async start() {
90
- void bus.start();
91
- },
92
- async close() {
93
- bus.stop();
94
- },
95
- on() {},
96
- off() {},
97
- requestClientInterrupt() {},
98
- } as unknown as VoiceAgentSession;
99
- }
100
-
101
- function runConnection(
102
- socket: FakeSocket,
103
- scheduler: ManualScheduler,
104
- opts: {
105
- idleTimeoutMs?: number;
106
- keepAliveIntervalMs?: number;
107
- rawBinaryInput?: boolean;
108
- received?: UserAudioReceivedPacket[];
109
- } = {},
110
- ) {
111
- const received = opts.received ?? [];
112
- return runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
113
- sessionStore: new InMemorySessionStore(),
114
- scheduler,
115
- createSession: () => fakeSession(received),
116
- idleTimeoutMs: opts.idleTimeoutMs,
117
- keepAliveIntervalMs: opts.keepAliveIntervalMs,
118
- rawBinaryInput: opts.rawBinaryInput,
119
- });
120
- }
121
-
122
- function waitForReady(socket: FakeSocket): void {
123
- expect(socket.json().some((m) => m.type === "ready")).toBe(true);
124
- }
125
-
126
- function encodeEnvelopeFrame(
127
- sequence: number,
128
- audio: Uint8Array,
129
- contextId = "turn-envelope",
130
- ): Uint8Array {
131
- return encodeSyrinxAudioEnvelope({
132
- type: "audio",
133
- contextId,
134
- sampleRateHz: 16000,
135
- encoding: "pcm_s16le",
136
- channels: 1,
137
- byteLength: audio.byteLength,
138
- sequence,
139
- }, audio);
140
- }
141
-
142
- function findOddAndEvenEnvelopeFrames(
143
- pcm: Uint8Array,
144
- ): { ordered: [Uint8Array, Uint8Array]; odd: Uint8Array; even: Uint8Array } {
145
- const candidates: Array<{ frame: Uint8Array; parity: number; sequence: number }> = [];
146
- for (let index = 0; index < 32; index += 1) {
147
- const frame = encodeEnvelopeFrame(index + 1, pcm, `turn-envelope-${"x".repeat(index)}`);
148
- candidates.push({ frame, parity: frame.byteLength % 2, sequence: index + 1 });
149
- }
150
- const odd = candidates.find((entry) => entry.parity === 1);
151
- const even = candidates.find((entry) => entry.parity === 0 && entry.sequence > (odd?.sequence ?? 0));
152
- if (!odd || !even) throw new Error("Could not construct odd and even envelope frame lengths");
153
- const ordered = odd.sequence < even.sequence
154
- ? [odd.frame, even.frame] as [Uint8Array, Uint8Array]
155
- : [even.frame, odd.frame] as [Uint8Array, Uint8Array];
156
- return { ordered, odd: odd.frame, even: even.frame };
157
- }
158
-
159
- describe("edge keep-alive / idle close", () => {
160
- it("arms a keep-alive heartbeat once the session is ready and re-arms while active", async () => {
161
- const socket = new FakeSocket();
162
- const scheduler = new ManualScheduler();
163
- await runConnection(socket, scheduler, { idleTimeoutMs: 10_000, keepAliveIntervalMs: 1_000 });
164
-
165
- expect(socket.json().some((m) => m.type === "ready")).toBe(true);
166
- expect(scheduler.tasks.has(KEEP_ALIVE_KEY)).toBe(true);
167
-
168
- // A fresh heartbeat with recent client activity must re-arm, not close.
169
- await scheduler.fire(KEEP_ALIVE_KEY);
170
- expect(socket.disposed).toBe(false);
171
- expect(scheduler.tasks.has(KEEP_ALIVE_KEY)).toBe(true);
172
- });
173
-
174
- it("closes a half-open client that has been idle past idleTimeoutMs", async () => {
175
- const socket = new FakeSocket();
176
- const scheduler = new ManualScheduler();
177
- await runConnection(socket, scheduler, { idleTimeoutMs: 30, keepAliveIntervalMs: 1_000 });
178
-
179
- // No further client traffic; let the idle window elapse, then beat.
180
- await new Promise((r) => setTimeout(r, 60));
181
- await scheduler.fire(KEEP_ALIVE_KEY);
182
-
183
- expect(socket.disposed).toBe(true);
184
- expect(socket.json().some((m) => m.type === "error" && m.category === "idle_timeout")).toBe(true);
185
- });
186
-
187
- it("a client message resets the idle window", async () => {
188
- const socket = new FakeSocket();
189
- const scheduler = new ManualScheduler();
190
- await runConnection(socket, scheduler, { idleTimeoutMs: 30, keepAliveIntervalMs: 1_000 });
191
-
192
- await new Promise((r) => setTimeout(r, 60));
193
- socket.emit(JSON.stringify({ type: "ping" })); // refreshes lastClientMessageMs
194
- await scheduler.fire(KEEP_ALIVE_KEY);
195
-
196
- expect(socket.disposed).toBe(false);
197
- expect(scheduler.tasks.has(KEEP_ALIVE_KEY)).toBe(true);
198
- });
199
-
200
- it("keepAliveIntervalMs=0 disables the heartbeat entirely", async () => {
201
- const socket = new FakeSocket();
202
- const scheduler = new ManualScheduler();
203
- await runConnection(socket, scheduler, { keepAliveIntervalMs: 0 });
204
-
205
- expect(socket.json().some((m) => m.type === "ready")).toBe(true);
206
- expect(scheduler.tasks.has(KEEP_ALIVE_KEY)).toBe(false);
207
- });
208
- });
209
-
210
- describe("edge inbound binary audio envelopes", () => {
211
- it("decodes syrinx.audio.v1 envelopes into even PCM payloads", async () => {
212
- const received: UserAudioReceivedPacket[] = [];
213
- const socket = new FakeSocket();
214
- const scheduler = new ManualScheduler();
215
- await runConnection(socket, scheduler, { received });
216
- waitForReady(socket);
217
-
218
- const pcm = pcm16SamplesToBytes(new Int16Array([0, 32767, -32768, 16384]));
219
- const { ordered, odd: oddHeaderFrame, even: evenHeaderFrame } = findOddAndEvenEnvelopeFrames(pcm);
220
- expect(oddHeaderFrame.byteLength % 2).toBe(1);
221
- expect(evenHeaderFrame.byteLength % 2).toBe(0);
222
-
223
- for (const frame of ordered) socket.emit(frame, true);
224
- await new Promise((resolve) => setTimeout(resolve, 20));
225
-
226
- expect(received).toHaveLength(2);
227
- for (const packet of received) {
228
- expect(packet.kind).toBe("user.audio_received");
229
- expect(packet.contextId?.startsWith("turn-envelope")).toBe(true);
230
- expect(packet.audio.byteLength % 2).toBe(0);
231
- expect(packet.audio).toEqual(pcm);
232
- }
233
- expect(socket.json().some((m) => m.type === "error")).toBe(false);
234
- });
235
-
236
- it("rejects raw binary audio unless rawBinaryInput is enabled", async () => {
237
- const received: UserAudioReceivedPacket[] = [];
238
- const socket = new FakeSocket();
239
- const scheduler = new ManualScheduler();
240
- await runConnection(socket, scheduler, { received });
241
- waitForReady(socket);
242
-
243
- socket.emit(new Uint8Array([1, 2, 3, 4]), true);
244
- await new Promise((resolve) => setTimeout(resolve, 20));
245
-
246
- expect(received).toEqual([]);
247
- expect(socket.json()).toContainEqual({
248
- type: "error",
249
- component: "transport",
250
- category: "invalid_input",
251
- message: "Raw binary websocket audio is disabled; use syrinx.audio.v1 or JSON audio frames",
252
- });
253
- });
254
- });
255
-
256
- describe("edge inbound JSON audio (sample-rate handling)", () => {
257
- function sessionCapturing(
258
- audio: UserAudioReceivedPacket[],
259
- turns: Array<{ contextId: string; previousContextId?: string }>,
260
- ): VoiceAgentSession {
261
- const bus = new PipelineBusImpl();
262
- bus.on("user.audio_received", (p) => {
263
- audio.push(p as UserAudioReceivedPacket);
264
- });
265
- bus.on("turn.change", (p) => {
266
- turns.push(p as { contextId: string; previousContextId?: string });
267
- });
268
- return {
269
- bus,
270
- async start() {
271
- void bus.start();
272
- },
273
- async close() {
274
- bus.stop();
275
- },
276
- on() {},
277
- off() {},
278
- requestClientInterrupt() {},
279
- } as unknown as VoiceAgentSession;
280
- }
281
-
282
- it("resamples JSON audio from the client sampleRateHz to the engine input rate", async () => {
283
- const audio: UserAudioReceivedPacket[] = [];
284
- const turns: Array<{ contextId: string; previousContextId?: string }> = [];
285
- const socket = new FakeSocket();
286
- const scheduler = new ManualScheduler();
287
- await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
288
- sessionStore: new InMemorySessionStore(),
289
- scheduler,
290
- createSession: () => sessionCapturing(audio, turns),
291
- });
292
- waitForReady(socket);
293
-
294
- // 8 kHz mono PCM16 → engine default 16 kHz: sample count (and byte length) should ~double.
295
- const pcm8k = pcm16SamplesToBytes(new Int16Array(80).fill(1000));
296
- socket.emit(
297
- JSON.stringify({ type: "audio", audio: Buffer.from(pcm8k).toString("base64"), sampleRateHz: 8000, contextId: "json-turn" }),
298
- );
299
- await new Promise((r) => setTimeout(r, 20));
300
-
301
- expect(audio).toHaveLength(1);
302
- // BUG: JSON path pushed raw 8 kHz bytes unchanged instead of resampling to 16 kHz.
303
- expect(audio[0]!.audio.byteLength).toBeGreaterThan(pcm8k.byteLength * 1.5);
304
- // BUG: JSON path never emitted turn.change on contextId rotation (text/binary branches do).
305
- expect(turns.some((t) => t.contextId === "json-turn")).toBe(true);
306
- });
307
- });
308
-
309
- describe("edge recorder finalize on disconnect", () => {
310
- function fakeRecorder() {
311
- const calls: Array<{ sessionId: string; closedAtMs: number }> = [];
312
- const rec: EdgeRecorder = {
313
- onUserAudio() {},
314
- onAssistantAudio() {},
315
- finalize(meta) {
316
- calls.push(meta);
317
- },
318
- };
319
- return { rec, calls };
320
- }
321
-
322
- it("finalizes the recording on an error-path disconnect, not just a clean close", async () => {
323
- const socket = new FakeSocket();
324
- const scheduler = new ManualScheduler();
325
- const { rec, calls } = fakeRecorder();
326
- await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
327
- sessionStore: new InMemorySessionStore(),
328
- scheduler,
329
- createSession: () => fakeSession(),
330
- recorder: rec,
331
- });
332
- waitForReady(socket);
333
-
334
- socket.emitError(new Error("abnormal disconnect")); // error path, no clean onClose
335
- await new Promise((resolve) => setTimeout(resolve, 0));
336
-
337
- expect(calls).toHaveLength(1); // recording must still be finalized
338
- });
339
-
340
- it("finalizes exactly once when both an error and a close fire", async () => {
341
- const socket = new FakeSocket();
342
- const scheduler = new ManualScheduler();
343
- const { rec, calls } = fakeRecorder();
344
- await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
345
- sessionStore: new InMemorySessionStore(),
346
- scheduler,
347
- createSession: () => fakeSession(),
348
- recorder: rec,
349
- });
350
- waitForReady(socket);
351
-
352
- socket.emitError(new Error("drop"));
353
- socket.dispose(); // fires onClose
354
- await new Promise((resolve) => setTimeout(resolve, 0));
355
-
356
- expect(calls).toHaveLength(1); // idempotent teardown — not twice
357
- });
358
- });
359
-
360
- describe("edge barge-in downlink", () => {
361
- it("sends audio_clear and agent_interrupted when an interrupt is detected", async () => {
362
- const socket = new FakeSocket();
363
- const scheduler = new ManualScheduler();
364
- const session = fakeSession();
365
- await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
366
- sessionStore: new InMemorySessionStore(),
367
- scheduler,
368
- createSession: () => session,
369
- });
370
- waitForReady(socket);
371
-
372
- session.bus.push(Route.Critical, {
373
- kind: "interrupt.detected",
374
- contextId: "assistant-turn",
375
- timestampMs: Date.now(),
376
- source: "vad",
377
- });
378
- await new Promise((resolve) => setTimeout(resolve, 0));
379
-
380
- expect(socket.json()).toContainEqual({ type: "audio_clear", turnId: "assistant-turn", reason: "barge_in" });
381
- expect(socket.json()).toContainEqual({ type: "agent_interrupted", turnId: "assistant-turn", reason: "barge_in" });
382
- });
383
- });
384
-
385
- describe("edge tool-call cues (G3/WBS-3)", () => {
386
- it("surfaces the typed lifecycle as tool_call_* wire messages: started → delayed → complete", async () => {
387
- const socket = new FakeSocket();
388
- const scheduler = new ManualScheduler();
389
- const session = new VoiceAgentSessionImpl({ plugins: {}, delayCueAfterMs: 20 });
390
- await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
391
- sessionStore: new InMemorySessionStore(),
392
- scheduler,
393
- createSession: () => session,
394
- });
395
- waitForReady(socket);
396
-
397
- session.bus.push(Route.Main, {
398
- kind: "llm.tool_call",
399
- contextId: "turn-1",
400
- timestampMs: Date.now(),
401
- toolId: "t1",
402
- toolName: "consult_knowledge",
403
- toolArgs: { query: "fees" },
404
- });
405
- await new Promise((resolve) => setTimeout(resolve, 50));
406
- session.bus.push(Route.Main, {
407
- kind: "llm.tool_result",
408
- contextId: "turn-1",
409
- timestampMs: Date.now(),
410
- toolId: "t1",
411
- toolName: "consult_knowledge",
412
- result: "answer",
413
- });
414
- await new Promise((resolve) => setTimeout(resolve, 20));
415
-
416
- expect(socket.json()).toContainEqual({
417
- type: "tool_call_started", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge",
418
- });
419
- expect(socket.json()).toContainEqual({
420
- type: "tool_call_delayed", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge", afterMs: 20,
421
- });
422
- expect(socket.json()).toContainEqual({
423
- type: "tool_call_complete", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge",
424
- });
425
- });
426
-
427
- it("surfaces tool_call_failed when a barge-in aborts the pending delegate (R5)", async () => {
428
- const socket = new FakeSocket();
429
- const scheduler = new ManualScheduler();
430
- const session = new VoiceAgentSessionImpl({ plugins: {}, delayCueAfterMs: 0 });
431
- await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
432
- sessionStore: new InMemorySessionStore(),
433
- scheduler,
434
- createSession: () => session,
435
- });
436
- waitForReady(socket);
437
-
438
- session.bus.push(Route.Main, {
439
- kind: "llm.tool_call",
440
- contextId: "turn-1",
441
- timestampMs: Date.now(),
442
- toolId: "t1",
443
- toolName: "consult_knowledge",
444
- toolArgs: {},
445
- });
446
- await new Promise((resolve) => setTimeout(resolve, 10));
447
- session.bus.push(Route.Critical, {
448
- kind: "interrupt.detected",
449
- contextId: "turn-1",
450
- timestampMs: Date.now(),
451
- source: "vad",
452
- });
453
- await new Promise((resolve) => setTimeout(resolve, 10));
454
-
455
- expect(socket.json()).toContainEqual({
456
- type: "tool_call_failed", turnId: "turn-1", toolId: "t1", toolName: "consult_knowledge",
457
- });
458
- // Barge-in downlink unaffected — the flush messages still went out.
459
- expect(socket.json().some((m) => m.type === "audio_clear")).toBe(true);
460
- });
461
- });
462
-
463
- describe("edge client playout progress", () => {
464
- interface ProgressPacket {
465
- contextId: string;
466
- playedOutMs: number;
467
- complete: boolean;
468
- }
469
-
470
- function sessionCapturingProgress(progress: ProgressPacket[]): VoiceAgentSession {
471
- const bus = new PipelineBusImpl();
472
- bus.on("tts.playout_progress", (p) => {
473
- progress.push(p as unknown as ProgressPacket);
474
- });
475
- return {
476
- bus,
477
- async start() {
478
- void bus.start();
479
- },
480
- async close() {
481
- bus.stop();
482
- },
483
- on() {},
484
- off() {},
485
- requestClientInterrupt() {},
486
- } as unknown as VoiceAgentSession;
487
- }
488
-
489
- it("maps a client playout_progress message onto a tts.playout_progress bus packet", async () => {
490
- const progress: ProgressPacket[] = [];
491
- const socket = new FakeSocket();
492
- const scheduler = new ManualScheduler();
493
- await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
494
- sessionStore: new InMemorySessionStore(),
495
- scheduler,
496
- createSession: () => sessionCapturingProgress(progress),
497
- });
498
- waitForReady(socket);
499
-
500
- socket.emit(
501
- JSON.stringify({ type: "playout_progress", contextId: "assistant-turn", playedOutMs: 1280 }),
502
- );
503
- socket.emit(
504
- JSON.stringify({ type: "playout_progress", contextId: "assistant-turn", playedOutMs: 2400, complete: true }),
505
- );
506
- await new Promise((r) => setTimeout(r, 20));
507
-
508
- expect(progress).toEqual([
509
- { kind: "tts.playout_progress", contextId: "assistant-turn", playedOutMs: 1280, complete: false, timestampMs: expect.any(Number) },
510
- { kind: "tts.playout_progress", contextId: "assistant-turn", playedOutMs: 2400, complete: true, timestampMs: expect.any(Number) },
511
- ]);
512
- });
513
-
514
- it("rejects a playout_progress message without a numeric playedOutMs", async () => {
515
- const progress: ProgressPacket[] = [];
516
- const socket = new FakeSocket();
517
- const scheduler = new ManualScheduler();
518
- await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
519
- sessionStore: new InMemorySessionStore(),
520
- scheduler,
521
- createSession: () => sessionCapturingProgress(progress),
522
- });
523
- waitForReady(socket);
524
-
525
- socket.emit(JSON.stringify({ type: "playout_progress", contextId: "assistant-turn" }));
526
- await new Promise((r) => setTimeout(r, 20));
527
-
528
- expect(progress).toHaveLength(0);
529
- expect(socket.json().some((m) => m.type === "error" && m.category === "invalid_input")).toBe(true);
530
- });
531
- });
532
-
533
- describe("edge background audio", () => {
534
- it("mixes the bed into the wire audio while the recorder keeps the clean track", async () => {
535
- const socket = new FakeSocket();
536
- const scheduler = new ManualScheduler();
537
- const recorded: Int16Array[] = [];
538
- const rec: EdgeRecorder = {
539
- onUserAudio() {},
540
- onAssistantAudio(_contextId: string, audio: Uint8Array) {
541
- recorded.push(new Int16Array(audio.buffer.slice(audio.byteOffset, audio.byteOffset + audio.byteLength)));
542
- },
543
- finalize() {},
544
- };
545
- const bus = new PipelineBusImpl();
546
- const session = {
547
- bus,
548
- async start() {
549
- void bus.start();
550
- },
551
- async close() {
552
- bus.stop();
553
- },
554
- on() {},
555
- off() {},
556
- requestClientInterrupt() {},
557
- } as unknown as VoiceAgentSession;
558
-
559
- await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
560
- sessionStore: new InMemorySessionStore(),
561
- scheduler,
562
- createSession: () => session,
563
- recorder: rec,
564
- backgroundAudio: {
565
- ambient: { pcm: new Int16Array(160).fill(2000), sampleRateHz: 16000, gain: 0.5 },
566
- duckWhileSpeaking: 0.5,
567
- fadeMs: 0,
568
- },
569
- });
570
- waitForReady(socket);
571
-
572
- bus.push(Route.Main, {
573
- kind: "tts.audio",
574
- contextId: "bg-turn",
575
- timestampMs: Date.now(),
576
- audio: pcm16SamplesToBytes(new Int16Array(4)), // silent speech chunk
577
- sampleRateHz: 16000,
578
- });
579
- await new Promise((r) => setTimeout(r, 20));
580
-
581
- // Recorder saw the CLEAN chunk.
582
- expect(recorded).toHaveLength(1);
583
- expect(Array.from(recorded[0]!)).toEqual([0, 0, 0, 0]);
584
-
585
- // The wire envelope carries the bed: 2000 × 0.5 gain × 0.5 duck = 500.
586
- const binary = socket.sent.find((d): d is Uint8Array => d instanceof Uint8Array);
587
- expect(binary).toBeDefined();
588
- const wireSamples = new Int16Array(binary!.buffer.slice(binary!.byteOffset + (binary!.byteLength - 8)));
589
- expect(Array.from(wireSamples)).toEqual([500, 500, 500, 500]);
590
- });
591
- });