@kuralle-syrinx/server-websocket 2.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.
Files changed (42) hide show
  1. package/LICENSE +22 -0
  2. package/package.json +31 -0
  3. package/src/admission-control.test.ts +247 -0
  4. package/src/browser-opus.test.ts +41 -0
  5. package/src/browser-opus.ts +59 -0
  6. package/src/browser-pacing.test.ts +440 -0
  7. package/src/edge-twilio.test.ts +215 -0
  8. package/src/edge-twilio.ts +285 -0
  9. package/src/edge.test.ts +272 -0
  10. package/src/edge.ts +652 -0
  11. package/src/graceful-drain.test.ts +306 -0
  12. package/src/inbound-audio.ts +102 -0
  13. package/src/index.test.ts +2071 -0
  14. package/src/index.ts +891 -0
  15. package/src/json-message.ts +39 -0
  16. package/src/outbound-playout-pipeline.test.ts +208 -0
  17. package/src/outbound-playout-pipeline.ts +167 -0
  18. package/src/paced-playout.test.ts +247 -0
  19. package/src/paced-playout.ts +161 -0
  20. package/src/playout-progress.test.ts +56 -0
  21. package/src/playout-progress.ts +67 -0
  22. package/src/session-store.test.ts +274 -0
  23. package/src/session-store.ts +123 -0
  24. package/src/smartpbx.test.ts +967 -0
  25. package/src/smartpbx.ts +531 -0
  26. package/src/telnyx.test.ts +1413 -0
  27. package/src/telnyx.ts +708 -0
  28. package/src/test-helpers.ts +264 -0
  29. package/src/transport-helpers.ts +78 -0
  30. package/src/transport-host.test.ts +51 -0
  31. package/src/transport-host.ts +213 -0
  32. package/src/turn-metrics.test.ts +327 -0
  33. package/src/turn-metrics.ts +193 -0
  34. package/src/twilio.test.ts +1275 -0
  35. package/src/twilio.ts +599 -0
  36. package/src/websocket-close.test.ts +63 -0
  37. package/src/websocket-close.ts +68 -0
  38. package/src/websocket-lifecycle.test.ts +49 -0
  39. package/src/websocket-lifecycle.ts +105 -0
  40. package/src/websocket-upgrade.ts +102 -0
  41. package/tsconfig.json +22 -0
  42. package/vitest.config.ts +7 -0
