From 20336ea7161cf00165f5add4f18ebc849261f77c Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Tue, 11 Aug 2026 19:54:05 -0400 Subject: [PATCH] feat(audio): rebuild playback + visualization on resident daemon and PCM cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/hooks/useAudio.ts | 73 ++- src/stores/visualizer.ts | 202 ++++--- src/utils/audio-pcm-cache.ts | 363 ++++++++++++ src/utils/audio-player.ts | 742 ++++++++++++++++++------- src/utils/audio-stream-reader.ts | 324 ----------- src/utils/cover-art.ts | 144 ++++- tests/audio-backend.test.ts | 192 +++++++ tests/audio-pcm-cache.test.ts | 231 ++++++++ tests/audio-stream-reader.test.ts | 239 -------- tests/cover-art.test.ts | 77 +++ tests/external-pause-reconcile.test.ts | 50 +- tests/visualizer-store.test.ts | 2 +- 12 files changed, 1757 insertions(+), 882 deletions(-) create mode 100644 src/utils/audio-pcm-cache.ts delete mode 100644 src/utils/audio-stream-reader.ts create mode 100644 tests/audio-backend.test.ts create mode 100644 tests/audio-pcm-cache.test.ts delete mode 100644 tests/audio-stream-reader.test.ts create mode 100644 tests/cover-art.test.ts diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index 35f77d3..9d19622 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -13,8 +13,11 @@ */ import { onCleanup } from "solid-js"; -import { unlinkSync } from "fs"; -import { fetchCoverArt, coverTempPath } from "../utils/cover-art"; +import { + cachedCoverPath, + fetchCoverArt, + prefetchCoverArt, +} from "../utils/cover-art"; import { createAudioBackend, detectPlayers, @@ -158,11 +161,6 @@ function registerExitTeardown(): void { } catch { /* best-effort at exit */ } - try { - unlinkSync(coverTempPath()); - } catch { - /* best-effort at exit */ - } }; process.on("exit", teardown); for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"] as const) { @@ -231,6 +229,15 @@ function startPolling(): void { try { pollCount++; 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 // staying stuck on "playing" with a frozen waveform // (getPosition would just re-read the same frozen time-pos). @@ -256,11 +263,6 @@ function startPolling(): void { media.setPosition(pos); } } - - // Check if backend stopped playing (track ended) - if (!backend.isPlaying()) { - finalizeTrackEnd(); - } } else if (pollCount % PAUSE_WATCH_TICKS === 0) { // Paused — watch for playback restarted from outside (AirPods, // lock-screen/media-center play). Only while the player is @@ -314,9 +316,12 @@ async function play(episode: Episode): Promise { const feedStore = useFeedStore(); const feed = feedStore.feeds().find((f) => f.podcast.id === episode.podcastId); const podcastTitle = feed?.customName || feed?.podcast.title || ""; - const coverArtPath = feed?.podcast.coverUrl - ? await fetchCoverArt(feed.podcast.coverUrl) - : null; + // Cover art must NEVER gate playback (it was a curl subprocess blocking + // play() by up to 8s). Serve the disk-cached file synchronously when it + // 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 const savedProgress = progressStore.get(episode.id); @@ -333,6 +338,16 @@ async function play(episode: Episode): Promise { coverArtPath: coverArtPath ?? undefined, }); + if (coverUrl && !coverArtPath) { + fetchCoverArt(coverUrl) + .then((path) => { + if (path && currentEpisode()?.id === episode.id) { + b.addCoverArt(path).catch(() => {}); + } + }) + .catch(() => {}); + } + setCurrentEpisode(episode); setIsPlaying(true); setPosition(startPos); @@ -404,6 +419,29 @@ async function load(episode: Episode): Promise { media.setPlaybackState(false); 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() }); } @@ -564,9 +602,8 @@ async function switchBackend(name: BackendName): Promise { .feeds() .find((f) => f.podcast.id === ep.podcastId); const podcastTitle = feed?.customName || feed?.podcast.title || ""; - const coverArtPath = feed?.podcast.coverUrl - ? await fetchCoverArt(feed.podcast.coverUrl) - : null; + const coverUrl = feed?.podcast.coverUrl; + const coverArtPath = coverUrl ? cachedCoverPath(coverUrl) : null; await backend.play(ep.audioUrl, { startPosition: pos, volume: vol, diff --git a/src/stores/visualizer.ts b/src/stores/visualizer.ts index 9637117..ddd652b 100644 --- a/src/stores/visualizer.ts +++ b/src/stores/visualizer.ts @@ -2,20 +2,29 @@ * visualizer-store — module-level singleton owning the realtime waveform * pipeline (ffmpeg decode + cavacore FFT), shared across PlayerPage mounts. * - * The pipeline lives here rather than in the Player page component so it can - * outlive the page: Shell unmounts a tab's page the moment the tab loses - * focus, which would otherwise kill the ffmpeg decode + FFT loop instantly. - * Instead the store keeps the visualization warm for UNLOAD_DELAY_MS after - * the Player tab stops being focused, then tears it down (kills ffmpeg, - * destroys the cava plan). Returning to the tab within the delay resumes - * seamlessly; after an unload, regaining focus restarts the pipeline from - * the current playback position. + * Pipeline shape (see utils/audio-pcm-cache.ts for the rationale): + * an ffmpeg process decodes the episode at full speed into a + * position-indexed PCM cache; the render loop reads the window ending at + * the player's current position from that cache. Because reads are + * indexed by playback time, PAUSE/RESUME/SEEK/SPEED need no pipeline + * choreography at all — and cannot desync: * - * The store subscribes to the module-level playback signals in - * `utils/audio-signals.ts` (`audioPlaybackSignals`), so it reacts to - * play/pause/seek/speed even while no Player page is mounted. `focused` is - * fed by PlayerPage (mounted ⇔ Player tab visible), `barCount` by - * RealtimeWaveform (terminal width). + * - Pause: stop the render loop and the decode pass; the PCM cache stays + * resident. Bars freeze on the last rendered frame. + * - Resume: re-arm the render loop — bars render instantly from the cache + * — and continue the tail decode in the background. No cold start, no + * 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 { @@ -30,7 +39,7 @@ import { type CavaCore, type CavaCoreConfig, } 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 { audioPlaybackSignals } from "@/utils/audio-signals"; import { useAppStore } from "@/stores/app"; @@ -83,7 +92,10 @@ function createVisualizerStore(): VisualizerStore { const scaler = createBarScaler(); 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 | null = null; let sampleBuffer: Float64Array | null = null; let unloadTimer: ReturnType | null = null; @@ -91,7 +103,6 @@ function createVisualizerStore(): VisualizerStore { // What the running pipeline was started with — lets the playback effect // tell "nothing changed, stay warm" from "must restart". let activeUrl = ""; - let activeSpeed = 1; let activeBars = 64; // ── Lifecycle helpers ────────────────────────────────────────────── @@ -139,7 +150,7 @@ function createVisualizerStore(): VisualizerStore { // ── Start/stop the visualization pipeline ────────────────────────── - const startVisualization = (url: string, position: number, speed: number) => { + const startVisualization = (url: string, position: number) => { stopVisualization(); if (!url || !initCava() || !cava) return; @@ -152,7 +163,7 @@ function createVisualizerStore(): VisualizerStore { const viz = useAppStore().state().settings.visualizer; const config: CavaCoreConfig = { bars: barCount(), - sampleRate: 44100, + sampleRate: PCM_SAMPLE_RATE, channels: 1, noiseReduction: viz.noiseReduction, lowCutOff: viz.lowCutOff, @@ -164,32 +175,29 @@ function createVisualizerStore(): VisualizerStore { // Pre-warm the FFT window: libcavacore's window is malloc'd // uninitialized, so the first real frame would FFT garbage and // render full-scale bars. One zero frame the size of the whole - // input buffer clears it (at 44.1kHz mono the window is 8192 - // samples — FFTbassbufferSize × channels; a 512-sample frame would - // leave the tail garbage). + // input buffer clears it. cava.execute(new Float64Array(8192)); // Pre-allocate sample read buffer sampleBuffer = new Float64Array(SAMPLES_PER_FRAME); - // Start ffmpeg decode stream (reuse reader if same URL, else create new) - if (!reader || reader.url !== url) { - if (reader) reader.stop(); - reader = new AudioStreamReader({ url }); + // PCM cache per episode (reuse when the episode is unchanged) + if (!pcm || pcm.url !== url) { + if (pcm) pcm.stop(); + 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 // this, a fresh play at position 0 would sample the window ending at - // exactly 0 — a 1-sample slice the reader can never fill — so the - // bars would be starved until the first mpv poll advanced the - // position clock. Seeding makes the interpolated target advance - // immediately, so bars render as soon as ffmpeg has any audio. + // exactly 0 — a 1-sample slice — so bars would be starved until the + // first mpv poll advanced the position clock. lastPolledPosition = position; lastPolledAt = performance.now(); activeUrl = url; - activeSpeed = speed; activeBars = barCount(); setIsLoading(true); frameTimer = setInterval(renderFrame, FRAME_INTERVAL); @@ -201,9 +209,10 @@ function createVisualizerStore(): VisualizerStore { clearInterval(frameTimer); frameTimer = null; } - if (reader) { - reader.stop(); - // Don't null reader — we reuse it across start/stop cycles + if (pcm) { + pcm.stop(); + // 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) { cava.destroy(); @@ -212,17 +221,59 @@ function createVisualizerStore(): VisualizerStore { 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) ───────────────────────────────── 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 - // head — the reader decodes independently (paced at the player's - // clock rate with a LEAD_SECONDS burst head start) and only the - // position clock ties the bars to what's actually playing. + // Sample the FFT window at the player's position. Outside decoded + // coverage (decode cold start, seek into a hole) the read is empty + // and the LAST FRAME simply holds — never clamped/repeated junk. const target = smoothPosition(); - const count = reader.read(sampleBuffer, target); + const count = pcm.readWindow(sampleBuffer, target); // Never feed a partial FFT window to cava. if (count < sampleBuffer.length) return; @@ -235,44 +286,53 @@ function createVisualizerStore(): VisualizerStore { // ── Playback subscription ────────────────────────────────────────── // - // Keeps the pipeline matched to playback. `focused` is a dep so focus - // regain re-evaluates (and can restart an unloaded pipeline), but the - // guard below makes a focus flip on an already-correct warm pipeline a - // no-op — no churn when flipping back to the Player tab within the - // unload delay. A real change (url/speed/barCount, stop/start, or a - // stale pipeline after an unload) restarts from the current position. + // Keeps the pipeline matched to playback. Pause suspends (render loop + + // decode pass die, cache survives) so resume is instant. Stop/track-end/ + // disable fully tears down. `focused` is a dep so focus regain + // re-evaluates; the guards make a focus flip on an already-correct warm + // pipeline a no-op. Speed is deliberately NOT a dep — the PCM cache is + // position-indexed, so playback-rate changes need no pipeline restart. createEffect( on( [ audioPlaybackSignals.isPlaying, () => audioPlaybackSignals.currentEpisode()?.audioUrl ?? "", - audioPlaybackSignals.speed, barCount, focused, () => useAppStore().state().settings.visualizer.enabled, ], - ([playing, url, speed, , , enabled]) => { - if (!playing || !url || !enabled) { + ([playing, url, , , enabled]) => { + if (!url || !enabled) { stopVisualization(); return; } - // Warm and already correct — nothing to do (e.g. focus - // regained within the unload delay). + if (!playing) { + // 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 ( - frameTimer !== null && + frameTimer === null && + pcm && + cava?.isReady && url === activeUrl && - speed === activeSpeed && 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; } if (!focused()) return; // playing away: stay warm; unload timer decides - startVisualization( - url, - untrack(audioPlaybackSignals.position), - speed, - ); + startVisualization(url, untrack(audioPlaybackSignals.position)); }, ), ); @@ -296,7 +356,6 @@ function createVisualizerStore(): VisualizerStore { startVisualization( audioPlaybackSignals.currentEpisode()!.audioUrl, untrack(audioPlaybackSignals.position), - audioPlaybackSignals.speed() ?? 1, ); } } 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) - // only on significant jumps (>2s), which indicate a user seek. - // This is intentionally a separate effect — it should NOT trigger a - // full pipeline restart, just restart the ffmpeg stream at the new pos. + // Watches position for significant jumps (>2s = user seek). Decoded + // audio at the new position is served instantly with zero action; a + // jump into an undecoded hole kicks a background segment decode there + // while the last frame holds. let lastSyncPosition = 0; createEffect( on(audioPlaybackSignals.position, (pos) => { - if (!audioPlaybackSignals.isPlaying() || !reader?.running) { + if (!audioPlaybackSignals.isPlaying() || !pcm) { lastSyncPosition = pos; return; } @@ -327,10 +386,21 @@ function createVisualizerStore(): VisualizerStore { lastSyncPosition = pos; 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 { // state diff --git a/src/utils/audio-pcm-cache.ts b/src/utils/audio-pcm-cache.ts new file mode 100644 index 0000000..92b533d --- /dev/null +++ b/src/utils/audio-pcm-cache.ts @@ -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 { + const stdout = this.proc?.stdout; + if (!stdout || typeof stdout === "number") return; + + const reader = (stdout as ReadableStream).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 */ + } + } + } +} diff --git a/src/utils/audio-player.ts b/src/utils/audio-player.ts index 1556677..e1d82ed 100644 --- a/src/utils/audio-player.ts +++ b/src/utils/audio-player.ts @@ -6,10 +6,28 @@ * restart. When mpv isn't installed there is no fallback: the no-op backend * surfaces "No audio player found" honestly rather than degrading through * 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 { existsSync } from "fs"; +import { existsSync, unlinkSync } from "fs"; import { tmpdir } from "os"; import { dirname, join } from "path"; import type { Socket, Subprocess } from "bun"; @@ -31,6 +49,18 @@ export interface AudioState { export interface AudioBackend { readonly name: BackendName; play(url: string, opts?: PlayOptions): Promise; + /** + * 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; + /** + * 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; pause(): Promise; resume(): Promise; stop(): Promise; @@ -41,11 +71,11 @@ export interface AudioBackend { getDuration(): Promise; isPlaying(): boolean; /** Live pause state: `true` paused, `false` playing, `undefined` when - * the read failed (callers keep the last known state). Unlike - * `isPlaying()` — which reflects only commands PodTUI sent — this - * reflects the player's real state, including pauses initiated - * OUTSIDE PodTUI (system sleep/lock, AirPod removal, device swap, - * OS media keys, the Now Playing center). */ + * unknown (player unreachable / not yet loaded). Unlike `isPlaying()` — + * which reflects only commands PodTUI sent — this reflects the player's + * real state, including pauses initiated OUTSIDE PodTUI (system + * sleep/lock, AirPod removal, device swap, OS media keys, the Now + * Playing center). */ getPauseState(): Promise; /** True while the player process is running (regardless of pause). */ isAlive(): boolean; @@ -80,8 +110,16 @@ function which(cmd: string): string | null { return null; } +let mpvInstance = 0; 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; } +// ── 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 void>(); + private eventWaiters = new Map void>>(); + onEvent: MpvEventHandler = () => {}; + + async connect(path: string): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + 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; + try { + msg = JSON.parse(line) as Record; + } 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 { + const sock = this.sock; + if (!sock) return Promise.resolve({ error: "not-connected" }); + const id = this.nextId++; + const { promise, resolve } = Promise.withResolvers(); + 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 { + const { promise, resolve } = Promise.withResolvers(); + 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 ────────────────────────────────────────────────────── -// 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 { readonly name: BackendName = "mpv"; private proc: Subprocess | null = null; private socketPath = mpvSocketPath(); - private _playing = false; + private conn: MpvConnection | null = null; + /** Guarantee daemon startup runs once (concurrent play/preload). */ + private startPromise: Promise | 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 _duration = 0; + /** null until the first pause observation arrives. */ + private _paused: boolean | null = null; + private _volume = 100; private _speed = 1; private _exited = false; + /** Last playback error reported via end-file reason "error". */ + private _playbackError: string | null = null; - async play(url: string, opts?: PlayOptions): Promise { - await this.stop(); + // ── Daemon lifecycle ───────────────────────────────────────────── + private async ensureDaemon(): Promise { + 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 { // Clean up stale socket try { - if (existsSync(this.socketPath)) { - const { unlinkSync } = await import("fs"); - unlinkSync(this.socketPath); - } + unlinkSync(this.socketPath); } catch { /* ignore */ } - const args = [ - resolveMpvBinary() ?? "mpv", - "--no-video", - "--no-terminal", - "--really-quiet", - `--input-ipc-server=${this.socketPath}`, - `--volume=${Math.round((opts?.volume ?? 1) * 100)}`, - `--speed=${opts?.speed ?? 1}`, - ]; - - if (opts?.mediaTitle) { - args.push(`--force-media-title=${opts.mediaTitle}`); - } - - if (opts?.coverArtPath) { - // Explicit cover file → albumart track → macOS Now Playing artwork - // (works for remote streams, not just local downloads). - args.push(`--cover-art-files=${opts.coverArtPath}`); - } - - if (opts?.startPosition && opts.startPosition > 0) { - args.push(`--start=${opts.startPosition}`); - } - - args.push(url); - - this.proc = Bun.spawn(args, { - stdout: "ignore", - stderr: "ignore", - stdin: "ignore", - }); - - this._playing = true; + this.proc = Bun.spawn( + [ + resolveMpvBinary() ?? "mpv", + "--no-video", + "--no-terminal", + "--really-quiet", + // Stay alive after finishing/unloading files; PodTUI owns one mpv + // for its whole session and switches episodes via loadfile. + "--idle=yes", + "--keep-open=yes", + // Cap the demuxer cache. mpv's defaults (150MiB) make it race + // 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 + // target / 40MiB hard cap is a few MB at podcast bitrates: + // plenty for instant resume + stall resilience. + "--cache-secs=90", + "--demuxer-max-bytes=40MiB", + "--demuxer-max-back-bytes=20MiB", + `--input-ipc-server=${this.socketPath}`, + ], + { stdout: "ignore", stderr: "ignore", stdin: "ignore" }, + ); 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 .then(() => { - this._playing = false; this._exited = true; + this._intentPlaying = false; + this._loadedUrl = null; + this._paused = null; }) .catch(() => {}); - } - private async waitForSocket(timeoutMs: number): Promise { + // mpv creates the socket asynchronously (measured ~600ms cold spawn). const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (existsSync(this.socketPath)) return; + while (Date.now() - start < 3000) { + if (this._exited) break; + if (existsSync(this.socketPath)) break; 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(command: unknown[]): Promise { - try { - const conn = await Bun.connect({ - unix: this.socketPath, - socket: { - data() {}, - error() {}, - close() {}, - open() {}, - }, - }); - conn.write(JSON.stringify({ command }) + "\n"); - // Don't wait, just schedule a close - setTimeout(() => { - try { - conn.end(); - } catch {} - }, 50); - } catch { - /* ignore */ + private async send( + command: unknown[], + ): Promise { + if (!this.conn) return { error: "not-connected" }; + return this.conn.send(command); + } + + private handleEvent(msg: MpvEvent): void { + if (msg.event === "property-change") { + if (msg.id === OBS_TIME_POS) { + // `data` is number while playing; unavailable → undefined while + // idle. Keep last known on transient gaps, reset on idle. + if (typeof msg.data === "number") this._position = msg.data; + } else if (msg.id === OBS_PAUSE) { + if (typeof msg.data === "boolean") this._paused = msg.data; + } else if (msg.id === OBS_DURATION) { + if (typeof msg.data === "number" && msg.data > 0) { + this._duration = msg.data; + } + } else if (msg.id === OBS_EOF) { + // 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 { + 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. - * - * Resolves the parsed numeric value, or `undefined` when the read fails - * (socket error, timeout, unparseable response, or the property being - * unavailable — e.g. `time-pos` before playback starts). Failure is - * distinct from a legitimate `0` so callers can keep the last known - * value instead of snapping the position clock to zero on a transient - * 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. + * Every loadfile (play, preload, replay) runs under this mutex: useAudio + * fires the boot preload unawaited, so without serialization a user + * pressing Play mid-preload would send loadfile(no-pause) followed by the + * in-flight preload's loadfile(pause=yes) — and the stale preload would + * pause the file the user just started. The mutex also prevents + * presentation options (title/cover) of one episode from interleaving + * with the loadfile of another. */ - private async getProperty(name: string): Promise { - try { - return await new Promise((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); + private loadMutex: Promise = Promise.resolve(); - Bun.connect({ - unix: this.socketPath, - socket: { - open(socket) { - sock = socket; - socket.write( - JSON.stringify({ command: ["get_property", name] }) + "\n", - ); - }, - data(_socket, data) { - buf += Buffer.from(data).toString(); - let nl = buf.indexOf("\n"); - while (nl !== -1) { - const line = buf.slice(0, nl); - buf = buf.slice(nl + 1); - nl = buf.indexOf("\n"); - try { - const parsed = JSON.parse(line); - // Events carry no request_id; only settle on - // the actual command response. - if (parsed?.request_id === undefined) continue; - if (parsed?.data !== undefined) { - done(Number(parsed.data) || 0); - } else { - done(undefined); - } - return; - } catch { - /* skip malformed lines */ - } - } - }, - error() { - done(undefined); - }, - close() { - done(undefined); - }, - }, - }).catch(() => done(undefined)); - }); - } catch { - return undefined; + private runLoadExclusive(fn: () => Promise): Promise { + const result = this.loadMutex.then(fn); + this.loadMutex = result.catch(() => {}); + return result; + } + + private async loadFileLocked( + url: string, + opts: PlayOptions | undefined, + paused: boolean, + ): Promise { + await this.applyPresentation(opts); + // Paused preload of a mid-episode restore: pass NO start= option and + // seek while paused instead. mpv defers --start stream work (open, + // header probe, demuxer seek) until playback begins — measured: the + // demuxer cache stays EMPTY during the whole preload and the eventual + // unpause pays 4.3s. A time-pos seek while paused executes at once, + // so the stream opens and buffers during the preload, and the first + // real Play is a sub-second unpause. + const pausedSeek = + paused && opts?.startPosition && opts.startPosition > 0 + ? opts.startPosition + : null; + const loadOpts = + pausedSeek && opts ? { ...opts, startPosition: undefined } : opts; + // Register the file-loaded waiter BEFORE loadfile: the event can + // arrive between the command response and listener setup otherwise. + const fileLoaded = pausedSeek && this.conn ? this.conn.waitEvent("file-loaded") : null; + const resp = await this.send([ + "loadfile", + url, + "replace", + -1, + this.loadfileOptions(loadOpts, paused), + ]); + if (resp.error && resp.error !== "success") { + throw new Error(`mpv loadfile failed: ${resp.error}`); } + 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 { + 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 { + 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 { + 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 { await this.send(["set_property", "pause", true]); - this._playing = false; + this._intentPlaying = false; } async resume(): Promise { + 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]); - this._playing = true; + this._intentPlaying = true; } async stop(): Promise { - if (this.proc) { - try { - this.proc.kill(); - } catch { - /* ignore */ - } - this.proc = null; + if (this.conn && this._loadedUrl) { + await this.send(["stop"]); } - this._playing = false; + this._intentPlaying = false; + this._loadedUrl = null; + this._loadedPaused = false; + this._ended = false; this._position = 0; - - // Clean up socket - try { - if (existsSync(this.socketPath)) { - const { unlinkSync } = await import("fs"); - unlinkSync(this.socketPath); - } - } catch { - /* ignore */ - } + this._duration = 0; + await this.send(["set_property", "cover-art-files", ""]); } async seek(seconds: number): Promise { @@ -368,41 +698,55 @@ export class MpvBackend implements AudioBackend { } async getPosition(): Promise { - // Live-fetch `time-pos` so the position clock is as fresh as the - // 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; - } + // Observed at ~20Hz by mpv — no socket roundtrip on the UI poll. return this._position; } async getDuration(): Promise { - if (this._duration <= 0) { - const dur = await this.getProperty("duration"); - if (dur !== undefined && dur > 0) this._duration = dur; - } return this._duration; } isPlaying(): boolean { - return this._playing; + return this._intentPlaying && this.isAlive() && !this._ended; } async getPauseState(): Promise { - if (!this.isAlive()) return undefined; - const p = await this.getProperty("pause"); - if (p === undefined) return undefined; - return p === 1; + if (!this.isAlive() || this._paused === null) return undefined; + return this._paused; } isAlive(): boolean { return this.proc !== null && !this._exited; } + /** Last mpv playback failure (end-file reason "error"), if any. */ + getPlaybackError(): string | null { + return this._playbackError; + } + 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 { readonly name: BackendName = "none"; async play(): Promise {} + async preload(): Promise {} + async addCoverArt(): Promise {} async pause(): Promise {} async resume(): Promise {} async stop(): Promise {} diff --git a/src/utils/audio-stream-reader.ts b/src/utils/audio-stream-reader.ts deleted file mode 100644 index 0b3dc9d..0000000 --- a/src/utils/audio-stream-reader.ts +++ /dev/null @@ -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 `) 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 { - const stdout = this.proc?.stdout; - if (!stdout || typeof stdout === "number") return; - - const reader = (stdout as ReadableStream).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 */ - } - } - } -} diff --git a/src/utils/cover-art.ts b/src/utils/cover-art.ts index 2cb4a51..203c694 100644 --- a/src/utils/cover-art.ts +++ b/src/utils/cover-art.ts @@ -2,34 +2,97 @@ * Cover-art staging for the system Now Playing session. * * macOS shows the media session's albumart in the audio center (Control - * Center / lock screen). mpv reads it from `--cover-art-files` (loads the - * file as an albumart video track), so the podcast cover is staged to a temp - * file BEFORE playback starts and passed to mpv. + * Center / lock screen). mpv reads artwork from `--cover-art-files` (loads + * the file as an albumart video track), so the podcast cover must exist on + * 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 * `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 - * on macOS and Linux. Bounded: a slow cover server must never stall audio, - * so an 8s cap drops the art. + * on macOS and Linux. Bounded: a slow cover server must never stall audio. */ -import { tmpdir } from "os"; +import { existsSync, mkdirSync, renameSync, statSync } from "fs"; +import { createHash } from "crypto"; 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 { - const path = coverTempPath(); +function coversDir(): string | null { + if (cacheDir !== undefined) return cacheDir; + let dir: string | null = null; 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 { - /* 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 { - return await Promise.race([ - (async () => { - const proc = Bun.spawn([ + return existsSync(path) && statSync(path).size > 0 ? path : null; + } catch { + 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>(); + +/** + * 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 { + 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 => { + const staging = `${dest}.${process.pid}.tmp`; + try { + const { promise, resolve } = Promise.withResolvers(); + const proc = Bun.spawn( + [ "curl", "-sS", "--fail", @@ -38,20 +101,41 @@ export async function fetchCoverArt(url: string): Promise { "--max-filesize", "2097152", "-o", - path, + staging, url, - ]); - const code = await proc.exited; - if (code !== 0) return null; - try { - return statSync(path).size > 0 ? path : null; - } catch { - return null; - } - })(), - new Promise((resolve) => setTimeout(() => resolve(null), 8000)), - ]); - } catch { - return null; - } + ], + { stdout: "ignore", stderr: "ignore", stdin: "ignore" }, + ); + proc.exited + .then((code) => { + if (code !== 0) return resolve(null); + try { + if (statSync(staging).size <= 0) return resolve(null); + renameSync(staging, dest); + resolve(dest); + } catch { + resolve(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(() => {}); } diff --git a/tests/audio-backend.test.ts b/tests/audio-backend.test.ts new file mode 100644 index 0000000..f591be7 --- /dev/null +++ b/tests/audio-backend.test.ts @@ -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, + timeoutMs = 8000, +): Promise { + 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 { + 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 }, +); diff --git a/tests/audio-pcm-cache.test.ts b/tests/audio-pcm-cache.test.ts new file mode 100644 index 0000000..bd3a0ec --- /dev/null +++ b/tests/audio-pcm-cache.test.ts @@ -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 { + 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 { + 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 }, +); diff --git a/tests/audio-stream-reader.test.ts b/tests/audio-stream-reader.test.ts deleted file mode 100644 index d9974d9..0000000 --- a/tests/audio-stream-reader.test.ts +++ /dev/null @@ -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 `), 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 { - 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 }, -); diff --git a/tests/cover-art.test.ts b/tests/cover-art.test.ts new file mode 100644 index 0000000..406a3b3 --- /dev/null +++ b/tests/cover-art.test.ts @@ -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 */ + } + } + } +}); diff --git a/tests/external-pause-reconcile.test.ts b/tests/external-pause-reconcile.test.ts index bfe2f81..879c3ec 100644 --- a/tests/external-pause-reconcile.test.ts +++ b/tests/external-pause-reconcile.test.ts @@ -28,7 +28,14 @@ * identity bun loads from disk, bypassing the leaked mock. */ 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 { 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. const { useAudio } = await import("../src/hooks/useAudio?external-pause-test"); -/** The pid-derived socket path the backend tells mpv to bind. */ -function mpvSocket(): string { - return join(tmpdir(), `podtui-mpv-${process.pid}.sock`); +/** + * The socket path of the LIVE backend daemon in this process. The backend + * names sockets per-instance (`podtui-mpv--.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. */ async function mpvCommand(command: unknown[]): Promise { + const socket = mpvSocket(); + if (!socket) throw new Error("backend mpv socket not found"); const { promise, resolve, reject } = Promise.withResolvers(); let settled = false; const settle = (err: Error | null): void => { @@ -121,7 +151,7 @@ async function mpvCommand(command: unknown[]): Promise { else resolve(); }; Bun.connect({ - unix: mpvSocket(), + unix: socket, socket: { open(s) { s.write(JSON.stringify({ command }) + "\n"); @@ -213,8 +243,16 @@ afterAll(async () => { } catch { /* best-effort */ } + // The resident daemon survives stop() by design — quit it so test + // workers don't leak idle mpv processes. try { - rmSync(mpvSocket(), { force: true }); + await mpvCommand(["quit"]); + } catch { + /* best-effort */ + } + try { + const socket = mpvSocket(); + if (socket) rmSync(socket, { force: true }); } catch { /* best-effort */ } diff --git a/tests/visualizer-store.test.ts b/tests/visualizer-store.test.ts index 053d37b..52b5d03 100644 --- a/tests/visualizer-store.test.ts +++ b/tests/visualizer-store.test.ts @@ -15,7 +15,7 @@ * * Uses a self-generated local WAV (a frequency chirp, so different playback * positions produce measurably different bar output) and the real ffmpeg + - * native cavacore pipeline, mirroring audio-stream-reader.test.ts. + * native cavacore pipeline, mirroring audio-pcm-cache.test.ts. * * Timing note: this is an integration test of the store's real timers — the * unload path is a genuine `setTimeout` in the store, and bun 1.3.8 ships no