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