@kuralle-syrinx/cf-agents 3.1.0 → 4.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.
@@ -1,16 +1,23 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  //
3
3
  // R2-backed implementation of the transport EdgeRecorder. Taps inbound caller
4
- // audio and outbound TTS audio and, on call end, writes to an R2 bucket:
5
- // - conversation.wav : the FULL conversation, one stereo file (user = left,
6
- // assistant = right), time-aligned by wall-clock so the
7
- // assistant sits after the user instead of stacked at 0.
8
- // - user.wav / assistant.wav : the per-speaker stems (useful for diarization).
4
+ // audio and outbound TTS audio and writes to an R2 bucket:
5
+ // - user.wav / assistant.wav : the per-speaker stems, time-aligned by wall-clock
6
+ // (user chunks at their wall offset; assistant re-anchored to
7
+ // playout) with silence filling the gaps the durable artifacts.
8
+ // - conversation.wav : best-effort stereo mix (user = left, assistant = right),
9
+ // written only for short calls that stayed wholly in DO RAM;
10
+ // omitted (flagged in the manifest) once a stem streams out.
9
11
  // - manifest.json : durations / byte lengths / truncation flags.
10
12
  // Mirrors the Node `voice-recorder` conversation-track approach (wall-clock byte
11
- // offsets + stereo interleave) but stays edge-safe (no node:fs). Cloudflare's own
12
- // withVoice persists transcripts to SQLite, not raw audio — this is the additive
13
- // piece. Buffered (memory-capped) and flushed once on finalize, off the hot path.
13
+ // offsets + stereo interleave) but stays edge-safe (no node:fs).
14
+ //
15
+ // Memory: the DO has ~128 MB. Rather than buffer the whole call and gap-fill the full
16
+ // wall-clock length at finalize (which OOMs long/mostly-silent calls), each stem is
17
+ // gap-filled INCREMENTALLY and streamed to R2 via multipart upload: bytes accumulate
18
+ // into a bounded buffer and flush as 5 MiB parts, so retained memory stays ~O(part size)
19
+ // per stem regardless of call length. Short calls (< one part) never open a multipart and
20
+ // are written with a single put, exactly as before.
14
21
 
15
22
  import type { EdgeRecorder } from "@kuralle-syrinx/server-websocket/edge";
16
23
  import { interleaveStereoPcm16, pcm16ToWav } from "@kuralle-syrinx/recorder/wav";
@@ -21,40 +28,93 @@ export interface R2EdgeRecorderOptions {
21
28
  readonly startedAtMs: number;
22
29
  /** Object key prefix. Default "recordings". */
23
30
  readonly keyPrefix?: string;
24
- /** Per-stream memory cap; recording past it is dropped and flagged. Default 64 MiB. */
31
+ /**
32
+ * Optional per-stream truncation cap on captured audio bytes. Past it, audio is
33
+ * dropped and the stem is flagged `truncated`. Default: unlimited — memory is bounded
34
+ * by streaming, not by retention, so any length records without OOM.
35
+ */
25
36
  readonly maxBytesPerStream?: number;
26
37
  /** Injectable clock (test seam). Defaults to Date.now. */
27
38
  readonly now?: () => number;
28
39
  }
29
40
 
