diff --git a/src/pages/Player/RealtimeWaveform.tsx b/src/pages/Player/RealtimeWaveform.tsx index 4f302fc..342f4ec 100644 --- a/src/pages/Player/RealtimeWaveform.tsx +++ b/src/pages/Player/RealtimeWaveform.tsx @@ -168,8 +168,9 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { if (!cava?.isReady || !reader?.running || !sampleBuffer) return; // Sample the FFT window at the player's position, not the decode - // head — the reader decodes independently and only the position clock - // ties the bars to what's actually playing. + // head — the reader decodes independently (paced at the player's + // clock rate with a LEAD_SECONDS burst head start) and only the + // position clock ties the bars to what's actually playing. const target = smoothPosition(); const count = reader.read(sampleBuffer, target); // Never feed a partial FFT window to cava. diff --git a/src/utils/audio-stream-reader.ts b/src/utils/audio-stream-reader.ts index b6da4e8..0b3dc9d 100644 --- a/src/utils/audio-stream-reader.ts +++ b/src/utils/audio-stream-reader.ts @@ -7,10 +7,12 @@ * and serves windows *at a requested playback position* to the caller. * * This is independent from the actual playback backend — it's a - * read-only "tap" on the audio for FFT analysis purposes. Because it is a - * separate decoder, sync with the player is maintained by pacing decode at - * the player's clock rate (`-readrate `) and sampling the window at - * the position the player reports, never at the decode head. + * read-only "tap" on the audio for FFT analysis purposes. Sync with the + * player is maintained by pacing decode at the player's clock rate + * (`-readrate `) while front-loading a burst of LEAD_SECONDS + * (`-readrate_initial_burst`) so the decode head leads the player + * position by a stable lead — read() samples at the exact position the + * player reports, never at the decode head. */ /** PCM output format constants */ @@ -27,6 +29,26 @@ const BYTES_PER_SAMPLE = 2; // s16le */ const RING_BUFFER_SAMPLES = SAMPLE_RATE * 10; +/** + * Decode-head lead over the player position, in seconds. + * + * `-readrate_initial_burst LEAD_SECONDS` makes ffmpeg emit this much audio + * immediately on start, then pace at realtime (`-readrate speed`) after. + * The decode head thus leads the player by ~LEAD_SECONDS from the very + * first frame. read() samples at the player's current position, which is + * always behind the head — so it finds freshly decoded samples there + * instead of clamping to stale data. + * + * Bare `-readrate speed` (no burst) starts ffmpeg ε behind mpv (input-open + * + first-packet latency) and, since both advance at the same rate, never + * catches up — the bars lag by ε (up to several seconds on network + * streams). The burst eliminates that constant offset. + * + * Must stay within the ring window (RING_BUFFER_SAMPLES ~10s) so the + * lead audio hasn't wrapped out by the time the player reaches it. + */ +const LEAD_SECONDS = 3; + export interface AudioStreamReaderOptions { /** Audio URL or file path to decode */ url: string; @@ -79,8 +101,10 @@ export class AudioStreamReader { * immediately. * * @param startPosition Seek position in seconds (default: 0). - * @param speed Playback speed multiplier (default: 1). Applies ffmpeg - * atempo filter so visualization stays in sync with audio. + * @param speed Playback speed multiplier (default: 1). Paces ffmpeg + * at the player's advance rate so decode tracks the + * player clock; `-readrate_initial_burst` front-loads + * a LEAD_SECONDS head start. */ start(startPosition = 0, speed = 1): void { // Always kill the previous process first — no early return on _running @@ -101,17 +125,18 @@ export class AudioStreamReader { "ffmpeg", "-loglevel", "quiet", - // Pace input at the player's advance rate (speed× native) rather - // than native rate. Decoding slower than the player makes the - // decoded position fall behind the playback position linearly - // (bars drift away at (speed-1)s per second); decoding unthrottled - // fills the ring with audio seconds ahead of the player (laggy - // bars) and hits EOF early (bars freeze). `-readrate speed` keeps - // the decode head just ahead of the position the renderer samples, - // tracking the player clock with only mpv's startup latency as a - // constant offset — absorbed by the ring buffer. + // Pace input at the player's advance rate (speed× native). Combined + // with -readrate_initial_burst below, the decode head starts + // LEAD_SECONDS ahead of the player and advances at the same rate — + // read() samples at the player position and always finds fresh data. "-readrate", String(readRate), + // Front-load LEAD_SECONDS of audio immediately so the decode head + // leads the player from the very first frame. Without this, ffmpeg + // starts ε behind mpv (input-open + first-packet latency) and, + // pacing at the same rate, never catches up — bars lag by ε. + "-readrate_initial_burst", + String(LEAD_SECONDS), ]; // `-reconnect*` are http-protocol options: ffmpeg rejects them at diff --git a/tests/audio-stream-reader.test.ts b/tests/audio-stream-reader.test.ts index 456699d..d9974d9 100644 --- a/tests/audio-stream-reader.test.ts +++ b/tests/audio-stream-reader.test.ts @@ -170,3 +170,70 @@ test.skipIf(!hasFfmpeg)( } }, ); + +test.skipIf(!hasFfmpeg)( + "sustained render loop: ffmpeg stays alive and decode head maintains a lead over the player", + async () => { + // Real wall-clock time is required here: this test validates ffmpeg's + // actual decode pacing (-readrate + -readrate_initial_burst) against + // the platform clock. Deterministic time control cannot reproduce the + // race where ffmpeg exits early and the bars freeze — that only + // surfaces when a real process writes to a real pipe. + // + // Simulates the actual render loop: for ~5s of wall time, advance a + // simulated player position at 1× realtime and call read() each frame. + // The decode head must stay ahead of the player position so read() + // always returns 512 samples, and ffmpeg must not exit early (which + // would freeze the bars). This test would have caught the + // backpressure-pacing failure where ffmpeg decoded all data into the + // pipe buffer instantly, exited, and the readLoop stopped. + const wav = join( + tmpdir(), + `podtui-reader-${process.pid}-${Date.now()}.wav`, + ); + writeSineWav(wav, 30); + const reader = new AudioStreamReader({ url: wav }); + try { + reader.start(0, 1); + + const FRAME_MS = 33; + const DURATION_MS = 5000; + const out = new Float64Array(512); + let successes = 0; + let failures = 0; + let minLead = Infinity; + + const start = Date.now(); + for (let frame = 0; Date.now() - start < DURATION_MS; frame++) { + const playerPos = (Date.now() - start) / 1000; + const count = reader.read(out, playerPos); + if (count === 512) successes++; + else failures++; + + // The decode head should stay ahead of the player position. + const headPos = reader.samplesWritten / SAMPLE_RATE; + const lead = headPos - playerPos; + if (frame > 3) minLead = Math.min(minLead, lead); + + await Bun.sleep(FRAME_MS); + } + + // ffmpeg must still be running — it must not have exited early. + expect(reader.running).toBe(true); + + // The vast majority of frames should return a full window. + // A few early failures during ffmpeg startup are acceptable. + expect(failures).toBeLessThan(5); + expect(successes).toBeGreaterThan(100); + + // The decode head must maintain a positive lead over the player. + // Without -readrate_initial_burst, the head would lag behind by + // the ffmpeg startup latency and never catch up. + expect(minLead).toBeGreaterThan(0); + } finally { + reader.stop(); + await Bun.$`rm -f ${wav}`.quiet(); + } + }, + { timeout: 15000 }, +);