fix(visualizer): bars recover after far-forward seeks into undecoded audio
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.
This commit is contained in:
@@ -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<typeof setTimeout>;
|
||||
|
||||
// ── 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<typeof setInterval> | null = null;
|
||||
let frameTimer: TimerHandle | null = null;
|
||||
let sampleBuffer: Float64Array | null = null;
|
||||
let unloadTimer: ReturnType<typeof setTimeout> | 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> = Promise.resolve();
|
||||
|
||||
/** Update sections of config.json (read-modify-write, serialized, overwrite). */
|
||||
export function updateConfig(patch: Partial<PodTuiConfig>): 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<PodTuiConfig>;
|
||||
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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user