feat(audio): rebuild playback + visualization on resident daemon and PCM cache

Two fragility points, rebuilt at the root:

Playback: one resident mpv daemon (--idle --keep-open) with a persistent
IPC connection and observe_property state instead of spawn-per-episode and
connect-per-poll. Play/pause/seek are sub-ms commands; time-pos pushes at
~20Hz; external pauses arrive as events. Boot session restore preloads the
episode paused (loadfile + paused time-pos seek, since mpv defers --start
stream work until playback) so first Play is a ~400ms unpause instead of a
cold 4.3s open+seek. Load ops are mutex-serialized so a raced preload
cannot clobber an in-flight play.

Data throttling: mpv demuxer cache capped (cache-secs=90, max-bytes=40MiB)
so a paused preload no longer races to its 150MiB default (measured
45.7MB/12s); decoder paced at 4x realtime instead of 84x so playback start
isn't starved by the visualizer ripping the whole episode.

Visualization: replaced the paced-ring reader (AudioStreamReader) with a
position-indexed PCM cache (audio-pcm-cache). ffmpeg fills a cache indexed
by absolute playback time; reads at the player position are always exact.
Pause freezes the render loop, resume re-arms it — no coverage guessing,
no clamped-buffer freeze (the pause->broken-waveform->freeze bug). Seeks
and speed changes need no pipeline restarts; uncovered reads return empty
and the last frame holds.

Cover art: persistent per-URL disk cache under XDG cache dir; play() no
longer awaits a curl subprocess (up to 8s). Cache hit = one stat; misses
apply late via mpv video-add.

Test suite: 161 pass. New tests pin the position-index contract (sample-
exact window reads, hold-on-uncovered, pause-keeps-cache, seek segments),
the daemon contract (play/pause/resume/seek/stop, preload fast path,
EOF->replay), and cover cache/single-flight/404.
This commit is contained in:
2026-08-11 19:54:05 -04:00
parent 8b7b38276e
commit 20336ea716
12 changed files with 1757 additions and 882 deletions

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
.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,