@kuralle-syrinx/server-websocket 4.2.0 → 4.4.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/package.json +6 -5
- package/src/carrier-commands.ts +332 -0
- package/src/edge-telnyx.ts +389 -0
- package/src/edge.ts +23 -2
- package/src/index.ts +25 -2
- package/src/telnyx-codec.ts +163 -0
- package/src/telnyx.ts +54 -59
- package/src/turn-metrics.ts +107 -5
- package/src/twilio.ts +23 -0
- package/src/wire-carrier-control.ts +179 -0
- package/src/admission-control.test.ts +0 -270
- package/src/background-audio.test.ts +0 -224
- package/src/browser-opus.test.ts +0 -41
- package/src/browser-pacing.test.ts +0 -440
- package/src/edge-twilio.test.ts +0 -293
- package/src/edge.test.ts +0 -591
- package/src/graceful-drain.test.ts +0 -306
- package/src/inbound-audio.test.ts +0 -58
- package/src/index.test.ts +0 -2074
- package/src/outbound-playout-pipeline.test.ts +0 -314
- package/src/paced-playout.test.ts +0 -247
- package/src/playout-progress.test.ts +0 -56
- package/src/session-store.test.ts +0 -274
- package/src/smartpbx.test.ts +0 -967
- package/src/telnyx.test.ts +0 -1457
- package/src/transport-host.test.ts +0 -51
- package/src/turn-metrics.test.ts +0 -327
- package/src/twilio-auth.test.ts +0 -36
- package/src/twilio.test.ts +0 -1275
- package/src/websocket-close.test.ts +0 -63
- package/src/websocket-lifecycle.test.ts +0 -49
- package/tsconfig.json +0 -22
- package/vitest.config.ts +0 -7
package/src/edge-twilio.test.ts
DELETED
|
@@ -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
|
-
});
|