@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
|
@@ -1,270 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { createServer } from "node:http";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
5
|
-
import WebSocket, { WebSocketServer } from "ws";
|
|
6
|
-
import { VoiceAgentSession } from "@kuralle-syrinx/core";
|
|
7
|
-
import {
|
|
8
|
-
createSmartPbxMediaStreamServer,
|
|
9
|
-
createTelnyxMediaStreamServer,
|
|
10
|
-
createTwilioMediaStreamServer,
|
|
11
|
-
createVoiceWebSocketServer,
|
|
12
|
-
} from "./index.js";
|
|
13
|
-
import { TRANSPORT_ADMISSION_REJECTED_METRIC } from "./transport-host.js";
|
|
14
|
-
import {
|
|
15
|
-
openBrowserSocketReady,
|
|
16
|
-
openSocket,
|
|
17
|
-
registerHttpServer,
|
|
18
|
-
registerServer,
|
|
19
|
-
registerSocket,
|
|
20
|
-
setupTransportTestCleanup,
|
|
21
|
-
waitForClose,
|
|
22
|
-
waitForCondition,
|
|
23
|
-
} from "./test-helpers.js";
|
|
24
|
-
|
|
25
|
-
setupTransportTestCleanup();
|
|
26
|
-
|
|
27
|
-
function websocketUrl(port: number): string {
|
|
28
|
-
return `ws://127.0.0.1:${port}/ws`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
describe("WT-08 admission control and upgrade routing", () => {
|
|
32
|
-
it("rejects connections beyond maxConcurrentSessions with 1013 and transport.admission_rejected", async () => {
|
|
33
|
-
const metrics: string[] = [];
|
|
34
|
-
const server = registerServer(await createVoiceWebSocketServer({
|
|
35
|
-
port: 0,
|
|
36
|
-
maxConcurrentSessions: 2,
|
|
37
|
-
onTransportMetric: (name) => metrics.push(name),
|
|
38
|
-
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
39
|
-
}));
|
|
40
|
-
const address = server.address();
|
|
41
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
42
|
-
|
|
43
|
-
const first = await openBrowserSocketReady(websocketUrl(address.port));
|
|
44
|
-
const second = await openBrowserSocketReady(websocketUrl(address.port));
|
|
45
|
-
expect(server.wsServer.clients.size).toBe(2);
|
|
46
|
-
|
|
47
|
-
const rejected = registerSocket(new WebSocket(websocketUrl(address.port)));
|
|
48
|
-
await new Promise<void>((resolveOpen, reject) => {
|
|
49
|
-
rejected.once("open", resolveOpen);
|
|
50
|
-
rejected.once("error", reject);
|
|
51
|
-
});
|
|
52
|
-
const closeCode = await waitForClose(rejected);
|
|
53
|
-
|
|
54
|
-
expect(closeCode).toBe(1013);
|
|
55
|
-
expect(metrics).toContain(TRANSPORT_ADMISSION_REJECTED_METRIC);
|
|
56
|
-
|
|
57
|
-
first.close();
|
|
58
|
-
second.close();
|
|
59
|
-
await server.close();
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("allows a new connection after an admitted session disconnects", async () => {
|
|
63
|
-
const server = registerServer(await createVoiceWebSocketServer({
|
|
64
|
-
port: 0,
|
|
65
|
-
maxConcurrentSessions: 1,
|
|
66
|
-
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
67
|
-
}));
|
|
68
|
-
const address = server.address();
|
|
69
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
70
|
-
|
|
71
|
-
const first = await openBrowserSocketReady(websocketUrl(address.port));
|
|
72
|
-
first.close();
|
|
73
|
-
await waitForCondition(() => server.wsServer.clients.size === 0);
|
|
74
|
-
|
|
75
|
-
const second = await openBrowserSocketReady(websocketUrl(address.port));
|
|
76
|
-
expect(second.readyState).toBe(WebSocket.OPEN);
|
|
77
|
-
|
|
78
|
-
second.close();
|
|
79
|
-
await server.close();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("rejects an unauthorized upgrade with 4401 and admits an authorized one", async () => {
|
|
83
|
-
const server = registerServer(await createVoiceWebSocketServer({
|
|
84
|
-
port: 0,
|
|
85
|
-
authorize: (request) => new URL(request.url ?? "/", "http://x").searchParams.get("token") === "secret",
|
|
86
|
-
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
87
|
-
}));
|
|
88
|
-
const address = server.address();
|
|
89
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
90
|
-
|
|
91
|
-
const rejected = registerSocket(new WebSocket(`${websocketUrl(address.port)}?token=wrong`));
|
|
92
|
-
await new Promise<void>((resolve, reject) => {
|
|
93
|
-
rejected.once("open", resolve);
|
|
94
|
-
rejected.once("error", reject);
|
|
95
|
-
});
|
|
96
|
-
expect(await waitForClose(rejected)).toBe(4401);
|
|
97
|
-
|
|
98
|
-
const authorized = await openBrowserSocketReady(`${websocketUrl(address.port)}?token=secret`);
|
|
99
|
-
expect(authorized.readyState).toBe(WebSocket.OPEN);
|
|
100
|
-
|
|
101
|
-
authorized.close();
|
|
102
|
-
await server.close();
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("destroys sockets on unmatched upgrade paths when this router is the sole upgrade handler", async () => {
|
|
106
|
-
const httpServer = registerHttpServer(createServer());
|
|
107
|
-
const server = registerServer(await createVoiceWebSocketServer({
|
|
108
|
-
server: httpServer,
|
|
109
|
-
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
110
|
-
}));
|
|
111
|
-
await new Promise<void>((resolveListen, reject) => {
|
|
112
|
-
httpServer.once("error", reject);
|
|
113
|
-
httpServer.listen(0, "127.0.0.1", () => {
|
|
114
|
-
httpServer.off("error", reject);
|
|
115
|
-
resolveListen();
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
const address = httpServer.address();
|
|
119
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
120
|
-
|
|
121
|
-
const unknownPathClient = registerSocket(
|
|
122
|
-
new WebSocket(`ws://127.0.0.1:${String(address.port)}/unknown-path`),
|
|
123
|
-
);
|
|
124
|
-
const closeCode = await waitForClose(unknownPathClient);
|
|
125
|
-
expect(closeCode).toBe(1006);
|
|
126
|
-
|
|
127
|
-
const registered = await openBrowserSocketReady(websocketUrl(address.port));
|
|
128
|
-
expect(registered.readyState).toBe(WebSocket.OPEN);
|
|
129
|
-
|
|
130
|
-
registered.close();
|
|
131
|
-
await server.close();
|
|
132
|
-
await new Promise<void>((resolveClose) => httpServer.close(() => resolveClose()));
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("leaves unmatched upgrade paths for co-registered upgrade listeners on a shared HTTP server", async () => {
|
|
136
|
-
const httpServer = registerHttpServer(createServer());
|
|
137
|
-
const server = registerServer(await createVoiceWebSocketServer({
|
|
138
|
-
server: httpServer,
|
|
139
|
-
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
140
|
-
}));
|
|
141
|
-
const foreignPath = "/foreign-ws";
|
|
142
|
-
const foreignConnections: WebSocket[] = [];
|
|
143
|
-
const foreignListener = (
|
|
144
|
-
request: import("node:http").IncomingMessage,
|
|
145
|
-
socket: import("node:net").Socket,
|
|
146
|
-
head: Buffer,
|
|
147
|
-
): void => {
|
|
148
|
-
if (new URL(request.url ?? "/", "http://localhost").pathname !== foreignPath) return;
|
|
149
|
-
const foreignServer = new WebSocketServer({ noServer: true });
|
|
150
|
-
foreignServer.handleUpgrade(request, socket, head, (websocket) => {
|
|
151
|
-
foreignConnections.push(websocket);
|
|
152
|
-
foreignServer.emit("connection", websocket, request);
|
|
153
|
-
});
|
|
154
|
-
};
|
|
155
|
-
httpServer.on("upgrade", foreignListener);
|
|
156
|
-
await new Promise<void>((resolveListen, reject) => {
|
|
157
|
-
httpServer.once("error", reject);
|
|
158
|
-
httpServer.listen(0, "127.0.0.1", () => {
|
|
159
|
-
httpServer.off("error", reject);
|
|
160
|
-
resolveListen();
|
|
161
|
-
});
|
|
162
|
-
});
|
|
163
|
-
const address = httpServer.address();
|
|
164
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
165
|
-
|
|
166
|
-
const foreignClient = await openSocket(`ws://127.0.0.1:${String(address.port)}${foreignPath}`, {
|
|
167
|
-
perMessageDeflate: false,
|
|
168
|
-
});
|
|
169
|
-
expect(foreignClient.readyState).toBe(WebSocket.OPEN);
|
|
170
|
-
expect(foreignConnections).toHaveLength(1);
|
|
171
|
-
|
|
172
|
-
foreignClient.close();
|
|
173
|
-
await server.close();
|
|
174
|
-
httpServer.off("upgrade", foreignListener);
|
|
175
|
-
await new Promise<void>((resolveClose) => httpServer.close(() => resolveClose()));
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
it("routes multiple provider websocket paths on a shared HTTP server without handshake cross-talk", async () => {
|
|
179
|
-
const httpServer = registerHttpServer(createServer());
|
|
180
|
-
const [twilio, telnyx, smartpbx] = await Promise.all([
|
|
181
|
-
registerServer(await createTwilioMediaStreamServer({
|
|
182
|
-
server: httpServer,
|
|
183
|
-
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
184
|
-
})),
|
|
185
|
-
registerServer(await createTelnyxMediaStreamServer({
|
|
186
|
-
server: httpServer,
|
|
187
|
-
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
188
|
-
})),
|
|
189
|
-
registerServer(await createSmartPbxMediaStreamServer({
|
|
190
|
-
server: httpServer,
|
|
191
|
-
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
192
|
-
})),
|
|
193
|
-
]);
|
|
194
|
-
await new Promise<void>((resolveListen, reject) => {
|
|
195
|
-
httpServer.once("error", reject);
|
|
196
|
-
httpServer.listen(0, "127.0.0.1", () => {
|
|
197
|
-
httpServer.off("error", reject);
|
|
198
|
-
resolveListen();
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
const address = httpServer.address();
|
|
202
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
203
|
-
|
|
204
|
-
const clients = await Promise.all([
|
|
205
|
-
openSocket(`ws://127.0.0.1:${String(address.port)}/twilio`, { perMessageDeflate: false }),
|
|
206
|
-
openSocket(`ws://127.0.0.1:${String(address.port)}/telnyx`, { perMessageDeflate: false }),
|
|
207
|
-
openSocket(`ws://127.0.0.1:${String(address.port)}/media-stream`, { perMessageDeflate: false }),
|
|
208
|
-
]);
|
|
209
|
-
expect(clients.map((client) => client.readyState)).toEqual([
|
|
210
|
-
WebSocket.OPEN,
|
|
211
|
-
WebSocket.OPEN,
|
|
212
|
-
WebSocket.OPEN,
|
|
213
|
-
]);
|
|
214
|
-
|
|
215
|
-
const scanner = registerSocket(
|
|
216
|
-
new WebSocket(`ws://127.0.0.1:${String(address.port)}/random-scanner-path`),
|
|
217
|
-
);
|
|
218
|
-
expect(await waitForClose(scanner)).toBe(1006);
|
|
219
|
-
|
|
220
|
-
for (const client of clients) client.close();
|
|
221
|
-
await Promise.all([twilio.close(), telnyx.close(), smartpbx.close()]);
|
|
222
|
-
await new Promise<void>((resolveClose) => httpServer.close(() => resolveClose()));
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it("enforces a global shared-server cap when maxConcurrentSessionsScope is server", async () => {
|
|
226
|
-
const httpServer = registerHttpServer(createServer());
|
|
227
|
-
const metrics: string[] = [];
|
|
228
|
-
const [twilio, telnyx] = await Promise.all([
|
|
229
|
-
registerServer(await createTwilioMediaStreamServer({
|
|
230
|
-
server: httpServer,
|
|
231
|
-
maxConcurrentSessions: 1,
|
|
232
|
-
maxConcurrentSessionsScope: "server",
|
|
233
|
-
onTransportMetric: (name) => metrics.push(name),
|
|
234
|
-
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
235
|
-
})),
|
|
236
|
-
registerServer(await createTelnyxMediaStreamServer({
|
|
237
|
-
server: httpServer,
|
|
238
|
-
maxConcurrentSessions: 1,
|
|
239
|
-
maxConcurrentSessionsScope: "server",
|
|
240
|
-
onTransportMetric: (name) => metrics.push(name),
|
|
241
|
-
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
242
|
-
})),
|
|
243
|
-
]);
|
|
244
|
-
await new Promise<void>((resolveListen, reject) => {
|
|
245
|
-
httpServer.once("error", reject);
|
|
246
|
-
httpServer.listen(0, "127.0.0.1", () => {
|
|
247
|
-
httpServer.off("error", reject);
|
|
248
|
-
resolveListen();
|
|
249
|
-
});
|
|
250
|
-
});
|
|
251
|
-
const address = httpServer.address();
|
|
252
|
-
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
253
|
-
|
|
254
|
-
const first = await openSocket(`ws://127.0.0.1:${String(address.port)}/twilio`, { perMessageDeflate: false });
|
|
255
|
-
expect(first.readyState).toBe(WebSocket.OPEN);
|
|
256
|
-
|
|
257
|
-
const second = registerSocket(new WebSocket(`ws://127.0.0.1:${String(address.port)}/telnyx`));
|
|
258
|
-
await new Promise<void>((resolveOpen, reject) => {
|
|
259
|
-
second.once("open", resolveOpen);
|
|
260
|
-
second.once("error", reject);
|
|
261
|
-
});
|
|
262
|
-
const closeCode = await waitForClose(second);
|
|
263
|
-
expect(closeCode).toBe(1013);
|
|
264
|
-
expect(metrics).toContain(TRANSPORT_ADMISSION_REJECTED_METRIC);
|
|
265
|
-
|
|
266
|
-
first.close();
|
|
267
|
-
await Promise.all([twilio.close(), telnyx.close()]);
|
|
268
|
-
await new Promise<void>((resolveClose) => httpServer.close(() => resolveClose()));
|
|
269
|
-
});
|
|
270
|
-
});
|
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
|
|
5
|
-
import { BackgroundAudioMixer } from "./background-audio.js";
|
|
6
|
-
|
|
7
|
-
function pcmBytes(samples: number[]): Uint8Array {
|
|
8
|
-
return new Uint8Array(Int16Array.from(samples).buffer.slice(0));
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function samplesOf(bytes: Uint8Array): number[] {
|
|
12
|
-
return Array.from(new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 2));
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Constant-value ambient source: every mixed sample adds value*gain. */
|
|
16
|
-
function constantSource(value: number, length = 160, sampleRateHz = 16000) {
|
|
17
|
-
return { pcm: new Int16Array(length).fill(value), sampleRateHz };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
describe("BackgroundAudioMixer", () => {
|
|
21
|
-
it("mixes ducked ambient into a TTS chunk and full-gain ambient into idle frames", () => {
|
|
22
|
-
const mixer = new BackgroundAudioMixer({
|
|
23
|
-
ambient: { ...constantSource(1000), gain: 0.5 },
|
|
24
|
-
duckWhileSpeaking: 0.5,
|
|
25
|
-
fadeMs: 0,
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const t0 = 1_000_000;
|
|
29
|
-
const mixed = mixer.mix(pcmBytes([0, 0, 0, 0]), 16000, t0);
|
|
30
|
-
// ambient 1000 * gain 0.5 * duck 0.5 = 250 on top of silence
|
|
31
|
-
expect(samplesOf(mixed)).toEqual([250, 250, 250, 250]);
|
|
32
|
-
|
|
33
|
-
// Once speech has drained, the idle frame carries ambient at full gain.
|
|
34
|
-
const idle = mixer.idleFrame(1, 16000, t0 + 10_000);
|
|
35
|
-
expect(idle).not.toBeNull();
|
|
36
|
-
expect(samplesOf(idle!)).toEqual(Array(16).fill(500)); // 1ms @16k = 16 samples, 1000*0.5
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("suppresses idle frames while speech is still playing out", () => {
|
|
40
|
-
const mixer = new BackgroundAudioMixer({ ambient: constantSource(1000), fadeMs: 0 });
|
|
41
|
-
const t0 = 2_000_000;
|
|
42
|
-
// 160 samples @16k = 10ms of speech → speaking until t0+10.
|
|
43
|
-
mixer.mix(pcmBytes(Array(160).fill(0)), 16000, t0);
|
|
44
|
-
expect(mixer.isSpeaking(t0 + 5)).toBe(true);
|
|
45
|
-
expect(mixer.idleFrame(20, 16000, t0 + 5)).toBeNull();
|
|
46
|
-
expect(mixer.isSpeaking(t0 + 11)).toBe(false);
|
|
47
|
-
expect(mixer.idleFrame(20, 16000, t0 + 11)).not.toBeNull();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("keeps the ambient position continuous across mix and idle frames", () => {
|
|
51
|
-
// Ramp source so position is observable: sample i has value i.
|
|
52
|
-
const ramp = Int16Array.from({ length: 1000 }, (_, i) => i);
|
|
53
|
-
const mixer = new BackgroundAudioMixer({
|
|
54
|
-
ambient: { pcm: ramp, sampleRateHz: 16000, gain: 1 },
|
|
55
|
-
duckWhileSpeaking: 1,
|
|
56
|
-
fadeMs: 0,
|
|
57
|
-
});
|
|
58
|
-
const t0 = 3_000_000;
|
|
59
|
-
const first = mixer.mix(pcmBytes([0, 0, 0, 0]), 16000, t0);
|
|
60
|
-
expect(samplesOf(first)).toEqual([0, 1, 2, 3]);
|
|
61
|
-
const idle = mixer.idleFrame(1, 16000, t0 + 1000);
|
|
62
|
-
expect(samplesOf(idle!)[0]).toBe(4); // continues where mix left off
|
|
63
|
-
const next = mixer.mix(pcmBytes([0, 0]), 16000, t0 + 2000);
|
|
64
|
-
expect(samplesOf(next)).toEqual([20, 21]); // 4 + 16 idle samples consumed
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("loops the ambient source past its end", () => {
|
|
68
|
-
const ramp = Int16Array.from({ length: 4 }, (_, i) => i + 1); // 1,2,3,4
|
|
69
|
-
const mixer = new BackgroundAudioMixer({
|
|
70
|
-
ambient: { pcm: ramp, sampleRateHz: 16000, gain: 1 },
|
|
71
|
-
duckWhileSpeaking: 1,
|
|
72
|
-
fadeMs: 0,
|
|
73
|
-
});
|
|
74
|
-
const mixed = mixer.mix(pcmBytes(Array(10).fill(0)), 16000, 4_000_000);
|
|
75
|
-
expect(samplesOf(mixed)).toEqual([1, 2, 3, 4, 1, 2, 3, 4, 1, 2]);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("plays the thinking loop only while thinking, and restarts it fresh each episode", () => {
|
|
79
|
-
const ramp = Int16Array.from({ length: 100 }, (_, i) => i);
|
|
80
|
-
const mixer = new BackgroundAudioMixer({
|
|
81
|
-
thinking: { pcm: ramp, sampleRateHz: 16000, gain: 1 },
|
|
82
|
-
fadeMs: 0,
|
|
83
|
-
});
|
|
84
|
-
const t0 = 5_000_000;
|
|
85
|
-
expect(mixer.idleFrame(1, 16000, t0)).toBeNull(); // nothing to play
|
|
86
|
-
|
|
87
|
-
mixer.setThinking(true);
|
|
88
|
-
const a = mixer.idleFrame(1, 16000, t0);
|
|
89
|
-
expect(samplesOf(a!)).toEqual(Array.from({ length: 16 }, (_, i) => i));
|
|
90
|
-
|
|
91
|
-
mixer.setThinking(false);
|
|
92
|
-
expect(mixer.idleFrame(1, 16000, t0)).toBeNull();
|
|
93
|
-
|
|
94
|
-
mixer.setThinking(true); // new episode restarts at 0
|
|
95
|
-
const b = mixer.idleFrame(1, 16000, t0);
|
|
96
|
-
expect(samplesOf(b!)[0]).toBe(0);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("resamples sources to the wire rate once and mixes correctly", () => {
|
|
100
|
-
// 8k constant source mixed into a 16k chunk: values unchanged, length follows the chunk.
|
|
101
|
-
const mixer = new BackgroundAudioMixer({
|
|
102
|
-
ambient: { pcm: new Int16Array(80).fill(800), sampleRateHz: 8000, gain: 1 },
|
|
103
|
-
duckWhileSpeaking: 1,
|
|
104
|
-
fadeMs: 0,
|
|
105
|
-
});
|
|
106
|
-
const mixed = mixer.mix(pcmBytes(Array(8).fill(0)), 16000, 6_000_000);
|
|
107
|
-
expect(samplesOf(mixed)).toEqual(Array(8).fill(800));
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("clips the sum to int16 range", () => {
|
|
111
|
-
const mixer = new BackgroundAudioMixer({
|
|
112
|
-
ambient: { pcm: new Int16Array(16).fill(20000), sampleRateHz: 16000, gain: 1 },
|
|
113
|
-
duckWhileSpeaking: 1,
|
|
114
|
-
fadeMs: 0,
|
|
115
|
-
});
|
|
116
|
-
const mixed = mixer.mix(pcmBytes([30000, -30000]), 16000, 7_000_000);
|
|
117
|
-
expect(samplesOf(mixed)).toEqual([32767, -10000]);
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
it("passes TTS through untouched when no sources are configured", () => {
|
|
121
|
-
const mixer = new BackgroundAudioMixer({});
|
|
122
|
-
const chunk = pcmBytes([1, 2, 3]);
|
|
123
|
-
expect(samplesOf(mixer.mix(chunk, 16000, 8_000_000))).toEqual([1, 2, 3]);
|
|
124
|
-
expect(mixer.idleFrame(20, 16000, 8_000_000)).toBeNull();
|
|
125
|
-
expect(mixer.hasSources).toBe(false);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("fades the ambient bed in at start instead of hard-cutting (equal-power ramp)", () => {
|
|
129
|
-
// fadeMs 1 @ 16k = 16-sample ramp.
|
|
130
|
-
const mixer = new BackgroundAudioMixer({
|
|
131
|
-
ambient: { ...constantSource(1000), gain: 1 },
|
|
132
|
-
fadeMs: 1,
|
|
133
|
-
});
|
|
134
|
-
const first = mixer.idleFrame(1, 16000, 9_000_000)!;
|
|
135
|
-
const samples = samplesOf(first);
|
|
136
|
-
expect(samples[0]).toBe(0); // sin(0) = 0
|
|
137
|
-
expect(samples[8]!).toBeGreaterThan(400); // mid-ramp
|
|
138
|
-
expect(samples[8]!).toBeLessThan(900);
|
|
139
|
-
// Past the ramp: full gain, permanently.
|
|
140
|
-
const second = mixer.idleFrame(1, 16000, 9_000_100)!;
|
|
141
|
-
expect(samplesOf(second)).toEqual(Array(16).fill(1000));
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("fades the thinking loop out on stop, then goes silent", () => {
|
|
145
|
-
const mixer = new BackgroundAudioMixer({
|
|
146
|
-
thinking: { pcm: new Int16Array(1000).fill(1000), sampleRateHz: 16000, gain: 1 },
|
|
147
|
-
fadeMs: 1, // 16-sample ramps
|
|
148
|
-
});
|
|
149
|
-
const t0 = 10_000_000;
|
|
150
|
-
mixer.setThinking(true);
|
|
151
|
-
mixer.idleFrame(2, 16000, t0); // ride past the fade-in
|
|
152
|
-
const steady = mixer.idleFrame(1, 16000, t0 + 100)!;
|
|
153
|
-
expect(samplesOf(steady)).toEqual(Array(16).fill(1000));
|
|
154
|
-
|
|
155
|
-
mixer.setThinking(false);
|
|
156
|
-
const fading = mixer.idleFrame(1, 16000, t0 + 200)!; // one fade-out ramp, decaying
|
|
157
|
-
const fadeSamples = samplesOf(fading);
|
|
158
|
-
expect(fadeSamples[0]).toBe(1000); // cos(0) = 1
|
|
159
|
-
expect(fadeSamples[15]!).toBeLessThan(250); // near the end of the ramp
|
|
160
|
-
expect(mixer.idleFrame(1, 16000, t0 + 300)).toBeNull(); // episode fully over
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("IP-C3: plays a one-shot cue in an idle frame with thinking ducked under it", () => {
|
|
164
|
-
const ramp = Int16Array.from({ length: 2000 }, (_, i) => (i % 2 === 0 ? 800 : -800));
|
|
165
|
-
const cue = Int16Array.from({ length: 160 }, (_, i) => Math.round(6000 * Math.sin((2 * Math.PI * i) / 160)));
|
|
166
|
-
const mixer = new BackgroundAudioMixer({
|
|
167
|
-
thinking: { pcm: ramp, sampleRateHz: 16000, gain: 1 },
|
|
168
|
-
cues: { mm_hmm: { pcm: cue, sampleRateHz: 16000, gain: 1 } },
|
|
169
|
-
fadeMs: 0,
|
|
170
|
-
});
|
|
171
|
-
const t0 = 12_000_000;
|
|
172
|
-
mixer.setThinking(true);
|
|
173
|
-
expect(mixer.queueCue("mm_hmm")).toBe(true);
|
|
174
|
-
|
|
175
|
-
const frame = mixer.idleFrame(10, 16000, t0)!;
|
|
176
|
-
const samples = samplesOf(frame);
|
|
177
|
-
expect(samples.some((s) => Math.abs(s) > 1000)).toBe(true);
|
|
178
|
-
expect(samples.every((s) => s <= 32767 && s >= -32768)).toBe(true);
|
|
179
|
-
|
|
180
|
-
mixer.setThinking(true);
|
|
181
|
-
expect(mixer.queueCue("missing")).toBe(false);
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it("IP-C3: thinking loop continues before and after a one-shot cue", () => {
|
|
185
|
-
const thinking = Int16Array.from({ length: 1000 }, (_, i) => i);
|
|
186
|
-
const cue = new Int16Array(32).fill(5000);
|
|
187
|
-
const mixer = new BackgroundAudioMixer({
|
|
188
|
-
thinking: { pcm: thinking, sampleRateHz: 16000, gain: 1 },
|
|
189
|
-
cues: { mm_hmm: { pcm: cue, sampleRateHz: 16000, gain: 1 } },
|
|
190
|
-
fadeMs: 0,
|
|
191
|
-
});
|
|
192
|
-
const t0 = 13_000_000;
|
|
193
|
-
mixer.setThinking(true);
|
|
194
|
-
const before = samplesOf(mixer.idleFrame(1, 16000, t0)!);
|
|
195
|
-
expect(before[0]).toBe(0);
|
|
196
|
-
|
|
197
|
-
mixer.queueCue("mm_hmm");
|
|
198
|
-
mixer.idleFrame(1, 16000, t0 + 100);
|
|
199
|
-
const after = samplesOf(mixer.idleFrame(1, 16000, t0 + 200)!);
|
|
200
|
-
expect(after[0]).toBeGreaterThan(0);
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
it("IP-C3: resolves cues from config bytes without filesystem access", () => {
|
|
204
|
-
const cuePcm = new Int16Array(80).fill(1200);
|
|
205
|
-
const mixer = new BackgroundAudioMixer({
|
|
206
|
-
cues: { mm_hmm: { pcm: cuePcm, sampleRateHz: 16000, gain: 0.8 } },
|
|
207
|
-
fadeMs: 0,
|
|
208
|
-
});
|
|
209
|
-
expect(mixer.hasCue("mm_hmm")).toBe(true);
|
|
210
|
-
expect(mixer.queueCue("mm_hmm")).toBe(true);
|
|
211
|
-
const frame = mixer.idleFrame(5, 16000, 14_000_000)!;
|
|
212
|
-
expect(samplesOf(frame).some((s) => s !== 0)).toBe(true);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it("fades each new thinking episode in from silence", () => {
|
|
216
|
-
const mixer = new BackgroundAudioMixer({
|
|
217
|
-
thinking: { pcm: new Int16Array(1000).fill(1000), sampleRateHz: 16000, gain: 1 },
|
|
218
|
-
fadeMs: 1,
|
|
219
|
-
});
|
|
220
|
-
mixer.setThinking(true);
|
|
221
|
-
const first = mixer.idleFrame(1, 16000, 11_000_000)!;
|
|
222
|
-
expect(samplesOf(first)[0]).toBe(0); // soft entry, not a hard cut
|
|
223
|
-
});
|
|
224
|
-
});
|
package/src/browser-opus.test.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { Decoder as OpusDecoder, Encoder as OpusEncoder } from "@evan/opus";
|
|
5
|
-
import { pcm16BytesToSamples, pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
|
|
6
|
-
import { BROWSER_OPUS_FRAME_DURATION_MS, createBrowserOpusCodec } from "./browser-opus.js";
|
|
7
|
-
|
|
8
|
-
describe("browser opus codec", () => {
|
|
9
|
-
it("produces non-empty opus frames and decodes them back to PCM16 bytes", () => {
|
|
10
|
-
const pcm = new Int16Array(960);
|
|
11
|
-
pcm[0] = 1000;
|
|
12
|
-
pcm[3] = -1000;
|
|
13
|
-
const encoder = new OpusEncoder({ channels: 1, sample_rate: 48000, application: "voip" });
|
|
14
|
-
const decoder = new OpusDecoder({ channels: 1, sample_rate: 48000 });
|
|
15
|
-
const opus = encoder.encode(pcm16SamplesToBytes(pcm));
|
|
16
|
-
expect(opus.byteLength).toBeGreaterThan(0);
|
|
17
|
-
const decoded = pcm16BytesToSamples(decoder.decode(opus));
|
|
18
|
-
expect(decoded.length).toBeGreaterThan(0);
|
|
19
|
-
|
|
20
|
-
const codec = createBrowserOpusCodec(48000);
|
|
21
|
-
const wire = codec.encodePcm16Frame(pcm, true)[0]!;
|
|
22
|
-
expect(wire.byteLength).toBeGreaterThan(0);
|
|
23
|
-
expect(codec.decodeOpusFrame(wire).length).toBeGreaterThan(0);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("does not zero-pad partial PCM until explicitly requested", () => {
|
|
27
|
-
const codec = createBrowserOpusCodec(48000);
|
|
28
|
-
const quarter = new Int16Array(Math.round((48000 * BROWSER_OPUS_FRAME_DURATION_MS) / 4000));
|
|
29
|
-
expect(codec.encodePcm16Frame(quarter, false)).toEqual([]);
|
|
30
|
-
expect(codec.encodePcm16Frame(quarter, false)).toEqual([]);
|
|
31
|
-
expect(codec.encodePcm16Frame(new Int16Array(0), true).length).toBeGreaterThan(0);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("accumulates partial PCM before emitting a complete opus frame", () => {
|
|
35
|
-
const codec = createBrowserOpusCodec(48000);
|
|
36
|
-
const half = new Int16Array(Math.round((48000 * BROWSER_OPUS_FRAME_DURATION_MS) / 2000));
|
|
37
|
-
expect(codec.encodePcm16Frame(half, false)).toEqual([]);
|
|
38
|
-
const full = codec.encodePcm16Frame(half, true);
|
|
39
|
-
expect(full.length).toBeGreaterThan(0);
|
|
40
|
-
});
|
|
41
|
-
});
|