@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.
- package/LICENSE +22 -0
- package/package.json +31 -0
- package/src/admission-control.test.ts +247 -0
- package/src/browser-opus.test.ts +41 -0
- package/src/browser-opus.ts +59 -0
- package/src/browser-pacing.test.ts +440 -0
- package/src/edge-twilio.test.ts +215 -0
- package/src/edge-twilio.ts +285 -0
- package/src/edge.test.ts +272 -0
- package/src/edge.ts +652 -0
- package/src/graceful-drain.test.ts +306 -0
- package/src/inbound-audio.ts +102 -0
- package/src/index.test.ts +2071 -0
- package/src/index.ts +891 -0
- package/src/json-message.ts +39 -0
- package/src/outbound-playout-pipeline.test.ts +208 -0
- package/src/outbound-playout-pipeline.ts +167 -0
- package/src/paced-playout.test.ts +247 -0
- package/src/paced-playout.ts +161 -0
- package/src/playout-progress.test.ts +56 -0
- package/src/playout-progress.ts +67 -0
- package/src/session-store.test.ts +274 -0
- package/src/session-store.ts +123 -0
- package/src/smartpbx.test.ts +967 -0
- package/src/smartpbx.ts +531 -0
- package/src/telnyx.test.ts +1413 -0
- package/src/telnyx.ts +708 -0
- package/src/test-helpers.ts +264 -0
- package/src/transport-helpers.ts +78 -0
- package/src/transport-host.test.ts +51 -0
- package/src/transport-host.ts +213 -0
- package/src/turn-metrics.test.ts +327 -0
- package/src/turn-metrics.ts +193 -0
- package/src/twilio.test.ts +1275 -0
- package/src/twilio.ts +599 -0
- package/src/websocket-close.test.ts +63 -0
- package/src/websocket-close.ts +68 -0
- package/src/websocket-lifecycle.test.ts +49 -0
- package/src/websocket-lifecycle.ts +105 -0
- package/src/websocket-upgrade.ts +102 -0
- package/tsconfig.json +22 -0
- 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
|
+
});
|