feat(audio): rebuild playback + visualization on resident daemon and PCM cache
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.
This commit is contained in:
@@ -13,8 +13,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { onCleanup } from "solid-js";
|
import { onCleanup } from "solid-js";
|
||||||
import { unlinkSync } from "fs";
|
import {
|
||||||
import { fetchCoverArt, coverTempPath } from "../utils/cover-art";
|
cachedCoverPath,
|
||||||
|
fetchCoverArt,
|
||||||
|
prefetchCoverArt,
|
||||||
|
} from "../utils/cover-art";
|
||||||
import {
|
import {
|
||||||
createAudioBackend,
|
createAudioBackend,
|
||||||
detectPlayers,
|
detectPlayers,
|
||||||
@@ -158,11 +161,6 @@ function registerExitTeardown(): void {
|
|||||||
} catch {
|
} catch {
|
||||||
/* best-effort at exit */
|
/* best-effort at exit */
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
unlinkSync(coverTempPath());
|
|
||||||
} catch {
|
|
||||||
/* best-effort at exit */
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
process.on("exit", teardown);
|
process.on("exit", teardown);
|
||||||
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
|
||||||
@@ -231,6 +229,15 @@ function startPolling(): void {
|
|||||||
try {
|
try {
|
||||||
pollCount++;
|
pollCount++;
|
||||||
if (isPlaying()) {
|
if (isPlaying()) {
|
||||||
|
// Track ended (eof-reached observed) or process died. Check
|
||||||
|
// BEFORE pause reconciliation: mpv keeps the file open at EOF
|
||||||
|
// and reports pause=true there, which would otherwise be
|
||||||
|
// mistaken for an external pause and never finalize.
|
||||||
|
if (!backend.isPlaying()) {
|
||||||
|
finalizeTrackEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// mpv can pause itself outside PodTUI. Reconcile instead of
|
// mpv can pause itself outside PodTUI. Reconcile instead of
|
||||||
// staying stuck on "playing" with a frozen waveform
|
// staying stuck on "playing" with a frozen waveform
|
||||||
// (getPosition would just re-read the same frozen time-pos).
|
// (getPosition would just re-read the same frozen time-pos).
|
||||||
@@ -256,11 +263,6 @@ function startPolling(): void {
|
|||||||
media.setPosition(pos);
|
media.setPosition(pos);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if backend stopped playing (track ended)
|
|
||||||
if (!backend.isPlaying()) {
|
|
||||||
finalizeTrackEnd();
|
|
||||||
}
|
|
||||||
} else if (pollCount % PAUSE_WATCH_TICKS === 0) {
|
} else if (pollCount % PAUSE_WATCH_TICKS === 0) {
|
||||||
// Paused — watch for playback restarted from outside (AirPods,
|
// Paused — watch for playback restarted from outside (AirPods,
|
||||||
// lock-screen/media-center play). Only while the player is
|
// lock-screen/media-center play). Only while the player is
|
||||||
@@ -314,9 +316,12 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
const feedStore = useFeedStore();
|
const feedStore = useFeedStore();
|
||||||
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId);
|
||||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
const coverArtPath = feed?.podcast.coverUrl
|
// Cover art must NEVER gate playback (it was a curl subprocess blocking
|
||||||
? await fetchCoverArt(feed.podcast.coverUrl)
|
// play() by up to 8s). Serve the disk-cached file synchronously when it
|
||||||
: null;
|
// exists; on a miss, start playback bare and fetch in the background —
|
||||||
|
// the backend applies late art at runtime (mpv video-add).
|
||||||
|
const coverUrl = feed?.podcast.coverUrl;
|
||||||
|
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
|
||||||
|
|
||||||
// Resume from saved progress if available and not completed
|
// Resume from saved progress if available and not completed
|
||||||
const savedProgress = progressStore.get(episode.id);
|
const savedProgress = progressStore.get(episode.id);
|
||||||
@@ -333,6 +338,16 @@ async function play(episode: Episode): Promise<void> {
|
|||||||
coverArtPath: coverArtPath ?? undefined,
|
coverArtPath: coverArtPath ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (coverUrl && !coverArtPath) {
|
||||||
|
fetchCoverArt(coverUrl)
|
||||||
|
.then((path) => {
|
||||||
|
if (path && currentEpisode()?.id === episode.id) {
|
||||||
|
b.addCoverArt(path).catch(() => {});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
setCurrentEpisode(episode);
|
setCurrentEpisode(episode);
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
setPosition(startPos);
|
setPosition(startPos);
|
||||||
@@ -404,6 +419,29 @@ async function load(episode: Episode): Promise<void> {
|
|||||||
media.setPlaybackState(false);
|
media.setPlaybackState(false);
|
||||||
if (pos > 0) media.setPosition(pos);
|
if (pos > 0) media.setPosition(pos);
|
||||||
|
|
||||||
|
// Preload the episode into the backend PAUSED: mpv opens the stream and
|
||||||
|
// fills its demuxer cache while parked, so the user's first Play flips
|
||||||
|
// `pause` off instead of paying the ~2s stream-open cold. Fire-and-forget
|
||||||
|
// — a failed preload just makes the first play take the cold path.
|
||||||
|
if (episode.audioUrl && backend) {
|
||||||
|
const coverUrl = feed?.podcast.coverUrl;
|
||||||
|
if (coverUrl) prefetchCoverArt(coverUrl);
|
||||||
|
const backendSnap = backend;
|
||||||
|
backendSnap
|
||||||
|
.preload(episode.audioUrl, {
|
||||||
|
volume: volume(),
|
||||||
|
speed: storeSpeed || speed(),
|
||||||
|
startPosition: pos > 0 ? pos : undefined,
|
||||||
|
mediaTitle: podcastTitle
|
||||||
|
? `${podcastTitle} — ${episode.title}`
|
||||||
|
: episode.title,
|
||||||
|
coverArtPath: coverUrl
|
||||||
|
? (cachedCoverPath(coverUrl) ?? undefined)
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
saveLastPlayerToFile({ episodeId: episode.id, timestamp: new Date() });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -564,9 +602,8 @@ async function switchBackend(name: BackendName): Promise<void> {
|
|||||||
.feeds()
|
.feeds()
|
||||||
.find((f) => f.podcast.id === ep.podcastId);
|
.find((f) => f.podcast.id === ep.podcastId);
|
||||||
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
const podcastTitle = feed?.customName || feed?.podcast.title || "";
|
||||||
const coverArtPath = feed?.podcast.coverUrl
|
const coverUrl = feed?.podcast.coverUrl;
|
||||||
? await fetchCoverArt(feed.podcast.coverUrl)
|
const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null;
|
||||||
: null;
|
|
||||||
await backend.play(ep.audioUrl, {
|
await backend.play(ep.audioUrl, {
|
||||||
startPosition: pos,
|
startPosition: pos,
|
||||||
volume: vol,
|
volume: vol,
|
||||||
|
|||||||
@@ -2,20 +2,29 @@
|
|||||||
* visualizer-store — module-level singleton owning the realtime waveform
|
* visualizer-store — module-level singleton owning the realtime waveform
|
||||||
* pipeline (ffmpeg decode + cavacore FFT), shared across PlayerPage mounts.
|
* pipeline (ffmpeg decode + cavacore FFT), shared across PlayerPage mounts.
|
||||||
*
|
*
|
||||||
* The pipeline lives here rather than in the Player page component so it can
|
* Pipeline shape (see utils/audio-pcm-cache.ts for the rationale):
|
||||||
* outlive the page: Shell unmounts a tab's page the moment the tab loses
|
* an ffmpeg process decodes the episode at full speed into a
|
||||||
* focus, which would otherwise kill the ffmpeg decode + FFT loop instantly.
|
* position-indexed PCM cache; the render loop reads the window ending at
|
||||||
* Instead the store keeps the visualization warm for UNLOAD_DELAY_MS after
|
* the player's current position from that cache. Because reads are
|
||||||
* the Player tab stops being focused, then tears it down (kills ffmpeg,
|
* indexed by playback time, PAUSE/RESUME/SEEK/SPEED need no pipeline
|
||||||
* destroys the cava plan). Returning to the tab within the delay resumes
|
* choreography at all — and cannot desync:
|
||||||
* seamlessly; after an unload, regaining focus restarts the pipeline from
|
|
||||||
* the current playback position.
|
|
||||||
*
|
*
|
||||||
* The store subscribes to the module-level playback signals in
|
* - Pause: stop the render loop and the decode pass; the PCM cache stays
|
||||||
* `utils/audio-signals.ts` (`audioPlaybackSignals`), so it reacts to
|
* resident. Bars freeze on the last rendered frame.
|
||||||
* play/pause/seek/speed even while no Player page is mounted. `focused` is
|
* - Resume: re-arm the render loop — bars render instantly from the cache
|
||||||
* fed by PlayerPage (mounted ⇔ Player tab visible), `barCount` by
|
* — and continue the tail decode in the background. No cold start, no
|
||||||
* RealtimeWaveform (terminal width).
|
* coverage guessing, no clamped-buffer freeze (the old bug: resume
|
||||||
|
* re-armed the loop over a DEAD ffmpeg and the bars exhausted the ring
|
||||||
|
* buffer, then froze on a repeated stale window forever).
|
||||||
|
* - Seek into decoded audio: nothing to do. Seek into a hole: kick off a
|
||||||
|
* decode segment there; the last frame holds until data arrives.
|
||||||
|
* - Speed changes: nothing. The cache is position-indexed raw PCM.
|
||||||
|
*
|
||||||
|
* Focus lifecycle: Shell unmounts a tab's page when it loses focus, but the
|
||||||
|
* pipeline outlives the page so playback keeps visualizing; UNLOAD_DELAY_MS
|
||||||
|
* after the Player tab stops being focused it tears down. Reads outside
|
||||||
|
* decoded coverage return empty — the renderer simply holds the last frame
|
||||||
|
* until the decode frontier arrives.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +39,7 @@ import {
|
|||||||
type CavaCore,
|
type CavaCore,
|
||||||
type CavaCoreConfig,
|
type CavaCoreConfig,
|
||||||
} from "@/utils/cavacore";
|
} from "@/utils/cavacore";
|
||||||
import { AudioStreamReader } from "@/utils/audio-stream-reader";
|
import { EpisodePcmCache, PCM_SAMPLE_RATE } from "@/utils/audio-pcm-cache";
|
||||||
import { createBarScaler } from "@/utils/bar-mapping";
|
import { createBarScaler } from "@/utils/bar-mapping";
|
||||||
import { audioPlaybackSignals } from "@/utils/audio-signals";
|
import { audioPlaybackSignals } from "@/utils/audio-signals";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
@@ -83,7 +92,10 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
const scaler = createBarScaler();
|
const scaler = createBarScaler();
|
||||||
|
|
||||||
let cava: CavaCore | null = null;
|
let cava: CavaCore | null = null;
|
||||||
let reader: AudioStreamReader | null = null;
|
// Position-indexed PCM cache for the current episode. Kept across
|
||||||
|
// pause/resume (segments survive; only the ffmpeg pass is killed) and
|
||||||
|
// dropped only on episode change, stop, disable, or unload.
|
||||||
|
let pcm: EpisodePcmCache | null = null;
|
||||||
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
let frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let sampleBuffer: Float64Array | null = null;
|
let sampleBuffer: Float64Array | null = null;
|
||||||
let unloadTimer: ReturnType<typeof setTimeout> | null = null;
|
let unloadTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -91,7 +103,6 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
// What the running pipeline was started with — lets the playback effect
|
// What the running pipeline was started with — lets the playback effect
|
||||||
// tell "nothing changed, stay warm" from "must restart".
|
// tell "nothing changed, stay warm" from "must restart".
|
||||||
let activeUrl = "";
|
let activeUrl = "";
|
||||||
let activeSpeed = 1;
|
|
||||||
let activeBars = 64;
|
let activeBars = 64;
|
||||||
|
|
||||||
// ── Lifecycle helpers ──────────────────────────────────────────────
|
// ── Lifecycle helpers ──────────────────────────────────────────────
|
||||||
@@ -139,7 +150,7 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
|
|
||||||
// ── Start/stop the visualization pipeline ──────────────────────────
|
// ── Start/stop the visualization pipeline ──────────────────────────
|
||||||
|
|
||||||
const startVisualization = (url: string, position: number, speed: number) => {
|
const startVisualization = (url: string, position: number) => {
|
||||||
stopVisualization();
|
stopVisualization();
|
||||||
|
|
||||||
if (!url || !initCava() || !cava) return;
|
if (!url || !initCava() || !cava) return;
|
||||||
@@ -152,7 +163,7 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
const viz = useAppStore().state().settings.visualizer;
|
const viz = useAppStore().state().settings.visualizer;
|
||||||
const config: CavaCoreConfig = {
|
const config: CavaCoreConfig = {
|
||||||
bars: barCount(),
|
bars: barCount(),
|
||||||
sampleRate: 44100,
|
sampleRate: PCM_SAMPLE_RATE,
|
||||||
channels: 1,
|
channels: 1,
|
||||||
noiseReduction: viz.noiseReduction,
|
noiseReduction: viz.noiseReduction,
|
||||||
lowCutOff: viz.lowCutOff,
|
lowCutOff: viz.lowCutOff,
|
||||||
@@ -164,32 +175,29 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
// Pre-warm the FFT window: libcavacore's window is malloc'd
|
// Pre-warm the FFT window: libcavacore's window is malloc'd
|
||||||
// uninitialized, so the first real frame would FFT garbage and
|
// uninitialized, so the first real frame would FFT garbage and
|
||||||
// render full-scale bars. One zero frame the size of the whole
|
// render full-scale bars. One zero frame the size of the whole
|
||||||
// input buffer clears it (at 44.1kHz mono the window is 8192
|
// input buffer clears it.
|
||||||
// samples — FFTbassbufferSize × channels; a 512-sample frame would
|
|
||||||
// leave the tail garbage).
|
|
||||||
cava.execute(new Float64Array(8192));
|
cava.execute(new Float64Array(8192));
|
||||||
|
|
||||||
// Pre-allocate sample read buffer
|
// Pre-allocate sample read buffer
|
||||||
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
sampleBuffer = new Float64Array(SAMPLES_PER_FRAME);
|
||||||
|
|
||||||
// Start ffmpeg decode stream (reuse reader if same URL, else create new)
|
// PCM cache per episode (reuse when the episode is unchanged)
|
||||||
if (!reader || reader.url !== url) {
|
if (!pcm || pcm.url !== url) {
|
||||||
if (reader) reader.stop();
|
if (pcm) pcm.stop();
|
||||||
reader = new AudioStreamReader({ url });
|
pcm = new EpisodePcmCache({ url });
|
||||||
}
|
}
|
||||||
reader.start(position, speed);
|
// Decode from 1s before the position so the window ENDING at the
|
||||||
|
// position is covered as soon as the first PCM lands.
|
||||||
|
pcm.startDecode(Math.max(0, position - 1));
|
||||||
|
|
||||||
// Seed the smooth position clock with the start position. Without
|
// Seed the smooth position clock with the start position. Without
|
||||||
// this, a fresh play at position 0 would sample the window ending at
|
// 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
|
// exactly 0 — a 1-sample slice — so bars would be starved until the
|
||||||
// bars would be starved until the first mpv poll advanced the
|
// first mpv poll advanced the position clock.
|
||||||
// position clock. Seeding makes the interpolated target advance
|
|
||||||
// immediately, so bars render as soon as ffmpeg has any audio.
|
|
||||||
lastPolledPosition = position;
|
lastPolledPosition = position;
|
||||||
lastPolledAt = performance.now();
|
lastPolledAt = performance.now();
|
||||||
|
|
||||||
activeUrl = url;
|
activeUrl = url;
|
||||||
activeSpeed = speed;
|
|
||||||
activeBars = barCount();
|
activeBars = barCount();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||||
@@ -201,9 +209,10 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
clearInterval(frameTimer);
|
clearInterval(frameTimer);
|
||||||
frameTimer = null;
|
frameTimer = null;
|
||||||
}
|
}
|
||||||
if (reader) {
|
if (pcm) {
|
||||||
reader.stop();
|
pcm.stop();
|
||||||
// Don't null reader — we reuse it across start/stop cycles
|
// Keep the (now cache-less, url-tagged) object: a re-start of the
|
||||||
|
// same episode reuses it; segments re-decode in seconds at 80x.
|
||||||
}
|
}
|
||||||
if (cava?.isReady) {
|
if (cava?.isReady) {
|
||||||
cava.destroy();
|
cava.destroy();
|
||||||
@@ -212,17 +221,59 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Pause: freeze the loop, keep the cache ──────────────────────────
|
||||||
|
//
|
||||||
|
// The render loop stops (bars hold their last frame) and the ffmpeg
|
||||||
|
// pass dies (no background CPU), but the decoded PCM stays: resume
|
||||||
|
// serves it instantly.
|
||||||
|
|
||||||
|
const suspendVisualization = () => {
|
||||||
|
clearUnloadTimer();
|
||||||
|
if (frameTimer) {
|
||||||
|
clearInterval(frameTimer);
|
||||||
|
frameTimer = null;
|
||||||
|
}
|
||||||
|
if (pcm) pcm.pauseDecode();
|
||||||
|
// Cava plan + sampleBuffer stay alive — cheap to reuse on resume.
|
||||||
|
// Clear the loading spinner: if the pipeline never produced bars
|
||||||
|
// (still cold-starting when paused), the component should fall back
|
||||||
|
// to the placeholder, not freeze on a spinner.
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Resume: re-arm the render loop, top up the cache ───────────────
|
||||||
|
//
|
||||||
|
// Returns true if the pipeline resumed, false if there was nothing to
|
||||||
|
// resume (no prior pipeline).
|
||||||
|
|
||||||
|
const resumeVisualization = (): boolean => {
|
||||||
|
// Already running — nothing to do.
|
||||||
|
if (frameTimer !== null) return true;
|
||||||
|
if (!pcm || !cava?.isReady || !sampleBuffer) return false;
|
||||||
|
|
||||||
|
const pos = untrack(audioPlaybackSignals.position);
|
||||||
|
|
||||||
|
// Bars come from the cache on the next frame tick (~33ms) whenever
|
||||||
|
// the position is covered; any gap (uncached region) restarts the
|
||||||
|
// decode pass in the background with the last frame holding.
|
||||||
|
pcm.ensureDecodeAround(pos);
|
||||||
|
|
||||||
|
lastPolledPosition = pos;
|
||||||
|
lastPolledAt = performance.now();
|
||||||
|
frameTimer = setInterval(renderFrame, FRAME_INTERVAL);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
// ── Render loop (called at ~30fps) ─────────────────────────────────
|
||||||
|
|
||||||
const renderFrame = () => {
|
const renderFrame = () => {
|
||||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
if (!cava?.isReady || !sampleBuffer || !pcm) return;
|
||||||
|
|
||||||
// Sample the FFT window at the player's position, not the decode
|
// Sample the FFT window at the player's position. Outside decoded
|
||||||
// head — the reader decodes independently (paced at the player's
|
// coverage (decode cold start, seek into a hole) the read is empty
|
||||||
// clock rate with a LEAD_SECONDS burst head start) and only the
|
// and the LAST FRAME simply holds — never clamped/repeated junk.
|
||||||
// position clock ties the bars to what's actually playing.
|
|
||||||
const target = smoothPosition();
|
const target = smoothPosition();
|
||||||
const count = reader.read(sampleBuffer, target);
|
const count = pcm.readWindow(sampleBuffer, target);
|
||||||
// Never feed a partial FFT window to cava.
|
// Never feed a partial FFT window to cava.
|
||||||
if (count < sampleBuffer.length) return;
|
if (count < sampleBuffer.length) return;
|
||||||
|
|
||||||
@@ -235,44 +286,53 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
|
|
||||||
// ── Playback subscription ──────────────────────────────────────────
|
// ── Playback subscription ──────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Keeps the pipeline matched to playback. `focused` is a dep so focus
|
// Keeps the pipeline matched to playback. Pause suspends (render loop +
|
||||||
// regain re-evaluates (and can restart an unloaded pipeline), but the
|
// decode pass die, cache survives) so resume is instant. Stop/track-end/
|
||||||
// guard below makes a focus flip on an already-correct warm pipeline a
|
// disable fully tears down. `focused` is a dep so focus regain
|
||||||
// no-op — no churn when flipping back to the Player tab within the
|
// re-evaluates; the guards make a focus flip on an already-correct warm
|
||||||
// unload delay. A real change (url/speed/barCount, stop/start, or a
|
// pipeline a no-op. Speed is deliberately NOT a dep — the PCM cache is
|
||||||
// stale pipeline after an unload) restarts from the current position.
|
// position-indexed, so playback-rate changes need no pipeline restart.
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
[
|
[
|
||||||
audioPlaybackSignals.isPlaying,
|
audioPlaybackSignals.isPlaying,
|
||||||
() => audioPlaybackSignals.currentEpisode()?.audioUrl ?? "",
|
() => audioPlaybackSignals.currentEpisode()?.audioUrl ?? "",
|
||||||
audioPlaybackSignals.speed,
|
|
||||||
barCount,
|
barCount,
|
||||||
focused,
|
focused,
|
||||||
() => useAppStore().state().settings.visualizer.enabled,
|
() => useAppStore().state().settings.visualizer.enabled,
|
||||||
],
|
],
|
||||||
([playing, url, speed, , , enabled]) => {
|
([playing, url, , , enabled]) => {
|
||||||
if (!playing || !url || !enabled) {
|
if (!url || !enabled) {
|
||||||
stopVisualization();
|
stopVisualization();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Warm and already correct — nothing to do (e.g. focus
|
if (!playing) {
|
||||||
// regained within the unload delay).
|
// Pause: freeze the loop, keep the cache. Only if the
|
||||||
|
// pipeline is actually running — otherwise no-op.
|
||||||
|
if (frameTimer !== null) suspendVisualization();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playing — try a fast resume first. If it succeeds and the
|
||||||
|
// pipeline matches, done.
|
||||||
if (
|
if (
|
||||||
frameTimer !== null &&
|
frameTimer === null &&
|
||||||
|
pcm &&
|
||||||
|
cava?.isReady &&
|
||||||
url === activeUrl &&
|
url === activeUrl &&
|
||||||
speed === activeSpeed &&
|
|
||||||
barCount() === activeBars
|
barCount() === activeBars
|
||||||
) {
|
) {
|
||||||
|
if (resumeVisualization()) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm and already correct — nothing to do (e.g. focus
|
||||||
|
// regained within the unload delay while still playing).
|
||||||
|
if (frameTimer !== null && url === activeUrl && barCount() === activeBars) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!focused()) return; // playing away: stay warm; unload timer decides
|
if (!focused()) return; // playing away: stay warm; unload timer decides
|
||||||
startVisualization(
|
startVisualization(url, untrack(audioPlaybackSignals.position));
|
||||||
url,
|
|
||||||
untrack(audioPlaybackSignals.position),
|
|
||||||
speed,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -296,7 +356,6 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
startVisualization(
|
startVisualization(
|
||||||
audioPlaybackSignals.currentEpisode()!.audioUrl,
|
audioPlaybackSignals.currentEpisode()!.audioUrl,
|
||||||
untrack(audioPlaybackSignals.position),
|
untrack(audioPlaybackSignals.position),
|
||||||
audioPlaybackSignals.speed() ?? 1,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (frameTimer !== null) {
|
} else if (frameTimer !== null) {
|
||||||
@@ -308,17 +367,17 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Seek detection: lightweight effect for position jumps ──────────
|
// ── Seek detection: jump coverage, not pipeline restarts ───────────
|
||||||
//
|
//
|
||||||
// Watches position and restarts the reader (not the whole pipeline)
|
// Watches position for significant jumps (>2s = user seek). Decoded
|
||||||
// only on significant jumps (>2s), which indicate a user seek.
|
// audio at the new position is served instantly with zero action; a
|
||||||
// This is intentionally a separate effect — it should NOT trigger a
|
// jump into an undecoded hole kicks a background segment decode there
|
||||||
// full pipeline restart, just restart the ffmpeg stream at the new pos.
|
// while the last frame holds.
|
||||||
|
|
||||||
let lastSyncPosition = 0;
|
let lastSyncPosition = 0;
|
||||||
createEffect(
|
createEffect(
|
||||||
on(audioPlaybackSignals.position, (pos) => {
|
on(audioPlaybackSignals.position, (pos) => {
|
||||||
if (!audioPlaybackSignals.isPlaying() || !reader?.running) {
|
if (!audioPlaybackSignals.isPlaying() || !pcm) {
|
||||||
lastSyncPosition = pos;
|
lastSyncPosition = pos;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -327,10 +386,21 @@ function createVisualizerStore(): VisualizerStore {
|
|||||||
lastSyncPosition = pos;
|
lastSyncPosition = pos;
|
||||||
|
|
||||||
if (delta > 2) {
|
if (delta > 2) {
|
||||||
reader.restart(pos, audioPlaybackSignals.speed() ?? 1);
|
pcm.ensureDecodeAround(pos);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// ── Process-exit teardown ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The pipeline lives in a detached createRoot that is never disposed,
|
||||||
|
// so Solid's onCleanup never runs. `q`/`:quit` call process.exit(0)
|
||||||
|
// (bypassing onCleanup); SIGINT/TERM/HUP are caught by useAudio's
|
||||||
|
// handler. This handler runs synchronously on `exit` and kills the
|
||||||
|
// ffmpeg child + destroys the cava plan so they don't outlive the host.
|
||||||
|
// Without it, a warm pipeline leaks an orphaned ffmpeg process on quit.
|
||||||
|
process.on("exit", () => {
|
||||||
|
stopVisualization();
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// state
|
// state
|
||||||
|
|||||||
363
src/utils/audio-pcm-cache.ts
Normal file
363
src/utils/audio-pcm-cache.ts
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
/**
|
||||||
|
* Position-indexed PCM cache for visualization.
|
||||||
|
*
|
||||||
|
* One ffmpeg process decodes the episode's audio at 4x realtime (with an
|
||||||
|
* 8s initial burst — fast enough to serve bars and seeks instantly, throttled
|
||||||
|
* enough that a remote episode isn't ripped at 84x while mpv is trying to
|
||||||
|
* start playback) into an in-memory cache indexed by ABSOLUTE playback time.
|
||||||
|
* The renderer then reads the PCM
|
||||||
|
* window ending at the player's current position with zero sync machinery:
|
||||||
|
* there is no pacing (-readrate), no lead-burst, no decode-head/player
|
||||||
|
* drift math, no ring wrap, and nothing that knows or cares about pause,
|
||||||
|
* resume, seek, or playback speed — those all collapse to "read at a
|
||||||
|
* different position in the cache".
|
||||||
|
*
|
||||||
|
* Pause/resume contract (the failure mode of the old design):
|
||||||
|
* - pauseDecode() kills ffmpeg but KEEPS the cache. Resume reads from it
|
||||||
|
* instantly and resumes the tail decode in the background.
|
||||||
|
* - Reads outside decoded coverage (startup, seek into an undecoded hole)
|
||||||
|
* return 0 — the renderer HOLDS the last rendered frame rather than
|
||||||
|
* freezing on a clamped buffer or decaying into junk bars.
|
||||||
|
*
|
||||||
|
* Seeks into undecoded territory start a fresh SEGMENT (a second decode
|
||||||
|
* pass over just that region) — earlier segments stay valid, mp3 decode of
|
||||||
|
* the same file is deterministic so abutting segments agree.
|
||||||
|
*
|
||||||
|
* Memory: 22050 Hz mono s16 ≈ 44 KB/s ≈ 2.6 MB/min (~80 MB per 30 min),
|
||||||
|
* freed on stop(). 22050 Hz covers Nyquist 11 kHz, above the default 10 kHz
|
||||||
|
* high-cutoff of the visualizer's FFT config.
|
||||||
|
*
|
||||||
|
* Downloads via ffmpeg's own http stack with reconnect flags, matching the
|
||||||
|
* old reader; local files skip them (ffmpeg rejects http-only options for
|
||||||
|
* file inputs).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Subprocess } from "bun";
|
||||||
|
|
||||||
|
/** PCM output format constants */
|
||||||
|
export const PCM_SAMPLE_RATE = 22050;
|
||||||
|
const BYTES_PER_SAMPLE = 2; // s16le
|
||||||
|
|
||||||
|
/** Initial segment capacity: 4 Mi samples ≈ 190 s of audio (8 MB). */
|
||||||
|
const INITIAL_CAPACITY_SAMPLES = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monotonically increasing generation counter.
|
||||||
|
* Each startDecode() increments this; the read loop checks it to know
|
||||||
|
* if it's been superseded and should bail out.
|
||||||
|
*/
|
||||||
|
let globalGeneration = 0;
|
||||||
|
|
||||||
|
interface Segment {
|
||||||
|
/** Playback seconds where this segment's first sample sits. */
|
||||||
|
baseSec: number;
|
||||||
|
/** Sample buffer; capacity >= written, doubled on overflow. */
|
||||||
|
samples: Int16Array;
|
||||||
|
/** Samples written so far (== decoded length of the segment). */
|
||||||
|
written: number;
|
||||||
|
/** ffmpeg reached stream EOF while writing this segment — nothing more
|
||||||
|
* will ever arrive after its end. */
|
||||||
|
finished: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EpisodePcmCacheOptions {
|
||||||
|
/** Audio URL or file path to decode */
|
||||||
|
url: string;
|
||||||
|
/** Sample rate (default: 22050) */
|
||||||
|
sampleRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EpisodePcmCache {
|
||||||
|
private proc: Subprocess | null = null;
|
||||||
|
private segments: Segment[] = [];
|
||||||
|
private generation = 0;
|
||||||
|
private _decoding = false;
|
||||||
|
/** Base offset (playback seconds) of the running decode pass; null when idle. */
|
||||||
|
private activeBaseSec: number | null = null;
|
||||||
|
readonly url: string;
|
||||||
|
readonly sampleRate: number;
|
||||||
|
|
||||||
|
constructor(options: EpisodePcmCacheOptions) {
|
||||||
|
this.url = options.url;
|
||||||
|
this.sampleRate = options.sampleRate ?? PCM_SAMPLE_RATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an ffmpeg decode pass is currently running. */
|
||||||
|
get decoding(): boolean {
|
||||||
|
return this._decoding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End (playback seconds) of the furthest-decoded segment. */
|
||||||
|
get coverageEndSec(): number {
|
||||||
|
let end = 0;
|
||||||
|
for (const seg of this.segments) {
|
||||||
|
const segEnd = seg.baseSec + seg.written / this.sampleRate;
|
||||||
|
if (segEnd > end) end = segEnd;
|
||||||
|
}
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the furthest segment finished at stream EOF. */
|
||||||
|
get decodeFinished(): boolean {
|
||||||
|
let maxEnd = -1;
|
||||||
|
let finished = false;
|
||||||
|
for (const seg of this.segments) {
|
||||||
|
const segEnd = seg.baseSec + seg.written / this.sampleRate;
|
||||||
|
if (segEnd > maxEnd) {
|
||||||
|
maxEnd = segEnd;
|
||||||
|
finished = seg.finished;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return finished;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start decoding at `fromSec` of playback time into a fresh segment.
|
||||||
|
* Kills any in-flight pass first; existing segments stay readable.
|
||||||
|
*/
|
||||||
|
startDecode(fromSec: number): void {
|
||||||
|
this.killProcess();
|
||||||
|
|
||||||
|
if (!Bun.which("ffmpeg")) {
|
||||||
|
throw new Error("ffmpeg not found — required for audio visualization");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.generation = ++globalGeneration;
|
||||||
|
const myGeneration = this.generation;
|
||||||
|
|
||||||
|
const segment: Segment = {
|
||||||
|
baseSec: Math.max(0, fromSec),
|
||||||
|
samples: new Int16Array(INITIAL_CAPACITY_SAMPLES),
|
||||||
|
written: 0,
|
||||||
|
finished: false,
|
||||||
|
};
|
||||||
|
this.segments.push(segment);
|
||||||
|
|
||||||
|
const args = ["ffmpeg", "-loglevel", "quiet"];
|
||||||
|
|
||||||
|
// Pace the decode at 4x realtime (with an 8s initial burst) instead of
|
||||||
|
// flat-out: unthrottled decode measures ~84x realtime, which pulls the
|
||||||
|
// ENTIRE episode from the network within the first minute of playback
|
||||||
|
// (~160MB/hr) and starves mpv's own buffering right at startup. 4x
|
||||||
|
// still fills the cache 4x faster than playback consumes it, lands a
|
||||||
|
// 75-min episode in ~19 min of background work, and the burst makes
|
||||||
|
// the first bars available immediately.
|
||||||
|
args.push("-readrate", "4", "-readrate_initial_burst", "8");
|
||||||
|
|
||||||
|
// `-reconnect*` are http-protocol options: ffmpeg rejects them at
|
||||||
|
// input-open when the input is a local file, killing the process
|
||||||
|
// before any PCM is produced. Only pass them for network URLs.
|
||||||
|
if (/^https?:\/\//i.test(this.url)) {
|
||||||
|
args.push(
|
||||||
|
"-reconnect",
|
||||||
|
"1",
|
||||||
|
"-reconnect_streamed",
|
||||||
|
"1",
|
||||||
|
"-reconnect_delay_max",
|
||||||
|
"5",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek before input for network efficiency (container-level skip is
|
||||||
|
// near-instant for mp3/aac; no pre-position decode burn).
|
||||||
|
if (fromSec > 0) {
|
||||||
|
args.push("-ss", String(Math.max(0, fromSec)));
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(
|
||||||
|
"-i",
|
||||||
|
this.url,
|
||||||
|
"-ac",
|
||||||
|
"1",
|
||||||
|
"-ar",
|
||||||
|
String(this.sampleRate),
|
||||||
|
"-f",
|
||||||
|
"s16le",
|
||||||
|
"-acodec",
|
||||||
|
"pcm_s16le",
|
||||||
|
"-",
|
||||||
|
);
|
||||||
|
|
||||||
|
this.proc = Bun.spawn(args, {
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "ignore",
|
||||||
|
stdin: "ignore",
|
||||||
|
});
|
||||||
|
this._decoding = true;
|
||||||
|
this.activeBaseSec = segment.baseSec;
|
||||||
|
this.readLoop(myGeneration, segment);
|
||||||
|
|
||||||
|
this.proc.exited
|
||||||
|
.then((code) => {
|
||||||
|
if (this.generation === myGeneration) {
|
||||||
|
this._decoding = false;
|
||||||
|
this.activeBaseSec = null;
|
||||||
|
// Exit 0 == decoded to stream EOF.
|
||||||
|
if (code === 0) segment.finished = true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (this.generation === myGeneration) {
|
||||||
|
this._decoding = false;
|
||||||
|
this.activeBaseSec = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `sec` of playback time has decoded PCM on hand.
|
||||||
|
*/
|
||||||
|
covers(sec: number): boolean {
|
||||||
|
const idx = Math.round(sec * this.sampleRate);
|
||||||
|
for (const seg of this.segments) {
|
||||||
|
const base = Math.round(seg.baseSec * this.sampleRate);
|
||||||
|
if (idx >= base && idx < base + seg.written) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure decode is progressing toward `sec`: no-op while a pass is
|
||||||
|
* running or the episode is fully decoded; otherwise resumes the tail
|
||||||
|
* decode from the frontier (when `sec` is inside coverage) or starts a
|
||||||
|
* new segment at `sec` (seek into a hole / resume past cached audio).
|
||||||
|
*/
|
||||||
|
ensureDecodeAround(sec: number): void {
|
||||||
|
if (this._decoding) {
|
||||||
|
// A decode pass fills monotonically FORWARD from its base. Only a
|
||||||
|
// target at/after the active base is eventually covered by it —
|
||||||
|
// a target BEHIND the base (seek into an undecoded hole ahead of
|
||||||
|
// the active pass) never is: kill the pass and restart at sec.
|
||||||
|
if (this.activeBaseSec !== null && sec >= this.activeBaseSec) return;
|
||||||
|
this.startDecode(Math.max(0, sec));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.covers(sec)) {
|
||||||
|
// Covered here: continue the tail so the cache keeps filling
|
||||||
|
// past the position (unless the whole episode is decoded).
|
||||||
|
if (this.decodeFinished) return;
|
||||||
|
this.startDecode(this.coverageEndSec > sec ? this.coverageEndSec : sec);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Seek into an undecoded region: start a fresh segment there.
|
||||||
|
this.startDecode(Math.max(0, sec));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the PCM window ENDING at `atSec` of playback into `out`
|
||||||
|
* (Int16 magnitudes widened to f64, the scale cavacore expects).
|
||||||
|
*
|
||||||
|
* Returns the number of samples written: `out.length` on a full hit, 0
|
||||||
|
* when the window is not (fully) decoded yet — the caller HOLDS the
|
||||||
|
* last rendered frame instead of rendering partial/stale data.
|
||||||
|
*/
|
||||||
|
readWindow(out: Float64Array, atSec: number): number {
|
||||||
|
if (out.length === 0) return 0;
|
||||||
|
const endIdx = Math.round(atSec * this.sampleRate);
|
||||||
|
const startIdx = endIdx - out.length + 1;
|
||||||
|
for (const seg of this.segments) {
|
||||||
|
const base = Math.round(seg.baseSec * this.sampleRate);
|
||||||
|
if (startIdx < base || endIdx >= base + seg.written) continue;
|
||||||
|
const rel = startIdx - base;
|
||||||
|
const src = seg.samples;
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
out[i] = src[rel + i];
|
||||||
|
}
|
||||||
|
return out.length;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause contract: kill the ffmpeg pass but KEEP every decoded segment.
|
||||||
|
* Resume later serves bars from the cache instantly.
|
||||||
|
*/
|
||||||
|
pauseDecode(): void {
|
||||||
|
this.generation = ++globalGeneration;
|
||||||
|
this._decoding = false;
|
||||||
|
this.activeBaseSec = null;
|
||||||
|
this.killProcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kill the decode pass AND drop all cached audio. */
|
||||||
|
stop(): void {
|
||||||
|
this.pauseDecode();
|
||||||
|
this.segments = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kill the ffmpeg process without touching generation/state. */
|
||||||
|
private killProcess(): void {
|
||||||
|
if (this.proc) {
|
||||||
|
try {
|
||||||
|
this.proc.kill();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.proc = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal: continuously reads stdout from ffmpeg and appends samples
|
||||||
|
* to the segment at their absolute playback-time offsets. */
|
||||||
|
private async readLoop(myGeneration: number, segment: Segment): Promise<void> {
|
||||||
|
const stdout = this.proc?.stdout;
|
||||||
|
if (!stdout || typeof stdout === "number") return;
|
||||||
|
|
||||||
|
const reader = (stdout as ReadableStream<Uint8Array>).getReader();
|
||||||
|
// s16 sample pairs can straddle pipe chunk boundaries: carry a lone
|
||||||
|
// trailing byte into the next chunk (dropping it would byte-flip
|
||||||
|
// every sample that follows).
|
||||||
|
let carry: number | null = null;
|
||||||
|
try {
|
||||||
|
while (this.generation === myGeneration) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done || this.generation !== myGeneration) break;
|
||||||
|
if (!value || value.byteLength === 0) continue;
|
||||||
|
|
||||||
|
let view: Uint8Array = value;
|
||||||
|
if (carry !== null) {
|
||||||
|
const merged = new Uint8Array(1 + value.byteLength);
|
||||||
|
merged[0] = carry;
|
||||||
|
merged.set(value, 1);
|
||||||
|
view = merged;
|
||||||
|
carry = null;
|
||||||
|
}
|
||||||
|
if (view.byteLength % BYTES_PER_SAMPLE !== 0) {
|
||||||
|
carry = view[view.byteLength - 1];
|
||||||
|
view = view.subarray(0, view.byteLength - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleCount = view.byteLength / BYTES_PER_SAMPLE;
|
||||||
|
if (sampleCount === 0) continue;
|
||||||
|
|
||||||
|
if (segment.written + sampleCount > segment.samples.length) {
|
||||||
|
const grown = new Int16Array(
|
||||||
|
Math.max(
|
||||||
|
segment.samples.length * 2,
|
||||||
|
segment.written + sampleCount,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
grown.set(segment.samples.subarray(0, segment.written));
|
||||||
|
segment.samples = grown;
|
||||||
|
}
|
||||||
|
// Int16Array view over the byte buffer: s16le is the platform's
|
||||||
|
// native endianness on every supported target (arm64/x64 are LE).
|
||||||
|
const src = new Int16Array(
|
||||||
|
view.buffer,
|
||||||
|
view.byteOffset,
|
||||||
|
sampleCount,
|
||||||
|
);
|
||||||
|
segment.samples.set(src, segment.written);
|
||||||
|
segment.written += sampleCount;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Stream ended or process killed — expected during stop()
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,28 @@
|
|||||||
* restart. When mpv isn't installed there is no fallback: the no-op backend
|
* restart. When mpv isn't installed there is no fallback: the no-op backend
|
||||||
* surfaces "No audio player found" honestly rather than degrading through
|
* surfaces "No audio player found" honestly rather than degrading through
|
||||||
* players that can't change speed/volume without restarting.
|
* players that can't change speed/volume without restarting.
|
||||||
|
*
|
||||||
|
* The backend owns ONE RESIDENT mpv daemon (`--idle=yes --keep-open=yes`)
|
||||||
|
* for the app's lifetime instead of spawning a fresh player per episode:
|
||||||
|
*
|
||||||
|
* - Play/pause/seek are IPC commands on a persistent Unix-socket
|
||||||
|
* connection — no process spawn, no socket connect/disconnect churn per
|
||||||
|
* poll, no `waitForSocket` on the play path. Measured command latency is
|
||||||
|
* single-digit ms; a mid-episode resume after pause takes ~300ms on a
|
||||||
|
* network stream.
|
||||||
|
* - State (time-pos, pause, duration) is OBSERVED (`observe_property`):
|
||||||
|
* mpv pushes time-pos at ~20Hz while playing, so `getPosition()` /
|
||||||
|
* `getPauseState()` read a cache instead of round-tripping the socket on
|
||||||
|
* every 150ms UI tick. External pauses (AirPod removal, system sleep,
|
||||||
|
* Now Playing center) arrive as pause property events with zero polling.
|
||||||
|
* - A restored session can PRELOAD: the episode is loaded paused so mpv
|
||||||
|
* fills its demuxer cache ahead of time; the first real play just flips
|
||||||
|
* `pause` to false — the ~2s network open is paid at boot, not on the
|
||||||
|
* user's first Play.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { platform } from "os";
|
import { platform } from "os";
|
||||||
import { existsSync } from "fs";
|
import { existsSync, unlinkSync } from "fs";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import type { Socket, Subprocess } from "bun";
|
import type { Socket, Subprocess } from "bun";
|
||||||
@@ -31,6 +49,18 @@ export interface AudioState {
|
|||||||
export interface AudioBackend {
|
export interface AudioBackend {
|
||||||
readonly name: BackendName;
|
readonly name: BackendName;
|
||||||
play(url: string, opts?: PlayOptions): Promise<void>;
|
play(url: string, opts?: PlayOptions): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Load the URL paused WITHOUT starting playback, so the player buffers
|
||||||
|
* ahead of the user's first Play (used for boot session restore).
|
||||||
|
* A subsequent play() of the SAME url flips pause off — near-instant.
|
||||||
|
*/
|
||||||
|
preload(url: string, opts?: PlayOptions): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Attach a cover-art image to the currently-loaded file at runtime
|
||||||
|
* (mpv `video-add`). Lets play() start without waiting on art; the
|
||||||
|
* Now Playing artwork pops in when the download lands.
|
||||||
|
*/
|
||||||
|
addCoverArt(path: string): Promise<void>;
|
||||||
pause(): Promise<void>;
|
pause(): Promise<void>;
|
||||||
resume(): Promise<void>;
|
resume(): Promise<void>;
|
||||||
stop(): Promise<void>;
|
stop(): Promise<void>;
|
||||||
@@ -41,11 +71,11 @@ export interface AudioBackend {
|
|||||||
getDuration(): Promise<number>;
|
getDuration(): Promise<number>;
|
||||||
isPlaying(): boolean;
|
isPlaying(): boolean;
|
||||||
/** Live pause state: `true` paused, `false` playing, `undefined` when
|
/** Live pause state: `true` paused, `false` playing, `undefined` when
|
||||||
* the read failed (callers keep the last known state). Unlike
|
* unknown (player unreachable / not yet loaded). Unlike `isPlaying()` —
|
||||||
* `isPlaying()` — which reflects only commands PodTUI sent — this
|
* which reflects only commands PodTUI sent — this reflects the player's
|
||||||
* reflects the player's real state, including pauses initiated
|
* real state, including pauses initiated OUTSIDE PodTUI (system
|
||||||
* OUTSIDE PodTUI (system sleep/lock, AirPod removal, device swap,
|
* sleep/lock, AirPod removal, device swap, OS media keys, the Now
|
||||||
* OS media keys, the Now Playing center). */
|
* Playing center). */
|
||||||
getPauseState(): Promise<boolean | undefined>;
|
getPauseState(): Promise<boolean | undefined>;
|
||||||
/** True while the player process is running (regardless of pause). */
|
/** True while the player process is running (regardless of pause). */
|
||||||
isAlive(): boolean;
|
isAlive(): boolean;
|
||||||
@@ -80,8 +110,16 @@ function which(cmd: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mpvInstance = 0;
|
||||||
function mpvSocketPath(): string {
|
function mpvSocketPath(): string {
|
||||||
return join(tmpdir(), `podtui-mpv-${process.pid}.sock`);
|
// Per-instance, not just per-pid: tests (and backend switching) create
|
||||||
|
// several MpvBackend objects in ONE bun process — a pid-only path makes
|
||||||
|
// every daemon bind the same socket, so later daemons unlink the path
|
||||||
|
// out from under earlier ones and IPC cross-talks between backends.
|
||||||
|
return join(
|
||||||
|
tmpdir(),
|
||||||
|
`podtui-mpv-${process.pid}-${mpvInstance++}.sock`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,231 +162,523 @@ function resolveMpvBinary(): string | null {
|
|||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── mpv JSON IPC connection ─────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// One persistent Unix-socket connection to the resident mpv daemon. Lines
|
||||||
|
// from mpv are either command responses (`request_id` present — correlated
|
||||||
|
// to the pending promise) or unsolicited traffic (property-change events
|
||||||
|
// from `observe_property`, end-file, ...), dispatched to the event handler.
|
||||||
|
|
||||||
|
interface MpvResponse {
|
||||||
|
error?: string;
|
||||||
|
data?: unknown;
|
||||||
|
request_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MpvEvent {
|
||||||
|
event: string;
|
||||||
|
/** Observation id for property-change events. */
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
data?: unknown;
|
||||||
|
reason?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MpvEventHandler = (msg: MpvEvent) => void;
|
||||||
|
|
||||||
|
class MpvConnection {
|
||||||
|
private sock: Socket | null = null;
|
||||||
|
private buf = "";
|
||||||
|
private nextId = 1;
|
||||||
|
private pending = new Map<number, (msg: MpvResponse) => void>();
|
||||||
|
private eventWaiters = new Map<string, Array<(msg: MpvEvent) => void>>();
|
||||||
|
onEvent: MpvEventHandler = () => {};
|
||||||
|
|
||||||
|
async connect(path: string): Promise<void> {
|
||||||
|
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||||
|
let settled = false;
|
||||||
|
Bun.connect({
|
||||||
|
unix: path,
|
||||||
|
socket: {
|
||||||
|
open: (socket) => {
|
||||||
|
this.sock = socket;
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
data: (_socket, data) => this.onData(data),
|
||||||
|
error: (_socket, err) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
this.handleTeardown();
|
||||||
|
},
|
||||||
|
close: () => this.handleTeardown(),
|
||||||
|
},
|
||||||
|
}).catch((err) => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private onData(data: Uint8Array): void {
|
||||||
|
this.buf += Buffer.from(data).toString();
|
||||||
|
let nl = this.buf.indexOf("\n");
|
||||||
|
while (nl !== -1) {
|
||||||
|
const line = this.buf.slice(0, nl);
|
||||||
|
this.buf = this.buf.slice(nl + 1);
|
||||||
|
nl = this.buf.indexOf("\n");
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
let msg: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
continue; // skip malformed lines
|
||||||
|
}
|
||||||
|
if (msg.request_id !== undefined) {
|
||||||
|
const resolve = this.pending.get(msg.request_id as number);
|
||||||
|
if (resolve) {
|
||||||
|
this.pending.delete(msg.request_id as number);
|
||||||
|
resolve(msg as MpvResponse);
|
||||||
|
}
|
||||||
|
} else if (typeof msg.event === "string") {
|
||||||
|
const event = msg as unknown as MpvEvent;
|
||||||
|
this.onEvent(event);
|
||||||
|
const waiters = this.eventWaiters.get(event.event);
|
||||||
|
if (waiters) {
|
||||||
|
this.eventWaiters.delete(event.event);
|
||||||
|
for (const w of waiters) w(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Socket died / daemon gone: fail all pending commands so no caller
|
||||||
|
* hangs on a dead connection. */
|
||||||
|
private handleTeardown(): void {
|
||||||
|
for (const resolve of this.pending.values()) {
|
||||||
|
resolve({ error: "connection-lost" });
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
this.sock = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send a command and await mpv's response (correlated by request_id).
|
||||||
|
* Resolves `{ error: "timeout" }` instead of hanging when mpv stalls. */
|
||||||
|
send(command: unknown[], timeoutMs = 2000): Promise<MpvResponse> {
|
||||||
|
const sock = this.sock;
|
||||||
|
if (!sock) return Promise.resolve({ error: "not-connected" });
|
||||||
|
const id = this.nextId++;
|
||||||
|
const { promise, resolve } = Promise.withResolvers<MpvResponse>();
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (this.pending.delete(id)) resolve({ error: "timeout" });
|
||||||
|
}, timeoutMs);
|
||||||
|
this.pending.set(id, (msg) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(msg);
|
||||||
|
});
|
||||||
|
sock.write(JSON.stringify({ command, request_id: id }) + "\n");
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One-shot wait for an mpv event by name. Register BEFORE the command
|
||||||
|
* that triggers it. Resolves null on timeout instead of hanging. */
|
||||||
|
waitEvent(name: string, timeoutMs = 5000): Promise<MpvEvent | null> {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<MpvEvent | null>();
|
||||||
|
const list = this.eventWaiters.get(name) ?? [];
|
||||||
|
list.push(resolve);
|
||||||
|
this.eventWaiters.set(name, list);
|
||||||
|
setTimeout(() => {
|
||||||
|
const current = this.eventWaiters.get(name);
|
||||||
|
if (current) {
|
||||||
|
this.eventWaiters.set(
|
||||||
|
name,
|
||||||
|
current.filter((w) => w !== resolve),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolve(null);
|
||||||
|
}, timeoutMs);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
try {
|
||||||
|
this.sock?.end();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.handleTeardown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── mpv Backend ──────────────────────────────────────────────────────
|
// ── mpv Backend ──────────────────────────────────────────────────────
|
||||||
// Uses JSON IPC over a Unix socket for full bidirectional control.
|
// One resident daemon for the app's lifetime, controlled over a single
|
||||||
|
// persistent JSON IPC connection with property observation.
|
||||||
|
|
||||||
|
/** Property observation ids (correlate property-change events). */
|
||||||
|
const OBS_TIME_POS = 1;
|
||||||
|
const OBS_PAUSE = 2;
|
||||||
|
const OBS_DURATION = 3;
|
||||||
|
const OBS_EOF = 4;
|
||||||
|
|
||||||
export class MpvBackend implements AudioBackend {
|
export class MpvBackend implements AudioBackend {
|
||||||
readonly name: BackendName = "mpv";
|
readonly name: BackendName = "mpv";
|
||||||
private proc: Subprocess | null = null;
|
private proc: Subprocess | null = null;
|
||||||
private socketPath = mpvSocketPath();
|
private socketPath = mpvSocketPath();
|
||||||
private _playing = false;
|
private conn: MpvConnection | null = null;
|
||||||
|
/** Guarantee daemon startup runs once (concurrent play/preload). */
|
||||||
|
private startPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
// Command intent: what PodTUI asked the player to do.
|
||||||
|
private _intentPlaying = false;
|
||||||
|
/** The file currently loaded via loadfile (null = idle). */
|
||||||
|
private _loadedUrl: string | null = null;
|
||||||
|
/** The current file was loadfile'd paused (preload) and not yet played. */
|
||||||
|
private _loadedPaused = false;
|
||||||
|
/** Set on end-file reason "eof"/"error"; cleared by the next loadfile. */
|
||||||
|
private _ended = false;
|
||||||
|
|
||||||
|
// Observed (player-reported) state, pushed by mpv property-change events.
|
||||||
private _position = 0;
|
private _position = 0;
|
||||||
private _duration = 0;
|
private _duration = 0;
|
||||||
|
/** null until the first pause observation arrives. */
|
||||||
|
private _paused: boolean | null = null;
|
||||||
|
|
||||||
private _volume = 100;
|
private _volume = 100;
|
||||||
private _speed = 1;
|
private _speed = 1;
|
||||||
private _exited = false;
|
private _exited = false;
|
||||||
|
/** Last playback error reported via end-file reason "error". */
|
||||||
|
private _playbackError: string | null = null;
|
||||||
|
|
||||||
async play(url: string, opts?: PlayOptions): Promise<void> {
|
// ── Daemon lifecycle ─────────────────────────────────────────────
|
||||||
await this.stop();
|
|
||||||
|
|
||||||
|
private async ensureDaemon(): Promise<void> {
|
||||||
|
if (this.proc && !this._exited && this.conn) return;
|
||||||
|
if (this.startPromise) return this.startPromise;
|
||||||
|
this.startPromise = this.spawnDaemon().finally(() => {
|
||||||
|
this.startPromise = null;
|
||||||
|
});
|
||||||
|
return this.startPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async spawnDaemon(): Promise<void> {
|
||||||
// Clean up stale socket
|
// Clean up stale socket
|
||||||
try {
|
try {
|
||||||
if (existsSync(this.socketPath)) {
|
unlinkSync(this.socketPath);
|
||||||
const { unlinkSync } = await import("fs");
|
|
||||||
unlinkSync(this.socketPath);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = [
|
this.proc = Bun.spawn(
|
||||||
resolveMpvBinary() ?? "mpv",
|
[
|
||||||
"--no-video",
|
resolveMpvBinary() ?? "mpv",
|
||||||
"--no-terminal",
|
"--no-video",
|
||||||
"--really-quiet",
|
"--no-terminal",
|
||||||
`--input-ipc-server=${this.socketPath}`,
|
"--really-quiet",
|
||||||
`--volume=${Math.round((opts?.volume ?? 1) * 100)}`,
|
// Stay alive after finishing/unloading files; PodTUI owns one mpv
|
||||||
`--speed=${opts?.speed ?? 1}`,
|
// for its whole session and switches episodes via loadfile.
|
||||||
];
|
"--idle=yes",
|
||||||
|
"--keep-open=yes",
|
||||||
if (opts?.mediaTitle) {
|
// Cap the demuxer cache. mpv's defaults (150MiB) make it race
|
||||||
args.push(`--force-media-title=${opts.mediaTitle}`);
|
// to fill while a preload sits paused — measured 45MB pulled
|
||||||
}
|
// within 12s of a boot-restore preload, saturating the link
|
||||||
|
// exactly when everything else is starting up. ~90s forward
|
||||||
if (opts?.coverArtPath) {
|
// target / 40MiB hard cap is a few MB at podcast bitrates:
|
||||||
// Explicit cover file → albumart track → macOS Now Playing artwork
|
// plenty for instant resume + stall resilience.
|
||||||
// (works for remote streams, not just local downloads).
|
"--cache-secs=90",
|
||||||
args.push(`--cover-art-files=${opts.coverArtPath}`);
|
"--demuxer-max-bytes=40MiB",
|
||||||
}
|
"--demuxer-max-back-bytes=20MiB",
|
||||||
|
`--input-ipc-server=${this.socketPath}`,
|
||||||
if (opts?.startPosition && opts.startPosition > 0) {
|
],
|
||||||
args.push(`--start=${opts.startPosition}`);
|
{ stdout: "ignore", stderr: "ignore", stdin: "ignore" },
|
||||||
}
|
);
|
||||||
|
|
||||||
args.push(url);
|
|
||||||
|
|
||||||
this.proc = Bun.spawn(args, {
|
|
||||||
stdout: "ignore",
|
|
||||||
stderr: "ignore",
|
|
||||||
stdin: "ignore",
|
|
||||||
});
|
|
||||||
|
|
||||||
this._playing = true;
|
|
||||||
this._exited = false;
|
this._exited = false;
|
||||||
this._position = opts?.startPosition ?? 0;
|
|
||||||
this._volume = Math.round((opts?.volume ?? 1) * 100);
|
|
||||||
this._speed = opts?.speed ?? 1;
|
|
||||||
|
|
||||||
// Wait for socket to appear (mpv creates it async)
|
|
||||||
await this.waitForSocket(2000);
|
|
||||||
|
|
||||||
// Position is fetched live from mpv on each getPosition() call (see
|
|
||||||
// below) — the UI polls it, so no internal poll timer is needed.
|
|
||||||
|
|
||||||
// Detect process exit
|
|
||||||
this.proc.exited
|
this.proc.exited
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this._playing = false;
|
|
||||||
this._exited = true;
|
this._exited = true;
|
||||||
|
this._intentPlaying = false;
|
||||||
|
this._loadedUrl = null;
|
||||||
|
this._paused = null;
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
|
||||||
|
|
||||||
private async waitForSocket(timeoutMs: number): Promise<void> {
|
// mpv creates the socket asynchronously (measured ~600ms cold spawn).
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
while (Date.now() - start < timeoutMs) {
|
while (Date.now() - start < 3000) {
|
||||||
if (existsSync(this.socketPath)) return;
|
if (this._exited) break;
|
||||||
|
if (existsSync(this.socketPath)) break;
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const conn = new MpvConnection();
|
||||||
|
conn.onEvent = (msg) => this.handleEvent(msg);
|
||||||
|
await conn.connect(this.socketPath);
|
||||||
|
this.conn = conn;
|
||||||
|
|
||||||
|
// Observe the state the UI polls: mpv then pushes changes at ~20Hz
|
||||||
|
// while playing and broadcasts external changes (AirPods pull, OS
|
||||||
|
// media keys) with zero polling from our side.
|
||||||
|
await this.send(["observe_property", OBS_TIME_POS, "time-pos"]);
|
||||||
|
await this.send(["observe_property", OBS_PAUSE, "pause"]);
|
||||||
|
await this.send(["observe_property", OBS_DURATION, "duration"]);
|
||||||
|
// With --keep-open=yes mpv does NOT emit end-file at natural EOF — it
|
||||||
|
// sets eof-reached=true (and pauses at the last frame) instead. That
|
||||||
|
// property is the track-end signal; end-file only covers unload/error.
|
||||||
|
await this.send(["observe_property", OBS_EOF, "eof-reached"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send a fire-and-forget command (no response needed) */
|
private async send(
|
||||||
private async send(command: unknown[]): Promise<void> {
|
command: unknown[],
|
||||||
try {
|
): Promise<MpvResponse> {
|
||||||
const conn = await Bun.connect({
|
if (!this.conn) return { error: "not-connected" };
|
||||||
unix: this.socketPath,
|
return this.conn.send(command);
|
||||||
socket: {
|
}
|
||||||
data() {},
|
|
||||||
error() {},
|
private handleEvent(msg: MpvEvent): void {
|
||||||
close() {},
|
if (msg.event === "property-change") {
|
||||||
open() {},
|
if (msg.id === OBS_TIME_POS) {
|
||||||
},
|
// `data` is number while playing; unavailable → undefined while
|
||||||
});
|
// idle. Keep last known on transient gaps, reset on idle.
|
||||||
conn.write(JSON.stringify({ command }) + "\n");
|
if (typeof msg.data === "number") this._position = msg.data;
|
||||||
// Don't wait, just schedule a close
|
} else if (msg.id === OBS_PAUSE) {
|
||||||
setTimeout(() => {
|
if (typeof msg.data === "boolean") this._paused = msg.data;
|
||||||
try {
|
} else if (msg.id === OBS_DURATION) {
|
||||||
conn.end();
|
if (typeof msg.data === "number" && msg.data > 0) {
|
||||||
} catch {}
|
this._duration = msg.data;
|
||||||
}, 50);
|
}
|
||||||
} catch {
|
} else if (msg.id === OBS_EOF) {
|
||||||
/* ignore */
|
// Natural end-of-file (or a brand-new load reporting false).
|
||||||
|
this._ended = msg.data === true;
|
||||||
|
if (this._ended) this._intentPlaying = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.event === "end-file") {
|
||||||
|
if (msg.reason === "eof") {
|
||||||
|
this._ended = true;
|
||||||
|
this._intentPlaying = false;
|
||||||
|
} else if (msg.reason === "error") {
|
||||||
|
this._ended = true;
|
||||||
|
this._intentPlaying = false;
|
||||||
|
this._playbackError = msg.error ?? "mpv failed to play the stream";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.event === "file-loaded") {
|
||||||
|
this._ended = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── File presentation options ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// force-media-title and cover-art-files are set as global properties
|
||||||
|
// BEFORE loadfile (verified: runtime-settable; values containing commas
|
||||||
|
// would corrupt the per-file options string). Numbers (volume, speed,
|
||||||
|
// start, pause) ride as per-file options on loadfile itself so each
|
||||||
|
// loadfile is self-contained.
|
||||||
|
|
||||||
|
private async applyPresentation(opts?: PlayOptions): Promise<void> {
|
||||||
|
await this.send([
|
||||||
|
"set_property",
|
||||||
|
"force-media-title",
|
||||||
|
opts?.mediaTitle ?? "",
|
||||||
|
]);
|
||||||
|
await this.send([
|
||||||
|
"set_property",
|
||||||
|
"cover-art-files",
|
||||||
|
opts?.coverArtPath ?? "",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadfileOptions(opts: PlayOptions | undefined, paused: boolean): string {
|
||||||
|
const parts: string[] = [`pause=${paused ? "yes" : "no"}`];
|
||||||
|
if (opts?.startPosition && opts.startPosition > 0) {
|
||||||
|
parts.push(`start=${Math.max(0, opts.startPosition)}`);
|
||||||
|
}
|
||||||
|
const vol = Math.round((opts?.volume ?? 1) * 100);
|
||||||
|
if (Number.isFinite(vol)) parts.push(`volume=${vol}`);
|
||||||
|
const speed = opts?.speed ?? 1;
|
||||||
|
if (Number.isFinite(speed) && speed > 0) parts.push(`speed=${speed}`);
|
||||||
|
return parts.join(",");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a property value from mpv via IPC.
|
* Every loadfile (play, preload, replay) runs under this mutex: useAudio
|
||||||
*
|
* fires the boot preload unawaited, so without serialization a user
|
||||||
* Resolves the parsed numeric value, or `undefined` when the read fails
|
* pressing Play mid-preload would send loadfile(no-pause) followed by the
|
||||||
* (socket error, timeout, unparseable response, or the property being
|
* in-flight preload's loadfile(pause=yes) — and the stale preload would
|
||||||
* unavailable — e.g. `time-pos` before playback starts). Failure is
|
* pause the file the user just started. The mutex also prevents
|
||||||
* distinct from a legitimate `0` so callers can keep the last known
|
* presentation options (title/cover) of one episode from interleaving
|
||||||
* value instead of snapping the position clock to zero on a transient
|
* with the loadfile of another.
|
||||||
* error; the next poll retries.
|
|
||||||
*
|
|
||||||
* mpv multiplexes unsolicited events (audio-reconfig, file-loaded, ...)
|
|
||||||
* onto the same connection, so we line-buffer and only settle on the
|
|
||||||
* line that carries the command response (`request_id` set). The socket
|
|
||||||
* is closed once the response is handled — leaving it open leaks an fd
|
|
||||||
* per poll, while closing it before mpv processes the request drops the
|
|
||||||
* reply.
|
|
||||||
*/
|
*/
|
||||||
private async getProperty(name: string): Promise<number | undefined> {
|
private loadMutex: Promise<unknown> = Promise.resolve();
|
||||||
try {
|
|
||||||
return await new Promise<number | undefined>((resolve) => {
|
|
||||||
let settled = false;
|
|
||||||
let sock: Socket | null = null;
|
|
||||||
let buf = "";
|
|
||||||
const done = (value: number | undefined) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
clearTimeout(timeout);
|
|
||||||
try {
|
|
||||||
sock?.end();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
resolve(value);
|
|
||||||
};
|
|
||||||
const timeout = setTimeout(() => done(undefined), 300);
|
|
||||||
|
|
||||||
Bun.connect({
|
private runLoadExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
unix: this.socketPath,
|
const result = this.loadMutex.then(fn);
|
||||||
socket: {
|
this.loadMutex = result.catch(() => {});
|
||||||
open(socket) {
|
return result;
|
||||||
sock = socket;
|
}
|
||||||
socket.write(
|
|
||||||
JSON.stringify({ command: ["get_property", name] }) + "\n",
|
private async loadFileLocked(
|
||||||
);
|
url: string,
|
||||||
},
|
opts: PlayOptions | undefined,
|
||||||
data(_socket, data) {
|
paused: boolean,
|
||||||
buf += Buffer.from(data).toString();
|
): Promise<void> {
|
||||||
let nl = buf.indexOf("\n");
|
await this.applyPresentation(opts);
|
||||||
while (nl !== -1) {
|
// Paused preload of a mid-episode restore: pass NO start= option and
|
||||||
const line = buf.slice(0, nl);
|
// seek while paused instead. mpv defers --start stream work (open,
|
||||||
buf = buf.slice(nl + 1);
|
// header probe, demuxer seek) until playback begins — measured: the
|
||||||
nl = buf.indexOf("\n");
|
// demuxer cache stays EMPTY during the whole preload and the eventual
|
||||||
try {
|
// unpause pays 4.3s. A time-pos seek while paused executes at once,
|
||||||
const parsed = JSON.parse(line);
|
// so the stream opens and buffers during the preload, and the first
|
||||||
// Events carry no request_id; only settle on
|
// real Play is a sub-second unpause.
|
||||||
// the actual command response.
|
const pausedSeek =
|
||||||
if (parsed?.request_id === undefined) continue;
|
paused && opts?.startPosition && opts.startPosition > 0
|
||||||
if (parsed?.data !== undefined) {
|
? opts.startPosition
|
||||||
done(Number(parsed.data) || 0);
|
: null;
|
||||||
} else {
|
const loadOpts =
|
||||||
done(undefined);
|
pausedSeek && opts ? { ...opts, startPosition: undefined } : opts;
|
||||||
}
|
// Register the file-loaded waiter BEFORE loadfile: the event can
|
||||||
return;
|
// arrive between the command response and listener setup otherwise.
|
||||||
} catch {
|
const fileLoaded = pausedSeek && this.conn ? this.conn.waitEvent("file-loaded") : null;
|
||||||
/* skip malformed lines */
|
const resp = await this.send([
|
||||||
}
|
"loadfile",
|
||||||
}
|
url,
|
||||||
},
|
"replace",
|
||||||
error() {
|
-1,
|
||||||
done(undefined);
|
this.loadfileOptions(loadOpts, paused),
|
||||||
},
|
]);
|
||||||
close() {
|
if (resp.error && resp.error !== "success") {
|
||||||
done(undefined);
|
throw new Error(`mpv loadfile failed: ${resp.error}`);
|
||||||
},
|
|
||||||
},
|
|
||||||
}).catch(() => done(undefined));
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
if (pausedSeek) {
|
||||||
|
// time-pos sent before file-loaded is silently dropped by mpv
|
||||||
|
// (no file yet) — the preload then parked at 0 and the restore
|
||||||
|
// position was lost. Wait for the open, then seek.
|
||||||
|
await fileLoaded;
|
||||||
|
await this.send(["set_property", "time-pos", pausedSeek]);
|
||||||
|
this._position = pausedSeek;
|
||||||
|
}
|
||||||
|
this._loadedUrl = url;
|
||||||
|
this._loadedPaused = paused;
|
||||||
|
this._ended = false;
|
||||||
|
this._playbackError = null;
|
||||||
|
this._position = opts?.startPosition ?? 0;
|
||||||
|
this._duration = 0;
|
||||||
|
this._volume = Math.round((opts?.volume ?? 1) * 100);
|
||||||
|
this._speed = opts?.speed ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AudioBackend ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async play(url: string, opts?: PlayOptions): Promise<void> {
|
||||||
|
await this.ensureDaemon();
|
||||||
|
// Mark intent before the mutex: a boot preload queued behind this
|
||||||
|
// play checks it and skips its own stale paused-load.
|
||||||
|
this._intentPlaying = true;
|
||||||
|
await this.runLoadExclusive(async () => {
|
||||||
|
// Fast path: this exact URL was PRELOADED paused (boot restore) —
|
||||||
|
// mpv has been buffering it since boot, so flipping pause off starts
|
||||||
|
// audio ~instantly. Re-acquire the start position only when it
|
||||||
|
// moved meaningfully since the preload (progress saved meanwhile).
|
||||||
|
if (this._loadedUrl === url && this._loadedPaused && !this._ended) {
|
||||||
|
const target = opts?.startPosition ?? this._position;
|
||||||
|
if (Math.abs(target - this._position) > 2) {
|
||||||
|
await this.send(["set_property", "time-pos", target]);
|
||||||
|
this._position = target;
|
||||||
|
}
|
||||||
|
await this.send([
|
||||||
|
"set_property",
|
||||||
|
"volume",
|
||||||
|
Math.round((opts?.volume ?? 1) * 100),
|
||||||
|
]);
|
||||||
|
await this.send(["set_property", "speed", opts?.speed ?? 1]);
|
||||||
|
if (opts?.coverArtPath) {
|
||||||
|
// File is already loaded: cover-art-files only applies at
|
||||||
|
// load, so add the art as a runtime albumart track instead.
|
||||||
|
await this.send(["set_property", "cover-art-files", opts.coverArtPath]);
|
||||||
|
await this.send(["video-add", opts.coverArtPath]);
|
||||||
|
}
|
||||||
|
if (opts?.mediaTitle) {
|
||||||
|
await this.send(["set_property", "force-media-title", opts.mediaTitle]);
|
||||||
|
}
|
||||||
|
await this.send(["set_property", "pause", false]);
|
||||||
|
this._loadedPaused = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.loadFileLocked(url, opts, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async preload(url: string, opts?: PlayOptions): Promise<void> {
|
||||||
|
await this.ensureDaemon();
|
||||||
|
await this.runLoadExclusive(async () => {
|
||||||
|
// Already loaded (paused park, or actively playing because the
|
||||||
|
// user pressed Play while this preload was queued — either way
|
||||||
|
// the file is in the player and must not be clobbered).
|
||||||
|
if (this._loadedUrl === url) return;
|
||||||
|
await this.loadFileLocked(url, opts, true);
|
||||||
|
this._intentPlaying = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addCoverArt(path: string): Promise<void> {
|
||||||
|
if (!this._loadedUrl) return;
|
||||||
|
// Keep the property pointing at the latest art too, so a subsequent
|
||||||
|
// loadfile of the same episode carries it.
|
||||||
|
await this.send(["set_property", "cover-art-files", path]);
|
||||||
|
await this.send(["video-add", path]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async pause(): Promise<void> {
|
async pause(): Promise<void> {
|
||||||
await this.send(["set_property", "pause", true]);
|
await this.send(["set_property", "pause", true]);
|
||||||
this._playing = false;
|
this._intentPlaying = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async resume(): Promise<void> {
|
async resume(): Promise<void> {
|
||||||
|
if (this._ended && this._loadedUrl) {
|
||||||
|
// Play pressed on a finished episode: replay from the top.
|
||||||
|
this._ended = false;
|
||||||
|
const url = this._loadedUrl;
|
||||||
|
await this.runLoadExclusive(async () => {
|
||||||
|
await this.loadFileLocked(
|
||||||
|
url,
|
||||||
|
{ volume: this._volume / 100, speed: this._speed },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
this._intentPlaying = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._loadedPaused && this._loadedUrl) {
|
||||||
|
// Deferred first play of a preloaded file.
|
||||||
|
this._loadedPaused = false;
|
||||||
|
}
|
||||||
|
this._ended = false;
|
||||||
await this.send(["set_property", "pause", false]);
|
await this.send(["set_property", "pause", false]);
|
||||||
this._playing = true;
|
this._intentPlaying = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
if (this.proc) {
|
if (this.conn && this._loadedUrl) {
|
||||||
try {
|
await this.send(["stop"]);
|
||||||
this.proc.kill();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
this.proc = null;
|
|
||||||
}
|
}
|
||||||
this._playing = false;
|
this._intentPlaying = false;
|
||||||
|
this._loadedUrl = null;
|
||||||
|
this._loadedPaused = false;
|
||||||
|
this._ended = false;
|
||||||
this._position = 0;
|
this._position = 0;
|
||||||
|
this._duration = 0;
|
||||||
// Clean up socket
|
await this.send(["set_property", "cover-art-files", ""]);
|
||||||
try {
|
|
||||||
if (existsSync(this.socketPath)) {
|
|
||||||
const { unlinkSync } = await import("fs");
|
|
||||||
unlinkSync(this.socketPath);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async seek(seconds: number): Promise<void> {
|
async seek(seconds: number): Promise<void> {
|
||||||
@@ -368,41 +698,55 @@ export class MpvBackend implements AudioBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getPosition(): Promise<number> {
|
async getPosition(): Promise<number> {
|
||||||
// Live-fetch `time-pos` so the position clock is as fresh as the
|
// Observed at ~20Hz by mpv — no socket roundtrip on the UI poll.
|
||||||
// UI's poll rate (the hook polls this at ~150ms). On a transient IPC
|
|
||||||
// failure, keep the last known value rather than returning 0.
|
|
||||||
if (this._playing && this.proc) {
|
|
||||||
const pos = await this.getProperty("time-pos");
|
|
||||||
if (pos !== undefined) this._position = pos;
|
|
||||||
}
|
|
||||||
return this._position;
|
return this._position;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDuration(): Promise<number> {
|
async getDuration(): Promise<number> {
|
||||||
if (this._duration <= 0) {
|
|
||||||
const dur = await this.getProperty("duration");
|
|
||||||
if (dur !== undefined && dur > 0) this._duration = dur;
|
|
||||||
}
|
|
||||||
return this._duration;
|
return this._duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
isPlaying(): boolean {
|
isPlaying(): boolean {
|
||||||
return this._playing;
|
return this._intentPlaying && this.isAlive() && !this._ended;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPauseState(): Promise<boolean | undefined> {
|
async getPauseState(): Promise<boolean | undefined> {
|
||||||
if (!this.isAlive()) return undefined;
|
if (!this.isAlive() || this._paused === null) return undefined;
|
||||||
const p = await this.getProperty("pause");
|
return this._paused;
|
||||||
if (p === undefined) return undefined;
|
|
||||||
return p === 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isAlive(): boolean {
|
isAlive(): boolean {
|
||||||
return this.proc !== null && !this._exited;
|
return this.proc !== null && !this._exited;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Last mpv playback failure (end-file reason "error"), if any. */
|
||||||
|
getPlaybackError(): string | null {
|
||||||
|
return this._playbackError;
|
||||||
|
}
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this.stop();
|
const conn = this.conn;
|
||||||
|
this.conn = null;
|
||||||
|
if (conn) {
|
||||||
|
// Ask nicely, then force: dispose runs inside process-exit
|
||||||
|
// handlers where awaiting is not guaranteed to complete.
|
||||||
|
conn.send(["quit"], 500).catch(() => {});
|
||||||
|
}
|
||||||
|
if (this.proc) {
|
||||||
|
try {
|
||||||
|
this.proc.kill();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
this.proc = null;
|
||||||
|
}
|
||||||
|
this._exited = true;
|
||||||
|
this._intentPlaying = false;
|
||||||
|
try {
|
||||||
|
unlinkSync(this.socketPath);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +755,8 @@ export class MpvBackend implements AudioBackend {
|
|||||||
class NoopBackend implements AudioBackend {
|
class NoopBackend implements AudioBackend {
|
||||||
readonly name: BackendName = "none";
|
readonly name: BackendName = "none";
|
||||||
async play(): Promise<void> {}
|
async play(): Promise<void> {}
|
||||||
|
async preload(): Promise<void> {}
|
||||||
|
async addCoverArt(): Promise<void> {}
|
||||||
async pause(): Promise<void> {}
|
async pause(): Promise<void> {}
|
||||||
async resume(): Promise<void> {}
|
async resume(): Promise<void> {}
|
||||||
async stop(): Promise<void> {}
|
async stop(): Promise<void> {}
|
||||||
|
|||||||
@@ -1,324 +0,0 @@
|
|||||||
/**
|
|
||||||
* Real-time audio stream reader for visualization.
|
|
||||||
*
|
|
||||||
* Spawns a separate ffmpeg process that decodes the same audio URL
|
|
||||||
* the player is using and outputs raw PCM data (signed 16-bit LE, mono,
|
|
||||||
* 44100 Hz) to a pipe. The reader accumulates samples in a ring buffer
|
|
||||||
* and serves windows *at a requested playback position* to the caller.
|
|
||||||
*
|
|
||||||
* This is independent from the actual playback backend — it's a
|
|
||||||
* read-only "tap" on the audio for FFT analysis purposes. Sync with the
|
|
||||||
* player is maintained by pacing decode at the player's clock rate
|
|
||||||
* (`-readrate <speed>`) while front-loading a burst of LEAD_SECONDS
|
|
||||||
* (`-readrate_initial_burst`) so the decode head leads the player
|
|
||||||
* position by a stable lead — read() samples at the exact position the
|
|
||||||
* player reports, never at the decode head.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** PCM output format constants */
|
|
||||||
const SAMPLE_RATE = 44100;
|
|
||||||
const CHANNELS = 1;
|
|
||||||
const BYTES_PER_SAMPLE = 2; // s16le
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many samples to buffer (~10 seconds).
|
|
||||||
* Large enough to absorb the gap between mpv's startup latency (0.5–3s,
|
|
||||||
* more for network streams at speed) and the reader's decode head, plus
|
|
||||||
* short player stalls. Samples older than the ring window are never needed
|
|
||||||
* again — the renderer only samples at the current playback position.
|
|
||||||
*/
|
|
||||||
const RING_BUFFER_SAMPLES = SAMPLE_RATE * 10;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decode-head lead over the player position, in seconds.
|
|
||||||
*
|
|
||||||
* `-readrate_initial_burst LEAD_SECONDS` makes ffmpeg emit this much audio
|
|
||||||
* immediately on start, then pace at realtime (`-readrate speed`) after.
|
|
||||||
* The decode head thus leads the player by ~LEAD_SECONDS from the very
|
|
||||||
* first frame. read() samples at the player's current position, which is
|
|
||||||
* always behind the head — so it finds freshly decoded samples there
|
|
||||||
* instead of clamping to stale data.
|
|
||||||
*
|
|
||||||
* Bare `-readrate speed` (no burst) starts ffmpeg ε behind mpv (input-open
|
|
||||||
* + first-packet latency) and, since both advance at the same rate, never
|
|
||||||
* catches up — the bars lag by ε (up to several seconds on network
|
|
||||||
* streams). The burst eliminates that constant offset.
|
|
||||||
*
|
|
||||||
* Must stay within the ring window (RING_BUFFER_SAMPLES ~10s) so the
|
|
||||||
* lead audio hasn't wrapped out by the time the player reaches it.
|
|
||||||
*/
|
|
||||||
const LEAD_SECONDS = 3;
|
|
||||||
|
|
||||||
export interface AudioStreamReaderOptions {
|
|
||||||
/** Audio URL or file path to decode */
|
|
||||||
url: string;
|
|
||||||
/** Sample rate (default: 44100) */
|
|
||||||
sampleRate?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Monotonically increasing generation counter.
|
|
||||||
* Each start() increments this; the read loop checks it to know
|
|
||||||
* if it's been superseded and should bail out.
|
|
||||||
*/
|
|
||||||
let globalGeneration = 0;
|
|
||||||
|
|
||||||
import type { Subprocess } from "bun";
|
|
||||||
|
|
||||||
export class AudioStreamReader {
|
|
||||||
private proc: Subprocess | null = null;
|
|
||||||
private ringBuffer: Float64Array;
|
|
||||||
private writePos = 0;
|
|
||||||
private totalSamplesWritten = 0;
|
|
||||||
private startPosition = 0;
|
|
||||||
private _running = false;
|
|
||||||
private generation = 0;
|
|
||||||
readonly url: string;
|
|
||||||
private sampleRate: number;
|
|
||||||
|
|
||||||
constructor(options: AudioStreamReaderOptions) {
|
|
||||||
this.url = options.url;
|
|
||||||
this.sampleRate = options.sampleRate ?? SAMPLE_RATE;
|
|
||||||
this.ringBuffer = new Float64Array(RING_BUFFER_SAMPLES);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the reader is actively reading samples. */
|
|
||||||
get running(): boolean {
|
|
||||||
return this._running;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Total number of samples written since start(). */
|
|
||||||
get samplesWritten(): number {
|
|
||||||
return this.totalSamplesWritten;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the ffmpeg decode process and begin reading PCM data.
|
|
||||||
*
|
|
||||||
* If already running, the previous process is killed first.
|
|
||||||
* Uses a generation counter to guarantee that only one read loop
|
|
||||||
* is ever active — stale loops from killed processes bail out
|
|
||||||
* immediately.
|
|
||||||
*
|
|
||||||
* @param startPosition Seek position in seconds (default: 0).
|
|
||||||
* @param speed Playback speed multiplier (default: 1). Paces ffmpeg
|
|
||||||
* at the player's advance rate so decode tracks the
|
|
||||||
* player clock; `-readrate_initial_burst` front-loads
|
|
||||||
* a LEAD_SECONDS head start.
|
|
||||||
*/
|
|
||||||
start(startPosition = 0, speed = 1): void {
|
|
||||||
// Always kill the previous process first — no early return on _running
|
|
||||||
this.killProcess();
|
|
||||||
|
|
||||||
if (!Bun.which("ffmpeg")) {
|
|
||||||
throw new Error("ffmpeg not found — required for audio visualization");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment generation so any lingering read loop from a previous
|
|
||||||
// start() will see a mismatch and exit.
|
|
||||||
this.generation = ++globalGeneration;
|
|
||||||
this.startPosition = Math.max(0, startPosition);
|
|
||||||
|
|
||||||
const readRate = Math.max(0.25, speed > 0 ? speed : 1);
|
|
||||||
|
|
||||||
const args = [
|
|
||||||
"ffmpeg",
|
|
||||||
"-loglevel",
|
|
||||||
"quiet",
|
|
||||||
// Pace input at the player's advance rate (speed× native). Combined
|
|
||||||
// with -readrate_initial_burst below, the decode head starts
|
|
||||||
// LEAD_SECONDS ahead of the player and advances at the same rate —
|
|
||||||
// read() samples at the player position and always finds fresh data.
|
|
||||||
"-readrate",
|
|
||||||
String(readRate),
|
|
||||||
// Front-load LEAD_SECONDS of audio immediately so the decode head
|
|
||||||
// leads the player from the very first frame. Without this, ffmpeg
|
|
||||||
// starts ε behind mpv (input-open + first-packet latency) and,
|
|
||||||
// pacing at the same rate, never catches up — bars lag by ε.
|
|
||||||
"-readrate_initial_burst",
|
|
||||||
String(LEAD_SECONDS),
|
|
||||||
];
|
|
||||||
|
|
||||||
// `-reconnect*` are http-protocol options: ffmpeg rejects them at
|
|
||||||
// input-open when the input is a local file, killing the process
|
|
||||||
// before any PCM is produced. Only pass them for network URLs.
|
|
||||||
if (/^https?:\/\//i.test(this.url)) {
|
|
||||||
args.push(
|
|
||||||
"-reconnect",
|
|
||||||
"1",
|
|
||||||
"-reconnect_streamed",
|
|
||||||
"1",
|
|
||||||
"-reconnect_delay_max",
|
|
||||||
"5",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seek before input for network efficiency
|
|
||||||
if (startPosition > 0) {
|
|
||||||
args.push("-ss", String(startPosition));
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push("-i", this.url);
|
|
||||||
|
|
||||||
// No atempo filter: the renderer samples the *source* audio at the
|
|
||||||
// player's current position, so output samples map 1:1 to input time
|
|
||||||
// (stream index = (targetSeconds - startPosition) * sampleRate).
|
|
||||||
args.push(
|
|
||||||
"-ac",
|
|
||||||
String(CHANNELS),
|
|
||||||
"-ar",
|
|
||||||
String(this.sampleRate),
|
|
||||||
"-f",
|
|
||||||
"s16le",
|
|
||||||
"-acodec",
|
|
||||||
"pcm_s16le",
|
|
||||||
"-",
|
|
||||||
);
|
|
||||||
|
|
||||||
this.proc = Bun.spawn(args, {
|
|
||||||
stdout: "pipe",
|
|
||||||
stderr: "ignore",
|
|
||||||
stdin: "ignore",
|
|
||||||
});
|
|
||||||
|
|
||||||
this._running = true;
|
|
||||||
this.writePos = 0;
|
|
||||||
this.totalSamplesWritten = 0;
|
|
||||||
|
|
||||||
const myGeneration = this.generation;
|
|
||||||
|
|
||||||
this.readLoop(myGeneration);
|
|
||||||
|
|
||||||
// Detect process exit
|
|
||||||
this.proc.exited
|
|
||||||
.then(() => {
|
|
||||||
// Only clear _running if this is still the current generation
|
|
||||||
if (this.generation === myGeneration) {
|
|
||||||
this._running = false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (this.generation === myGeneration) {
|
|
||||||
this._running = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read the visualization window ending at `targetSeconds` of playback.
|
|
||||||
*
|
|
||||||
* The player (mpv) and this decoder are independent processes, so the
|
|
||||||
* decode head and the actual playback position drift apart (startup skew,
|
|
||||||
* stalls, speed changes). Instead of sampling the decode head, we select
|
|
||||||
* the window *at* the position the player reports, clamped to the nearest
|
|
||||||
* available samples when the target hasn't been decoded yet (decode head
|
|
||||||
* behind) or has already wrapped out of the ring (long stall).
|
|
||||||
*
|
|
||||||
* @param out - Float64Array to fill with samples (scaled ~+/-32768 for cavacore).
|
|
||||||
* @param targetSeconds - Playback position (input seconds) to sample.
|
|
||||||
* @returns Number of samples written to `out`.
|
|
||||||
*/
|
|
||||||
read(out: Float64Array, targetSeconds: number): number {
|
|
||||||
if (this.totalSamplesWritten <= 0 || out.length === 0) return 0;
|
|
||||||
|
|
||||||
const headSample = this.totalSamplesWritten - 1;
|
|
||||||
const coveredStart = Math.max(
|
|
||||||
0,
|
|
||||||
this.totalSamplesWritten - this.ringBuffer.length,
|
|
||||||
);
|
|
||||||
|
|
||||||
const targetSample = Math.max(
|
|
||||||
0,
|
|
||||||
Math.round((targetSeconds - this.startPosition) * this.sampleRate),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Window end: the target, clamped to what's been decoded so far.
|
|
||||||
const endSample = Math.min(targetSample, headSample);
|
|
||||||
// Window start: at most out.length samples back, clamped to what the
|
|
||||||
// ring still holds (target older than the ring -> serve the oldest
|
|
||||||
// available window, which is the closest to the target).
|
|
||||||
const startSample = Math.max(
|
|
||||||
coveredStart,
|
|
||||||
Math.min(endSample, endSample - out.length + 1),
|
|
||||||
);
|
|
||||||
const available = endSample - startSample + 1;
|
|
||||||
if (available <= 0) return 0;
|
|
||||||
|
|
||||||
const ringLen = this.ringBuffer.length;
|
|
||||||
for (let i = 0; i < available; i++) {
|
|
||||||
out[i] = this.ringBuffer[(startSample + i) % ringLen];
|
|
||||||
}
|
|
||||||
|
|
||||||
return available;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the ffmpeg process and clean up.
|
|
||||||
* Safe to call multiple times. Guarantees the read loop exits.
|
|
||||||
*/
|
|
||||||
stop(): void {
|
|
||||||
// Bump generation to invalidate any running read loop
|
|
||||||
this.generation = ++globalGeneration;
|
|
||||||
this._running = false;
|
|
||||||
this.killProcess();
|
|
||||||
this.writePos = 0;
|
|
||||||
this.totalSamplesWritten = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restart the reader at a new position and/or speed.
|
|
||||||
*/
|
|
||||||
restart(startPosition = 0, speed = 1): void {
|
|
||||||
this.start(startPosition, speed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Kill the ffmpeg process without touching generation/state. */
|
|
||||||
private killProcess(): void {
|
|
||||||
if (this.proc) {
|
|
||||||
try {
|
|
||||||
this.proc.kill();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
this.proc = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Internal: continuously reads stdout from ffmpeg and fills the ring buffer. */
|
|
||||||
private async readLoop(myGeneration: number): Promise<void> {
|
|
||||||
const stdout = this.proc?.stdout;
|
|
||||||
if (!stdout || typeof stdout === "number") return;
|
|
||||||
|
|
||||||
const reader = (stdout as ReadableStream<Uint8Array>).getReader();
|
|
||||||
try {
|
|
||||||
while (this.generation === myGeneration) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done || this.generation !== myGeneration) break;
|
|
||||||
if (!value || value.byteLength === 0) continue;
|
|
||||||
|
|
||||||
const sampleCount = Math.floor(value.byteLength / BYTES_PER_SAMPLE);
|
|
||||||
if (sampleCount === 0) continue;
|
|
||||||
|
|
||||||
const int16View = new Int16Array(
|
|
||||||
value.buffer,
|
|
||||||
value.byteOffset,
|
|
||||||
sampleCount,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (let i = 0; i < sampleCount; i++) {
|
|
||||||
this.ringBuffer[this.writePos] = int16View[i];
|
|
||||||
this.writePos = (this.writePos + 1) % this.ringBuffer.length;
|
|
||||||
this.totalSamplesWritten++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Stream ended or process killed — expected during stop()
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
reader.releaseLock();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,34 +2,97 @@
|
|||||||
* Cover-art staging for the system Now Playing session.
|
* Cover-art staging for the system Now Playing session.
|
||||||
*
|
*
|
||||||
* macOS shows the media session's albumart in the audio center (Control
|
* macOS shows the media session's albumart in the audio center (Control
|
||||||
* Center / lock screen). mpv reads it from `--cover-art-files` (loads the
|
* Center / lock screen). mpv reads artwork from `--cover-art-files` (loads
|
||||||
* file as an albumart video track), so the podcast cover is staged to a temp
|
* the file as an albumart video track), so the podcast cover must exist on
|
||||||
* file BEFORE playback starts and passed to mpv.
|
* disk before (cover-art-files) or right after (video-add) playback starts.
|
||||||
|
*
|
||||||
|
* Covers are cached persistently under `$XDG_CACHE_HOME/podtui/covers`
|
||||||
|
* (~/.cache/podtui/covers by default), keyed by the URL hash, so the
|
||||||
|
* download happens ONCE per feed — subsequent plays (including the
|
||||||
|
* boot-restored episode) hit the disk cache and never wait on the network.
|
||||||
|
* The play path must never block on art: `cachedCoverPath` is the sync
|
||||||
|
* fast path; `fetchCoverArt` is awaited only by flows where latency does
|
||||||
|
* not matter (CLI play) or fired in the background with the result
|
||||||
|
* applied to a live mpv via `video-add`.
|
||||||
*
|
*
|
||||||
* Downloaded via `curl` (not `fetch`): Bun's `fetch` hangs in compiled
|
* Downloaded via `curl` (not `fetch`): Bun's `fetch` hangs in compiled
|
||||||
* `bun build --compile` binaries (Bun 1.3.8), timing out on any host —
|
* `bun build --compile` binaries (Bun 1.3.8), timing out on any host —
|
||||||
* which would silently drop every cover in shipped builds. curl is present
|
* which would silently drop every cover in shipped builds. curl is present
|
||||||
* on macOS and Linux. Bounded: a slow cover server must never stall audio,
|
* on macOS and Linux. Bounded: a slow cover server must never stall audio.
|
||||||
* so an 8s cap drops the art.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { tmpdir } from "os";
|
import { existsSync, mkdirSync, renameSync, statSync } from "fs";
|
||||||
|
import { createHash } from "crypto";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { unlinkSync, statSync } from "fs";
|
|
||||||
|
|
||||||
export const coverTempPath = () => join(tmpdir(), "podtui-cover.jpg");
|
/** Resolved once per process; null when no home directory is detectable. */
|
||||||
|
let cacheDir: string | null | undefined;
|
||||||
|
|
||||||
export async function fetchCoverArt(url: string): Promise<string | null> {
|
function coversDir(): string | null {
|
||||||
const path = coverTempPath();
|
if (cacheDir !== undefined) return cacheDir;
|
||||||
|
let dir: string | null = null;
|
||||||
try {
|
try {
|
||||||
unlinkSync(path);
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||||
|
if (home) {
|
||||||
|
dir = join(process.env.XDG_CACHE_HOME ?? join(home, ".cache"), "podtui", "covers");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* no stale cover */
|
dir = null;
|
||||||
}
|
}
|
||||||
|
cacheDir = dir;
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachePathFor(url: string): string | null {
|
||||||
|
const dir = coversDir();
|
||||||
|
if (!dir) return null;
|
||||||
|
return join(dir, `${createHash("sha1").update(url).digest("hex")}.jpg`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync fast path: the cached cover file for `url`, or null when it has not
|
||||||
|
* been downloaded yet. This is what keeps cover art off the play() critical
|
||||||
|
* path — a cache hit costs one stat() and a miss simply plays without art
|
||||||
|
* (or applies it late via video-add).
|
||||||
|
*/
|
||||||
|
export function cachedCoverPath(url: string): string | null {
|
||||||
|
const path = cachePathFor(url);
|
||||||
|
if (!path) return null;
|
||||||
try {
|
try {
|
||||||
return await Promise.race([
|
return existsSync(path) && statSync(path).size > 0 ? path : null;
|
||||||
(async () => {
|
} catch {
|
||||||
const proc = Bun.spawn([
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-flight downloads keyed by URL — a burst of plays of the same show
|
||||||
|
* shares one curl instead of racing ephemeral files. */
|
||||||
|
const inflight = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the cover for `url`, returns its cache path. Cache hits return
|
||||||
|
* immediately. Downloads are single-flight per URL and time-bounded (8s);
|
||||||
|
* failure resolves null and retries on the next call. The file is written
|
||||||
|
* to a temp name and renamed into place so a killed process can never
|
||||||
|
* poison the cache with a truncated file.
|
||||||
|
*/
|
||||||
|
export function fetchCoverArt(url: string): Promise<string | null> {
|
||||||
|
const cached = cachedCoverPath(url);
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
|
||||||
|
const dest = cachePathFor(url);
|
||||||
|
if (!dest) return Promise.resolve(null);
|
||||||
|
|
||||||
|
const pending = inflight.get(url);
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
const task = (async (): Promise<string | null> => {
|
||||||
|
const staging = `${dest}.${process.pid}.tmp`;
|
||||||
|
try {
|
||||||
|
const { promise, resolve } = Promise.withResolvers<string | null>();
|
||||||
|
const proc = Bun.spawn(
|
||||||
|
[
|
||||||
"curl",
|
"curl",
|
||||||
"-sS",
|
"-sS",
|
||||||
"--fail",
|
"--fail",
|
||||||
@@ -38,20 +101,41 @@ export async function fetchCoverArt(url: string): Promise<string | null> {
|
|||||||
"--max-filesize",
|
"--max-filesize",
|
||||||
"2097152",
|
"2097152",
|
||||||
"-o",
|
"-o",
|
||||||
path,
|
staging,
|
||||||
url,
|
url,
|
||||||
]);
|
],
|
||||||
const code = await proc.exited;
|
{ stdout: "ignore", stderr: "ignore", stdin: "ignore" },
|
||||||
if (code !== 0) return null;
|
);
|
||||||
try {
|
proc.exited
|
||||||
return statSync(path).size > 0 ? path : null;
|
.then((code) => {
|
||||||
} catch {
|
if (code !== 0) return resolve(null);
|
||||||
return null;
|
try {
|
||||||
}
|
if (statSync(staging).size <= 0) return resolve(null);
|
||||||
})(),
|
renameSync(staging, dest);
|
||||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8000)),
|
resolve(dest);
|
||||||
]);
|
} catch {
|
||||||
} catch {
|
resolve(null);
|
||||||
return null;
|
}
|
||||||
}
|
})
|
||||||
|
.catch(() => resolve(null));
|
||||||
|
setTimeout(() => resolve(null), 8000);
|
||||||
|
return await promise;
|
||||||
|
} finally {
|
||||||
|
inflight.delete(url);
|
||||||
|
// Best-effort staging cleanup (no-op after a successful rename).
|
||||||
|
try {
|
||||||
|
Bun.spawn(["rm", "-f", staging], { stdout: "ignore", stderr: "ignore" });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inflight.set(url, task);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget warm-up used by the boot/restore path. */
|
||||||
|
export function prefetchCoverArt(url: string): void {
|
||||||
|
fetchCoverArt(url).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
192
tests/audio-backend.test.ts
Normal file
192
tests/audio-backend.test.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* MpvBackend resident-daemon contract tests (real mpv process).
|
||||||
|
*
|
||||||
|
* Pins the IPC contract the app's playback depends on:
|
||||||
|
*
|
||||||
|
* 1. play() loads a file and position advances (observed, no polling).
|
||||||
|
* 2. pause()/resume() flip the player-reported pause state through IPC.
|
||||||
|
* 3. seek() lands where asked.
|
||||||
|
* 4. stop() unloads the file but keeps the daemon alive (isAlive stays
|
||||||
|
* true — the daemon model's whole point: no process churn per episode).
|
||||||
|
* 5. preload() parks an episode paused; play() of the SAME url then starts
|
||||||
|
* it by unpausing — the boot-restore fast path with no second load.
|
||||||
|
* 6. EOF: the episode ends → isPlaying() goes false on its own; pressing
|
||||||
|
* resume() afterwards replays from the top.
|
||||||
|
*
|
||||||
|
* All playback runs silent (volume 0). Requires a real mpv on PATH;
|
||||||
|
* tests skip where it is missing.
|
||||||
|
*/
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import { MpvBackend } from "../src/utils/audio-player";
|
||||||
|
|
||||||
|
const SAMPLE_RATE = 22050;
|
||||||
|
const FREQ = 440;
|
||||||
|
const AMP = 20000;
|
||||||
|
|
||||||
|
/** Write a WAV file containing `seconds` of a sine at AMP amplitude. */
|
||||||
|
function writeSineWav(path: string, seconds: number): void {
|
||||||
|
const total = Math.round(seconds * 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);
|
||||||
|
dv.setUint16(22, 1, true);
|
||||||
|
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);
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
|
||||||
|
dv.setInt16(44 + i * 2, v, true);
|
||||||
|
}
|
||||||
|
Bun.write(path, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll a predicate until true or the deadline expires. */
|
||||||
|
async function waitFor(
|
||||||
|
label: string,
|
||||||
|
pred: () => boolean | Promise<boolean>,
|
||||||
|
timeoutMs = 8000,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
for (;;) {
|
||||||
|
if (await pred()) return;
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error(`${label}: not true within ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
await Bun.sleep(50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMpv = !!Bun.which("mpv");
|
||||||
|
const wavA = join(tmpdir(), `podtui-backend-${process.pid}-a.wav`);
|
||||||
|
const wavB = join(tmpdir(), `podtui-backend-${process.pid}-b.wav`);
|
||||||
|
|
||||||
|
function fixtureWavs(): void {
|
||||||
|
writeSineWav(wavA, 8);
|
||||||
|
writeSineWav(wavB, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup(backend: MpvBackend): Promise<void> {
|
||||||
|
backend.dispose();
|
||||||
|
await Bun.$`rm -f ${wavA} ${wavB}`.quiet();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.skipIf(!hasMpv)(
|
||||||
|
"play / pause / resume / seek over the resident daemon",
|
||||||
|
async () => {
|
||||||
|
fixtureWavs();
|
||||||
|
const backend = new MpvBackend();
|
||||||
|
try {
|
||||||
|
await backend.play(wavA, { volume: 0, speed: 1, startPosition: 1 });
|
||||||
|
expect(backend.isAlive()).toBe(true);
|
||||||
|
expect(backend.isPlaying()).toBe(true);
|
||||||
|
|
||||||
|
// Observed position advances without any polling from us.
|
||||||
|
await waitFor("position advances", async () => (await backend.getPosition()) > 1.3);
|
||||||
|
expect(await backend.getPauseState()).toBe(false);
|
||||||
|
expect(await backend.getDuration()).toBeGreaterThan(7.5);
|
||||||
|
|
||||||
|
// Pause: reported by the player's own state, position stalls.
|
||||||
|
await backend.pause();
|
||||||
|
await waitFor("paused state observed", async () => (await backend.getPauseState()) === true);
|
||||||
|
const posAtPause = await backend.getPosition();
|
||||||
|
await Bun.sleep(400);
|
||||||
|
expect(Math.abs((await backend.getPosition()) - posAtPause)).toBeLessThan(0.3);
|
||||||
|
|
||||||
|
// Resume: clock advances again.
|
||||||
|
await backend.resume();
|
||||||
|
await waitFor("resumed state observed", async () => (await backend.getPauseState()) === false);
|
||||||
|
await waitFor(
|
||||||
|
"position advances after resume",
|
||||||
|
async () => (await backend.getPosition()) > posAtPause + 0.3,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seek lands where asked.
|
||||||
|
await backend.seek(6);
|
||||||
|
await waitFor(
|
||||||
|
"seek observed",
|
||||||
|
async () => Math.abs((await backend.getPosition()) - 6) < 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop unloads the file — but the daemon stays resident.
|
||||||
|
await backend.stop();
|
||||||
|
expect(backend.isPlaying()).toBe(false);
|
||||||
|
expect(backend.isAlive()).toBe(true);
|
||||||
|
expect(await backend.getPosition()).toBe(0);
|
||||||
|
} finally {
|
||||||
|
await cleanup(backend);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasMpv)(
|
||||||
|
"preload parks the episode paused; play() of the same url starts it by unpausing",
|
||||||
|
async () => {
|
||||||
|
fixtureWavs();
|
||||||
|
const backend = new MpvBackend();
|
||||||
|
try {
|
||||||
|
await backend.preload(wavB, { volume: 0, speed: 1, startPosition: 2 });
|
||||||
|
// Parked: paused, at the requested offset, nothing advancing.
|
||||||
|
await waitFor(
|
||||||
|
"preload observed paused",
|
||||||
|
async () => (await backend.getPauseState()) === true,
|
||||||
|
);
|
||||||
|
const parkedPos = await backend.getPosition();
|
||||||
|
expect(parkedPos).toBeGreaterThan(1.5);
|
||||||
|
expect(backend.isPlaying()).toBe(false);
|
||||||
|
await Bun.sleep(400);
|
||||||
|
expect(Math.abs((await backend.getPosition()) - parkedPos)).toBeLessThan(0.3);
|
||||||
|
|
||||||
|
// The boot-restore fast path: play() unpauses instead of re-loading.
|
||||||
|
await backend.play(wavB, { volume: 0, speed: 1, startPosition: parkedPos });
|
||||||
|
expect(backend.isPlaying()).toBe(true);
|
||||||
|
await waitFor(
|
||||||
|
"preload fast path plays",
|
||||||
|
async () => (await backend.getPosition()) > parkedPos + 0.3,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await cleanup(backend);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasMpv)(
|
||||||
|
"EOF marks playback ended; resume() then replays from the top",
|
||||||
|
async () => {
|
||||||
|
const wavShort = join(tmpdir(), `podtui-backend-${process.pid}-short.wav`);
|
||||||
|
writeSineWav(wavShort, 2);
|
||||||
|
const backend = new MpvBackend();
|
||||||
|
try {
|
||||||
|
await backend.play(wavShort, { volume: 0, speed: 2 });
|
||||||
|
// 2s at 2x ends in ~1s+startup. isPlaying() must drop on its own.
|
||||||
|
await waitFor("episode ended", async () => !backend.isPlaying());
|
||||||
|
|
||||||
|
// Play pressed on a finished episode replays from the top.
|
||||||
|
await backend.resume();
|
||||||
|
await waitFor("replay started", async () => backend.isPlaying());
|
||||||
|
await waitFor(
|
||||||
|
"replay position near start",
|
||||||
|
async () => (await backend.getPosition()) < 3 && backend.isPlaying(),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
backend.dispose();
|
||||||
|
await Bun.$`rm -f ${wavShort}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
231
tests/audio-pcm-cache.test.ts
Normal file
231
tests/audio-pcm-cache.test.ts
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* EpisodePcmCache position-index contract tests.
|
||||||
|
*
|
||||||
|
* The visualizer's bars are served from a position-indexed PCM cache that
|
||||||
|
* ffmpeg fills at full speed. These tests pin the observable contracts the
|
||||||
|
* fragile paced-ring design kept breaking:
|
||||||
|
*
|
||||||
|
* 1. readWindow(out, at) serves the EXACT window ending at playback time
|
||||||
|
* `at` — position mapping is sample-precise, independent of how fast or
|
||||||
|
* far the decode has run.
|
||||||
|
* 2. Reads outside decoded coverage return 0 — the renderer HOLDS the last
|
||||||
|
* frame. (The old reader CLAMPED to a stale buffer; re-rendering the
|
||||||
|
* same window decayed cava into a frozen junk pattern after pause.)
|
||||||
|
* 3. pauseDecode kills ffmpeg but keeps the cache: resume serves bars
|
||||||
|
* instantly, ensureDecodeAround restarts the tail decode.
|
||||||
|
* 4. Seeking into an undecoded region starts a new segment there WITHOUT
|
||||||
|
* invalidating the previously decoded coverage.
|
||||||
|
*
|
||||||
|
* Uses a self-generated WAV (440Hz sine, mono, 22050Hz s16le — the cache's
|
||||||
|
* native rate) so expected samples are computed analytically with no
|
||||||
|
* resampler tolerance.
|
||||||
|
*/
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import { EpisodePcmCache } from "../src/utils/audio-pcm-cache";
|
||||||
|
|
||||||
|
const SAMPLE_RATE = 22050;
|
||||||
|
const FREQ = 440;
|
||||||
|
const AMP = 30000;
|
||||||
|
|
||||||
|
/** Write a WAV file containing `seconds` of a 440Hz sine at AMP amplitude. */
|
||||||
|
function writeSineWav(path: string, seconds: number): void {
|
||||||
|
const total = Math.round(seconds * 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);
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
|
||||||
|
dv.setInt16(44 + i * 2, v, true);
|
||||||
|
}
|
||||||
|
Bun.write(path, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Analytic sample value at a file index, matching the writer's formula. */
|
||||||
|
function expectedAt(fileIndex: number): number {
|
||||||
|
return Math.round(AMP * Math.sin((2 * Math.PI * FREQ * fileIndex) / SAMPLE_RATE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block until the cache covers playback time `sec`. */
|
||||||
|
async function waitForCoverage(
|
||||||
|
cache: EpisodePcmCache,
|
||||||
|
sec: number,
|
||||||
|
timeoutMs = 10000,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!cache.covers(sec)) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error(`cache did not cover ${sec}s in time`);
|
||||||
|
}
|
||||||
|
await Bun.sleep(25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block until the furthest decode pass has hit stream EOF. */
|
||||||
|
async function waitForFinished(
|
||||||
|
cache: EpisodePcmCache,
|
||||||
|
timeoutMs = 10000,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!cache.decodeFinished) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error("decode did not finish in time");
|
||||||
|
}
|
||||||
|
await Bun.sleep(25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tmpWav(): string {
|
||||||
|
return join(tmpdir(), `podtui-pcm-${process.pid}-${Math.floor(Math.random() * 1e9)}.wav`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFfmpeg = !!Bun.which("ffmpeg");
|
||||||
|
const FIVE_SEC_BASE = 5 * SAMPLE_RATE; // decode offset for position-mapping tests
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"readWindow serves the exact window ending at the requested position",
|
||||||
|
async () => {
|
||||||
|
const wav = tmpWav();
|
||||||
|
writeSineWav(wav, 30);
|
||||||
|
const cache = new EpisodePcmCache({ url: wav });
|
||||||
|
try {
|
||||||
|
cache.startDecode(5);
|
||||||
|
await waitForCoverage(cache, 6.5);
|
||||||
|
|
||||||
|
const out = new Float64Array(512);
|
||||||
|
expect(cache.readWindow(out, 5.1)).toBe(512);
|
||||||
|
// Window ENDS at the target: out[i] is the sample at
|
||||||
|
// round(5.1*SR) - (len-1) + i (5s offset + 0.1s).
|
||||||
|
const endIdx = Math.round(5.1 * SAMPLE_RATE);
|
||||||
|
for (let i = 0; i < 512; i++) {
|
||||||
|
const idx = endIdx - (out.length - 1) + i;
|
||||||
|
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 5ms later window is the same stream shifted by exactly
|
||||||
|
// round(0.005*SR)=110 samples — pins position mapping precision.
|
||||||
|
const later = new Float64Array(512);
|
||||||
|
expect(cache.readWindow(later, 5.105)).toBe(512);
|
||||||
|
for (let i = 0; i <= 512 - 111; i++) {
|
||||||
|
expect(later[i]).toBe(out[i + 110]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cache.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"reads outside decoded coverage return 0 (renderer holds last frame, never stale junk)",
|
||||||
|
async () => {
|
||||||
|
const wav = tmpWav();
|
||||||
|
writeSineWav(wav, 30);
|
||||||
|
const cache = new EpisodePcmCache({ url: wav });
|
||||||
|
try {
|
||||||
|
cache.startDecode(5);
|
||||||
|
await waitForCoverage(cache, 5.5);
|
||||||
|
|
||||||
|
const out = new Float64Array(512);
|
||||||
|
out.fill(-999);
|
||||||
|
|
||||||
|
// Beyond the decode frontier.
|
||||||
|
expect(cache.readWindow(out, 999)).toBe(0);
|
||||||
|
// Before the segment base (decode started at 5s).
|
||||||
|
expect(cache.readWindow(out, 4.0)).toBe(0);
|
||||||
|
// Buffer untouched — no partial/stale samples leak through.
|
||||||
|
for (let i = 0; i < 16; i++) expect(out[i]).toBe(-999);
|
||||||
|
} finally {
|
||||||
|
cache.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"pauseDecode keeps the cache: resume serves instantly, tail decode continues",
|
||||||
|
async () => {
|
||||||
|
const wav = tmpWav();
|
||||||
|
writeSineWav(wav, 12); // short: full tail decode lands well under a second
|
||||||
|
const cache = new EpisodePcmCache({ url: wav });
|
||||||
|
try {
|
||||||
|
cache.startDecode(0);
|
||||||
|
await waitForCoverage(cache, 1.5);
|
||||||
|
|
||||||
|
// Pause: decode dies, cache must survive.
|
||||||
|
cache.pauseDecode();
|
||||||
|
expect(cache.decoding).toBe(false);
|
||||||
|
expect(cache.covers(1)).toBe(true);
|
||||||
|
|
||||||
|
// Serve from cache immediately after pause — this is the resume
|
||||||
|
// fast path: zero ffmpeg cold start.
|
||||||
|
const out = new Float64Array(512);
|
||||||
|
expect(cache.readWindow(out, 1.0)).toBe(512);
|
||||||
|
const endIdx = Math.round(1.0 * SAMPLE_RATE);
|
||||||
|
for (let i = 0; i < 512; i++) {
|
||||||
|
const idx = endIdx - (out.length - 1) + i;
|
||||||
|
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume: tail decode restarts and eventually covers the file.
|
||||||
|
cache.ensureDecodeAround(1.0);
|
||||||
|
await waitForFinished(cache);
|
||||||
|
expect(cache.coverageEndSec).toBeGreaterThanOrEqual(11.9);
|
||||||
|
expect(cache.readWindow(out, 11.5)).toBe(512);
|
||||||
|
} finally {
|
||||||
|
cache.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"seek into an undecoded region starts a new segment without losing earlier coverage",
|
||||||
|
async () => {
|
||||||
|
const wav = tmpWav();
|
||||||
|
writeSineWav(wav, 30);
|
||||||
|
const cache = new EpisodePcmCache({ url: wav });
|
||||||
|
try {
|
||||||
|
// Decoded the back half only...
|
||||||
|
cache.startDecode(10);
|
||||||
|
await waitForCoverage(cache, 11);
|
||||||
|
expect(cache.covers(2)).toBe(false);
|
||||||
|
|
||||||
|
// ...then the user seeks to 2s: a new segment decodes the front,
|
||||||
|
// and the back-half coverage stays valid throughout.
|
||||||
|
cache.ensureDecodeAround(2);
|
||||||
|
await waitForCoverage(cache, 2.2);
|
||||||
|
expect(cache.covers(10.5)).toBe(true);
|
||||||
|
|
||||||
|
const out = new Float64Array(512);
|
||||||
|
expect(cache.readWindow(out, 10.5)).toBe(512);
|
||||||
|
const endIdx = Math.round(10.5 * SAMPLE_RATE);
|
||||||
|
for (let i = 0; i < 512; i++) {
|
||||||
|
const idx = endIdx - (out.length - 1) + i;
|
||||||
|
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cache.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 20000 },
|
||||||
|
);
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
/**
|
|
||||||
* AudioStreamReader sync contract tests.
|
|
||||||
*
|
|
||||||
* The visualizer's bars must track the player's position in real time even
|
|
||||||
* though the reader is an independent ffmpeg process. These tests pin the
|
|
||||||
* two mechanisms that make that true:
|
|
||||||
*
|
|
||||||
* 1. `read(out, target)` serves the FFT window *at* the requested playback
|
|
||||||
* position — not at the decode head, which drifts from the player
|
|
||||||
* (startup skew, stalls).
|
|
||||||
* 2. Decode is paced at the player's clock rate (`-readrate <speed>`), so
|
|
||||||
* the decode head keeps up with the position at any playback speed —
|
|
||||||
* native-rate pacing falls behind by (speed-1)s per second.
|
|
||||||
*
|
|
||||||
* Uses a self-generated WAV (440Hz sine, mono, 44.1kHz s16le) so the
|
|
||||||
* expected samples can be computed analytically and compared exactly.
|
|
||||||
*/
|
|
||||||
import { test, expect } from "bun:test";
|
|
||||||
import { tmpdir } from "os";
|
|
||||||
import { join } from "path";
|
|
||||||
import { AudioStreamReader } from "../src/utils/audio-stream-reader";
|
|
||||||
|
|
||||||
const SAMPLE_RATE = 44100;
|
|
||||||
const FREQ = 440;
|
|
||||||
const AMP = 30000;
|
|
||||||
|
|
||||||
/** Write a WAV file containing `seconds` of a 440Hz sine at AMP amplitude. */
|
|
||||||
function writeSineWav(path: string, seconds: number): void {
|
|
||||||
const total = Math.round(seconds * 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);
|
|
||||||
for (let i = 0; i < total; i++) {
|
|
||||||
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
|
|
||||||
dv.setInt16(44 + i * 2, v, true);
|
|
||||||
}
|
|
||||||
Bun.write(path, buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Analytic sample value at a file index, matching the writer's formula. */
|
|
||||||
function expectedAt(fileIndex: number): number {
|
|
||||||
return Math.round(AMP * Math.sin((2 * Math.PI * FREQ * fileIndex) / SAMPLE_RATE));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Block until the reader's decode head has advanced past `samples` samples.
|
|
||||||
* The head advances at readrate × real time, so this bounds how long we wait.
|
|
||||||
*/
|
|
||||||
async function waitForHead(
|
|
||||||
reader: AudioStreamReader,
|
|
||||||
samples: number,
|
|
||||||
timeoutMs = 8000,
|
|
||||||
): Promise<void> {
|
|
||||||
const start = Date.now();
|
|
||||||
while (reader.samplesWritten < samples) {
|
|
||||||
if (Date.now() - start > timeoutMs) {
|
|
||||||
throw new Error("reader decode head did not advance in time");
|
|
||||||
}
|
|
||||||
await Bun.sleep(25);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasFfmpeg = !!Bun.which("ffmpeg");
|
|
||||||
|
|
||||||
test.skipIf(!hasFfmpeg)(
|
|
||||||
"read() serves the exact window at the requested position",
|
|
||||||
async () => {
|
|
||||||
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
|
|
||||||
writeSineWav(wav, 20);
|
|
||||||
const reader = new AudioStreamReader({ url: wav });
|
|
||||||
try {
|
|
||||||
reader.start(5, 1);
|
|
||||||
// Cover targets up to ~5.6s (head must pass the read target).
|
|
||||||
await waitForHead(reader, Math.round(0.6 * SAMPLE_RATE));
|
|
||||||
|
|
||||||
const out = new Float64Array(512);
|
|
||||||
|
|
||||||
// Window at 5.1s: the window ENDS at the target, so out[i] is at
|
|
||||||
// file index 5*SR + round((5.1-5)*SR) - (len-1) + i.
|
|
||||||
expect(reader.read(out, 5.1)).toBe(512);
|
|
||||||
for (let i = 0; i < 512; i++) {
|
|
||||||
const idx =
|
|
||||||
Math.round(5 * SAMPLE_RATE) +
|
|
||||||
Math.round((5.1 - 5) * SAMPLE_RATE) -
|
|
||||||
(out.length - 1) +
|
|
||||||
i;
|
|
||||||
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Window at 5.105s is the same stream shifted by exactly
|
|
||||||
// round(0.005*SR)=221 samples — pins that the target maps to a
|
|
||||||
// precise offset, not "whatever the decode head is at".
|
|
||||||
const later = new Float64Array(512);
|
|
||||||
expect(reader.read(later, 5.105)).toBe(512);
|
|
||||||
for (let i = 0; i <= 512 - 222; i++) {
|
|
||||||
expect(later[i]).toBe(out[i + 221]);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.stop();
|
|
||||||
await Bun.$`rm -f ${wav}`.quiet();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test.skipIf(!hasFfmpeg)(
|
|
||||||
"decode keeps up with the player clock at 2x speed",
|
|
||||||
async () => {
|
|
||||||
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
|
|
||||||
writeSineWav(wav, 20);
|
|
||||||
const reader = new AudioStreamReader({ url: wav });
|
|
||||||
try {
|
|
||||||
reader.start(0, 2);
|
|
||||||
// At 2x pacing the head reaches 2.5s after ~1.25s of wall time.
|
|
||||||
// With native-rate pacing it would only be at ~1.25s, and the
|
|
||||||
// window at 2.5s would clamp to the head — content mismatch.
|
|
||||||
await waitForHead(reader, Math.round(2.5 * SAMPLE_RATE));
|
|
||||||
|
|
||||||
const out = new Float64Array(512);
|
|
||||||
expect(reader.read(out, 2.5)).toBe(512);
|
|
||||||
for (let i = 0; i < 512; i++) {
|
|
||||||
const idx =
|
|
||||||
Math.round(2.5 * SAMPLE_RATE) - (out.length - 1) + i;
|
|
||||||
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.stop();
|
|
||||||
await Bun.$`rm -f ${wav}`.quiet();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test.skipIf(!hasFfmpeg)(
|
|
||||||
"read() clamps to the nearest samples when the target is beyond the head",
|
|
||||||
async () => {
|
|
||||||
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
|
|
||||||
writeSineWav(wav, 20);
|
|
||||||
const reader = new AudioStreamReader({ url: wav });
|
|
||||||
try {
|
|
||||||
reader.start(0, 1);
|
|
||||||
await waitForHead(reader, Math.round(0.3 * SAMPLE_RATE));
|
|
||||||
|
|
||||||
// Target far beyond the decode head: serve the newest available
|
|
||||||
// window (real sine samples, never zeros or garbage).
|
|
||||||
const out = new Float64Array(512);
|
|
||||||
expect(reader.read(out, 999)).toBe(512);
|
|
||||||
const maxAbs = Math.max(...Array.from(out, Math.abs));
|
|
||||||
expect(maxAbs).toBeGreaterThan(10000);
|
|
||||||
for (const v of out) {
|
|
||||||
expect(Math.abs(v)).toBeLessThanOrEqual(AMP + 1);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
reader.stop();
|
|
||||||
await Bun.$`rm -f ${wav}`.quiet();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test.skipIf(!hasFfmpeg)(
|
|
||||||
"sustained render loop: ffmpeg stays alive and decode head maintains a lead over the player",
|
|
||||||
async () => {
|
|
||||||
// Real wall-clock time is required here: this test validates ffmpeg's
|
|
||||||
// actual decode pacing (-readrate + -readrate_initial_burst) against
|
|
||||||
// the platform clock. Deterministic time control cannot reproduce the
|
|
||||||
// race where ffmpeg exits early and the bars freeze — that only
|
|
||||||
// surfaces when a real process writes to a real pipe.
|
|
||||||
//
|
|
||||||
// Simulates the actual render loop: for ~5s of wall time, advance a
|
|
||||||
// simulated player position at 1× realtime and call read() each frame.
|
|
||||||
// The decode head must stay ahead of the player position so read()
|
|
||||||
// always returns 512 samples, and ffmpeg must not exit early (which
|
|
||||||
// would freeze the bars). This test would have caught the
|
|
||||||
// backpressure-pacing failure where ffmpeg decoded all data into the
|
|
||||||
// pipe buffer instantly, exited, and the readLoop stopped.
|
|
||||||
const wav = join(
|
|
||||||
tmpdir(),
|
|
||||||
`podtui-reader-${process.pid}-${Date.now()}.wav`,
|
|
||||||
);
|
|
||||||
writeSineWav(wav, 30);
|
|
||||||
const reader = new AudioStreamReader({ url: wav });
|
|
||||||
try {
|
|
||||||
reader.start(0, 1);
|
|
||||||
|
|
||||||
const FRAME_MS = 33;
|
|
||||||
const DURATION_MS = 5000;
|
|
||||||
const out = new Float64Array(512);
|
|
||||||
let successes = 0;
|
|
||||||
let failures = 0;
|
|
||||||
let minLead = Infinity;
|
|
||||||
|
|
||||||
const start = Date.now();
|
|
||||||
for (let frame = 0; Date.now() - start < DURATION_MS; frame++) {
|
|
||||||
const playerPos = (Date.now() - start) / 1000;
|
|
||||||
const count = reader.read(out, playerPos);
|
|
||||||
if (count === 512) successes++;
|
|
||||||
else failures++;
|
|
||||||
|
|
||||||
// The decode head should stay ahead of the player position.
|
|
||||||
const headPos = reader.samplesWritten / SAMPLE_RATE;
|
|
||||||
const lead = headPos - playerPos;
|
|
||||||
if (frame > 3) minLead = Math.min(minLead, lead);
|
|
||||||
|
|
||||||
await Bun.sleep(FRAME_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ffmpeg must still be running — it must not have exited early.
|
|
||||||
expect(reader.running).toBe(true);
|
|
||||||
|
|
||||||
// The vast majority of frames should return a full window.
|
|
||||||
// A few early failures during ffmpeg startup are acceptable.
|
|
||||||
expect(failures).toBeLessThan(5);
|
|
||||||
expect(successes).toBeGreaterThan(100);
|
|
||||||
|
|
||||||
// The decode head must maintain a positive lead over the player.
|
|
||||||
// Without -readrate_initial_burst, the head would lag behind by
|
|
||||||
// the ffmpeg startup latency and never catch up.
|
|
||||||
expect(minLead).toBeGreaterThan(0);
|
|
||||||
} finally {
|
|
||||||
reader.stop();
|
|
||||||
await Bun.$`rm -f ${wav}`.quiet();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ timeout: 15000 },
|
|
||||||
);
|
|
||||||
77
tests/cover-art.test.ts
Normal file
77
tests/cover-art.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Cover-art disk-cache contract tests.
|
||||||
|
*
|
||||||
|
* fetchCoverArt downloads each cover ONCE into a persistent per-URL cache;
|
||||||
|
* playback never waits on the network for art it has already fetched. Pins:
|
||||||
|
*
|
||||||
|
* 1. A fetch stores the bytes on disk and returns the cache path.
|
||||||
|
* 2. A second fetch of the same URL returns the cached path WITHOUT hitting
|
||||||
|
* the server again (request count stays 1).
|
||||||
|
* 3. Concurrent fetches of the same URL share one download (single-flight).
|
||||||
|
* 4. A failed fetch (404) resolves null instead of throwing.
|
||||||
|
*
|
||||||
|
* Served from a local Bun server — no external network dependence. Cache
|
||||||
|
* entries created here are removed afterwards.
|
||||||
|
*/
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { unlinkSync } from "fs";
|
||||||
|
import { cachedCoverPath, fetchCoverArt } from "../src/utils/cover-art";
|
||||||
|
|
||||||
|
const FAKE_JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, ...new Array(256).fill(7)]);
|
||||||
|
|
||||||
|
test("cover art is fetched once, cached on disk, and shared", async () => {
|
||||||
|
let requests = 0;
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(req) {
|
||||||
|
requests++;
|
||||||
|
if (new URL(req.url).pathname === "/missing.jpg") {
|
||||||
|
return new Response("nope", { status: 404 });
|
||||||
|
}
|
||||||
|
return new Response(FAKE_JPEG, {
|
||||||
|
headers: { "content-type": "image/jpeg" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `http://127.0.0.1:${server.port}/cover.jpg`;
|
||||||
|
const missing = `http://127.0.0.1:${server.port}/missing.jpg`;
|
||||||
|
let cachedPath: string | null = null;
|
||||||
|
try {
|
||||||
|
expect(cachedCoverPath(url)).toBeNull();
|
||||||
|
|
||||||
|
// First fetch: downloads and caches.
|
||||||
|
cachedPath = await fetchCoverArt(url);
|
||||||
|
expect(cachedPath).not.toBeNull();
|
||||||
|
expect(requests).toBe(1);
|
||||||
|
expect(Bun.file(cachedPath!).size).toBe(FAKE_JPEG.byteLength);
|
||||||
|
|
||||||
|
// Second fetch: disk hit, server untouched.
|
||||||
|
expect(await fetchCoverArt(url)).toBe(cachedPath);
|
||||||
|
expect(requests).toBe(1);
|
||||||
|
|
||||||
|
// Single-flight: parallel misses of a fresh URL make ONE request.
|
||||||
|
const shared = `http://127.0.0.1:${server.port}/shared.jpg`;
|
||||||
|
const [a, b, c] = await Promise.all([
|
||||||
|
fetchCoverArt(shared),
|
||||||
|
fetchCoverArt(shared),
|
||||||
|
fetchCoverArt(shared),
|
||||||
|
]);
|
||||||
|
expect(a).not.toBeNull();
|
||||||
|
expect(a).toBe(b);
|
||||||
|
expect(b).toBe(c);
|
||||||
|
if (a) unlinkSync(a);
|
||||||
|
|
||||||
|
// 404 resolves null, never throws.
|
||||||
|
expect(await fetchCoverArt(missing)).toBeNull();
|
||||||
|
} finally {
|
||||||
|
server.stop(true);
|
||||||
|
if (cachedPath) {
|
||||||
|
try {
|
||||||
|
unlinkSync(cachedPath);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -28,7 +28,14 @@
|
|||||||
* identity bun loads from disk, bypassing the leaked mock.
|
* identity bun loads from disk, bypassing the leaked mock.
|
||||||
*/
|
*/
|
||||||
import { test, expect, afterAll } from "bun:test";
|
import { test, expect, afterAll } from "bun:test";
|
||||||
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
import {
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
writeFileSync,
|
||||||
|
rmSync,
|
||||||
|
readdirSync,
|
||||||
|
statSync,
|
||||||
|
} from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
@@ -102,9 +109,30 @@ const wavPath = join(tmpdir(), `podtui-extpause-${process.pid}.wav`);
|
|||||||
// loads the real file instead of a leaked mock.module from another test file.
|
// loads the real file instead of a leaked mock.module from another test file.
|
||||||
const { useAudio } = await import("../src/hooks/useAudio?external-pause-test");
|
const { useAudio } = await import("../src/hooks/useAudio?external-pause-test");
|
||||||
|
|
||||||
/** The pid-derived socket path the backend tells mpv to bind. */
|
/**
|
||||||
function mpvSocket(): string {
|
* The socket path of the LIVE backend daemon in this process. The backend
|
||||||
return join(tmpdir(), `podtui-mpv-${process.pid}.sock`);
|
* names sockets per-instance (`podtui-mpv-<pid>-<instance>.sock`), so scan
|
||||||
|
* tmpdir for this pid's sockets and take the newest (the one mpv actually
|
||||||
|
* bound — earlier instances may have been orphaned by a re-spawn).
|
||||||
|
*/
|
||||||
|
function mpvSocket(): string | null {
|
||||||
|
let newest: string | null = null;
|
||||||
|
let newestMtime = 0;
|
||||||
|
for (const name of readdirSync(tmpdir())) {
|
||||||
|
if (
|
||||||
|
!name.startsWith(`podtui-mpv-${process.pid}-`) ||
|
||||||
|
!name.endsWith(".sock")
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const candidate = join(tmpdir(), name);
|
||||||
|
const mtime = statSync(candidate).mtimeMs;
|
||||||
|
if (mtime > newestMtime) {
|
||||||
|
newest = candidate;
|
||||||
|
newestMtime = mtime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newest;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,6 +140,8 @@ function mpvSocket(): string {
|
|||||||
* media session pauses/resumes mpv without PodTUI's involvement.
|
* media session pauses/resumes mpv without PodTUI's involvement.
|
||||||
*/
|
*/
|
||||||
async function mpvCommand(command: unknown[]): Promise<void> {
|
async function mpvCommand(command: unknown[]): Promise<void> {
|
||||||
|
const socket = mpvSocket();
|
||||||
|
if (!socket) throw new Error("backend mpv socket not found");
|
||||||
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
const { promise, resolve, reject } = Promise.withResolvers<void>();
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const settle = (err: Error | null): void => {
|
const settle = (err: Error | null): void => {
|
||||||
@@ -121,7 +151,7 @@ async function mpvCommand(command: unknown[]): Promise<void> {
|
|||||||
else resolve();
|
else resolve();
|
||||||
};
|
};
|
||||||
Bun.connect({
|
Bun.connect({
|
||||||
unix: mpvSocket(),
|
unix: socket,
|
||||||
socket: {
|
socket: {
|
||||||
open(s) {
|
open(s) {
|
||||||
s.write(JSON.stringify({ command }) + "\n");
|
s.write(JSON.stringify({ command }) + "\n");
|
||||||
@@ -213,8 +243,16 @@ afterAll(async () => {
|
|||||||
} catch {
|
} catch {
|
||||||
/* best-effort */
|
/* best-effort */
|
||||||
}
|
}
|
||||||
|
// The resident daemon survives stop() by design — quit it so test
|
||||||
|
// workers don't leak idle mpv processes.
|
||||||
try {
|
try {
|
||||||
rmSync(mpvSocket(), { force: true });
|
await mpvCommand(["quit"]);
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const socket = mpvSocket();
|
||||||
|
if (socket) rmSync(socket, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
/* best-effort */
|
/* best-effort */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
*
|
*
|
||||||
* Uses a self-generated local WAV (a frequency chirp, so different playback
|
* Uses a self-generated local WAV (a frequency chirp, so different playback
|
||||||
* positions produce measurably different bar output) and the real ffmpeg +
|
* positions produce measurably different bar output) and the real ffmpeg +
|
||||||
* native cavacore pipeline, mirroring audio-stream-reader.test.ts.
|
* native cavacore pipeline, mirroring audio-pcm-cache.test.ts.
|
||||||
*
|
*
|
||||||
* Timing note: this is an integration test of the store's real timers — the
|
* 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
|
* unload path is a genuine `setTimeout` in the store, and bun 1.3.8 ships no
|
||||||
|
|||||||
Reference in New Issue
Block a user