From eaa3002b9d930264e27c7f4dc8ed72e02f7ea780 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 23 Jul 2026 22:05:04 +0200 Subject: [PATCH] fix(tts): cap replay retention so long utterances stay bounded in memory Streamed PCM kept for the nonzero-exit replay is now dropped once the utterance exceeds 60s (~5.8 MB at 24 kHz mono f32): the recovered failure is a short clip that fits the pipe buffer before a broken backend dies, while replaying a long already-played utterance would duplicate audio and unbounded retention would defeat streaming for long input. --- .../coding-agent/src/tts/streaming-player.ts | 21 ++++++++++++++++++- .../test/tts/streaming-player.test.ts | 18 ++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/tts/streaming-player.ts b/packages/coding-agent/src/tts/streaming-player.ts index 6dc458d72..43373e25d 100644 --- a/packages/coding-agent/src/tts/streaming-player.ts +++ b/packages/coding-agent/src/tts/streaming-player.ts @@ -31,6 +31,15 @@ const DEFAULT_SAMPLE_RATE = 24_000; const LEAD_SECONDS = 0.6; /** Output gain applied while ducked (the user is speaking over the assistant). */ export const DUCK_GAIN = 0.25; +/** + * Cap on streamed PCM retained for the nonzero-exit replay. Past this the + * buffer is dropped: the failure being recovered is a short clip that fits the + * OS pipe buffer before a broken backend dies, while a backend that consumed + * minutes of realtime-paced audio was playing it — replaying a whole long + * utterance would duplicate audio, and unbounded retention (~5.8 MB/min at + * 24 kHz mono f32) would defeat streaming for long input. + */ +const REPLAY_RETENTION_SECONDS = 60; /** Injection seam for {@link streamingPlayerCommandsFor} — defaults to real PATH/tools lookups. */ export interface StreamingPlayerLookup { @@ -99,6 +108,8 @@ export interface StreamingPlayerOptions { commandsFor?: (sampleRate: number) => PlayerCommand[]; /** Per-file fallback playback; defaults to {@link playAudioFile}. */ playAudio?: (wavPath: string, signal: AbortSignal) => Promise; + /** Max seconds of streamed PCM retained for the nonzero-exit replay; defaults to {@link REPLAY_RETENTION_SECONDS}. */ + replayRetentionSeconds?: number; } /** @@ -125,12 +136,15 @@ export class StreamingAudioPlayer { #drain: Promise = Promise.resolve(); readonly #commandsFor: (sampleRate: number) => PlayerCommand[]; readonly #playAudio: (wavPath: string, signal: AbortSignal) => Promise; + readonly #replayRetentionSec: number; /** Streamed PCM retained for this utterance so a failed backend can be replayed via file playback. */ #played: Float32Array[] = []; + #playedSec = 0; constructor(options: StreamingPlayerOptions = {}) { this.#commandsFor = options.commandsFor ?? (rate => streamingPlayerCommandsFor(process.platform, rate)); this.#playAudio = options.playAudio ?? ((wavPath, signal) => playAudioFile(wavPath, { signal })); + this.#replayRetentionSec = options.replayRetentionSeconds ?? REPLAY_RETENTION_SECONDS; } /** Pick a backend and begin draining. Idempotent; the first call's rate wins. */ @@ -235,7 +249,12 @@ export class StreamingAudioPlayer { continue; } if (this.#mode === "stream") { - this.#played.push(chunk); + if (this.#playedSec <= this.#replayRetentionSec) { + this.#played.push(chunk); + this.#playedSec += chunk.length / this.#sampleRate; + // Over the cap: drop retention for the rest of the utterance. + if (this.#playedSec > this.#replayRetentionSec) this.#played.length = 0; + } // Pace writes so the player buffers ~LEAD_SECONDS, no more, keeping // ducking and stop responsive instead of locked behind buffered audio. const ahead = this.#writtenSec - (performance.now() - this.#startedAt) / 1000; diff --git a/packages/coding-agent/test/tts/streaming-player.test.ts b/packages/coding-agent/test/tts/streaming-player.test.ts index 0b004cc35..69d1f53d4 100644 --- a/packages/coding-agent/test/tts/streaming-player.test.ts +++ b/packages/coding-agent/test/tts/streaming-player.test.ts @@ -51,4 +51,22 @@ describe("StreamingAudioPlayer nonzero-exit fallback", () => { await player.end(); expect(played.length).toBe(2); }); + + it("drops the replay buffer once the utterance exceeds the retention cap", async () => { + // Long input must not accumulate unbounded PCM; past the cap the + // nonzero-exit replay is forfeited rather than duplicating audio the + // backend already played. + const played: string[] = []; + const player = new StreamingAudioPlayer({ + commandsFor: (): PlayerCommand[] => [{ cmd: "sh", args: ["-c", "cat >/dev/null; exit 1"] }], + playAudio: async wavPath => { + played.push(wavPath); + }, + replayRetentionSeconds: 0.25, + }); + player.start(24_000); + player.write(clip()); + await player.end(); + expect(played.length).toBe(0); + }); });