@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,440 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // WT-03: Browser outbound pacing + playout clock + client jitter buffer
3
+
4
+ import { createServer } from "node:http";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import WebSocket from "ws";
7
+ import { Route, VoiceAgentSession } from "@kuralle-syrinx/core";
8
+ import { pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
9
+ import { createVoiceWebSocketServer, type VoiceWebSocketServer } from "./index.js";
10
+
11
+ function browserUrl(port: number): string {
12
+ return `ws://127.0.0.1:${port}/ws`;
13
+ }
14
+
15
+ async function openBrowserSocketReady(url: string): Promise<WebSocket> {
16
+ const socket = new WebSocket(url);
17
+ await new Promise<void>((resolve, reject) => {
18
+ const onMessage = (data: WebSocket.RawData, isBinary: boolean): void => {
19
+ if (isBinary) return;
20
+ try {
21
+ const parsed = JSON.parse(data.toString()) as { type?: string };
22
+ if (parsed.type === "ready") {
23
+ socket.off("message", onMessage);
24
+ resolve();
25
+ }
26
+ } catch {
27
+ // Ignore parsing errors
28
+ }
29
+ };
30
+ socket.on("message", onMessage);
31
+ socket.once("error", reject);
32
+ });
33
+ return socket;
34
+ }
35
+
36
+ function waitMs(ms: number): Promise<void> {
37
+ return new Promise((resolve) => setTimeout(resolve, ms));
38
+ }
39
+
40
+ async function waitForCondition(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
41
+ const startedAt = Date.now();
42
+ while (Date.now() - startedAt < timeoutMs) {
43
+ if (predicate()) return;
44
+ await waitMs(10);
45
+ }
46
+ throw new Error("Timed out waiting for browser pacing condition");
47
+ }
48
+
49
+ async function readJsonMatching(socket: WebSocket, predicate: (m: unknown) => boolean): Promise<unknown> {
50
+ return new Promise((resolve) => {
51
+ const onMessage = (data: WebSocket.RawData, isBinary: boolean) => {
52
+ if (isBinary) return;
53
+ const message = JSON.parse(data.toString()) as unknown;
54
+ if (!predicate(message)) return;
55
+ socket.off("message", onMessage);
56
+ resolve(message);
57
+ };
58
+ socket.on("message", onMessage);
59
+ });
60
+ }
61
+
62
+ function collectMessagesMatching(
63
+ socket: WebSocket,
64
+ predicate: (m: unknown) => boolean,
65
+ timeoutMs: number
66
+ ): Promise<unknown[]> {
67
+ return new Promise((resolve) => {
68
+ const messages: unknown[] = [];
69
+ const timeout = setTimeout(() => {
70
+ socket.off("message", onMessage);
71
+ resolve(messages);
72
+ }, timeoutMs);
73
+
74
+ const onMessage = (data: WebSocket.RawData, isBinary: boolean) => {
75
+ if (isBinary) return;
76
+ try {
77
+ const message = JSON.parse(data.toString()) as unknown;
78
+ if (predicate(message)) {
79
+ messages.push(message);
80
+ }
81
+ } catch {
82
+ // Ignore parsing errors
83
+ }
84
+ };
85
+ socket.on("message", onMessage);
86
+ });
87
+ }
88
+
89
+ // Track servers so we can force-close them in afterEach even if test fails
90
+ let activeServers: VoiceWebSocketServer[] = [];
91
+ let activeHttpServers: ReturnType<typeof createServer>[] = [];
92
+
93
+ beforeEach(() => {
94
+ activeServers = [];
95
+ activeHttpServers = [];
96
+ });
97
+
98
+ afterEach(async () => {
99
+ for (const server of activeServers) {
100
+ await server.close().catch(() => undefined);
101
+ }
102
+ for (const httpServer of activeHttpServers) {
103
+ httpServer.close();
104
+ }
105
+ activeServers = [];
106
+ activeHttpServers = [];
107
+ });
108
+
109
+ describe("WT-03 Browser outbound pacing", () => {
110
+ it("browser adapter enqueues paced frames with consistent timing", async () => {
111
+ const httpServer = createServer();
112
+ activeHttpServers.push(httpServer);
113
+
114
+ let session: VoiceAgentSession | null = null;
115
+ const server = await createVoiceWebSocketServer({
116
+ server: httpServer,
117
+ port: 0, // Let the system assign a port
118
+ createSession: () => {
119
+ session = new VoiceAgentSession({ plugins: {} });
120
+ return session;
121
+ },
122
+ outboundFrameDurationMs: 20,
123
+ });
124
+ activeServers.push(server);
125
+
126
+ const port = (server.address() as any).port;
127
+ const socket = await openBrowserSocketReady(browserUrl(port));
128
+
129
+ // Session is already started by the websocket server
130
+
131
+ // Send TTS audio - 160ms of 16kHz PCM16 (should be split into ~8 frames of 20ms each)
132
+ const sampleCount = Math.floor(0.16 * 16000); // 160ms at 16kHz
133
+ const audioSamples = new Int16Array(sampleCount).fill(1000); // Simple sine-like values
134
+ const audioBytes = pcm16SamplesToBytes(audioSamples);
135
+
136
+ // Collect tts_chunk messages over 200ms to ensure all frames arrive
137
+ const ttsChunksPromise = collectMessagesMatching(
138
+ socket,
139
+ (m: any) => m.type === "tts_chunk",
140
+ 300
141
+ );
142
+
143
+ // Send TTS audio
144
+ session!.bus.push(Route.Main, {
145
+ kind: "tts.audio",
146
+ contextId: "test-turn",
147
+ timestampMs: Date.now(),
148
+ audio: audioBytes,
149
+ sampleRateHz: 16000,
150
+ });
151
+
152
+ // End TTS
153
+ session!.bus.push(Route.Main, {
154
+ kind: "tts.end",
155
+ contextId: "test-turn",
156
+ timestampMs: Date.now(),
157
+ });
158
+
159
+ const ttsChunks = await ttsChunksPromise;
160
+ socket.close();
161
+
162
+ // Verify we got multiple frames (pacing is working)
163
+ expect(ttsChunks.length).toBeGreaterThan(1);
164
+
165
+ // Verify each frame has expected structure
166
+ for (const chunk of ttsChunks) {
167
+ const c = chunk as any;
168
+ expect(c.type).toBe("tts_chunk");
169
+ expect(c.turnId).toBe("test-turn");
170
+ expect(c.sequence).toBeGreaterThan(0);
171
+ expect(c.sampleRateHz).toBe(16000);
172
+ expect(c.encoding).toBe("opus");
173
+ expect(c.channels).toBe(1);
174
+ expect(c.byteLength).toBeGreaterThan(0);
175
+ expect(c.durationMs).toBeGreaterThan(0);
176
+ }
177
+
178
+ // Verify sequence numbers increment
179
+ const sequences = ttsChunks.map((c: any) => c.sequence);
180
+ for (let i = 1; i < sequences.length; i++) {
181
+ expect(sequences[i]).toBe(sequences[i - 1] + 1);
182
+ }
183
+ });
184
+
185
+ it("emits tts.playout_progress events during paced playback", async () => {
186
+ const httpServer = createServer();
187
+ activeHttpServers.push(httpServer);
188
+
189
+ let session: VoiceAgentSession | null = null;
190
+ const server = await createVoiceWebSocketServer({
191
+ server: httpServer,
192
+ port: 0,
193
+ createSession: () => {
194
+ session = new VoiceAgentSession({ plugins: {} });
195
+ return session;
196
+ },
197
+ outboundFrameDurationMs: 20,
198
+ });
199
+ activeServers.push(server);
200
+
201
+ const port = (server.address() as any).port;
202
+ const socket = await openBrowserSocketReady(browserUrl(port));
203
+
204
+ // Session is already started by the websocket server
205
+
206
+ // Track playout progress events
207
+ const progressEvents: any[] = [];
208
+ const removeProgressHandler = session!.bus.on("tts.playout_progress", (pkt: any) => {
209
+ if (pkt.contextId === "test-turn") {
210
+ progressEvents.push(pkt);
211
+ }
212
+ });
213
+
214
+ // Send TTS audio - 80ms worth
215
+ const sampleCount = Math.floor(0.08 * 16000);
216
+ const audioSamples = new Int16Array(sampleCount).fill(1000);
217
+ const audioBytes = pcm16SamplesToBytes(audioSamples);
218
+
219
+ session!.bus.push(Route.Main, {
220
+ kind: "tts.audio",
221
+ contextId: "test-turn",
222
+ timestampMs: Date.now(),
223
+ audio: audioBytes,
224
+ sampleRateHz: 16000,
225
+ });
226
+
227
+ session!.bus.push(Route.Main, {
228
+ kind: "tts.end",
229
+ contextId: "test-turn",
230
+ timestampMs: Date.now(),
231
+ });
232
+
233
+ // Wait for playout to complete
234
+ await waitMs(150);
235
+
236
+ socket.close();
237
+ removeProgressHandler();
238
+
239
+ // Should have received playout progress events
240
+ expect(progressEvents.length).toBeGreaterThan(0);
241
+
242
+ // Last event should be completion
243
+ const lastEvent = progressEvents[progressEvents.length - 1];
244
+ expect(lastEvent.complete).toBe(true);
245
+ });
246
+
247
+ it("handles interrupt.tts by clearing playout queue and sending audio_clear", async () => {
248
+ const httpServer = createServer();
249
+ activeHttpServers.push(httpServer);
250
+
251
+ let session: VoiceAgentSession | null = null;
252
+ const server = await createVoiceWebSocketServer({
253
+ server: httpServer,
254
+ port: 0,
255
+ createSession: () => {
256
+ session = new VoiceAgentSession({ plugins: {} });
257
+ return session;
258
+ },
259
+ outboundFrameDurationMs: 20,
260
+ });
261
+ activeServers.push(server);
262
+
263
+ const port = (server.address() as any).port;
264
+ const socket = await openBrowserSocketReady(browserUrl(port));
265
+
266
+ // Session is already started by the websocket server
267
+
268
+ // Send a longer TTS audio burst
269
+ const sampleCount = Math.floor(0.2 * 16000); // 200ms
270
+ const audioSamples = new Int16Array(sampleCount).fill(1000);
271
+ const audioBytes = pcm16SamplesToBytes(audioSamples);
272
+
273
+ session!.bus.push(Route.Main, {
274
+ kind: "tts.audio",
275
+ contextId: "test-turn",
276
+ timestampMs: Date.now(),
277
+ audio: audioBytes,
278
+ sampleRateHz: 16000,
279
+ });
280
+
281
+ // Wait a bit for some frames to be queued
282
+ await waitMs(10);
283
+
284
+ // Listen for audio_clear message
285
+ const audioClearPromise = readJsonMatching(socket, (m: any) => m.type === "audio_clear");
286
+
287
+ // Send interrupt
288
+ session!.bus.push(Route.Main, {
289
+ kind: "interrupt.tts",
290
+ contextId: "test-turn",
291
+ timestampMs: Date.now(),
292
+ });
293
+
294
+ const audioClear = await audioClearPromise;
295
+ socket.close();
296
+
297
+ expect((audioClear as any).type).toBe("audio_clear");
298
+ expect((audioClear as any).turnId).toBe("test-turn");
299
+ expect((audioClear as any).reason).toBe("barge_in");
300
+ });
301
+
302
+ it("respects custom outbound frame duration and queue limits", async () => {
303
+ const httpServer = createServer();
304
+ activeHttpServers.push(httpServer);
305
+
306
+ let session: VoiceAgentSession | null = null;
307
+ const server = await createVoiceWebSocketServer({
308
+ server: httpServer,
309
+ port: 0,
310
+ createSession: () => {
311
+ session = new VoiceAgentSession({ plugins: {} });
312
+ return session;
313
+ },
314
+ outboundFrameDurationMs: 40, // Larger frames
315
+ maxQueuedOutputAudioMs: 100, // Small queue to trigger overflow
316
+ });
317
+ activeServers.push(server);
318
+
319
+ const port = (server.address() as any).port;
320
+ const socket = await openBrowserSocketReady(browserUrl(port));
321
+
322
+ // Session is already started by the websocket server
323
+
324
+ // Send large audio burst to trigger overflow
325
+ const sampleCount = Math.floor(1.0 * 16000); // 1 second
326
+ const audioSamples = new Int16Array(sampleCount).fill(1000);
327
+ const audioBytes = pcm16SamplesToBytes(audioSamples);
328
+
329
+ const ttsChunksPromise = collectMessagesMatching(
330
+ socket,
331
+ (m: any) => m.type === "tts_chunk",
332
+ 200
333
+ );
334
+
335
+ session!.bus.push(Route.Main, {
336
+ kind: "tts.audio",
337
+ contextId: "test-turn",
338
+ timestampMs: Date.now(),
339
+ audio: audioBytes,
340
+ sampleRateHz: 16000,
341
+ });
342
+
343
+ const ttsChunks = await ttsChunksPromise;
344
+ socket.close();
345
+
346
+ // Tail-drop overflow: the head of the burst (up to the 100ms cap) still
347
+ // plays out — a tiny cap must never silence the stream entirely.
348
+ expect(ttsChunks.length).toBeGreaterThan(0);
349
+ expect(ttsChunks.length).toBeLessThan(20);
350
+ const firstChunk = ttsChunks[0] as any;
351
+ expect(firstChunk.durationMs).toBeGreaterThan(0);
352
+ });
353
+
354
+ it("delivers a long reply in full under the default playout bound (no overflow)", async () => {
355
+ const httpServer = createServer();
356
+ activeHttpServers.push(httpServer);
357
+
358
+ let session: VoiceAgentSession | null = null;
359
+ const metrics: Array<{ name: string; value: string }> = [];
360
+ const server = await createVoiceWebSocketServer({
361
+ server: httpServer,
362
+ port: 0,
363
+ createSession: () => {
364
+ session = new VoiceAgentSession({ plugins: {} });
365
+ return session;
366
+ },
367
+ });
368
+ activeServers.push(server);
369
+
370
+ const port = (server.address() as any).port;
371
+ const socket = await openBrowserSocketReady(browserUrl(port));
372
+ session!.bus.on("metric.conversation", (pkt) => {
373
+ const metric = pkt as unknown as { name: string; value: string };
374
+ metrics.push(metric);
375
+ });
376
+
377
+ const ttsChunksPromise = collectMessagesMatching(
378
+ socket,
379
+ (m: any) => m.type === "tts_chunk",
380
+ 1_200, // collect for the full paced second
381
+ );
382
+ // A 1s burst — far beyond the old 200ms cap that used to clear-and-stop.
383
+ session!.bus.push(Route.Main, {
384
+ kind: "tts.audio",
385
+ contextId: "default-bound-turn",
386
+ timestampMs: Date.now(),
387
+ audio: pcm16SamplesToBytes(new Int16Array(16000)),
388
+ sampleRateHz: 16000,
389
+ });
390
+
391
+ const ttsChunks = await ttsChunksPromise;
392
+ socket.close();
393
+
394
+ expect(ttsChunks.length).toBeGreaterThanOrEqual(40); // ≥800ms of the reply paced out
395
+ expect(metrics.some((m) => m.name.includes("overflow"))).toBe(false);
396
+ });
397
+
398
+ it("allows long playout queues only through an explicit override", async () => {
399
+ const httpServer = createServer();
400
+ activeHttpServers.push(httpServer);
401
+
402
+ let session: VoiceAgentSession | null = null;
403
+ const server = await createVoiceWebSocketServer({
404
+ server: httpServer,
405
+ port: 0,
406
+ createSession: () => {
407
+ session = new VoiceAgentSession({ plugins: {} });
408
+ return session;
409
+ },
410
+ maxQueuedOutputAudioMs: 1000,
411
+ });
412
+ activeServers.push(server);
413
+
414
+ const port = (server.address() as any).port;
415
+ const socket = await openBrowserSocketReady(browserUrl(port));
416
+ let closed = false;
417
+ socket.once("close", () => {
418
+ closed = true;
419
+ });
420
+
421
+ const ttsChunksPromise = collectMessagesMatching(
422
+ socket,
423
+ (m: any) => m.type === "tts_chunk",
424
+ 150,
425
+ );
426
+ session!.bus.push(Route.Main, {
427
+ kind: "tts.audio",
428
+ contextId: "explicit-long-queue-turn",
429
+ timestampMs: Date.now(),
430
+ audio: pcm16SamplesToBytes(new Int16Array(6400)),
431
+ sampleRateHz: 16000,
432
+ });
433
+
434
+ const chunks = await ttsChunksPromise;
435
+ socket.close();
436
+
437
+ expect(chunks.length).toBeGreaterThan(0);
438
+ expect(closed).toBe(false);
439
+ });
440
+ });
@@ -0,0 +1,215 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ PipelineBusImpl,
6
+ Route,
7
+ type UserAudioReceivedPacket,
8
+ type VoiceAgentSession,
9
+ } from "@kuralle-syrinx/core";
10
+ import {
11
+ decodeMuLawToPcm16,
12
+ encodePcm16ToMuLaw,
13
+ pcm16BytesToSamples,
14
+ } from "@kuralle-syrinx/core/audio";
15
+ import type { ManagedSocket, SocketData } from "@kuralle-syrinx/ws";
16
+ import { InMemorySessionStore } from "./session-store.js";
17
+ import { runTwilioEdgeWebSocketConnection } from "./edge-twilio.js";
18
+
19
+ class FakeSocket implements ManagedSocket {
20
+ isOpen = true;
21
+ disposed = false;
22
+ readonly sent: SocketData[] = [];
23
+ #onMessage?: (data: SocketData, isBinary: boolean) => void;
24
+ #onClose?: (code: number, reason: string) => void;
25
+ get isOpenValue(): boolean {
26
+ return this.isOpen;
27
+ }
28
+ send(data: SocketData): void {
29
+ this.sent.push(data);
30
+ }
31
+ keepAlivePing(): void {}
32
+ async verify(): Promise<boolean> {
33
+ return this.isOpen;
34
+ }
35
+ dispose(): void {
36
+ this.disposed = true;
37
+ this.isOpen = false;
38
+ this.#onClose?.(1000, "disposed");
39
+ }
40
+ onOpen(): void {}
41
+ onMessage(handler: (data: SocketData, isBinary: boolean) => void): void {
42
+ this.#onMessage = handler;
43
+ }
44
+ onClose(handler: (code: number, reason: string) => void): void {
45
+ this.#onClose = handler;
46
+ }
47
+ onError(): void {}
48
+ emit(data: SocketData): void {
49
+ this.#onMessage?.(data, false);
50
+ }
51
+ json(): Array<Record<string, unknown>> {
52
+ return this.sent
53
+ .filter((entry): entry is string => typeof entry === "string")
54
+ .map((entry) => JSON.parse(entry) as Record<string, unknown>);
55
+ }
56
+ }
57
+
58
+ function fakeSession(received: UserAudioReceivedPacket[] = []): VoiceAgentSession {
59
+ const bus = new PipelineBusImpl();
60
+ bus.on("user.audio_received", (pkt) => {
61
+ received.push(pkt as UserAudioReceivedPacket);
62
+ });
63
+ return {
64
+ bus,
65
+ async start() {
66
+ void bus.start();
67
+ },
68
+ async close() {
69
+ bus.stop();
70
+ },
71
+ on() {},
72
+ off() {},
73
+ requestClientInterrupt() {},
74
+ } as unknown as VoiceAgentSession;
75
+ }
76
+
77
+ function bytesToBase64(bytes: Uint8Array): string {
78
+ return Buffer.from(bytes).toString("base64");
79
+ }
80
+
81
+ async function startConnection(received: UserAudioReceivedPacket[] = []) {
82
+ const socket = new FakeSocket();
83
+ const session = fakeSession(received);
84
+ await runTwilioEdgeWebSocketConnection(
85
+ socket,
86
+ new Request("https://edge.test/twilio?sessionId=tw1"),
87
+ {
88
+ sessionStore: new InMemorySessionStore(),
89
+ createSession: () => session,
90
+ keepAliveIntervalMs: 0,
91
+ },
92
+ );
93
+ socket.emit(JSON.stringify({ event: "connected", protocol: "Call" }));
94
+ socket.emit(JSON.stringify({
95
+ event: "start",
96
+ streamSid: "MZ123",
97
+ start: { streamSid: "MZ123", callSid: "CA456" },
98
+ }));
99
+ return { socket, session };
100
+ }
101
+
102
+ describe("Twilio edge ingress", () => {
103
+ it("decodes inbound mu-law media to 16k PCM user audio with a stable callSid context", async () => {
104
+ const received: UserAudioReceivedPacket[] = [];
105
+ const { socket } = await startConnection(received);
106
+
107
+ const samples8k = new Int16Array(160).fill(8000); // 20ms at 8k
108
+ const payload = bytesToBase64(encodePcm16ToMuLaw(samples8k));
109
+ socket.emit(JSON.stringify({ event: "media", streamSid: "MZ123", media: { payload } }));
110
+ await new Promise((resolve) => setTimeout(resolve, 10));
111
+
112
+ expect(received).toHaveLength(1);
113
+ expect(received[0]!.contextId).toBe("twilio-CA456");
114
+ const pcm = pcm16BytesToSamples(received[0]!.audio);
115
+ expect(pcm.length).toBe(320); // 8k → 16k doubles the samples
116
+ expect(Math.abs(pcm[100]! - 8000)).toBeLessThan(600); // mu-law quantization tolerance
117
+ });
118
+
119
+ it("encodes engine TTS PCM to 8k mu-law media frames for Twilio", async () => {
120
+ const { socket, session } = await startConnection();
121
+
122
+ const pcm16k = new Int16Array(640).fill(6000); // 40ms at 16k
123
+ const bytes = new Uint8Array(pcm16k.buffer.slice(0));
124
+ session.bus.push(Route.Critical, {
125
+ kind: "tts.audio",
126
+ contextId: "twilio-CA456",
127
+ timestampMs: Date.now(),
128
+ audio: bytes,
129
+ sampleRateHz: 16000,
130
+ });
131
+ await new Promise((resolve) => setTimeout(resolve, 10));
132
+
133
+ const media = socket.json().filter((msg) => msg.event === "media");
134
+ expect(media).toHaveLength(1);
135
+ expect(media[0]!.streamSid).toBe("MZ123");
136
+ const payload = (media[0]!.media as { payload: string }).payload;
137
+ const decoded = decodeMuLawToPcm16(new Uint8Array(Buffer.from(payload, "base64")));
138
+ expect(decoded.length).toBe(320); // 16k → 8k halves the samples
139
+ expect(Math.abs(decoded[100]! - 6000)).toBeLessThan(600);
140
+ });
141
+
142
+ it("sends a clear event on interrupt.detected (barge-in)", async () => {
143
+ const { socket, session } = await startConnection();
144
+
145
+ session.bus.push(Route.Critical, {
146
+ kind: "interrupt.detected",
147
+ contextId: "twilio-CA456",
148
+ timestampMs: Date.now(),
149
+ source: "vad",
150
+ });
151
+ await new Promise((resolve) => setTimeout(resolve, 10));
152
+
153
+ expect(socket.json()).toContainEqual({ event: "clear", streamSid: "MZ123" });
154
+ });
155
+
156
+ it("releases the session when Twilio sends stop", async () => {
157
+ const { socket } = await startConnection();
158
+ socket.emit(JSON.stringify({ event: "stop", streamSid: "MZ123" }));
159
+ expect(socket.disposed).toBe(true);
160
+ });
161
+
162
+ it("rotates the uplink contextId after each completed turn", async () => {
163
+ const received: UserAudioReceivedPacket[] = [];
164
+ const { socket, session } = await startConnection(received);
165
+ const payload = bytesToBase64(encodePcm16ToMuLaw(new Int16Array(160).fill(4000)));
166
+
167
+ socket.emit(JSON.stringify({ event: "media", streamSid: "MZ123", media: { payload } }));
168
+ session.bus.push(Route.Main, {
169
+ kind: "eos.turn_complete",
170
+ contextId: "twilio-CA456",
171
+ timestampMs: Date.now(),
172
+ text: "first turn",
173
+ transcripts: [],
174
+ });
175
+ await new Promise((resolve) => setTimeout(resolve, 10));
176
+ socket.emit(JSON.stringify({ event: "media", streamSid: "MZ123", media: { payload } }));
177
+ await new Promise((resolve) => setTimeout(resolve, 10));
178
+
179
+ expect(received).toHaveLength(2);
180
+ expect(received[0]!.contextId).toBe("twilio-CA456");
181
+ expect(received[1]!.contextId).toBe("twilio-CA456-t1");
182
+ });
183
+
184
+ it("buffers start/media that arrive before the session lease resolves", async () => {
185
+ const received: UserAudioReceivedPacket[] = [];
186
+ const socket = new FakeSocket();
187
+ const session = fakeSession(received);
188
+ const run = runTwilioEdgeWebSocketConnection(
189
+ socket,
190
+ new Request("https://edge.test/twilio?sessionId=tw-early"),
191
+ {
192
+ sessionStore: new InMemorySessionStore(),
193
+ createSession: async () => {
194
+ await new Promise((resolve) => setTimeout(resolve, 30)); // slow startup
195
+ return session;
196
+ },
197
+ keepAliveIntervalMs: 0,
198
+ },
199
+ );
200
+ // Twilio talks immediately — before startup finishes.
201
+ socket.emit(JSON.stringify({ event: "connected", protocol: "Call" }));
202
+ socket.emit(JSON.stringify({
203
+ event: "start",
204
+ streamSid: "MZ9",
205
+ start: { streamSid: "MZ9", callSid: "CA9" },
206
+ }));
207
+ const payload = bytesToBase64(encodePcm16ToMuLaw(new Int16Array(160).fill(4000)));
208
+ socket.emit(JSON.stringify({ event: "media", streamSid: "MZ9", media: { payload } }));
209
+ await run;
210
+ await new Promise((resolve) => setTimeout(resolve, 10));
211
+
212
+ expect(received).toHaveLength(1);
213
+ expect(received[0]!.contextId).toBe("twilio-CA9");
214
+ });
215
+ });