feat(player): waveform pipeline survives tab switches — 30s unload grace + braille spinner loading

The waveform's ffmpeg decode + cavacore FFT pipeline lived inside
RealtimeWaveform, so switching away from the Player tab unmounted it and
killed the pipeline instantly — respawning ffmpeg on every return.

Move the pipeline into a module-level store (stores/visualizer.ts) that
outlives the page:
- losing Player-tab focus keeps the pipeline warm for
  VISUALIZER_UNLOAD_DELAY_MS (30s), then tears it down (kills ffmpeg,
  destroys the cava plan); regaining focus within the delay resumes the
  warm pipeline with no restart churn; after an unload it restarts from
  the current playback position.
- playback signals move to utils/audio-signals.ts (module-level, no
  useAudio() owner needed) so the store reacts to play/pause/seek/speed
  while no Player page is mounted; useAudio re-imports them.
- render a braille spinner as the loading state for the visualizer
  (first play / after unload); stale bars stay on screen during warmup
  restarts so the waveform never blanks out for the network-bound cold
  start.
- seed the smooth position clock at pipeline start: with the position
  still frozen at 0 while mpv opens the stream, the reader sampled a
  1-sample window that could never fill, starving the bars until mpv's
  first poll.

Pins the store contract in tests/visualizer-store.test.ts: loading→bars,
warm resume without restart, 30s unload, and bars while position is
frozen at 0.
This commit is contained in:
2026-08-11 14:07:58 -04:00
parent 5e3ad48a2d
commit 8496922aaf
6 changed files with 687 additions and 236 deletions

View File

