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:
@@ -12,7 +12,7 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createSignal, onCleanup } from "solid-js";
|
import { onCleanup } from "solid-js";
|
||||||
import { unlinkSync } from "fs";
|
import { unlinkSync } from "fs";
|
||||||
import { fetchCoverArt, coverTempPath } from "../utils/cover-art";
|
import { fetchCoverArt, coverTempPath } from "../utils/cover-art";
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +22,26 @@ import {
|
|||||||
type BackendName,
|
type BackendName,
|
||||||
type DetectedPlayer,
|
type DetectedPlayer,
|
||||||
} from "../utils/audio-player";
|
} 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 { emit, on } from "../utils/event-bus";
|
||||||
import { useAppStore } from "../stores/app";
|
import { useAppStore } from "../stores/app";
|
||||||
import { useProgressStore } from "../stores/progress";
|
import { useProgressStore } from "../stores/progress";
|
||||||
@@ -71,17 +91,9 @@ let pollTimer: ReturnType<typeof setInterval> | null = null;
|
|||||||
let refCount = 0;
|
let refCount = 0;
|
||||||
let pollCount = 0; // Counts poll ticks for throttling progress saves
|
let pollCount = 0; // Counts poll ticks for throttling progress saves
|
||||||
|
|
||||||
const [isPlaying, setIsPlaying] = createSignal(false);
|
// Playback signals are declared in utils/audio-signals.ts (imported above)
|
||||||
const [position, setPosition] = createSignal(0);
|
// so non-component consumers (the visualizer store) can subscribe without
|
||||||
const [duration, setDuration] = createSignal(0);
|
// mounting a useAudio() owner.
|
||||||
const [volume, setVolume] = createSignal(1);
|
|
||||||
const [speed, setSpeed] = createSignal(1);
|
|
||||||
const [backendName, setBackendName] = createSignal<BackendName>("none");
|
|
||||||
const [error, setError] = createSignal<string | null>(null);
|
|
||||||
const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(null);
|
|
||||||
const [availablePlayers, setAvailablePlayers] = createSignal<DetectedPlayer[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
/** True once the current episode has been handed to the backend (play
|
/** 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.
|
* started). `false` means the episode is only LOADED in the player (e.g.
|
||||||
|
|||||||
@@ -10,12 +10,12 @@
|
|||||||
* tab root.
|
* tab root.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Show } from "solid-js";
|
import { Show, onMount, onCleanup } from "solid-js";
|
||||||
import { PlaybackControls } from "./PlaybackControls";
|
import { PlaybackControls } from "./PlaybackControls";
|
||||||
import { ProgressBar } from "./ProgressBar";
|
import { ProgressBar } from "./ProgressBar";
|
||||||
import { RealtimeWaveform } from "./RealtimeWaveform";
|
import { RealtimeWaveform } from "./RealtimeWaveform";
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useVisualizer } from "@/stores/visualizer";
|
||||||
import { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
import { useNavigation, DEPTH_CENTER_PANE } from "@/context/NavigationContext";
|
||||||
import { PaneRow } from "@/components/PaneRow";
|
import { PaneRow } from "@/components/PaneRow";
|
||||||
@@ -27,8 +27,16 @@ export function PlayerPage() {
|
|||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
const viz = useVisualizer();
|
||||||
const muted = () => theme.muted || theme.text;
|
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 isActive = () => nav.activePane() === DEPTH_CENTER_PANE;
|
||||||
|
|
||||||
const progressPercent = () => {
|
const progressPercent = () => {
|
||||||
@@ -82,18 +90,7 @@ export function PlayerPage() {
|
|||||||
|
|
||||||
<ProgressBar />
|
<ProgressBar />
|
||||||
|
|
||||||
<RealtimeWaveform
|
<RealtimeWaveform />
|
||||||
visualizerConfig={(() => {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
})()}
|
|
||||||
/>
|
|
||||||
</box>
|
</box>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,55 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* RealtimeWaveform — live audio frequency visualization using cavacore.
|
* RealtimeWaveform — renders the shared visualizer pipeline state.
|
||||||
*
|
*
|
||||||
* Spawns an independent ffmpeg
|
* The pipeline (ffmpeg decode + cavacore FFT) lives in the module-level
|
||||||
* process to decode the audio stream, feeds PCM samples through cavacore
|
* visualizer store (`@/stores/visualizer`), not in this component, so it
|
||||||
* for FFT analysis, and renders frequency bars as colored terminal
|
* survives PlayerPage unmounts: leaving the Player tab keeps the waveform
|
||||||
* characters at ~30fps.
|
* 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 { useTerminalDimensions } from "@opentui/solid";
|
||||||
import {
|
import { useVisualizer } from "@/stores/visualizer";
|
||||||
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 { useTheme } from "@/context/ThemeContext";
|
import { useTheme } from "@/context/ThemeContext";
|
||||||
|
import { LoadingIndicator } from "@/components/LoadingIndicator";
|
||||||
|
import { BAR_LEVELS, barChars } from "@/utils/bar-mapping";
|
||||||
import { PANE_RATIO } from "@/utils/navigation";
|
import { PANE_RATIO } from "@/utils/navigation";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export type RealtimeWaveformProps = {
|
|
||||||
visualizerConfig?: Partial<CavaCoreConfig>;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 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 ────────────────────────────────────────────────────────
|
// ── Component ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
export function RealtimeWaveform() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const audio = useAudio();
|
const viz = useVisualizer();
|
||||||
|
|
||||||
// Frequency bar values (0.0–1.0 per bar)
|
|
||||||
const [barData, setBarData] = createSignal<number[]>([]);
|
|
||||||
|
|
||||||
// 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<typeof setInterval> | null = null;
|
|
||||||
let sampleBuffer: Float64Array | null = null;
|
|
||||||
|
|
||||||
// Bar count scales with terminal width so the waveform fills its pane.
|
// Bar count scales with terminal width so the waveform fills its pane.
|
||||||
// The player is a 2-pane row: current column = (current+preview) of
|
// 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 ──────────────────────────────────
|
// Keep the store's bar count in sync with the terminal width; the store
|
||||||
|
// re-inits the running pipeline when it changes (terminal resize).
|
||||||
const initCava = () => {
|
createEffect(on(numBars, (n) => viz.setBarCount(n)));
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Rendering ──────────────────────────────────────────────────────
|
// ── Rendering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const renderLine = () => {
|
const renderLine = () => {
|
||||||
const bars = barData();
|
const bars = viz.barData();
|
||||||
const count = numBars();
|
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 <LoadingIndicator />;
|
||||||
|
}
|
||||||
|
|
||||||
if (bars.length === 0) {
|
if (bars.length === 0) {
|
||||||
const placeholder = ".".repeat(count);
|
const placeholder = ".".repeat(count);
|
||||||
return (
|
return (
|
||||||
|
|||||||
359
src/stores/visualizer.ts
Normal file
359
src/stores/visualizer.ts
Normal file
@@ -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<number[]>([]);
|
||||||
|
|
||||||
|
// 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<typeof setInterval> | null = null;
|
||||||
|
let sampleBuffer: Float64Array | null = null;
|
||||||
|
let unloadTimer: ReturnType<typeof setTimeout> | 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;
|
||||||
|
}
|
||||||
42
src/utils/audio-signals.ts
Normal file
42
src/utils/audio-signals.ts
Normal file
@@ -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<BackendName>("none");
|
||||||
|
export const [error, setError] = createSignal<string | null>(null);
|
||||||
|
export const [currentEpisode, setCurrentEpisode] = createSignal<Episode | null>(
|
||||||
|
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;
|
||||||
222
tests/visualizer-store.test.ts
Normal file
222
tests/visualizer-store.test.ts
Normal 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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user