@kuralle-syrinx/recorder 4.2.0 → 4.3.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 +3 -3
- package/src/index.test.ts +0 -839
- package/src/wav.test.ts +0 -42
- package/tsconfig.json +0 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kuralle-syrinx/recorder",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Stereo call recorder plugin for Syrinx — time-aligned user/assistant conversation WAV plus per-speaker stems",
|
|
6
6
|
"keywords": [
|
|
@@ -29,12 +29,12 @@
|
|
|
29
29
|
"./wav": "./src/wav.ts"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@kuralle-syrinx/core": "4.
|
|
32
|
+
"@kuralle-syrinx/core": "4.3.0"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/node": "^22.0.0",
|
|
36
36
|
"typescript": "^5.7.0",
|
|
37
|
-
"vitest": "^2.
|
|
37
|
+
"vitest": "^3.2.6"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"typecheck": "tsc --noEmit",
|
package/src/index.test.ts
DELETED
|
@@ -1,839 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
7
|
-
import { PipelineBusImpl, Route } from "@kuralle-syrinx/core";
|
|
8
|
-
import type { RecordAssistantAudioPacket, RecordUserAudioPacket, TextToSpeechPlayoutProgressPacket, VoicePacket } from "@kuralle-syrinx/core";
|
|
9
|
-
import {
|
|
10
|
-
VoiceSessionRecorder,
|
|
11
|
-
validateVoiceSessionRecorderManifest,
|
|
12
|
-
type VoiceSessionRecorderManifest,
|
|
13
|
-
} from "./index.js";
|
|
14
|
-
|
|
15
|
-
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
16
|
-
const dir = await mkdtemp(join(tmpdir(), "syrinx-recorder-"));
|
|
17
|
-
try {
|
|
18
|
-
return await fn(dir);
|
|
19
|
-
} finally {
|
|
20
|
-
await rm(dir, { recursive: true, force: true });
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function packet(kind: string): VoicePacket {
|
|
25
|
-
return {
|
|
26
|
-
kind,
|
|
27
|
-
contextId: "turn-1",
|
|
28
|
-
timestampMs: Date.now(),
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
describe("VoiceSessionRecorder", () => {
|
|
33
|
-
afterEach(() => {
|
|
34
|
-
vi.useRealTimers();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("records bus packets as JSONL without embedding raw audio bytes", async () => {
|
|
38
|
-
await withTempDir(async (dir) => {
|
|
39
|
-
const bus = new PipelineBusImpl();
|
|
40
|
-
const recorder = new VoiceSessionRecorder();
|
|
41
|
-
await recorder.initialize(bus, { output_dir: dir });
|
|
42
|
-
|
|
43
|
-
bus.push(Route.Main, {
|
|
44
|
-
kind: "record.user_audio",
|
|
45
|
-
contextId: "turn-1",
|
|
46
|
-
timestampMs: Date.now(),
|
|
47
|
-
audio: new Uint8Array([1, 2, 3, 4]),
|
|
48
|
-
} satisfies RecordUserAudioPacket);
|
|
49
|
-
bus.push(Route.Critical, packet("interrupt.tts"));
|
|
50
|
-
|
|
51
|
-
await recorder.close();
|
|
52
|
-
|
|
53
|
-
const events = await readFile(join(dir, "events.jsonl"), "utf8");
|
|
54
|
-
const lines = events.trim().split("\n").map((line) => JSON.parse(line) as Record<string, any>);
|
|
55
|
-
|
|
56
|
-
expect(lines).toHaveLength(2);
|
|
57
|
-
expect(lines[0]).toMatchObject({
|
|
58
|
-
route: "Main",
|
|
59
|
-
kind: "record.user_audio",
|
|
60
|
-
context_id: "turn-1",
|
|
61
|
-
packet: {
|
|
62
|
-
audio: {
|
|
63
|
-
type: "Uint8Array",
|
|
64
|
-
byteLength: 4,
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
});
|
|
68
|
-
expect(lines[1]).toMatchObject({
|
|
69
|
-
route: "Critical",
|
|
70
|
-
kind: "interrupt.tts",
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("flushes user and assistant audio files on close", async () => {
|
|
76
|
-
// Pin the clock so the assistant's first chunk anchors at offset 0 (no
|
|
77
|
-
// leading silence), keeping the exact-bytes assertions deterministic.
|
|
78
|
-
vi.useFakeTimers();
|
|
79
|
-
vi.setSystemTime(0);
|
|
80
|
-
|
|
81
|
-
await withTempDir(async (dir) => {
|
|
82
|
-
const bus = new PipelineBusImpl();
|
|
83
|
-
const recorder = new VoiceSessionRecorder();
|
|
84
|
-
await recorder.initialize(bus, { output_dir: dir });
|
|
85
|
-
const start = bus.start();
|
|
86
|
-
|
|
87
|
-
bus.push(Route.Main, {
|
|
88
|
-
kind: "record.user_audio",
|
|
89
|
-
contextId: "turn-1",
|
|
90
|
-
timestampMs: Date.now(),
|
|
91
|
-
audio: new Uint8Array([1, 2, 3, 4]),
|
|
92
|
-
} satisfies RecordUserAudioPacket);
|
|
93
|
-
bus.push(Route.Main, {
|
|
94
|
-
kind: "record.assistant_audio",
|
|
95
|
-
contextId: "turn-1",
|
|
96
|
-
timestampMs: Date.now(),
|
|
97
|
-
audio: new Uint8Array([5, 6, 7, 8]),
|
|
98
|
-
sampleRateHz: 24000,
|
|
99
|
-
truncate: false,
|
|
100
|
-
} satisfies RecordAssistantAudioPacket);
|
|
101
|
-
|
|
102
|
-
await vi.runOnlyPendingTimersAsync();
|
|
103
|
-
await Promise.resolve();
|
|
104
|
-
bus.stop();
|
|
105
|
-
await start;
|
|
106
|
-
const closePromise = recorder.close();
|
|
107
|
-
await vi.runOnlyPendingTimersAsync();
|
|
108
|
-
await closePromise;
|
|
109
|
-
|
|
110
|
-
await expect(readFile(join(dir, "user_audio.pcm"))).resolves.toEqual(Buffer.from([1, 2, 3, 4]));
|
|
111
|
-
await expect(readFile(join(dir, "assistant_audio.pcm"))).resolves.toEqual(Buffer.from([5, 6, 7, 8]));
|
|
112
|
-
await expect(stat(join(dir, "events.jsonl"))).resolves.toMatchObject({ size: expect.any(Number) });
|
|
113
|
-
const manifest = JSON.parse(await readFile(join(dir, "manifest.json"), "utf8")) as Record<string, any>;
|
|
114
|
-
expect(manifest).toMatchObject({
|
|
115
|
-
schemaVersion: 1,
|
|
116
|
-
files: {
|
|
117
|
-
eventsPath: join(dir, "events.jsonl"),
|
|
118
|
-
userAudioPath: join(dir, "user_audio.pcm"),
|
|
119
|
-
assistantAudioPath: join(dir, "assistant_audio.pcm"),
|
|
120
|
-
manifestPath: join(dir, "manifest.json"),
|
|
121
|
-
},
|
|
122
|
-
audio: {
|
|
123
|
-
user: {
|
|
124
|
-
sampleRateHz: 16000,
|
|
125
|
-
encoding: "pcm_s16le",
|
|
126
|
-
channels: 1,
|
|
127
|
-
byteLength: 4,
|
|
128
|
-
chunks: 1,
|
|
129
|
-
},
|
|
130
|
-
assistant: {
|
|
131
|
-
sampleRateHz: 24000,
|
|
132
|
-
encoding: "pcm_s16le",
|
|
133
|
-
channels: 1,
|
|
134
|
-
byteLength: 4,
|
|
135
|
-
chunks: 1,
|
|
136
|
-
truncations: 0,
|
|
137
|
-
},
|
|
138
|
-
},
|
|
139
|
-
events: {
|
|
140
|
-
packets: 2,
|
|
141
|
-
byteLength: expect.any(Number),
|
|
142
|
-
},
|
|
143
|
-
});
|
|
144
|
-
});
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("rejects odd-byte PCM16 audio before writing misleading recorder artifacts", async () => {
|
|
148
|
-
await withTempDir(async (dir) => {
|
|
149
|
-
const bus = new PipelineBusImpl();
|
|
150
|
-
const recorder = new VoiceSessionRecorder();
|
|
151
|
-
await recorder.initialize(bus, { output_dir: dir });
|
|
152
|
-
|
|
153
|
-
bus.push(Route.Main, {
|
|
154
|
-
kind: "record.assistant_audio",
|
|
155
|
-
contextId: "turn-1",
|
|
156
|
-
timestampMs: Date.now(),
|
|
157
|
-
audio: new Uint8Array([1, 2, 3]),
|
|
158
|
-
sampleRateHz: 16000,
|
|
159
|
-
truncate: false,
|
|
160
|
-
} satisfies RecordAssistantAudioPacket);
|
|
161
|
-
|
|
162
|
-
await expect(recorder.close()).rejects.toThrow("record.assistant_audio audio must contain an even number of PCM16 bytes");
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("rejects assistant audio without source sample-rate metadata", async () => {
|
|
167
|
-
await withTempDir(async (dir) => {
|
|
168
|
-
const bus = new PipelineBusImpl();
|
|
169
|
-
const recorder = new VoiceSessionRecorder();
|
|
170
|
-
await recorder.initialize(bus, { output_dir: dir });
|
|
171
|
-
|
|
172
|
-
bus.push(Route.Main, {
|
|
173
|
-
kind: "record.assistant_audio",
|
|
174
|
-
contextId: "turn-1",
|
|
175
|
-
timestampMs: Date.now(),
|
|
176
|
-
audio: new Uint8Array([1, 2, 3, 4]),
|
|
177
|
-
truncate: false,
|
|
178
|
-
} as RecordAssistantAudioPacket);
|
|
179
|
-
|
|
180
|
-
await expect(recorder.close()).rejects.toThrow("record.assistant_audio sampleRateHz must be a positive integer");
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
it("uses assistant recording sample-rate metadata for manifest duration", async () => {
|
|
185
|
-
// Pin the clock so the first chunk's wall-clock anchor is 0 (no leading
|
|
186
|
-
// silence), isolating the duration-from-sample-rate behavior under test.
|
|
187
|
-
vi.useFakeTimers();
|
|
188
|
-
vi.setSystemTime(0);
|
|
189
|
-
|
|
190
|
-
await withTempDir(async (dir) => {
|
|
191
|
-
const bus = new PipelineBusImpl();
|
|
192
|
-
const recorder = new VoiceSessionRecorder();
|
|
193
|
-
await recorder.initialize(bus, { output_dir: dir });
|
|
194
|
-
const start = bus.start();
|
|
195
|
-
|
|
196
|
-
bus.push(Route.Main, {
|
|
197
|
-
kind: "record.assistant_audio",
|
|
198
|
-
contextId: "turn-1",
|
|
199
|
-
timestampMs: Date.now(),
|
|
200
|
-
audio: new Uint8Array(3200),
|
|
201
|
-
sampleRateHz: 16000,
|
|
202
|
-
truncate: false,
|
|
203
|
-
} satisfies RecordAssistantAudioPacket);
|
|
204
|
-
|
|
205
|
-
await vi.runOnlyPendingTimersAsync();
|
|
206
|
-
await Promise.resolve();
|
|
207
|
-
bus.stop();
|
|
208
|
-
await start;
|
|
209
|
-
const closePromise = recorder.close();
|
|
210
|
-
await vi.runOnlyPendingTimersAsync();
|
|
211
|
-
await closePromise;
|
|
212
|
-
|
|
213
|
-
const manifest = JSON.parse(await readFile(join(dir, "manifest.json"), "utf8")) as Record<string, any>;
|
|
214
|
-
expect(manifest.audio.assistant).toMatchObject({
|
|
215
|
-
sampleRateHz: 16000,
|
|
216
|
-
byteLength: 3200,
|
|
217
|
-
durationMs: 100,
|
|
218
|
-
chunks: 1,
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it("rejects mixed assistant sample rates inside one recorder session", async () => {
|
|
224
|
-
await withTempDir(async (dir) => {
|
|
225
|
-
const bus = new PipelineBusImpl();
|
|
226
|
-
const recorder = new VoiceSessionRecorder();
|
|
227
|
-
await recorder.initialize(bus, { output_dir: dir });
|
|
228
|
-
|
|
229
|
-
bus.push(Route.Main, {
|
|
230
|
-
kind: "record.assistant_audio",
|
|
231
|
-
contextId: "turn-1",
|
|
232
|
-
timestampMs: Date.now(),
|
|
233
|
-
audio: new Uint8Array(320),
|
|
234
|
-
sampleRateHz: 16000,
|
|
235
|
-
truncate: false,
|
|
236
|
-
} satisfies RecordAssistantAudioPacket);
|
|
237
|
-
bus.push(Route.Main, {
|
|
238
|
-
kind: "record.assistant_audio",
|
|
239
|
-
contextId: "turn-2",
|
|
240
|
-
timestampMs: Date.now(),
|
|
241
|
-
audio: new Uint8Array(480),
|
|
242
|
-
sampleRateHz: 24000,
|
|
243
|
-
truncate: false,
|
|
244
|
-
} satisfies RecordAssistantAudioPacket);
|
|
245
|
-
|
|
246
|
-
await expect(recorder.close()).rejects.toThrow(
|
|
247
|
-
"record.assistant_audio sampleRateHz changed within recorder session: 16000 -> 24000",
|
|
248
|
-
);
|
|
249
|
-
});
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
it("truncates queued assistant audio at the wall-clock playback position", async () => {
|
|
253
|
-
vi.useFakeTimers();
|
|
254
|
-
vi.setSystemTime(0);
|
|
255
|
-
|
|
256
|
-
await withTempDir(async (dir) => {
|
|
257
|
-
const bus = new PipelineBusImpl();
|
|
258
|
-
const recorder = new VoiceSessionRecorder();
|
|
259
|
-
await recorder.initialize(bus, {
|
|
260
|
-
output_dir: dir,
|
|
261
|
-
assistant_sample_rate_hz: 16000,
|
|
262
|
-
});
|
|
263
|
-
const start = bus.start();
|
|
264
|
-
|
|
265
|
-
for (const value of [0xa1, 0xa2, 0xa3]) {
|
|
266
|
-
bus.push(Route.Main, {
|
|
267
|
-
kind: "record.assistant_audio",
|
|
268
|
-
contextId: "turn-1",
|
|
269
|
-
timestampMs: Date.now(),
|
|
270
|
-
audio: new Uint8Array(320).fill(value),
|
|
271
|
-
sampleRateHz: 16000,
|
|
272
|
-
truncate: false,
|
|
273
|
-
} satisfies RecordAssistantAudioPacket);
|
|
274
|
-
}
|
|
275
|
-
await vi.runOnlyPendingTimersAsync();
|
|
276
|
-
await Promise.resolve();
|
|
277
|
-
|
|
278
|
-
vi.setSystemTime(10);
|
|
279
|
-
bus.push(Route.Critical, {
|
|
280
|
-
kind: "record.assistant_audio",
|
|
281
|
-
contextId: "turn-1",
|
|
282
|
-
timestampMs: Date.now(),
|
|
283
|
-
audio: new Uint8Array(0),
|
|
284
|
-
truncate: true,
|
|
285
|
-
} satisfies RecordAssistantAudioPacket);
|
|
286
|
-
await vi.runOnlyPendingTimersAsync();
|
|
287
|
-
await Promise.resolve();
|
|
288
|
-
|
|
289
|
-
bus.stop();
|
|
290
|
-
await start;
|
|
291
|
-
const closePromise = recorder.close();
|
|
292
|
-
await vi.runOnlyPendingTimersAsync();
|
|
293
|
-
await closePromise;
|
|
294
|
-
|
|
295
|
-
await expect(readFile(join(dir, "assistant_audio.pcm"))).resolves.toEqual(Buffer.alloc(320, 0xa1));
|
|
296
|
-
const manifest = JSON.parse(await readFile(join(dir, "manifest.json"), "utf8")) as Record<string, any>;
|
|
297
|
-
expect(manifest.audio.assistant).toMatchObject({
|
|
298
|
-
sampleRateHz: 16000,
|
|
299
|
-
byteLength: 320,
|
|
300
|
-
durationMs: 10,
|
|
301
|
-
chunks: 1,
|
|
302
|
-
truncations: 1,
|
|
303
|
-
});
|
|
304
|
-
});
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it("re-anchors the assistant track to its real playout-start from tts.playout_progress", async () => {
|
|
308
|
-
vi.useFakeTimers();
|
|
309
|
-
vi.setSystemTime(0);
|
|
310
|
-
|
|
311
|
-
await withTempDir(async (dir) => {
|
|
312
|
-
const bus = new PipelineBusImpl();
|
|
313
|
-
const recorder = new VoiceSessionRecorder();
|
|
314
|
-
await recorder.initialize(bus, { output_dir: dir, assistant_sample_rate_hz: 16000 });
|
|
315
|
-
const start = bus.start();
|
|
316
|
-
|
|
317
|
-
// Audio generated at t=0 (bursty arrival) — generation anchor is offset 0.
|
|
318
|
-
bus.push(Route.Main, {
|
|
319
|
-
kind: "record.assistant_audio",
|
|
320
|
-
contextId: "turn-1",
|
|
321
|
-
timestampMs: Date.now(),
|
|
322
|
-
audio: new Uint8Array(320).fill(0xa1),
|
|
323
|
-
sampleRateHz: 16000,
|
|
324
|
-
truncate: false,
|
|
325
|
-
} satisfies RecordAssistantAudioPacket);
|
|
326
|
-
await vi.runOnlyPendingTimersAsync();
|
|
327
|
-
await Promise.resolve();
|
|
328
|
-
|
|
329
|
-
// The transport reports the audio actually began playing at t=1000ms.
|
|
330
|
-
bus.push(Route.Main, {
|
|
331
|
-
kind: "tts.playout_progress",
|
|
332
|
-
contextId: "turn-1",
|
|
333
|
-
timestampMs: 1000,
|
|
334
|
-
playedOutMs: 0,
|
|
335
|
-
complete: false,
|
|
336
|
-
} satisfies TextToSpeechPlayoutProgressPacket);
|
|
337
|
-
await vi.runOnlyPendingTimersAsync();
|
|
338
|
-
await Promise.resolve();
|
|
339
|
-
|
|
340
|
-
bus.stop();
|
|
341
|
-
await start;
|
|
342
|
-
const closePromise = recorder.close();
|
|
343
|
-
await vi.runOnlyPendingTimersAsync();
|
|
344
|
-
await closePromise;
|
|
345
|
-
|
|
346
|
-
// 1000ms @ 16 kHz s16 = 32000 bytes of leading silence, then the 320-byte chunk.
|
|
347
|
-
const pcm = await readFile(join(dir, "assistant_audio.pcm"));
|
|
348
|
-
expect(pcm.byteLength).toBe(32320);
|
|
349
|
-
expect(pcm.subarray(0, 32000)).toEqual(Buffer.alloc(32000, 0));
|
|
350
|
-
expect(pcm.subarray(32000)).toEqual(Buffer.alloc(320, 0xa1));
|
|
351
|
-
});
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
it("lays a multi-chunk assistant turn contiguously from its playout-start", async () => {
|
|
355
|
-
// The first assistant chunk is recorded at generation offset 0; later chunks
|
|
356
|
-
// jump to bursty wall-clock offsets. A re-anchor must lay them contiguously
|
|
357
|
-
// from the playout-start, not add each chunk's generation offset on top of it
|
|
358
|
-
// (which doubled the position and stranded the speech in dead air).
|
|
359
|
-
vi.useFakeTimers();
|
|
360
|
-
vi.setSystemTime(0);
|
|
361
|
-
|
|
362
|
-
await withTempDir(async (dir) => {
|
|
363
|
-
const bus = new PipelineBusImpl();
|
|
364
|
-
const recorder = new VoiceSessionRecorder();
|
|
365
|
-
await recorder.initialize(bus, { output_dir: dir, assistant_sample_rate_hz: 16000 });
|
|
366
|
-
const start = bus.start();
|
|
367
|
-
|
|
368
|
-
// First chunk arrives at t=0 → generation offset 0.
|
|
369
|
-
bus.push(Route.Main, {
|
|
370
|
-
kind: "record.assistant_audio",
|
|
371
|
-
contextId: "turn-1",
|
|
372
|
-
timestampMs: Date.now(),
|
|
373
|
-
audio: new Uint8Array(320).fill(0xa1),
|
|
374
|
-
sampleRateHz: 16000,
|
|
375
|
-
truncate: false,
|
|
376
|
-
} satisfies RecordAssistantAudioPacket);
|
|
377
|
-
await vi.runOnlyPendingTimersAsync();
|
|
378
|
-
await Promise.resolve();
|
|
379
|
-
|
|
380
|
-
// Second chunk arrives at t=1000ms → generation offset jumps to ~32000.
|
|
381
|
-
vi.setSystemTime(1000);
|
|
382
|
-
bus.push(Route.Main, {
|
|
383
|
-
kind: "record.assistant_audio",
|
|
384
|
-
contextId: "turn-1",
|
|
385
|
-
timestampMs: Date.now(),
|
|
386
|
-
audio: new Uint8Array(320).fill(0xb2),
|
|
387
|
-
sampleRateHz: 16000,
|
|
388
|
-
truncate: false,
|
|
389
|
-
} satisfies RecordAssistantAudioPacket);
|
|
390
|
-
await vi.runOnlyPendingTimersAsync();
|
|
391
|
-
await Promise.resolve();
|
|
392
|
-
|
|
393
|
-
// Playout for the turn began at t=1000ms.
|
|
394
|
-
bus.push(Route.Main, {
|
|
395
|
-
kind: "tts.playout_progress",
|
|
396
|
-
contextId: "turn-1",
|
|
397
|
-
timestampMs: 1000,
|
|
398
|
-
playedOutMs: 0,
|
|
399
|
-
complete: true,
|
|
400
|
-
} satisfies TextToSpeechPlayoutProgressPacket);
|
|
401
|
-
await vi.runOnlyPendingTimersAsync();
|
|
402
|
-
await Promise.resolve();
|
|
403
|
-
|
|
404
|
-
bus.stop();
|
|
405
|
-
await start;
|
|
406
|
-
const closePromise = recorder.close();
|
|
407
|
-
await vi.runOnlyPendingTimersAsync();
|
|
408
|
-
await closePromise;
|
|
409
|
-
|
|
410
|
-
// 32000 bytes of leading silence, then BOTH chunks back-to-back (no gap).
|
|
411
|
-
const pcm = await readFile(join(dir, "assistant_audio.pcm"));
|
|
412
|
-
expect(pcm.byteLength).toBe(32640);
|
|
413
|
-
expect(pcm.subarray(0, 32000)).toEqual(Buffer.alloc(32000, 0));
|
|
414
|
-
expect(pcm.subarray(32000, 32320)).toEqual(Buffer.alloc(320, 0xa1));
|
|
415
|
-
expect(pcm.subarray(32320, 32640)).toEqual(Buffer.alloc(320, 0xb2));
|
|
416
|
-
});
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
it("anchors a single-packet assistant turn at its wall-clock position, not offset 0", async () => {
|
|
420
|
-
// Providers that emit one packet per turn (e.g. Gemini) deliver the whole
|
|
421
|
-
// response as the first assistant chunk. It must land at the time it was
|
|
422
|
-
// produced — not pinned to offset 0, which would overlap the user's opening
|
|
423
|
-
// turn. No playout signal here: this is the generation-arrival fallback path.
|
|
424
|
-
vi.useFakeTimers();
|
|
425
|
-
vi.setSystemTime(0);
|
|
426
|
-
|
|
427
|
-
await withTempDir(async (dir) => {
|
|
428
|
-
const bus = new PipelineBusImpl();
|
|
429
|
-
const recorder = new VoiceSessionRecorder();
|
|
430
|
-
await recorder.initialize(bus, { output_dir: dir, assistant_sample_rate_hz: 16000 });
|
|
431
|
-
const start = bus.start();
|
|
432
|
-
|
|
433
|
-
// The assistant's first (and only) chunk arrives 500ms into the session.
|
|
434
|
-
vi.setSystemTime(500);
|
|
435
|
-
bus.push(Route.Main, {
|
|
436
|
-
kind: "record.assistant_audio",
|
|
437
|
-
contextId: "turn-1",
|
|
438
|
-
timestampMs: Date.now(),
|
|
439
|
-
audio: new Uint8Array(320).fill(0xa1),
|
|
440
|
-
sampleRateHz: 16000,
|
|
441
|
-
truncate: false,
|
|
442
|
-
} satisfies RecordAssistantAudioPacket);
|
|
443
|
-
await vi.runOnlyPendingTimersAsync();
|
|
444
|
-
await Promise.resolve();
|
|
445
|
-
|
|
446
|
-
bus.stop();
|
|
447
|
-
await start;
|
|
448
|
-
const closePromise = recorder.close();
|
|
449
|
-
await vi.runOnlyPendingTimersAsync();
|
|
450
|
-
await closePromise;
|
|
451
|
-
|
|
452
|
-
// 500ms @ 16 kHz s16 = 16000 bytes of leading silence, then the chunk.
|
|
453
|
-
const pcm = await readFile(join(dir, "assistant_audio.pcm"));
|
|
454
|
-
expect(pcm.byteLength).toBe(16320);
|
|
455
|
-
expect(pcm.subarray(0, 16000)).toEqual(Buffer.alloc(16000, 0));
|
|
456
|
-
expect(pcm.subarray(16000)).toEqual(Buffer.alloc(320, 0xa1));
|
|
457
|
-
});
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
it("retains terminal playback truncation when main dispatch is blocked during shutdown", async () => {
|
|
461
|
-
vi.useFakeTimers();
|
|
462
|
-
vi.setSystemTime(0);
|
|
463
|
-
|
|
464
|
-
await withTempDir(async (dir) => {
|
|
465
|
-
const bus = new PipelineBusImpl();
|
|
466
|
-
let releaseMain: () => void = () => undefined;
|
|
467
|
-
let notifyMainBlocked: () => void = () => undefined;
|
|
468
|
-
const mainReleased = new Promise<void>((resolve) => {
|
|
469
|
-
releaseMain = resolve;
|
|
470
|
-
});
|
|
471
|
-
const mainBlocked = new Promise<void>((resolve) => {
|
|
472
|
-
notifyMainBlocked = resolve;
|
|
473
|
-
});
|
|
474
|
-
bus.on("record.assistant_audio", async (pkt) => {
|
|
475
|
-
if (!(pkt as RecordAssistantAudioPacket).truncate) {
|
|
476
|
-
notifyMainBlocked();
|
|
477
|
-
await mainReleased;
|
|
478
|
-
}
|
|
479
|
-
});
|
|
480
|
-
|
|
481
|
-
const recorder = new VoiceSessionRecorder();
|
|
482
|
-
await recorder.initialize(bus, {
|
|
483
|
-
output_dir: dir,
|
|
484
|
-
assistant_sample_rate_hz: 16000,
|
|
485
|
-
});
|
|
486
|
-
const start = bus.start();
|
|
487
|
-
|
|
488
|
-
for (const value of [0xa1, 0xa2, 0xa3]) {
|
|
489
|
-
bus.push(Route.Main, {
|
|
490
|
-
kind: "record.assistant_audio",
|
|
491
|
-
contextId: "turn-1",
|
|
492
|
-
timestampMs: Date.now(),
|
|
493
|
-
audio: new Uint8Array(320).fill(value),
|
|
494
|
-
sampleRateHz: 16000,
|
|
495
|
-
truncate: false,
|
|
496
|
-
} satisfies RecordAssistantAudioPacket);
|
|
497
|
-
}
|
|
498
|
-
await mainBlocked;
|
|
499
|
-
|
|
500
|
-
vi.setSystemTime(10);
|
|
501
|
-
bus.push(Route.Critical, {
|
|
502
|
-
kind: "record.assistant_audio",
|
|
503
|
-
contextId: "turn-1",
|
|
504
|
-
timestampMs: Date.now(),
|
|
505
|
-
audio: new Uint8Array(0),
|
|
506
|
-
truncate: true,
|
|
507
|
-
} satisfies RecordAssistantAudioPacket);
|
|
508
|
-
await Promise.resolve();
|
|
509
|
-
|
|
510
|
-
const closePromise = recorder.close();
|
|
511
|
-
await vi.runOnlyPendingTimersAsync();
|
|
512
|
-
await closePromise;
|
|
513
|
-
releaseMain();
|
|
514
|
-
bus.stop();
|
|
515
|
-
await start;
|
|
516
|
-
|
|
517
|
-
await expect(readFile(join(dir, "assistant_audio.pcm"))).resolves.toEqual(Buffer.alloc(320, 0xa1));
|
|
518
|
-
const manifest = JSON.parse(await readFile(join(dir, "manifest.json"), "utf8")) as Record<string, any>;
|
|
519
|
-
expect(manifest.audio.assistant).toMatchObject({
|
|
520
|
-
byteLength: 320,
|
|
521
|
-
durationMs: 10,
|
|
522
|
-
truncations: 1,
|
|
523
|
-
});
|
|
524
|
-
});
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
it("validates recorder manifest duration and path evidence", () => {
|
|
528
|
-
const manifest = makeRecorderManifest();
|
|
529
|
-
|
|
530
|
-
expect(validateVoiceSessionRecorderManifest(manifest)).toStrictEqual([]);
|
|
531
|
-
|
|
532
|
-
expect(validateVoiceSessionRecorderManifest({
|
|
533
|
-
...manifest,
|
|
534
|
-
audio: {
|
|
535
|
-
...manifest.audio,
|
|
536
|
-
assistant: {
|
|
537
|
-
...manifest.audio.assistant,
|
|
538
|
-
durationMs: 1,
|
|
539
|
-
},
|
|
540
|
-
},
|
|
541
|
-
})).toContain("audio.assistant.durationMs 1 did not match 100 from byte count/sample rate");
|
|
542
|
-
|
|
543
|
-
expect(validateVoiceSessionRecorderManifest({
|
|
544
|
-
...manifest,
|
|
545
|
-
audio: {
|
|
546
|
-
...manifest.audio,
|
|
547
|
-
user: {
|
|
548
|
-
...manifest.audio.user,
|
|
549
|
-
path: "/tmp/other-user-audio.pcm",
|
|
550
|
-
},
|
|
551
|
-
},
|
|
552
|
-
})).toContain("audio.user.path must match files.userAudioPath");
|
|
553
|
-
});
|
|
554
|
-
|
|
555
|
-
it("rejects recorder manifests with invalid PCM byte accounting", () => {
|
|
556
|
-
const manifest = makeRecorderManifest();
|
|
557
|
-
|
|
558
|
-
expect(validateVoiceSessionRecorderManifest({
|
|
559
|
-
...manifest,
|
|
560
|
-
audio: {
|
|
561
|
-
...manifest.audio,
|
|
562
|
-
user: {
|
|
563
|
-
...manifest.audio.user,
|
|
564
|
-
byteLength: 3,
|
|
565
|
-
durationMs: 0,
|
|
566
|
-
},
|
|
567
|
-
},
|
|
568
|
-
})).toContain("audio.user.byteLength must contain an even number of PCM16 bytes");
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
it("reports malformed recorder manifests without throwing", () => {
|
|
572
|
-
expect(validateVoiceSessionRecorderManifest(null)).toStrictEqual(["manifest must be an object"]);
|
|
573
|
-
expect(validateVoiceSessionRecorderManifest({ schemaVersion: 1 })).toEqual(expect.arrayContaining([
|
|
574
|
-
"files must be an object",
|
|
575
|
-
"audio must be an object",
|
|
576
|
-
"events must be an object",
|
|
577
|
-
]));
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
it("keeps the persisted user track contiguous and applies wall-clock gaps only in conversation.wav", async () => {
|
|
581
|
-
vi.useFakeTimers();
|
|
582
|
-
vi.setSystemTime(0);
|
|
583
|
-
|
|
584
|
-
await withTempDir(async (dir) => {
|
|
585
|
-
const bus = new PipelineBusImpl();
|
|
586
|
-
const recorder = new VoiceSessionRecorder();
|
|
587
|
-
await recorder.initialize(bus, {
|
|
588
|
-
output_dir: dir,
|
|
589
|
-
user_sample_rate_hz: 16000,
|
|
590
|
-
});
|
|
591
|
-
const start = bus.start();
|
|
592
|
-
|
|
593
|
-
bus.push(Route.Main, {
|
|
594
|
-
kind: "record.user_audio",
|
|
595
|
-
contextId: "turn-1",
|
|
596
|
-
timestampMs: 0,
|
|
597
|
-
audio: new Uint8Array(320).fill(0x11),
|
|
598
|
-
} satisfies RecordUserAudioPacket);
|
|
599
|
-
await vi.runOnlyPendingTimersAsync();
|
|
600
|
-
await Promise.resolve();
|
|
601
|
-
|
|
602
|
-
// Advance 100ms → 3200 bytes at 16 kHz. Second chunk lands at wall-clock byteOffset=3200.
|
|
603
|
-
vi.setSystemTime(100);
|
|
604
|
-
|
|
605
|
-
bus.push(Route.Main, {
|
|
606
|
-
kind: "record.user_audio",
|
|
607
|
-
contextId: "turn-2",
|
|
608
|
-
timestampMs: 100,
|
|
609
|
-
audio: new Uint8Array(320).fill(0x22),
|
|
610
|
-
} satisfies RecordUserAudioPacket);
|
|
611
|
-
await vi.runOnlyPendingTimersAsync();
|
|
612
|
-
await Promise.resolve();
|
|
613
|
-
|
|
614
|
-
bus.stop();
|
|
615
|
-
await start;
|
|
616
|
-
const closePromise = recorder.close();
|
|
617
|
-
await vi.runOnlyPendingTimersAsync();
|
|
618
|
-
await closePromise;
|
|
619
|
-
|
|
620
|
-
// Persisted user track: contiguous speech only (no inter-turn silence).
|
|
621
|
-
const userPcm = await readFile(join(dir, "user_audio.pcm"));
|
|
622
|
-
expect(userPcm.byteLength).toBe(640);
|
|
623
|
-
expect(userPcm.subarray(0, 320)).toEqual(Buffer.alloc(320, 0x11));
|
|
624
|
-
expect(userPcm.subarray(320, 640)).toEqual(Buffer.alloc(320, 0x22));
|
|
625
|
-
|
|
626
|
-
// conversation.wav LEFT (user) channel: wall-clock positioned with the silence gap.
|
|
627
|
-
const wav = await readFile(join(dir, "conversation.wav"));
|
|
628
|
-
const data = wav.subarray(44);
|
|
629
|
-
const frames = data.byteLength >> 2;
|
|
630
|
-
const left = Buffer.alloc(frames * 2);
|
|
631
|
-
for (let i = 0; i < frames; i++) data.copy(left, i * 2, i * 4, i * 4 + 2);
|
|
632
|
-
expect(left.byteLength).toBe(3520);
|
|
633
|
-
expect(left.subarray(0, 320)).toEqual(Buffer.alloc(320, 0x11));
|
|
634
|
-
expect(left.subarray(320, 3200)).toEqual(Buffer.alloc(2880, 0));
|
|
635
|
-
expect(left.subarray(3200, 3520)).toEqual(Buffer.alloc(320, 0x22));
|
|
636
|
-
});
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
it("writes stereo conversation.wav with user on L and assistant on R", async () => {
|
|
640
|
-
vi.useFakeTimers();
|
|
641
|
-
vi.setSystemTime(0);
|
|
642
|
-
await withTempDir(async (dir) => {
|
|
643
|
-
const bus = new PipelineBusImpl();
|
|
644
|
-
const recorder = new VoiceSessionRecorder();
|
|
645
|
-
await recorder.initialize(bus, {
|
|
646
|
-
output_dir: dir,
|
|
647
|
-
user_sample_rate_hz: 16000,
|
|
648
|
-
assistant_sample_rate_hz: 16000,
|
|
649
|
-
});
|
|
650
|
-
const start = bus.start();
|
|
651
|
-
|
|
652
|
-
const userSample = 0x0100;
|
|
653
|
-
const assistSample = 0x0200;
|
|
654
|
-
const userBytes = Buffer.alloc(8);
|
|
655
|
-
const assistBytes = Buffer.alloc(8);
|
|
656
|
-
for (let i = 0; i < 4; i++) {
|
|
657
|
-
userBytes.writeInt16LE(userSample, i * 2);
|
|
658
|
-
assistBytes.writeInt16LE(assistSample, i * 2);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
bus.push(Route.Main, {
|
|
662
|
-
kind: "record.user_audio",
|
|
663
|
-
contextId: "turn-1",
|
|
664
|
-
timestampMs: Date.now(),
|
|
665
|
-
audio: new Uint8Array(userBytes),
|
|
666
|
-
} satisfies RecordUserAudioPacket);
|
|
667
|
-
bus.push(Route.Main, {
|
|
668
|
-
kind: "record.assistant_audio",
|
|
669
|
-
contextId: "turn-1",
|
|
670
|
-
timestampMs: Date.now(),
|
|
671
|
-
audio: new Uint8Array(assistBytes),
|
|
672
|
-
sampleRateHz: 16000,
|
|
673
|
-
truncate: false,
|
|
674
|
-
} satisfies RecordAssistantAudioPacket);
|
|
675
|
-
|
|
676
|
-
await vi.runOnlyPendingTimersAsync();
|
|
677
|
-
await Promise.resolve();
|
|
678
|
-
bus.stop();
|
|
679
|
-
await start;
|
|
680
|
-
const closePromise = recorder.close();
|
|
681
|
-
await vi.runOnlyPendingTimersAsync();
|
|
682
|
-
await closePromise;
|
|
683
|
-
|
|
684
|
-
const wav = await readFile(join(dir, "conversation.wav"));
|
|
685
|
-
// channels field at offset 22
|
|
686
|
-
expect(wav.readUInt16LE(22)).toBe(2);
|
|
687
|
-
// sample rate at offset 24
|
|
688
|
-
expect(wav.readUInt32LE(24)).toBe(16000);
|
|
689
|
-
// 4 stereo frames = 16 bytes PCM; total = 44 + 16 = 60
|
|
690
|
-
expect(wav.byteLength).toBe(60);
|
|
691
|
-
// Frame 0: L = userSample, R = assistSample
|
|
692
|
-
expect(wav.readInt16LE(44)).toBe(userSample);
|
|
693
|
-
expect(wav.readInt16LE(46)).toBe(assistSample);
|
|
694
|
-
// Frame 1 same
|
|
695
|
-
expect(wav.readInt16LE(48)).toBe(userSample);
|
|
696
|
-
expect(wav.readInt16LE(50)).toBe(assistSample);
|
|
697
|
-
});
|
|
698
|
-
});
|
|
699
|
-
|
|
700
|
-
it("resamples assistant audio from 24kHz to user rate when rates differ", async () => {
|
|
701
|
-
vi.useFakeTimers();
|
|
702
|
-
vi.setSystemTime(0);
|
|
703
|
-
await withTempDir(async (dir) => {
|
|
704
|
-
const bus = new PipelineBusImpl();
|
|
705
|
-
const recorder = new VoiceSessionRecorder();
|
|
706
|
-
await recorder.initialize(bus, {
|
|
707
|
-
output_dir: dir,
|
|
708
|
-
user_sample_rate_hz: 16000,
|
|
709
|
-
assistant_sample_rate_hz: 24000,
|
|
710
|
-
});
|
|
711
|
-
const start = bus.start();
|
|
712
|
-
|
|
713
|
-
// User: 160 samples = 10ms at 16kHz
|
|
714
|
-
bus.push(Route.Main, {
|
|
715
|
-
kind: "record.user_audio",
|
|
716
|
-
contextId: "turn-1",
|
|
717
|
-
timestampMs: Date.now(),
|
|
718
|
-
audio: new Uint8Array(320),
|
|
719
|
-
} satisfies RecordUserAudioPacket);
|
|
720
|
-
// Assistant: 240 samples = 10ms at 24kHz
|
|
721
|
-
bus.push(Route.Main, {
|
|
722
|
-
kind: "record.assistant_audio",
|
|
723
|
-
contextId: "turn-1",
|
|
724
|
-
timestampMs: Date.now(),
|
|
725
|
-
audio: new Uint8Array(480),
|
|
726
|
-
sampleRateHz: 24000,
|
|
727
|
-
truncate: false,
|
|
728
|
-
} satisfies RecordAssistantAudioPacket);
|
|
729
|
-
|
|
730
|
-
await vi.runOnlyPendingTimersAsync();
|
|
731
|
-
await Promise.resolve();
|
|
732
|
-
bus.stop();
|
|
733
|
-
await start;
|
|
734
|
-
const closePromise = recorder.close();
|
|
735
|
-
await vi.runOnlyPendingTimersAsync();
|
|
736
|
-
await closePromise;
|
|
737
|
-
|
|
738
|
-
const wav = await readFile(join(dir, "conversation.wav"));
|
|
739
|
-
// Resampled assistant: round(240 * 16000 / 24000) = 160 samples
|
|
740
|
-
// Both tracks: 160 samples → 160 stereo frames × 4 bytes = 640 bytes PCM
|
|
741
|
-
expect(wav.byteLength).toBe(44 + 640);
|
|
742
|
-
expect(wav.readUInt16LE(22)).toBe(2);
|
|
743
|
-
expect(wav.readUInt32LE(24)).toBe(16000);
|
|
744
|
-
});
|
|
745
|
-
});
|
|
746
|
-
|
|
747
|
-
it("includes conversation entry in manifest and validates it", async () => {
|
|
748
|
-
vi.useFakeTimers();
|
|
749
|
-
vi.setSystemTime(0);
|
|
750
|
-
await withTempDir(async (dir) => {
|
|
751
|
-
const bus = new PipelineBusImpl();
|
|
752
|
-
const recorder = new VoiceSessionRecorder();
|
|
753
|
-
await recorder.initialize(bus, {
|
|
754
|
-
output_dir: dir,
|
|
755
|
-
user_sample_rate_hz: 16000,
|
|
756
|
-
assistant_sample_rate_hz: 16000,
|
|
757
|
-
});
|
|
758
|
-
const start = bus.start();
|
|
759
|
-
|
|
760
|
-
// 3200 bytes = 100ms at 16kHz
|
|
761
|
-
bus.push(Route.Main, {
|
|
762
|
-
kind: "record.user_audio",
|
|
763
|
-
contextId: "turn-1",
|
|
764
|
-
timestampMs: Date.now(),
|
|
765
|
-
audio: new Uint8Array(3200),
|
|
766
|
-
} satisfies RecordUserAudioPacket);
|
|
767
|
-
bus.push(Route.Main, {
|
|
768
|
-
kind: "record.assistant_audio",
|
|
769
|
-
contextId: "turn-1",
|
|
770
|
-
timestampMs: Date.now(),
|
|
771
|
-
audio: new Uint8Array(3200),
|
|
772
|
-
sampleRateHz: 16000,
|
|
773
|
-
truncate: false,
|
|
774
|
-
} satisfies RecordAssistantAudioPacket);
|
|
775
|
-
|
|
776
|
-
await vi.runOnlyPendingTimersAsync();
|
|
777
|
-
await Promise.resolve();
|
|
778
|
-
bus.stop();
|
|
779
|
-
await start;
|
|
780
|
-
const closePromise = recorder.close();
|
|
781
|
-
await vi.runOnlyPendingTimersAsync();
|
|
782
|
-
await closePromise;
|
|
783
|
-
|
|
784
|
-
const manifest = JSON.parse(await readFile(join(dir, "manifest.json"), "utf8")) as Record<string, any>;
|
|
785
|
-
expect(manifest.audio.conversation).toMatchObject({
|
|
786
|
-
channels: 2,
|
|
787
|
-
encoding: "pcm_s16le",
|
|
788
|
-
sampleRateHz: 16000,
|
|
789
|
-
// 1600 user samples + 1600 assistant samples interleaved = 3200 stereo frames × 2 bytes × 2 ch = 6400
|
|
790
|
-
byteLength: 6400,
|
|
791
|
-
durationMs: 100,
|
|
792
|
-
});
|
|
793
|
-
expect(manifest.audio.conversation.path).toBe(join(dir, "conversation.wav"));
|
|
794
|
-
expect(validateVoiceSessionRecorderManifest(manifest)).toStrictEqual([]);
|
|
795
|
-
});
|
|
796
|
-
});
|
|
797
|
-
});
|
|
798
|
-
|
|
799
|
-
function makeRecorderManifest(): VoiceSessionRecorderManifest {
|
|
800
|
-
return {
|
|
801
|
-
schemaVersion: 1,
|
|
802
|
-
sessionId: "session-1",
|
|
803
|
-
startedAtMs: 1000,
|
|
804
|
-
closedAtMs: 2000,
|
|
805
|
-
files: {
|
|
806
|
-
directory: "/tmp/session-1",
|
|
807
|
-
eventsPath: "/tmp/session-1/events.jsonl",
|
|
808
|
-
userAudioPath: "/tmp/session-1/user_audio.pcm",
|
|
809
|
-
assistantAudioPath: "/tmp/session-1/assistant_audio.pcm",
|
|
810
|
-
manifestPath: "/tmp/session-1/manifest.json",
|
|
811
|
-
},
|
|
812
|
-
audio: {
|
|
813
|
-
user: {
|
|
814
|
-
path: "/tmp/session-1/user_audio.pcm",
|
|
815
|
-
sampleRateHz: 16000,
|
|
816
|
-
encoding: "pcm_s16le",
|
|
817
|
-
channels: 1,
|
|
818
|
-
byteLength: 3200,
|
|
819
|
-
durationMs: 100,
|
|
820
|
-
chunks: 1,
|
|
821
|
-
},
|
|
822
|
-
assistant: {
|
|
823
|
-
path: "/tmp/session-1/assistant_audio.pcm",
|
|
824
|
-
sampleRateHz: 16000,
|
|
825
|
-
encoding: "pcm_s16le",
|
|
826
|
-
channels: 1,
|
|
827
|
-
byteLength: 3200,
|
|
828
|
-
durationMs: 100,
|
|
829
|
-
chunks: 1,
|
|
830
|
-
truncations: 0,
|
|
831
|
-
},
|
|
832
|
-
},
|
|
833
|
-
events: {
|
|
834
|
-
path: "/tmp/session-1/events.jsonl",
|
|
835
|
-
packets: 4,
|
|
836
|
-
byteLength: 256,
|
|
837
|
-
},
|
|
838
|
-
};
|
|
839
|
-
}
|
package/src/wav.test.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { interleaveStereoPcm16, pcm16ToWav } from "./wav.js";
|
|
5
|
-
|
|
6
|
-
describe("pcm16ToWav", () => {
|
|
7
|
-
it("writes a canonical 44-byte RIFF/PCM header for the given rate and channels", () => {
|
|
8
|
-
const pcm = new Uint8Array([1, 2, 3, 4]); // 2 mono samples
|
|
9
|
-
const wav = pcm16ToWav(pcm, 16000, 1);
|
|
10
|
-
expect(wav.byteLength).toBe(44 + 4);
|
|
11
|
-
const view = new DataView(wav.buffer);
|
|
12
|
-
expect(String.fromCharCode(wav[0]!, wav[1]!, wav[2]!, wav[3]!)).toBe("RIFF");
|
|
13
|
-
expect(view.getUint32(4, true)).toBe(36 + 4); // chunk size
|
|
14
|
-
expect(String.fromCharCode(wav[8]!, wav[9]!, wav[10]!, wav[11]!)).toBe("WAVE");
|
|
15
|
-
expect(view.getUint16(20, true)).toBe(1); // PCM
|
|
16
|
-
expect(view.getUint16(22, true)).toBe(1); // channels
|
|
17
|
-
expect(view.getUint32(24, true)).toBe(16000); // sample rate
|
|
18
|
-
expect(view.getUint16(32, true)).toBe(2); // blockAlign = channels * 2
|
|
19
|
-
expect(view.getUint32(40, true)).toBe(4); // data size
|
|
20
|
-
expect([...wav.subarray(44)]).toEqual([1, 2, 3, 4]);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("sets stereo blockAlign and byteRate", () => {
|
|
24
|
-
const view = new DataView(pcm16ToWav(new Uint8Array(8), 24000, 2).buffer);
|
|
25
|
-
expect(view.getUint16(32, true)).toBe(4); // 2ch * 2 bytes
|
|
26
|
-
expect(view.getUint32(28, true)).toBe(24000 * 4); // byteRate
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe("interleaveStereoPcm16", () => {
|
|
31
|
-
it("interleaves L/R frames and pads the shorter stream with silence", () => {
|
|
32
|
-
const left = new Uint8Array([0x10, 0x00, 0x20, 0x00]); // samples 16, 32
|
|
33
|
-
const right = new Uint8Array([0x01, 0x00]); // sample 1, then padded
|
|
34
|
-
const out = interleaveStereoPcm16(left, right);
|
|
35
|
-
const view = new DataView(out.buffer);
|
|
36
|
-
expect(out.byteLength).toBe(2 * 4); // 2 frames × (2ch × 2 bytes)
|
|
37
|
-
expect(view.getInt16(0, true)).toBe(16); // L0
|
|
38
|
-
expect(view.getInt16(2, true)).toBe(1); // R0
|
|
39
|
-
expect(view.getInt16(4, true)).toBe(32); // L1
|
|
40
|
-
expect(view.getInt16(6, true)).toBe(0); // R1 padded
|
|
41
|
-
});
|
|
42
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "NodeNext",
|
|
5
|
-
"moduleResolution": "NodeNext",
|
|
6
|
-
"lib": ["ES2022"],
|
|
7
|
-
"types": ["node"],
|
|
8
|
-
"strict": true,
|
|
9
|
-
"noUncheckedIndexedAccess": true,
|
|
10
|
-
"noImplicitReturns": true,
|
|
11
|
-
"declaration": true,
|
|
12
|
-
"declarationMap": true,
|
|
13
|
-
"sourceMap": true,
|
|
14
|
-
"outDir": "./dist",
|
|
15
|
-
"rootDir": "./src",
|
|
16
|
-
"esModuleInterop": true,
|
|
17
|
-
"skipLibCheck": true,
|
|
18
|
-
"forceConsistentCasingInFileNames": true
|
|
19
|
-
},
|
|
20
|
-
"include": ["src/**/*.ts"],
|
|
21
|
-
"exclude": ["node_modules", "dist"]
|
|
22
|
-
}
|