@@ -0,0 +1,285 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // Twilio Media Streams ingress for the Workers edge: bridges Twilio's WebSocket
4
+ // protocol (base64 μ-law 8 kHz both ways) to a VoiceAgentSession, mirroring the
5
+ // session-lease/heartbeat pattern of edge.ts. Provider-agnostic — the bridge only
6
+ // speaks core packets (user.audio_received / tts.audio / interrupt.detected).
7
+ //
8
+ // Barge-in: interrupt.detected → Twilio `clear` event, which drops Twilio's
9
+ // buffered playout instantly (their barge-in mechanism). Playout-clock caveat:
10
+ // like the browser edge path, turn-taking uses the estimate fallback (no paced
11
+ // transport progress events on the edge yet).
12
+
13
+ import {
14
+ Route,
15
+ TimerScheduler,
16
+ type Scheduler,
17
+ type VoiceAgentSession,
18
+ type TextToSpeechAudioPacket,
19
+ } from "@kuralle-syrinx/core";
20
+ import {
21
+ decodeMuLawToPcm16,
22
+ encodePcm16ToMuLaw,
23
+ pcm16BytesToSamples,
24
+ pcm16SamplesToBytes,
25
+ resamplePcm16Streaming,
26
+ type StreamingPcm16Resampler,
27
+ } from "@kuralle-syrinx/core/audio";
28
+ import type { ManagedSocket, SocketData } from "@kuralle-syrinx/ws";
29
+ import {
30
+ createWorkersInboundSocket,
31
+ type WorkersDurableObjectWebSocketContext,
32
+ } from "@kuralle-syrinx/ws/workers";
33
+ import type { SessionStore } from "./session-store.js";
34
+
35
+ const TWILIO_SAMPLE_RATE_HZ = 8_000;
36
+ const DEFAULT_RESUME_WINDOW_MS = 15_000;
37
+ const DEFAULT_KEEP_ALIVE_INTERVAL_MS = 15_000;
38
+ const DEFAULT_IDLE_TIMEOUT_MS = 60_000;
39
+ const DEFAULT_STARTUP_TIMEOUT_MS = 15_000;
40
+ const KEEP_ALIVE_KEY = "voice.edge.twilio.keep_alive";
41
+
42
+ export interface TwilioEdgeWebSocketOptions {
43
+ readonly sessionStore: SessionStore;
44
+ readonly createSession: (request: Request) => VoiceAgentSession | Promise<VoiceAgentSession>;
45
+ readonly scheduler?: Scheduler;
46
+ /** Engine-side PCM rate (default 16000). Twilio's leg is always 8 kHz μ-law. */
47
+ readonly engineSampleRateHz?: number;
48
+ readonly resumeWindowMs?: number;
49
+ readonly keepAliveIntervalMs?: number;
50
+ readonly idleTimeoutMs?: number;
51
+ readonly startupTimeoutMs?: number;
52
+ }
53
+
54
+ export interface TwilioEdgeWebSocketUpgrade {
55
+ readonly response: Response;
56
+ readonly controller: ReturnType<typeof createWorkersInboundSocket>["controller"];
57
+ }
58
+
59
+ export function createTwilioEdgeWebSocketUpgrade(
60
+ request: Request,
61
+ options: TwilioEdgeWebSocketOptions,
62
+ ctx?: WorkersDurableObjectWebSocketContext,
63
+ ): TwilioEdgeWebSocketUpgrade {
64
+ const inbound = createWorkersInboundSocket(ctx);
65
+ void runTwilioEdgeWebSocketConnection(inbound.socket, request, options);
66
+ return { response: inbound.response, controller: inbound.controller };
67
+ }
68
+
69
+ export async function runTwilioEdgeWebSocketConnection(
70
+ socket: ManagedSocket,
71
+ request: Request,
72
+ options: TwilioEdgeWebSocketOptions,
73
+ ): Promise<void> {
74
+ const scheduler = options.scheduler ?? new TimerScheduler();
75
+ const engineRate = options.engineSampleRateHz ?? 16_000;
76
+ const resumeWindowMs = options.resumeWindowMs ?? DEFAULT_RESUME_WINDOW_MS;
77
+ const keepAliveIntervalMs = options.keepAliveIntervalMs ?? DEFAULT_KEEP_ALIVE_INTERVAL_MS;
78
+ const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
79
+ const startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
80
+
81
+ const uplinkResamplers = new Map<string, StreamingPcm16Resampler>();
82
+ const downlinkResamplers = new Map<string, StreamingPcm16Resampler>();
83
+ const disposers: Array<() => void> = [];
84
+ let session: VoiceAgentSession | null = null;
85
+ let sessionId = "";
86
+ let streamSid = "";
87
+ let contextId = "";
88
+ let contextBase = "";
89
+ let turnCounter = 0;
90
+ let closed = false;
91
+ let stopped = false;
92
+ let lastClientMessageMs = Date.now();
93
+
94
+ const sendTwilioJson = (value: unknown): void => {
95
+ if (!socket.isOpen) return;
96
+ socket.send(JSON.stringify(value));
97
+ };
98
+
99
+ const cleanup = (): void => {
100
+ if (closed) return;
101
+ closed = true;
102
+ scheduler.cancel(KEEP_ALIVE_KEY);
103
+ for (const dispose of disposers.splice(0)) dispose();
104
+ if (session && sessionId) {
105
+ void options.sessionStore.release(sessionId, stopped ? 0 : resumeWindowMs);
106
+ }
107
+ };
108
+
109
+ socket.onClose(() => {
110
+ cleanup();
111
+ });
112
+ socket.onError(() => {
113
+ cleanup();
114
+ });
115
+
116
+ // Twilio sends connected/start (and media) immediately after the upgrade —
117
+ // before the session lease (provider sockets, kuralle init) resolves. Buffer
118
+ // until the handler is live or the start event is lost and the whole call
119
+ // streams into the void.
120
+ const pendingMessages: Array<{ data: SocketData; isBinary: boolean }> = [];
121
+ let pendingBytes = 0;
122
+ let liveHandler: ((data: SocketData, isBinary: boolean) => void) | null = null;
123
+ socket.onMessage((data: SocketData, isBinary: boolean) => {
124
+ lastClientMessageMs = Date.now();
125
+ if (liveHandler) {
126
+ liveHandler(data, isBinary);
127
+ return;
128
+ }
129
+ const byteLength = typeof data === "string" ? data.length : (data as ArrayBuffer | Uint8Array).byteLength;
130
+ if (pendingBytes + byteLength > 2 * 1024 * 1024) return; // cap startup buffering
131
+ pendingBytes += byteLength;
132
+ pendingMessages.push({ data, isBinary });
133
+ });
134
+
135
+ try {
136
+ const url = new URL(request.url);
137
+ sessionId = url.searchParams.get("sessionId")?.trim() || crypto.randomUUID();
138
+
139
+ const startupTimer = new Promise<never>((_, reject) => {
140
+ scheduler.schedule("voice.edge.twilio.startup", startupTimeoutMs, () => {
141
+ reject(new Error("twilio session startup timeout"));
142
+ });
143
+ });
144
+ const leased = await Promise.race([
145
+ options.sessionStore.lease(sessionId, async () => {
146
+ const sess = await options.createSession(request);
147
+ await sess.start();
148
+ return {
149
+ id: sessionId,
150
+ session: sess,
151
+ currentContextId: "",
152
+ contextSampleRates: new Map(),
153
+ inputSequence: { lastSequence: null },
154
+ turnMetricsTurns: new Map(),
155
+ closeTimer: null,
156
+ connectionCount: 1,
157
+ };
158
+ }),
159
+ startupTimer,
160
+ ]);
161
+ scheduler.cancel("voice.edge.twilio.startup");
162
+ session = leased.managed.session;
163
+ if (closed) {
164
+ await options.sessionStore.release(sessionId, 0);
165
+ return;
166
+ }
167
+
168
+ // Downlink: engine PCM → 8 kHz μ-law media frames; barge-in → clear.
169
+ disposers.push(
170
+ session.bus.on("tts.audio", (pkt) => {
171
+ if (!streamSid) return;
172
+ const audio = pkt as TextToSpeechAudioPacket;
173
+ const samples = pcm16BytesToSamples(audio.audio);
174
+ const sourceRate = audio.sampleRateHz ?? engineRate;
175
+ const resampled = resamplePcm16Streaming(downlinkResamplers, samples, sourceRate, TWILIO_SAMPLE_RATE_HZ);
176
+ const mulaw = encodePcm16ToMuLaw(resampled);
177
+ sendTwilioJson({
178
+ event: "media",
179
+ streamSid,
180
+ media: { payload: bytesToBase64(mulaw) },
181
+ });
182
+ }),
183
+ session.bus.on("interrupt.detected", () => {
184
+ if (!streamSid) return;
185
+ sendTwilioJson({ event: "clear", streamSid });
186
+ }),
187
+ // The phone mic streams continuously on one call, but the engine finishes a
188
+ // turn per contextId — rotate on turn_complete exactly like the browser
189
+ // client does, or only the first utterance is ever heard (the STT plugin
190
+ // drops transcripts for already-finalized contexts).
191
+ session.bus.on("eos.turn_complete", () => {
192
+ if (!contextBase) return;
193
+ turnCounter += 1;
194
+ contextId = `${contextBase}-t${String(turnCounter)}`;
195
+ }),
196
+ );
197
+
198
+ if (keepAliveIntervalMs > 0) {
199
+ const heartbeat = (): void => {
200
+ if (closed) return;
201
+ if (idleTimeoutMs > 0 && Date.now() - lastClientMessageMs > idleTimeoutMs) {
202
+ socket.dispose();
203
+ return;
204
+ }
205
+ scheduler.schedule(KEEP_ALIVE_KEY, keepAliveIntervalMs, heartbeat);
206
+ };
207
+ scheduler.schedule(KEEP_ALIVE_KEY, keepAliveIntervalMs, heartbeat);
208
+ }
209
+
210
+ const handleMessage = (data: SocketData, isBinary: boolean): void => {
211
+ if (isBinary || closed || !session) return;
212
+ let message: Record<string, unknown>;
213
+ try {
214
+ message = JSON.parse(socketDataToText(data)) as Record<string, unknown>;
215
+ } catch {
216
+ return;
217
+ }
218
+ const event = message["event"];
219
+ if (event === "connected" || event === "mark" || event === "dtmf") return;
220
+ if (event === "start") {
221
+ const start = (message["start"] ?? {}) as Record<string, unknown>;
222
+ streamSid =
223
+ (typeof start["streamSid"] === "string" && start["streamSid"]) ||
224
+ (typeof message["streamSid"] === "string" && message["streamSid"]) ||
225
+ "";
226
+ const callSid = typeof start["callSid"] === "string" ? start["callSid"] : "";
227
+ contextBase = callSid ? `twilio-${callSid}` : `twilio-${streamSid || sessionId}`;
228
+ contextId = contextBase;
229
+ return;
230
+ }
231
+ if (event === "media") {
232
+ const media = (message["media"] ?? {}) as Record<string, unknown>;
233
+ const payload = typeof media["payload"] === "string" ? media["payload"] : "";
234
+ if (!payload || !contextId) return;
235
+ const mulaw = base64ToBytes(payload);
236
+ const pcm8k = decodeMuLawToPcm16(mulaw);
237
+ const pcm16k = resamplePcm16Streaming(uplinkResamplers, pcm8k, TWILIO_SAMPLE_RATE_HZ, engineRate);
238
+ session.bus.push(Route.Main, {
239
+ kind: "user.audio_received",
240
+ contextId,
241
+ timestampMs: Date.now(),
242
+ audio: pcm16SamplesToBytes(pcm16k),
243
+ });
244
+ return;
245
+ }
246
+ if (event === "stop") {
247
+ stopped = true;
248
+ socket.dispose();
249
+ }
250
+ };
251
+ liveHandler = handleMessage;
252
+ for (const queued of pendingMessages.splice(0)) {
253
+ handleMessage(queued.data, queued.isBinary);
254
+ }
255
+ } catch (err) {
256
+ sendTwilioJson({
257
+ event: "error",
258
+ message: err instanceof Error ? err.message : String(err),
259
+ });
260
+ socket.dispose();
261
+ cleanup();
262
+ }
263
+ }
264
+
265
+ function socketDataToText(data: SocketData): string {
266
+ if (typeof data === "string") return data;
267
+ if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
268
+ return new TextDecoder().decode(data as Uint8Array);
269
+ }
270
+
271
+ function base64ToBytes(value: string): Uint8Array {
272
+ const binary = atob(value);
273
+ const bytes = new Uint8Array(binary.length);
274
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
275
+ return bytes;
276
+ }
277
+
278
+ function bytesToBase64(bytes: Uint8Array): string {
279
+ let binary = "";
280
+ const chunkSize = 0x8000;
281
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
282
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
283
+ }
284
+ return btoa(binary);
285
+ }
@@ -0,0 +1,272 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ PipelineBusImpl,
6
+ Route,
7
+ encodeSyrinxAudioEnvelope,
8
+ type Scheduler,
9
+ type ScheduledCallback,
10
+ type UserAudioReceivedPacket,
11
+ type VoiceAgentSession,
12
+ } from "@kuralle-syrinx/core";
13
+ import { pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
14
+ import type { ManagedSocket, SocketData } from "@kuralle-syrinx/ws";
15
+ import { InMemorySessionStore } from "./session-store.js";
16
+ import { runVoiceEdgeWebSocketConnection } from "./edge.js";
17
+
18
+ const KEEP_ALIVE_KEY = "voice.edge.keep_alive";
19
+
20
+ /** Scheduler whose tasks fire only when the test calls fire() — no real timers. */
21
+ class ManualScheduler implements Scheduler {
22
+ readonly tasks = new Map<string, ScheduledCallback>();
23
+ schedule(key: string, _delayMs: number, cb: ScheduledCallback): void {
24
+ this.tasks.set(key, cb);
25
+ }
26
+ cancel(key: string): void {
27
+ this.tasks.delete(key);
28
+ }
29
+ async fire(key: string): Promise<void> {
30
+ const cb = this.tasks.get(key);
31
+ this.tasks.delete(key);
32
+ if (cb) await cb();
33
+ }
34
+ }
35
+
36
+ class FakeSocket implements ManagedSocket {
37
+ isOpen = true;
38
+ disposed = false;
39
+ readonly sent: SocketData[] = [];
40
+ #onMessage?: (data: SocketData, isBinary: boolean) => void;
41
+ #onClose?: (code: number, reason: string) => void;
42
+ get isOpenValue(): boolean {
43
+ return this.isOpen;
44
+ }
45
+ send(data: SocketData): void {
46
+ this.sent.push(data);
47
+ }
48
+ keepAlivePing(): void {}
49
+ async verify(): Promise<boolean> {
50
+ return this.isOpen;
51
+ }
52
+ dispose(): void {
53
+ this.disposed = true;
54
+ this.isOpen = false;
55
+ this.#onClose?.(1000, "disposed");
56
+ }
57
+ onOpen(): void {}
58
+ onMessage(handler: (data: SocketData, isBinary: boolean) => void): void {
59
+ this.#onMessage = handler;
60
+ }
61
+ onClose(handler: (code: number, reason: string) => void): void {
62
+ this.#onClose = handler;
63
+ }
64
+ onError(): void {}
65
+ emit(data: SocketData, isBinary = false): void {
66
+ this.#onMessage?.(data, isBinary);
67
+ }
68
+ json(): Array<Record<string, unknown>> {
69
+ return this.sent
70
+ .filter((s): s is string => typeof s === "string")
71
+ .map((s) => JSON.parse(s) as Record<string, unknown>);
72
+ }
73
+ }
74
+
75
+ function fakeSession(received: UserAudioReceivedPacket[] = []): VoiceAgentSession {
76
+ const bus = new PipelineBusImpl();
77
+ bus.on("user.audio_received", (pkt) => {
78
+ received.push(pkt as UserAudioReceivedPacket);
79
+ });
80
+ return {
81
+ bus,
82
+ async start() {
83
+ void bus.start();
84
+ },
85
+ async close() {
86
+ bus.stop();
87
+ },
88
+ on() {},
89
+ off() {},
90
+ requestClientInterrupt() {},
91
+ } as unknown as VoiceAgentSession;
92
+ }
93
+
94
+ function runConnection(
95
+ socket: FakeSocket,
96
+ scheduler: ManualScheduler,
97
+ opts: {
98
+ idleTimeoutMs?: number;
99
+ keepAliveIntervalMs?: number;
100
+ rawBinaryInput?: boolean;
101
+ received?: UserAudioReceivedPacket[];
102
+ } = {},
103
+ ) {
104
+ const received = opts.received ?? [];
105
+ return runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
106
+ sessionStore: new InMemorySessionStore(),
107
+ scheduler,
108
+ createSession: () => fakeSession(received),
109
+ idleTimeoutMs: opts.idleTimeoutMs,
110
+ keepAliveIntervalMs: opts.keepAliveIntervalMs,
111
+ rawBinaryInput: opts.rawBinaryInput,
112
+ });
113
+ }
114
+
115
+ function waitForReady(socket: FakeSocket): void {
116
+ expect(socket.json().some((m) => m.type === "ready")).toBe(true);
117
+ }
118
+
119
+ function encodeEnvelopeFrame(
120
+ sequence: number,
121
+ audio: Uint8Array,
122
+ contextId = "turn-envelope",
123
+ ): Uint8Array {
124
+ return encodeSyrinxAudioEnvelope({
125
+ type: "audio",
126
+ contextId,
127
+ sampleRateHz: 16000,
128
+ encoding: "pcm_s16le",
129
+ channels: 1,
130
+ byteLength: audio.byteLength,
131
+ sequence,
132
+ }, audio);
133
+ }
134
+
135
+ function findOddAndEvenEnvelopeFrames(
136
+ pcm: Uint8Array,
137
+ ): { ordered: [Uint8Array, Uint8Array]; odd: Uint8Array; even: Uint8Array } {
138
+ const candidates: Array<{ frame: Uint8Array; parity: number; sequence: number }> = [];
139
+ for (let index = 0; index < 32; index += 1) {
140
+ const frame = encodeEnvelopeFrame(index + 1, pcm, `turn-envelope-${"x".repeat(index)}`);
141
+ candidates.push({ frame, parity: frame.byteLength % 2, sequence: index + 1 });
142
+ }
143
+ const odd = candidates.find((entry) => entry.parity === 1);
144
+ const even = candidates.find((entry) => entry.parity === 0 && entry.sequence > (odd?.sequence ?? 0));
145
+ if (!odd || !even) throw new Error("Could not construct odd and even envelope frame lengths");
146
+ const ordered = odd.sequence < even.sequence
147
+ ? [odd.frame, even.frame] as [Uint8Array, Uint8Array]
148
+ : [even.frame, odd.frame] as [Uint8Array, Uint8Array];
149
+ return { ordered, odd: odd.frame, even: even.frame };
150
+ }
151
+
152
+ describe("edge keep-alive / idle close", () => {
153
+ it("arms a keep-alive heartbeat once the session is ready and re-arms while active", async () => {
154
+ const socket = new FakeSocket();
155
+ const scheduler = new ManualScheduler();
156
+ await runConnection(socket, scheduler, { idleTimeoutMs: 10_000, keepAliveIntervalMs: 1_000 });
157
+
158
+ expect(socket.json().some((m) => m.type === "ready")).toBe(true);
159
+ expect(scheduler.tasks.has(KEEP_ALIVE_KEY)).toBe(true);
160
+
161
+ // A fresh heartbeat with recent client activity must re-arm, not close.
162
+ await scheduler.fire(KEEP_ALIVE_KEY);
163
+ expect(socket.disposed).toBe(false);
164
+ expect(scheduler.tasks.has(KEEP_ALIVE_KEY)).toBe(true);
165
+ });
166
+
167
+ it("closes a half-open client that has been idle past idleTimeoutMs", async () => {
168
+ const socket = new FakeSocket();
169
+ const scheduler = new ManualScheduler();
170
+ await runConnection(socket, scheduler, { idleTimeoutMs: 30, keepAliveIntervalMs: 1_000 });
171
+
172
+ // No further client traffic; let the idle window elapse, then beat.
173
+ await new Promise((r) => setTimeout(r, 60));
174
+ await scheduler.fire(KEEP_ALIVE_KEY);
175
+
176
+ expect(socket.disposed).toBe(true);
177
+ expect(socket.json().some((m) => m.type === "error" && m.category === "idle_timeout")).toBe(true);
178
+ });
179
+
180
+ it("a client message resets the idle window", async () => {
181
+ const socket = new FakeSocket();
182
+ const scheduler = new ManualScheduler();
183
+ await runConnection(socket, scheduler, { idleTimeoutMs: 30, keepAliveIntervalMs: 1_000 });
184
+
185
+ await new Promise((r) => setTimeout(r, 60));
186
+ socket.emit(JSON.stringify({ type: "ping" })); // refreshes lastClientMessageMs
187
+ await scheduler.fire(KEEP_ALIVE_KEY);
188
+
189
+ expect(socket.disposed).toBe(false);
190
+ expect(scheduler.tasks.has(KEEP_ALIVE_KEY)).toBe(true);
191
+ });
192
+
193
+ it("keepAliveIntervalMs=0 disables the heartbeat entirely", async () => {
194
+ const socket = new FakeSocket();
195
+ const scheduler = new ManualScheduler();
196
+ await runConnection(socket, scheduler, { keepAliveIntervalMs: 0 });
197
+
198
+ expect(socket.json().some((m) => m.type === "ready")).toBe(true);
199
+ expect(scheduler.tasks.has(KEEP_ALIVE_KEY)).toBe(false);
200
+ });
201
+ });
202
+
203
+ describe("edge inbound binary audio envelopes", () => {
204
+ it("decodes syrinx.audio.v1 envelopes into even PCM payloads", async () => {
205
+ const received: UserAudioReceivedPacket[] = [];
206
+ const socket = new FakeSocket();
207
+ const scheduler = new ManualScheduler();
208
+ await runConnection(socket, scheduler, { received });
209
+ waitForReady(socket);
210
+
211
+ const pcm = pcm16SamplesToBytes(new Int16Array([0, 32767, -32768, 16384]));
212
+ const { ordered, odd: oddHeaderFrame, even: evenHeaderFrame } = findOddAndEvenEnvelopeFrames(pcm);
213
+ expect(oddHeaderFrame.byteLength % 2).toBe(1);
214
+ expect(evenHeaderFrame.byteLength % 2).toBe(0);
215
+
216
+ for (const frame of ordered) socket.emit(frame, true);
217
+ await new Promise((resolve) => setTimeout(resolve, 20));
218
+
219
+ expect(received).toHaveLength(2);
220
+ for (const packet of received) {
221
+ expect(packet.kind).toBe("user.audio_received");
222
+ expect(packet.contextId?.startsWith("turn-envelope")).toBe(true);
223
+ expect(packet.audio.byteLength % 2).toBe(0);
224
+ expect(packet.audio).toEqual(pcm);
225
+ }
226
+ expect(socket.json().some((m) => m.type === "error")).toBe(false);
227
+ });
228
+
229
+ it("rejects raw binary audio unless rawBinaryInput is enabled", async () => {
230
+ const received: UserAudioReceivedPacket[] = [];
231
+ const socket = new FakeSocket();
232
+ const scheduler = new ManualScheduler();
233
+ await runConnection(socket, scheduler, { received });
234
+ waitForReady(socket);
235
+
236
+ socket.emit(new Uint8Array([1, 2, 3, 4]), true);
237
+ await new Promise((resolve) => setTimeout(resolve, 20));
238
+
239
+ expect(received).toEqual([]);
240
+ expect(socket.json()).toContainEqual({
241
+ type: "error",
242
+ component: "transport",
243
+ category: "invalid_input",
244
+ message: "Raw binary websocket audio is disabled; use syrinx.audio.v1 or JSON audio frames",
245
+ });
246
+ });
247
+ });
248
+
249
+ describe("edge barge-in downlink", () => {
250
+ it("sends audio_clear and agent_interrupted when an interrupt is detected", async () => {
251
+ const socket = new FakeSocket();
252
+ const scheduler = new ManualScheduler();
253
+ const session = fakeSession();
254
+ await runVoiceEdgeWebSocketConnection(socket, new Request("https://edge.test/ws?sessionId=s1"), {
255
+ sessionStore: new InMemorySessionStore(),
256
+ scheduler,
257
+ createSession: () => session,
258
+ });
259
+ waitForReady(socket);
260
+
261
+ session.bus.push(Route.Critical, {
262
+ kind: "interrupt.detected",
263
+ contextId: "assistant-turn",
264
+ timestampMs: Date.now(),
265
+ source: "vad",
266
+ });
267
+ await new Promise((resolve) => setTimeout(resolve, 0));
268
+
269
+ expect(socket.json()).toContainEqual({ type: "audio_clear", turnId: "assistant-turn", reason: "barge_in" });
270
+ expect(socket.json()).toContainEqual({ type: "agent_interrupted", turnId: "assistant-turn", reason: "barge_in" });
271
+ });
272
+ });