30
- const DEFAULT_MAX_BYTES_PER_STREAM = 64 * 1024 * 1024;
41
+ // R2 requires every multipart part except the last to be at least 5 MiB. We flush at
42
+ // exactly that: bigger parts waste RAM, smaller ones are rejected.
43
+ const PART_SIZE_BYTES = 5 * 1024 * 1024;
44
+ // Emit long silence gaps in small slices so a multi-minute gap never allocates its full
45
+ // wall-clock length at once (that was the OOM).
46
+ const SILENCE_SLICE_BYTES = 64 * 1024;
47
+
48
+ /** A FIFO byte queue that hands back exact-length runs without holding the whole call. */
49
+ class PartBuffer {
50
+ #chunks: Uint8Array[] = [];
51
+ #size = 0;
52
+
53
+ push(bytes: Uint8Array): void {
54
+ if (bytes.byteLength === 0) return;
55
+ this.#chunks.push(bytes);
56
+ this.#size += bytes.byteLength;
57
+ }
31
58
 
32
- interface AudioChunk {
33
- offsetBytes: number;
34
- data: Uint8Array;
59
+ get size(): number {
60
+ return this.#size;
61
+ }
62
+
63
+ /** Remove and return the first `n` bytes (caller guarantees n <= size). */
64
+ take(n: number): Uint8Array {
65
+ const out = new Uint8Array(n);
66
+ let filled = 0;
67
+ while (filled < n) {
68
+ const head = this.#chunks[0]!;
69
+ const need = n - filled;
70
+ if (head.byteLength <= need) {
71
+ out.set(head, filled);
72
+ filled += head.byteLength;
73
+ this.#chunks.shift();
74
+ } else {
75
+ out.set(head.subarray(0, need), filled);
76
+ this.#chunks[0] = head.subarray(need);
77
+ filled += need;
78
+ }
79
+ }
80
+ this.#size -= n;
81
+ return out;
82
+ }
83
+
84
+ drain(): Uint8Array {
85
+ return this.take(this.#size);
86
+ }
35
87
  }
36
88
 
37
- interface StreamBuffer {
38
- chunks: AudioChunk[];
89
+ interface Stem {
90
+ readonly key: string;
91
+ readonly buf: PartBuffer;
92
+ /** Deferred part-1 payload (the first PART_SIZE bytes) once the stem goes multipart. */
93
+ head: Uint8Array | null;
94
+ multipart: boolean;
95
+ uploadId: string | null;
96
+ parts: R2UploadedPart[];
97
+ partSeq: number; // next partNumber for streamed middle parts (part 1 reserved for the header)
98
+ tail: Promise<void>; // serialises the async create/upload chain fired from sync callbacks
39
99
  cursorBytes: number; // end of the last placed chunk on the wall-clock timeline
40
100
  dataBytes: number; // actual audio bytes captured (for the cap)
41
101
  sampleRateHz: number;
42
102
  truncated: boolean;
43
103
  }
44
104
 
45
- function emptyStream(): StreamBuffer {
46
- return { chunks: [], cursorBytes: 0, dataBytes: 0, sampleRateHz: 16000, truncated: false };
47
- }
48
-
49
105
  export class R2EdgeRecorder implements EdgeRecorder {
50
- readonly #user = emptyStream();
51
- readonly #assistant = emptyStream();
52
- readonly #maxBytes: number;
106
+ readonly #prefix: string;
107
+ readonly #user: Stem;
108
+ readonly #assistant: Stem;
109
+ readonly #maxBytes: number | undefined;
53
110
  readonly #now: () => number;
54
111
  #finalized = false;
55
112
 
56
113
  constructor(private readonly opts: R2EdgeRecorderOptions) {
57
- this.#maxBytes = opts.maxBytesPerStream ?? DEFAULT_MAX_BYTES_PER_STREAM;
114
+ this.#prefix = `${opts.keyPrefix ?? "recordings"}/${opts.sessionId}/${opts.startedAtMs}`;
115
+ this.#user = this.#emptyStem(`${this.#prefix}/user.wav`);
116
+ this.#assistant = this.#emptyStem(`${this.#prefix}/assistant.wav`);
117
+ this.#maxBytes = opts.maxBytesPerStream;
58
118
  this.#now = opts.now ?? Date.now;
59
119
  }
60
120
 
@@ -71,63 +131,158 @@ export class R2EdgeRecorder implements EdgeRecorder {
71
131
  this.#finalized = true;
72
132
  if (this.#user.dataBytes === 0 && this.#assistant.dataBytes === 0) return;
73
133
 
74
- const prefix = `${this.opts.keyPrefix ?? "recordings"}/${this.opts.sessionId}/${this.opts.startedAtMs}`;
75
134
  const rate = this.#user.sampleRateHz; // conversation timeline runs at the user (input) rate
76
135
 
77
- const userPcm = gapFill(this.#user);
78
- const assistantRaw = gapFill(this.#assistant);
79
- const assistantPcm =
80
- this.#assistant.sampleRateHz === rate
81
- ? assistantRaw
82
- : resamplePcm16(assistantRaw, this.#assistant.sampleRateHz, rate);
83
- const conversation = interleaveStereoPcm16(userPcm, assistantPcm);
136
+ // Close each stem sequentially (never hold both full WAV copies at once). A stem that
137
+ // never streamed hands back its full mono PCM so a best-effort stereo mix can be built.
138
+ const userMono = await this.#closeStem(this.#user);
139
+ const assistantMono = await this.#closeStem(this.#assistant);
140
+
141
+ const conversation =
142
+ userMono && assistantMono
143
+ ? await this.#writeStereo(userMono, assistantMono, rate)
144
+ : {
145
+ path: `${this.#prefix}/conversation.wav`,
146
+ sampleRateHz: rate,
147
+ channels: 2 as const,
148
+ encoding: "pcm_s16le" as const,
149
+ byteLength: 0,
150
+ durationMs: 0,
151
+ omitted: true as const,
152
+ };
84
153
 
85
154
  const manifest = {
86
155
  schemaVersion: 1 as const,
87
156
  sessionId: meta.sessionId,
88
157
  startedAtMs: this.opts.startedAtMs,
89
158
  closedAtMs: meta.closedAtMs,
90
- conversation: {
91
- path: `${prefix}/conversation.wav`,
92
- sampleRateHz: rate,
93
- channels: 2 as const,
94
- encoding: "pcm_s16le" as const,
95
- byteLength: conversation.byteLength,
96
- durationMs: rate > 0 ? Math.round((conversation.byteLength / 4 / rate) * 1000) : 0,
97
- },
98
- user: this.#describe(this.#user, `${prefix}/user.wav`),
99
- assistant: this.#describe(this.#assistant, `${prefix}/assistant.wav`),
159
+ conversation,
160
+ user: this.#describe(this.#user),
161
+ assistant: this.#describe(this.#assistant),
100
162
  };
101
163
 
102
- await Promise.all([
103
- this.opts.bucket.put(`${prefix}/conversation.wav`, pcm16ToWav(conversation, rate, 2), {
104
- httpMetadata: { contentType: "audio/wav" },
105
- }),
106
- this.opts.bucket.put(`${prefix}/user.wav`, pcm16ToWav(userPcm, this.#user.sampleRateHz, 1), {
107
- httpMetadata: { contentType: "audio/wav" },
108
- }),
109
- this.opts.bucket.put(`${prefix}/assistant.wav`, pcm16ToWav(assistantRaw, this.#assistant.sampleRateHz, 1), {
110
- httpMetadata: { contentType: "audio/wav" },
111
- }),
112
- this.opts.bucket.put(`${prefix}/manifest.json`, JSON.stringify(manifest, null, 2), {
113
- httpMetadata: { contentType: "application/json" },
114
- }),
115
- ]);
164
+ await this.opts.bucket.put(`${this.#prefix}/manifest.json`, JSON.stringify(manifest, null, 2), {
165
+ httpMetadata: { contentType: "application/json" },
166
+ });
116
167
  }
117
168
 
118
- #append(buf: StreamBuffer, audio: Uint8Array, sampleRateHz: number): void {
119
- buf.sampleRateHz = sampleRateHz;
120
- if (buf.dataBytes + audio.byteLength > this.#maxBytes) {
121
- buf.truncated = true;
169
+ #emptyStem(key: string): Stem {
170
+ return {
171
+ key,
172
+ buf: new PartBuffer(),
173
+ head: null,
174
+ multipart: false,
175
+ uploadId: null,
176
+ parts: [],
177
+ partSeq: 2,
178
+ tail: Promise.resolve(),
179
+ cursorBytes: 0,
180
+ dataBytes: 0,
181
+ sampleRateHz: 16000,
182
+ truncated: false,
183
+ };
184
+ }
185
+
186
+ #append(stem: Stem, audio: Uint8Array, sampleRateHz: number): void {
187
+ stem.sampleRateHz = sampleRateHz;
188
+ if (this.#maxBytes !== undefined && stem.dataBytes + audio.byteLength > this.#maxBytes) {
189
+ stem.truncated = true;
122
190
  return;
123
191
  }
124
- // Anchor each chunk at its wall-clock position so the two speakers line up on a
125
- // shared timeline; never overlap the previous chunk in the same stream.
192
+ // Anchor each chunk at its wall-clock position so the two speakers line up on a shared
193
+ // timeline; never overlap the previous chunk. Emit the intervening silence + the chunk
194
+ // incrementally so the timeline is never materialised whole.
126
195
  const wallOffset = this.#wallOffsetBytes(sampleRateHz);
127
- const offsetBytes = Math.max(buf.cursorBytes, wallOffset);
128
- buf.chunks.push({ offsetBytes, data: audio.slice() });
129
- buf.cursorBytes = offsetBytes + audio.byteLength;
130
- buf.dataBytes += audio.byteLength;
196
+ const offsetBytes = Math.max(stem.cursorBytes, wallOffset);
197
+ const gap = offsetBytes - stem.cursorBytes;
198
+ if (gap > 0) this.#emitSilence(stem, gap);
199
+ this.#emit(stem, audio.slice());
200
+ stem.cursorBytes = offsetBytes + audio.byteLength;
201
+ stem.dataBytes += audio.byteLength;
202
+ }
203
+
204
+ #emitSilence(stem: Stem, bytes: number): void {
205
+ let remaining = bytes;
206
+ while (remaining > 0) {
207
+ const slice = Math.min(remaining, SILENCE_SLICE_BYTES);
208
+ this.#emit(stem, new Uint8Array(slice)); // zero = PCM16 silence
209
+ remaining -= slice;
210
+ }
211
+ }
212
+
213
+ #emit(stem: Stem, bytes: Uint8Array): void {
214
+ if (bytes.byteLength === 0) return;
215
+ stem.buf.push(bytes);
216
+ if (!stem.multipart && stem.buf.size >= PART_SIZE_BYTES) {
217
+ // Commit to multipart: retain the first part's worth as the deferred part 1 (its WAV
218
+ // header needs the final total length, known only at finalize), then stream the rest.
219
+ stem.head = stem.buf.take(PART_SIZE_BYTES);
220
+ stem.multipart = true;
221
+ this.#enqueueCreate(stem);
222
+ }
223
+ if (stem.multipart) {
224
+ while (stem.buf.size >= PART_SIZE_BYTES) {
225
+ this.#enqueueUpload(stem, stem.partSeq++, stem.buf.take(PART_SIZE_BYTES));
226
+ }
227
+ }
228
+ }
229
+
230
+ #enqueueCreate(stem: Stem): void {
231
+ stem.tail = stem.tail.then(async () => {
232
+ const mpu = await this.opts.bucket.createMultipartUpload(stem.key, {
233
+ httpMetadata: { contentType: "audio/wav" },
234
+ });
235
+ stem.uploadId = mpu.uploadId;
236
+ });
237
+ }
238
+
239
+ #enqueueUpload(stem: Stem, partNumber: number, body: Uint8Array): void {
240
+ stem.tail = stem.tail.then(async () => {
241
+ const mpu = this.opts.bucket.resumeMultipartUpload(stem.key, stem.uploadId!);
242
+ stem.parts.push(await mpu.uploadPart(partNumber, body));
243
+ });
244
+ }
245
+
246
+ /** Flush a stem to R2. Returns its full mono PCM if it stayed in RAM, else null. */
247
+ async #closeStem(stem: Stem): Promise<Uint8Array | null> {
248
+ if (!stem.multipart) {
249
+ const mono = stem.buf.drain();
250
+ await this.opts.bucket.put(stem.key, pcm16ToWav(mono, stem.sampleRateHz, 1), {
251
+ httpMetadata: { contentType: "audio/wav" },
252
+ });
253
+ return mono;
254
+ }
255
+ await stem.tail; // drain the queued create + middle-part uploads
256
+ const mpu = this.opts.bucket.resumeMultipartUpload(stem.key, stem.uploadId!);
257
+ // Part 1 = the WAV header (now that the total data length is known) + the retained head.
258
+ const head = stem.head ?? new Uint8Array(0);
259
+ const part1 = concat(wavHeader(stem.cursorBytes, stem.sampleRateHz, 1), head);
260
+ stem.parts.push(await mpu.uploadPart(1, part1));
261
+ const remainder = stem.buf.drain();
262
+ if (remainder.byteLength > 0) stem.parts.push(await mpu.uploadPart(stem.partSeq++, remainder));
263
+ stem.parts.sort((a, b) => a.partNumber - b.partNumber);
264
+ await mpu.complete(stem.parts);
265
+ return null;
266
+ }
267
+
268
+ async #writeStereo(userPcm: Uint8Array, assistantMono: Uint8Array, rate: number) {
269
+ const assistantPcm =
270
+ this.#assistant.sampleRateHz === rate
271
+ ? assistantMono
272
+ : resamplePcm16(assistantMono, this.#assistant.sampleRateHz, rate);
273
+ const stereo = interleaveStereoPcm16(userPcm, assistantPcm);
274
+ await this.opts.bucket.put(`${this.#prefix}/conversation.wav`, pcm16ToWav(stereo, rate, 2), {
275
+ httpMetadata: { contentType: "audio/wav" },
276
+ });
277
+ return {
278
+ path: `${this.#prefix}/conversation.wav`,
279
+ sampleRateHz: rate,
280
+ channels: 2 as const,
281
+ encoding: "pcm_s16le" as const,
282
+ byteLength: stereo.byteLength,
283
+ durationMs: rate > 0 ? Math.round((stereo.byteLength / 4 / rate) * 1000) : 0,
284
+ omitted: false as const,
285
+ };
131
286
  }
132
287
 
133
288
  #wallOffsetBytes(sampleRateHz: number): number {
@@ -136,26 +291,52 @@ export class R2EdgeRecorder implements EdgeRecorder {
136
291
  return bytes - (bytes % 2);
137
292
  }
138
293
 
139
- #describe(buf: StreamBuffer, path: string) {
294
+ #describe(stem: Stem) {
140
295
  return {
141
- path,
142
- sampleRateHz: buf.sampleRateHz,
296
+ path: stem.key,
297
+ sampleRateHz: stem.sampleRateHz,
143
298
  encoding: "pcm_s16le" as const,
144
299
  channels: 1 as const,
145
- byteLength: buf.cursorBytes,
146
- durationMs: buf.sampleRateHz > 0 ? Math.round((buf.cursorBytes / 2 / buf.sampleRateHz) * 1000) : 0,
147
- truncated: buf.truncated,
300
+ byteLength: stem.cursorBytes,
301
+ durationMs: stem.sampleRateHz > 0 ? Math.round((stem.cursorBytes / 2 / stem.sampleRateHz) * 1000) : 0,
302
+ truncated: stem.truncated,
148
303
  };
149
304
  }
150
305
  }
