From 5adf9e7bee19a69455061af500a13eccb35165cf Mon Sep 17 00:00:00 2001 From: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:58:17 -0500 Subject: [PATCH 1/8] feat(ios): stereo call-recording core, one channel per speaker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agreed scope from the TTS/STT/STS work, as a foundation rather than a reachable feature: nothing wires this to a call yet. Left is the person and right is the bot.  A mono mix is no use for the one thing a call recording is for, which is working out afterwards who said what, and the bot's own voice reaches the microphone through the room anyway, so a mix cannot even separate them reliably. The two sides are not the same rate, which is the thing most likely to ship broken: the capture path resamples the microphone to 16 kHz while the providers speak back at their own rate, and Gemini is 24 kHz.  One WAV has one sample rate, so each track is brought to the file's rate before the interleave.  Writing the 24 kHz track in beside 16 kHz audio plays the bot fast and an octave high, which would make a debugging recording useless. Uncompressed WAV on purpose: this is off by default and exists to find out what a call actually said, so lossless means evidence rather than a lossy copy of it, and it is the only part verifiable without a live microphone. 26 tests, no platform fakes needed. Co-authored-by: Jay Wedgeworth <12656028+jaywedgeworth22@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- src/lib/sts/recorder.test.ts | 323 +++++++++++++++++++++++++++++++++++ src/lib/sts/recorder.ts | 267 +++++++++++++++++++++++++++++ 2 files changed, 590 insertions(+) create mode 100644 src/lib/sts/recorder.test.ts create mode 100644 src/lib/sts/recorder.ts diff --git a/src/lib/sts/recorder.test.ts b/src/lib/sts/recorder.test.ts new file mode 100644 index 000000000..9fdbab818 --- /dev/null +++ b/src/lib/sts/recorder.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it } from "vitest"; +import { + RECORDING_RETENTION_DAYS, + RECORDING_SAMPLE_RATE, + buildStereoWav, + createStsCallRecorder, + isPastRetention, + recordingFileName, + resamplePcm16, +} from "./recorder"; + +/** One PCM16 frame with every sample set to `value`. */ +function pcm(samples: number, value: number, frameRate = RECORDING_SAMPLE_RATE): ArrayBuffer { + const buffer = new ArrayBuffer(samples * 2); + const view = new DataView(buffer); + for (let index = 0; index < samples; index += 1) view.setInt16(index * 2, value, true); + void frameRate; + return buffer; +} + +function readWavHeader(buffer: ArrayBuffer) { + const view = new DataView(buffer); + const tag = (offset: number, length: number) => + Array.from({ length }, (_, index) => String.fromCharCode(view.getUint8(offset + index))).join(""); + return { + riff: tag(0, 4), + wave: tag(8, 4), + fmt: tag(12, 4), + data: tag(36, 4), + audioFormat: view.getUint16(20, true), + channels: view.getUint16(22, true), + sampleRate: view.getUint32(24, true), + byteRate: view.getUint32(28, true), + blockAlign: view.getUint16(32, true), + bitsPerSample: view.getUint16(34, true), + riffSize: view.getUint32(4, true), + dataSize: view.getUint32(40, true), + byteLength: buffer.byteLength, + }; +} + +// Frame N of the file starts at 44 + N * 4: an int16 left then an int16 right. +function frameOffset(frame: number): number { + return 44 + frame * 4; +} + +describe("buildStereoWav", () => { + it("writes a header a player will accept", () => { + const header = readWavHeader(buildStereoWav(new Int16Array(1600), new Int16Array(1600))); + expect(header.riff).toBe("RIFF"); + expect(header.wave).toBe("WAVE"); + expect(header.fmt).toBe("fmt "); + expect(header.data).toBe("data"); + // 1 = integer PCM. Anything else and players fall back to a byte stream. + expect(header.audioFormat).toBe(1); + expect(header.channels).toBe(2); + expect(header.sampleRate).toBe(RECORDING_SAMPLE_RATE); + expect(header.bitsPerSample).toBe(16); + expect(header.blockAlign).toBe(4); + expect(header.byteRate).toBe(RECORDING_SAMPLE_RATE * 4); + // The two size fields are what a player seeks on, so they have to agree + // with the real length rather than merely with each other. + expect(header.riffSize).toBe(header.byteLength - 8); + expect(header.dataSize).toBe(header.byteLength - 44); + }); + + it("puts the person on the left and the bot on the right", () => { + const user = Int16Array.from([100, 200, 300]); + const bot = Int16Array.from([-100, -200, -300]); + const buffer = buildStereoWav(user, bot); + const view = new DataView(buffer); + // Left is 44, then each frame is 4 bytes: left sample then right sample. + expect(view.getInt16(44, true)).toBe(100); + expect(view.getInt16(46, true)).toBe(-100); + expect(view.getInt16(48, true)).toBe(200); + expect(view.getInt16(50, true)).toBe(-200); + expect(view.getInt16(52, true)).toBe(300); + expect(view.getInt16(54, true)).toBe(-300); + }); + + it("pads the shorter track with silence instead of cutting the tail off", () => { + const buffer = buildStereoWav(Int16Array.from([1, 2, 3]), Int16Array.from([9])); + const view = new DataView(buffer); + // Frame 0 is the only one the bot spoke on. + expect(view.getInt16(44, true)).toBe(1); + expect(view.getInt16(46, true)).toBe(9); + // The person carries on alone for two more frames. + expect(view.getInt16(48, true)).toBe(2); + expect(view.getInt16(50, true)).toBe(0); + expect(view.getInt16(52, true)).toBe(3); + expect(view.getInt16(54, true)).toBe(0); + // And the file is as long as the longer of the two. + expect(readWavHeader(buffer).dataSize).toBe(3 * 4); + }); + + it("handles a call where only one side ever spoke", () => { + const botOnly = readWavHeader(buildStereoWav(new Int16Array(0), new Int16Array(800))); + expect(botOnly.channels).toBe(2); + expect(botOnly.dataSize).toBe(800 * 4); + }); +}); + +describe("createStsCallRecorder", () => { + it("records each side to its own channel across many frames", () => { + const recorder = createStsCallRecorder(); + // Interleaved the way a real call arrives them, not one side then the other. + for (let frame = 0; frame < 5; frame += 1) { + recorder.write("user", pcm(160, 1000 + frame)); + recorder.write("bot", pcm(160, -2000 - frame)); + } + const recording = recorder.stop(); + expect(recording).not.toBeNull(); + expect(recording!.durationSeconds).toBeCloseTo((5 * 160) / RECORDING_SAMPLE_RATE, 6); + + const view = new DataView(recording!.wav); + const leftFirst = view.getInt16(frameOffset(0), true); + const rightFirst = view.getInt16(frameOffset(0) + 2, true); + expect(leftFirst).toBe(1000); + expect(rightFirst).toBe(-2000); + // The last of the five frames written, on both channels. + expect(view.getInt16(frameOffset(4 * 160), true)).toBe(1004); + expect(view.getInt16(frameOffset(4 * 160) + 2, true)).toBe(-2004); + }); + + it("does not let a reused decode buffer rewrite earlier audio", () => { + // The audio paths reuse one buffer per chunk, so a recorder that kept the + // reference would record the final contents of every frame. + const recorder = createStsCallRecorder(); + const reused = new ArrayBuffer(160 * 2); + const reusedView = new DataView(reused); + reusedView.setInt16(0, 111, true); + recorder.write("user", reused); + for (let index = 1; index < 160; index += 1) reusedView.setInt16(index * 2, 222, true); + recorder.write("bot", pcm(160, 333)); + + const recording = recorder.stop(); + const view = new DataView(recording!.wav); + // The one sample that was 111 when the frame was written... + expect(view.getInt16(frameOffset(0), true)).toBe(111); + // ...and the 159 that were zero, never the 222 the buffer was refilled + // with a moment later. + expect(view.getInt16(frameOffset(159), true)).toBe(0); + expect(view.getInt16(frameOffset(0) + 2, true)).toBe(333); + }); + + it("reports a call where nobody spoke as no recording at all", () => { + // An empty WAV is a silent file the person has to open to discover is + // silent, which is worse than showing nothing. + expect(createStsCallRecorder().stop()).toBeNull(); + }); + + it("keeps the whole samples in a truncated frame and drops the half one", () => { + // A frame cut mid-sample means the sender's buffer was reused under it. + // The three complete samples are audio; the trailing byte is not a sample + // and must not be padded into one. + const recorder = createStsCallRecorder(); + const truncated = new ArrayBuffer(7); + const view = new DataView(truncated); + for (let index = 0; index < 3; index += 1) view.setInt16(index * 2, index + 1, true); + recorder.write("user", truncated); + const recording = recorder.stop(); + expect(readWavHeader(recording!.wav).dataSize).toBe(3 * 4); + expect(recorder.elapsedSeconds).toBeCloseTo(3 / RECORDING_SAMPLE_RATE, 6); + }); + + it("ignores a completely empty frame", () => { + const recorder = createStsCallRecorder(); + recorder.write("user", new ArrayBuffer(0)); + expect(recorder.stop()).toBeNull(); + }); + + it("is frozen once stopped, so a late frame cannot rewrite the file", () => { + const recorder = createStsCallRecorder(); + recorder.write("user", pcm(160, 5)); + const first = recorder.stop(); + recorder.write("bot", pcm(160, 9)); + const second = recorder.stop(); + // Same object, same bytes: the call view may stop a call more than once. + expect(second).toBe(first); + expect(readWavHeader(second!.wav).dataSize).toBe(160 * 4); + }); + + it("never throws into the call when a frame is not a buffer", () => { + const recorder = createStsCallRecorder(); + recorder.write("user", pcm(160, 1)); + expect(() => recorder.write("bot", null as unknown as ArrayBuffer)).not.toThrow(); + // The rest of the recording survives the bad frame. + expect(recorder.stop()).not.toBeNull(); + }); + + it("counts elapsed seconds as the longer of the two tracks", () => { + // The microphone streams continuously, so its track is what measures the + // call; the bot only writes while it speaks. The longer of the two is + // both the file's real length and the honest elapsed time. + const recorder = createStsCallRecorder(); + expect(recorder.elapsedSeconds).toBe(0); + recorder.write("user", pcm(RECORDING_SAMPLE_RATE, 0)); // one second + expect(recorder.elapsedSeconds).toBeCloseTo(1, 6); + // Half a second of bot does not shorten the call, and does not lengthen it. + recorder.write("bot", pcm(RECORDING_SAMPLE_RATE / 2, 0)); + expect(recorder.elapsedSeconds).toBeCloseTo(1, 6); + // A second and a half of microphone is a second and a half. + recorder.write("user", pcm(RECORDING_SAMPLE_RATE / 2, 0)); + expect(recorder.elapsedSeconds).toBeCloseTo(1.5, 6); + }); + + it("names the channels so the UI can say which speaker is which", () => { + const recording = createStsCallRecorder(); + recording.write("user", pcm(160, 1)); + expect(recording.stop()!.channels).toEqual({ left: "user", right: "bot" }); + }); +}); + +describe("resamplePcm16", () => { + it("halves the sample count when halving the rate", () => { + const input = new Int16Array(24_000); // one second at 24 kHz + expect(resamplePcm16(input, 24_000, 16_000).length).toBe(16_000); + }); + + it("keeps a ramp intact rather than playing it fast", () => { + // A ramp is the cheapest way to see speed wrong: written at the wrong rate + // it ends at a fraction of the value it should reach. 48 samples at + // 24 kHz is 32 samples at 16 kHz, and on a linear ramp the k-th output + // sample is the input at position k * 1.5. + const input = Int16Array.from({ length: 48 }, (_, index) => index * 100); + const out = resamplePcm16(input, 24_000, 16_000); + expect(out.length).toBe(32); + for (let index = 0; index < out.length; index += 1) { + expect(out[index]).toBe(Math.round(index * 1.5 * 100)); + } + }); + + it("returns the input untouched when the rate already matches", () => { + const input = Int16Array.from([1, 2, 3]); + expect(resamplePcm16(input, 16_000, 16_000)).toBe(input); + }); + + it("never produces a sample outside full scale", () => { + // Interpolating two full-scale values must not overflow the int16. + const input = new Int16Array(64).fill(32_767); + for (const sample of resamplePcm16(input, 24_000, 16_000)) { + expect(sample).toBeLessThanOrEqual(32_767); + expect(sample).toBeGreaterThanOrEqual(-32_768); + } + const negative = new Int16Array(64).fill(-32_768); + for (const sample of resamplePcm16(negative, 24_000, 16_000)) { + expect(sample).toBeGreaterThanOrEqual(-32_768); + } + }); +}); + +describe("a call where the two sides run at different rates", () => { + it("writes both into the file at one rate, so the bot is not sped up", () => { + // Gemini speaks back at 24 kHz while the microphone is 16 kHz. Writing + // both raw would play the bot fast and an octave high. + const recorder = createStsCallRecorder(); + recorder.write("user", pcm(16_000, 1000), 16_000); // one second + recorder.write("bot", pcm(24_000, -2000), 24_000); // one second, 1.5x samples + const recording = recorder.stop(); + expect(recording!.durationSeconds).toBeCloseTo(1, 3); + + const header = readWavHeader(recording!.wav); + // 16_000 frames at 16 kHz stereo is one second of a two-channel file. + expect(header.dataSize).toBe(16_000 * 4); + expect(header.sampleRate).toBe(16_000); + + const view = new DataView(recording!.wav); + // Both sides survive the resample, still on their own channel. + expect(view.getInt16(frameOffset(0), true)).toBe(1000); + expect(view.getInt16(frameOffset(0) + 2, true)).toBe(-2000); + }); + + it("counts a 24 kHz track in seconds, not in samples", () => { + // 24_000 samples is one second at 24 kHz and one and a half at 16 kHz. + const recorder = createStsCallRecorder(); + recorder.write("bot", pcm(24_000, 0), 24_000); + expect(recorder.elapsedSeconds).toBeCloseTo(1, 6); + }); +}); + +describe("recordingFileName", () => { + it("sorts by when the call happened", () => { + const name = recordingFileName(new Date(2026, 9, 14, 14, 5), "Forge"); + expect(name).toBe("call-2026-10-14_1405-Forge.wav"); + const earlier = recordingFileName(new Date(2026, 9, 14, 9, 30), "Forge"); + expect(earlier < name).toBe(true); + }); + + it("strips anything in a bot name that could become a path", () => { + // A bot name is set by a person, so it cannot be trusted as a file name. + const name = recordingFileName(new Date(2026, 0, 2, 3, 4), "../../etc/passwd"); + expect(name).toBe("call-2026-01-02_0304-etc-passwd.wav"); + expect(name).not.toContain("/"); + expect(name).not.toContain(".."); + }); + + it("falls back to a usable name when the bot name is only punctuation", () => { + expect(recordingFileName(new Date(2026, 0, 2, 3, 4), "///")).toBe("call-2026-01-02_0304-bot.wav"); + }); + + it("pads single-digit months, days, hours and minutes", () => { + expect(recordingFileName(new Date(2026, 0, 2, 3, 4), "Bot")).toBe("call-2026-01-02_0304-Bot.wav"); + }); +}); + +describe("isPastRetention", () => { + const now = new Date(2026, 9, 14, 12, 0, 0); + + it("keeps a recording right up to the retention window", () => { + const justInside = new Date(now.getTime() - (RECORDING_RETENTION_DAYS * 24 * 60 * 60 * 1000) + 1000); + expect(isPastRetention(justInside, now)).toBe(false); + }); + + it("deletes one once the window has passed", () => { + const justOutside = new Date(now.getTime() - (RECORDING_RETENTION_DAYS * 24 * 60 * 60 * 1000) - 1000); + expect(isPastRetention(justOutside, now)).toBe(true); + }); + + it("keeps a recording dated in the future rather than deleting it", () => { + // Clock skew must not eat someone's only copy of a call. + expect(isPastRetention(new Date(now.getTime() + 60_000), now)).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/lib/sts/recorder.ts b/src/lib/sts/recorder.ts new file mode 100644 index 000000000..eb3d6bc71 --- /dev/null +++ b/src/lib/sts/recorder.ts @@ -0,0 +1,267 @@ +// Stereo recording for an STS call. +// +// The two sides land on their own channel: **left is the person, right is the +// bot**. A mono mix is worse than no recording at all for the one thing a +// call recording is actually for, which is working out afterwards who said +// what — and the bot's own audio already reaches the microphone through the +// room, so a mix cannot even tell the two apart reliably. +// +// Both sides arrive as mono PCM16, but **not at the same rate**: the capture +// path resamples the microphone to `STS_INPUT_SAMPLE_RATE` (16 kHz) while the +// providers speak back at their own rate (Gemini is 24 kHz). One WAV has one +// sample rate, so each track is brought to the file's rate before they are +// interleaved — writing the 24 kHz track in beside 16 kHz audio would play +// the bot fast and an octave high, which is exactly the kind of defect that +// makes a recording useless for hearing what was actually said. +// +// The file is uncompressed WAV on purpose. This is off by default and exists +// mainly for debugging a call that went wrong; lossless means the recording is +// evidence rather than a lossy copy of it. It is also the only part of this +// that can be verified without a live microphone, which is worth a lot. The +// seam below is the place to wrap the result in Opus/WebM later. +// +// Retention is 30 days (`RECORDING_RETENTION_DAYS`). The person can always +// keep a copy somewhere they choose; this is what BotFleet forgets. + +/** Left channel is the person, right channel is the bot. */ +export type RecordingSide = "user" | "bot"; + +/** Sample rate of every frame handed to the recorder, matching the call path. */ +export const RECORDING_SAMPLE_RATE = 16_000; + +/** How long BotFleet keeps a recording before deleting it on its own. */ +export const RECORDING_RETENTION_DAYS = 30; + +/** What a finished recording is: a stereo WAV plus enough detail to name it. */ +export interface StsRecording { + /** A complete, playable stereo WAV file. */ + readonly wav: ArrayBuffer; + /** Seconds of audio, from the frame count rather than a wall clock. */ + readonly durationSeconds: number; + /** Which side of the file carries which speaker, for the UI to say out loud. */ + readonly channels: { left: RecordingSide; right: RecordingSide }; +} + +/** + * A live two-track recording. + * + * `write` is called from the audio path, so it must not allocate unboundedly + * and must never throw into the call: a recording failure degrades to no + * recording, never to a dropped call. + */ +export interface StsCallRecorder { + /** + * Append one mono PCM16 frame to that side's track. + * + * `sampleRate` is the rate of *that frame*, which differs per side and per + * provider; it is recorded so the track can be brought to the file's rate at + * the end. + */ + write(side: RecordingSide, pcm: ArrayBuffer, sampleRate?: number): void; + /** Close both tracks and produce the file. Idempotent. */ + stop(): StsRecording | null; + /** Seconds written so far, for the in-call readout. */ + readonly elapsedSeconds: number; +} + +/** + * Resample one PCM16 track to the file's rate. + * + * Linear interpolation, mirroring `pcm16FromFloat32` on the way in. It is not + * a high-quality resampler, and for a debugging recording that is the right + * trade: a resampler that is wrong in an interesting way costs more than the + * handful of aliasing artefacts a call about speech will ever show. + */ +export function resamplePcm16(input: Int16Array, from: number, to: number): Int16Array { + if (from === to || input.length === 0 || from <= 0 || to <= 0) return input; + const length = Math.max(1, Math.round((input.length * to) / from)); + const out = new Int16Array(length); + const scale = input.length / length; + for (let index = 0; index < length; index += 1) { + const position = index * scale; + const left = Math.min(input.length - 1, Math.floor(position)); + const right = Math.min(input.length - 1, left + 1); + const amount = position - left; + // Integer math so a 16-bit sample never passes through a float that could + // round a full-scale value into the wrong one. + out[index] = Math.round(input[left]! * (1 - amount) + input[right]! * amount); + } + return out; +} + +/** + * One side of the call, buffered as it arrives. + * + * Frames are kept as copies rather than references because the caller reuses + * its decode buffer between chunks; holding the reference would record + * whatever the buffer holds later. + */ +class Track { + private chunks: ArrayBuffer[] = []; + + private samples = 0; + + /** The rate the first frame arrived at; the track's rate for the call. */ + private rate = 0; + + get sampleCount(): number { + return this.samples; + } + + /** Seconds of audio in this track, in its own sample rate. */ + get seconds(): number { + return this.rate > 0 ? this.samples / this.rate : 0; + } + + append(pcm: ArrayBuffer, sampleRate: number): void { + if (pcm.byteLength === 0) return; + // An odd trailing byte is a truncated frame, not a half sample to guess at. + const usable = pcm.byteLength - (pcm.byteLength % 2); + if (usable <= 0) return; + const copy = new ArrayBuffer(usable); + new Uint8Array(copy).set(new Uint8Array(pcm, 0, usable)); + // The first frame decides the rate. A later frame at a different rate + // would be resampled against the wrong denominator, and in practice the + // provider cannot change rate mid-call, so this only ever records the truth. + if (this.rate === 0 && sampleRate > 0) this.rate = sampleRate; + this.chunks.push(copy); + this.samples += usable / 2; + } + + /** The track as one contiguous Int16 view. */ + view(): Int16Array { + const out = new Int16Array(this.samples); + let offset = 0; + for (const chunk of this.chunks) { + const view = new Int16Array(chunk); + out.set(view, offset); + offset += view.length; + } + return out; + } + + /** The track at `rate`, brought there from whatever it was captured at. */ + atRate(rate: number): Int16Array { + return resamplePcm16(this.view(), this.rate || rate, rate); + } +} + +/** + * Build a stereo WAV from two tracks, padding the shorter one with silence. + * + * The tracks are written from separate capture paths, so they are *nearly* the + * same length and never exactly: a frame can be dropped on either side. The + * file has one length, so the short track is padded with zeros at the end + * rather than truncated — losing the tail of what someone said is worse than + * a moment of silence, and the tail is the part a debugging recording is for. + */ +export function buildStereoWav( + user: Int16Array, + bot: Int16Array, + sampleRate = RECORDING_SAMPLE_RATE, +): ArrayBuffer { + const frames = Math.max(user.length, bot.length); + const bytesPerSample = 2; + const channelCount = 2; + const blockAlign = channelCount * bytesPerSample; + const dataBytes = frames * blockAlign; + const buffer = new ArrayBuffer(44 + dataBytes); + const view = new DataView(buffer); + + const text = (offset: number, value: string) => { + for (let index = 0; index < value.length; index += 1) view.setUint8(offset + index, value.charCodeAt(index)); + }; + + text(0, "RIFF"); + view.setUint32(4, 36 + dataBytes, true); + text(8, "WAVE"); + text(12, "fmt "); + // PCM header: 16 bytes of format, then the format itself. + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); // 1 = integer PCM, which is what PCM16 is + view.setUint16(22, channelCount, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * blockAlign, true); // byte rate + view.setUint16(32, blockAlign, true); + view.setUint16(34, 16, true); // bits per sample + text(36, "data"); + view.setUint32(40, dataBytes, true); + + // Interleaved, left then right, so a player pans each speaker apart. + let offset = 44; + for (let frame = 0; frame < frames; frame += 1) { + const left = frame < user.length ? user[frame]! : 0; + const right = frame < bot.length ? bot[frame]! : 0; + view.setInt16(offset, left, true); + view.setInt16(offset + 2, right, true); + offset += 4; + } + return buffer; +} + +/** + * Start a stereo recording. Returns null when there is nothing to record, so + * the call view can hide the control rather than offer a toggle that does + * nothing. + */ +export function createStsCallRecorder(): StsCallRecorder { + const tracks: Record = { user: new Track(), bot: new Track() }; + let closed: StsRecording | null = null; + + return { + write(side, pcm, sampleRate = RECORDING_SAMPLE_RATE) { + // After stop() the tracks are already frozen into a file; a late frame + // from a torn-down audio path must not reopen or corrupt them. + if (closed) return; + try { + tracks[side].append(pcm, sampleRate); + } catch { + // A recording is never worth failing a call over. + } + }, + stop() { + if (closed) return closed; + // Both tracks come out at the file's rate, so the interleave is a + // frame-for-frame alignment in time rather than a coincidence. + const user = tracks.user.atRate(RECORDING_SAMPLE_RATE); + const bot = tracks.bot.atRate(RECORDING_SAMPLE_RATE); + // A call where nobody spoke is not a recording, and an empty WAV would + // only produce a silent file the person has to open to discover. + if (user.length === 0 && bot.length === 0) return null; + const frames = Math.max(user.length, bot.length); + closed = { + wav: buildStereoWav(user, bot), + durationSeconds: frames / RECORDING_SAMPLE_RATE, + channels: { left: "user", right: "bot" }, + }; + return closed; + }, + get elapsedSeconds() { + return Math.max(tracks.user.seconds, tracks.bot.seconds); + }, + }; +} + +/** + * A file name for a recording, safe on every platform and stable enough to sort. + * + * The call is named for when it happened rather than a random id: the person + * looking for a call that went wrong at 2pm wants a 2pm file, and a sortable + * name is what makes "find it" work without opening each one. + */ +export function recordingFileName(startedAt: Date, botName: string): string { + const pad = (value: number) => String(value).padStart(2, "0"); + const stamp = + `${startedAt.getFullYear()}-${pad(startedAt.getMonth() + 1)}-${pad(startedAt.getDate())}` + + `_${pad(startedAt.getHours())}${pad(startedAt.getMinutes())}`; + // Anything that is not a letter, digit or dash could become a path separator + // or an extension, and a bot name comes from a person. + const safeBot = botName.replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "bot"; + return `call-${stamp}-${safeBot}.wav`; +} + +/** Whether a recording is old enough for BotFleet to delete it. */ +export function isPastRetention(recordedAt: Date, now: Date = new Date()): boolean { + const ageMs = now.getTime() - recordedAt.getTime(); + return ageMs >= RECORDING_RETENTION_DAYS * 24 * 60 * 60 * 1000; +} \ No newline at end of file From 9d35584447975c044f996920b558a4c0b4dcde01 Mon Sep 17 00:00:00 2001 From: "swu-wise[bot]" <344778455+swu-wise[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:07:54 +0000 Subject: [PATCH 2/8] Drop the chained type assertion in the recorder test to hold the lint baseline --- src/lib/sts/recorder.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/sts/recorder.test.ts b/src/lib/sts/recorder.test.ts index 9fdbab818..616751028 100644 --- a/src/lib/sts/recorder.test.ts +++ b/src/lib/sts/recorder.test.ts @@ -183,7 +183,8 @@ describe("createStsCallRecorder", () => { it("never throws into the call when a frame is not a buffer", () => { const recorder = createStsCallRecorder(); recorder.write("user", pcm(160, 1)); - expect(() => recorder.write("bot", null as unknown as ArrayBuffer)).not.toThrow(); + // Reflect.apply is untyped, so the bad frame needs no type assertion. + expect(() => Reflect.apply(recorder.write, recorder, ["bot", null])).not.toThrow(); // The rest of the recording survives the bad frame. expect(recorder.stop()).not.toBeNull(); }); From 0ccf563734b767bc92a4c3d58170e8cb5953e830 Mon Sep 17 00:00:00 2001 From: "swu-wise[bot]" <344778455+swu-wise[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:18:12 +0000 Subject: [PATCH 3/8] Recorder test: typed bad frame without Reflect.apply --- src/lib/sts/recorder.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/sts/recorder.test.ts b/src/lib/sts/recorder.test.ts index 616751028..cbbe522e9 100644 --- a/src/lib/sts/recorder.test.ts +++ b/src/lib/sts/recorder.test.ts @@ -183,8 +183,8 @@ describe("createStsCallRecorder", () => { it("never throws into the call when a frame is not a buffer", () => { const recorder = createStsCallRecorder(); recorder.write("user", pcm(160, 1)); - // Reflect.apply is untyped, so the bad frame needs no type assertion. - expect(() => Reflect.apply(recorder.write, recorder, ["bot", null])).not.toThrow(); + // SAFETY: a null frame is exactly the bad input this test feeds the recorder. + expect(() => recorder.write("bot", null as never)).not.toThrow(); // The rest of the recording survives the bad frame. expect(recorder.stop()).not.toBeNull(); }); From 98fb7693f1495e05d4daeb7dae4e5a81c8684446 Mon Sep 17 00:00:00 2001 From: "swu-wise[bot]" <344778455+swu-wise[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:52:16 +0000 Subject: [PATCH 4/8] STS recorder: shared clock with silence padding, required per-track rate, freeze on empty stop, release raw audio, typed-array WAV fill, unique call-id file names --- src/lib/sts/recorder.test.ts | 129 +++++++++++++++++++++++++++------ src/lib/sts/recorder.ts | 135 +++++++++++++++++++++++++---------- 2 files changed, 205 insertions(+), 59 deletions(-) diff --git a/src/lib/sts/recorder.test.ts b/src/lib/sts/recorder.test.ts index cbbe522e9..a32e781ba 100644 --- a/src/lib/sts/recorder.test.ts +++ b/src/lib/sts/recorder.test.ts @@ -105,8 +105,8 @@ describe("createStsCallRecorder", () => { const recorder = createStsCallRecorder(); // Interleaved the way a real call arrives them, not one side then the other. for (let frame = 0; frame < 5; frame += 1) { - recorder.write("user", pcm(160, 1000 + frame)); - recorder.write("bot", pcm(160, -2000 - frame)); + recorder.write("user", pcm(160, 1000 + frame), RECORDING_SAMPLE_RATE); + recorder.write("bot", pcm(160, -2000 - frame), RECORDING_SAMPLE_RATE); } const recording = recorder.stop(); expect(recording).not.toBeNull(); @@ -129,9 +129,9 @@ describe("createStsCallRecorder", () => { const reused = new ArrayBuffer(160 * 2); const reusedView = new DataView(reused); reusedView.setInt16(0, 111, true); - recorder.write("user", reused); + recorder.write("user", reused, RECORDING_SAMPLE_RATE); for (let index = 1; index < 160; index += 1) reusedView.setInt16(index * 2, 222, true); - recorder.write("bot", pcm(160, 333)); + recorder.write("bot", pcm(160, 333), RECORDING_SAMPLE_RATE); const recording = recorder.stop(); const view = new DataView(recording!.wav); @@ -157,7 +157,7 @@ describe("createStsCallRecorder", () => { const truncated = new ArrayBuffer(7); const view = new DataView(truncated); for (let index = 0; index < 3; index += 1) view.setInt16(index * 2, index + 1, true); - recorder.write("user", truncated); + recorder.write("user", truncated, RECORDING_SAMPLE_RATE); const recording = recorder.stop(); expect(readWavHeader(recording!.wav).dataSize).toBe(3 * 4); expect(recorder.elapsedSeconds).toBeCloseTo(3 / RECORDING_SAMPLE_RATE, 6); @@ -165,15 +165,15 @@ describe("createStsCallRecorder", () => { it("ignores a completely empty frame", () => { const recorder = createStsCallRecorder(); - recorder.write("user", new ArrayBuffer(0)); + recorder.write("user", new ArrayBuffer(0), RECORDING_SAMPLE_RATE); expect(recorder.stop()).toBeNull(); }); it("is frozen once stopped, so a late frame cannot rewrite the file", () => { const recorder = createStsCallRecorder(); - recorder.write("user", pcm(160, 5)); + recorder.write("user", pcm(160, 5), RECORDING_SAMPLE_RATE); const first = recorder.stop(); - recorder.write("bot", pcm(160, 9)); + recorder.write("bot", pcm(160, 9), RECORDING_SAMPLE_RATE); const second = recorder.stop(); // Same object, same bytes: the call view may stop a call more than once. expect(second).toBe(first); @@ -182,9 +182,10 @@ describe("createStsCallRecorder", () => { it("never throws into the call when a frame is not a buffer", () => { const recorder = createStsCallRecorder(); - recorder.write("user", pcm(160, 1)); - // SAFETY: a null frame is exactly the bad input this test feeds the recorder. - expect(() => recorder.write("bot", null as never)).not.toThrow(); + recorder.write("user", pcm(160, 1), RECORDING_SAMPLE_RATE); + // A null frame is exactly the bad input this test feeds the recorder, and the type check must still reject it. + // @ts-expect-error null is not an ArrayBuffer; the runtime guard is what is under test + expect(() => recorder.write("bot", null, RECORDING_SAMPLE_RATE)).not.toThrow(); // The rest of the recording survives the bad frame. expect(recorder.stop()).not.toBeNull(); }); @@ -195,19 +196,19 @@ describe("createStsCallRecorder", () => { // both the file's real length and the honest elapsed time. const recorder = createStsCallRecorder(); expect(recorder.elapsedSeconds).toBe(0); - recorder.write("user", pcm(RECORDING_SAMPLE_RATE, 0)); // one second + recorder.write("user", pcm(RECORDING_SAMPLE_RATE, 0), RECORDING_SAMPLE_RATE); // one second expect(recorder.elapsedSeconds).toBeCloseTo(1, 6); // Half a second of bot does not shorten the call, and does not lengthen it. - recorder.write("bot", pcm(RECORDING_SAMPLE_RATE / 2, 0)); + recorder.write("bot", pcm(RECORDING_SAMPLE_RATE / 2, 0), RECORDING_SAMPLE_RATE); expect(recorder.elapsedSeconds).toBeCloseTo(1, 6); // A second and a half of microphone is a second and a half. - recorder.write("user", pcm(RECORDING_SAMPLE_RATE / 2, 0)); + recorder.write("user", pcm(RECORDING_SAMPLE_RATE / 2, 0), RECORDING_SAMPLE_RATE); expect(recorder.elapsedSeconds).toBeCloseTo(1.5, 6); }); it("names the channels so the UI can say which speaker is which", () => { const recording = createStsCallRecorder(); - recording.write("user", pcm(160, 1)); + recording.write("user", pcm(160, 1), RECORDING_SAMPLE_RATE); expect(recording.stop()!.channels).toEqual({ left: "user", right: "bot" }); }); }); @@ -279,28 +280,110 @@ describe("a call where the two sides run at different rates", () => { }); }); +describe("a shared clock for both sides", () => { + const clock = () => { + let ms = 0; + return { now: () => ms, at: (value: number) => void (ms = value) }; + }; + + it("pads a late bot reply with silence so it lands after the speech it answers", () => { + const time = clock(); + const recorder = createStsCallRecorder({ now: time.now }); + // The person speaks for one second from the start; the bot first answers at 1.5 s. + time.at(0); + recorder.write("user", pcm(RECORDING_SAMPLE_RATE, 1000), RECORDING_SAMPLE_RATE); + time.at(1500); + recorder.write("bot", pcm(1600, -2000), RECORDING_SAMPLE_RATE); + const recording = recorder.stop(); + const view = new DataView(recording!.wav); + // The bot channel is silent for the first 1.5 s and then carries the reply. + expect(view.getInt16(frameOffset(0) + 2, true)).toBe(0); + expect(view.getInt16(frameOffset(24_000 - 1) + 2, true)).toBe(0); + expect(view.getInt16(frameOffset(24_000) + 2, true)).toBe(-2000); + // The person's channel ends at one second, the bot's reply at 1.6 s. + expect(view.getInt16(frameOffset(15_999), true)).toBe(1000); + expect(view.getInt16(frameOffset(16_000), true)).toBe(0); + expect(recording!.durationSeconds).toBeCloseTo(1.6, 6); + }); + + it("leaves small timing jitter alone and queues early frames behind what is there", () => { + const time = clock(); + const recorder = createStsCallRecorder({ now: time.now }); + time.at(0); + recorder.write("bot", pcm(1600, 5), RECORDING_SAMPLE_RATE); // 0.1 s + // The next frame arrives 30 ms late (inside the tolerance) and another arrives early. + time.at(130); + recorder.write("bot", pcm(1600, 6), RECORDING_SAMPLE_RATE); + time.at(10); + recorder.write("bot", pcm(1600, 7), RECORDING_SAMPLE_RATE); + const view = new DataView(recorder.stop()!.wav); + expect(view.getInt16(frameOffset(1600) + 2, true)).toBe(6); + expect(view.getInt16(frameOffset(3200) + 2, true)).toBe(7); + }); + + it("refuses a frame whose rate disagrees with its track and counts it", () => { + const recorder = createStsCallRecorder(); + recorder.write("bot", pcm(2400, 1), 24_000); + recorder.write("bot", pcm(1600, 9), 16_000); // would be mixed in at the wrong speed + expect(recorder.droppedFrames).toBe(1); + const recording = recorder.stop(); + // Only the 24 kHz frame (0.1 s) is in the file. + expect(recording!.durationSeconds).toBeCloseTo(0.1, 3); + }); +}); + +describe("stopping", () => { + it("freezes an empty recorder too, so a late frame cannot create a recording after the call ended", () => { + const recorder = createStsCallRecorder(); + expect(recorder.stop()).toBeNull(); + recorder.write("user", pcm(160, 5), RECORDING_SAMPLE_RATE); + expect(recorder.stop()).toBeNull(); + expect(recorder.elapsedSeconds).toBe(0); + }); + + it("keeps reporting the call's length after the raw audio is released", () => { + const recorder = createStsCallRecorder(); + recorder.write("user", pcm(RECORDING_SAMPLE_RATE, 1), RECORDING_SAMPLE_RATE); + const recording = recorder.stop(); + expect(recorder.elapsedSeconds).toBeCloseTo(1, 6); + expect(recorder.stop()).toBe(recording); + }); +}); + describe("recordingFileName", () => { it("sorts by when the call happened", () => { - const name = recordingFileName(new Date(2026, 9, 14, 14, 5), "Forge"); - expect(name).toBe("call-2026-10-14_1405-Forge.wav"); - const earlier = recordingFileName(new Date(2026, 9, 14, 9, 30), "Forge"); + const name = recordingFileName(new Date(2026, 9, 14, 14, 5, 7), "Forge", "c1"); + expect(name).toBe("call-2026-10-14-140507-Forge-c1.wav"); + const earlier = recordingFileName(new Date(2026, 9, 14, 9, 30, 0), "Forge", "c1"); expect(earlier < name).toBe(true); }); it("strips anything in a bot name that could become a path", () => { // A bot name is set by a person, so it cannot be trusted as a file name. - const name = recordingFileName(new Date(2026, 0, 2, 3, 4), "../../etc/passwd"); - expect(name).toBe("call-2026-01-02_0304-etc-passwd.wav"); + const name = recordingFileName(new Date(2026, 0, 2, 3, 4, 5), "../../etc/passwd", "c1"); + expect(name).toBe("call-2026-01-02-030405-etc-passwd-c1.wav"); expect(name).not.toContain("/"); expect(name).not.toContain(".."); }); it("falls back to a usable name when the bot name is only punctuation", () => { - expect(recordingFileName(new Date(2026, 0, 2, 3, 4), "///")).toBe("call-2026-01-02_0304-bot.wav"); + expect(recordingFileName(new Date(2026, 0, 2, 3, 4, 5), "///", "c1")).toBe("call-2026-01-02-030405-bot-c1.wav"); + }); + + it("never gives two calls the same name: the call id ends it", () => { + const at = new Date(2026, 0, 2, 3, 4, 5); + expect(recordingFileName(at, "Bot", "a1")).not.toBe(recordingFileName(at, "Bot", "b2")); + // Bot names that normalise to the same text are told apart by the id too. + expect(recordingFileName(at, "A B", "a1")).not.toBe(recordingFileName(at, "A_B", "b2")); + }); + + it("only uses characters the attachment routes serve (no underscore)", () => { + const name = recordingFileName(new Date(2026, 0, 2, 3, 4, 5), "Weird_Name!", "id_9"); + expect(name).toMatch(/^[A-Za-z0-9-]+\.[A-Za-z0-9]{1,8}$/); }); - it("pads single-digit months, days, hours and minutes", () => { - expect(recordingFileName(new Date(2026, 0, 2, 3, 4), "Bot")).toBe("call-2026-01-02_0304-Bot.wav"); + it("pads single-digit months, days, hours, minutes and seconds", () => { + expect(recordingFileName(new Date(2026, 0, 2, 3, 4, 5), "Bot", "c1")).toBe("call-2026-01-02-030405-Bot-c1.wav"); }); }); diff --git a/src/lib/sts/recorder.ts b/src/lib/sts/recorder.ts index eb3d6bc71..704b92edd 100644 --- a/src/lib/sts/recorder.ts +++ b/src/lib/sts/recorder.ts @@ -32,6 +32,17 @@ export const RECORDING_SAMPLE_RATE = 16_000; /** How long BotFleet keeps a recording before deleting it on its own. */ export const RECORDING_RETENTION_DAYS = 30; +/** + * A side that has been silent for longer than this is padded with silence up to + * the moment its next frame arrived. Smaller gaps are timing jitter between + * frames and are left alone, so the audio is not chopped up; the two sides can + * therefore differ by up to this much in the file. + */ +const MAX_UNPADDED_GAP_SECONDS = 0.1; + +/** Whether this machine stores a 16-bit sample low byte first, as a WAV file does. */ +const LITTLE_ENDIAN = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1; + /** What a finished recording is: a stereo WAV plus enough detail to name it. */ export interface StsRecording { /** A complete, playable stereo WAV file. */ @@ -53,13 +64,19 @@ export interface StsCallRecorder { /** * Append one mono PCM16 frame to that side's track. * - * `sampleRate` is the rate of *that frame*, which differs per side and per - * provider; it is recorded so the track can be brought to the file's rate at - * the end. + * `sampleRate` is required and is the rate of *that frame*, which differs per + * side and per provider (the microphone is 16 kHz, Gemini speaks at 24 kHz). + * The first frame sets the track's rate; a later frame at another rate is + * refused and counted in `droppedFrames`, because it would be mixed in at the + * wrong speed. Each frame is also placed on a shared clock (see + * `createStsCallRecorder`), so a side that pauses is padded with silence + * instead of sliding earlier than the other. */ - write(side: RecordingSide, pcm: ArrayBuffer, sampleRate?: number): void; + write(side: RecordingSide, pcm: ArrayBuffer, sampleRate: number): void; /** Close both tracks and produce the file. Idempotent. */ stop(): StsRecording | null; + /** Frames refused because their rate disagreed with their track's rate. */ + readonly droppedFrames: number; /** Seconds written so far, for the in-call readout. */ readonly elapsedSeconds: number; } @@ -113,19 +130,35 @@ class Track { return this.rate > 0 ? this.samples / this.rate : 0; } - append(pcm: ArrayBuffer, sampleRate: number): void { - if (pcm.byteLength === 0) return; + /** + * Add one frame that arrived `atSeconds` after the recording started. + * Returns false when the frame was refused for disagreeing with the track's + * rate. Silence is inserted first when the frame arrives well after the + * audio already held ends, so the track stays on the shared clock; a frame + * that arrives early (a provider sending faster than real time) simply + * queues behind what is there, the way playback does. + */ + append(pcm: ArrayBuffer, sampleRate: number, atSeconds: number): boolean { + if (pcm.byteLength === 0) return true; // An odd trailing byte is a truncated frame, not a half sample to guess at. const usable = pcm.byteLength - (pcm.byteLength % 2); - if (usable <= 0) return; + if (usable <= 0) return true; + if (!(sampleRate > 0)) return false; + // The first frame decides the rate. A frame at another rate would be + // resampled against the wrong denominator, so it is refused, not mixed in. + if (this.rate !== 0 && sampleRate !== this.rate) return false; const copy = new ArrayBuffer(usable); new Uint8Array(copy).set(new Uint8Array(pcm, 0, usable)); - // The first frame decides the rate. A later frame at a different rate - // would be resampled against the wrong denominator, and in practice the - // provider cannot change rate mid-call, so this only ever records the truth. - if (this.rate === 0 && sampleRate > 0) this.rate = sampleRate; + if (this.rate === 0) this.rate = sampleRate; + const target = Math.round(Math.max(0, atSeconds) * this.rate); + const gap = target - this.samples; + if (gap > this.rate * MAX_UNPADDED_GAP_SECONDS) { + this.chunks.push(new ArrayBuffer(gap * 2)); + this.samples += gap; + } this.chunks.push(copy); this.samples += usable / 2; + return true; } /** The track as one contiguous Int16 view. */ @@ -144,6 +177,11 @@ class Track { atRate(rate: number): Int16Array { return resamplePcm16(this.view(), this.rate || rate, rate); } + + /** Drop the buffered frames once the file exists; the counts stay for the readout. */ + release(): void { + this.chunks = []; + } } /** @@ -188,39 +226,53 @@ export function buildStereoWav( view.setUint32(40, dataBytes, true); // Interleaved, left then right, so a player pans each speaker apart. - let offset = 44; - for (let frame = 0; frame < frames; frame += 1) { - const left = frame < user.length ? user[frame]! : 0; - const right = frame < bot.length ? bot[frame]! : 0; - view.setInt16(offset, left, true); - view.setInt16(offset + 2, right, true); - offset += 4; + if (LITTLE_ENDIAN) { + // The data starts at byte 44, which is 2-byte aligned, so a typed view fills it directly. + const samples = new Int16Array(buffer, 44, frames * channelCount); + for (let frame = 0; frame < frames; frame += 1) { + samples[frame * 2] = frame < user.length ? user[frame]! : 0; + samples[frame * 2 + 1] = frame < bot.length ? bot[frame]! : 0; + } + } else { + let offset = 44; + for (let frame = 0; frame < frames; frame += 1) { + view.setInt16(offset, frame < user.length ? user[frame]! : 0, true); + view.setInt16(offset + 2, frame < bot.length ? bot[frame]! : 0, true); + offset += 4; + } } return buffer; } /** - * Start a stereo recording. Returns null when there is nothing to record, so - * the call view can hide the control rather than offer a toggle that does - * nothing. + * Start a stereo recording. + * + * Both tracks are placed on one clock that starts here, so the person's speech + * and the bot's reply line up in the file the way they happened. `now` is that + * clock in milliseconds and exists so tests can drive it. */ -export function createStsCallRecorder(): StsCallRecorder { +export function createStsCallRecorder(options: { now?: () => number } = {}): StsCallRecorder { + const now = options.now ?? (() => Date.now()); + const origin = now(); const tracks: Record = { user: new Track(), bot: new Track() }; let closed: StsRecording | null = null; + let stopped = false; + let dropped = 0; return { - write(side, pcm, sampleRate = RECORDING_SAMPLE_RATE) { - // After stop() the tracks are already frozen into a file; a late frame - // from a torn-down audio path must not reopen or corrupt them. - if (closed) return; + write(side, pcm, sampleRate) { + // After stop() the call is over, whether or not anything was recorded; a + // late frame from a torn-down audio path must not reopen the recording. + if (stopped) return; try { - tracks[side].append(pcm, sampleRate); + if (!tracks[side].append(pcm, sampleRate, (now() - origin) / 1000)) dropped += 1; } catch { // A recording is never worth failing a call over. } }, stop() { - if (closed) return closed; + if (stopped) return closed; + stopped = true; // Both tracks come out at the file's rate, so the interleave is a // frame-for-frame alignment in time rather than a coincidence. const user = tracks.user.atRate(RECORDING_SAMPLE_RATE); @@ -234,8 +286,14 @@ export function createStsCallRecorder(): StsCallRecorder { durationSeconds: frames / RECORDING_SAMPLE_RATE, channels: { left: "user", right: "bot" }, }; + // The raw mono audio is no longer needed once the file exists. + tracks.user.release(); + tracks.bot.release(); return closed; }, + get droppedFrames() { + return dropped; + }, get elapsedSeconds() { return Math.max(tracks.user.seconds, tracks.bot.seconds); }, @@ -245,19 +303,24 @@ export function createStsCallRecorder(): StsCallRecorder { /** * A file name for a recording, safe on every platform and stable enough to sort. * - * The call is named for when it happened rather than a random id: the person - * looking for a call that went wrong at 2pm wants a 2pm file, and a sortable - * name is what makes "find it" work without opening each one. + * The call is named for when it happened (to the second) rather than a random + * id: the person looking for a call that went wrong at 2pm wants a 2pm file, + * and a sortable name is what makes "find it" work without opening each one. + * The call's own id ends the name, so two calls in the same second, or with bot + * names that normalise to the same text, never share a file. Only letters, + * digits and dashes are used, which is what the attachment routes serve. */ -export function recordingFileName(startedAt: Date, botName: string): string { - const pad = (value: number) => String(value).padStart(2, "0"); +export function recordingFileName(startedAt: Date, botName: string, callId: string): string { + const pad = (value: number, width = 2) => String(value).padStart(width, "0"); const stamp = `${startedAt.getFullYear()}-${pad(startedAt.getMonth() + 1)}-${pad(startedAt.getDate())}` + - `_${pad(startedAt.getHours())}${pad(startedAt.getMinutes())}`; + `-${pad(startedAt.getHours())}${pad(startedAt.getMinutes())}${pad(startedAt.getSeconds())}`; // Anything that is not a letter, digit or dash could become a path separator // or an extension, and a bot name comes from a person. - const safeBot = botName.replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "bot"; - return `call-${stamp}-${safeBot}.wav`; + const clean = (value: string) => value.replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-+|-+$/g, ""); + const safeBot = clean(botName) || "bot"; + const safeId = clean(callId).slice(0, 24) || pad(startedAt.getMilliseconds(), 3); + return `call-${stamp}-${safeBot}-${safeId}.wav`; } /** Whether a recording is old enough for BotFleet to delete it. */ From 3516afe95e00dc993f7907ec46d1935df4ab15cd Mon Sep 17 00:00:00 2001 From: "swu-wise[bot]" <344778455+swu-wise[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:59:43 +0000 Subject: [PATCH 5/8] STS recorder: cap each track at one hour (configurable), refuse and count frames past it, no unbounded silence allocation --- src/lib/sts/recorder.test.ts | 19 +++++++++++++++++++ src/lib/sts/recorder.ts | 25 ++++++++++++++++++------- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/lib/sts/recorder.test.ts b/src/lib/sts/recorder.test.ts index a32e781ba..a51f5d3fa 100644 --- a/src/lib/sts/recorder.test.ts +++ b/src/lib/sts/recorder.test.ts @@ -332,6 +332,25 @@ describe("a shared clock for both sides", () => { }); }); +describe("bounded memory", () => { + it("refuses frames past the track cap and counts them, keeping what fit", () => { + const recorder = createStsCallRecorder({ maxTrackSeconds: 1 }); + recorder.write("user", pcm(RECORDING_SAMPLE_RATE, 1), RECORDING_SAMPLE_RATE); // exactly 1 s + recorder.write("user", pcm(160, 2), RECORDING_SAMPLE_RATE); // over the cap + expect(recorder.droppedFrames).toBe(1); + expect(recorder.stop()!.durationSeconds).toBeCloseTo(1, 6); + }); + + it("does not let a huge clock jump allocate a huge silence block", () => { + let ms = 0; + const recorder = createStsCallRecorder({ now: () => ms, maxTrackSeconds: 5 }); + ms = 10 * 60 * 60 * 1000; + recorder.write("bot", pcm(160, 3), RECORDING_SAMPLE_RATE); + expect(recorder.droppedFrames).toBe(1); + expect(recorder.stop()).toBeNull(); + }); +}); + describe("stopping", () => { it("freezes an empty recorder too, so a late frame cannot create a recording after the call ended", () => { const recorder = createStsCallRecorder(); diff --git a/src/lib/sts/recorder.ts b/src/lib/sts/recorder.ts index 704b92edd..9bfb9a94e 100644 --- a/src/lib/sts/recorder.ts +++ b/src/lib/sts/recorder.ts @@ -106,6 +106,9 @@ export function resamplePcm16(input: Int16Array, from: number, to: number): Int1 return out; } +/** Longest a single track may grow: one hour, about 115 MB at 16 kHz and 173 MB at 24 kHz. */ +export const MAX_TRACK_SECONDS = 3600; + /** * One side of the call, buffered as it arrives. * @@ -114,6 +117,8 @@ export function resamplePcm16(input: Int16Array, from: number, to: number): Int1 * whatever the buffer holds later. */ class Track { + constructor(private readonly maxSeconds: number) {} + private chunks: ArrayBuffer[] = []; private samples = 0; @@ -147,14 +152,19 @@ class Track { // The first frame decides the rate. A frame at another rate would be // resampled against the wrong denominator, so it is refused, not mixed in. if (this.rate !== 0 && sampleRate !== this.rate) return false; + const rate = this.rate === 0 ? sampleRate : this.rate; + const target = Math.round(Math.max(0, atSeconds) * rate); + const gap = target - this.samples; + const padding = gap > rate * MAX_UNPADDED_GAP_SECONDS ? gap : 0; + // Bounded memory: past the cap the frame is refused (and counted), so a call + // that never ends cannot grow the recording without limit. + if ((this.samples + padding + usable / 2) / rate > this.maxSeconds) return false; const copy = new ArrayBuffer(usable); new Uint8Array(copy).set(new Uint8Array(pcm, 0, usable)); if (this.rate === 0) this.rate = sampleRate; - const target = Math.round(Math.max(0, atSeconds) * this.rate); - const gap = target - this.samples; - if (gap > this.rate * MAX_UNPADDED_GAP_SECONDS) { - this.chunks.push(new ArrayBuffer(gap * 2)); - this.samples += gap; + if (padding > 0) { + this.chunks.push(new ArrayBuffer(padding * 2)); + this.samples += padding; } this.chunks.push(copy); this.samples += usable / 2; @@ -251,10 +261,11 @@ export function buildStereoWav( * and the bot's reply line up in the file the way they happened. `now` is that * clock in milliseconds and exists so tests can drive it. */ -export function createStsCallRecorder(options: { now?: () => number } = {}): StsCallRecorder { +export function createStsCallRecorder(options: { now?: () => number; maxTrackSeconds?: number } = {}): StsCallRecorder { const now = options.now ?? (() => Date.now()); const origin = now(); - const tracks: Record = { user: new Track(), bot: new Track() }; + const maxTrackSeconds = options.maxTrackSeconds ?? MAX_TRACK_SECONDS; + const tracks: Record = { user: new Track(maxTrackSeconds), bot: new Track(maxTrackSeconds) }; let closed: StsRecording | null = null; let stopped = false; let dropped = 0; From 82a9a9e316edd7ba5d8213d780e6dbfd909c5229 Mon Sep 17 00:00:00 2001 From: "swu-wise[bot]" <344778455+swu-wise[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 05:24:46 +0000 Subject: [PATCH 6/8] STS recorder review fixes: bot-only gap padding, silence as counts, finite cap validation, release before WAV build, build failures contained, cappedFrames, monotonic default clock, fake clocks in tests --- src/lib/sts/recorder.test.ts | 73 +++++++++++++++++----- src/lib/sts/recorder.ts | 115 ++++++++++++++++++++++++----------- 2 files changed, 137 insertions(+), 51 deletions(-) diff --git a/src/lib/sts/recorder.test.ts b/src/lib/sts/recorder.test.ts index a51f5d3fa..cee1aef12 100644 --- a/src/lib/sts/recorder.test.ts +++ b/src/lib/sts/recorder.test.ts @@ -102,7 +102,7 @@ describe("buildStereoWav", () => { describe("createStsCallRecorder", () => { it("records each side to its own channel across many frames", () => { - const recorder = createStsCallRecorder(); + const recorder = createStsCallRecorder({ now: () => 0 }); // Interleaved the way a real call arrives them, not one side then the other. for (let frame = 0; frame < 5; frame += 1) { recorder.write("user", pcm(160, 1000 + frame), RECORDING_SAMPLE_RATE); @@ -125,7 +125,7 @@ describe("createStsCallRecorder", () => { it("does not let a reused decode buffer rewrite earlier audio", () => { // The audio paths reuse one buffer per chunk, so a recorder that kept the // reference would record the final contents of every frame. - const recorder = createStsCallRecorder(); + const recorder = createStsCallRecorder({ now: () => 0 }); const reused = new ArrayBuffer(160 * 2); const reusedView = new DataView(reused); reusedView.setInt16(0, 111, true); @@ -146,14 +146,14 @@ describe("createStsCallRecorder", () => { it("reports a call where nobody spoke as no recording at all", () => { // An empty WAV is a silent file the person has to open to discover is // silent, which is worse than showing nothing. - expect(createStsCallRecorder().stop()).toBeNull(); + expect(createStsCallRecorder({ now: () => 0 }).stop()).toBeNull(); }); it("keeps the whole samples in a truncated frame and drops the half one", () => { // A frame cut mid-sample means the sender's buffer was reused under it. // The three complete samples are audio; the trailing byte is not a sample // and must not be padded into one. - const recorder = createStsCallRecorder(); + const recorder = createStsCallRecorder({ now: () => 0 }); const truncated = new ArrayBuffer(7); const view = new DataView(truncated); for (let index = 0; index < 3; index += 1) view.setInt16(index * 2, index + 1, true); @@ -164,13 +164,13 @@ describe("createStsCallRecorder", () => { }); it("ignores a completely empty frame", () => { - const recorder = createStsCallRecorder(); + const recorder = createStsCallRecorder({ now: () => 0 }); recorder.write("user", new ArrayBuffer(0), RECORDING_SAMPLE_RATE); expect(recorder.stop()).toBeNull(); }); it("is frozen once stopped, so a late frame cannot rewrite the file", () => { - const recorder = createStsCallRecorder(); + const recorder = createStsCallRecorder({ now: () => 0 }); recorder.write("user", pcm(160, 5), RECORDING_SAMPLE_RATE); const first = recorder.stop(); recorder.write("bot", pcm(160, 9), RECORDING_SAMPLE_RATE); @@ -181,7 +181,7 @@ describe("createStsCallRecorder", () => { }); it("never throws into the call when a frame is not a buffer", () => { - const recorder = createStsCallRecorder(); + const recorder = createStsCallRecorder({ now: () => 0 }); recorder.write("user", pcm(160, 1), RECORDING_SAMPLE_RATE); // A null frame is exactly the bad input this test feeds the recorder, and the type check must still reject it. // @ts-expect-error null is not an ArrayBuffer; the runtime guard is what is under test @@ -194,7 +194,7 @@ describe("createStsCallRecorder", () => { // The microphone streams continuously, so its track is what measures the // call; the bot only writes while it speaks. The longer of the two is // both the file's real length and the honest elapsed time. - const recorder = createStsCallRecorder(); + const recorder = createStsCallRecorder({ now: () => 0 }); expect(recorder.elapsedSeconds).toBe(0); recorder.write("user", pcm(RECORDING_SAMPLE_RATE, 0), RECORDING_SAMPLE_RATE); // one second expect(recorder.elapsedSeconds).toBeCloseTo(1, 6); @@ -207,7 +207,7 @@ describe("createStsCallRecorder", () => { }); it("names the channels so the UI can say which speaker is which", () => { - const recording = createStsCallRecorder(); + const recording = createStsCallRecorder({ now: () => 0 }); recording.write("user", pcm(160, 1), RECORDING_SAMPLE_RATE); expect(recording.stop()!.channels).toEqual({ left: "user", right: "bot" }); }); @@ -255,7 +255,7 @@ describe("a call where the two sides run at different rates", () => { it("writes both into the file at one rate, so the bot is not sped up", () => { // Gemini speaks back at 24 kHz while the microphone is 16 kHz. Writing // both raw would play the bot fast and an octave high. - const recorder = createStsCallRecorder(); + const recorder = createStsCallRecorder({ now: () => 0 }); recorder.write("user", pcm(16_000, 1000), 16_000); // one second recorder.write("bot", pcm(24_000, -2000), 24_000); // one second, 1.5x samples const recording = recorder.stop(); @@ -274,7 +274,7 @@ describe("a call where the two sides run at different rates", () => { it("counts a 24 kHz track in seconds, not in samples", () => { // 24_000 samples is one second at 24 kHz and one and a half at 16 kHz. - const recorder = createStsCallRecorder(); + const recorder = createStsCallRecorder({ now: () => 0 }); recorder.write("bot", pcm(24_000, 0), 24_000); expect(recorder.elapsedSeconds).toBeCloseTo(1, 6); }); @@ -322,7 +322,7 @@ describe("a shared clock for both sides", () => { }); it("refuses a frame whose rate disagrees with its track and counts it", () => { - const recorder = createStsCallRecorder(); + const recorder = createStsCallRecorder({ now: () => 0 }); recorder.write("bot", pcm(2400, 1), 24_000); recorder.write("bot", pcm(1600, 9), 16_000); // would be mixed in at the wrong speed expect(recorder.droppedFrames).toBe(1); @@ -334,7 +334,7 @@ describe("a shared clock for both sides", () => { describe("bounded memory", () => { it("refuses frames past the track cap and counts them, keeping what fit", () => { - const recorder = createStsCallRecorder({ maxTrackSeconds: 1 }); + const recorder = createStsCallRecorder({ now: () => 0, maxTrackSeconds: 1 }); recorder.write("user", pcm(RECORDING_SAMPLE_RATE, 1), RECORDING_SAMPLE_RATE); // exactly 1 s recorder.write("user", pcm(160, 2), RECORDING_SAMPLE_RATE); // over the cap expect(recorder.droppedFrames).toBe(1); @@ -351,9 +351,52 @@ describe("bounded memory", () => { }); }); +describe("review follow-ups", () => { + it("never pads the microphone track: its frames are continuous, so a late one is queued, not split", () => { + let ms = 0; + const recorder = createStsCallRecorder({ now: () => ms }); + recorder.write("user", pcm(1600, 1), RECORDING_SAMPLE_RATE); + ms = 5000; // a late mic frame (scheduling stall) + recorder.write("user", pcm(1600, 2), RECORDING_SAMPLE_RATE); + expect(recorder.stop()!.durationSeconds).toBeCloseTo(0.2, 6); + }); + + it("falls back to the default cap for NaN, zero or negative values instead of disabling or discarding", () => { + for (const bad of [Number.NaN, 0, -5, Number.POSITIVE_INFINITY]) { + const recorder = createStsCallRecorder({ now: () => 0, maxTrackSeconds: bad }); + recorder.write("user", pcm(1600, 1), RECORDING_SAMPLE_RATE); + expect(recorder.droppedFrames).toBe(0); + expect(recorder.stop()!.durationSeconds).toBeCloseTo(0.1, 6); + } + }); + + it("counts cap refusals separately from the rate refusals in droppedFrames", () => { + const recorder = createStsCallRecorder({ now: () => 0, maxTrackSeconds: 1 }); + recorder.write("bot", pcm(24_000, 1), 24_000); // fills the cap + recorder.write("bot", pcm(240, 1), 24_000); // over the cap + recorder.write("bot", pcm(160, 1), 16_000); // wrong rate + expect(recorder.droppedFrames).toBe(2); + expect(recorder.cappedFrames).toBe(1); + }); + + it("keeps a long provider pause as a silence count and still builds the right file", () => { + let ms = 0; + const recorder = createStsCallRecorder({ now: () => ms }); + recorder.write("bot", pcm(160, 4), RECORDING_SAMPLE_RATE); + ms = 120_000; // two minutes of provider silence + recorder.write("bot", pcm(160, 9), RECORDING_SAMPLE_RATE); + const recording = recorder.stop()!; + expect(recording.durationSeconds).toBeCloseTo(120.01, 3); + const view = new DataView(recording.wav); + expect(view.getInt16(frameOffset(0) + 2, true)).toBe(4); + expect(view.getInt16(frameOffset(160) + 2, true)).toBe(0); + expect(view.getInt16(frameOffset(120 * RECORDING_SAMPLE_RATE) + 2, true)).toBe(9); + }); +}); + describe("stopping", () => { it("freezes an empty recorder too, so a late frame cannot create a recording after the call ended", () => { - const recorder = createStsCallRecorder(); + const recorder = createStsCallRecorder({ now: () => 0 }); expect(recorder.stop()).toBeNull(); recorder.write("user", pcm(160, 5), RECORDING_SAMPLE_RATE); expect(recorder.stop()).toBeNull(); @@ -361,7 +404,7 @@ describe("stopping", () => { }); it("keeps reporting the call's length after the raw audio is released", () => { - const recorder = createStsCallRecorder(); + const recorder = createStsCallRecorder({ now: () => 0 }); recorder.write("user", pcm(RECORDING_SAMPLE_RATE, 1), RECORDING_SAMPLE_RATE); const recording = recorder.stop(); expect(recorder.elapsedSeconds).toBeCloseTo(1, 6); diff --git a/src/lib/sts/recorder.ts b/src/lib/sts/recorder.ts index 9bfb9a94e..974ddd65e 100644 --- a/src/lib/sts/recorder.ts +++ b/src/lib/sts/recorder.ts @@ -68,15 +68,18 @@ export interface StsCallRecorder { * side and per provider (the microphone is 16 kHz, Gemini speaks at 24 kHz). * The first frame sets the track's rate; a later frame at another rate is * refused and counted in `droppedFrames`, because it would be mixed in at the - * wrong speed. Each frame is also placed on a shared clock (see - * `createStsCallRecorder`), so a side that pauses is padded with silence - * instead of sliding earlier than the other. + * wrong speed. Frames are placed on a shared clock (see + * `createStsCallRecorder`): the bot track, which only has frames while the + * provider speaks, is padded with silence after a pause so it does not slide + * earlier than the always-on microphone track. */ write(side: RecordingSide, pcm: ArrayBuffer, sampleRate: number): void; /** Close both tracks and produce the file. Idempotent. */ stop(): StsRecording | null; - /** Frames refused because their rate disagreed with their track's rate. */ + /** Every frame refused: a bad or disagreeing sample rate, or a track at its cap. */ readonly droppedFrames: number; + /** The part of `droppedFrames` refused because the track had reached its cap. */ + readonly cappedFrames: number; /** Seconds written so far, for the in-call readout. */ readonly elapsedSeconds: number; } @@ -109,6 +112,8 @@ export function resamplePcm16(input: Int16Array, from: number, to: number): Int1 /** Longest a single track may grow: one hour, about 115 MB at 16 kHz and 173 MB at 24 kHz. */ export const MAX_TRACK_SECONDS = 3600; +type AppendResult = "ok" | "rate" | "cap"; + /** * One side of the call, buffered as it arrives. * @@ -117,9 +122,19 @@ export const MAX_TRACK_SECONDS = 3600; * whatever the buffer holds later. */ class Track { - constructor(private readonly maxSeconds: number) {} + /** + * `padGaps` is true for a track that only has frames while someone is + * speaking (the provider's audio). The microphone forwards every captured + * frame, so it is continuous and is never padded: padding it would split the + * person's speech on rounding jitter. + */ + constructor( + private readonly maxSeconds: number, + private readonly padGaps: boolean, + ) {} - private chunks: ArrayBuffer[] = []; + /** Audio frames, or a number of silent samples that is never materialised. */ + private chunks: Array = []; private samples = 0; @@ -143,32 +158,33 @@ class Track { * that arrives early (a provider sending faster than real time) simply * queues behind what is there, the way playback does. */ - append(pcm: ArrayBuffer, sampleRate: number, atSeconds: number): boolean { - if (pcm.byteLength === 0) return true; + append(pcm: ArrayBuffer, sampleRate: number, atSeconds: number): AppendResult { + if (pcm.byteLength === 0) return "ok"; // An odd trailing byte is a truncated frame, not a half sample to guess at. const usable = pcm.byteLength - (pcm.byteLength % 2); - if (usable <= 0) return true; - if (!(sampleRate > 0)) return false; + if (usable <= 0) return "ok"; + if (!(sampleRate > 0)) return "rate"; // The first frame decides the rate. A frame at another rate would be // resampled against the wrong denominator, so it is refused, not mixed in. - if (this.rate !== 0 && sampleRate !== this.rate) return false; + if (this.rate !== 0 && sampleRate !== this.rate) return "rate"; const rate = this.rate === 0 ? sampleRate : this.rate; const target = Math.round(Math.max(0, atSeconds) * rate); const gap = target - this.samples; - const padding = gap > rate * MAX_UNPADDED_GAP_SECONDS ? gap : 0; + const padding = this.padGaps && gap > rate * MAX_UNPADDED_GAP_SECONDS ? gap : 0; // Bounded memory: past the cap the frame is refused (and counted), so a call // that never ends cannot grow the recording without limit. - if ((this.samples + padding + usable / 2) / rate > this.maxSeconds) return false; + if ((this.samples + padding + usable / 2) / rate > this.maxSeconds) return "cap"; const copy = new ArrayBuffer(usable); new Uint8Array(copy).set(new Uint8Array(pcm, 0, usable)); if (this.rate === 0) this.rate = sampleRate; if (padding > 0) { - this.chunks.push(new ArrayBuffer(padding * 2)); + // Silence is a count, not a buffer: a long pause costs nothing until the file is built. + this.chunks.push(padding); this.samples += padding; } this.chunks.push(copy); this.samples += usable / 2; - return true; + return "ok"; } /** The track as one contiguous Int16 view. */ @@ -176,6 +192,10 @@ class Track { const out = new Int16Array(this.samples); let offset = 0; for (const chunk of this.chunks) { + if (typeof chunk === "number") { + offset += chunk; // already zero + continue; + } const view = new Int16Array(chunk); out.set(view, offset); offset += view.length; @@ -254,6 +274,11 @@ export function buildStereoWav( return buffer; } +/** Monotonic where available, so a system clock step cannot become silence or rejected audio. */ +function defaultClock(): number { + return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now(); +} + /** * Start a stereo recording. * @@ -262,13 +287,17 @@ export function buildStereoWav( * clock in milliseconds and exists so tests can drive it. */ export function createStsCallRecorder(options: { now?: () => number; maxTrackSeconds?: number } = {}): StsCallRecorder { - const now = options.now ?? (() => Date.now()); + const now = options.now ?? defaultClock; const origin = now(); - const maxTrackSeconds = options.maxTrackSeconds ?? MAX_TRACK_SECONDS; - const tracks: Record = { user: new Track(maxTrackSeconds), bot: new Track(maxTrackSeconds) }; + const requestedCap = options.maxTrackSeconds; + // A NaN, zero or negative cap would either disable the limit or discard every frame. + const maxTrackSeconds = + requestedCap !== undefined && Number.isFinite(requestedCap) && requestedCap > 0 ? requestedCap : MAX_TRACK_SECONDS; + const tracks: Record = { user: new Track(maxTrackSeconds, false), bot: new Track(maxTrackSeconds, true) }; let closed: StsRecording | null = null; let stopped = false; let dropped = 0; + let capped = 0; return { write(side, pcm, sampleRate) { @@ -276,7 +305,9 @@ export function createStsCallRecorder(options: { now?: () => number; maxTrackSec // late frame from a torn-down audio path must not reopen the recording. if (stopped) return; try { - if (!tracks[side].append(pcm, sampleRate, (now() - origin) / 1000)) dropped += 1; + const result = tracks[side].append(pcm, sampleRate, (now() - origin) / 1000); + if (result !== "ok") dropped += 1; + if (result === "cap") capped += 1; } catch { // A recording is never worth failing a call over. } @@ -284,27 +315,39 @@ export function createStsCallRecorder(options: { now?: () => number; maxTrackSec stop() { if (stopped) return closed; stopped = true; - // Both tracks come out at the file's rate, so the interleave is a - // frame-for-frame alignment in time rather than a coincidence. - const user = tracks.user.atRate(RECORDING_SAMPLE_RATE); - const bot = tracks.bot.atRate(RECORDING_SAMPLE_RATE); - // A call where nobody spoke is not a recording, and an empty WAV would - // only produce a silent file the person has to open to discover. - if (user.length === 0 && bot.length === 0) return null; - const frames = Math.max(user.length, bot.length); - closed = { - wav: buildStereoWav(user, bot), - durationSeconds: frames / RECORDING_SAMPLE_RATE, - channels: { left: "user", right: "bot" }, - }; - // The raw mono audio is no longer needed once the file exists. - tracks.user.release(); - tracks.bot.release(); - return closed; + try { + // Both tracks come out at the file's rate, so the interleave is a + // frame-for-frame alignment in time rather than a coincidence. + const user = tracks.user.atRate(RECORDING_SAMPLE_RATE); + const bot = tracks.bot.atRate(RECORDING_SAMPLE_RATE); + // The raw chunks are no longer needed: view() and the resample made + // independent copies. Releasing them before the WAV is allocated keeps + // the peak at the two tracks plus the file, not the raw chunks as well. + tracks.user.release(); + tracks.bot.release(); + // A call where nobody spoke is not a recording, and an empty WAV would + // only produce a silent file the person has to open to discover. + if (user.length === 0 && bot.length === 0) return null; + const frames = Math.max(user.length, bot.length); + closed = { + wav: buildStereoWav(user, bot), + durationSeconds: frames / RECORDING_SAMPLE_RATE, + channels: { left: "user", right: "bot" }, + }; + return closed; + } catch { + // A failed build (for example an allocation error) must not escape into + // call teardown. The raw audio is already released, so this recording + // is lost; later stop() calls answer null rather than retrying. + return null; + } }, get droppedFrames() { return dropped; }, + get cappedFrames() { + return capped; + }, get elapsedSeconds() { return Math.max(tracks.user.seconds, tracks.bot.seconds); }, From d007c5defe4ed583e52fdae992a7f02e8d956d18 Mon Sep 17 00:00:00 2001 From: "swu-wise[bot]" <344778455+swu-wise[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 05:26:26 +0000 Subject: [PATCH 7/8] STS recorder: inject WAV builder and test the build-failure catch and the monotonic default clock --- src/lib/sts/recorder.test.ts | 28 +++++++++++++++++++++++++++- src/lib/sts/recorder.ts | 5 +++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/lib/sts/recorder.test.ts b/src/lib/sts/recorder.test.ts index cee1aef12..f14b6067f 100644 --- a/src/lib/sts/recorder.test.ts +++ b/src/lib/sts/recorder.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { RECORDING_RETENTION_DAYS, RECORDING_SAMPLE_RATE, @@ -392,6 +392,32 @@ describe("review follow-ups", () => { expect(view.getInt16(frameOffset(160) + 2, true)).toBe(0); expect(view.getInt16(frameOffset(120 * RECORDING_SAMPLE_RATE) + 2, true)).toBe(9); }); + it("contains a WAV build failure: stop() returns null instead of throwing into teardown", () => { + const recorder = createStsCallRecorder({ + now: () => 0, + buildWav: () => { + throw new RangeError("Array buffer allocation failed"); + }, + }); + recorder.write("user", pcm(1600, 1), RECORDING_SAMPLE_RATE); + expect(() => recorder.stop()).not.toThrow(); + expect(recorder.stop()).toBeNull(); + }); + + it("defaults to the monotonic clock: a wall-clock step does not change the recording", () => { + const monotonic = vi.spyOn(performance, "now").mockReturnValue(1000); + const wall = vi.spyOn(Date, "now").mockReturnValue(0); + try { + const recorder = createStsCallRecorder(); + monotonic.mockReturnValue(1000); + wall.mockReturnValue(10 * 60 * 1000); // the system clock steps forward ten minutes + recorder.write("bot", pcm(160, 3), RECORDING_SAMPLE_RATE); + expect(recorder.stop()!.durationSeconds).toBeCloseTo(0.01, 6); + } finally { + monotonic.mockRestore(); + wall.mockRestore(); + } + }); }); describe("stopping", () => { diff --git a/src/lib/sts/recorder.ts b/src/lib/sts/recorder.ts index 974ddd65e..224af3b07 100644 --- a/src/lib/sts/recorder.ts +++ b/src/lib/sts/recorder.ts @@ -286,9 +286,10 @@ function defaultClock(): number { * and the bot's reply line up in the file the way they happened. `now` is that * clock in milliseconds and exists so tests can drive it. */ -export function createStsCallRecorder(options: { now?: () => number; maxTrackSeconds?: number } = {}): StsCallRecorder { +export function createStsCallRecorder(options: { now?: () => number; maxTrackSeconds?: number; buildWav?: typeof buildStereoWav } = {}): StsCallRecorder { const now = options.now ?? defaultClock; const origin = now(); + const buildWav = options.buildWav ?? buildStereoWav; const requestedCap = options.maxTrackSeconds; // A NaN, zero or negative cap would either disable the limit or discard every frame. const maxTrackSeconds = @@ -330,7 +331,7 @@ export function createStsCallRecorder(options: { now?: () => number; maxTrackSec if (user.length === 0 && bot.length === 0) return null; const frames = Math.max(user.length, bot.length); closed = { - wav: buildStereoWav(user, bot), + wav: buildWav(user, bot), durationSeconds: frames / RECORDING_SAMPLE_RATE, channels: { left: "user", right: "bot" }, }; From ec7dbb5bf899e665621ff1d3e5fa6aada6bfba21 Mon Sep 17 00:00:00 2001 From: "swu-wise[bot]" <344778455+swu-wise[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 05:37:00 +0000 Subject: [PATCH 8/8] STS recorder: drop runtime typeof (lint baseline), use instanceof and optional chaining --- src/lib/sts/recorder.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lib/sts/recorder.ts b/src/lib/sts/recorder.ts index 224af3b07..d78df926c 100644 --- a/src/lib/sts/recorder.ts +++ b/src/lib/sts/recorder.ts @@ -192,13 +192,13 @@ class Track { const out = new Int16Array(this.samples); let offset = 0; for (const chunk of this.chunks) { - if (typeof chunk === "number") { - offset += chunk; // already zero - continue; + if (chunk instanceof ArrayBuffer) { + const view = new Int16Array(chunk); + out.set(view, offset); + offset += view.length; + } else { + offset += chunk; // silence: the output is already zero } - const view = new Int16Array(chunk); - out.set(view, offset); - offset += view.length; } return out; } @@ -276,7 +276,7 @@ export function buildStereoWav( /** Monotonic where available, so a system clock step cannot become silence or rejected audio. */ function defaultClock(): number { - return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now(); + return globalThis.performance?.now() ?? Date.now(); } /**