@kuralle-syrinx/server-websocket 4.4.0 → 4.5.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/dist/backchannel-cue-fixtures.d.ts +4 -0
- package/dist/backchannel-cue-fixtures.d.ts.map +1 -0
- package/dist/backchannel-cue-fixtures.js +25 -0
- package/dist/backchannel-cue-fixtures.js.map +1 -0
- package/dist/background-audio.d.ts +59 -0
- package/dist/background-audio.d.ts.map +1 -0
- package/dist/background-audio.js +248 -0
- package/dist/background-audio.js.map +1 -0
- package/dist/browser-opus.d.ts +12 -0
- package/dist/browser-opus.d.ts.map +1 -0
- package/dist/browser-opus.js +48 -0
- package/dist/browser-opus.js.map +1 -0
- package/dist/carrier-commands.d.ts +124 -0
- package/dist/carrier-commands.d.ts.map +1 -0
- package/dist/carrier-commands.js +193 -0
- package/dist/carrier-commands.js.map +1 -0
- package/dist/edge-telnyx.d.ts +37 -0
- package/dist/edge-telnyx.d.ts.map +1 -0
- package/dist/edge-telnyx.js +320 -0
- package/dist/edge-telnyx.js.map +1 -0
- package/dist/edge-twilio.d.ts +31 -0
- package/dist/edge-twilio.d.ts.map +1 -0
- package/dist/edge-twilio.js +286 -0
- package/dist/edge-twilio.js.map +1 -0
- package/dist/edge.d.ts +71 -0
- package/dist/edge.d.ts.map +1 -0
- package/dist/edge.js +560 -0
- package/dist/edge.js.map +1 -0
- package/dist/inbound-audio.d.ts +13 -0
- package/dist/inbound-audio.d.ts.map +1 -0
- package/dist/inbound-audio.js +66 -0
- package/dist/inbound-audio.js.map +1 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +718 -0
- package/dist/index.js.map +1 -0
- package/dist/json-message.d.ts +7 -0
- package/dist/json-message.d.ts.map +1 -0
- package/dist/json-message.js +41 -0
- package/dist/json-message.js.map +1 -0
- package/dist/outbound-playout-pipeline.d.ts +53 -0
- package/dist/outbound-playout-pipeline.d.ts.map +1 -0
- package/dist/outbound-playout-pipeline.js +148 -0
- package/dist/outbound-playout-pipeline.js.map +1 -0
- package/dist/paced-playout.d.ts +42 -0
- package/dist/paced-playout.d.ts.map +1 -0
- package/dist/paced-playout.js +151 -0
- package/dist/paced-playout.js.map +1 -0
- package/dist/playout-progress.d.ts +26 -0
- package/dist/playout-progress.d.ts.map +1 -0
- package/dist/playout-progress.js +64 -0
- package/dist/playout-progress.js.map +1 -0
- package/dist/session-store.d.ts +39 -0
- package/dist/session-store.d.ts.map +1 -0
- package/dist/session-store.js +80 -0
- package/dist/session-store.js.map +1 -0
- package/dist/smartpbx.d.ts +47 -0
- package/dist/smartpbx.d.ts.map +1 -0
- package/dist/smartpbx.js +437 -0
- package/dist/smartpbx.js.map +1 -0
- package/dist/telnyx-codec.d.ts +41 -0
- package/dist/telnyx-codec.d.ts.map +1 -0
- package/dist/telnyx-codec.js +114 -0
- package/dist/telnyx-codec.js.map +1 -0
- package/dist/telnyx.d.ts +52 -0
- package/dist/telnyx.d.ts.map +1 -0
- package/dist/telnyx.js +550 -0
- package/dist/telnyx.js.map +1 -0
- package/dist/test-helpers.d.ts +20 -0
- package/dist/test-helpers.d.ts.map +1 -0
- package/dist/test-helpers.js +242 -0
- package/dist/test-helpers.js.map +1 -0
- package/dist/transport-helpers.d.ts +12 -0
- package/dist/transport-helpers.d.ts.map +1 -0
- package/dist/transport-helpers.js +90 -0
- package/dist/transport-helpers.js.map +1 -0
- package/dist/transport-host.d.ts +56 -0
- package/dist/transport-host.d.ts.map +1 -0
- package/dist/transport-host.js +110 -0
- package/dist/transport-host.js.map +1 -0
- package/dist/turn-metrics.d.ts +79 -0
- package/dist/turn-metrics.d.ts.map +1 -0
- package/dist/turn-metrics.js +208 -0
- package/dist/turn-metrics.js.map +1 -0
- package/dist/twilio-auth.d.ts +12 -0
- package/dist/twilio-auth.d.ts.map +1 -0
- package/dist/twilio-auth.js +44 -0
- package/dist/twilio-auth.js.map +1 -0
- package/dist/twilio.d.ts +56 -0
- package/dist/twilio.d.ts.map +1 -0
- package/dist/twilio.js +511 -0
- package/dist/twilio.js.map +1 -0
- package/dist/websocket-close.d.ts +12 -0
- package/dist/websocket-close.d.ts.map +1 -0
- package/dist/websocket-close.js +58 -0
- package/dist/websocket-close.js.map +1 -0
- package/dist/websocket-lifecycle.d.ts +29 -0
- package/dist/websocket-lifecycle.d.ts.map +1 -0
- package/dist/websocket-lifecycle.js +87 -0
- package/dist/websocket-lifecycle.js.map +1 -0
- package/dist/websocket-upgrade.d.ts +9 -0
- package/dist/websocket-upgrade.d.ts.map +1 -0
- package/dist/websocket-upgrade.js +91 -0
- package/dist/websocket-upgrade.js.map +1 -0
- package/dist/wire-carrier-control.d.ts +27 -0
- package/dist/wire-carrier-control.d.ts.map +1 -0
- package/dist/wire-carrier-control.js +116 -0
- package/dist/wire-carrier-control.js.map +1 -0
- package/package.json +32 -11
- package/src/admission-control.test.ts +270 -0
- package/src/background-audio.test.ts +224 -0
- package/src/browser-opus.test.ts +41 -0
- package/src/browser-pacing.test.ts +440 -0
- package/src/carrier-commands.test.ts +198 -0
- package/src/edge-telnyx.test.ts +333 -0
- package/src/edge-twilio.test.ts +293 -0
- package/src/edge.test.ts +727 -0
- package/src/graceful-drain.test.ts +306 -0
- package/src/inbound-audio.test.ts +58 -0
- package/src/index.test.ts +2078 -0
- package/src/outbound-playout-pipeline.test.ts +314 -0
- package/src/paced-playout.test.ts +247 -0
- package/src/playout-progress.test.ts +56 -0
- package/src/session-store.test.ts +274 -0
- package/src/smartpbx.test.ts +986 -0
- package/src/telnyx.test.ts +1633 -0
- package/src/transport-host.test.ts +51 -0
- package/src/turn-metrics.test.ts +503 -0
- package/src/twilio-auth.test.ts +36 -0
- package/src/twilio.test.ts +1274 -0
- package/src/websocket-close.test.ts +63 -0
- package/src/websocket-lifecycle.test.ts +49 -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(48000); // opus frames carry the 48 kHz codec rate
|
|
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,198 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// Unit tests for pure carrier command constructors + mockable dispatch.
|
|
4
|
+
// Live carrier HTTP is NOT exercised (no credentials).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it, vi } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
buildTelnyxSendDtmf,
|
|
9
|
+
buildTelnyxTransfer,
|
|
10
|
+
buildTwilioSendDigits,
|
|
11
|
+
buildTwilioTransfer,
|
|
12
|
+
dispatchTelnyxCommand,
|
|
13
|
+
dispatchTwilioCommand,
|
|
14
|
+
resolveTransferSummary,
|
|
15
|
+
type FetchLike,
|
|
16
|
+
} from "./carrier-commands.js";
|
|
17
|
+
|
|
18
|
+
describe("buildTwilioSendDigits", () => {
|
|
19
|
+
it("maps digits + pause syntax into SendDigits and TwiML Play", () => {
|
|
20
|
+
const cmd = buildTwilioSendDigits("CAxxx", { digits: "1w2W9*#" });
|
|
21
|
+
expect(cmd.carrier).toBe("twilio");
|
|
22
|
+
expect(cmd.path).toBe("/Calls/CAxxx.json");
|
|
23
|
+
expect(cmd.form.SendDigits).toBe("1w2W9*#");
|
|
24
|
+
expect(cmd.twiml).toBe('<Response><Play digits="1w2W9*#"/></Response>');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("URL-encodes call SID in the path", () => {
|
|
28
|
+
const cmd = buildTwilioSendDigits("CA/special", { digits: "0" });
|
|
29
|
+
expect(cmd.path).toBe("/Calls/CA%2Fspecial.json");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("buildTelnyxSendDtmf", () => {
|
|
34
|
+
it("builds Call Control send_dtmf payload with pause letters retained", () => {
|
|
35
|
+
const cmd = buildTelnyxSendDtmf("v3:abc", { digits: "9w0W#" }, { durationMillis: 250 });
|
|
36
|
+
expect(cmd.carrier).toBe("telnyx");
|
|
37
|
+
expect(cmd.path).toBe("/v2/calls/v3%3Aabc/actions/send_dtmf");
|
|
38
|
+
expect(cmd.json).toEqual({ digits: "9w0W#", duration_millis: 250 });
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("buildTwilioTransfer", () => {
|
|
43
|
+
it("cold transfer emits Dial TwiML for E.164", () => {
|
|
44
|
+
const cmd = buildTwilioTransfer("CAcall", {
|
|
45
|
+
mode: "cold",
|
|
46
|
+
target: "+15551234567",
|
|
47
|
+
});
|
|
48
|
+
expect(cmd.twiml).toBe("<Response><Dial>+15551234567</Dial></Response>");
|
|
49
|
+
expect(cmd.form.Twiml).toContain("<Dial>");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("warm transfer carries summary in a TwiML comment and command field", () => {
|
|
53
|
+
const cmd = buildTwilioTransfer("CAcall", {
|
|
54
|
+
mode: "warm",
|
|
55
|
+
target: "+15557654321",
|
|
56
|
+
summary: "Caller asked about billing",
|
|
57
|
+
});
|
|
58
|
+
expect(cmd.summary).toBe("Caller asked about billing");
|
|
59
|
+
expect(cmd.twiml).toContain("<!-- warm-handoff: Caller asked about billing -->");
|
|
60
|
+
expect(cmd.twiml).toContain("<Dial>+15557654321</Dial>");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("sip_refer still uses Dial/Sip (not raw REFER) for answer-rate", () => {
|
|
64
|
+
const cmd = buildTwilioTransfer("CAcall", {
|
|
65
|
+
mode: "sip_refer",
|
|
66
|
+
target: "sip:agent@example.com",
|
|
67
|
+
});
|
|
68
|
+
expect(cmd.twiml).toBe("<Response><Dial><Sip>sip:agent@example.com</Sip></Dial></Response>");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("redirectUrl mode posts Url instead of inline Twiml", () => {
|
|
72
|
+
const cmd = buildTwilioTransfer(
|
|
73
|
+
"CAcall",
|
|
74
|
+
{ mode: "cold", target: "+15550001111" },
|
|
75
|
+
{ redirectUrl: "https://example.com/twiml" },
|
|
76
|
+
);
|
|
77
|
+
expect(cmd.form).toEqual({ Url: "https://example.com/twiml", Method: "POST" });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("buildTelnyxTransfer", () => {
|
|
82
|
+
it("cold transfer posts to Call Control transfer action", () => {
|
|
83
|
+
const cmd = buildTelnyxTransfer("v3:id", { mode: "cold", target: "+15551234567" });
|
|
84
|
+
expect(cmd.path).toBe("/v2/calls/v3%3Aid/actions/transfer");
|
|
85
|
+
expect(cmd.json.to).toBe("+15551234567");
|
|
86
|
+
expect(cmd.json.client_state).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("warm transfer encodes summary into client_state", () => {
|
|
90
|
+
const cmd = buildTelnyxTransfer("v3:id", {
|
|
91
|
+
mode: "warm",
|
|
92
|
+
target: "+15550001111",
|
|
93
|
+
summary: "Needs refund help",
|
|
94
|
+
});
|
|
95
|
+
expect(cmd.summary).toBe("Needs refund help");
|
|
96
|
+
expect(cmd.json.client_state).toBeTypeOf("string");
|
|
97
|
+
const raw = cmd.json.client_state!;
|
|
98
|
+
// base64url → standard base64 with correct padding
|
|
99
|
+
let b64 = raw.replace(/-/g, "+").replace(/_/g, "/");
|
|
100
|
+
while (b64.length % 4 !== 0) b64 += "=";
|
|
101
|
+
const json = JSON.parse(atob(b64)) as { summary: string; mode: string };
|
|
102
|
+
expect(json).toEqual({ summary: "Needs refund help", mode: "warm" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("sip_refer mode still uses transfer action with SIP URI as to", () => {
|
|
106
|
+
const cmd = buildTelnyxTransfer("v3:id", {
|
|
107
|
+
mode: "sip_refer",
|
|
108
|
+
target: "sip:desk@pbx.example",
|
|
109
|
+
});
|
|
110
|
+
expect(cmd.path).toContain("/actions/transfer");
|
|
111
|
+
expect(cmd.json.to).toBe("sip:desk@pbx.example");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("resolveTransferSummary (warm seam)", () => {
|
|
116
|
+
it("uses packet.summary when provided", async () => {
|
|
117
|
+
const summary = await resolveTransferSummary({
|
|
118
|
+
kind: "call.transfer",
|
|
119
|
+
contextId: "c1",
|
|
120
|
+
timestampMs: 1,
|
|
121
|
+
mode: "warm",
|
|
122
|
+
target: "+1",
|
|
123
|
+
summary: "from packet",
|
|
124
|
+
});
|
|
125
|
+
expect(summary).toBe("from packet");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("calls injectable summarizer when warm and summary missing", async () => {
|
|
129
|
+
const summarizer = vi.fn(async () => "from seam");
|
|
130
|
+
const summary = await resolveTransferSummary(
|
|
131
|
+
{
|
|
132
|
+
kind: "call.transfer",
|
|
133
|
+
contextId: "c1",
|
|
134
|
+
timestampMs: 1,
|
|
135
|
+
mode: "warm",
|
|
136
|
+
target: "+1555",
|
|
137
|
+
},
|
|
138
|
+
summarizer,
|
|
139
|
+
);
|
|
140
|
+
expect(summarizer).toHaveBeenCalledWith({ contextId: "c1", target: "+1555" });
|
|
141
|
+
expect(summary).toBe("from seam");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("does not call summarizer for cold transfer", async () => {
|
|
145
|
+
const summarizer = vi.fn(async () => "nope");
|
|
146
|
+
const summary = await resolveTransferSummary(
|
|
147
|
+
{
|
|
148
|
+
kind: "call.transfer",
|
|
149
|
+
contextId: "c1",
|
|
150
|
+
timestampMs: 1,
|
|
151
|
+
mode: "cold",
|
|
152
|
+
target: "+1555",
|
|
153
|
+
},
|
|
154
|
+
summarizer,
|
|
155
|
+
);
|
|
156
|
+
expect(summarizer).not.toHaveBeenCalled();
|
|
157
|
+
expect(summary).toBeUndefined();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("dispatch via fetch (mockable, no live carrier)", () => {
|
|
162
|
+
it("dispatchTwilioCommand posts form body with Basic auth", async () => {
|
|
163
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
164
|
+
const fetchImpl: FetchLike = async (url, init) => {
|
|
165
|
+
calls.push({ url, init });
|
|
166
|
+
return new Response("{}", { status: 200 });
|
|
167
|
+
};
|
|
168
|
+
const cmd = buildTwilioSendDigits("CAsid", { digits: "12" });
|
|
169
|
+
await dispatchTwilioCommand(
|
|
170
|
+
{ accountSid: "ACacct", authToken: "secret" },
|
|
171
|
+
cmd,
|
|
172
|
+
fetchImpl,
|
|
173
|
+
);
|
|
174
|
+
expect(calls).toHaveLength(1);
|
|
175
|
+
expect(calls[0]!.url).toContain("/2010-04-01/Accounts/ACacct/Calls/CAsid.json");
|
|
176
|
+
expect(calls[0]!.init?.method).toBe("POST");
|
|
177
|
+
const headers = calls[0]!.init?.headers as Record<string, string>;
|
|
178
|
+
expect(headers.Authorization).toMatch(/^Basic /);
|
|
179
|
+
expect(headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
|
|
180
|
+
expect(calls[0]!.init?.body).toBe("SendDigits=12");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("dispatchTelnyxCommand posts JSON with Bearer auth", async () => {
|
|
184
|
+
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
|
185
|
+
const fetchImpl: FetchLike = async (url, init) => {
|
|
186
|
+
calls.push({ url, init });
|
|
187
|
+
return new Response("{}", { status: 200 });
|
|
188
|
+
};
|
|
189
|
+
const cmd = buildTelnyxTransfer("v3:x", { mode: "cold", target: "+1555" });
|
|
190
|
+
await dispatchTelnyxCommand({ apiKey: "KEY" }, cmd, fetchImpl);
|
|
191
|
+
expect(calls[0]!.url).toBe("https://api.telnyx.com/v2/calls/v3%3Ax/actions/transfer");
|
|
192
|
+
expect(calls[0]!.init?.headers).toMatchObject({
|
|
193
|
+
Authorization: "Bearer KEY",
|
|
194
|
+
"Content-Type": "application/json",
|
|
195
|
+
});
|
|
196
|
+
expect(JSON.parse(String(calls[0]!.init?.body))).toEqual({ to: "+1555" });
|
|
197
|
+
});
|
|
198
|
+
});
|