fix(visualizer): show loading spinner on resume until fresh bars arrive

Resume re-arms a pipeline whose ffmpeg pass was killed at pause, so the
pre-pause bars are stale until fresh frames flow. Three changes:

- resumeVisualization always sets the loading state (previously only for
  positions outside decoded coverage) and records the resume point;
  renderFrame clears it only once the position clock advances past that
  point — a player still re-buffering after a long pause keeps the
  spinner instead of serving static cached bars.
- stopVisualization clears barData so cold restarts (unload, disable,
  episode change) show the spinner rather than stale bars, and never
  suppress it.
- renderFrame detects a frozen position clock while playing (STALL_DETECT_MS)
  and surfaces it as a loading state; recovery clears it.

Tests: resume-into-undecoded-audio shows loading until bars land; frozen
position clock surfaces a stall and recovery clears it; disable/enable
pins barData cleared on stop and the restart loading flash.
This commit is contained in:
2026-08-13 21:04:12 -04:00
parent 0aac0a157f
commit 22059c24ca
3 changed files with 212 additions and 10 deletions

View File

@@ -8,8 +8,9 @@
*
* This component only subscribes to store state, reports the width-derived
* bar count (terminal resize re-inits the running pipeline), and renders:
* a braille spinner while the pipeline is loading its first frames, the
* frequency bars once frames arrive, and a dotted placeholder when idle.
* a braille spinner while the pipeline is loading its first frames or the
* player is stalled (re-buffering), the frequency bars once frames arrive,
* and a dotted placeholder when idle.
*/
import { createEffect, on } from "solid-js";
@@ -53,12 +54,13 @@ export function RealtimeWaveform() {
const bars = viz.barData();
const count = numBars();
// Loading state: the braille spinner shows while the pipeline warms
// up — but only when there are no bars to render yet (first play /
// after an unload). On resume/seek the last bars stay on screen
// until fresh frames arrive, so the waveform never blanks out for
// the (multi-second, network-bound) cold start.
if (bars.length === 0 && viz.isLoading()) {
// Loading state: the braille spinner shows while the pipeline is
// warming up — cold start (first play / after an unload), resume
// into undecoded audio, or a stalled position clock (mpv
// re-buffering after a long pause on a network stream). The store
// clears it the moment the first fresh frame renders, so stale
// bars never masquerade as live data while the pipeline re-arms.
if (viz.isLoading() || viz.isStalled()) {
return <LoadingIndicator />;
}

View File

@@ -25,6 +25,12 @@
* after the Player tab stops being focused it tears down. Reads outside
* decoded coverage return empty — the renderer simply holds the last frame
* until the decode frontier arrives.
*
* Loading semantics: `isLoading` is true from any pipeline start (cold
* start, resume into undecoded audio) until the first complete FFT frame,
* and `isStalled` while playback claims to be live but the position clock
* is frozen (player re-buffering). The component renders the spinner for
* either; bars replace it the moment fresh frames arrive.
*/
import {
@@ -55,6 +61,14 @@ const FRAME_INTERVAL = 33;
/** Number of PCM samples to read per frame (512 is a good FFT window) */
const SAMPLES_PER_FRAME = 512;
/**
* How long the position clock may stay frozen while the UI believes
* playback is live before the waveform reports a stall (loading state).
* mpv polls time-pos every ~150ms, so a frozen clock means the player is
* re-buffering — the long-pause-then-resume case on network streams.
*/
const STALL_DETECT_MS = 2000;
/** Timer handle as returned by setTimeout/setInterval in this runtime. */
type TimerHandle = ReturnType<typeof setTimeout>;
@@ -65,6 +79,10 @@ export interface VisualizerStore {
barData: () => number[];
/** True from pipeline start until the first complete FFT frame renders. */
isLoading: () => boolean;
/** True while playback claims to be live but the position clock has
* been frozen past STALL_DETECT_MS (player re-buffering, e.g. after a
* long pause on a network stream). */
isStalled: () => boolean;
/** True while the ~30fps render loop is armed. */
isRunning: () => boolean;
/** Report whether the Player tab is the visible tab. */
@@ -82,6 +100,10 @@ function createVisualizerStore(): VisualizerStore {
// True from pipeline start until the first complete FFT frame renders.
const [isLoading, setIsLoading] = createSignal(false);
// True while playback is live but the position clock is frozen
// (player re-buffering) — see STALL_DETECT_MS.
const [isStalled, setIsStalled] = createSignal(false);
// Whether the Player tab is the visible tab (fed by PlayerPage).
const [focused, setFocused] = createSignal(false);
@@ -103,6 +125,20 @@ function createVisualizerStore(): VisualizerStore {
let sampleBuffer: Float64Array | null = null;
let unloadTimer: TimerHandle | null = null;
// Stall tracker: last observed position-signal value and when it moved.
// Any change (forward, backward, seek) re-arms the clock; a frozen
// signal while playing trips isStalled after STALL_DETECT_MS.
let lastRenderPos = -1;
let lastPosMoveAt = 0;
// Resume point: the position a paused pipeline was re-armed at. The
// loading state set by resume only clears once the position clock has
// advanced PAST this — while the player is still re-buffering, the
// cache can serve the same window forever and the stale pre-pause bars
// must not masquerade as live data. -1 = cold start (clear on the
// first produced frame, regardless of the clock).
let resumePos = -1;
// What the running pipeline was started with — lets the playback effect
// tell "nothing changed, stay warm" from "must restart".
let activeUrl = "";
@@ -200,9 +236,19 @@ function createVisualizerStore(): VisualizerStore {
lastPolledPosition = position;
lastPolledAt = performance.now();
// Seed the stall tracker: a fresh pipeline should not report a
// stall just because the first position poll hasn't landed.
lastRenderPos = position;
lastPosMoveAt = performance.now();
// Cold start: the loading state clears on the first produced frame
// (see renderFrame) — no resume-position gating.
resumePos = -1;
activeUrl = url;
activeBars = barCount();
setIsLoading(true);
setIsStalled(false);
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
};
@@ -224,6 +270,14 @@ function createVisualizerStore(): VisualizerStore {
}
sampleBuffer = null;
setIsLoading(false);
setIsStalled(false);
// Drop the last rendered frame: after a stop the bars are stale (a
// different episode, a different position) and would masquerade as
// live data while the next cold start warms up — and, because the
// component only shows the spinner while bars are empty, they'd
// also suppress the loading state. Cold restarts re-render fresh
// bars within the first frame.
setBarData([]);
};
// ── Pause: freeze the loop, keep the cache ──────────────────────────
@@ -248,6 +302,7 @@ function createVisualizerStore(): VisualizerStore {
// (still cold-starting when paused), the component should fall back
// to the placeholder, not freeze on a spinner.
setIsLoading(false);
setIsStalled(false);
};
// ── Resume: re-arm the render loop, top up the cache ───────────────
@@ -269,6 +324,20 @@ function createVisualizerStore(): VisualizerStore {
lastPolledPosition = pos;
lastPolledAt = performance.now();
// Re-arm the stall tracker from the resume position (a long pause
// left the old timestamps stale — they'd trip the stall detector on
// the very first frame otherwise).
lastRenderPos = pos;
lastPosMoveAt = performance.now();
// Resume re-arms a pipeline whose ffmpeg pass was killed at pause:
// the pre-pause bars are stale until fresh frames flow, so show the
// loading state IN THEIR PLACE. It clears only once the position
// clock has advanced past the resume point (see renderFrame) — a
// player still re-buffering after a long pause keeps the spinner
// instead of serving static cached bars.
resumePos = pos;
setIsLoading(true);
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
return true;
};
@@ -282,6 +351,26 @@ function createVisualizerStore(): VisualizerStore {
// coverage (decode cold start, seek into a hole) the read is empty
// and the LAST FRAME simply holds — never clamped/repeated junk.
const target = smoothPosition();
// Stall detection: while the UI believes playback is live, the
// position signal must keep advancing (useAudio polls it every
// ~150ms). A frozen clock with a warm pipeline means the player is
// re-buffering — the classic long-pause-then-resume on a network
// stream — and without this the waveform shows dead-looking static
// bars for the whole stall. Report it as loading; the first frame
// after the clock moves again clears it.
const rawPos = audioPlaybackSignals.position();
if (rawPos !== lastRenderPos) {
lastRenderPos = rawPos;
lastPosMoveAt = performance.now();
if (isStalled()) setIsStalled(false);
} else if (
audioPlaybackSignals.isPlaying() &&
performance.now() - lastPosMoveAt > STALL_DETECT_MS
) {
setIsStalled(true);
}
const count = pcm.readWindow(sampleBuffer, target);
// Never feed a partial FFT window to cava.
if (count < sampleBuffer.length) return;
@@ -290,7 +379,14 @@ function createVisualizerStore(): VisualizerStore {
// Normalize against the running peak and copy to a new array
setBarData(scaler(output));
if (isLoading()) setIsLoading(false);
// Fresh frames only count once the position clock has moved past
// the resume point: while the player is still re-buffering after a
// long pause, the cache serves the same window and the spinner must
// stay in place of the stale bars. Cold starts (resumePos < 0)
// clear on the first frame as before.
if (isLoading() && (resumePos < 0 || rawPos > resumePos)) {
setIsLoading(false);
}
};
// ── Playback subscription ──────────────────────────────────────────
@@ -425,6 +521,7 @@ function createVisualizerStore(): VisualizerStore {
// state
barData,
isLoading,
isStalled,
isRunning: () => frameTimer !== null,
// inputs
setFocused,

View File

@@ -230,15 +230,118 @@ test.skipIf(skip)(
app.updateVisualizer({ enabled: false });
await waitFor(() => !viz.isRunning(), 10000);
expect(viz.isLoading()).toBe(false);
// Stopping the pipeline must drop the last rendered frame — a cold
// restart (re-enable, unload, episode change) would otherwise show
// stale bars from the previous run and never reach the loading
// state (the spinner only shows while bars are empty).
expect(viz.barData().length).toBe(0);
app.updateVisualizer({ enabled: true });
await waitFor(() => viz.isRunning(), 10000);
// The restart surfaces the loading state before the first frame.
await waitFor(() => viz.isLoading(), 5000);
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
expect(viz.barData().length).toBe(64);
},
{ timeout: 20000 },
);
// A pause followed by a seek while paused, then resume, lands OUTSIDE the
// decoded sliding window: the cache can't serve bars instantly, so the
// store must surface the warm-up as a loading state instead of silently
// holding the stale pre-pause frame. Regression: resumeVisualization never
// set isLoading, so the last frame froze with no feedback until the
// re-decode's first frame landed.
test.skipIf(skip)(
"resume into undecoded audio shows the loading state until bars land",
async () => {
const viz = useVisualizer();
await startPlaying();
expect(viz.barData().length).toBe(64);
// Pause, then seek far ahead while paused (outside the ~10s of
// decoded coverage), then resume.
setIsPlaying(false);
await waitFor(() => !viz.isRunning(), 10000);
setPosition(30);
setIsPlaying(true);
// The resume position isn't decoded yet — loading, not frozen bars.
await waitFor(() => viz.isLoading(), 5000);
expect(viz.isRunning()).toBe(true);
// Playback advances past the resume point (mpv moves the clock);
// once the re-decode covers it, fresh bars replace the stale
// pre-pause frame (chirp spectrum at 30s ≠ 2s) and the loading
// state clears.
setPosition(31);
const barsBefore = viz.barData();
await waitFor(
() => !viz.isLoading() && viz.barData() !== barsBefore,
15000,
);
expect(viz.barData().length).toBe(64);
},
{ timeout: 30000 },
);
// After a long pause on a network stream, the player (mpv) re-buffers:
// `isPlaying` stays true but the position clock freezes. Without
// detection the waveform rendered the same cached window forever — static
// bars and no feedback. The render loop must report the stall as a
// loading state and clear it the moment the clock moves again.
test.skipIf(skip)(
"a frozen position clock while playing surfaces a stall; recovery clears it",
async () => {
const viz = useVisualizer();
await startPlaying();
expect(viz.isStalled()).toBe(false);
// Freeze the position: isPlaying stays true, the clock never moves.
await waitFor(() => viz.isStalled(), 10000);
// Player recovers — the clock advances again.
setPosition(4);
await waitFor(() => !viz.isStalled(), 3000);
expect(viz.isRunning()).toBe(true);
},
{ timeout: 20000 },
);
// Resume re-arms a pipeline whose ffmpeg pass was killed at pause: the
// stale pre-pause bars must not masquerade as live data while the player
// recovers. The spinner shows IN THEIR PLACE until the position clock
// advances past the resume point — a frozen clock (mpv re-buffering after
// a long pause) keeps the spinner even though the cache can serve the
// same window.
test.skipIf(skip)(
"resume shows the loading state in place of stale bars until the position clock advances",
async () => {
const viz = useVisualizer();
await startPlaying();
expect(viz.isLoading()).toBe(false);
// Pause, then resume against the still-covered position.
setIsPlaying(false);
await waitFor(() => !viz.isRunning(), 10000);
setIsPlaying(true);
// The spinner replaces the bars immediately on resume.
await waitFor(() => viz.isLoading(), 5000);
expect(viz.isRunning()).toBe(true);
// Position clock stays frozen at the resume point (re-buffering):
// the loading state must persist, not yield to static cached bars.
await Bun.sleep(250);
expect(viz.isLoading()).toBe(true);
// Player recovers — the clock advances → fresh bars, spinner gone.
setPosition(3);
await waitFor(() => !viz.isLoading(), 3000);
expect(viz.barData().length).toBe(64);
},
{ timeout: 20000 },
);
// ── Teardown ─────────────────────────────────────────────────────────────
afterAll(() => {