@kuralle-syrinx/browser-client 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/README.md +142 -0
- package/package.json +12 -4
- package/src/agent-state.ts +107 -0
- package/src/index.ts +37 -1
- package/src/session-metrics.ts +90 -0
- package/src/session-record.ts +307 -0
- package/src/turn-recorder.ts +207 -0
- package/src/turn-timeline.ts +148 -0
- package/src/audio.test.ts +0 -130
- package/src/index.test.ts +0 -818
- package/src/jitter-buffer.test.ts +0 -275
- package/src/studio-page.test.ts +0 -69
- package/src/transport.test.ts +0 -233
- package/tsconfig.json +0 -21
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
// WT-03: Client jitter buffer tests
|
|
3
|
-
|
|
4
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
-
import { AudioJitterBuffer } from "./audio.js";
|
|
6
|
-
|
|
7
|
-
// Mock AudioContext for testing
|
|
8
|
-
class MockAudioContext {
|
|
9
|
-
public currentTime = 0;
|
|
10
|
-
public destination = {};
|
|
11
|
-
private nextBufferId = 1;
|
|
12
|
-
|
|
13
|
-
createBuffer(channels: number, length: number, sampleRate: number): MockAudioBuffer {
|
|
14
|
-
return new MockAudioBuffer(channels, length, sampleRate, this.nextBufferId++);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
createBufferSource(): MockAudioBufferSource {
|
|
18
|
-
return new MockAudioBufferSource();
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
class MockAudioBuffer {
|
|
23
|
-
constructor(
|
|
24
|
-
public numberOfChannels: number,
|
|
25
|
-
public length: number,
|
|
26
|
-
public sampleRate: number,
|
|
27
|
-
public _id: number
|
|
28
|
-
) {}
|
|
29
|
-
|
|
30
|
-
get duration(): number {
|
|
31
|
-
return this.length / this.sampleRate;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
copyToChannel(_data: Float32Array, _channel: number): void {
|
|
35
|
-
// Mock implementation
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
class MockAudioBufferSource {
|
|
40
|
-
public buffer: MockAudioBuffer | null = null;
|
|
41
|
-
public onended: (() => void) | null = null;
|
|
42
|
-
private connected = false;
|
|
43
|
-
private started = false;
|
|
44
|
-
private startTime = 0;
|
|
45
|
-
|
|
46
|
-
connect(_destination: unknown): void {
|
|
47
|
-
this.connected = true;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
start(when = 0): void {
|
|
51
|
-
this.started = true;
|
|
52
|
-
this.startTime = when;
|
|
53
|
-
|
|
54
|
-
// Simulate completion after buffer duration
|
|
55
|
-
if (this.buffer && this.onended) {
|
|
56
|
-
setTimeout(() => {
|
|
57
|
-
this.onended?.();
|
|
58
|
-
}, this.buffer.duration * 1000);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
stop(): void {
|
|
63
|
-
this.started = false;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
describe("AudioJitterBuffer", () => {
|
|
68
|
-
let mockContext: MockAudioContext;
|
|
69
|
-
let jitterBuffer: AudioJitterBuffer;
|
|
70
|
-
|
|
71
|
-
beforeEach(() => {
|
|
72
|
-
vi.useFakeTimers();
|
|
73
|
-
mockContext = new MockAudioContext();
|
|
74
|
-
jitterBuffer = new AudioJitterBuffer(mockContext as any, {
|
|
75
|
-
sampleRateHz: 16000,
|
|
76
|
-
targetBufferMs: 100
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("enqueues and schedules audio frames with target buffer delay", () => {
|
|
81
|
-
const pcm16Data = new Int16Array(320); // 20ms at 16kHz
|
|
82
|
-
pcm16Data.fill(1000);
|
|
83
|
-
|
|
84
|
-
mockContext.currentTime = 1.0;
|
|
85
|
-
jitterBuffer.enqueue(pcm16Data.buffer, "test-context");
|
|
86
|
-
|
|
87
|
-
// Should schedule audio ~100ms + frame duration in the future
|
|
88
|
-
expect(jitterBuffer.bufferedDurationMs).toBeCloseTo(120, -1);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it("schedules multiple frames contiguously", () => {
|
|
92
|
-
const frameData = new Int16Array(320); // 20ms at 16kHz
|
|
93
|
-
frameData.fill(1000);
|
|
94
|
-
|
|
95
|
-
mockContext.currentTime = 1.0;
|
|
96
|
-
|
|
97
|
-
// Enqueue 3 frames
|
|
98
|
-
jitterBuffer.enqueue(frameData.buffer, "test-context");
|
|
99
|
-
jitterBuffer.enqueue(frameData.buffer, "test-context");
|
|
100
|
-
jitterBuffer.enqueue(frameData.buffer, "test-context");
|
|
101
|
-
|
|
102
|
-
// Should buffer ~160ms total (100ms initial + 3*20ms)
|
|
103
|
-
expect(jitterBuffer.bufferedDurationMs).toBeCloseTo(160, -1);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("tracks active context IDs", () => {
|
|
107
|
-
const frameData = new Int16Array(320);
|
|
108
|
-
frameData.fill(1000);
|
|
109
|
-
|
|
110
|
-
jitterBuffer.enqueue(frameData.buffer, "context-1");
|
|
111
|
-
jitterBuffer.enqueue(frameData.buffer, "context-2");
|
|
112
|
-
|
|
113
|
-
expect(jitterBuffer.activeContextIds).toContain("context-1");
|
|
114
|
-
expect(jitterBuffer.activeContextIds).toContain("context-2");
|
|
115
|
-
expect(jitterBuffer.activeContextIds).toHaveLength(2);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("reports played-out ms clamped to the scheduled window (heard clock)", () => {
|
|
119
|
-
const frameData = new Int16Array(320); // 20ms at 16kHz
|
|
120
|
-
frameData.fill(1000);
|
|
121
|
-
mockContext.currentTime = 1.0;
|
|
122
|
-
jitterBuffer.enqueue(frameData.buffer, "ctx"); // scheduled at 1.1, ends 1.12
|
|
123
|
-
|
|
124
|
-
// Before the frame starts playing → nothing heard yet.
|
|
125
|
-
expect(jitterBuffer.playedOutMs("ctx")).toBe(0);
|
|
126
|
-
expect(jitterBuffer.isPlayoutComplete("ctx")).toBe(false);
|
|
127
|
-
|
|
128
|
-
// Halfway through the 20ms frame → ~10ms heard.
|
|
129
|
-
mockContext.currentTime = 1.11;
|
|
130
|
-
expect(jitterBuffer.playedOutMs("ctx")).toBeCloseTo(10, 0);
|
|
131
|
-
|
|
132
|
-
// Past the end → clamped to the full 20ms, and complete.
|
|
133
|
-
mockContext.currentTime = 1.2;
|
|
134
|
-
expect(jitterBuffer.playedOutMs("ctx")).toBeCloseTo(20, 0);
|
|
135
|
-
expect(jitterBuffer.isPlayoutComplete("ctx")).toBe(true);
|
|
136
|
-
|
|
137
|
-
// Unknown context reports 0.
|
|
138
|
-
expect(jitterBuffer.playedOutMs("nope")).toBe(0);
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("clears specific context frames on context-specific clear", () => {
|
|
142
|
-
const frameData = new Int16Array(320);
|
|
143
|
-
frameData.fill(1000);
|
|
144
|
-
|
|
145
|
-
jitterBuffer.enqueue(frameData.buffer, "context-1");
|
|
146
|
-
jitterBuffer.enqueue(frameData.buffer, "context-2");
|
|
147
|
-
jitterBuffer.enqueue(frameData.buffer, "context-1");
|
|
148
|
-
|
|
149
|
-
expect(jitterBuffer.activeContextIds).toHaveLength(2);
|
|
150
|
-
|
|
151
|
-
jitterBuffer.clear("context-1");
|
|
152
|
-
|
|
153
|
-
expect(jitterBuffer.activeContextIds).toContain("context-2");
|
|
154
|
-
expect(jitterBuffer.activeContextIds).not.toContain("context-1");
|
|
155
|
-
expect(jitterBuffer.activeContextIds).toHaveLength(1);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("resets schedule baseline after context-specific clear", () => {
|
|
159
|
-
const frameData = new Int16Array(320);
|
|
160
|
-
frameData.fill(1000);
|
|
161
|
-
|
|
162
|
-
mockContext.currentTime = 1.0;
|
|
163
|
-
for (let i = 0; i < 50; i += 1) {
|
|
164
|
-
jitterBuffer.enqueue(frameData.buffer, "assistant");
|
|
165
|
-
}
|
|
166
|
-
expect(jitterBuffer.bufferedDurationMs).toBeGreaterThan(1000);
|
|
167
|
-
|
|
168
|
-
jitterBuffer.clear("assistant");
|
|
169
|
-
expect(jitterBuffer.bufferedDurationMs).toBe(0);
|
|
170
|
-
|
|
171
|
-
mockContext.currentTime = 1.05;
|
|
172
|
-
jitterBuffer.enqueue(frameData.buffer, "user-barge");
|
|
173
|
-
expect(jitterBuffer.bufferedDurationMs).toBeCloseTo(120, -1);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it("zeros buffered duration when the cleared context was the only one scheduled", () => {
|
|
177
|
-
const frameData = new Int16Array(320);
|
|
178
|
-
frameData.fill(1000);
|
|
179
|
-
|
|
180
|
-
mockContext.currentTime = 2.0;
|
|
181
|
-
jitterBuffer.enqueue(frameData.buffer, "solo-context");
|
|
182
|
-
jitterBuffer.enqueue(frameData.buffer, "solo-context");
|
|
183
|
-
expect(jitterBuffer.bufferedDurationMs).toBeGreaterThan(0);
|
|
184
|
-
|
|
185
|
-
jitterBuffer.clear("solo-context");
|
|
186
|
-
expect(jitterBuffer.activeContextIds).toHaveLength(0);
|
|
187
|
-
expect(jitterBuffer.bufferedDurationMs).toBe(0);
|
|
188
|
-
|
|
189
|
-
mockContext.currentTime = 8.0;
|
|
190
|
-
jitterBuffer.enqueue(frameData.buffer, "next-context");
|
|
191
|
-
expect(jitterBuffer.bufferedDurationMs).toBeCloseTo(120, -1);
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it("clears all frames on global clear", () => {
|
|
195
|
-
const frameData = new Int16Array(320);
|
|
196
|
-
frameData.fill(1000);
|
|
197
|
-
|
|
198
|
-
jitterBuffer.enqueue(frameData.buffer, "context-1");
|
|
199
|
-
jitterBuffer.enqueue(frameData.buffer, "context-2");
|
|
200
|
-
|
|
201
|
-
expect(jitterBuffer.activeContextIds).toHaveLength(2);
|
|
202
|
-
|
|
203
|
-
jitterBuffer.clear();
|
|
204
|
-
|
|
205
|
-
expect(jitterBuffer.activeContextIds).toHaveLength(0);
|
|
206
|
-
expect(jitterBuffer.bufferedDurationMs).toBe(0);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it("establishes new baseline when buffer is empty", () => {
|
|
210
|
-
const frameData = new Int16Array(320);
|
|
211
|
-
frameData.fill(1000);
|
|
212
|
-
|
|
213
|
-
mockContext.currentTime = 1.0;
|
|
214
|
-
jitterBuffer.enqueue(frameData.buffer, "context-1");
|
|
215
|
-
|
|
216
|
-
const initialBuffer = jitterBuffer.bufferedDurationMs;
|
|
217
|
-
|
|
218
|
-
// Clear and advance time
|
|
219
|
-
jitterBuffer.clear();
|
|
220
|
-
mockContext.currentTime = 2.0;
|
|
221
|
-
|
|
222
|
-
jitterBuffer.enqueue(frameData.buffer, "context-2");
|
|
223
|
-
|
|
224
|
-
// Should establish new baseline at current time + target buffer
|
|
225
|
-
expect(jitterBuffer.bufferedDurationMs).toBeCloseTo(initialBuffer, -1);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
it("handles PCM16 to Float32 conversion correctly", () => {
|
|
229
|
-
// Test with known values
|
|
230
|
-
const pcm16Data = new Int16Array([0, 16383, -16384, 32767, -32768]);
|
|
231
|
-
const expectedFloat32 = [0, 0.5, -0.5, 1.0, -1.0];
|
|
232
|
-
|
|
233
|
-
let capturedFloat32: Float32Array | null = null;
|
|
234
|
-
|
|
235
|
-
// Mock copyToChannel to capture the converted data
|
|
236
|
-
MockAudioBuffer.prototype.copyToChannel = function(data: Float32Array, _channel: number) {
|
|
237
|
-
capturedFloat32 = new Float32Array(data);
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
jitterBuffer.enqueue(pcm16Data.buffer);
|
|
241
|
-
|
|
242
|
-
expect(capturedFloat32).not.toBeNull();
|
|
243
|
-
expect(capturedFloat32![0]).toBeCloseTo(expectedFloat32[0]!, 5);
|
|
244
|
-
expect(capturedFloat32![1]).toBeCloseTo(expectedFloat32[1]!, 3);
|
|
245
|
-
expect(capturedFloat32![2]).toBeCloseTo(expectedFloat32[2]!, 3);
|
|
246
|
-
expect(capturedFloat32![3]).toBeCloseTo(expectedFloat32[3]!, 3);
|
|
247
|
-
expect(capturedFloat32![4]).toBeCloseTo(expectedFloat32[4]!, 3);
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it("handles empty or invalid audio data gracefully", () => {
|
|
251
|
-
const emptyData = new ArrayBuffer(0);
|
|
252
|
-
|
|
253
|
-
expect(() => {
|
|
254
|
-
jitterBuffer.enqueue(emptyData, "test-context");
|
|
255
|
-
}).not.toThrow();
|
|
256
|
-
|
|
257
|
-
expect(jitterBuffer.activeContextIds).toHaveLength(0);
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
it("advances scheduled time correctly for consecutive frames", () => {
|
|
261
|
-
const frame1 = new Int16Array(160).fill(1000); // 10ms at 16kHz
|
|
262
|
-
const frame2 = new Int16Array(320).fill(1000); // 20ms at 16kHz
|
|
263
|
-
|
|
264
|
-
mockContext.currentTime = 0;
|
|
265
|
-
|
|
266
|
-
jitterBuffer.enqueue(frame1.buffer, "context-1");
|
|
267
|
-
const buffer1 = jitterBuffer.bufferedDurationMs;
|
|
268
|
-
|
|
269
|
-
jitterBuffer.enqueue(frame2.buffer, "context-1");
|
|
270
|
-
const buffer2 = jitterBuffer.bufferedDurationMs;
|
|
271
|
-
|
|
272
|
-
// Second frame should add its duration to the buffer
|
|
273
|
-
expect(buffer2 - buffer1).toBeCloseTo(20, -1);
|
|
274
|
-
});
|
|
275
|
-
});
|
package/src/studio-page.test.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
// Guards the standalone studio page (packages/voice-client-browser/index.html), which is
|
|
4
|
-
// served raw by the review studio. Its assistant-audio decoder is PCM16-only (no Opus
|
|
5
|
-
// decoder), so it must declare a pcm_s16le downlink capability on connect — otherwise the
|
|
6
|
-
// server streams Opus envelopes it rejects with "PCM16 payload must contain an even number
|
|
7
|
-
// of bytes" / "durationMs mismatch". Regression guard for that contract.
|
|
8
|
-
|
|
9
|
-
import { readFileSync } from "node:fs";
|
|
10
|
-
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { describe, expect, it } from "vitest";
|
|
12
|
-
|
|
13
|
-
const html = readFileSync(fileURLToPath(new URL("../index.html", import.meta.url)), "utf8");
|
|
14
|
-
|
|
15
|
-
describe("studio index.html downlink codec negotiation", () => {
|
|
16
|
-
it("declares pcm_s16le downlink capability on connect", () => {
|
|
17
|
-
// The page has no Opus decoder, so it must negotiate PCM downlink.
|
|
18
|
-
expect(html).not.toMatch(/OpusDecoder|decodeOpus/);
|
|
19
|
-
const open = html.slice(html.indexOf('addEventListener("open"'), html.indexOf('addEventListener("close"'));
|
|
20
|
-
expect(open).toMatch(/"codec_capability"/);
|
|
21
|
-
expect(open).toMatch(/downlinkEncoding:\s*"pcm_s16le"/);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("rejects a non-pcm_s16le downlink encoding loudly instead of failing as cryptic PCM", () => {
|
|
25
|
-
// If the server ever sends a non-PCM envelope (codec negotiation failure), the decoder
|
|
26
|
-
// must branch on metadata.encoding and surface a clear error — not the misleading
|
|
27
|
-
// "PCM16 payload must contain an even number of bytes" the byte invariants would throw.
|
|
28
|
-
const decode = html.slice(html.indexOf("function decodeAssistantAudio"), html.indexOf("function hasPrefix"));
|
|
29
|
-
expect(decode).toMatch(/metadata\.encoding\s*&&\s*metadata\.encoding\s*!==\s*"pcm_s16le"/);
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
describe("studio index.html capture turn lifecycle", () => {
|
|
34
|
-
it("keeps the capture context open across VAD speech_ended until the server commits the turn", () => {
|
|
35
|
-
const speechEnded = html.slice(
|
|
36
|
-
html.indexOf('message.type === "speech_ended"'),
|
|
37
|
-
html.indexOf('message.type === "audio_clear"'),
|
|
38
|
-
);
|
|
39
|
-
expect(speechEnded).toMatch(/drainPcmQueue\(turn\.id\)/);
|
|
40
|
-
expect(speechEnded).not.toMatch(/activeTurn\s*=\s*null/);
|
|
41
|
-
|
|
42
|
-
const turnComplete = html.slice(
|
|
43
|
-
html.indexOf('message.type === "turn_complete"'),
|
|
44
|
-
html.indexOf('message.type === "agent_tool_call"'),
|
|
45
|
-
);
|
|
46
|
-
expect(turnComplete).toMatch(/activeTurn\?\.id\s*===\s*turn\.id/);
|
|
47
|
-
expect(turnComplete).toMatch(/activeTurn\s*=\s*null/);
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
describe("studio index.html local speech-start barge-in", () => {
|
|
52
|
-
it("flushes local assistant playout before sending a server client_interrupt", () => {
|
|
53
|
-
const handler = html.slice(
|
|
54
|
-
html.indexOf("function handleLocalSpeechStart"),
|
|
55
|
-
html.indexOf("function createCaptureTurn"),
|
|
56
|
-
);
|
|
57
|
-
expect(handler).toMatch(/flushOutputAudio\(\)[\s\S]*sendClientInterrupt/);
|
|
58
|
-
expect(handler).toMatch(/local_vad_speech_start/);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("does not use browser speech_ended or silence as an EOS signal", () => {
|
|
62
|
-
const capture = html.slice(
|
|
63
|
-
html.indexOf("processorNode.onaudioprocess"),
|
|
64
|
-
html.indexOf("sourceNode.connect"),
|
|
65
|
-
);
|
|
66
|
-
expect(capture).toMatch(/handleLocalSpeechStart/);
|
|
67
|
-
expect(capture).not.toMatch(/eos\.turn_complete|turn_complete|client_eos|speech_ended/);
|
|
68
|
-
});
|
|
69
|
-
});
|
package/src/transport.test.ts
DELETED
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { Decoder as OpusDecoder, Encoder as OpusEncoder } from "@evan/opus";
|
|
5
|
-
import { decodeSyrinxAudioEnvelope } from "@kuralle-syrinx/core";
|
|
6
|
-
import { pcm16BytesToSamples, pcm16SamplesToBytes } from "@kuralle-syrinx/core/audio";
|
|
7
|
-
import { SyrinxBrowserClient } from "./index.js";
|
|
8
|
-
import type { ClientTransport, ClientTransportHandlers } from "./transport.js";
|
|
9
|
-
import * as browserOpus from "./browser-opus.js";
|
|
10
|
-
import { pickBrowserWireCodec, createBrowserOpusCodec } from "./browser-opus.js";
|
|
11
|
-
|
|
12
|
-
async function pollUntil(predicate: () => boolean, timeoutMs: number, label: string): Promise<void> {
|
|
13
|
-
const deadline = Date.now() + timeoutMs;
|
|
14
|
-
while (Date.now() < deadline) {
|
|
15
|
-
if (predicate()) return;
|
|
16
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
17
|
-
}
|
|
18
|
-
throw new Error(`Timed out waiting for ${label}`);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
class FakeTransport implements ClientTransport {
|
|
22
|
-
readonly sent: unknown[] = [];
|
|
23
|
-
private handlers: ClientTransportHandlers = {};
|
|
24
|
-
private open = false;
|
|
25
|
-
|
|
26
|
-
get connected(): boolean {
|
|
27
|
-
return this.open;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
setHandlers(handlers: ClientTransportHandlers): void {
|
|
31
|
-
this.handlers = handlers;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
connect(_url: string): void {
|
|
35
|
-
this.open = true;
|
|
36
|
-
this.handlers.onOpen?.();
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
disconnect(): void {
|
|
40
|
-
this.open = false;
|
|
41
|
-
this.handlers.onClose?.(1000, "");
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
sendAudio(data: Uint8Array | ArrayBuffer): void {
|
|
45
|
-
this.sent.push(data);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
sendJson(value: unknown): void {
|
|
49
|
-
this.sent.push(JSON.stringify(value));
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
emitMessage(data: string): void {
|
|
53
|
-
this.handlers.onMessage?.(data);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
emitAudio(data: ArrayBuffer): void {
|
|
57
|
-
this.handlers.onAudio?.(data);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
describe("ClientTransport seam", () => {
|
|
62
|
-
it("drives SyrinxBrowserClient through an injected fake transport", () => {
|
|
63
|
-
const transport = new FakeTransport();
|
|
64
|
-
const client = new SyrinxBrowserClient({
|
|
65
|
-
url: "ws://unused/ws",
|
|
66
|
-
transport,
|
|
67
|
-
reconnect: false,
|
|
68
|
-
keepaliveIntervalMs: false,
|
|
69
|
-
});
|
|
70
|
-
const events: string[] = [];
|
|
71
|
-
client.on((event) => events.push(event.type));
|
|
72
|
-
client.connect();
|
|
73
|
-
|
|
74
|
-
expect(events).toContain("open");
|
|
75
|
-
transport.emitMessage(JSON.stringify({
|
|
76
|
-
type: "ready",
|
|
77
|
-
sessionId: "sess-fake",
|
|
78
|
-
audio: {
|
|
79
|
-
inputSampleRateHz: 16000,
|
|
80
|
-
outputSampleRateHz: 16000,
|
|
81
|
-
encoding: "pcm_s16le",
|
|
82
|
-
channels: 1,
|
|
83
|
-
},
|
|
84
|
-
}));
|
|
85
|
-
client.sendAudioPcm(new Uint8Array([1, 0, 2, 0]), 16000);
|
|
86
|
-
expect(transport.sent.length).toBeGreaterThan(0);
|
|
87
|
-
});
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
describe("browser opus negotiation", () => {
|
|
91
|
-
it("prefers opus when the server advertises it", () => {
|
|
92
|
-
expect(pickBrowserWireCodec(["pcm_s16le", "opus"], true)).toBe("opus");
|
|
93
|
-
expect(pickBrowserWireCodec(["pcm_s16le", "opus"], false)).toBe("pcm_s16le");
|
|
94
|
-
expect(pickBrowserWireCodec(["pcm_s16le"], true)).toBe("pcm_s16le");
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("produces opus frames and decodes them back to PCM16 samples", async () => {
|
|
98
|
-
const codec = await createBrowserOpusCodec(48000);
|
|
99
|
-
const pcm = new Int16Array(960);
|
|
100
|
-
pcm[0] = 1000;
|
|
101
|
-
const wire = codec.encodePcm16Frame(pcm, true)[0]!;
|
|
102
|
-
expect(wire.byteLength).toBeGreaterThan(0);
|
|
103
|
-
expect(codec.decodeOpusFrame(wire).length).toBeGreaterThan(0);
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("flushes pre-ready uplink audio in negotiated opus, not pcm", async () => {
|
|
107
|
-
const transport = new FakeTransport();
|
|
108
|
-
const client = new SyrinxBrowserClient({
|
|
109
|
-
url: "ws://unused/ws",
|
|
110
|
-
transport,
|
|
111
|
-
reconnect: false,
|
|
112
|
-
keepaliveIntervalMs: false,
|
|
113
|
-
});
|
|
114
|
-
client.connect();
|
|
115
|
-
|
|
116
|
-
const pcm = new Uint8Array(640);
|
|
117
|
-
pcm[0] = 1;
|
|
118
|
-
pcm[1] = 0;
|
|
119
|
-
client.sendAudioPcm(pcm, 16000, { contextId: "turn-early" });
|
|
120
|
-
|
|
121
|
-
expect(transport.sent.filter((entry) => entry instanceof Uint8Array)).toHaveLength(0);
|
|
122
|
-
|
|
123
|
-
transport.emitMessage(JSON.stringify({
|
|
124
|
-
type: "ready",
|
|
125
|
-
sessionId: "sess-opus-early",
|
|
126
|
-
audio: {
|
|
127
|
-
inputSampleRateHz: 16000,
|
|
128
|
-
outputSampleRateHz: 16000,
|
|
129
|
-
encoding: "opus",
|
|
130
|
-
supportedInputCodecs: ["pcm_s16le", "opus"],
|
|
131
|
-
channels: 1,
|
|
132
|
-
binaryEnvelope: "syrinx.audio.v1",
|
|
133
|
-
},
|
|
134
|
-
}));
|
|
135
|
-
|
|
136
|
-
await pollUntil(
|
|
137
|
-
() => transport.sent.some((entry) => {
|
|
138
|
-
if (typeof entry !== "string") return false;
|
|
139
|
-
return (JSON.parse(entry) as { type?: string }).type === "codec_capability";
|
|
140
|
-
}),
|
|
141
|
-
3_000,
|
|
142
|
-
"codec_capability",
|
|
143
|
-
);
|
|
144
|
-
|
|
145
|
-
const uplink = transport.sent.filter((entry): entry is Uint8Array => entry instanceof Uint8Array);
|
|
146
|
-
expect(uplink.length).toBeGreaterThan(0);
|
|
147
|
-
for (const frame of uplink) {
|
|
148
|
-
expect(decodeSyrinxAudioEnvelope(frame).header.encoding).toBe("opus");
|
|
149
|
-
}
|
|
150
|
-
expect(decodeSyrinxAudioEnvelope(uplink[0]!).header.contextId).toBe("turn-early");
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("encodes uplink envelopes as opus after ready negotiation", async () => {
|
|
154
|
-
const transport = new FakeTransport();
|
|
155
|
-
const client = new SyrinxBrowserClient({
|
|
156
|
-
url: "ws://unused/ws",
|
|
157
|
-
transport,
|
|
158
|
-
reconnect: false,
|
|
159
|
-
keepaliveIntervalMs: false,
|
|
160
|
-
});
|
|
161
|
-
client.connect();
|
|
162
|
-
transport.emitMessage(JSON.stringify({
|
|
163
|
-
type: "ready",
|
|
164
|
-
sessionId: "sess-opus",
|
|
165
|
-
audio: {
|
|
166
|
-
inputSampleRateHz: 16000,
|
|
167
|
-
outputSampleRateHz: 16000,
|
|
168
|
-
encoding: "opus",
|
|
169
|
-
supportedInputCodecs: ["pcm_s16le", "opus"],
|
|
170
|
-
channels: 1,
|
|
171
|
-
binaryEnvelope: "syrinx.audio.v1",
|
|
172
|
-
},
|
|
173
|
-
}));
|
|
174
|
-
|
|
175
|
-
await vi.waitFor(() => transport.sent.length > 0, { timeout: 50 }).catch(() => undefined);
|
|
176
|
-
|
|
177
|
-
const pcm = new Uint8Array(640);
|
|
178
|
-
pcm[0] = 1;
|
|
179
|
-
pcm[1] = 0;
|
|
180
|
-
client.sendAudioPcm(pcm, 16000, { contextId: "turn-opus" });
|
|
181
|
-
|
|
182
|
-
await vi.waitFor(() => transport.sent.some((entry) => {
|
|
183
|
-
if (!(entry instanceof Uint8Array)) return false;
|
|
184
|
-
return decodeSyrinxAudioEnvelope(entry).header.encoding === "opus";
|
|
185
|
-
}), { timeout: 3_000 });
|
|
186
|
-
|
|
187
|
-
const envelopeBytes = transport.sent.find((entry): entry is Uint8Array => {
|
|
188
|
-
if (!(entry instanceof Uint8Array)) return false;
|
|
189
|
-
return decodeSyrinxAudioEnvelope(entry).header.encoding === "opus";
|
|
190
|
-
})!;
|
|
191
|
-
const envelope = decodeSyrinxAudioEnvelope(envelopeBytes);
|
|
192
|
-
expect(envelope.header.sampleRateHz).toBe(48000);
|
|
193
|
-
const reference = pcm16BytesToSamples(
|
|
194
|
-
new OpusDecoder({ channels: 1, sample_rate: 48000 }).decode(envelope.audio),
|
|
195
|
-
);
|
|
196
|
-
expect(reference.length).toBeGreaterThan(0);
|
|
197
|
-
});
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
describe("browser downlink codec capability", () => {
|
|
201
|
-
it("reports pcm downlink when opus load fails", async () => {
|
|
202
|
-
vi.spyOn(browserOpus, "createBrowserOpusCodec").mockRejectedValue(new Error("opus unavailable"));
|
|
203
|
-
|
|
204
|
-
const transport = new FakeTransport();
|
|
205
|
-
const client = new SyrinxBrowserClient({
|
|
206
|
-
url: "ws://unused/ws",
|
|
207
|
-
transport,
|
|
208
|
-
reconnect: false,
|
|
209
|
-
keepaliveIntervalMs: false,
|
|
210
|
-
});
|
|
211
|
-
client.connect();
|
|
212
|
-
transport.emitMessage(JSON.stringify({
|
|
213
|
-
type: "ready",
|
|
214
|
-
sessionId: "sess-pcm-downlink",
|
|
215
|
-
audio: {
|
|
216
|
-
inputSampleRateHz: 16000,
|
|
217
|
-
outputSampleRateHz: 16000,
|
|
218
|
-
encoding: "opus",
|
|
219
|
-
supportedInputCodecs: ["pcm_s16le", "opus"],
|
|
220
|
-
channels: 1,
|
|
221
|
-
binaryEnvelope: "syrinx.audio.v1",
|
|
222
|
-
},
|
|
223
|
-
}));
|
|
224
|
-
|
|
225
|
-
await vi.waitFor(() => transport.sent.some((entry) => {
|
|
226
|
-
if (typeof entry !== "string") return false;
|
|
227
|
-
const message = JSON.parse(entry) as { type?: string; downlinkEncoding?: string };
|
|
228
|
-
return message.type === "codec_capability" && message.downlinkEncoding === "pcm_s16le";
|
|
229
|
-
}), { timeout: 3_000 });
|
|
230
|
-
|
|
231
|
-
vi.restoreAllMocks();
|
|
232
|
-
});
|
|
233
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "NodeNext",
|
|
5
|
-
"moduleResolution": "NodeNext",
|
|
6
|
-
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
-
"strict": true,
|
|
8
|
-
"noUncheckedIndexedAccess": true,
|
|
9
|
-
"noImplicitReturns": true,
|
|
10
|
-
"declaration": true,
|
|
11
|
-
"declarationMap": true,
|
|
12
|
-
"sourceMap": true,
|
|
13
|
-
"outDir": "./dist",
|
|
14
|
-
"rootDir": "./src",
|
|
15
|
-
"esModuleInterop": true,
|
|
16
|
-
"skipLibCheck": true,
|
|
17
|
-
"forceConsistentCasingInFileNames": true
|
|
18
|
-
},
|
|
19
|
-
"include": ["src/**/*.ts"],
|
|
20
|
-
"exclude": ["node_modules", "dist"]
|
|
21
|
-
}
|