@kuralle-syrinx/recorder 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,828 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { mkdir, writeFile } from "node:fs/promises";
4
+ import { createWriteStream, type WriteStream } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+ import {
7
+ Route,
8
+ type PipelineBus,
9
+ type PluginConfig,
10
+ type RecordAssistantAudioDataPacket,
11
+ type RecordAssistantAudioPacket,
12
+ type RecordUserAudioPacket,
13
+ type TextToSpeechPlayoutProgressPacket,
14
+ type VoicePacket,
15
+ type VoicePlugin,
16
+ } from "@kuralle-syrinx/core";
17
+
18
+ export interface VoiceSessionRecorderConfig {
19
+ readonly outputDir: string;
20
+ readonly sessionId?: string;
21
+ readonly eventsFile?: string;
22
+ readonly userAudioFile?: string;
23
+ readonly assistantAudioFile?: string;
24
+ readonly manifestFile?: string;
25
+ readonly conversationFile?: string;
26
+ readonly userSampleRateHz?: number;
27
+ readonly assistantSampleRateHz?: number;
28
+ }
29
+
30
+ export interface VoiceSessionRecorderFiles {
31
+ readonly directory: string;
32
+ readonly eventsPath: string;
33
+ readonly userAudioPath: string;
34
+ readonly assistantAudioPath: string;
35
+ readonly manifestPath: string;
36
+ readonly conversationAudioPath?: string;
37
+ }
38
+
39
+ export interface VoiceSessionRecorderManifest {
40
+ readonly schemaVersion: 1;
41
+ readonly sessionId?: string;
42
+ readonly startedAtMs: number;
43
+ readonly closedAtMs: number;
44
+ readonly files: VoiceSessionRecorderFiles;
45
+ readonly audio: {
46
+ readonly user: {
47
+ readonly path: string;
48
+ readonly sampleRateHz: number;
49
+ readonly encoding: "pcm_s16le";
50
+ readonly channels: 1;
51
+ readonly byteLength: number;
52
+ readonly durationMs: number;
53
+ readonly chunks: number;
54
+ };
55
+ readonly assistant: {
56
+ readonly path: string;
57
+ readonly sampleRateHz: number;
58
+ readonly encoding: "pcm_s16le";
59
+ readonly channels: 1;
60
+ readonly byteLength: number;
61
+ readonly durationMs: number;
62
+ readonly chunks: number;
63
+ readonly truncations: number;
64
+ };
65
+ readonly conversation?: {
66
+ readonly path: string;
67
+ readonly sampleRateHz: number;
68
+ readonly channels: 2;
69
+ readonly encoding: "pcm_s16le";
70
+ readonly byteLength: number;
71
+ readonly durationMs: number;
72
+ };
73
+ };
74
+ readonly events: {
75
+ readonly path: string;
76
+ readonly packets: number;
77
+ readonly byteLength: number;
78
+ };
79
+ }
80
+
81
+ type PacketRecord = {
82
+ readonly route: string;
83
+ readonly kind: string;
84
+ readonly context_id: string;
85
+ readonly timestamp_ms: number;
86
+ readonly packet: Record<string, unknown>;
87
+ };
88
+
89
+ type AudioChunk = { readonly byteOffset: number; readonly data: Uint8Array; readonly contextId?: string };
90
+
91
+ export class VoiceSessionRecorder implements VoicePlugin {
92
+ private bus: PipelineBus | null = null;
93
+ private events: WriteStream | null = null;
94
+ private userAudio: WriteStream | null = null;
95
+ private assistantAudio: WriteStream | null = null;
96
+ private sessionId: string | undefined;
97
+ private userSampleRateHz = 16000;
98
+ private userChunks: AudioChunk[] = [];
99
+ private userCursorBytes = 0;
100
+ private assistantChunks: AudioChunk[] = [];
101
+ private assistantCursorBytes = 0;
102
+ // Real playout-start wall-clock per context, from tts.playout_progress. Used to
103
+ // re-anchor each assistant turn onto the playout clock at finalize so the
104
+ // recording reflects what was heard, not when TTS generated it. Empty when no
105
+ // paced transport is wired (e.g. headless), in which case generation arrival
106
+ // positioning is kept.
107
+ private assistantPlayoutStartMs = new Map<string, number>();
108
+ private assistantSampleRateHz = 24000;
109
+ private assistantSampleRateLocked = false;
110
+ private startedAtMs = 0;
111
+ private userAudioBytes = 0;
112
+ private userAudioChunks = 0;
113
+ private assistantAudioBytes = 0;
114
+ private assistantAudioChunks = 0;
115
+ private assistantTruncations = 0;
116
+ private eventBytes = 0;
117
+ private eventPackets = 0;
118
+ private conversationFile = "conversation.wav";
119
+ private conversationAudioPath = "";
120
+ private conversationAudioBytes = 0;
121
+ private packetReader: ReadableStreamDefaultReader<{ route: Route; packet: VoicePacket }> | null = null;
122
+ private packetPump: Promise<void> | null = null;
123
+ private pendingWrites = new Set<Promise<void>>();
124
+ private closing = false;
125
+ private writeFailure: Error | null = null;
126
+ private filesValue: VoiceSessionRecorderFiles | null = null;
127
+
128
+ get files(): VoiceSessionRecorderFiles | null {
129
+ return this.filesValue;
130
+ }
131
+
132
+ async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
133
+ if (this.events) {
134
+ throw new Error("VoiceSessionRecorder is already initialized");
135
+ }
136
+
137
+ const recorderConfig = readRecorderConfig(config);
138
+ await mkdir(recorderConfig.outputDir, { recursive: true });
139
+ this.sessionId = recorderConfig.sessionId;
140
+ this.userSampleRateHz = recorderConfig.userSampleRateHz ?? 16000;
141
+ this.assistantSampleRateHz = recorderConfig.assistantSampleRateHz ?? 24000;
142
+ this.conversationFile = recorderConfig.conversationFile ?? "conversation.wav";
143
+ this.startedAtMs = Date.now();
144
+
145
+ const eventsPath = join(recorderConfig.outputDir, recorderConfig.eventsFile ?? "events.jsonl");
146
+ const userAudioPath = join(recorderConfig.outputDir, recorderConfig.userAudioFile ?? "user_audio.pcm");
147
+ const assistantAudioPath = join(
148
+ recorderConfig.outputDir,
149
+ recorderConfig.assistantAudioFile ?? "assistant_audio.pcm",
150
+ );
151
+ const manifestPath = join(recorderConfig.outputDir, recorderConfig.manifestFile ?? "manifest.json");
152
+ const conversationAudioPath = this.conversationFile
153
+ ? join(recorderConfig.outputDir, this.conversationFile)
154
+ : "";
155
+ this.conversationAudioPath = conversationAudioPath;
156
+
157
+ this.bus = bus;
158
+ this.events = createWriteStream(eventsPath, { flags: "w" });
159
+ this.userAudio = createWriteStream(userAudioPath, { flags: "w" });
160
+ this.assistantAudio = createWriteStream(assistantAudioPath, { flags: "w" });
161
+ this.events.setMaxListeners(0);
162
+ this.userAudio.setMaxListeners(0);
163
+ this.assistantAudio.setMaxListeners(0);
164
+ this.filesValue = {
165
+ directory: recorderConfig.outputDir,
166
+ eventsPath,
167
+ userAudioPath,
168
+ assistantAudioPath,
169
+ manifestPath,
170
+ ...(conversationAudioPath ? { conversationAudioPath } : {}),
171
+ };
172
+
173
+ this.packetReader = bus.allPackets.getReader();
174
+ this.packetPump = this.recordPackets();
175
+ }
176
+
177
+ async close(): Promise<void> {
178
+ if (this.closing) return;
179
+
180
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 0));
181
+ this.closing = true;
182
+ if (this.packetReader) {
183
+ await this.packetReader.cancel().catch(() => undefined);
184
+ }
185
+ await this.packetPump?.catch(() => undefined);
186
+
187
+ const userPcm = await this.flushUserAudio();
188
+ const assistantPcm = await this.flushAssistantAudio();
189
+ await this.waitForPendingWrites();
190
+ const writeFailure = this.writeFailure;
191
+ if (!writeFailure) {
192
+ await this.writeConversationWav(userPcm, assistantPcm);
193
+ await this.writeManifest();
194
+ }
195
+
196
+ await Promise.all([
197
+ closeWriteStream(this.events),
198
+ closeWriteStream(this.userAudio),
199
+ closeWriteStream(this.assistantAudio),
200
+ ]);
201
+
202
+ this.bus = null;
203
+ this.events = null;
204
+ this.userAudio = null;
205
+ this.assistantAudio = null;
206
+ this.sessionId = undefined;
207
+ this.userChunks = [];
208
+ this.userCursorBytes = 0;
209
+ this.assistantChunks = [];
210
+ this.assistantCursorBytes = 0;
211
+ this.assistantPlayoutStartMs.clear();
212
+ this.userAudioBytes = 0;
213
+ this.userAudioChunks = 0;
214
+ this.assistantAudioBytes = 0;
215
+ this.assistantAudioChunks = 0;
216
+ this.assistantTruncations = 0;
217
+ this.assistantSampleRateLocked = false;
218
+ this.conversationAudioBytes = 0;
219
+ this.eventBytes = 0;
220
+ this.eventPackets = 0;
221
+ this.packetReader = null;
222
+ this.packetPump = null;
223
+ this.pendingWrites.clear();
224
+ this.writeFailure = null;
225
+ this.closing = false;
226
+ if (writeFailure) throw writeFailure;
227
+ }
228
+
229
+ private async recordPackets(): Promise<void> {
230
+ const reader = this.packetReader;
231
+ if (!reader) return;
232
+
233
+ while (!this.closing) {
234
+ const next = await reader.read();
235
+ if (next.done) return;
236
+ this.writeEvent(next.value.route, next.value.packet);
237
+ if (next.value.packet.kind === "record.user_audio") {
238
+ this.recordUserAudio(next.value.packet as RecordUserAudioPacket);
239
+ } else if (next.value.packet.kind === "record.assistant_audio") {
240
+ this.recordAssistantAudio(next.value.packet as RecordAssistantAudioPacket);
241
+ } else if (next.value.packet.kind === "tts.playout_progress") {
242
+ this.recordPlayoutProgress(next.value.packet as TextToSpeechPlayoutProgressPacket);
243
+ }
244
+ }
245
+ }
246
+
247
+ private writeEvent(route: Route, packet: VoicePacket): void {
248
+ const events = this.events;
249
+ if (!events) return;
250
+
251
+ const record: PacketRecord = {
252
+ route: Route[route] ?? String(route),
253
+ kind: packet.kind,
254
+ context_id: packet.contextId,
255
+ timestamp_ms: packet.timestampMs,
256
+ packet: sanitizePacket(packet),
257
+ };
258
+ const line = Buffer.from(`${JSON.stringify(record)}\n`);
259
+ this.eventPackets += 1;
260
+ this.eventBytes += line.byteLength;
261
+ this.writeStreamData(events, line);
262
+ }
263
+
264
+ private recordUserAudio(packet: RecordUserAudioPacket): void {
265
+ if (packet.audio.byteLength === 0) return;
266
+ if (!this.validatePcm16ByteLength(packet.kind, packet.audio)) return;
267
+ const byteOffset = this.userChunks.length === 0
268
+ ? 0
269
+ : Math.max(this.userCursorBytes, this.currentUserWallOffsetBytes());
270
+ const copy = Uint8Array.from(packet.audio);
271
+ this.userChunks.push({ byteOffset, data: copy });
272
+ this.userCursorBytes = byteOffset + copy.byteLength;
273
+ }
274
+
275
+ private recordAssistantAudio(packet: RecordAssistantAudioPacket): void {
276
+ if (packet.truncate) {
277
+ this.assistantTruncations += 1;
278
+ this.truncateAssistantAudio();
279
+ return;
280
+ }
281
+ const audio = packet.audio;
282
+ if (audio.byteLength === 0) return;
283
+ if (!this.validatePcm16ByteLength(packet.kind, audio)) return;
284
+ if (!this.acceptAssistantSampleRate(packet)) return;
285
+ // Anchor every chunk — including the first — at its wall-clock position. The
286
+ // assistant speaks after the user, so pinning the first chunk to offset 0
287
+ // strands a whole turn at the start of the recording for providers that emit
288
+ // one packet per turn (e.g. Gemini), overlapping the user's opening turn.
289
+ const byteOffset = Math.max(this.assistantCursorBytes, this.currentAssistantWallOffsetBytes());
290
+ const copy = Uint8Array.from(audio);
291
+ this.assistantChunks.push({ byteOffset, data: copy, contextId: packet.contextId });
292
+ this.assistantCursorBytes = byteOffset + copy.byteLength;
293
+ }
294
+
295
+ private recordPlayoutProgress(packet: TextToSpeechPlayoutProgressPacket): void {
296
+ // The first progress for a context fixes its playout-start: the wall-clock at
297
+ // which its audio began reaching the wire (timestamp minus what had played).
298
+ if (this.assistantPlayoutStartMs.has(packet.contextId)) return;
299
+ this.assistantPlayoutStartMs.set(packet.contextId, packet.timestampMs - packet.playedOutMs);
300
+ }
301
+
302
+ private truncateAssistantAudio(): void {
303
+ const cutoff = this.currentAssistantWallOffsetBytes();
304
+ const kept: AudioChunk[] = [];
305
+ for (const chunk of this.assistantChunks) {
306
+ const chunkEnd = chunk.byteOffset + chunk.data.byteLength;
307
+ if (chunkEnd <= cutoff) {
308
+ kept.push(chunk);
309
+ continue;
310
+ }
311
+ if (chunk.byteOffset < cutoff) {
312
+ kept.push({ byteOffset: chunk.byteOffset, data: chunk.data.subarray(0, cutoff - chunk.byteOffset) });
313
+ }
314
+ }
315
+ this.assistantChunks = kept;
316
+ this.assistantCursorBytes = cutoff;
317
+ }
318
+
319
+ private async flushUserAudio(): Promise<Buffer> {
320
+ const stream = this.userAudio;
321
+ // The persisted user track stays contiguous (speech only) for backward
322
+ // compatibility with downstream per-turn slicing. Wall-clock alignment (silence
323
+ // for inter-turn gaps) is applied only to the combined conversation.wav.
324
+ const contiguous = this.userChunks.length > 0
325
+ ? Buffer.concat(this.userChunks.map((chunk) => Buffer.from(chunk.data)))
326
+ : Buffer.alloc(0);
327
+ this.userAudioBytes = contiguous.byteLength;
328
+ this.userAudioChunks = this.userChunks.length;
329
+ if (stream && contiguous.byteLength > 0) {
330
+ this.writeStreamData(stream, contiguous);
331
+ }
332
+ await this.waitForPendingWrites();
333
+ return renderChunks(this.userChunks).pcm;
334
+ }
335
+
336
+ private async flushAssistantAudio(): Promise<Buffer> {
337
+ const stream = this.assistantAudio;
338
+ const { pcm, bytes, count } = renderChunks(this.reanchorAssistantToPlayout());
339
+ this.assistantAudioBytes = bytes;
340
+ this.assistantAudioChunks = count;
341
+ if (stream && pcm.byteLength > 0) {
342
+ this.writeStreamData(stream, pcm);
343
+ }
344
+ await this.waitForPendingWrites();
345
+ return pcm;
346
+ }
347
+
348
+ private async writeConversationWav(userPcm: Buffer, assistantPcm: Buffer): Promise<void> {
349
+ if (!this.conversationAudioPath) return;
350
+ const userRate = this.userSampleRateHz;
351
+ const assistantRate = this.assistantSampleRateHz;
352
+ const resampled = assistantRate !== userRate
353
+ ? resampleLinear(assistantPcm, assistantRate, userRate)
354
+ : assistantPcm;
355
+ const stereo = interleaveInt16(userPcm, resampled);
356
+ this.conversationAudioBytes = stereo.byteLength;
357
+ await writeFile(this.conversationAudioPath, buildWav(stereo, 2, userRate));
358
+ }
359
+
360
+ private async writeManifest(): Promise<void> {
361
+ const files = this.filesValue;
362
+ if (!files) return;
363
+ const closedAtMs = Date.now();
364
+ const conversationEntry = this.conversationAudioPath
365
+ ? {
366
+ path: this.conversationAudioPath,
367
+ sampleRateHz: this.userSampleRateHz,
368
+ channels: 2 as const,
369
+ encoding: "pcm_s16le" as const,
370
+ byteLength: this.conversationAudioBytes,
371
+ durationMs: Math.round((this.conversationAudioBytes / 4 / this.userSampleRateHz) * 1000),
372
+ }
373
+ : undefined;
374
+ const manifest: VoiceSessionRecorderManifest = {
375
+ schemaVersion: 1,
376
+ sessionId: this.sessionId,
377
+ startedAtMs: this.startedAtMs,
378
+ closedAtMs,
379
+ files,
380
+ audio: {
381
+ user: {
382
+ path: files.userAudioPath,
383
+ sampleRateHz: this.userSampleRateHz,
384
+ encoding: "pcm_s16le",
385
+ channels: 1,
386
+ byteLength: this.userAudioBytes,
387
+ durationMs: pcm16DurationMs(this.userAudioBytes, this.userSampleRateHz),
388
+ chunks: this.userAudioChunks,
389
+ },
390
+ assistant: {
391
+ path: files.assistantAudioPath,
392
+ sampleRateHz: this.assistantSampleRateHz,
393
+ encoding: "pcm_s16le",
394
+ channels: 1,
395
+ byteLength: this.assistantAudioBytes,
396
+ durationMs: pcm16DurationMs(this.assistantAudioBytes, this.assistantSampleRateHz),
397
+ chunks: this.assistantAudioChunks,
398
+ truncations: this.assistantTruncations,
399
+ },
400
+ ...(conversationEntry ? { conversation: conversationEntry } : {}),
401
+ },
402
+ events: {
403
+ path: files.eventsPath,
404
+ packets: this.eventPackets,
405
+ byteLength: this.eventBytes,
406
+ },
407
+ };
408
+ assertVoiceSessionRecorderManifest(manifest);
409
+ await writeFile(files.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
410
+ }
411
+
412
+ private currentUserWallOffsetBytes(): number {
413
+ const elapsedMs = Math.max(0, Date.now() - this.startedAtMs);
414
+ const bytes = Math.floor((elapsedMs * this.userSampleRateHz * 2) / 1000);
415
+ return bytes - (bytes % 2);
416
+ }
417
+
418
+ private currentAssistantWallOffsetBytes(): number {
419
+ const elapsedMs = Math.max(0, Date.now() - this.startedAtMs);
420
+ const bytes = Math.floor((elapsedMs * this.assistantSampleRateHz * 2) / 1000);
421
+ return bytes - (bytes % 2);
422
+ }
423
+
424
+ // Re-lay each assistant turn contiguously from its real playout-start (when the
425
+ // transport reported audio reaching the wire) instead of TTS generation arrival.
426
+ // The generation byteOffsets are discarded for re-anchored turns: within one TTS
427
+ // context the audio plays back-to-back on the wire, so the recorder's own
428
+ // offsets — which start the first chunk at 0 and jump later chunks to bursty
429
+ // wall-clock positions — do not reflect what was heard. Turns without a playout
430
+ // signal keep their generation-arrival offset (the headless / no-pacer fallback).
431
+ private reanchorAssistantToPlayout(): AudioChunk[] {
432
+ if (this.assistantPlayoutStartMs.size === 0) return this.assistantChunks;
433
+ const rate = this.assistantSampleRateHz;
434
+ const cursorByContext = new Map<string, number>();
435
+ const placed = this.assistantChunks.map((chunk) => {
436
+ const startMs = chunk.contextId === undefined ? undefined : this.assistantPlayoutStartMs.get(chunk.contextId);
437
+ if (startMs === undefined || chunk.contextId === undefined) return chunk;
438
+ let cursor = cursorByContext.get(chunk.contextId);
439
+ if (cursor === undefined) {
440
+ const startBytesRaw = Math.max(0, Math.floor(((startMs - this.startedAtMs) * rate * 2) / 1000));
441
+ cursor = startBytesRaw - (startBytesRaw % 2);
442
+ }
443
+ cursorByContext.set(chunk.contextId, cursor + chunk.data.byteLength);
444
+ return { byteOffset: cursor, data: chunk.data, contextId: chunk.contextId };
445
+ });
446
+ return placed.sort((a, b) => a.byteOffset - b.byteOffset);
447
+ }
448
+
449
+ private writeStreamData(stream: WriteStream | null, data: Uint8Array): void {
450
+ if (!stream || data.byteLength === 0 || this.writeFailure) return;
451
+ const buffer = Buffer.from(data);
452
+ const writePromise = new Promise<void>((resolve, reject) => {
453
+ const onError = (err: Error) => {
454
+ stream.off("drain", onDrain);
455
+ reject(err);
456
+ };
457
+ const onDrain = () => {
458
+ stream.off("error", onError);
459
+ resolve();
460
+ };
461
+ stream.once("error", onError);
462
+ const flushed = stream.write(buffer, () => {
463
+ stream.off("error", onError);
464
+ stream.off("drain", onDrain);
465
+ resolve();
466
+ });
467
+ if (!flushed) {
468
+ stream.once("drain", onDrain);
469
+ }
470
+ }).catch((err: unknown) => {
471
+ this.writeFailure = err instanceof Error ? err : new Error(String(err));
472
+ }).finally(() => {
473
+ this.pendingWrites.delete(writePromise);
474
+ });
475
+ this.pendingWrites.add(writePromise);
476
+ }
477
+
478
+ private validatePcm16ByteLength(kind: string, audio: Uint8Array): boolean {
479
+ if (audio.byteLength % 2 === 0) return true;
480
+ this.writeFailure = new Error(`${kind} audio must contain an even number of PCM16 bytes`);
481
+ return false;
482
+ }
483
+
484
+ private acceptAssistantSampleRate(packet: RecordAssistantAudioDataPacket): boolean {
485
+ if (!isPositiveInteger(packet.sampleRateHz)) {
486
+ this.writeFailure = new Error("record.assistant_audio sampleRateHz must be a positive integer");
487
+ return false;
488
+ }
489
+
490
+ const packetSampleRateHz = packet.sampleRateHz;
491
+ if (!this.assistantSampleRateLocked) {
492
+ this.assistantSampleRateHz = packetSampleRateHz;
493
+ this.assistantSampleRateLocked = true;
494
+ return true;
495
+ }
496
+
497
+ if (packetSampleRateHz !== this.assistantSampleRateHz) {
498
+ this.writeFailure = new Error(
499
+ `record.assistant_audio sampleRateHz changed within recorder session: ${String(this.assistantSampleRateHz)} -> ${String(packetSampleRateHz)}`,
500
+ );
501
+ return false;
502
+ }
503
+ return true;
504
+ }
505
+
506
+ private async waitForPendingWrites(): Promise<void> {
507
+ while (this.pendingWrites.size > 0) {
508
+ await Promise.all([...this.pendingWrites]);
509
+ }
510
+ }
511
+ }
512
+
513
+ export function createVoiceSessionRecorder(config: VoiceSessionRecorderConfig): VoiceSessionRecorder {
514
+ return new VoiceSessionRecorderWithDefaultConfig(config);
515
+ }
516
+
517
+ export function assertVoiceSessionRecorderManifest(manifest: unknown): asserts manifest is VoiceSessionRecorderManifest {
518
+ const failures = validateVoiceSessionRecorderManifest(manifest);
519
+ if (failures.length > 0) {
520
+ throw new Error(`Invalid recorder manifest: ${failures.join("; ")}`);
521
+ }
522
+ }
523
+
524
+ export function validateVoiceSessionRecorderManifest(manifest: unknown): string[] {
525
+ const failures: string[] = [];
526
+ if (!isRecord(manifest)) return ["manifest must be an object"];
527
+ if (manifest.schemaVersion !== 1) failures.push(`expected schemaVersion 1, got ${String(manifest.schemaVersion)}`);
528
+ if (!isNonNegativeInteger(manifest.startedAtMs)) failures.push("startedAtMs must be a non-negative integer");
529
+ if (!isNonNegativeInteger(manifest.closedAtMs)) failures.push("closedAtMs must be a non-negative integer");
530
+ if (
531
+ isNonNegativeInteger(manifest.startedAtMs)
532
+ && isNonNegativeInteger(manifest.closedAtMs)
533
+ && manifest.closedAtMs < manifest.startedAtMs
534
+ ) {
535
+ failures.push("closedAtMs must be greater than or equal to startedAtMs");
536
+ }
537
+ const files = manifest.files;
538
+ const audio = manifest.audio;
539
+ const events = manifest.events;
540
+ if (!isRecorderFiles(files)) {
541
+ failures.push("files must be an object");
542
+ } else {
543
+ validateRecorderFiles(files, failures);
544
+ }
545
+ if (!isRecord(audio)) {
546
+ failures.push("audio must be an object");
547
+ } else {
548
+ validateRecorderAudio("audio.user", audio["user"], failures);
549
+ validateRecorderAudio("audio.assistant", audio["assistant"], failures);
550
+ if (isRecord(audio["conversation"])) {
551
+ validateConversationAudio("audio.conversation", audio["conversation"], failures);
552
+ if (
553
+ isRecorderFiles(files)
554
+ && typeof files["conversationAudioPath"] === "string"
555
+ && files["conversationAudioPath"].length > 0
556
+ && audio["conversation"]["path"] !== files["conversationAudioPath"]
557
+ ) {
558
+ failures.push("audio.conversation.path must match files.conversationAudioPath");
559
+ }
560
+ }
561
+ }
562
+ const userAudio = isRecord(audio) && isRecord(audio["user"]) ? audio["user"] : null;
563
+ const assistantAudio = isRecord(audio) && isRecord(audio["assistant"]) ? audio["assistant"] : null;
564
+ if (!isRecord(assistantAudio) || !isNonNegativeInteger(assistantAudio["truncations"])) {
565
+ failures.push("audio.assistant.truncations must be a non-negative integer");
566
+ }
567
+ if (userAudio && isRecorderFiles(files) && userAudio["path"] !== files.userAudioPath) {
568
+ failures.push("audio.user.path must match files.userAudioPath");
569
+ }
570
+ if (assistantAudio && isRecorderFiles(files) && assistantAudio["path"] !== files.assistantAudioPath) {
571
+ failures.push("audio.assistant.path must match files.assistantAudioPath");
572
+ }
573
+ if (!isRecord(events)) {
574
+ failures.push("events must be an object");
575
+ } else if (isRecorderFiles(files) && events["path"] !== files.eventsPath) {
576
+ failures.push("events.path must match files.eventsPath");
577
+ }
578
+ if (!isRecord(events) || !isNonNegativeInteger(events["packets"])) {
579
+ failures.push("events.packets must be a non-negative integer");
580
+ }
581
+ if (!isRecord(events) || !isNonNegativeInteger(events["byteLength"])) {
582
+ failures.push("events.byteLength must be a non-negative integer");
583
+ }
584
+ return failures;
585
+ }
586
+
587
+ class VoiceSessionRecorderWithDefaultConfig extends VoiceSessionRecorder {
588
+ constructor(private readonly defaultConfig: VoiceSessionRecorderConfig) {
589
+ super();
590
+ }
591
+
592
+ override async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
593
+ await super.initialize(bus, {
594
+ output_dir: this.defaultConfig.outputDir,
595
+ session_id: this.defaultConfig.sessionId,
596
+ events_file: this.defaultConfig.eventsFile,
597
+ user_audio_file: this.defaultConfig.userAudioFile,
598
+ assistant_audio_file: this.defaultConfig.assistantAudioFile,
599
+ manifest_file: this.defaultConfig.manifestFile,
600
+ conversation_file: this.defaultConfig.conversationFile,
601
+ user_sample_rate_hz: this.defaultConfig.userSampleRateHz,
602
+ assistant_sample_rate_hz: this.defaultConfig.assistantSampleRateHz,
603
+ ...config,
604
+ });
605
+ }
606
+ }
607
+
608
+ function readRecorderConfig(config: PluginConfig): VoiceSessionRecorderConfig {
609
+ const baseDir = readString(config, "output_dir") ?? readString(config, "dir") ?? "recordings";
610
+ const sessionId = readString(config, "session_id");
611
+ // Allow "" to disable; undefined means use default filename.
612
+ const conversationFileRaw = config["conversation_file"];
613
+ const conversationFile = typeof conversationFileRaw === "string" ? conversationFileRaw : "conversation.wav";
614
+ return {
615
+ outputDir: resolve(sessionId ? join(baseDir, sessionId) : baseDir),
616
+ sessionId,
617
+ eventsFile: readString(config, "events_file"),
618
+ userAudioFile: readString(config, "user_audio_file"),
619
+ assistantAudioFile: readString(config, "assistant_audio_file"),
620
+ manifestFile: readString(config, "manifest_file"),
621
+ conversationFile,
622
+ userSampleRateHz: readPositiveInteger(config, "user_sample_rate_hz"),
623
+ assistantSampleRateHz: readPositiveInteger(config, "assistant_sample_rate_hz"),
624
+ };
625
+ }
626
+
627
+ function readString(config: PluginConfig, key: string): string | undefined {
628
+ const value = config[key];
629
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
630
+ }
631
+
632
+ function readPositiveInteger(config: PluginConfig, key: string): number | undefined {
633
+ const value = config[key];
634
+ return isPositiveInteger(value) ? value : undefined;
635
+ }
636
+
637
+ function isPositiveInteger(value: unknown): value is number {
638
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
639
+ }
640
+
641
+ function isNonNegativeInteger(value: unknown): value is number {
642
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
643
+ }
644
+
645
+ function isRecord(value: unknown): value is Record<string, unknown> {
646
+ return typeof value === "object" && value !== null && !Array.isArray(value);
647
+ }
648
+
649
+ function pcm16DurationMs(byteLength: number, sampleRateHz: number): number {
650
+ if (sampleRateHz <= 0) return 0;
651
+ return Math.round((byteLength / 2 / sampleRateHz) * 1000);
652
+ }
653
+
654
+ function isRecorderFiles(value: unknown): value is VoiceSessionRecorderFiles {
655
+ if (!isRecord(value)) return false;
656
+ return (
657
+ typeof value["directory"] === "string"
658
+ && typeof value["eventsPath"] === "string"
659
+ && typeof value["userAudioPath"] === "string"
660
+ && typeof value["assistantAudioPath"] === "string"
661
+ && typeof value["manifestPath"] === "string"
662
+ );
663
+ }
664
+
665
+ function validateRecorderFiles(files: VoiceSessionRecorderFiles, failures: string[]): void {
666
+ const required: Array<keyof VoiceSessionRecorderFiles> = [
667
+ "directory", "eventsPath", "userAudioPath", "assistantAudioPath", "manifestPath",
668
+ ];
669
+ for (const key of required) {
670
+ if (typeof files[key] !== "string" || (files[key] as string).length === 0) {
671
+ failures.push(`files.${key} must be a non-empty string`);
672
+ }
673
+ }
674
+ }
675
+
676
+ function validateRecorderAudio(
677
+ label: string,
678
+ audio: unknown,
679
+ failures: string[],
680
+ ): void {
681
+ if (!isRecord(audio)) {
682
+ failures.push(`${label} must be an object`);
683
+ return;
684
+ }
685
+ if (!isPositiveInteger(audio["sampleRateHz"])) failures.push(`${label}.sampleRateHz must be a positive integer`);
686
+ if (audio["encoding"] !== "pcm_s16le") failures.push(`${label}.encoding must be pcm_s16le`);
687
+ if (audio["channels"] !== 1) failures.push(`${label}.channels must be 1`);
688
+ if (!isNonNegativeInteger(audio["byteLength"])) failures.push(`${label}.byteLength must be a non-negative integer`);
689
+ if (isNonNegativeInteger(audio["byteLength"]) && audio["byteLength"] % 2 !== 0) {
690
+ failures.push(`${label}.byteLength must contain an even number of PCM16 bytes`);
691
+ }
692
+ if (!isNonNegativeInteger(audio["durationMs"])) failures.push(`${label}.durationMs must be a non-negative integer`);
693
+ if (!isNonNegativeInteger(audio["chunks"])) failures.push(`${label}.chunks must be a non-negative integer`);
694
+ if (isPositiveInteger(audio["sampleRateHz"]) && isNonNegativeInteger(audio["byteLength"])) {
695
+ const expectedDurationMs = pcm16DurationMs(audio["byteLength"], audio["sampleRateHz"]);
696
+ if (audio["durationMs"] !== expectedDurationMs) {
697
+ failures.push(`${label}.durationMs ${String(audio["durationMs"])} did not match ${String(expectedDurationMs)} from byte count/sample rate`);
698
+ }
699
+ }
700
+ }
701
+
702
+ function validateConversationAudio(label: string, audio: Record<string, unknown>, failures: string[]): void {
703
+ if (!isPositiveInteger(audio["sampleRateHz"])) failures.push(`${label}.sampleRateHz must be a positive integer`);
704
+ if (audio["encoding"] !== "pcm_s16le") failures.push(`${label}.encoding must be pcm_s16le`);
705
+ if (audio["channels"] !== 2) failures.push(`${label}.channels must be 2`);
706
+ if (!isNonNegativeInteger(audio["byteLength"])) failures.push(`${label}.byteLength must be a non-negative integer`);
707
+ if (isNonNegativeInteger(audio["byteLength"]) && audio["byteLength"] % 4 !== 0) {
708
+ failures.push(`${label}.byteLength must be a multiple of 4 (stereo PCM16 frame)`);
709
+ }
710
+ if (!isNonNegativeInteger(audio["durationMs"])) failures.push(`${label}.durationMs must be a non-negative integer`);
711
+ if (isPositiveInteger(audio["sampleRateHz"]) && isNonNegativeInteger(audio["byteLength"])) {
712
+ const expectedDurationMs = Math.round((audio["byteLength"] / 4 / audio["sampleRateHz"]) * 1000);
713
+ if (audio["durationMs"] !== expectedDurationMs) {
714
+ failures.push(`${label}.durationMs ${String(audio["durationMs"])} did not match ${String(expectedDurationMs)} from byte count/sample rate`);
715
+ }
716
+ }
717
+ }
718
+
719
+ function sanitizePacket(packet: VoicePacket): Record<string, unknown> {
720
+ const result: Record<string, unknown> = {};
721
+ for (const [key, value] of Object.entries(packet) as Array<[string, unknown]>) {
722
+ if (value instanceof Uint8Array) {
723
+ result[key] = {
724
+ type: "Uint8Array",
725
+ byteLength: value.byteLength,
726
+ };
727
+ continue;
728
+ }
729
+ if (value instanceof Error) {
730
+ result[key] = {
731
+ name: value.name,
732
+ message: value.message,
733
+ };
734
+ continue;
735
+ }
736
+ result[key] = value;
737
+ }
738
+ return result;
739
+ }
740
+
741
+ async function closeWriteStream(stream: WriteStream | null): Promise<void> {
742
+ if (!stream) return;
743
+ if (stream.destroyed) return;
744
+
745
+ await new Promise<void>((resolveClose, reject) => {
746
+ stream.once("error", reject);
747
+ stream.end(() => {
748
+ stream.off("error", reject);
749
+ resolveClose();
750
+ });
751
+ });
752
+ }
753
+
754
+ function renderChunks(chunks: AudioChunk[]): { pcm: Buffer; bytes: number; count: number } {
755
+ const parts: Buffer[] = [];
756
+ let cursor = 0;
757
+ let bytes = 0;
758
+ let count = 0;
759
+ for (const chunk of chunks) {
760
+ if (chunk.byteOffset > cursor) {
761
+ const silence = Buffer.alloc(chunk.byteOffset - cursor);
762
+ parts.push(silence);
763
+ bytes += silence.byteLength;
764
+ cursor += silence.byteLength;
765
+ }
766
+ const buf = Buffer.from(chunk.data);
767
+ parts.push(buf);
768
+ count += 1;
769
+ bytes += chunk.data.byteLength;
770
+ cursor = chunk.byteOffset + chunk.data.byteLength;
771
+ }
772
+ return { pcm: parts.length > 0 ? Buffer.concat(parts) : Buffer.alloc(0), bytes, count };
773
+ }
774
+
775
+ // Linear interpolation resampler for int16 mono PCM. Quality is sufficient for a recording artifact.
776
+ function resampleLinear(src: Buffer, srcRate: number, dstRate: number): Buffer {
777
+ if (srcRate === dstRate) return src;
778
+ const srcSamples = src.byteLength >> 1;
779
+ const dstSamples = Math.round((srcSamples * dstRate) / srcRate);
780
+ if (dstSamples === 0 || srcSamples === 0) return Buffer.alloc(0);
781
+ const dst = Buffer.allocUnsafe(dstSamples * 2);
782
+ for (let i = 0; i < dstSamples; i++) {
783
+ const srcPos = (i * srcRate) / dstRate;
784
+ const srcIdx = Math.floor(srcPos);
785
+ const frac = srcPos - srcIdx;
786
+ const s0 = src.readInt16LE(Math.min(srcIdx, srcSamples - 1) * 2);
787
+ const s1 = src.readInt16LE(Math.min(srcIdx + 1, srcSamples - 1) * 2);
788
+ const val = Math.round(s0 + frac * (s1 - s0));
789
+ dst.writeInt16LE(Math.max(-32768, Math.min(32767, val)), i * 2);
790
+ }
791
+ return dst;
792
+ }
793
+
794
+ function interleaveInt16(left: Buffer, right: Buffer): Buffer {
795
+ const leftSamples = left.byteLength >> 1;
796
+ const rightSamples = right.byteLength >> 1;
797
+ const maxSamples = Math.max(leftSamples, rightSamples);
798
+ if (maxSamples === 0) return Buffer.alloc(0);
799
+ const out = Buffer.alloc(maxSamples * 4);
800
+ for (let i = 0; i < maxSamples; i++) {
801
+ const l = i < leftSamples ? left.readInt16LE(i * 2) : 0;
802
+ const r = i < rightSamples ? right.readInt16LE(i * 2) : 0;
803
+ out.writeInt16LE(l, i * 4);
804
+ out.writeInt16LE(r, i * 4 + 2);
805
+ }
806
+ return out;
807
+ }
808
+
809
+ function buildWav(pcm: Buffer, channels: number, sampleRateHz: number): Buffer {
810
+ const bitsPerSample = 16;
811
+ const blockAlign = channels * (bitsPerSample >> 3);
812
+ const byteRate = sampleRateHz * blockAlign;
813
+ const header = Buffer.allocUnsafe(44);
814
+ header.write("RIFF", 0, "ascii");
815
+ header.writeUInt32LE(36 + pcm.byteLength, 4);
816
+ header.write("WAVE", 8, "ascii");
817
+ header.write("fmt ", 12, "ascii");
818
+ header.writeUInt32LE(16, 16);
819
+ header.writeUInt16LE(1, 20);
820
+ header.writeUInt16LE(channels, 22);
821
+ header.writeUInt32LE(sampleRateHz, 24);
822
+ header.writeUInt32LE(byteRate, 28);
823
+ header.writeUInt16LE(blockAlign, 32);
824
+ header.writeUInt16LE(bitsPerSample, 34);
825
+ header.write("data", 36, "ascii");
826
+ header.writeUInt32LE(pcm.byteLength, 40);
827
+ return Buffer.concat([header, pcm]);
828
+ }