@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,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,102 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
SYRINX_AUDIO_ENVELOPE_NAME,
|
|
5
|
+
decodeSyrinxAudioEnvelope,
|
|
6
|
+
hasSyrinxAudioEnvelope,
|
|
7
|
+
} from "@kuralle-syrinx/core";
|
|
8
|
+
import {
|
|
9
|
+
pcm16BytesToSamples,
|
|
10
|
+
pcm16SamplesToBytes,
|
|
11
|
+
resamplePcm16Streaming,
|
|
12
|
+
type StreamingPcm16Resampler,
|
|
13
|
+
} from "@kuralle-syrinx/core/audio";
|
|
14
|
+
|
|
15
|
+
export type OpusIngressDecoder = (wire: Uint8Array, sampleRateHz: number) => Uint8Array;
|
|
16
|
+
|
|
17
|
+
export interface DecodedInboundBinaryAudio {
|
|
18
|
+
readonly contextId?: string;
|
|
19
|
+
readonly sampleRateHz: number;
|
|
20
|
+
readonly sequence?: number;
|
|
21
|
+
readonly audio: Uint8Array;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function socketDataToBytes(data: string | Uint8Array): Uint8Array {
|
|
25
|
+
if (typeof data === "string") return new TextEncoder().encode(data);
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function decodeInboundBinaryAudio(
|
|
30
|
+
data: Uint8Array,
|
|
31
|
+
defaultSampleRateHz: number,
|
|
32
|
+
rawBinaryInput: boolean,
|
|
33
|
+
engineInputSampleRateHz: number,
|
|
34
|
+
streamingResamplers: Map<string, StreamingPcm16Resampler>,
|
|
35
|
+
decodeOpusIngress: OpusIngressDecoder | null,
|
|
36
|
+
): DecodedInboundBinaryAudio {
|
|
37
|
+
if (!hasSyrinxAudioEnvelope(data)) {
|
|
38
|
+
if (!rawBinaryInput) {
|
|
39
|
+
throw new Error(`Raw binary websocket audio is disabled; use ${SYRINX_AUDIO_ENVELOPE_NAME} or JSON audio frames`);
|
|
40
|
+
}
|
|
41
|
+
return { sampleRateHz: defaultSampleRateHz, audio: data };
|
|
42
|
+
}
|
|
43
|
+
const { header, audio } = decodeSyrinxAudioEnvelope(data);
|
|
44
|
+
const sampleRateHz = requirePositiveIntegerFromHeader(header.sampleRateHz) ?? defaultSampleRateHz;
|
|
45
|
+
const wireAudio = header.encoding === "opus"
|
|
46
|
+
? decodeOpusIngressMessage(audio, sampleRateHz, decodeOpusIngress, engineInputSampleRateHz, streamingResamplers)
|
|
47
|
+
: audio;
|
|
48
|
+
return {
|
|
49
|
+
contextId: typeof header.contextId === "string" && header.contextId.length > 0 ? header.contextId : undefined,
|
|
50
|
+
sampleRateHz,
|
|
51
|
+
sequence: header.sequence,
|
|
52
|
+
audio: wireAudio,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function decodeOpusIngressMessage(
|
|
57
|
+
wire: Uint8Array,
|
|
58
|
+
sampleRateHz: number,
|
|
59
|
+
decodeOpusIngress: OpusIngressDecoder | null,
|
|
60
|
+
engineInputSampleRateHz: number,
|
|
61
|
+
streamingResamplers: Map<string, StreamingPcm16Resampler>,
|
|
62
|
+
): Uint8Array {
|
|
63
|
+
if (!decodeOpusIngress) throw new Error("Browser websocket opus ingress is not initialized");
|
|
64
|
+
const pcm = decodeOpusIngress(wire, sampleRateHz);
|
|
65
|
+
if (sampleRateHz === engineInputSampleRateHz) return pcm;
|
|
66
|
+
const samples = pcm16BytesToSamples(pcm);
|
|
67
|
+
return pcm16SamplesToBytes(
|
|
68
|
+
resamplePcm16Streaming(streamingResamplers, samples, sampleRateHz, engineInputSampleRateHz),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function rememberContextSampleRate(
|
|
73
|
+
contextSampleRates: Map<string, number>,
|
|
74
|
+
contextId: string,
|
|
75
|
+
sampleRateHz: number,
|
|
76
|
+
): void {
|
|
77
|
+
const existing = contextSampleRates.get(contextId);
|
|
78
|
+
if (existing !== undefined && existing !== sampleRateHz) {
|
|
79
|
+
throw new Error(`Websocket audio sampleRateHz changed within context ${contextId}: ${existing} -> ${sampleRateHz}`);
|
|
80
|
+
}
|
|
81
|
+
contextSampleRates.set(contextId, sampleRateHz);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resampleAudioBytes(
|
|
85
|
+
audio: Uint8Array,
|
|
86
|
+
sourceSampleRateHz: number,
|
|
87
|
+
targetSampleRateHz: number,
|
|
88
|
+
streamingResamplers: Map<string, StreamingPcm16Resampler>,
|
|
89
|
+
): Uint8Array {
|
|
90
|
+
if (audio.byteLength % 2 !== 0) {
|
|
91
|
+
throw new Error("PCM16 audio payload must contain an even number of bytes");
|
|
92
|
+
}
|
|
93
|
+
if (sourceSampleRateHz === targetSampleRateHz) return audio;
|
|
94
|
+
const samples = pcm16BytesToSamples(audio);
|
|
95
|
+
const resampled = resamplePcm16Streaming(streamingResamplers, samples, sourceSampleRateHz, targetSampleRateHz);
|
|
96
|
+
return pcm16SamplesToBytes(resampled);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function requirePositiveIntegerFromHeader(value: unknown): number | null {
|
|
100
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) return null;
|
|
101
|
+
return value;
|
|
102
|
+
}
|