diff --git a/scripts/tui-harness.tsx b/scripts/tui-harness.tsx index 459eed0..ab121a3 100644 --- a/scripts/tui-harness.tsx +++ b/scripts/tui-harness.tsx @@ -436,16 +436,9 @@ async function main() { } if (newAction) { if (flags.audio && audioControls?.switchBackend) { - // Re-detect: clear env so detection picks the best real backend. - delete process.env.PODTUI_AUDIO_BACKEND; // Force (re)creation of a real backend; useAudio caches, switchBackend resets. + delete process.env.PODTUI_AUDIO_BACKEND; await audioControls.switchBackend("mpv").catch(() => {}); - if ( - !audioControls.backendName() || - audioControls.backendName() === "none" - ) { - await audioControls.switchBackend("afplay").catch(() => {}); - } } actions.push(newAction); saveActions(actions); diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index 1718906..d80f499 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -88,7 +88,7 @@ function ensureBackend(): AudioBackend { // ── Process-exit teardown ───────────────────────────────────────────── // `q` (the quit action) calls `process.exit(0)`, which bypasses Solid's // onCleanup — where `backend.dispose()` would otherwise kill the spawned -// player (mpv/ffplay/afplay). Without this hook those child processes +// player (mpv). Without this hook those child processes // survive the host and keep playing audio after the TUI has quit. The // `exit` event fires synchronously on `process.exit(N)`; the signal // handlers cover Ctrl-C / kill, which otherwise terminate without running diff --git a/src/hooks/useMultimediaKeys.ts b/src/hooks/useMultimediaKeys.ts index ea79d69..034f7fa 100644 --- a/src/hooks/useMultimediaKeys.ts +++ b/src/hooks/useMultimediaKeys.ts @@ -5,43 +5,43 @@ * regardless of which component is focused. Uses the event bus to * decouple key detection from audio control logic. * - * Keys are only handled when an episode is loaded (or for play/pause, - * always). This prevents accidental volume/seek changes when there's - * nothing playing. + * Volume and speed are app-level settings — adjustable with or without + * an episode loaded (they apply to the next playback and persist). Seek + * is playback-dependent, so it still requires a loaded episode. */ -import { useKeyboard } from "@opentui/solid" -import { emit } from "../utils/event-bus" +import { useKeyboard } from "@opentui/solid"; +import { emit } from "../utils/event-bus"; export type MediaKeyAction = - | "media.toggle" - | "media.volumeUp" - | "media.volumeDown" - | "media.seekForward" - | "media.seekBackward" - | "media.speedCycle" + | "media.toggle" + | "media.volumeUp" + | "media.volumeDown" + | "media.seekForward" + | "media.seekBackward" + | "media.speedCycle"; /** Key-to-action mappings for multimedia controls */ const MEDIA_KEY_MAP: Record = { - // Common terminal media keys — these overlap with Player.tsx local - // bindings, but Player guards on `props.focused` so the global - // handler fires independently when the player tab is *not* active. - // - // When Player IS focused both handlers fire, but since the audio - // actions are idempotent (toggle = toggle, seek = additive) having - // them called twice for the same keypress is avoided by the event - // bus approach — the audio hook only processes event-bus events, and - // Player.tsx calls audio methods directly. We therefore guard with - // a "playerFocused" flag passed via options. -} + // Common terminal media keys — these overlap with Player.tsx local + // bindings, but Player guards on `props.focused` so the global + // handler fires independently when the player tab is *not* active. + // + // When Player IS focused both handlers fire, but since the audio + // actions are idempotent (toggle = toggle, seek = additive) having + // them called twice for the same keypress is avoided by the event + // bus approach — the audio hook only processes event-bus events, and + // Player.tsx calls audio methods directly. We therefore guard with + // a "playerFocused" flag passed via options. +}; export interface MultimediaKeysOptions { - /** When true, skip handling (Player.tsx handles keys locally) */ - playerFocused?: () => boolean - /** When true, skip handling (text input has focus) */ - inputFocused?: () => boolean - /** Whether an episode is currently loaded */ - hasEpisode?: () => boolean + /** When true, skip handling (Player.tsx handles keys locally) */ + playerFocused?: () => boolean; + /** When true, skip handling (text input has focus) */ + inputFocused?: () => boolean; + /** Whether an episode is currently loaded */ + hasEpisode?: () => boolean; } /** @@ -49,50 +49,47 @@ export interface MultimediaKeysOptions { * event bus. Call once at the app level (e.g. in App.tsx). */ export function useMultimediaKeys(options: MultimediaKeysOptions = {}) { - useKeyboard((key) => { - // Don't intercept when a text input owns the keyboard - if (options.inputFocused?.()) return + useKeyboard((key) => { + // Don't intercept when a text input owns the keyboard + if (options.inputFocused?.()) return; - // Don't intercept when Player component handles its own keys - if (options.playerFocused?.()) return + // Don't intercept when Player component handles its own keys + if (options.playerFocused?.()) return; - // Ctrl/Meta combos are app-level shortcuts, not media keys - if (key.ctrl || key.meta) return + // Ctrl/Meta combos are app-level shortcuts, not media keys + if (key.ctrl || key.meta) return; - switch (key.name) { - case "space": - // Toggle play/pause — always valid (may start a loaded episode) - emit("media.toggle", {}) - break + switch (key.name) { + case "space": + // Toggle play/pause — always valid (may start a loaded episode) + emit("media.toggle", {}); + break; - case "up": - if (!options.hasEpisode?.()) return - emit("media.volumeUp", {}) - break + case "up": + emit("media.volumeUp", {}); + break; - case "down": - if (!options.hasEpisode?.()) return - emit("media.volumeDown", {}) - break + case "down": + emit("media.volumeDown", {}); + break; - case "left": - if (!options.hasEpisode?.()) return - emit("media.seekBackward", {}) - break + case "left": + if (!options.hasEpisode?.()) return; + emit("media.seekBackward", {}); + break; - case "right": - if (!options.hasEpisode?.()) return - emit("media.seekForward", {}) - break + case "right": + if (!options.hasEpisode?.()) return; + emit("media.seekForward", {}); + break; - case "s": - if (!options.hasEpisode?.()) return - emit("media.speedCycle", {}) - break + case "s": + emit("media.speedCycle", {}); + break; - default: - // Not a media key — do nothing - break - } - }) + default: + // Not a media key — do nothing + break; + } + }); } diff --git a/src/pages/Player/PlaybackControls.tsx b/src/pages/Player/PlaybackControls.tsx index 46a7776..bb1c4fb 100644 --- a/src/pages/Player/PlaybackControls.tsx +++ b/src/pages/Player/PlaybackControls.tsx @@ -16,9 +16,6 @@ type PlaybackControlsProps = { const BACKEND_LABELS: Record = { mpv: "mpv", - ffplay: "ffplay", - afplay: "afplay", - system: "system", none: "none", }; @@ -60,10 +57,12 @@ export function PlaybackControls(props: PlaybackControlsProps) { Vol {Math.round(props.volume * 100)}% + ↑↓ Speed {props.speed}x + s {props.backendName && props.backendName !== "none" && ( diff --git a/src/pages/Player/PlayerPage.tsx b/src/pages/Player/PlayerPage.tsx index 8fc42a3..5c52c49 100644 --- a/src/pages/Player/PlayerPage.tsx +++ b/src/pages/Player/PlayerPage.tsx @@ -82,8 +82,9 @@ export function PlayerPage() { { const viz = useAppStore().state().settings.visualizer; + // bars is width-derived in RealtimeWaveform; pass only the + // audio-processing params here. return { - bars: viz.bars, noiseReduction: viz.noiseReduction, lowCutOff: viz.lowCutOff, highCutOff: viz.highCutOff, @@ -109,7 +110,7 @@ export function PlayerPage() { - {"P play/pause N next B prev ); diff --git a/src/pages/Player/RealtimeWaveform.tsx b/src/pages/Player/RealtimeWaveform.tsx index 580bec7..d3e8031 100644 --- a/src/pages/Player/RealtimeWaveform.tsx +++ b/src/pages/Player/RealtimeWaveform.tsx @@ -8,6 +8,7 @@ */ import { createSignal, createEffect, onCleanup, on, untrack } from "solid-js"; +import { useTerminalDimensions } from "@opentui/solid"; import { loadCavaCore, type CavaCore, @@ -16,6 +17,7 @@ import { import { AudioStreamReader } from "@/utils/audio-stream-reader"; import { useAudio } from "@/hooks/useAudio"; import { useTheme } from "@/context/ThemeContext"; +import { PANE_RATIO } from "@/utils/navigation"; // ── Types ──────────────────────────────────────────────────────────── @@ -51,17 +53,27 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { // Frequency bar values (0.0–1.0 per bar) const [barData, setBarData] = createSignal([]); - // Track whether cavacore is available - const [available, setAvailable] = createSignal(false); - let cava: CavaCore | null = null; let reader: AudioStreamReader | null = null; let frameTimer: ReturnType | null = null; let sampleBuffer: Float64Array | null = null; - // Bar count comes from the visualizer config (set in Settings); default 64. - // Single source of truth used for cavacore init, rendering, and seek clicks. - const numBars = () => props.visualizerConfig?.bars ?? 64; + // Bar count scales with terminal width so the waveform fills its pane. + // The player is a 2-pane row: current column = (current+preview) of + // (parent+current+preview) of the terminal width. Subtract ~8 chars of + // chrome (scrollbox border + box padding + waveform border + padding). + // Falls back to 64 before the renderer reports a real size. + const dimensions = useTerminalDimensions(); + const numBars = () => { + const total = PANE_RATIO.parent + PANE_RATIO.current + PANE_RATIO.preview; + const current = PANE_RATIO.current + PANE_RATIO.preview; // 2-pane grows current + const width = dimensions().width; + if (!width) return 64; + return Math.max( + 8, + Math.min(256, Math.floor((width * current) / total) - 8), + ); + }; // ── Lifecycle: init cavacore once ────────────────────────────────── @@ -70,11 +82,9 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { cava = loadCavaCore(); if (!cava) { - setAvailable(false); return false; } - setAvailable(true); return true; }; @@ -85,7 +95,9 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { if (!url || !initCava() || !cava) return; - // Initialize cavacore with current resolution + any overrides + // Initialize cavacore with current resolution + any overrides. + // bars is width-derived (see numBars); visualizerConfig supplies the + // audio-processing params (noise reduction, cutoffs, etc.). const config: CavaCoreConfig = { bars: numBars(), sampleRate: 44100, @@ -140,7 +152,7 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { const output = cava.execute(input); // Copy bar values to a new array for the signal - setBarData(Array.from(output)); + setBarData(Array.from(output as Float64Array)); }; createEffect( diff --git a/src/utils/audio-player.ts b/src/utils/audio-player.ts index 626a38d..f7ea9b9 100644 --- a/src/utils/audio-player.ts +++ b/src/utils/audio-player.ts @@ -1,14 +1,11 @@ /** - * Cross-platform audio playback engine for PodTUI. + * Audio playback engine for PodTUI. * - * Backend priority: - * 1. mpv — full IPC control (seek, volume, speed, position tracking) - * 2. ffplay — basic control via process signals - * 3. afplay — macOS built-in (no seek/speed, volume only) - * 4. system — open/xdg-open/start (fire-and-forget, no control) - * - * All backends implement the AudioBackend interface so the Player - * component doesn't need to care which one is active. + * Single backend: mpv — full IPC control (seek, volume, speed, position + * tracking), so speed/volume/seek changes apply instantly with no process + * 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. */ import { platform } from "os"; @@ -18,7 +15,7 @@ import { join } from "path"; // ── Types ──────────────────────────────────────────────────────────── -export type BackendName = "mpv" | "ffplay" | "afplay" | "system" | "none"; +export type BackendName = "mpv" | "none"; export interface AudioState { playing: boolean; @@ -381,467 +378,6 @@ export class MpvBackend implements AudioBackend { } } -// ── ffplay Backend ─────────────────────────────────────────────────── -// ffplay has no IPC. We track duration from episode metadata and -// position via elapsed wall-clock time. Seek requires restarting. - -class FfplayBackend implements AudioBackend { - readonly name: BackendName = "ffplay"; - private proc: ReturnType | null = null; - private _playing = false; - private _paused = false; - private _position = 0; - private _duration = 0; - private _volume = 100; - private _speed = 1; - private _url = ""; - private startTime = 0; - private pollTimer: ReturnType | null = null; - - async play(url: string, opts?: PlayOptions): Promise { - await this.stop(); - - this._url = url; - this._volume = Math.round((opts?.volume ?? 1) * 100); - this._speed = opts?.speed ?? 1; - this._position = opts?.startPosition ?? 0; - - this.spawnProcess(); - } - - private spawnProcess(): void { - const args = [ - "ffplay", - "-nodisp", - "-autoexit", - "-loglevel", - "quiet", - "-volume", - String(this._volume), - ]; - - if (this._position > 0) { - args.push("-ss", String(this._position)); - } - - if (this._speed !== 1) { - args.push("-af", `atempo=${this._speed}`); - } - - args.push("-i", this._url); - - this.proc = Bun.spawn(args, { - stdout: "ignore", - stderr: "ignore", - stdin: "ignore", - }); - - this._playing = true; - this._paused = false; - this.startTime = Date.now(); - this.startPolling(); - - this.proc.exited - .then(() => { - this._playing = false; - this.stopPolling(); - }) - .catch(() => {}); - } - - private startPolling(): void { - this.stopPolling(); - this.pollTimer = setInterval(() => { - if (!this._playing) return; - const elapsed = ((Date.now() - this.startTime) / 1000) * this._speed; - this._position = this._position + elapsed; - this.startTime = Date.now(); - }, 500); - } - - private stopPolling(): void { - if (this.pollTimer) { - clearInterval(this.pollTimer); - this.pollTimer = null; - } - } - - async pause(): Promise { - if (this.proc) { - const pid = (this.proc as unknown as { pid?: number } | null)?.pid; - try { - if (pid) process.kill(pid, "SIGSTOP"); - } catch {} - this._paused = true; - } - this._playing = false; - this.stopPolling(); - } - - async resume(): Promise { - if (!this._url) return; - if (this.proc && this._paused) { - const pid = (this.proc as unknown as { pid?: number } | null)?.pid; - try { - if (pid) process.kill(pid, "SIGCONT"); - } catch {} - this._paused = false; - this._playing = true; - this.startTime = Date.now(); - this.startPolling(); - return; - } - this.spawnProcess(); - } - - async stop(): Promise { - this.stopPolling(); - if (this.proc) { - try { - this.proc.kill(); - } catch {} - this.proc = null; - } - this._playing = false; - this._paused = false; - this._position = 0; - this._url = ""; - } - - async seek(seconds: number): Promise { - this._position = seconds; - if (this._playing && this._url) { - // Restart at new position - if (this.proc) { - try { - this.proc.kill(); - } catch {} - this.proc = null; - } - this.spawnProcess(); - const pid = (this.proc as unknown as { pid?: number } | null)?.pid; - if (this._paused && pid) { - try { - process.kill(pid, "SIGSTOP"); - } catch {} - this._playing = false; - } - } - } - - async setVolume(volume: number): Promise { - this._volume = Math.round(volume * 100); - // ffplay has no runtime IPC; volume will apply on next play/resume. - // Restart the process to apply immediately if currently playing. - if (this._url && (this._playing || this._paused)) { - this.stopPolling(); - if (this.proc) { - try { - this.proc.kill(); - } catch {} - this.proc = null; - } - this.spawnProcess(); - const pid = (this.proc as unknown as { pid?: number } | null)?.pid; - if (this._paused && pid) { - try { - process.kill(pid, "SIGSTOP"); - } catch {} - this._playing = false; - } - } - } - - async setSpeed(speed: number): Promise { - this._speed = speed; - if (this._url && (this._playing || this._paused)) { - this.stopPolling(); - if (this.proc) { - try { - this.proc.kill(); - } catch {} - this.proc = null; - } - this.spawnProcess(); - const pid = (this.proc as unknown as { pid?: number } | null)?.pid; - if (this._paused && pid) { - try { - process.kill(pid, "SIGSTOP"); - } catch {} - this._playing = false; - } - } - } - - async getPosition(): Promise { - return this._position; - } - - async getDuration(): Promise { - return this._duration; - } - - isPlaying(): boolean { - return this._playing; - } - - dispose(): void { - this.stop(); - } -} - -// ── afplay Backend (macOS) ─────────────────────────────────────────── -// Built-in on macOS. Supports volume and rate but no seek or position. - -class AfplayBackend implements AudioBackend { - readonly name: BackendName = "afplay"; - private proc: ReturnType | null = null; - private _playing = false; - private _paused = false; - private _position = 0; - private _duration = 0; - private _volume = 1; - private _speed = 1; - private _url = ""; - private startTime = 0; - private pollTimer: ReturnType | null = null; - - async play(url: string, opts?: PlayOptions): Promise { - await this.stop(); - - this._url = url; - this._volume = opts?.volume ?? 1; - this._speed = opts?.speed ?? 1; - this._position = opts?.startPosition ?? 0; - - this.spawnProcess(); - } - - private spawnProcess(): void { - // afplay supports --volume (0-1) and --rate - const args = [ - "afplay", - "--volume", - String(this._volume), - "--rate", - String(this._speed), - ]; - - if (this._position > 0) { - args.push( - "--time", - String(this._duration > 0 ? this._duration - this._position : 0), - ); - } - - args.push(this._url); - - this.proc = Bun.spawn(args, { - stdout: "ignore", - stderr: "ignore", - stdin: "ignore", - }); - - this._playing = true; - this._paused = false; - this.startTime = Date.now(); - this.startPolling(); - - this.proc.exited - .then(() => { - this._playing = false; - this.stopPolling(); - }) - .catch(() => {}); - } - - private startPolling(): void { - this.stopPolling(); - this.pollTimer = setInterval(() => { - if (!this._playing) return; - const elapsed = ((Date.now() - this.startTime) / 1000) * this._speed; - this._position = this._position + elapsed; - this.startTime = Date.now(); - }, 500); - } - - private stopPolling(): void { - if (this.pollTimer) { - clearInterval(this.pollTimer); - this.pollTimer = null; - } - } - - async pause(): Promise { - if (this.proc) { - const pid = (this.proc as unknown as { pid?: number } | null)?.pid; - try { - if (pid) process.kill(pid, "SIGSTOP"); - } catch {} - this._paused = true; - } - this._playing = false; - this.stopPolling(); - } - - async resume(): Promise { - if (!this._url) return; - if (this.proc && this._paused) { - const pid = (this.proc as unknown as { pid?: number } | null)?.pid; - try { - if (pid) process.kill(pid, "SIGCONT"); - } catch {} - this._paused = false; - this._playing = true; - this.startTime = Date.now(); - this.startPolling(); - return; - } - this.spawnProcess(); - } - - async stop(): Promise { - this.stopPolling(); - if (this.proc) { - try { - this.proc.kill(); - } catch {} - this.proc = null; - } - this._playing = false; - this._paused = false; - this._position = 0; - this._url = ""; - } - - async seek(seconds: number): Promise { - this._position = seconds; - if (this._playing && this._url) { - if (this.proc) { - try { - this.proc.kill(); - } catch {} - this.proc = null; - } - this.spawnProcess(); - const pid = (this.proc as unknown as { pid?: number } | null)?.pid; - if (this._paused && pid) { - try { - process.kill(pid, "SIGSTOP"); - } catch {} - this._playing = false; - } - } - } - - async setVolume(volume: number): Promise { - this._volume = volume; - // Restart the process with new volume to apply immediately - if (this._url && (this._playing || this._paused)) { - this.stopPolling(); - if (this.proc) { - try { - this.proc.kill(); - } catch {} - this.proc = null; - } - this.spawnProcess(); - const pid = (this.proc as unknown as { pid?: number } | null)?.pid; - if (this._paused && pid) { - try { - process.kill(pid, "SIGSTOP"); - } catch {} - this._playing = false; - } - } - } - - async setSpeed(speed: number): Promise { - this._speed = speed; - // Restart the process with new rate to apply immediately - if (this._url && (this._playing || this._paused)) { - this.stopPolling(); - if (this.proc) { - try { - this.proc.kill(); - } catch {} - this.proc = null; - } - this.spawnProcess(); - const pid = (this.proc as unknown as { pid?: number } | null)?.pid; - if (this._paused && pid) { - try { - process.kill(pid, "SIGSTOP"); - } catch {} - this._playing = false; - } - } - } - - async getPosition(): Promise { - return this._position; - } - - async getDuration(): Promise { - return this._duration; - } - - isPlaying(): boolean { - return this._playing; - } - - dispose(): void { - this.stop(); - } -} - -// ── System Backend (open/xdg-open) ─────────────────────────────────── -// Fire-and-forget. Opens the URL in the default handler. No control. - -class SystemBackend implements AudioBackend { - readonly name: BackendName = "system"; - private _playing = false; - - async play(url: string): Promise { - const os = platform(); - const cmd = - os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open"; - - Bun.spawn([cmd, url], { - stdout: "ignore", - stderr: "ignore", - stdin: "ignore", - }); - - this._playing = true; - } - - async pause(): Promise { - this._playing = false; - } - async resume(): Promise { - this._playing = true; - } - async stop(): Promise { - this._playing = false; - } - async seek(): Promise {} - async setVolume(): Promise {} - async setSpeed(): Promise {} - async getPosition(): Promise { - return 0; - } - async getDuration(): Promise { - return 0; - } - isPlaying(): boolean { - return this._playing; - } - dispose(): void { - this._playing = false; - } -} - // ── No-op Backend ──────────────────────────────────────────────────── class NoopBackend implements AudioBackend { @@ -896,53 +432,6 @@ export function detectPlayers(): DetectedPlayer[] { }); } - const ffplayPath = which("ffplay"); - if (ffplayPath) { - players.push({ - name: "ffplay", - path: ffplayPath, - capabilities: { - seek: true, - volume: true, - speed: false, - positionTracking: false, - }, - }); - } - - const os = platform(); - if (os === "darwin") { - const afplayPath = which("afplay"); - if (afplayPath) { - players.push({ - name: "afplay", - path: afplayPath, - capabilities: { - seek: true, - volume: true, - speed: true, - positionTracking: false, - }, - }); - } - } - - // System open is always available as fallback - const openCmd = - os === "darwin" ? "open" : os === "win32" ? "start" : "xdg-open"; - if (which(openCmd)) { - players.push({ - name: "system", - path: which(openCmd), - capabilities: { - seek: false, - volume: false, - speed: false, - positionTracking: false, - }, - }); - } - return players; } @@ -953,14 +442,7 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend { // An explicit `preferred` argument still wins. if (!preferred) { const envPref = process.env.PODTUI_AUDIO_BACKEND as BackendName | undefined; - if ( - envPref && - (envPref === "mpv" || - envPref === "ffplay" || - envPref === "afplay" || - envPref === "system" || - envPref === "none") - ) { + if (envPref && (envPref === "mpv" || envPref === "none")) { preferred = envPref; } } @@ -970,25 +452,13 @@ export function createAudioBackend(preferred?: BackendName): AudioBackend { if (backend) return backend; } - // Auto-detect in priority order - const players = detectPlayers(); - if (players.length === 0) return new NoopBackend(); - - return createBackendByName(players[0].name) ?? new NoopBackend(); + return which("mpv") ? new MpvBackend() : new NoopBackend(); } function createBackendByName(name: BackendName): AudioBackend | null { switch (name) { case "mpv": return which("mpv") ? new MpvBackend() : null; - case "ffplay": - return which("ffplay") ? new FfplayBackend() : null; - case "afplay": - return platform() === "darwin" && which("afplay") - ? new AfplayBackend() - : null; - case "system": - return new SystemBackend(); case "none": return new NoopBackend(); } diff --git a/tests/audio-dispose.test.ts b/tests/audio-dispose.test.ts index 76e77a9..81f5fa5 100644 --- a/tests/audio-dispose.test.ts +++ b/tests/audio-dispose.test.ts @@ -3,7 +3,7 @@ * * The `q` (quit) action routes through `process.exit(0)`, which bypasses * Solid's onCleanup (where useAudio's onCleanup disposes the backend). To - * keep spawned players (mpv/ffplay/afplay) from surviving the host, useAudio + * keep spawned players (mpv) from surviving the host, useAudio * registers a `process.on("exit")` handler that synchronously disposes the * backend. The exit handler's whole job is "kill the child process", so this * test pins the contract directly: a backend holding a real spawned subprocess @@ -11,7 +11,7 @@ * * Uses a real `Bun.spawn(["sleep", "60"])` subprocess as a stand-in for the * player process, injected into the (private) `proc` slot of an MpvBackend — - * mpv/ffplay/afplay all share the identical kill-on-dispose pattern, so + * mpv is the only real backend, and it uses the kill-on-dispose * exercising one is enough to guard the family. */ import { test, expect } from "bun:test";