@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
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kuralle
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kuralle-syrinx/server-websocket",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.ts",
|
|
11
|
+
"./edge": "./src/edge.ts",
|
|
12
|
+
"./edge-twilio": "./src/edge-twilio.ts",
|
|
13
|
+
"./session-store": "./src/session-store.ts"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@evan/opus": "1.0.3",
|
|
17
|
+
"ws": "^8.18.0",
|
|
18
|
+
"@kuralle-syrinx/core": "2.1.0",
|
|
19
|
+
"@kuralle-syrinx/ws": "2.1.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "^22.0.0",
|
|
23
|
+
"@types/ws": "^8.5.0",
|
|
24
|
+
"typescript": "^5.7.0",
|
|
25
|
+
"vitest": "^2.1.0"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"test": "vitest run --fileParallelism=false"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
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("destroys sockets on unmatched upgrade paths when this router is the sole upgrade handler", async () => {
|
|
83
|
+
const httpServer = registerHttpServer(createServer());
|
|
84
|
+
const server = registerServer(await createVoiceWebSocketServer({
|
|
85
|
+
server: httpServer,
|
|
86
|
+
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
87
|
+
}));
|
|
88
|
+
await new Promise<void>((resolveListen, reject) => {
|
|
89
|
+
httpServer.once("error", reject);
|
|
90
|
+
httpServer.listen(0, "127.0.0.1", () => {
|
|
91
|
+
httpServer.off("error", reject);
|
|
92
|
+
resolveListen();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
const address = httpServer.address();
|
|
96
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
97
|
+
|
|
98
|
+
const unknownPathClient = registerSocket(
|
|
99
|
+
new WebSocket(`ws://127.0.0.1:${String(address.port)}/unknown-path`),
|
|
100
|
+
);
|
|
101
|
+
const closeCode = await waitForClose(unknownPathClient);
|
|
102
|
+
expect(closeCode).toBe(1006);
|
|
103
|
+
|
|
104
|
+
const registered = await openBrowserSocketReady(websocketUrl(address.port));
|
|
105
|
+
expect(registered.readyState).toBe(WebSocket.OPEN);
|
|
106
|
+
|
|
107
|
+
registered.close();
|
|
108
|
+
await server.close();
|
|
109
|
+
await new Promise<void>((resolveClose) => httpServer.close(() => resolveClose()));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("leaves unmatched upgrade paths for co-registered upgrade listeners on a shared HTTP server", async () => {
|
|
113
|
+
const httpServer = registerHttpServer(createServer());
|
|
114
|
+
const server = registerServer(await createVoiceWebSocketServer({
|
|
115
|
+
server: httpServer,
|
|
116
|
+
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
117
|
+
}));
|
|
118
|
+
const foreignPath = "/foreign-ws";
|
|
119
|
+
const foreignConnections: WebSocket[] = [];
|
|
120
|
+
const foreignListener = (
|
|
121
|
+
request: import("node:http").IncomingMessage,
|
|
122
|
+
socket: import("node:net").Socket,
|
|
123
|
+
head: Buffer,
|
|
124
|
+
): void => {
|
|
125
|
+
if (new URL(request.url ?? "/", "http://localhost").pathname !== foreignPath) return;
|
|
126
|
+
const foreignServer = new WebSocketServer({ noServer: true });
|
|
127
|
+
foreignServer.handleUpgrade(request, socket, head, (websocket) => {
|
|
128
|
+
foreignConnections.push(websocket);
|
|
129
|
+
foreignServer.emit("connection", websocket, request);
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
httpServer.on("upgrade", foreignListener);
|
|
133
|
+
await new Promise<void>((resolveListen, reject) => {
|
|
134
|
+
httpServer.once("error", reject);
|
|
135
|
+
httpServer.listen(0, "127.0.0.1", () => {
|
|
136
|
+
httpServer.off("error", reject);
|
|
137
|
+
resolveListen();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
const address = httpServer.address();
|
|
141
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
142
|
+
|
|
143
|
+
const foreignClient = await openSocket(`ws://127.0.0.1:${String(address.port)}${foreignPath}`, {
|
|
144
|
+
perMessageDeflate: false,
|
|
145
|
+
});
|
|
146
|
+
expect(foreignClient.readyState).toBe(WebSocket.OPEN);
|
|
147
|
+
expect(foreignConnections).toHaveLength(1);
|
|
148
|
+
|
|
149
|
+
foreignClient.close();
|
|
150
|
+
await server.close();
|
|
151
|
+
httpServer.off("upgrade", foreignListener);
|
|
152
|
+
await new Promise<void>((resolveClose) => httpServer.close(() => resolveClose()));
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("routes multiple provider websocket paths on a shared HTTP server without handshake cross-talk", async () => {
|
|
156
|
+
const httpServer = registerHttpServer(createServer());
|
|
157
|
+
const [twilio, telnyx, smartpbx] = await Promise.all([
|
|
158
|
+
registerServer(await createTwilioMediaStreamServer({
|
|
159
|
+
server: httpServer,
|
|
160
|
+
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
161
|
+
})),
|
|
162
|
+
registerServer(await createTelnyxMediaStreamServer({
|
|
163
|
+
server: httpServer,
|
|
164
|
+
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
165
|
+
})),
|
|
166
|
+
registerServer(await createSmartPbxMediaStreamServer({
|
|
167
|
+
server: httpServer,
|
|
168
|
+
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
169
|
+
})),
|
|
170
|
+
]);
|
|
171
|
+
await new Promise<void>((resolveListen, reject) => {
|
|
172
|
+
httpServer.once("error", reject);
|
|
173
|
+
httpServer.listen(0, "127.0.0.1", () => {
|
|
174
|
+
httpServer.off("error", reject);
|
|
175
|
+
resolveListen();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
const address = httpServer.address();
|
|
179
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
180
|
+
|
|
181
|
+
const clients = await Promise.all([
|
|
182
|
+
openSocket(`ws://127.0.0.1:${String(address.port)}/twilio`, { perMessageDeflate: false }),
|
|
183
|
+
openSocket(`ws://127.0.0.1:${String(address.port)}/telnyx`, { perMessageDeflate: false }),
|
|
184
|
+
openSocket(`ws://127.0.0.1:${String(address.port)}/media-stream`, { perMessageDeflate: false }),
|
|
185
|
+
]);
|
|
186
|
+
expect(clients.map((client) => client.readyState)).toEqual([
|
|
187
|
+
WebSocket.OPEN,
|
|
188
|
+
WebSocket.OPEN,
|
|
189
|
+
WebSocket.OPEN,
|
|
190
|
+
]);
|
|
191
|
+
|
|
192
|
+
const scanner = registerSocket(
|
|
193
|
+
new WebSocket(`ws://127.0.0.1:${String(address.port)}/random-scanner-path`),
|
|
194
|
+
);
|
|
195
|
+
expect(await waitForClose(scanner)).toBe(1006);
|
|
196
|
+
|
|
197
|
+
for (const client of clients) client.close();
|
|
198
|
+
await Promise.all([twilio.close(), telnyx.close(), smartpbx.close()]);
|
|
199
|
+
await new Promise<void>((resolveClose) => httpServer.close(() => resolveClose()));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("enforces a global shared-server cap when maxConcurrentSessionsScope is server", async () => {
|
|
203
|
+
const httpServer = registerHttpServer(createServer());
|
|
204
|
+
const metrics: string[] = [];
|
|
205
|
+
const [twilio, telnyx] = await Promise.all([
|
|
206
|
+
registerServer(await createTwilioMediaStreamServer({
|
|
207
|
+
server: httpServer,
|
|
208
|
+
maxConcurrentSessions: 1,
|
|
209
|
+
maxConcurrentSessionsScope: "server",
|
|
210
|
+
onTransportMetric: (name) => metrics.push(name),
|
|
211
|
+
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
212
|
+
})),
|
|
213
|
+
registerServer(await createTelnyxMediaStreamServer({
|
|
214
|
+
server: httpServer,
|
|
215
|
+
maxConcurrentSessions: 1,
|
|
216
|
+
maxConcurrentSessionsScope: "server",
|
|
217
|
+
onTransportMetric: (name) => metrics.push(name),
|
|
218
|
+
createSession: () => new VoiceAgentSession({ plugins: {} }),
|
|
219
|
+
})),
|
|
220
|
+
]);
|
|
221
|
+
await new Promise<void>((resolveListen, reject) => {
|
|
222
|
+
httpServer.once("error", reject);
|
|
223
|
+
httpServer.listen(0, "127.0.0.1", () => {
|
|
224
|
+
httpServer.off("error", reject);
|
|
225
|
+
resolveListen();
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
const address = httpServer.address();
|
|
229
|
+
if (!address || typeof address === "string") throw new Error("Expected TCP address");
|
|
230
|
+
|
|
231
|
+
const first = await openSocket(`ws://127.0.0.1:${String(address.port)}/twilio`, { perMessageDeflate: false });
|
|
232
|
+
expect(first.readyState).toBe(WebSocket.OPEN);
|
|
233
|
+
|
|
234
|
+
const second = registerSocket(new WebSocket(`ws://127.0.0.1:${String(address.port)}/telnyx`));
|
|
235
|
+
await new Promise<void>((resolveOpen, reject) => {
|
|
236
|
+
second.once("open", resolveOpen);
|
|
237
|
+
second.once("error", reject);
|
|
238
|
+
});
|
|
239
|
+
const closeCode = await waitForClose(second);
|
|
240
|
+
expect(closeCode).toBe(1013);
|
|
241
|
+
expect(metrics).toContain(TRANSPORT_ADMISSION_REJECTED_METRIC);
|
|
242
|
+
|
|
243
|
+
first.close();
|
|
244
|
+
await Promise.all([twilio.close(), telnyx.close()]);
|
|
245
|
+
await new Promise<void>((resolveClose) => httpServer.close(() => resolveClose()));
|
|
246
|
+
});
|
|
247
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
|
|
3
|
+
import { Decoder as OpusDecoder, Encoder as OpusEncoder } from "@evan/opus";
|
|
4
|
+
import { pcm16BytesToSamples, pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
|
|
5
|
+
|
|
6
|
+
export const BROWSER_OPUS_SAMPLE_RATE_HZ = 48_000;
|
|
7
|
+
export const BROWSER_OPUS_FRAME_DURATION_MS = 20;
|
|
8
|
+
|
|
9
|
+
export type BrowserOpusCodec = {
|
|
10
|
+
readonly sampleRateHz: number;
|
|
11
|
+
readonly frameSamples: number;
|
|
12
|
+
encodePcm16Frame(samples: Int16Array, flush?: boolean): Uint8Array[];
|
|
13
|
+
decodeOpusFrame(wire: Uint8Array): Int16Array;
|
|
14
|
+
reset(): void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function createBrowserOpusCodec(sampleRateHz = BROWSER_OPUS_SAMPLE_RATE_HZ): BrowserOpusCodec {
|
|
18
|
+
const encoder = new OpusEncoder({ channels: 1, sample_rate: sampleRateHz as 48000, application: "voip" });
|
|
19
|
+
const decoder = new OpusDecoder({ channels: 1, sample_rate: sampleRateHz as 48000 });
|
|
20
|
+
const frameSamples = Math.max(1, Math.round((sampleRateHz * BROWSER_OPUS_FRAME_DURATION_MS) / 1000));
|
|
21
|
+
let encodeRemainder = new Int16Array(0);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
sampleRateHz,
|
|
25
|
+
frameSamples,
|
|
26
|
+
reset() {
|
|
27
|
+
encodeRemainder = new Int16Array(0);
|
|
28
|
+
},
|
|
29
|
+
decodeOpusFrame(wire: Uint8Array): Int16Array {
|
|
30
|
+
return pcm16BytesToSamples(decoder.decode(wire));
|
|
31
|
+
},
|
|
32
|
+
encodePcm16Frame(samples: Int16Array, flush = false): Uint8Array[] {
|
|
33
|
+
const pending = new Int16Array(encodeRemainder.length + samples.length);
|
|
34
|
+
pending.set(encodeRemainder);
|
|
35
|
+
pending.set(samples, encodeRemainder.length);
|
|
36
|
+
const completeFrames = Math.floor(pending.length / frameSamples);
|
|
37
|
+
const frames: Uint8Array[] = [];
|
|
38
|
+
for (let index = 0; index < completeFrames; index += 1) {
|
|
39
|
+
const frame = pending.subarray(index * frameSamples, (index + 1) * frameSamples);
|
|
40
|
+
frames.push(encoder.encode(pcm16SamplesToBytes(frame)));
|
|
41
|
+
}
|
|
42
|
+
const consumed = completeFrames * frameSamples;
|
|
43
|
+
const remainder = pending.subarray(consumed);
|
|
44
|
+
if (flush && remainder.length > 0) {
|
|
45
|
+
const padded = new Int16Array(frameSamples);
|
|
46
|
+
padded.set(remainder);
|
|
47
|
+
frames.push(encoder.encode(pcm16SamplesToBytes(padded)));
|
|
48
|
+
encodeRemainder = new Int16Array(0);
|
|
49
|
+
} else {
|
|
50
|
+
encodeRemainder = new Int16Array(remainder);
|
|
51
|
+
}
|
|
52
|
+
return frames;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function decodeBrowserOpusToPcm16Bytes(wire: Uint8Array, codec: BrowserOpusCodec): Uint8Array {
|
|
58
|
+
return pcm16SamplesToBytes(codec.decodeOpusFrame(wire));
|
|
59
|
+
}
|