From af827a9a962c42f3dc1ec59cbafe4db11e295052 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 11 Aug 2026 21:08:09 -0400 Subject: [PATCH] fix(visualizer): bars recover after far-forward seeks into undecoded audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensureDecodeAround let a running decode pass close ANY forward gap in place — at 4x pacing, skipping 30 min ahead meant ~7.5 min for the frontier to arrive: bars held their last frame indefinitely. Now a gap beyond 15s restarts the pass at the seek target (network coverage there in ~1.6s, verified); smaller gaps close in place (cheaper than a reconnect + range request). Seek-key holds are debounced (400ms) so rapid re-seeks don't reconnect-spam the stream's server, and pending seek-decode cancels on pause/stop so no ffmpeg restarts while paused. Also fixes a pre-existing config-write race that intermittently failed visualizer-toggle.test.ts: updateConfig re-resolved the config path and re-read the patch state when the deferred write-chain drained, so a queued save could land in a directory XDG_CONFIG_HOME had since been pointed at (or carry state mutated after queueing). Path and patch snapshot are now captured eagerly at call time. --- src/stores/visualizer.ts | 25 +++++++++++++-- src/utils/audio-pcm-cache.ts | 60 ++++++++++++++++++++++++----------- src/utils/config.ts | 34 ++++++++++++++++---- tests/audio-pcm-cache.test.ts | 49 ++++++++++++++++++++++++++++ 4 files changed, 139 insertions(+), 29 deletions(-) diff --git a/src/stores/visualizer.ts b/src/stores/visualizer.ts index ddd652b..6e04b75 100644 --- a/src/stores/visualizer.ts +++ b/src/stores/visualizer.ts @@ -55,6 +55,9 @@ const FRAME_INTERVAL = 33; /** Number of PCM samples to read per frame (512 is a good FFT window) */ const SAMPLES_PER_FRAME = 512; +/** Timer handle as returned by setTimeout/setInterval in this runtime. */ +type TimerHandle = ReturnType; + // ── Types ──────────────────────────────────────────────────────────────── export interface VisualizerStore { @@ -96,9 +99,9 @@ function createVisualizerStore(): VisualizerStore { // pause/resume (segments survive; only the ffmpeg pass is killed) and // dropped only on episode change, stop, disable, or unload. let pcm: EpisodePcmCache | null = null; - let frameTimer: ReturnType | null = null; + let frameTimer: TimerHandle | null = null; let sampleBuffer: Float64Array | null = null; - let unloadTimer: ReturnType | null = null; + let unloadTimer: TimerHandle | null = null; // What the running pipeline was started with — lets the playback effect // tell "nothing changed, stay warm" from "must restart". @@ -209,6 +212,8 @@ function createVisualizerStore(): VisualizerStore { clearInterval(frameTimer); frameTimer = null; } + clearTimeout(seekDecodeTimer); + seekDecodeTimer = undefined; if (pcm) { pcm.stop(); // Keep the (now cache-less, url-tagged) object: a re-start of the @@ -233,6 +238,10 @@ function createVisualizerStore(): VisualizerStore { clearInterval(frameTimer); frameTimer = null; } + // Cancel any debounced seek-decode: it would restart ffmpeg while + // paused, defeating the "no background CPU while paused" contract. + clearTimeout(seekDecodeTimer); + seekDecodeTimer = undefined; if (pcm) pcm.pauseDecode(); // Cava plan + sampleBuffer stay alive — cheap to reuse on resume. // Clear the loading spinner: if the pipeline never produced bars @@ -375,6 +384,7 @@ function createVisualizerStore(): VisualizerStore { // while the last frame holds. let lastSyncPosition = 0; + let seekDecodeTimer: TimerHandle | undefined; createEffect( on(audioPlaybackSignals.position, (pos) => { if (!audioPlaybackSignals.isPlaying() || !pcm) { @@ -386,7 +396,16 @@ function createVisualizerStore(): VisualizerStore { lastSyncPosition = pos; if (delta > 2) { - pcm.ensureDecodeAround(pos); + // Debounce: holding the seek key fires a jump per poll tick — + // without debounce each one restarts ffmpeg, spamming network + // reconnects against the stream's server. Wait for the user to + // settle, then decode at the final position. + clearTimeout(seekDecodeTimer); + const target = pcm; // capture for the timer + seekDecodeTimer = setTimeout(() => { + seekDecodeTimer = undefined; + target.ensureDecodeAround(untrack(audioPlaybackSignals.position)); + }, 400); } }), ); diff --git a/src/utils/audio-pcm-cache.ts b/src/utils/audio-pcm-cache.ts index 92b533d..13ed908 100644 --- a/src/utils/audio-pcm-cache.ts +++ b/src/utils/audio-pcm-cache.ts @@ -41,6 +41,14 @@ const BYTES_PER_SAMPLE = 2; // s16le /** Initial segment capacity: 4 Mi samples ≈ 190 s of audio (8 MB). */ const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024; +/** + * Gap (seconds) a running decode pass may close on its own before a restart + * at the seek target is cheaper than waiting: at 4x pacing, 15s of undecoded + * audio closes in ~4s — about the cost of a network reconnect + range + * request for a fresh ffmpeg pass. Beyond the gap, restart at the target. + */ +const CLOSE_IN_PLACE_GAP_SEC = 15; + /** * Monotonically increasing generation counter. * Each startDecode() increments this; the read loop checks it to know @@ -72,8 +80,8 @@ export class EpisodePcmCache { private segments: Segment[] = []; private generation = 0; private _decoding = false; - /** Base offset (playback seconds) of the running decode pass; null when idle. */ - private activeBaseSec: number | null = null; + /** The running pass's segment (base + frontier); null when idle. */ + private activeSegment: Segment | null = null; readonly url: string; readonly sampleRate: number; @@ -87,6 +95,13 @@ export class EpisodePcmCache { return this._decoding; } + /** Base (playback seconds) of the running decode pass; null when idle. */ + get activeDecodeBaseSec(): number | null { + return this._decoding && this.activeSegment + ? this.activeSegment.baseSec + : null; + } + /** End (playback seconds) of the furthest-decoded segment. */ get coverageEndSec(): number { let end = 0; @@ -184,14 +199,14 @@ export class EpisodePcmCache { stdin: "ignore", }); this._decoding = true; - this.activeBaseSec = segment.baseSec; + this.activeSegment = segment; this.readLoop(myGeneration, segment); this.proc.exited .then((code) => { if (this.generation === myGeneration) { this._decoding = false; - this.activeBaseSec = null; + this.activeSegment = null; // Exit 0 == decoded to stream EOF. if (code === 0) segment.finished = true; } @@ -199,7 +214,7 @@ export class EpisodePcmCache { .catch(() => { if (this.generation === myGeneration) { this._decoding = false; - this.activeBaseSec = null; + this.activeSegment = null; } }); } @@ -223,23 +238,30 @@ export class EpisodePcmCache { * new segment at `sec` (seek into a hole / resume past cached audio). */ ensureDecodeAround(sec: number): void { - if (this._decoding) { - // A decode pass fills monotonically FORWARD from its base. Only a - // target at/after the active base is eventually covered by it — - // a target BEHIND the base (seek into an undecoded hole ahead of - // the active pass) never is: kill the pass and restart at sec. - if (this.activeBaseSec !== null && sec >= this.activeBaseSec) return; - this.startDecode(Math.max(0, sec)); - return; - } + // Data already on hand: nothing needed here; only keep the tail + // filling if the decode is idle and the episode is unfinished. if (this.covers(sec)) { - // Covered here: continue the tail so the cache keeps filling - // past the position (unless the whole episode is decoded). - if (this.decodeFinished) return; + if (this._decoding || this.decodeFinished) return; this.startDecode(this.coverageEndSec > sec ? this.coverageEndSec : sec); return; } - // Seek into an undecoded region: start a fresh segment there. + + if (this._decoding && this.activeSegment !== null) { + // A decode pass fills monotonically FORWARD from its base. Targets + // behind the base are unreachable — restart at the target. + if (sec < this.activeSegment.baseSec) { + this.startDecode(Math.max(0, sec)); + return; + } + // Target past the pass's frontier: a SMALL gap closes on its own + // (4x pacing covers 15s in ~4s — about what a cold restart costs + // to reconnect + range-request a network stream), but a FAR-FORWARD + // seek would otherwise mean minutes of frozen bars while the pass + // chews through the skipped region. Restart at the target. + const frontier = + this.activeSegment.baseSec + this.activeSegment.written / this.sampleRate; + if (sec - frontier <= CLOSE_IN_PLACE_GAP_SEC) return; + } this.startDecode(Math.max(0, sec)); } @@ -275,7 +297,7 @@ export class EpisodePcmCache { pauseDecode(): void { this.generation = ++globalGeneration; this._decoding = false; - this.activeBaseSec = null; + this.activeSegment = null; this.killProcess(); } diff --git a/src/utils/config.ts b/src/utils/config.ts index ba697c6..01bd202 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -13,6 +13,7 @@ * always overwrite — no backup files are created. */ +import { mkdir } from "fs/promises"; import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir"; import type { AppSettings, @@ -58,15 +59,34 @@ let writeChain: Promise = Promise.resolve(); /** Update sections of config.json (read-modify-write, serialized, overwrite). */ export function updateConfig(patch: Partial): void { + // Capture the target path AND the patch data eagerly, at call time: + // the write chain defers execution, and both the config dir (tests + // re-point XDG_CONFIG_HOME between ops) and the state object (stores + // mutate in place) move under a pending write. Without the capture, a + // queued save writes the LATEST state into whatever directory is + // current when the chain drains — a cross-directory misdelivery that + // was the source of a flaky "enabled:false survives reload" test. + const configPath = getConfigFilePath(CONFIG_FILE); + const configDir = getConfigDir(); + const snapshot = JSON.parse(JSON.stringify(patch)) as Partial; writeChain = writeChain.then(async () => { try { - await ensureConfigDir(); - const current = await loadConfig(); - const next = { ...current, ...patch }; - await Bun.write( - getConfigFilePath(CONFIG_FILE), - JSON.stringify(next, null, 2), - ); + await migrateOnce(); + await mkdir(configDir, { recursive: true }); + let current: PodTuiConfig = {}; + try { + const file = Bun.file(configPath); + if (await file.exists()) { + const raw = await file.json(); + if (raw && typeof raw === "object") { + current = raw as PodTuiConfig; + } + } + } catch { + /* unreadable existing config — treat as empty */ + } + const next = { ...current, ...snapshot }; + await Bun.write(configPath, JSON.stringify(next, null, 2)); } catch { // Fire-and-forget persistence — silently ignore write errors. } diff --git a/tests/audio-pcm-cache.test.ts b/tests/audio-pcm-cache.test.ts index bd3a0ec..1e1cb7e 100644 --- a/tests/audio-pcm-cache.test.ts +++ b/tests/audio-pcm-cache.test.ts @@ -97,6 +97,55 @@ function tmpWav(): string { } const hasFfmpeg = !!Bun.which("ffmpeg"); + +test.skipIf(!hasFfmpeg)( + "far-forward seek into undecoded territory restarts decode AT the target (bars recover in seconds, not minutes)", + async () => { + const wav = tmpWav(); + writeSineWav(wav, 60); + const cache = new EpisodePcmCache({ url: wav }); + try { + cache.startDecode(0); + await waitForCoverage(cache, 1); + + // Skipping 45s ahead while the pass still crawls at 4x must restart + // the segment at the target — waiting for the frontier to chew + // through the skipped region is minutes of frozen bars. + cache.ensureDecodeAround(45); + expect(cache.decoding).toBe(true); + expect(cache.activeDecodeBaseSec).toBe(45); + await waitForCoverage(cache, 45.1); + } finally { + cache.stop(); + await Bun.$`rm -f ${wav}`.quiet(); + } + }, + { timeout: 20000 }, +); + +test.skipIf(!hasFfmpeg)( + "small forward gap closes in place — no needless reconnect", + async () => { + const wav = tmpWav(); + writeSineWav(wav, 60); + const cache = new EpisodePcmCache({ url: wav }); + try { + cache.startDecode(0); + await waitForCoverage(cache, 2); + + // ~5s past the running frontier: at 4x pacing this closes in ~1.5s, + // cheaper than a reconnect — the pass must NOT restart. + const target = cache.coverageEndSec + 5; + cache.ensureDecodeAround(target); + expect(cache.activeDecodeBaseSec).toBe(0); + await waitForCoverage(cache, target); + } finally { + cache.stop(); + await Bun.$`rm -f ${wav}`.quiet(); + } + }, + { timeout: 20000 }, +); const FIVE_SEC_BASE = 5 * SAMPLE_RATE; // decode offset for position-mapping tests test.skipIf(!hasFfmpeg)(