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.
This commit is contained in:
can1357
2026-07-23 22:15:24 +02:00
parent 46ae99cea8
commit eaa3002b9d
2 changed files with 38 additions and 1 deletions
@@ -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<void>;
/** 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<void> = Promise.resolve();
readonly #commandsFor: (sampleRate: number) => PlayerCommand[];
readonly #playAudio: (wavPath: string, signal: AbortSignal) => Promise<void>;
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;
@@ -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);
});
});