@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,1275 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import WebSocket from "ws";
5
+ import { Route, VoiceAgentSession, type ConversationMetricPacket, type RecordAssistantAudioPacket, type UserAudioReceivedPacket } from "@kuralle-syrinx/core";
6
+ import { createTwilioMediaStreamServer } from "./twilio.js";
7
+ import { decodeMuLawToPcm16, encodePcm16ToMuLaw, pcm16SamplesToBytes, resamplePcm16 } from "@kuralle-syrinx/core/audio";
8
+ import {
9
+ openSocket,
10
+ readJsonMatching,
11
+ registerServer,
12
+ setupTransportTestCleanup,
13
+ waitForCondition,
14
+ } from "./test-helpers.js";
15
+
16
+ setupTransportTestCleanup();
17
+
18
+ function twilioUrl(port: number): string {
19
+ return `ws://127.0.0.1:${port}/twilio`;
20
+ }
21
+
22
+ function twilioStart(streamSid = "MZ-test-stream", callSid = "CA-test-call"): Record<string, unknown> {
23
+ return {
24
+ event: "start",
25
+ streamSid,
26
+ start: {
27
+ streamSid,
28
+ callSid,
29
+ mediaFormat: {
30
+ encoding: "audio/x-mulaw",
31
+ sampleRate: 8000,
32
+ channels: 1,
33
+ },
34
+ },
35
+ };
36
+ }
37
+
38
+ describe("createTwilioMediaStreamServer", () => {
39
+ it("does not negotiate websocket compression for carrier media streams", async () => {
40
+ const server = registerServer(await createTwilioMediaStreamServer({
41
+ port: 0,
42
+ createSession: () => new VoiceAgentSession({ plugins: {} }),
43
+ }));
44
+ const address = server.address();
45
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
46
+
47
+ const client = await openSocket(twilioUrl(address.port), { perMessageDeflate: true });
48
+ expect(client.extensions).toBe("");
49
+
50
+ client.close();
51
+ await server.close();
52
+ });
53
+
54
+ it("decodes Twilio PCMU media frames into engine PCM16 audio packets", async () => {
55
+ const session = new VoiceAgentSession({ plugins: {} });
56
+ const received: UserAudioReceivedPacket[] = [];
57
+ session.bus.on("user.audio_received", (pkt) => {
58
+ received.push(pkt as UserAudioReceivedPacket);
59
+ });
60
+
61
+ const server = registerServer(await createTwilioMediaStreamServer({
62
+ port: 0,
63
+ inputSampleRateHz: 16000,
64
+ createSession: () => session,
65
+ contextId: () => "twilio-call-test",
66
+ }));
67
+ const address = server.address();
68
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
69
+
70
+ const client = await openSocket(twilioUrl(address.port));
71
+ const samples8k = new Int16Array([0, 1000, -1000, 3000]);
72
+ const ulaw = encodePcm16ToMuLaw(samples8k);
73
+
74
+ client.send(JSON.stringify({ event: "connected" }));
75
+ client.send(JSON.stringify(twilioStart()));
76
+ client.send(JSON.stringify({
77
+ event: "media",
78
+ streamSid: "MZ-test-stream",
79
+ media: {
80
+ track: "inbound",
81
+ chunk: "1",
82
+ timestamp: "20",
83
+ payload: Buffer.from(ulaw).toString("base64"),
84
+ },
85
+ }));
86
+ await new Promise((resolve) => setTimeout(resolve, 20));
87
+
88
+ const decoded = decodeMuLawToPcm16(ulaw);
89
+ const expected = pcm16SamplesToBytes(resamplePcm16(decoded, 8000, 16000));
90
+ expect(received).toEqual([
91
+ expect.objectContaining({
92
+ kind: "user.audio_received",
93
+ contextId: "twilio-call-test",
94
+ }),
95
+ ]);
96
+ expect(Buffer.from(received[0]!.audio)).toEqual(Buffer.from(expected));
97
+
98
+ client.close();
99
+ await server.close();
100
+ });
101
+
102
+ it("records Twilio media chunk gaps without dropping monotonic media", async () => {
103
+ const session = new VoiceAgentSession({ plugins: {} });
104
+ const received: UserAudioReceivedPacket[] = [];
105
+ const metrics: ConversationMetricPacket[] = [];
106
+ session.bus.on("user.audio_received", (pkt) => {
107
+ received.push(pkt as UserAudioReceivedPacket);
108
+ });
109
+ session.bus.on("metric.conversation", (pkt) => {
110
+ metrics.push(pkt as ConversationMetricPacket);
111
+ });
112
+
113
+ const server = registerServer(await createTwilioMediaStreamServer({
114
+ port: 0,
115
+ inputSampleRateHz: 16000,
116
+ createSession: () => session,
117
+ contextId: () => "twilio-gap-test",
118
+ }));
119
+ const address = server.address();
120
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
121
+
122
+ const client = await openSocket(twilioUrl(address.port));
123
+ const payload = Buffer.from(encodePcm16ToMuLaw(new Int16Array([0, 1000, -1000, 3000]))).toString("base64");
124
+
125
+ client.send(JSON.stringify(twilioStart()));
126
+ client.send(JSON.stringify({
127
+ event: "media",
128
+ streamSid: "MZ-test-stream",
129
+ media: { track: "inbound", chunk: "1", timestamp: "20", payload },
130
+ }));
131
+ client.send(JSON.stringify({
132
+ event: "media",
133
+ streamSid: "MZ-test-stream",
134
+ media: { track: "inbound", chunk: "4", timestamp: "80", payload },
135
+ }));
136
+ await new Promise((resolve) => setTimeout(resolve, 20));
137
+
138
+ expect(received).toHaveLength(2);
139
+ expect(metrics).toContainEqual(expect.objectContaining({
140
+ kind: "metric.conversation",
141
+ contextId: "twilio-gap-test",
142
+ name: "twilio.media_chunk_gap",
143
+ value: JSON.stringify({ expected: 2, actual: 4, missed: 2 }),
144
+ }));
145
+
146
+ client.close();
147
+ await server.close();
148
+ });
149
+
150
+ it("records Twilio media timestamp gaps and regressions without dropping media", async () => {
151
+ const session = new VoiceAgentSession({ plugins: {} });
152
+ const received: UserAudioReceivedPacket[] = [];
153
+ const metrics: ConversationMetricPacket[] = [];
154
+ session.bus.on("user.audio_received", (pkt) => {
155
+ received.push(pkt as UserAudioReceivedPacket);
156
+ });
157
+ session.bus.on("metric.conversation", (pkt) => {
158
+ metrics.push(pkt as ConversationMetricPacket);
159
+ });
160
+
161
+ const server = registerServer(await createTwilioMediaStreamServer({
162
+ port: 0,
163
+ inputSampleRateHz: 16000,
164
+ createSession: () => session,
165
+ contextId: () => "twilio-timestamp-test",
166
+ }));
167
+ const address = server.address();
168
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
169
+
170
+ const client = await openSocket(twilioUrl(address.port));
171
+ const payload = Buffer.from(encodePcm16ToMuLaw(new Int16Array(160))).toString("base64");
172
+
173
+ client.send(JSON.stringify(twilioStart()));
174
+ client.send(JSON.stringify({
175
+ event: "media",
176
+ streamSid: "MZ-test-stream",
177
+ media: { track: "inbound", chunk: "1", timestamp: "0", payload },
178
+ }));
179
+ client.send(JSON.stringify({
180
+ event: "media",
181
+ streamSid: "MZ-test-stream",
182
+ media: { track: "inbound", chunk: "2", timestamp: "60", payload },
183
+ }));
184
+ client.send(JSON.stringify({
185
+ event: "media",
186
+ streamSid: "MZ-test-stream",
187
+ media: { track: "inbound", chunk: "3", timestamp: "40", payload },
188
+ }));
189
+ await new Promise((resolve) => setTimeout(resolve, 20));
190
+
191
+ expect(received).toHaveLength(3);
192
+ expect(metrics).toContainEqual(expect.objectContaining({
193
+ kind: "metric.conversation",
194
+ contextId: "twilio-timestamp-test",
195
+ name: "twilio.media_timestamp_gap",
196
+ value: JSON.stringify({ expected: 20, actual: 60, missedMs: 40 }),
197
+ }));
198
+ expect(metrics).toContainEqual(expect.objectContaining({
199
+ kind: "metric.conversation",
200
+ contextId: "twilio-timestamp-test",
201
+ name: "twilio.media_timestamp_regression",
202
+ value: JSON.stringify({ previous: 60, actual: 40 }),
203
+ }));
204
+
205
+ client.close();
206
+ await server.close();
207
+ });
208
+
209
+ it("rejects duplicate Twilio media chunks before forwarding duplicate audio", async () => {
210
+ const session = new VoiceAgentSession({ plugins: {} });
211
+ const received: UserAudioReceivedPacket[] = [];
212
+ session.bus.on("user.audio_received", (pkt) => {
213
+ received.push(pkt as UserAudioReceivedPacket);
214
+ });
215
+
216
+ const server = registerServer(await createTwilioMediaStreamServer({
217
+ port: 0,
218
+ inputSampleRateHz: 16000,
219
+ createSession: () => session,
220
+ contextId: () => "twilio-duplicate-test",
221
+ }));
222
+ const address = server.address();
223
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
224
+
225
+ const client = await openSocket(twilioUrl(address.port));
226
+ const errorMessage = readJsonMatching(client, (message) => message.event === "syrinx_error");
227
+ const payload = Buffer.from(encodePcm16ToMuLaw(new Int16Array([0, 1000, -1000, 3000]))).toString("base64");
228
+
229
+ client.send(JSON.stringify(twilioStart()));
230
+ client.send(JSON.stringify({
231
+ event: "media",
232
+ streamSid: "MZ-test-stream",
233
+ media: { track: "inbound", chunk: "2", timestamp: "40", payload },
234
+ }));
235
+ await new Promise((resolve) => setTimeout(resolve, 20));
236
+ client.send(JSON.stringify({
237
+ event: "media",
238
+ streamSid: "MZ-test-stream",
239
+ media: { track: "inbound", chunk: "2", timestamp: "40", payload },
240
+ }));
241
+
242
+ await expect(errorMessage).resolves.toMatchObject({
243
+ event: "syrinx_error",
244
+ error: {
245
+ component: "transport",
246
+ category: "invalid_input",
247
+ message: "Twilio media.chunk must increase monotonically: 2 -> 2",
248
+ },
249
+ });
250
+ expect(received).toHaveLength(1);
251
+
252
+ client.close();
253
+ await server.close();
254
+ });
255
+
256
+ it("records Twilio top-level sequenceNumber gaps", async () => {
257
+ const session = new VoiceAgentSession({ plugins: {} });
258
+ const metrics: ConversationMetricPacket[] = [];
259
+ session.bus.on("metric.conversation", (pkt) => {
260
+ metrics.push(pkt as ConversationMetricPacket);
261
+ });
262
+
263
+ const server = registerServer(await createTwilioMediaStreamServer({
264
+ port: 0,
265
+ inputSampleRateHz: 16000,
266
+ createSession: () => session,
267
+ contextId: () => "twilio-sequence-gap-test",
268
+ }));
269
+ const address = server.address();
270
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
271
+
272
+ const client = await openSocket(twilioUrl(address.port));
273
+ const payload = Buffer.from(encodePcm16ToMuLaw(new Int16Array([0, 1000, -1000, 3000]))).toString("base64");
274
+
275
+ client.send(JSON.stringify({ ...twilioStart(), sequenceNumber: "1" }));
276
+ client.send(JSON.stringify({
277
+ event: "media",
278
+ sequenceNumber: "4",
279
+ streamSid: "MZ-test-stream",
280
+ media: { track: "inbound", chunk: "1", timestamp: "20", payload },
281
+ }));
282
+ await waitForCondition(() => metrics.some((metric) =>
283
+ metric.contextId === "twilio-sequence-gap-test" &&
284
+ metric.name === "twilio.sequence_gap",
285
+ ));
286
+
287
+ expect(metrics).toContainEqual(expect.objectContaining({
288
+ kind: "metric.conversation",
289
+ contextId: "twilio-sequence-gap-test",
290
+ name: "twilio.sequence_gap",
291
+ value: JSON.stringify({ expected: 2, actual: 4, missed: 2 }),
292
+ }));
293
+
294
+ client.close();
295
+ await server.close();
296
+ });
297
+
298
+ it("rejects duplicate Twilio top-level sequenceNumber before forwarding duplicate audio", async () => {
299
+ const session = new VoiceAgentSession({ plugins: {} });
300
+ const received: UserAudioReceivedPacket[] = [];
301
+ session.bus.on("user.audio_received", (pkt) => {
302
+ received.push(pkt as UserAudioReceivedPacket);
303
+ });
304
+
305
+ const server = registerServer(await createTwilioMediaStreamServer({
306
+ port: 0,
307
+ inputSampleRateHz: 16000,
308
+ createSession: () => session,
309
+ contextId: () => "twilio-sequence-duplicate-test",
310
+ }));
311
+ const address = server.address();
312
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
313
+
314
+ const client = await openSocket(twilioUrl(address.port));
315
+ const errorMessage = readJsonMatching(client, (message) => message.event === "syrinx_error");
316
+ const payload = Buffer.from(encodePcm16ToMuLaw(new Int16Array([0, 1000, -1000, 3000]))).toString("base64");
317
+
318
+ client.send(JSON.stringify({ ...twilioStart(), sequenceNumber: "1" }));
319
+ client.send(JSON.stringify({
320
+ event: "media",
321
+ sequenceNumber: "2",
322
+ streamSid: "MZ-test-stream",
323
+ media: { track: "inbound", chunk: "1", timestamp: "20", payload },
324
+ }));
325
+ await new Promise((resolve) => setTimeout(resolve, 20));
326
+ client.send(JSON.stringify({
327
+ event: "media",
328
+ sequenceNumber: "2",
329
+ streamSid: "MZ-test-stream",
330
+ media: { track: "inbound", chunk: "2", timestamp: "40", payload },
331
+ }));
332
+
333
+ await expect(errorMessage).resolves.toMatchObject({
334
+ event: "syrinx_error",
335
+ error: {
336
+ component: "transport",
337
+ category: "invalid_input",
338
+ message: "Twilio sequenceNumber must increase monotonically: 2 -> 2",
339
+ },
340
+ });
341
+ expect(received).toHaveLength(1);
342
+
343
+ client.close();
344
+ await server.close();
345
+ });
346
+
347
+ it("buffers Twilio start and media sent before session startup completes", async () => {
348
+ const session = new VoiceAgentSession({ plugins: {} });
349
+ const received: UserAudioReceivedPacket[] = [];
350
+ session.bus.on("user.audio_received", (pkt) => {
351
+ received.push(pkt as UserAudioReceivedPacket);
352
+ });
353
+
354
+ const server = registerServer(await createTwilioMediaStreamServer({
355
+ port: 0,
356
+ inputSampleRateHz: 16000,
357
+ createSession: async () => {
358
+ await new Promise((resolve) => setTimeout(resolve, 30));
359
+ return session;
360
+ },
361
+ contextId: () => "twilio-delayed-session",
362
+ }));
363
+ const address = server.address();
364
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
365
+
366
+ const client = await openSocket(twilioUrl(address.port));
367
+ const ulaw = encodePcm16ToMuLaw(new Int16Array([0, 1000, -1000, 3000]));
368
+ client.send(JSON.stringify(twilioStart()));
369
+ client.send(JSON.stringify({
370
+ event: "media",
371
+ streamSid: "MZ-test-stream",
372
+ media: {
373
+ track: "inbound",
374
+ chunk: "1",
375
+ timestamp: "20",
376
+ payload: Buffer.from(ulaw).toString("base64"),
377
+ },
378
+ }));
379
+ await new Promise((resolve) => setTimeout(resolve, 80));
380
+
381
+ expect(received).toEqual([
382
+ expect.objectContaining({
383
+ kind: "user.audio_received",
384
+ contextId: "twilio-delayed-session",
385
+ }),
386
+ ]);
387
+
388
+ client.close();
389
+ await server.close();
390
+ });
391
+
392
+ it("closes Twilio websocket connections when session startup exceeds startupTimeoutMs", async () => {
393
+ const server = registerServer(await createTwilioMediaStreamServer({
394
+ port: 0,
395
+ startupTimeoutMs: 10,
396
+ createSession: () => new Promise<VoiceAgentSession>(() => undefined),
397
+ }));
398
+ const address = server.address();
399
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
400
+
401
+ const client = await openSocket(twilioUrl(address.port));
402
+ const errorMessage = readJsonMatching(client, (message) => message.event === "syrinx_error");
403
+ const closed = new Promise<{ code: number; reason: string }>((resolve) => {
404
+ client.once("close", (code, reason) => {
405
+ resolve({ code, reason: reason.toString() });
406
+ });
407
+ });
408
+
409
+ await expect(errorMessage).resolves.toMatchObject({
410
+ event: "syrinx_error",
411
+ error: {
412
+ component: "transport",
413
+ },
414
+ });
415
+ await expect(closed).resolves.toEqual({
416
+ code: 1011,
417
+ reason: "session initialization failed",
418
+ });
419
+
420
+ await server.close();
421
+ });
422
+
423
+ it("rejects unsupported Twilio start media formats before forwarding audio", async () => {
424
+ const session = new VoiceAgentSession({ plugins: {} });
425
+ const received: UserAudioReceivedPacket[] = [];
426
+ session.bus.on("user.audio_received", (pkt) => {
427
+ received.push(pkt as UserAudioReceivedPacket);
428
+ });
429
+
430
+ const server = registerServer(await createTwilioMediaStreamServer({
431
+ port: 0,
432
+ createSession: () => session,
433
+ }));
434
+ const address = server.address();
435
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
436
+
437
+ const client = await openSocket(twilioUrl(address.port));
438
+ const errorMessage = readJsonMatching(client, (message) => message.event === "syrinx_error");
439
+ client.send(JSON.stringify({
440
+ event: "start",
441
+ streamSid: "MZ-test-stream",
442
+ start: {
443
+ streamSid: "MZ-test-stream",
444
+ callSid: "CA-test-call",
445
+ mediaFormat: {
446
+ encoding: "audio/x-mulaw",
447
+ sampleRate: 16000,
448
+ channels: 1,
449
+ },
450
+ },
451
+ }));
452
+
453
+ await expect(errorMessage).resolves.toMatchObject({
454
+ event: "syrinx_error",
455
+ error: {
456
+ component: "transport",
457
+ category: "invalid_input",
458
+ message: "Unsupported Twilio sample rate: 16000",
459
+ },
460
+ });
461
+ expect(received).toEqual([]);
462
+
463
+ client.close();
464
+ await server.close();
465
+ });
466
+
467
+ it("rejects malformed Twilio base64 media payloads before forwarding audio", async () => {
468
+ const session = new VoiceAgentSession({ plugins: {} });
469
+ const received: UserAudioReceivedPacket[] = [];
470
+ session.bus.on("user.audio_received", (pkt) => {
471
+ received.push(pkt as UserAudioReceivedPacket);
472
+ });
473
+
474
+ const server = registerServer(await createTwilioMediaStreamServer({
475
+ port: 0,
476
+ createSession: () => session,
477
+ }));
478
+ const address = server.address();
479
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
480
+
481
+ const client = await openSocket(twilioUrl(address.port));
482
+ const errorMessage = readJsonMatching(client, (message) => message.event === "syrinx_error");
483
+ client.send(JSON.stringify(twilioStart()));
484
+ client.send(JSON.stringify({
485
+ event: "media",
486
+ streamSid: "MZ-test-stream",
487
+ media: {
488
+ payload: "not base64",
489
+ },
490
+ }));
491
+
492
+ await expect(errorMessage).resolves.toMatchObject({
493
+ event: "syrinx_error",
494
+ error: {
495
+ component: "transport",
496
+ category: "invalid_input",
497
+ message: "media.payload must be valid base64",
498
+ },
499
+ });
500
+ expect(received).toEqual([]);
501
+
502
+ client.close();
503
+ await server.close();
504
+ });
505
+
506
+ it("rejects malformed Twilio JSON media envelopes before forwarding audio", async () => {
507
+ const session = new VoiceAgentSession({ plugins: {} });
508
+ const received: UserAudioReceivedPacket[] = [];
509
+ session.bus.on("user.audio_received", (pkt) => {
510
+ received.push(pkt as UserAudioReceivedPacket);
511
+ });
512
+
513
+ const server = registerServer(await createTwilioMediaStreamServer({
514
+ port: 0,
515
+ createSession: () => session,
516
+ }));
517
+ const address = server.address();
518
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
519
+
520
+ const client = await openSocket(twilioUrl(address.port));
521
+ const errorMessage = readJsonMatching(client, (message) => message.event === "syrinx_error");
522
+ client.send(JSON.stringify(twilioStart()));
523
+ client.send(JSON.stringify({
524
+ event: "media",
525
+ streamSid: "MZ-test-stream",
526
+ media: "not an object",
527
+ }));
528
+
529
+ await expect(errorMessage).resolves.toMatchObject({
530
+ event: "syrinx_error",
531
+ error: {
532
+ component: "transport",
533
+ category: "invalid_input",
534
+ message: "Twilio media must be a JSON object",
535
+ },
536
+ });
537
+ expect(received).toEqual([]);
538
+
539
+ client.close();
540
+ await server.close();
541
+ });
542
+
543
+ it("treats Twilio stop as a terminal stream boundary", async () => {
544
+ const session = new VoiceAgentSession({ plugins: {} });
545
+ const received: UserAudioReceivedPacket[] = [];
546
+ session.bus.on("user.audio_received", (pkt) => {
547
+ received.push(pkt as UserAudioReceivedPacket);
548
+ });
549
+
550
+ const server = registerServer(await createTwilioMediaStreamServer({
551
+ port: 0,
552
+ createSession: () => session,
553
+ }));
554
+ const address = server.address();
555
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
556
+
557
+ const client = await openSocket(twilioUrl(address.port));
558
+ const messages: any[] = [];
559
+ client.on("message", (data, isBinary) => {
560
+ if (!isBinary) messages.push(JSON.parse(data.toString()));
561
+ });
562
+
563
+ client.send(JSON.stringify(twilioStart()));
564
+ client.send(JSON.stringify({ event: "stop", streamSid: "MZ-test-stream" }));
565
+ client.send(JSON.stringify({
566
+ event: "media",
567
+ streamSid: "MZ-test-stream",
568
+ media: {
569
+ track: "inbound",
570
+ chunk: "2",
571
+ timestamp: "40",
572
+ payload: Buffer.from(encodePcm16ToMuLaw(new Int16Array([0, 1000, -1000, 3000]))).toString("base64"),
573
+ },
574
+ }));
575
+ session.bus.push(Route.Main, {
576
+ kind: "tts.audio",
577
+ contextId: "twilio-CA-test-call",
578
+ timestampMs: Date.now(),
579
+ audio: pcm16SamplesToBytes(new Int16Array(320)),
580
+ sampleRateHz: 16000,
581
+ });
582
+ await new Promise((resolve) => setTimeout(resolve, 40));
583
+
584
+ expect(received).toHaveLength(0);
585
+ expect(messages.filter((message) => message.event === "media")).toHaveLength(0);
586
+ expect(messages.filter((message) => message.event === "mark")).toHaveLength(0);
587
+ expect(messages.filter((message) => message.event === "clear")).toHaveLength(0);
588
+
589
+ client.close();
590
+ await server.close();
591
+ });
592
+
593
+ it("cancels queued Twilio playout when the provider sends stop", async () => {
594
+ const session = new VoiceAgentSession({ plugins: {} });
595
+ const metrics: ConversationMetricPacket[] = [];
596
+ const recording: RecordAssistantAudioPacket[] = [];
597
+ let releaseMain: () => void = () => undefined;
598
+ let notifyMainBlocked: () => void = () => undefined;
599
+ const mainReleased = new Promise<void>((resolve) => {
600
+ releaseMain = resolve;
601
+ });
602
+ const mainBlocked = new Promise<void>((resolve) => {
603
+ notifyMainBlocked = resolve;
604
+ });
605
+ session.bus.on("metric.conversation", (pkt) => {
606
+ metrics.push(pkt as ConversationMetricPacket);
607
+ });
608
+ session.bus.on("record.assistant_audio", (pkt) => {
609
+ recording.push(pkt as RecordAssistantAudioPacket);
610
+ });
611
+ session.bus.on("user.audio_received", async () => {
612
+ notifyMainBlocked();
613
+ await mainReleased;
614
+ });
615
+ const server = registerServer(await createTwilioMediaStreamServer({
616
+ port: 0,
617
+ outputSampleRateHz: 16000,
618
+ outboundFrameDurationMs: 250,
619
+ maxQueuedOutputAudioMs: 30_000,
620
+ createSession: () => session,
621
+ }));
622
+ const address = server.address();
623
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
624
+
625
+ const client = await openSocket(twilioUrl(address.port));
626
+ const messages: any[] = [];
627
+ client.on("message", (data, isBinary) => {
628
+ if (!isBinary) messages.push(JSON.parse(data.toString()));
629
+ });
630
+ client.send(JSON.stringify(twilioStart()));
631
+ await new Promise((resolve) => setTimeout(resolve, 20));
632
+
633
+ try {
634
+ const firstMedia = readJsonMatching(client, (message) => message.event === "media");
635
+ session.bus.push(Route.Main, {
636
+ kind: "tts.audio",
637
+ contextId: "twilio-CA-test-call",
638
+ timestampMs: Date.now(),
639
+ audio: pcm16SamplesToBytes(new Int16Array(16000)),
640
+ sampleRateHz: 16000,
641
+ });
642
+ await firstMedia;
643
+ expect(messages.filter((message) => message.event === "media")).toHaveLength(1);
644
+
645
+ client.send(JSON.stringify({
646
+ event: "media",
647
+ streamSid: "MZ-test-stream",
648
+ media: {
649
+ payload: Buffer.from(encodePcm16ToMuLaw(new Int16Array([0, 1000, -1000, 3000]))).toString("base64"),
650
+ },
651
+ }));
652
+ await mainBlocked;
653
+
654
+ client.send(JSON.stringify({ event: "stop", streamSid: "MZ-test-stream" }));
655
+ await new Promise((resolve) => setTimeout(resolve, 60));
656
+
657
+ expect(messages.filter((message) => message.event === "media")).toHaveLength(1);
658
+ expect(messages.filter((message) => message.event === "mark")).toHaveLength(0);
659
+ expect(metrics).toContainEqual(expect.objectContaining({
660
+ name: "twilio.stop_playout_cleared_ms",
661
+ value: expect.stringMatching(/^[1-9]\d*$/),
662
+ }));
663
+ expect(recording).toContainEqual(expect.objectContaining({
664
+ contextId: "twilio-CA-test-call",
665
+ truncate: true,
666
+ }));
667
+ } finally {
668
+ releaseMain();
669
+ client.close();
670
+ await server.close();
671
+ }
672
+ });
673
+
674
+ it("truncates queued Twilio playout when the websocket disconnects without stop", async () => {
675
+ const session = new VoiceAgentSession({ plugins: {} });
676
+ const metrics: ConversationMetricPacket[] = [];
677
+ const recording: RecordAssistantAudioPacket[] = [];
678
+ session.bus.on("metric.conversation", (pkt) => {
679
+ metrics.push(pkt as ConversationMetricPacket);
680
+ });
681
+ session.bus.on("record.assistant_audio", (pkt) => {
682
+ recording.push(pkt as RecordAssistantAudioPacket);
683
+ });
684
+ const server = registerServer(await createTwilioMediaStreamServer({
685
+ port: 0,
686
+ outputSampleRateHz: 16000,
687
+ outboundFrameDurationMs: 250,
688
+ maxQueuedOutputAudioMs: 30_000,
689
+ createSession: () => session,
690
+ }));
691
+ const address = server.address();
692
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
693
+ const client = await openSocket(twilioUrl(address.port));
694
+ client.send(JSON.stringify(twilioStart()));
695
+ await new Promise((resolve) => setTimeout(resolve, 20));
696
+
697
+ const firstMedia = readJsonMatching(client, (message) => message.event === "media");
698
+ session.bus.push(Route.Main, {
699
+ kind: "tts.audio",
700
+ contextId: "twilio-CA-test-call",
701
+ timestampMs: Date.now(),
702
+ audio: pcm16SamplesToBytes(new Int16Array(16000)),
703
+ sampleRateHz: 16000,
704
+ });
705
+ await firstMedia;
706
+ client.terminate();
707
+ await new Promise((resolve) => setTimeout(resolve, 60));
708
+
709
+ expect(metrics).toContainEqual(expect.objectContaining({
710
+ name: "twilio.disconnect_playout_cleared_ms",
711
+ value: expect.stringMatching(/^[1-9]\d*$/),
712
+ }));
713
+ expect(recording).toContainEqual(expect.objectContaining({ truncate: true }));
714
+ await server.close();
715
+ });
716
+
717
+ it("closes oversized inbound Twilio websocket messages before parsing", async () => {
718
+ const session = new VoiceAgentSession({ plugins: {} });
719
+ const server = registerServer(await createTwilioMediaStreamServer({
720
+ port: 0,
721
+ maxInboundMessageBytes: 8,
722
+ createSession: () => session,
723
+ }));
724
+ const address = server.address();
725
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
726
+
727
+ const client = await openSocket(twilioUrl(address.port));
728
+ const closed = new Promise<{ code: number; reason: string }>((resolve) => {
729
+ client.once("close", (code, reason) => {
730
+ resolve({ code, reason: reason.toString() });
731
+ });
732
+ });
733
+
734
+ client.send(JSON.stringify(twilioStart()));
735
+
736
+ await expect(closed).resolves.toEqual({
737
+ code: 1009,
738
+ reason: "websocket message too large",
739
+ });
740
+
741
+ await server.close();
742
+ });
743
+
744
+ it("encodes assistant PCM16 audio into 20 ms Twilio media frames, marks playback, and clears on interruption", async () => {
745
+ const session = new VoiceAgentSession({ plugins: {} });
746
+ const server = registerServer(await createTwilioMediaStreamServer({
747
+ port: 0,
748
+ outputSampleRateHz: 16000,
749
+ createSession: () => session,
750
+ }));
751
+ const address = server.address();
752
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
753
+
754
+ const client = await openSocket(twilioUrl(address.port));
755
+ client.send(JSON.stringify(twilioStart()));
756
+ await new Promise((resolve) => setTimeout(resolve, 20));
757
+
758
+ const mediaMessage = readJsonMatching(client, (message) => message.event === "media");
759
+ const markMessage = readJsonMatching(client, (message) => message.event === "mark");
760
+ const clearMessage = readJsonMatching(client, (message) => message.event === "clear");
761
+ const samples16k = new Int16Array(320);
762
+ for (let i = 0; i < samples16k.length; i += 1) {
763
+ samples16k[i] = i % 2 === 0 ? 1200 : -1200;
764
+ }
765
+
766
+ session.bus.push(Route.Main, {
767
+ kind: "tts.audio",
768
+ contextId: "twilio-CA-test-call",
769
+ timestampMs: Date.now(),
770
+ audio: pcm16SamplesToBytes(samples16k),
771
+ sampleRateHz: 16000,
772
+ });
773
+
774
+ const media = await mediaMessage;
775
+ expect(media).toMatchObject({
776
+ event: "media",
777
+ streamSid: "MZ-test-stream",
778
+ });
779
+ expect(Buffer.from(media.media.payload, "base64")).toHaveLength(160);
780
+ await expect(markMessage).resolves.toEqual({
781
+ event: "mark",
782
+ streamSid: "MZ-test-stream",
783
+ mark: {
784
+ name: "twilio-CA-test-call:1",
785
+ },
786
+ });
787
+ session.bus.push(Route.Critical, {
788
+ kind: "interrupt.tts",
789
+ contextId: "twilio-CA-test-call",
790
+ timestampMs: Date.now(),
791
+ });
792
+ await expect(clearMessage).resolves.toEqual({
793
+ event: "clear",
794
+ streamSid: "MZ-test-stream",
795
+ });
796
+
797
+ client.close();
798
+ await server.close();
799
+ });
800
+
801
+ it("paces outbound media frames and cancels unsent assistant audio on interruption", async () => {
802
+ const session = new VoiceAgentSession({ plugins: {} });
803
+ const server = registerServer(await createTwilioMediaStreamServer({
804
+ port: 0,
805
+ outputSampleRateHz: 16000,
806
+ outboundFrameDurationMs: 20,
807
+ maxQueuedOutputAudioMs: 30_000,
808
+ createSession: () => session,
809
+ }));
810
+ const address = server.address();
811
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
812
+
813
+ const client = await openSocket(twilioUrl(address.port));
814
+ const messages: any[] = [];
815
+ client.on("message", (data, isBinary) => {
816
+ if (!isBinary) messages.push(JSON.parse(data.toString()));
817
+ });
818
+ client.send(JSON.stringify(twilioStart()));
819
+ await new Promise((resolve) => setTimeout(resolve, 20));
820
+
821
+ const firstMedia = readJsonMatching(client, (message) => message.event === "media");
822
+ session.bus.push(Route.Main, {
823
+ kind: "tts.audio",
824
+ contextId: "twilio-CA-test-call",
825
+ timestampMs: Date.now(),
826
+ audio: pcm16SamplesToBytes(new Int16Array(16000)),
827
+ sampleRateHz: 16000,
828
+ });
829
+ await firstMedia;
830
+
831
+ expect(messages.filter((message) => message.event === "media")).toHaveLength(1);
832
+ expect(messages.filter((message) => message.event === "mark")).toHaveLength(0);
833
+
834
+ session.bus.push(Route.Critical, {
835
+ kind: "interrupt.tts",
836
+ contextId: "twilio-CA-test-call",
837
+ timestampMs: Date.now(),
838
+ });
839
+ await new Promise((resolve) => setTimeout(resolve, 60));
840
+
841
+ expect(messages.filter((message) => message.event === "media")).toHaveLength(1);
842
+ expect(messages.filter((message) => message.event === "mark")).toHaveLength(0);
843
+ expect(messages.filter((message) => message.event === "clear")).toHaveLength(1);
844
+
845
+ session.bus.push(Route.Main, {
846
+ kind: "tts.audio",
847
+ contextId: "twilio-CA-test-call",
848
+ timestampMs: Date.now(),
849
+ audio: pcm16SamplesToBytes(new Int16Array(320)),
850
+ sampleRateHz: 16000,
851
+ });
852
+ session.bus.push(Route.Main, {
853
+ kind: "tts.end",
854
+ contextId: "twilio-CA-test-call",
855
+ timestampMs: Date.now(),
856
+ });
857
+ await new Promise((resolve) => setTimeout(resolve, 60));
858
+
859
+ expect(messages.filter((message) => message.event === "media")).toHaveLength(1);
860
+ expect(messages.filter((message) => message.event === "mark")).toHaveLength(0);
861
+
862
+ client.close();
863
+ await server.close();
864
+ });
865
+
866
+ it("records Twilio mark callbacks as playback acknowledgement metrics", async () => {
867
+ const session = new VoiceAgentSession({ plugins: {} });
868
+ const metrics: ConversationMetricPacket[] = [];
869
+ session.bus.on("metric.conversation", (pkt) => {
870
+ metrics.push(pkt as ConversationMetricPacket);
871
+ });
872
+
873
+ const server = registerServer(await createTwilioMediaStreamServer({
874
+ port: 0,
875
+ createSession: () => session,
876
+ }));
877
+ const address = server.address();
878
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
879
+
880
+ const client = await openSocket(twilioUrl(address.port));
881
+ client.send(JSON.stringify(twilioStart()));
882
+ await new Promise((resolve) => setTimeout(resolve, 20));
883
+
884
+ client.send(JSON.stringify({
885
+ event: "mark",
886
+ streamSid: "MZ-test-stream",
887
+ mark: {
888
+ name: "twilio-CA-test-call:1",
889
+ },
890
+ }));
891
+ await new Promise((resolve) => setTimeout(resolve, 20));
892
+
893
+ expect(metrics).toEqual([
894
+ expect.objectContaining({
895
+ kind: "metric.conversation",
896
+ contextId: "twilio-CA-test-call",
897
+ name: "twilio.mark_received",
898
+ value: "twilio-CA-test-call:1",
899
+ }),
900
+ ]);
901
+
902
+ client.close();
903
+ await server.close();
904
+ });
905
+
906
+ it("sends the terminal Twilio playback mark after pending playback marks are acknowledged", async () => {
907
+ const session = new VoiceAgentSession({ plugins: {} });
908
+ const server = registerServer(await createTwilioMediaStreamServer({
909
+ port: 0,
910
+ outputSampleRateHz: 16000,
911
+ createSession: () => session,
912
+ }));
913
+ const address = server.address();
914
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
915
+
916
+ const client = await openSocket(twilioUrl(address.port));
917
+ client.send(JSON.stringify(twilioStart()));
918
+ await new Promise((resolve) => setTimeout(resolve, 20));
919
+
920
+ const playbackMark = readJsonMatching(client, (message) => message.event === "mark" && message.mark?.name === "twilio-CA-test-call:1");
921
+ session.bus.push(Route.Main, {
922
+ kind: "tts.audio",
923
+ contextId: "twilio-CA-test-call",
924
+ timestampMs: Date.now(),
925
+ audio: pcm16SamplesToBytes(new Int16Array(320)),
926
+ sampleRateHz: 16000,
927
+ });
928
+ const mark = await playbackMark;
929
+
930
+ const endMark = readJsonMatching(client, (message) => message.event === "mark" && message.mark?.name === "twilio-CA-test-call:end");
931
+ session.bus.push(Route.Main, {
932
+ kind: "tts.end",
933
+ contextId: "twilio-CA-test-call",
934
+ timestampMs: Date.now(),
935
+ });
936
+
937
+ await expect(Promise.race([
938
+ endMark.then(() => "end"),
939
+ new Promise((resolve) => setTimeout(() => resolve("pending"), 30)),
940
+ ])).resolves.toBe("pending");
941
+
942
+ client.send(JSON.stringify({ event: "mark", streamSid: "MZ-test-stream", mark: { name: mark.mark.name } }));
943
+ await expect(endMark).resolves.toEqual({
944
+ event: "mark",
945
+ streamSid: "MZ-test-stream",
946
+ mark: {
947
+ name: "twilio-CA-test-call:end",
948
+ },
949
+ });
950
+
951
+ client.close();
952
+ await server.close();
953
+ });
954
+
955
+ it("drops overflow tail without closing or silencing the stream when playout exceeds the bound", async () => {
956
+ const session = new VoiceAgentSession({ plugins: {} });
957
+ const metrics: ConversationMetricPacket[] = [];
958
+ const recording: RecordAssistantAudioPacket[] = [];
959
+ let closed = false;
960
+ session.bus.on("metric.conversation", (pkt) => {
961
+ metrics.push(pkt as ConversationMetricPacket);
962
+ });
963
+ session.bus.on("record.assistant_audio", (pkt) => {
964
+ recording.push(pkt as RecordAssistantAudioPacket);
965
+ });
966
+ const server = registerServer(await createTwilioMediaStreamServer({
967
+ port: 0,
968
+ outputSampleRateHz: 16000,
969
+ outboundFrameDurationMs: 20,
970
+ maxQueuedOutputAudioMs: 100,
971
+ createSession: () => session,
972
+ }));
973
+ const address = server.address();
974
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
975
+ const client = await openSocket(twilioUrl(address.port));
976
+ client.once("close", () => {
977
+ closed = true;
978
+ });
979
+ client.send(JSON.stringify(twilioStart()));
980
+ await new Promise((resolve) => setTimeout(resolve, 20));
981
+
982
+ session.bus.push(Route.Main, {
983
+ kind: "tts.audio",
984
+ contextId: "twilio-CA-test-call",
985
+ timestampMs: Date.now(),
986
+ audio: pcm16SamplesToBytes(new Int16Array(1280)),
987
+ sampleRateHz: 16000,
988
+ });
989
+ await new Promise((resolve) => setTimeout(resolve, 10));
990
+ session.bus.push(Route.Main, {
991
+ kind: "tts.audio",
992
+ contextId: "twilio-CA-test-call",
993
+ timestampMs: Date.now(),
994
+ audio: pcm16SamplesToBytes(new Int16Array(1280)),
995
+ sampleRateHz: 16000,
996
+ });
997
+
998
+ await waitForCondition(() =>
999
+ metrics.some((metric) => metric.name === "twilio.overflow_playout_cleared_ms"),
1000
+ );
1001
+ expect(closed).toBe(false);
1002
+ const overflowMetric = metrics.find((metric) => metric.name === "twilio.overflow_playout_cleared_ms");
1003
+ expect(overflowMetric).toBeDefined();
1004
+ expect(Number(overflowMetric!.value)).toBeGreaterThan(0);
1005
+ expect(recording).toContainEqual(expect.objectContaining({ truncate: true }));
1006
+ client.close();
1007
+ await server.close();
1008
+ });
1009
+
1010
+ it("sends heartbeat pings to telephony websocket peers", async () => {
1011
+ const session = new VoiceAgentSession({ plugins: {} });
1012
+ const server = registerServer(await createTwilioMediaStreamServer({
1013
+ port: 0,
1014
+ heartbeatIntervalMs: 10,
1015
+ createSession: () => session,
1016
+ }));
1017
+ const address = server.address();
1018
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
1019
+
1020
+ const client = await openSocket(twilioUrl(address.port));
1021
+ const ping = new Promise<void>((resolve) => {
1022
+ client.once("ping", () => resolve());
1023
+ });
1024
+
1025
+ await expect(ping).resolves.toBeUndefined();
1026
+
1027
+ client.close();
1028
+ await server.close();
1029
+ });
1030
+
1031
+ it("closes Twilio websocket sessions that exceed maxSessionDurationMs", async () => {
1032
+ const session = new VoiceAgentSession({ plugins: {} });
1033
+ const server = registerServer(await createTwilioMediaStreamServer({
1034
+ port: 0,
1035
+ maxSessionDurationMs: 10,
1036
+ createSession: () => session,
1037
+ }));
1038
+ const address = server.address();
1039
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
1040
+
1041
+ const client = await openSocket(twilioUrl(address.port));
1042
+ const closed = new Promise<{ code: number; reason: string }>((resolve) => {
1043
+ client.once("close", (code, reason) => {
1044
+ resolve({ code, reason: reason.toString() });
1045
+ });
1046
+ });
1047
+
1048
+ await expect(closed).resolves.toEqual({
1049
+ code: 1000,
1050
+ reason: "websocket max session duration exceeded",
1051
+ });
1052
+
1053
+ await server.close();
1054
+ });
1055
+
1056
+ it("closes slow Twilio consumers before outbound media buffers grow unbounded", async () => {
1057
+ const session = new VoiceAgentSession({ plugins: {} });
1058
+ const metrics: ConversationMetricPacket[] = [];
1059
+ session.bus.on("metric.conversation", (pkt) => {
1060
+ metrics.push(pkt as ConversationMetricPacket);
1061
+ });
1062
+ const server = registerServer(await createTwilioMediaStreamServer({
1063
+ port: 0,
1064
+ maxBufferedAmountBytes: 1,
1065
+ createSession: () => session,
1066
+ }));
1067
+ const address = server.address();
1068
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
1069
+
1070
+ const client = await openSocket(twilioUrl(address.port));
1071
+ client.send(JSON.stringify(twilioStart()));
1072
+ await new Promise((resolve) => setTimeout(resolve, 20));
1073
+
1074
+ const serverSocket = [...server.wsServer.clients][0];
1075
+ if (!serverSocket) throw new Error("Expected server websocket");
1076
+ Object.defineProperty(serverSocket, "bufferedAmount", { configurable: true, value: 2 });
1077
+
1078
+ const closed = new Promise<{ code: number; reason: string }>((resolve) => {
1079
+ client.once("close", (code, reason) => {
1080
+ resolve({ code, reason: reason.toString() });
1081
+ });
1082
+ });
1083
+ session.bus.push(Route.Main, {
1084
+ kind: "tts.audio",
1085
+ contextId: "twilio-CA-test-call",
1086
+ timestampMs: Date.now(),
1087
+ audio: pcm16SamplesToBytes(new Int16Array(320)),
1088
+ sampleRateHz: 16000,
1089
+ });
1090
+
1091
+ await expect(closed).resolves.toEqual({
1092
+ code: 1013,
1093
+ reason: "websocket send buffer exceeded",
1094
+ });
1095
+ await new Promise((resolve) => setTimeout(resolve, 20));
1096
+ expect(metrics).not.toContainEqual(expect.objectContaining({
1097
+ name: "twilio.mark_sent",
1098
+ }));
1099
+ expect(metrics).toContainEqual(expect.objectContaining({
1100
+ name: "twilio.send_buffer_playout_cleared_ms",
1101
+ value: "20",
1102
+ }));
1103
+
1104
+ await server.close();
1105
+ });
1106
+
1107
+ it("drops Twilio DTMF before start with a metric and no dtmf.received packet", async () => {
1108
+ const session = new VoiceAgentSession({ plugins: {} });
1109
+ const dtmfReceived: unknown[] = [];
1110
+ const metrics: ConversationMetricPacket[] = [];
1111
+ session.bus.on("dtmf.received", (pkt) => { dtmfReceived.push(pkt); });
1112
+ session.bus.on("metric.conversation", (pkt) => {
1113
+ metrics.push(pkt as ConversationMetricPacket);
1114
+ });
1115
+
1116
+ const server = registerServer(await createTwilioMediaStreamServer({
1117
+ port: 0,
1118
+ createSession: () => session,
1119
+ }));
1120
+ const address = server.address();
1121
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
1122
+
1123
+ const client = await openSocket(twilioUrl(address.port));
1124
+ client.send(JSON.stringify({
1125
+ event: "dtmf",
1126
+ streamSid: "MZ-test-stream",
1127
+ dtmf: { digit: "5" },
1128
+ }));
1129
+ await new Promise((resolve) => setTimeout(resolve, 20));
1130
+
1131
+ expect(dtmfReceived).toEqual([]);
1132
+ expect(metrics).toContainEqual(expect.objectContaining({
1133
+ name: "twilio.dtmf.before_start",
1134
+ value: "5",
1135
+ }));
1136
+
1137
+ client.close();
1138
+ await server.close();
1139
+ });
1140
+
1141
+ it("test:dtmf_typed_per_carrier emits dtmf.received for Twilio DTMF", async () => {
1142
+ const session = new VoiceAgentSession({ plugins: {} });
1143
+ const dtmfReceived: Array<{ kind: string; digit: string; provider: string; rawDigit: string; contextId: string }> = [];
1144
+ session.bus.on("dtmf.received", (pkt) => {
1145
+ dtmfReceived.push(pkt as unknown as { kind: string; digit: string; provider: string; rawDigit: string; contextId: string });
1146
+ });
1147
+
1148
+ const server = registerServer(await createTwilioMediaStreamServer({
1149
+ port: 0,
1150
+ createSession: () => session,
1151
+ }));
1152
+ const address = server.address();
1153
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
1154
+
1155
+ const client = await openSocket(twilioUrl(address.port));
1156
+ client.send(JSON.stringify(twilioStart()));
1157
+ client.send(JSON.stringify({
1158
+ event: "dtmf",
1159
+ streamSid: "MZ-test-stream",
1160
+ dtmf: { digit: "5" },
1161
+ }));
1162
+ await waitForCondition(() => dtmfReceived.length === 1);
1163
+
1164
+ expect(dtmfReceived).toEqual([
1165
+ expect.objectContaining({
1166
+ kind: "dtmf.received",
1167
+ digit: "5",
1168
+ provider: "twilio",
1169
+ rawDigit: "5",
1170
+ contextId: "twilio-CA-test-call",
1171
+ }),
1172
+ ]);
1173
+
1174
+ client.close();
1175
+ await server.close();
1176
+ });
1177
+
1178
+ it("test:dtmf_bypasses_speech_path: Twilio DTMF during playout emits no audio, VAD, STT, or interrupt packets", async () => {
1179
+ const session = new VoiceAgentSession({ plugins: {} });
1180
+ const dtmfReceived: unknown[] = [];
1181
+ const speechPath: unknown[] = [];
1182
+ const speechKinds = [
1183
+ "user.audio_received",
1184
+ "stt.audio",
1185
+ "vad.audio",
1186
+ "vad.speech_started",
1187
+ "vad.speech_ended",
1188
+ "vad.speech_activity",
1189
+ "interrupt.detected",
1190
+ "interrupt.tts",
1191
+ "interrupt.llm",
1192
+ "interrupt.stt",
1193
+ ] as const;
1194
+ session.bus.on("dtmf.received", (pkt) => { dtmfReceived.push(pkt); });
1195
+ for (const kind of speechKinds) {
1196
+ session.bus.on(kind, (pkt) => { speechPath.push(pkt); });
1197
+ }
1198
+
1199
+ const server = registerServer(await createTwilioMediaStreamServer({
1200
+ port: 0,
1201
+ outputSampleRateHz: 16000,
1202
+ createSession: () => session,
1203
+ }));
1204
+ const address = server.address();
1205
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
1206
+
1207
+ const client = await openSocket(twilioUrl(address.port));
1208
+ client.send(JSON.stringify(twilioStart()));
1209
+ await new Promise((resolve) => setTimeout(resolve, 20));
1210
+
1211
+ session.bus.push(Route.Main, {
1212
+ kind: "tts.audio",
1213
+ contextId: "twilio-CA-test-call",
1214
+ timestampMs: Date.now(),
1215
+ audio: pcm16SamplesToBytes(new Int16Array(320)),
1216
+ sampleRateHz: 16000,
1217
+ });
1218
+ client.send(JSON.stringify({
1219
+ event: "dtmf",
1220
+ streamSid: "MZ-test-stream",
1221
+ dtmf: { digit: "#" },
1222
+ }));
1223
+ await waitForCondition(() => dtmfReceived.length === 1);
1224
+
1225
+ expect(dtmfReceived).toEqual([
1226
+ expect.objectContaining({
1227
+ kind: "dtmf.received",
1228
+ digit: "#",
1229
+ provider: "twilio",
1230
+ rawDigit: "#",
1231
+ }),
1232
+ ]);
1233
+ expect(speechPath).toEqual([]);
1234
+
1235
+ client.close();
1236
+ await server.close();
1237
+ });
1238
+
1239
+ it("emits dtmf.unparsed for invalid Twilio DTMF digits", async () => {
1240
+ const session = new VoiceAgentSession({ plugins: {} });
1241
+ const metrics: ConversationMetricPacket[] = [];
1242
+ const dtmfReceived: unknown[] = [];
1243
+ session.bus.on("metric.conversation", (pkt) => {
1244
+ metrics.push(pkt as ConversationMetricPacket);
1245
+ });
1246
+ session.bus.on("dtmf.received", (pkt) => { dtmfReceived.push(pkt); });
1247
+
1248
+ const server = registerServer(await createTwilioMediaStreamServer({
1249
+ port: 0,
1250
+ createSession: () => session,
1251
+ }));
1252
+ const address = server.address();
1253
+ if (!address || typeof address === "string") throw new Error("Expected TCP address");
1254
+
1255
+ const client = await openSocket(twilioUrl(address.port));
1256
+ client.send(JSON.stringify(twilioStart()));
1257
+ client.send(JSON.stringify({
1258
+ event: "dtmf",
1259
+ streamSid: "MZ-test-stream",
1260
+ dtmf: { digit: "invalid" },
1261
+ }));
1262
+ await waitForCondition(() => metrics.some((metric) => metric.name === "dtmf.unparsed"));
1263
+
1264
+ expect(metrics).toContainEqual(expect.objectContaining({
1265
+ kind: "metric.conversation",
1266
+ name: "dtmf.unparsed",
1267
+ value: "invalid",
1268
+ contextId: "twilio-CA-test-call",
1269
+ }));
1270
+ expect(dtmfReceived).toEqual([]);
1271
+
1272
+ client.close();
1273
+ await server.close();
1274
+ });
1275
+ });