@kuralle-syrinx/server-websocket 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,293 +0,0 @@
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(
82
- received: UserAudioReceivedPacket[] = [],
83
- extraOptions: Record<string, unknown> = {},
84
- ) {
85
- const socket = new FakeSocket();
86
- const session = fakeSession(received);
87
- await runTwilioEdgeWebSocketConnection(
88
- socket,
89
- new Request("https://edge.test/twilio?sessionId=tw1"),
90
- {
91
- sessionStore: new InMemorySessionStore(),
92
- createSession: () => session,
93
- keepAliveIntervalMs: 0,
94
- ...extraOptions,
95
- },
96
- );
97
- socket.emit(JSON.stringify({ event: "connected", protocol: "Call" }));
98
- socket.emit(JSON.stringify({
99
- event: "start",
100
- streamSid: "MZ123",
101
- start: { streamSid: "MZ123", callSid: "CA456" },
102
- }));
103
- return { socket, session };
104
- }
105
-
106
- describe("Twilio edge ingress", () => {
107
- it("decodes inbound mu-law media to 16k PCM user audio with a stable callSid context", async () => {
108
- const received: UserAudioReceivedPacket[] = [];
109
- const { socket } = await startConnection(received);
110
-
111
- const samples8k = new Int16Array(160).fill(8000); // 20ms at 8k
112
- const payload = bytesToBase64(encodePcm16ToMuLaw(samples8k));
113
- socket.emit(JSON.stringify({ event: "media", streamSid: "MZ123", media: { payload } }));
114
- await new Promise((resolve) => setTimeout(resolve, 10));
115
-
116
- expect(received).toHaveLength(1);
117
- expect(received[0]!.contextId).toBe("twilio-CA456");
118
- const pcm = pcm16BytesToSamples(received[0]!.audio);
119
- expect(pcm.length).toBe(320); // 8k → 16k doubles the samples
120
- expect(Math.abs(pcm[100]! - 8000)).toBeLessThan(600); // mu-law quantization tolerance
121
- });
122
-
123
- it("encodes engine TTS PCM to 8k mu-law media frames for Twilio", async () => {
124
- const { socket, session } = await startConnection();
125
-
126
- const pcm16k = new Int16Array(640).fill(6000); // 40ms at 16k
127
- const bytes = new Uint8Array(pcm16k.buffer.slice(0));
128
- session.bus.push(Route.Critical, {
129
- kind: "tts.audio",
130
- contextId: "twilio-CA456",
131
- timestampMs: Date.now(),
132
- audio: bytes,
133
- sampleRateHz: 16000,
134
- });
135
- await new Promise((resolve) => setTimeout(resolve, 10));
136
-
137
- const media = socket.json().filter((msg) => msg.event === "media");
138
- expect(media).toHaveLength(1);
139
- expect(media[0]!.streamSid).toBe("MZ123");
140
- const payload = (media[0]!.media as { payload: string }).payload;
141
- const decoded = decodeMuLawToPcm16(new Uint8Array(Buffer.from(payload, "base64")));
142
- expect(decoded.length).toBe(320); // 16k → 8k halves the samples
143
- expect(Math.abs(decoded[100]! - 6000)).toBeLessThan(600);
144
- });
145
-
146
- it("mixes the background bed (ducked) under TTS media", async () => {
147
- const { socket, session } = await startConnection([], {
148
- backgroundAudio: {
149
- ambient: { pcm: new Int16Array(320).fill(4000), sampleRateHz: 16000, gain: 0.5 },
150
- duckWhileSpeaking: 0.5,
151
- fadeMs: 0,
152
- },
153
- backgroundIdleFrameMs: 10_000, // keep the idle ticker out of this test
154
- });
155
-
156
- const silence = new Uint8Array(new Int16Array(640).buffer.slice(0)); // 40ms @16k of silence
157
- session.bus.push(Route.Critical, {
158
- kind: "tts.audio",
159
- contextId: "twilio-CA456",
160
- timestampMs: Date.now(),
161
- audio: silence,
162
- sampleRateHz: 16000,
163
- });
164
- await new Promise((resolve) => setTimeout(resolve, 10));
165
-
166
- const media = socket.json().filter((msg) => msg.event === "media");
167
- expect(media).toHaveLength(1);
168
- const payload = (media[0]!.media as { payload: string }).payload;
169
- const decoded = decodeMuLawToPcm16(new Uint8Array(Buffer.from(payload, "base64")));
170
- // ambient 4000 × gain 0.5 × duck 0.5 = 1000 under the silent speech
171
- expect(Math.abs(decoded[100]! - 1000)).toBeLessThan(120); // mu-law quantization tolerance
172
- });
173
-
174
- it("sends idle comfort-noise frames between turns", async () => {
175
- const { socket } = await startConnection([], {
176
- backgroundAudio: {
177
- ambient: { pcm: new Int16Array(320).fill(4000), sampleRateHz: 8000, gain: 0.5 },
178
- fadeMs: 0,
179
- },
180
- backgroundIdleFrameMs: 25,
181
- });
182
-
183
- await new Promise((resolve) => setTimeout(resolve, 120));
184
-
185
- const media = socket.json().filter((msg) => msg.event === "media");
186
- expect(media.length).toBeGreaterThanOrEqual(2); // ticker fills the silence
187
- const payload = (media[0]!.media as { payload: string }).payload;
188
- const decoded = decodeMuLawToPcm16(new Uint8Array(Buffer.from(payload, "base64")));
189
- // full-gain bed between turns: 4000 × 0.5 = 2000
190
- expect(Math.abs(decoded[10]! - 2000)).toBeLessThan(220);
191
- expect(decoded.length).toBe(200); // 25ms @ 8k
192
- });
193
-
194
- it("sends a clear event on interrupt.detected (barge-in)", async () => {
195
- const { socket, session } = await startConnection();
196
-
197
- session.bus.push(Route.Critical, {
198
- kind: "interrupt.detected",
199
- contextId: "twilio-CA456",
200
- timestampMs: Date.now(),
201
- source: "vad",
202
- });
203
- await new Promise((resolve) => setTimeout(resolve, 10));
204
-
205
- expect(socket.json()).toContainEqual({ event: "clear", streamSid: "MZ123" });
206
- });
207
-
208
- it("releases the session when Twilio sends stop", async () => {
209
- const { socket } = await startConnection();
210
- socket.emit(JSON.stringify({ event: "stop", streamSid: "MZ123" }));
211
- expect(socket.disposed).toBe(true);
212
- });
213
-
214
- it("actually closes the session on hangup (no provider-socket leak)", async () => {
215
- // Regression for the R1 leak: cleanup released without decrementing
216
- // connectionCount, so release early-returned and session.close() never ran —
217
- // Deepgram/TTS sockets leaked until DO eviction on every phone call.
218
- const socket = new FakeSocket();
219
- let closed = false;
220
- const bus = new PipelineBusImpl();
221
- const session = {
222
- bus,
223
- async start() { void bus.start(); },
224
- async close() { closed = true; bus.stop(); },
225
- on() {}, off() {}, requestClientInterrupt() {},
226
- } as unknown as VoiceAgentSession;
227
-
228
- await runTwilioEdgeWebSocketConnection(
229
- socket,
230
- new Request("https://edge.test/twilio?sessionId=tw-close"),
231
- { sessionStore: new InMemorySessionStore(), createSession: () => session, keepAliveIntervalMs: 0 },
232
- );
233
- socket.emit(JSON.stringify({ event: "start", streamSid: "MZ9", start: { streamSid: "MZ9", callSid: "CA9" } }));
234
- socket.emit(JSON.stringify({ event: "stop", streamSid: "MZ9" }));
235
- await new Promise((r) => setTimeout(r, 10));
236
-
237
- expect(closed).toBe(true);
238
- });
239
-
240
- it("rotates the uplink contextId after each completed turn", async () => {
241
- const received: UserAudioReceivedPacket[] = [];
242
- const { socket, session } = await startConnection(received);
243
- const payload = bytesToBase64(encodePcm16ToMuLaw(new Int16Array(160).fill(4000)));
244
-
245
- socket.emit(JSON.stringify({ event: "media", streamSid: "MZ123", media: { payload } }));
246
- session.bus.push(Route.Main, {
247
- kind: "eos.turn_complete",
248
- contextId: "twilio-CA456",
249
- timestampMs: Date.now(),
250
- text: "first turn",
251
- transcripts: [],
252
- });
253
- await new Promise((resolve) => setTimeout(resolve, 10));
254
- socket.emit(JSON.stringify({ event: "media", streamSid: "MZ123", media: { payload } }));
255
- await new Promise((resolve) => setTimeout(resolve, 10));
256
-
257
- expect(received).toHaveLength(2);
258
- expect(received[0]!.contextId).toBe("twilio-CA456");
259
- expect(received[1]!.contextId).toBe("twilio-CA456-t1");
260
- });
261
-
262
- it("buffers start/media that arrive before the session lease resolves", async () => {
263
- const received: UserAudioReceivedPacket[] = [];
264
- const socket = new FakeSocket();
265
- const session = fakeSession(received);
266
- const run = runTwilioEdgeWebSocketConnection(
267
- socket,
268
- new Request("https://edge.test/twilio?sessionId=tw-early"),
269
- {
270
- sessionStore: new InMemorySessionStore(),
271
- createSession: async () => {
272
- await new Promise((resolve) => setTimeout(resolve, 30)); // slow startup
273
- return session;
274
- },
275
- keepAliveIntervalMs: 0,
276
- },
277
- );
278
- // Twilio talks immediately — before startup finishes.
279
- socket.emit(JSON.stringify({ event: "connected", protocol: "Call" }));
280
- socket.emit(JSON.stringify({
281
- event: "start",
282
- streamSid: "MZ9",
283
- start: { streamSid: "MZ9", callSid: "CA9" },
284
- }));
285
- const payload = bytesToBase64(encodePcm16ToMuLaw(new Int16Array(160).fill(4000)));
286
- socket.emit(JSON.stringify({ event: "media", streamSid: "MZ9", media: { payload } }));
287
- await run;
288
- await new Promise((resolve) => setTimeout(resolve, 10));
289
-
290
- expect(received).toHaveLength(1);
291
- expect(received[0]!.contextId).toBe("twilio-CA9");
292
- });
293
- });