@kuralle-syrinx/recorder 4.4.0 → 4.5.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.
@@ -0,0 +1,839 @@
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
+ }