@@ -0,0 +1,222 @@
/**
* Visualizer store lifecycle tests — pin the waveform pipeline contract:
*
* - playback starts the pipeline and exposes a loading state until the
* first FFT frame renders (the braille-spinner window);
* - losing Player-tab focus does NOT kill a warm pipeline — it keeps
* rendering for the grace period, and regaining focus within the delay
* resumes it without a restart;
* - after VISUALIZER_UNLOAD_DELAY_MS unfocused the pipeline tears down
* (ffmpeg process + cava plan released).
*
* The store subscribes to the module-level signals in utils/audio-signals.ts
* (no useAudio mock — the signals are exported and driven directly, so this
* file can never leak a module mock into another test's worker).
*
* Uses a self-generated local WAV (a frequency chirp, so different playback
* positions produce measurably different bar output) and the real ffmpeg +
* native cavacore pipeline, mirroring audio-stream-reader.test.ts.
*
* Timing note: this is an integration test of the store's real timers — the
* unload path is a genuine `setTimeout` in the store, and bun 1.3.8 ships no
* fake-timer API (no `mock.timer`, no `vi.useFakeTimers`), so the grace
* period must be exercised against the platform clock. Delays are kept to
* the minimum that observes the contract (see the 30s unload test).
*/
import { test, expect, afterAll } from "bun:test";
import { tmpdir } from "os";
import { join } from "path";
import { setIsPlaying, setPosition, setCurrentEpisode } from "../src/utils/audio-signals";
import type { Episode } from "../src/types/episode";
// ── Sandbox (the app store reads config from XDG_CONFIG_HOME at first
// use; set before importing the store) ─────────────────────────────────
process.env.XDG_CONFIG_HOME = join(tmpdir(), `podtui-viz-test-${process.pid}`);
process.env.XDG_DATA_HOME = join(tmpdir(), `podtui-viz-data-${process.pid}`);
process.env.PODTUI_AUDIO_BACKEND = "none";
const { useVisualizer, VISUALIZER_UNLOAD_DELAY_MS } = await import(
"../src/stores/visualizer"
);
// ── Local chirp WAV (200Hz → 2kHz over 45s) ─────────────────────────────
const SAMPLE_RATE = 44100;
const F0 = 200;
const F1 = 2000;
const DURATION = 45;
const AMP = 30000;
const hasFfmpeg = !!Bun.which("ffmpeg");
const hasNativeLib = Bun.file(
join(process.cwd(), "src", "native", "libcavacore.dylib"),
).exists();
async function writeChirpWav(path: string): Promise<void> {
const total = Math.round(DURATION * SAMPLE_RATE);
const dataSize = total * 2;
const buf = new Uint8Array(44 + dataSize);
const dv = new DataView(buf.buffer);
const ascii = (off: number, s: string) => {
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
};
ascii(0, "RIFF");
dv.setUint32(4, 36 + dataSize, true);
ascii(8, "WAVE");
ascii(12, "fmt ");
dv.setUint32(16, 16, true);
dv.setUint16(20, 1, true); // PCM
dv.setUint16(22, 1, true); // mono
dv.setUint32(24, SAMPLE_RATE, true);
dv.setUint32(28, SAMPLE_RATE * 2, true);
dv.setUint16(32, 2, true);
dv.setUint16(34, 16, true);
ascii(36, "data");
dv.setUint32(40, dataSize, true);
// Linear chirp: instantaneous frequency sweeps F0 → F1 over DURATION.
const sweep = (F1 - F0) / DURATION;
for (let i = 0; i < total; i++) {
const t = i / SAMPLE_RATE;
const phase = 2 * Math.PI * (F0 * t + 0.5 * sweep * t * t);
dv.setInt16(44 + i * 2, Math.round(AMP * Math.sin(phase)), true);
}
await Bun.write(path, buf);
}
const wavPath = join(tmpdir(), `podtui-viz-${process.pid}-${Date.now()}.wav`);
await writeChirpWav(wavPath); // long enough to outlast the unload delay at readrate 1
// ── Helpers ─────────────────────────────────────────────────────────────
/** Poll `check` every 5ms until truthy; throw after `timeoutMs`. */
async function waitFor(
check: () => boolean,
timeoutMs = 10000,
): Promise<void> {
const start = Date.now();
while (!check()) {
if (Date.now() - start > timeoutMs) {
throw new Error("condition not met in time");
}
await Bun.sleep(5);
}
}
/**
* Start playback against the local WAV and wait for the first frame.
*
* The reader samples the window ENDING at the playback position, so a
* frozen position clock would serve a 1-sample window at position 0 and
* never produce a full frame (in production mpv advances the clock every
* poll). Drive the clock to 2s right after play — inside the 3s decode-head
* burst — so complete windows are available immediately.
*/
async function startPlaying(): Promise<void> {
const viz = useVisualizer();
viz.setBarCount(64);
viz.setFocused(true);
setCurrentEpisode({ audioUrl: wavPath } as unknown as Episode);
setIsPlaying(true);
setPosition(2);
await waitFor(() => viz.isRunning(), 10000);
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
}
const skip = !(hasFfmpeg && hasNativeLib);
// ── Tests ────────────────────────────────────────────────────────────────
test.skipIf(skip)(
"starts on playback: loading state first, then frequency bars",
async () => {
const viz = useVisualizer();
await startPlaying();
expect(viz.barData().length).toBe(64);
expect(viz.isLoading()).toBe(false);
},
{ timeout: 20000 },
);
// Regression: with the position clock frozen at the start position (mpv
// still opening the stream), the reader samples a window ending at the
// start — a 1-sample slice it can never fill. The smooth clock must be
// seeded at pipeline start so the interpolated target advances and bars
// render as soon as ffmpeg has ANY audio, not after the first position poll.
test.skipIf(skip)(
"renders bars while the position clock is still frozen at 0",
async () => {
const viz = useVisualizer();
viz.setBarCount(64);
viz.setFocused(true);
setCurrentEpisode({ audioUrl: wavPath } as unknown as Episode);
setIsPlaying(true);
// Deliberately do NOT advance the mock position: the clock stays at 0.
await waitFor(() => viz.isRunning(), 10000);
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
expect(viz.barData().length).toBe(64);
},
{ timeout: 20000 },
);
test.skipIf(skip)(
"losing focus keeps the warm pipeline alive; refocus within the delay resumes without restart",
async () => {
const viz = useVisualizer();
await startPlaying();
viz.setFocused(false);
// Not an instant teardown: observe the pipeline well inside the 30s
// grace window.
await Bun.sleep(500);
expect(viz.isRunning()).toBe(true);
expect(viz.isLoading()).toBe(false);
// Still live: moving the position clock changes the bars (chirp →
// different spectrum at 3s than at the 2s start position).
setPosition(3);
const barsBefore = viz.barData();
await waitFor(() => viz.barData() !== barsBefore, 3000);
// Refocus within the delay: warm pipeline, no restart — a restart
// would respawn ffmpeg and flash the loading state. Watch for that
// flash over a short observation window.
viz.setFocused(true);
let sawRestartLoading = false;
const start = Date.now();
while (Date.now() - start < 250) {
if (viz.isLoading()) sawRestartLoading = true;
await Bun.sleep(5);
}
expect(sawRestartLoading).toBe(false);
expect(viz.isRunning()).toBe(true);
expect(viz.barData().length).toBe(64);
},
{ timeout: 20000 },
);
// The store's unload is a real `setTimeout(VISUALIZER_UNLOAD_DELAY_MS)` with
// no injectable clock (bun 1.3.8 has no fake timers), so the grace period is
// exercised against the platform clock — this is the deliberate-exception
// case from the no-real-timers rule.
test.skipIf(skip)(
"unloads the pipeline after the unfocused grace delay",
async () => {
const viz = useVisualizer();
await startPlaying();
expect(viz.isRunning()).toBe(true);
viz.setFocused(false);
await Bun.sleep(VISUALIZER_UNLOAD_DELAY_MS + 1500);
expect(viz.isRunning()).toBe(false);
expect(viz.isLoading()).toBe(false);
},
{ timeout: 45000 },
);
// ── Teardown ─────────────────────────────────────────────────────────────
afterAll(() => {
// Release any pipeline still running (e.g. if a test failed midway).
setIsPlaying(false);
});