Two fragility points, rebuilt at the root: Playback: one resident mpv daemon (--idle --keep-open) with a persistent IPC connection and observe_property state instead of spawn-per-episode and connect-per-poll. Play/pause/seek are sub-ms commands; time-pos pushes at ~20Hz; external pauses arrive as events. Boot session restore preloads the episode paused (loadfile + paused time-pos seek, since mpv defers --start stream work until playback) so first Play is a ~400ms unpause instead of a cold 4.3s open+seek. Load ops are mutex-serialized so a raced preload cannot clobber an in-flight play. Data throttling: mpv demuxer cache capped (cache-secs=90, max-bytes=40MiB) so a paused preload no longer races to its 150MiB default (measured 45.7MB/12s); decoder paced at 4x realtime instead of 84x so playback start isn't starved by the visualizer ripping the whole episode. Visualization: replaced the paced-ring reader (AudioStreamReader) with a position-indexed PCM cache (audio-pcm-cache). ffmpeg fills a cache indexed by absolute playback time; reads at the player position are always exact. Pause freezes the render loop, resume re-arms it — no coverage guessing, no clamped-buffer freeze (the pause->broken-waveform->freeze bug). Seeks and speed changes need no pipeline restarts; uncovered reads return empty and the last frame holds. Cover art: persistent per-URL disk cache under XDG cache dir; play() no longer awaits a curl subprocess (up to 8s). Cache hit = one stat; misses apply late via mpv video-add. Test suite: 161 pass. New tests pin the position-index contract (sample- exact window reads, hold-on-uncovered, pause-keeps-cache, seek segments), the daemon contract (play/pause/resume/seek/stop, preload fast path, EOF->replay), and cover cache/single-flight/404.
248 lines
9.3 KiB
TypeScript
248 lines
9.3 KiB
TypeScript
/**
|
|
* 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-pcm-cache.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 { useAppStore } from "../src/stores/app";
|
|
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 },
|
|
);
|
|
|
|
// Settings master switch: turning the visualizer off must tear the running
|
|
// pipeline down (not just hide the component), and re-enabling restarts it
|
|
// from the current position.
|
|
test.skipIf(skip)(
|
|
"disabling the visualizer stops a running pipeline; re-enabling restarts it",
|
|
async () => {
|
|
const viz = useVisualizer();
|
|
const app = useAppStore();
|
|
await app.whenReady();
|
|
await startPlaying();
|
|
expect(viz.isRunning()).toBe(true);
|
|
|
|
app.updateVisualizer({ enabled: false });
|
|
await waitFor(() => !viz.isRunning(), 10000);
|
|
expect(viz.isLoading()).toBe(false);
|
|
|
|
app.updateVisualizer({ enabled: true });
|
|
await waitFor(() => viz.isRunning(), 10000);
|
|
await waitFor(() => !viz.isLoading() && viz.barData().length > 0, 10000);
|
|
expect(viz.barData().length).toBe(64);
|
|
},
|
|
{ timeout: 20000 },
|
|
);
|
|
|
|
// ── Teardown ─────────────────────────────────────────────────────────────
|
|
|
|
afterAll(() => {
|
|
// Release any pipeline still running (e.g. if a test failed midway).
|
|
setIsPlaying(false);
|
|
});
|