From 8496922aaf96fa799d1a0fcfb42b3d9932fa45c9 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 11 Aug 2026 14:07:58 -0400 Subject: [PATCH] =?UTF-8?q?feat(player):=20waveform=20pipeline=20survives?= =?UTF-8?q?=20tab=20switches=20=E2=80=94=2030s=20unload=20grace=20+=20brai?= =?UTF-8?q?lle=20spinner=20loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/hooks/useAudio.ts | 36 ++- src/pages/Player/PlayerPage.tsx | 25 +- src/pages/Player/RealtimeWaveform.tsx | 239 +++-------------- src/stores/visualizer.ts | 359 ++++++++++++++++++++++++++ src/utils/audio-signals.ts | 42 +++ tests/visualizer-store.test.ts | 222 ++++++++++++++++ 6 files changed, 687 insertions(+), 236 deletions(-) create mode 100644 src/stores/visualizer.ts create mode 100644 src/utils/audio-signals.ts create mode 100644 tests/visualizer-store.test.ts diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index 343238d..35f77d3 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -12,7 +12,7 @@ * ``` */ -import { createSignal, onCleanup } from "solid-js"; +import { onCleanup } from "solid-js"; import { unlinkSync } from "fs"; import { fetchCoverArt, coverTempPath } from "../utils/cover-art"; import { @@ -22,6 +22,26 @@ import { type BackendName, type DetectedPlayer, } from "../utils/audio-player"; +import { + isPlaying, + setIsPlaying, + position, + setPosition, + duration, + setDuration, + volume, + setVolume, + speed, + setSpeed, + backendName, + setBackendName, + error, + setError, + currentEpisode, + setCurrentEpisode, + availablePlayers, + setAvailablePlayers, +} from "../utils/audio-signals"; import { emit, on } from "../utils/event-bus"; import { useAppStore } from "../stores/app"; import { useProgressStore } from "../stores/progress"; @@ -71,17 +91,9 @@ let pollTimer: ReturnType | null = null; let refCount = 0; let pollCount = 0; // Counts poll ticks for throttling progress saves -const [isPlaying, setIsPlaying] = createSignal(false); -const [position, setPosition] = createSignal(0); -const [duration, setDuration] = createSignal(0); -const [volume, setVolume] = createSignal(1); -const [speed, setSpeed] = createSignal(1); -const [backendName, setBackendName] = createSignal("none"); -const [error, setError] = createSignal(null); -const [currentEpisode, setCurrentEpisode] = createSignal(null); -const [availablePlayers, setAvailablePlayers] = createSignal( - [], -); +// Playback signals are declared in utils/audio-signals.ts (imported above) +// so non-component consumers (the visualizer store) can subscribe without +// mounting a useAudio() owner. /** True once the current episode has been handed to the backend (play * started). `false` means the episode is only LOADED in the player (e.g. diff --git a/src/pages/Player/PlayerPage.tsx b/src/pages/Player/PlayerPage.tsx index ca6154d..7eca4aa 100644 --- a/src/pages/Player/PlayerPage.tsx +++ b/src/pages/Player/PlayerPage.tsx @@ -10,12 +10,12 @@ * tab root. */ -import { Show } from "solid-js"; +import { Show, onMount, onCleanup } from "solid-js"; import { PlaybackControls } from "./PlaybackControls"; import { ProgressBar } from "./ProgressBar"; import { RealtimeWaveform } from "./RealtimeWaveform"; import { useAudio } from "@/hooks/useAudio"; -import { useAppStore } from "@/stores/app"; +import { useVisualizer } from "@/stores/visualizer"; import { useTheme } from "@/context/ThemeContext"; import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext"; import { PaneRow } from "@/components/PaneRow"; @@ -27,8 +27,16 @@ export function PlayerPage() { const audio = useAudio(); const { theme } = useTheme(); const nav = useNavigation(); + const viz = useVisualizer(); const muted = () => theme.muted || theme.text; + // The page is mounted exactly while the Player tab is in focus (Shell + // renders only the active tab), so mount ⇔ focused. Report it to the + // visualizer store: losing focus starts the unload grace timer instead + // of killing the pipeline with the page; regaining focus restarts it. + onMount(() => viz.setFocused(true)); + onCleanup(() => viz.setFocused(false)); + const isActive = () => nav.activePane() === DEPTH_CENTER_PANE; const progressPercent = () => { @@ -82,18 +90,7 @@ export function PlayerPage() { - { - const viz = useAppStore().state().settings.visualizer; - // bars is width-derived in RealtimeWaveform; pass only the - // audio-processing params here. - return { - noiseReduction: viz.noiseReduction, - lowCutOff: viz.lowCutOff, - highCutOff: viz.highCutOff, - }; - })()} - /> + )} diff --git a/src/pages/Player/RealtimeWaveform.tsx b/src/pages/Player/RealtimeWaveform.tsx index 342f4ec..f469ab3 100644 --- a/src/pages/Player/RealtimeWaveform.tsx +++ b/src/pages/Player/RealtimeWaveform.tsx @@ -1,55 +1,30 @@ /** - * RealtimeWaveform — live audio frequency visualization using cavacore. + * RealtimeWaveform — renders the shared visualizer pipeline state. * - * Spawns an independent ffmpeg - * process to decode the audio stream, feeds PCM samples through cavacore - * for FFT analysis, and renders frequency bars as colored terminal - * characters at ~30fps. + * The pipeline (ffmpeg decode + cavacore FFT) lives in the module-level + * visualizer store (`@/stores/visualizer`), not in this component, so it + * survives PlayerPage unmounts: leaving the Player tab keeps the waveform + * warm for VISUALIZER_UNLOAD_DELAY_MS, then the store tears it down. + * + * 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. */ -import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js"; +import { createEffect, on } from "solid-js"; import { useTerminalDimensions } from "@opentui/solid"; -import { - loadCavaCore, - type CavaCore, - type CavaCoreConfig, -} from "@/utils/cavacore"; -import { AudioStreamReader } from "@/utils/audio-stream-reader"; -import { BAR_LEVELS, barChars, createBarScaler } from "@/utils/bar-mapping"; -import { useAudio } from "@/hooks/useAudio"; +import { useVisualizer } from "@/stores/visualizer"; import { useTheme } from "@/context/ThemeContext"; +import { LoadingIndicator } from "@/components/LoadingIndicator"; +import { BAR_LEVELS, barChars } from "@/utils/bar-mapping"; import { PANE_RATIO } from "@/utils/navigation"; -// ── Types ──────────────────────────────────────────────────────────── - -export type RealtimeWaveformProps = { - visualizerConfig?: Partial; -}; - -/** Target frame interval in ms (~30 fps) */ -const FRAME_INTERVAL = 33; - -/** Number of PCM samples to read per frame (512 is a good FFT window) */ -const SAMPLES_PER_FRAME = 512; - // ── Component ──────────────────────────────────────────────────────── -export function RealtimeWaveform(props: RealtimeWaveformProps) { +export function RealtimeWaveform() { const { theme } = useTheme(); - const audio = useAudio(); - - // Frequency bar values (0.0–1.0 per bar) - const [barData, setBarData] = createSignal([]); - - // Peak-follower scaler replaces cava's autosens: normalizes each FFT - // frame against the running peak so a loud start can't pin every bar - // at full height and quiet content still gets normalized up. - const scaler = createBarScaler(); - - let cava: CavaCore | null = null; - let reader: AudioStreamReader | null = null; - let frameTimer: ReturnType | null = null; - let sampleBuffer: Float64Array | null = null; + const viz = useVisualizer(); // Bar count scales with terminal width so the waveform fills its pane. // The player is a 2-pane row: current column = (current+preview) of @@ -68,181 +43,25 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { ); }; - // ── Lifecycle: init cavacore once ────────────────────────────────── - - const initCava = () => { - if (cava) return true; - - cava = loadCavaCore(); - if (!cava) { - return false; - } - - return true; - }; - - // ── Smooth position clock ────────────────────────────────────────── - // - // audio.position() updates at the useAudio poll rate (~150ms). Between - // polls, interpolate the position from wall time so the FFT window - // tracks the audio continuously instead of stepping. The 0.5s cap - // prevents extrapolating far beyond reality when the player stalls - // (e.g. network re-buffering). - - let lastPolledPosition = 0; - let lastPolledAt = 0; - const smoothPosition = () => { - const pos = audio.position(); - const now = performance.now(); - if (pos !== lastPolledPosition) { - lastPolledPosition = pos; - lastPolledAt = now; - return pos; - } - if (lastPolledAt === 0) return pos; - const elapsed = Math.min((now - lastPolledAt) / 1000, 0.5); - return lastPolledPosition + elapsed * (audio.speed() ?? 1); - }; - - // ── Start/stop the visualization pipeline ────────────────────────── - - const startVisualization = (url: string, position: number, speed: number) => { - stopVisualization(); - - if (!url || !initCava() || !cava) return; - - // Initialize cavacore with current resolution + any overrides. - // bars is width-derived (see numBars); visualizerConfig supplies the - // audio-processing params (noise reduction, cutoffs, etc.). - // autosens is disabled (after the spread so it always wins): cava's - // autosens gain-ramps during silence then clips everything to 1.0 - // when audio arrives — the JS peak scaler handles dynamics instead. - const config: CavaCoreConfig = { - bars: numBars(), - sampleRate: 44100, - channels: 1, - ...props.visualizerConfig, - autosens: 0, - }; - cava.init(config); - - // Pre-warm the FFT window: libcavacore's window is malloc'd - // uninitialized, so the first real frame would FFT garbage and - // render full-scale bars. One zero frame the size of the whole - // input buffer clears it (at 44.1kHz mono the window is 8192 - // samples — FFTbassbufferSize × channels; a 512-sample frame would - // leave the tail garbage). - cava.execute(new Float64Array(8192)); - - // Pre-allocate sample read buffer - sampleBuffer = new Float64Array(SAMPLES_PER_FRAME); - - // Start ffmpeg decode stream (reuse reader if same URL, else create new) - if (!reader || reader.url !== url) { - if (reader) reader.stop(); - reader = new AudioStreamReader({ url }); - } - reader.start(position, speed); - - frameTimer = setInterval(renderFrame, FRAME_INTERVAL); - }; - - const stopVisualization = () => { - if (frameTimer) { - clearInterval(frameTimer); - frameTimer = null; - } - if (reader) { - reader.stop(); - // Don't null reader — we reuse it across start/stop cycles - } - if (cava?.isReady) { - cava.destroy(); - } - sampleBuffer = null; - }; - - // ── Render loop (called at ~30fps) ───────────────────────────────── - - const renderFrame = () => { - if (!cava?.isReady || !reader?.running || !sampleBuffer) return; - - // Sample the FFT window at the player's position, not the decode - // head — the reader decodes independently (paced at the player's - // clock rate with a LEAD_SECONDS burst head start) and only the - // position clock ties the bars to what's actually playing. - const target = smoothPosition(); - const count = reader.read(sampleBuffer, target); - // Never feed a partial FFT window to cava. - if (count < sampleBuffer.length) return; - - const output = cava.execute(sampleBuffer); - - // Normalize against the running peak and copy to a new array - setBarData(scaler(output)); - }; - - createEffect( - on( - [ - audio.isPlaying, - () => audio.currentEpisode()?.audioUrl ?? "", - audio.speed, - numBars, - ], - ([playing, url, speed]) => { - if (playing && url) { - const pos = untrack(audio.position); - startVisualization(url, pos, speed); - } else { - stopVisualization(); - } - }, - ), - ); - - // ── Seek detection: lightweight effect for position jumps ────────── - // - // Watches position and restarts the reader (not the whole pipeline) - // only on significant jumps (>2s), which indicate a user seek. - // This is intentionally a separate effect — it should NOT trigger a - // full pipeline restart, just restart the ffmpeg stream at the new pos. - - let lastSyncPosition = 0; - createEffect( - on(audio.position, (pos) => { - if (!audio.isPlaying || !reader?.running) { - lastSyncPosition = pos; - return; - } - - const delta = Math.abs(pos - lastSyncPosition); - lastSyncPosition = pos; - - if (delta > 2) { - reader.restart(pos, audio.speed() ?? 1); - } - }), - ); - - onCleanup(() => { - stopVisualization(); - if (reader) { - reader.stop(); - reader = null; - } - // Don't null cava itself — it can be reused. But do destroy its plan. - if (cava?.isReady) { - cava.destroy(); - } - }); + // Keep the store's bar count in sync with the terminal width; the store + // re-inits the running pipeline when it changes (terminal resize). + createEffect(on(numBars, (n) => viz.setBarCount(n))); // ── Rendering ────────────────────────────────────────────────────── const renderLine = () => { - const bars = barData(); + 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()) { + return ; + } + if (bars.length === 0) { const placeholder = ".".repeat(count); return ( diff --git a/src/stores/visualizer.ts b/src/stores/visualizer.ts new file mode 100644 index 0000000..0d2af64 --- /dev/null +++ b/src/stores/visualizer.ts @@ -0,0 +1,359 @@ +/** + * visualizer-store — module-level singleton owning the realtime waveform + * pipeline (ffmpeg decode + cavacore FFT), shared across PlayerPage mounts. + * + * The pipeline lives here rather than in the Player page component so it can + * outlive the page: Shell unmounts a tab's page the moment the tab loses + * focus, which would otherwise kill the ffmpeg decode + FFT loop instantly. + * Instead the store keeps the visualization warm for UNLOAD_DELAY_MS after + * the Player tab stops being focused, then tears it down (kills ffmpeg, + * destroys the cava plan). Returning to the tab within the delay resumes + * seamlessly; after an unload, regaining focus restarts the pipeline from + * the current playback position. + * + * The store subscribes to the module-level playback signals in + * `utils/audio-signals.ts` (`audioPlaybackSignals`), so it reacts to + * play/pause/seek/speed even while no Player page is mounted. `focused` is + * fed by PlayerPage (mounted ⇔ Player tab visible), `barCount` by + * RealtimeWaveform (terminal width). + */ + +import { + createSignal, + createEffect, + createRoot, + on, + untrack, +} from "solid-js"; +import { + loadCavaCore, + type CavaCore, + type CavaCoreConfig, +} from "@/utils/cavacore"; +import { AudioStreamReader } from "@/utils/audio-stream-reader"; +import { createBarScaler } from "@/utils/bar-mapping"; +import { audioPlaybackSignals } from "@/utils/audio-signals"; +import { useAppStore } from "@/stores/app"; + +// ── Constants ──────────────────────────────────────────────────────────── + +/** How long the pipeline keeps running after the Player tab loses focus. */ +export const VISUALIZER_UNLOAD_DELAY_MS = 30_000; + +/** Target frame interval in ms (~30 fps) */ +const FRAME_INTERVAL = 33; + +/** Number of PCM samples to read per frame (512 is a good FFT window) */ +const SAMPLES_PER_FRAME = 512; + +// ── Types ──────────────────────────────────────────────────────────────── + +export interface VisualizerStore { + /** Frequency bar values (0.0–1.0 per bar), empty until the first frame. */ + barData: () => number[]; + /** True from pipeline start until the first complete FFT frame renders. */ + isLoading: () => boolean; + /** True while the ~30fps render loop is armed. */ + isRunning: () => boolean; + /** Report whether the Player tab is the visible tab. */ + setFocused: (focused: boolean) => void; + /** Report the terminal-width-derived bar count (resize re-inits). */ + setBarCount: (count: number) => void; +} + +// ── Store factory ──────────────────────────────────────────────────────── + +function createVisualizerStore(): VisualizerStore { + // Frequency bar values (0.0–1.0 per bar) + const [barData, setBarData] = createSignal([]); + + // True from pipeline start until the first complete FFT frame renders. + const [isLoading, setIsLoading] = createSignal(false); + + // Whether the Player tab is the visible tab (fed by PlayerPage). + const [focused, setFocused] = createSignal(false); + + // Width-derived bar count (fed by RealtimeWaveform; default before the + // renderer reports a real size). + const [barCount, setBarCount] = createSignal(64); + + // Peak-follower scaler replaces cava's autosens: normalizes each FFT + // frame against the running peak so a loud start can't pin every bar + // at full height and quiet content still gets normalized up. + const scaler = createBarScaler(); + + let cava: CavaCore | null = null; + let reader: AudioStreamReader | null = null; + let frameTimer: ReturnType | null = null; + let sampleBuffer: Float64Array | null = null; + let unloadTimer: ReturnType | null = null; + + // What the running pipeline was started with — lets the playback effect + // tell "nothing changed, stay warm" from "must restart". + let activeUrl = ""; + let activeSpeed = 1; + let activeBars = 64; + + // ── Lifecycle helpers ────────────────────────────────────────────── + + const clearUnloadTimer = () => { + if (unloadTimer) { + clearTimeout(unloadTimer); + unloadTimer = null; + } + }; + + const initCava = () => { + if (cava) return true; + + cava = loadCavaCore(); + if (!cava) { + return false; + } + + return true; + }; + + // ── Smooth position clock ────────────────────────────────────────── + // + // audio.position() updates at the useAudio poll rate (~150ms). Between + // polls, interpolate the position from wall time so the FFT window + // tracks the audio continuously instead of stepping. The 0.5s cap + // prevents extrapolating far beyond reality when the player stalls + // (e.g. network re-buffering). + + let lastPolledPosition = 0; + let lastPolledAt = 0; + const smoothPosition = () => { + const pos = audioPlaybackSignals.position(); + const now = performance.now(); + if (pos !== lastPolledPosition) { + lastPolledPosition = pos; + lastPolledAt = now; + return pos; + } + if (lastPolledAt === 0) return pos; + const elapsed = Math.min((now - lastPolledAt) / 1000, 0.5); + return lastPolledPosition + elapsed * (audioPlaybackSignals.speed() ?? 1); + }; + + // ── Start/stop the visualization pipeline ────────────────────────── + + const startVisualization = (url: string, position: number, speed: number) => { + stopVisualization(); + + if (!url || !initCava() || !cava) return; + + // Initialize cavacore with current resolution + the user's + // audio-processing params (noise reduction, cutoffs, etc.). + // autosens is disabled (after the spread so it always wins): cava's + // autosens gain-ramps during silence then clips everything to 1.0 + // when audio arrives — the JS peak scaler handles dynamics instead. + const viz = useAppStore().state().settings.visualizer; + const config: CavaCoreConfig = { + bars: barCount(), + sampleRate: 44100, + channels: 1, + noiseReduction: viz.noiseReduction, + lowCutOff: viz.lowCutOff, + highCutOff: viz.highCutOff, + autosens: 0, + }; + cava.init(config); + + // Pre-warm the FFT window: libcavacore's window is malloc'd + // uninitialized, so the first real frame would FFT garbage and + // render full-scale bars. One zero frame the size of the whole + // input buffer clears it (at 44.1kHz mono the window is 8192 + // samples — FFTbassbufferSize × channels; a 512-sample frame would + // leave the tail garbage). + cava.execute(new Float64Array(8192)); + + // Pre-allocate sample read buffer + sampleBuffer = new Float64Array(SAMPLES_PER_FRAME); + + // Start ffmpeg decode stream (reuse reader if same URL, else create new) + if (!reader || reader.url !== url) { + if (reader) reader.stop(); + reader = new AudioStreamReader({ url }); + } + reader.start(position, speed); + + // Seed the smooth position clock with the start position. Without + // this, a fresh play at position 0 would sample the window ending at + // exactly 0 — a 1-sample slice the reader can never fill — so the + // bars would be starved until the first mpv poll advanced the + // position clock. Seeding makes the interpolated target advance + // immediately, so bars render as soon as ffmpeg has any audio. + lastPolledPosition = position; + lastPolledAt = performance.now(); + + activeUrl = url; + activeSpeed = speed; + activeBars = barCount(); + setIsLoading(true); + frameTimer = setInterval(renderFrame, FRAME_INTERVAL); + }; + + const stopVisualization = () => { + clearUnloadTimer(); + if (frameTimer) { + clearInterval(frameTimer); + frameTimer = null; + } + if (reader) { + reader.stop(); + // Don't null reader — we reuse it across start/stop cycles + } + if (cava?.isReady) { + cava.destroy(); + } + sampleBuffer = null; + setIsLoading(false); + }; + + // ── Render loop (called at ~30fps) ───────────────────────────────── + + const renderFrame = () => { + if (!cava?.isReady || !reader?.running || !sampleBuffer) return; + + // Sample the FFT window at the player's position, not the decode + // head — the reader decodes independently (paced at the player's + // clock rate with a LEAD_SECONDS burst head start) and only the + // position clock ties the bars to what's actually playing. + const target = smoothPosition(); + const count = reader.read(sampleBuffer, target); + // Never feed a partial FFT window to cava. + if (count < sampleBuffer.length) return; + + const output = cava.execute(sampleBuffer); + + // Normalize against the running peak and copy to a new array + setBarData(scaler(output)); + if (isLoading()) setIsLoading(false); + }; + + // ── Playback subscription ────────────────────────────────────────── + // + // Keeps the pipeline matched to playback. `focused` is a dep so focus + // regain re-evaluates (and can restart an unloaded pipeline), but the + // guard below makes a focus flip on an already-correct warm pipeline a + // no-op — no churn when flipping back to the Player tab within the + // unload delay. A real change (url/speed/barCount, stop/start, or a + // stale pipeline after an unload) restarts from the current position. + + createEffect( + on( + [ + audioPlaybackSignals.isPlaying, + () => audioPlaybackSignals.currentEpisode()?.audioUrl ?? "", + audioPlaybackSignals.speed, + barCount, + focused, + ], + ([playing, url, speed]) => { + if (!playing || !url) { + stopVisualization(); + return; + } + // Warm and already correct — nothing to do (e.g. focus + // regained within the unload delay). + if ( + frameTimer !== null && + url === activeUrl && + speed === activeSpeed && + barCount() === activeBars + ) { + return; + } + if (!focused()) return; // playing away: stay warm; unload timer decides + startVisualization( + url, + untrack(audioPlaybackSignals.position), + speed, + ); + }, + ), + ); + + // ── Focus subscription: unload after the grace delay ─────────────── + + createEffect( + on(focused, (f) => { + clearUnloadTimer(); + if (f) { + // Pipeline was unloaded (or never started) but playback is + // still going — restart from the current position. When the + // pipeline is warm the playback effect above is the one that + // acts (guard: no-op for an unchanged warm pipeline). + if ( + audioPlaybackSignals.isPlaying() && + audioPlaybackSignals.currentEpisode()?.audioUrl && + frameTimer === null + ) { + startVisualization( + audioPlaybackSignals.currentEpisode()!.audioUrl, + untrack(audioPlaybackSignals.position), + audioPlaybackSignals.speed() ?? 1, + ); + } + } else if (frameTimer !== null) { + unloadTimer = setTimeout(() => { + unloadTimer = null; + stopVisualization(); + }, VISUALIZER_UNLOAD_DELAY_MS); + } + }), + ); + + // ── Seek detection: lightweight effect for position jumps ────────── + // + // Watches position and restarts the reader (not the whole pipeline) + // only on significant jumps (>2s), which indicate a user seek. + // This is intentionally a separate effect — it should NOT trigger a + // full pipeline restart, just restart the ffmpeg stream at the new pos. + + let lastSyncPosition = 0; + createEffect( + on(audioPlaybackSignals.position, (pos) => { + if (!audioPlaybackSignals.isPlaying() || !reader?.running) { + lastSyncPosition = pos; + return; + } + + const delta = Math.abs(pos - lastSyncPosition); + lastSyncPosition = pos; + + if (delta > 2) { + reader.restart(pos, audioPlaybackSignals.speed() ?? 1); + } + }), + ); + + return { + // state + barData, + isLoading, + isRunning: () => frameTimer !== null, + // inputs + setFocused, + setBarCount, + }; +} + +// ── Singleton ───────────────────────────────────────────────────────────── + +let visualizerStoreInstance: VisualizerStore | null = null; + +/** + * Accessor for the shared visualizer store. Created once inside a + * `createRoot` so its effects are owned by a detached root — not by + * whichever component happens to call first (PlayerPage unmounts would + * otherwise dispose the pipeline effects with it). + */ +export function useVisualizer(): VisualizerStore { + if (!visualizerStoreInstance) { + visualizerStoreInstance = createRoot(() => createVisualizerStore()); + } + return visualizerStoreInstance; +} diff --git a/src/utils/audio-signals.ts b/src/utils/audio-signals.ts new file mode 100644 index 0000000..2232bb1 --- /dev/null +++ b/src/utils/audio-signals.ts @@ -0,0 +1,42 @@ +/** + * audio-signals — module-level playback state shared by useAudio and + * non-component consumers. + * + * useAudio's playback state is a module-level singleton (signals live at + * module scope, every `useAudio()` call shares them). Those signals are + * declared here so components that must react to playback WITHOUT mounting + * a `useAudio()` owner — the visualizer store — can subscribe directly via + * `audioPlaybackSignals` (or the individual accessors/setters), instead of + * going through the hook. `useAudio()` re-exports nothing from this module + * for callers; it imports the accessors and setters for its own use. + */ + +import { createSignal } from "solid-js"; +import type { Episode } from "../types/episode"; +import type { BackendName, DetectedPlayer } from "./audio-player"; + +export const [isPlaying, setIsPlaying] = createSignal(false); +export const [position, setPosition] = createSignal(0); +export const [duration, setDuration] = createSignal(0); +export const [volume, setVolume] = createSignal(1); +export const [speed, setSpeed] = createSignal(1); +export const [backendName, setBackendName] = createSignal("none"); +export const [error, setError] = createSignal(null); +export const [currentEpisode, setCurrentEpisode] = createSignal( + null, +); +export const [availablePlayers, setAvailablePlayers] = createSignal< + DetectedPlayer[] +>([]); + +/** + * The playback signals the visualizer pipeline reacts to. `useAudio()` + * itself remains the component-facing surface; this is for module-level + * consumers that must track playback without a component owner. + */ +export const audioPlaybackSignals = { + isPlaying, + position, + speed, + currentEpisode, +} as const; diff --git a/tests/visualizer-store.test.ts b/tests/visualizer-store.test.ts new file mode 100644 index 0000000..6b3570e --- /dev/null +++ b/tests/visualizer-store.test.ts @@ -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 { + 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 { + 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 { + 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); +});