151
306
 
152
- /** Lay chunks onto a silence-filled mono timeline at their wall-clock offsets. */
153
- function gapFill(buf: StreamBuffer): Uint8Array {
154
- const out = new Uint8Array(buf.cursorBytes); // zero = PCM16 silence
155
- for (const chunk of buf.chunks) out.set(chunk.data, chunk.offsetBytes);
307
+ function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
308
+ const out = new Uint8Array(a.byteLength + b.byteLength);
309
+ out.set(a, 0);
310
+ out.set(b, a.byteLength);
156
311
  return out;
157
312
  }
158
313
 
314
+ /** Build the 44-byte canonical WAV (RIFF/PCM) header for a stream of `dataBytes` PCM16LE. */
315
+ function wavHeader(dataBytes: number, sampleRateHz: number, channels: number): Uint8Array {
316
+ const blockAlign = channels * 2; // 16-bit samples
317
+ const byteRate = sampleRateHz * blockAlign;
318
+ const out = new Uint8Array(44);
319
+ const view = new DataView(out.buffer);
320
+ writeAscii(view, 0, "RIFF");
321
+ view.setUint32(4, 36 + dataBytes, true);
322
+ writeAscii(view, 8, "WAVE");
323
+ writeAscii(view, 12, "fmt ");
324
+ view.setUint32(16, 16, true);
325
+ view.setUint16(20, 1, true); // PCM
326
+ view.setUint16(22, channels, true);
327
+ view.setUint32(24, sampleRateHz, true);
328
+ view.setUint32(28, byteRate, true);
329
+ view.setUint16(32, blockAlign, true);
330
+ view.setUint16(34, 16, true);
331
+ writeAscii(view, 36, "data");
332
+ view.setUint32(40, dataBytes, true);
333
+ return out;
334
+ }
335
+
336
+ function writeAscii(view: DataView, offset: number, text: string): void {
337
+ for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
338
+ }
339
+
159
340
  function resamplePcm16(pcm: Uint8Array, fromHz: number, toHz: number): Uint8Array {
160
341
  if (fromHz === toHz || pcm.byteLength === 0) return pcm;
161
342
  const src = new DataView(pcm.buffer, pcm.byteOffset, pcm.byteLength);
@@ -174,4 +355,3 @@ function resamplePcm16(pcm: Uint8Array, fromHz: number, toHz: number): Uint8Arra
174
355
  }
175
356
  return out;
176
357
  }
177
-