@kuralle-syrinx/server-websocket 4.4.1 → 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,306 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// WT-04: Graceful connection draining on shutdown
|
|
3
|
+
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } 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 { createTwilioMediaStreamServer, type TwilioMediaStreamServer } from "./twilio.js";
|
|
10
|
+
import { createVoiceWebSocketServer, type VoiceWebSocketServer } from "./index.js";
|
|
11
|
+
|
|
12
|
+
function twilioUrl(port: number): string {
|
|
13
|
+
return `ws://127.0.0.1:${port}/twilio`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function browserUrl(port: number): string {
|
|
17
|
+
return `ws://127.0.0.1:${port}/ws`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function openSocket(url: string): Promise<WebSocket> {
|
|
21
|
+
const socket = new WebSocket(url);
|
|
22
|
+
await new Promise<void>((resolveOpen, reject) => {
|
|
23
|
+
socket.once("open", resolveOpen);
|
|
24
|
+
socket.once("error", reject);
|
|
25
|
+
});
|
|
26
|
+
return socket;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The browser server sends `ready` proactively right after the upgrade. The message
|
|
30
|
+
// listener must therefore be attached BEFORE the socket opens — `ws` does not buffer
|
|
31
|
+
// events for an absent listener, so `await openSocket()` then a later `readJsonMatching`
|
|
32
|
+
// races and drops `ready`, hanging the test. (Telephony servers don't send anything
|
|
33
|
+
// until the client's `start`, so they aren't exposed to this.)
|
|
34
|
+
async function openBrowserSocketReady(url: string): Promise<WebSocket> {
|
|
35
|
+
const socket = new WebSocket(url);
|
|
36
|
+
await new Promise<void>((resolve, reject) => {
|
|
37
|
+
const onMessage = (data: WebSocket.RawData, isBinary: boolean): void => {
|
|
38
|
+
if (isBinary) return;
|
|
39
|
+
if ((JSON.parse(data.toString()) as { type?: string }).type === "ready") {
|
|
40
|
+
socket.off("message", onMessage);
|
|
41
|
+
resolve();
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
socket.on("message", onMessage);
|
|
45
|
+
socket.once("error", reject);
|
|
46
|
+
});
|
|
47
|
+
return socket;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function twilioStart(streamSid = "MZ-test", callSid = "CA-test"): Record<string, unknown> {
|
|
51
|
+
return {
|
|
52
|
+
event: "start",
|
|
53
|
+
streamSid,
|
|
54
|
+
start: {
|
|
55
|
+
streamSid,
|
|
56
|
+
callSid,
|
|
57
|
+
mediaFormat: { encoding: "audio/x-mulaw", sampleRate: 8000, channels: 1 },
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function waitForClose(socket: WebSocket): Promise<number> {
|
|
63
|
+
return new Promise<number>((resolve) => {
|
|
64
|
+
let done = false;
|
|
65
|
+
const finish = (code: number): void => {
|
|
66
|
+
if (!done) { done = true; resolve(code); }
|
|
67
|
+
};
|
|
68
|
+
// A graceful close sends a 1001 frame → a clean 'close' with that code (wins, since
|
|
69
|
+
// the server waits for the handshake before any terminate). A non-graceful
|
|
70
|
+
// terminate() RSTs the socket → the ws client surfaces ECONNRESET as an 'error'
|
|
71
|
+
// that is not always followed by a timely 'close', so treat it as an abnormal 1006.
|
|
72
|
+
socket.on("close", (code) => finish(code));
|
|
73
|
+
socket.on("error", () => finish(1006));
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function waitMs(ms: number): Promise<void> {
|
|
78
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function readJsonMatching(socket: WebSocket, predicate: (m: unknown) => boolean): Promise<unknown> {
|
|
82
|
+
return new Promise((resolve) => {
|
|
83
|
+
const onMessage = (data: WebSocket.RawData, isBinary: boolean) => {
|
|
84
|
+
if (isBinary) return;
|
|
85
|
+
const message = JSON.parse(data.toString()) as unknown;
|
|
86
|
+
if (!predicate(message)) return;
|
|
87
|
+
socket.off("message", onMessage);
|
|
88
|
+
resolve(message);
|
|
89
|
+
};
|
|
90
|
+
socket.on("message", onMessage);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Track servers so we can force-close them in afterEach even if test fails
|
|
95
|
+
let activeServers: Array<TwilioMediaStreamServer | VoiceWebSocketServer> = [];
|
|
96
|
+
let activeHttpServers: ReturnType<typeof createServer>[] = [];
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
activeServers = [];
|
|
100
|
+
activeHttpServers = [];
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterEach(async () => {
|
|
104
|
+
await Promise.allSettled(activeServers.map((s) => s.close()));
|
|
105
|
+
await Promise.allSettled(
|
|
106
|
+
activeHttpServers.map((h) => new Promise<void>((res) => h.close(() => res()))),
|
|
107
|
+
);
|
|
108
|
+
activeServers = [];
|
|
109
|
+
activeHttpServers = [];
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("graceful connection draining (WT-04)", () => {
|
|
113
|
+
it("non-graceful close terminates all clients immediately", async () => {
|
|
114
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
115
|
+
const server = await createTwilioMediaStreamServer({
|
|
116
|
+
port: 0,
|
|
117
|
+
createSession: () => session,
|
|
118
|
+
});
|
|
119
|
+
activeServers.push(server);
|
|
120
|
+
const address = server.address();
|
|
121
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
122
|
+
|
|
123
|
+
const client = await openSocket(twilioUrl(address.port));
|
|
124
|
+
client.send(JSON.stringify(twilioStart()));
|
|
125
|
+
await waitMs(30);
|
|
126
|
+
|
|
127
|
+
const closePromise = waitForClose(client);
|
|
128
|
+
const before = Date.now();
|
|
129
|
+
await server.close(); // non-graceful (default)
|
|
130
|
+
const closeCode = await closePromise;
|
|
131
|
+
const elapsed = Date.now() - before;
|
|
132
|
+
|
|
133
|
+
// terminate() sends no close frame; client sees abnormal close (1006)
|
|
134
|
+
expect(closeCode).toBe(1006);
|
|
135
|
+
expect(elapsed).toBeLessThan(500);
|
|
136
|
+
}, 10_000);
|
|
137
|
+
|
|
138
|
+
it("graceful close with no pending audio sends 1001 going-away to clients", async () => {
|
|
139
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
140
|
+
const server = await createTwilioMediaStreamServer({
|
|
141
|
+
port: 0,
|
|
142
|
+
createSession: () => session,
|
|
143
|
+
});
|
|
144
|
+
activeServers.push(server);
|
|
145
|
+
const address = server.address();
|
|
146
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
147
|
+
|
|
148
|
+
const client = await openSocket(twilioUrl(address.port));
|
|
149
|
+
client.send(JSON.stringify(twilioStart()));
|
|
150
|
+
await waitMs(30); // let wireSession + processMessage complete
|
|
151
|
+
|
|
152
|
+
const closePromise = waitForClose(client);
|
|
153
|
+
await server.close({ graceful: true, drainDeadlineMs: 5_000 });
|
|
154
|
+
const closeCode = await closePromise;
|
|
155
|
+
|
|
156
|
+
expect(closeCode).toBe(1001);
|
|
157
|
+
}, 10_000);
|
|
158
|
+
|
|
159
|
+
it("graceful close drains pending paced audio before sending 1001", async () => {
|
|
160
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
161
|
+
const server = await createTwilioMediaStreamServer({
|
|
162
|
+
port: 0,
|
|
163
|
+
outboundFrameDurationMs: 20,
|
|
164
|
+
outputSampleRateHz: 8000,
|
|
165
|
+
maxQueuedOutputAudioMs: 30_000,
|
|
166
|
+
createSession: () => session,
|
|
167
|
+
});
|
|
168
|
+
activeServers.push(server);
|
|
169
|
+
const address = server.address();
|
|
170
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
171
|
+
|
|
172
|
+
const client = await openSocket(twilioUrl(address.port));
|
|
173
|
+
client.send(JSON.stringify(twilioStart()));
|
|
174
|
+
await waitMs(30);
|
|
175
|
+
|
|
176
|
+
const mediaFrames: unknown[] = [];
|
|
177
|
+
client.on("message", (data, isBinary) => {
|
|
178
|
+
if (isBinary) return;
|
|
179
|
+
const message = JSON.parse(data.toString()) as { event?: string };
|
|
180
|
+
if (message.event === "media") mediaFrames.push(message);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Push audio via the bus (bus delivers asynchronously)
|
|
184
|
+
const audio80ms = pcm16SamplesToBytes(new Int16Array(640)); // 80ms at 8kHz
|
|
185
|
+
session.bus.push(Route.Main, {
|
|
186
|
+
kind: "tts.audio",
|
|
187
|
+
contextId: "twilio-CA-test",
|
|
188
|
+
timestampMs: Date.now(),
|
|
189
|
+
audio: audio80ms,
|
|
190
|
+
sampleRateHz: 8000,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// Wait for at least one media frame to arrive — confirms audio is in the queue
|
|
194
|
+
await readJsonMatching(client, (m) => (m as { event?: string }).event === "media");
|
|
195
|
+
|
|
196
|
+
// Now the queue has remaining frames. Graceful close should drain them then send 1001.
|
|
197
|
+
const closePromise = waitForClose(client);
|
|
198
|
+
await server.close({ graceful: true, drainDeadlineMs: 5_000 });
|
|
199
|
+
const closeCode = await closePromise;
|
|
200
|
+
|
|
201
|
+
expect(closeCode).toBe(1001);
|
|
202
|
+
// At least one frame received before close (the one we waited for)
|
|
203
|
+
expect(mediaFrames.length).toBeGreaterThan(0);
|
|
204
|
+
}, 15_000);
|
|
205
|
+
|
|
206
|
+
it("graceful close force-terminates at drainDeadlineMs for wedged consumers", async () => {
|
|
207
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
208
|
+
const server = await createTwilioMediaStreamServer({
|
|
209
|
+
port: 0,
|
|
210
|
+
outboundFrameDurationMs: 20,
|
|
211
|
+
outputSampleRateHz: 8000,
|
|
212
|
+
maxQueuedOutputAudioMs: 30_000,
|
|
213
|
+
createSession: () => session,
|
|
214
|
+
});
|
|
215
|
+
activeServers.push(server);
|
|
216
|
+
const address = server.address();
|
|
217
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
218
|
+
|
|
219
|
+
const client = await openSocket(twilioUrl(address.port));
|
|
220
|
+
client.send(JSON.stringify(twilioStart()));
|
|
221
|
+
await waitMs(30);
|
|
222
|
+
|
|
223
|
+
// Push 30s of audio that will NOT drain before the deadline
|
|
224
|
+
const longAudio = pcm16SamplesToBytes(new Int16Array(8000 * 30)); // 30s at 8kHz
|
|
225
|
+
session.bus.push(Route.Main, {
|
|
226
|
+
kind: "tts.audio",
|
|
227
|
+
contextId: "twilio-CA-test",
|
|
228
|
+
timestampMs: Date.now(),
|
|
229
|
+
audio: longAudio,
|
|
230
|
+
sampleRateHz: 8000,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Wait for first frame so audio is confirmed in the queue
|
|
234
|
+
await readJsonMatching(client, (m) => (m as { event?: string }).event === "media");
|
|
235
|
+
|
|
236
|
+
const closePromise = waitForClose(client);
|
|
237
|
+
const before = Date.now();
|
|
238
|
+
// 80ms deadline — far shorter than 30s of audio
|
|
239
|
+
await server.close({ graceful: true, drainDeadlineMs: 80 });
|
|
240
|
+
const closeCode = await closePromise;
|
|
241
|
+
const elapsed = Date.now() - before;
|
|
242
|
+
|
|
243
|
+
// Force-terminated at deadline: client sees abnormal close
|
|
244
|
+
expect(closeCode).toBe(1006);
|
|
245
|
+
// Should complete within a reasonable time after the deadline
|
|
246
|
+
expect(elapsed).toBeLessThan(800);
|
|
247
|
+
}, 10_000);
|
|
248
|
+
|
|
249
|
+
it("createVoiceWebSocketServer graceful close sends 1001 to browser clients", async () => {
|
|
250
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
251
|
+
const server = await createVoiceWebSocketServer({
|
|
252
|
+
port: 0,
|
|
253
|
+
createSession: () => session,
|
|
254
|
+
});
|
|
255
|
+
activeServers.push(server);
|
|
256
|
+
const address = server.address();
|
|
257
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
258
|
+
|
|
259
|
+
const client = await openBrowserSocketReady(browserUrl(address.port));
|
|
260
|
+
const closePromise = waitForClose(client);
|
|
261
|
+
await server.close({ graceful: true, drainDeadlineMs: 3_000 });
|
|
262
|
+
expect(await closePromise).toBe(1001);
|
|
263
|
+
}, 10_000);
|
|
264
|
+
|
|
265
|
+
it("createVoiceWebSocketServer close() with no opts sends immediate RST (1006) to browser clients", async () => {
|
|
266
|
+
const session = new VoiceAgentSession({ plugins: {} });
|
|
267
|
+
const server = await createVoiceWebSocketServer({
|
|
268
|
+
port: 0,
|
|
269
|
+
createSession: () => session,
|
|
270
|
+
});
|
|
271
|
+
activeServers.push(server);
|
|
272
|
+
const address = server.address();
|
|
273
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
274
|
+
|
|
275
|
+
const client = await openBrowserSocketReady(browserUrl(address.port));
|
|
276
|
+
const closePromise = waitForClose(client);
|
|
277
|
+
await server.close();
|
|
278
|
+
expect(await closePromise).toBe(1006);
|
|
279
|
+
}, 10_000);
|
|
280
|
+
|
|
281
|
+
it("multiple simultaneous clients all receive 1001 on graceful close", async () => {
|
|
282
|
+
const server = await createTwilioMediaStreamServer({
|
|
283
|
+
port: 0,
|
|
284
|
+
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
285
|
+
});
|
|
286
|
+
activeServers.push(server);
|
|
287
|
+
const address = server.address();
|
|
288
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
289
|
+
|
|
290
|
+
const [c1, c2] = await Promise.all([
|
|
291
|
+
openSocket(twilioUrl(address.port)),
|
|
292
|
+
openSocket(twilioUrl(address.port)),
|
|
293
|
+
]);
|
|
294
|
+
c1.send(JSON.stringify(twilioStart("MZ-1", "CA-1")));
|
|
295
|
+
c2.send(JSON.stringify(twilioStart("MZ-2", "CA-2")));
|
|
296
|
+
await waitMs(40);
|
|
297
|
+
|
|
298
|
+
const [codes] = await Promise.all([
|
|
299
|
+
Promise.all([waitForClose(c1), waitForClose(c2)]),
|
|
300
|
+
server.close({ graceful: true, drainDeadlineMs: 3_000 }),
|
|
301
|
+
]);
|
|
302
|
+
|
|
303
|
+
expect(codes[0]).toBe(1001);
|
|
304
|
+
expect(codes[1]).toBe(1001);
|
|
305
|
+
}, 15_000);
|
|
306
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { encodeSyrinxAudioEnvelope } from "@kuralle-syrinx/core";
|
|
5
|
+
import { decodeInboundBinaryAudio, resampleAudioBytes, type OpusIngressDecoder } from "./inbound-audio.js";
|
|
6
|
+
|
|
7
|
+
// Regression for the opus mic-uplink double-resample P0: opus ingress is decoded
|
|
8
|
+
// AND resampled to the engine rate inside decodeInboundBinaryAudio, so it must
|
|
9
|
+
// report the ENGINE rate — not the 48 kHz header rate — or the caller resamples
|
|
10
|
+
// the already-16 kHz audio a second time and delivers ~1/3 the samples (3× fast).
|
|
11
|
+
describe("decodeInboundBinaryAudio opus rate", () => {
|
|
12
|
+
const ENGINE_RATE = 16000;
|
|
13
|
+
const WIRE_RATE = 48000;
|
|
14
|
+
|
|
15
|
+
// A fake opus decoder returning PCM at the opus native rate (48 kHz), as the real
|
|
16
|
+
// decoder does. decodeInboundBinaryAudio then resamples it ONCE to the engine rate.
|
|
17
|
+
// 20 ms at 48 kHz mono PCM16 = 960 samples = 1920 bytes → 320 samples = 640 bytes @ 16 kHz.
|
|
18
|
+
const opusNativePcm = new Uint8Array(1920);
|
|
19
|
+
const fakeOpusDecoder: OpusIngressDecoder = () => opusNativePcm;
|
|
20
|
+
|
|
21
|
+
function opusEnvelope(): Uint8Array {
|
|
22
|
+
// The opus payload bytes are opaque to the decoder mock.
|
|
23
|
+
const opusPayload = new Uint8Array([1, 2, 3, 4]);
|
|
24
|
+
return encodeSyrinxAudioEnvelope(
|
|
25
|
+
{
|
|
26
|
+
type: "audio",
|
|
27
|
+
contextId: "turn-1",
|
|
28
|
+
sampleRateHz: WIRE_RATE,
|
|
29
|
+
sequence: 1,
|
|
30
|
+
encoding: "opus",
|
|
31
|
+
channels: 1,
|
|
32
|
+
byteLength: opusPayload.byteLength,
|
|
33
|
+
durationMs: 20,
|
|
34
|
+
},
|
|
35
|
+
opusPayload,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
it("reports the engine rate, not the header rate, so the caller does not resample again", () => {
|
|
40
|
+
const decoded = decodeInboundBinaryAudio(
|
|
41
|
+
opusEnvelope(),
|
|
42
|
+
ENGINE_RATE,
|
|
43
|
+
false,
|
|
44
|
+
ENGINE_RATE,
|
|
45
|
+
new Map(),
|
|
46
|
+
fakeOpusDecoder,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Decoded once from 48 kHz → 16 kHz inside decodeInboundBinaryAudio: 640 bytes.
|
|
50
|
+
expect(decoded.sampleRateHz).toBe(ENGINE_RATE);
|
|
51
|
+
expect(decoded.audio.byteLength).toBe(640);
|
|
52
|
+
|
|
53
|
+
// The caller's final resample (reported rate → engine rate) is now an identity:
|
|
54
|
+
// 320 samples in, 320 samples out (not ~107 as under the old 48 kHz label → 3× fast).
|
|
55
|
+
const final = resampleAudioBytes(decoded.audio, decoded.sampleRateHz, ENGINE_RATE, new Map());
|
|
56
|
+
expect(final.byteLength).toBe(640);
|
|
57
|
+
});
|
|
58
|
+
});
|