From 19eae4fd5ac0a06be28f2f560839f0dea450e007 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 10 Aug 2026 15:51:02 -0400 Subject: [PATCH] feat: sync waveform to live player position (mpv time-pos, position-window reads, smooth clock) --- src/hooks/useAudio.ts | 14 +- src/pages/Player/RealtimeWaveform.tsx | 31 +++- src/utils/audio-player.ts | 205 ++++++++++---------------- src/utils/audio-stream-reader.ts | 146 ++++++++++-------- tests/audio-stream-reader.test.ts | 172 +++++++++++++++++++++ 5 files changed, 373 insertions(+), 195 deletions(-) create mode 100644 tests/audio-stream-reader.test.ts diff --git a/src/hooks/useAudio.ts b/src/hooks/useAudio.ts index 5484324..5320548 100644 --- a/src/hooks/useAudio.ts +++ b/src/hooks/useAudio.ts @@ -122,17 +122,21 @@ function registerExitTeardown(): void { function startPolling(): void { stopPolling(); pollCount = 0; + // Guard against overlapping ticks if a socket read ever outlives the + // interval (getPosition opens a fresh mpv IPC connection per call). + let pollInFlight = false; pollTimer = setInterval(async () => { - if (!backend || !isPlaying()) return; + if (!backend || !isPlaying() || pollInFlight) return; + pollInFlight = true; try { const pos = await backend.getPosition(); const dur = await backend.getDuration(); setPosition(pos); if (dur > 0) setDuration(dur); - // Save progress every ~5 seconds (10 ticks * 500ms) + // Save progress every ~5 seconds (33 ticks * 150ms) pollCount++; - if (pollCount % 10 === 0) { + if (pollCount % 33 === 0) { const ep = currentEpisode(); if (ep) { const progressStore = useProgressStore(); @@ -156,8 +160,10 @@ function startPolling(): void { } } catch { // Backend may have been disposed + } finally { + pollInFlight = false; } - }, 500); + }, 150); } function stopPolling(): void { diff --git a/src/pages/Player/RealtimeWaveform.tsx b/src/pages/Player/RealtimeWaveform.tsx index 36f8b23..ad16c33 100644 --- a/src/pages/Player/RealtimeWaveform.tsx +++ b/src/pages/Player/RealtimeWaveform.tsx @@ -88,6 +88,29 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { return true; }; + // ── Smooth position clock ────────────────────────────────────────── + // + // audio.position() updates at the useAudio poll rate (~150ms). Between + // polls, interpolate the position from wall time so the FFT window (and + // the played/future split) tracks the audio continuously instead of + // stepping. The 0.5s cap prevents extrapolating far beyond reality when + // the player stalls (e.g. network re-buffering). + + let lastPolledPosition = 0; + let lastPolledAt = 0; + const smoothPosition = () => { + const pos = audio.position(); + const now = performance.now(); + if (pos !== lastPolledPosition) { + lastPolledPosition = pos; + lastPolledAt = now; + return pos; + } + if (lastPolledAt === 0) return pos; + const elapsed = Math.min((now - lastPolledAt) / 1000, 0.5); + return lastPolledPosition + elapsed * (audio.speed() ?? 1); + }; + // ── Start/stop the visualization pipeline ────────────────────────── const startVisualization = (url: string, position: number, speed: number) => { @@ -139,7 +162,11 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { const renderFrame = () => { if (!cava?.isReady || !reader?.running || !sampleBuffer) return; - const count = reader.read(sampleBuffer); + // Sample the FFT window at the player's position, not the decode + // head — the reader decodes independently and only the position clock + // ties the bars to what's actually playing. + const target = smoothPosition(); + const count = reader.read(sampleBuffer, target); if (count === 0) return; const input = @@ -212,7 +239,7 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) { const playedRatio = () => audio.duration() <= 0 ? 0 - : Math.min(1, audio.position() / audio.duration()); + : Math.min(1, smoothPosition() / audio.duration()); const renderLine = () => { const bars = barData(); diff --git a/src/utils/audio-player.ts b/src/utils/audio-player.ts index f7ea9b9..9c9e843 100644 --- a/src/utils/audio-player.ts +++ b/src/utils/audio-player.ts @@ -12,6 +12,7 @@ import { platform } from "os"; import { existsSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; +import type { Socket, Subprocess } from "bun"; // ── Types ──────────────────────────────────────────────────────────── @@ -77,14 +78,13 @@ function mpvSocketPath(): string { export class MpvBackend implements AudioBackend { readonly name: BackendName = "mpv"; - private proc: ReturnType | null = null; + private proc: Subprocess | null = null; private socketPath = mpvSocketPath(); private _playing = false; private _position = 0; private _duration = 0; private _volume = 100; private _speed = 1; - private pollTimer: ReturnType | null = null; async play(url: string, opts?: PlayOptions): Promise { await this.stop(); @@ -129,14 +129,13 @@ export class MpvBackend implements AudioBackend { // Wait for socket to appear (mpv creates it async) await this.waitForSocket(2000); - // Start polling position - this.startPolling(); + // 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.stopPolling(); }) .catch(() => {}); } @@ -149,79 +148,6 @@ export class MpvBackend implements AudioBackend { } } - private async ipc(command: unknown[]): Promise { - try { - const socket = await Bun.connect({ - unix: this.socketPath, - socket: { - data(_socket, data) { - // Response handling is done by reading below - }, - error(_socket, err) {}, - close() {}, - open() {}, - }, - }); - - const payload = JSON.stringify({ command }) + "\n"; - socket.write(payload); - - // Read response with timeout - const response = await new Promise((resolve) => { - let buf = ""; - const reader = setInterval(() => { - // Check if we got a response already - if (buf.includes("\n")) { - clearInterval(reader); - resolve(buf); - } - }, 10); - setTimeout(() => { - clearInterval(reader); - resolve(buf); - }, 200); - }); - - socket.end(); - if (response) { - try { - return JSON.parse(response.split("\n")[0]); - } catch { - return null; - } - } - return null; - } catch { - return null; - } - } - - /** Send a command over mpv's IPC and get the parsed response data. */ - private async ipcCommand(command: unknown[]): Promise { - try { - const conn = await Bun.connect({ - unix: this.socketPath, - socket: { - data() {}, - error() {}, - close() {}, - open() {}, - }, - }); - - const payload = JSON.stringify({ command }) + "\n"; - conn.write(payload); - - // Give mpv a moment to process, then read via a fresh connection - await new Promise((r) => setTimeout(r, 30)); - conn.end(); - - return null; - } catch { - return null; - } - } - /** Send a fire-and-forget command (no response needed) */ private async send(command: unknown[]): Promise { try { @@ -246,65 +172,85 @@ export class MpvBackend implements AudioBackend { } } - /** Get a property value from mpv via IPC */ - private async getProperty(name: string): Promise { + /** + * 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. + */ + private async getProperty(name: string): Promise { try { - return await new Promise((resolve) => { - let result = 0; - const timeout = setTimeout(() => resolve(result), 300); + 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); Bun.connect({ unix: this.socketPath, socket: { - data(_socket, data) { - try { - const text = Buffer.from(data).toString(); - const parsed = JSON.parse(text.split("\n")[0]); - if (parsed?.data !== undefined) { - result = Number(parsed.data) || 0; - } - } catch { - /* ignore parse errors */ - } - clearTimeout(timeout); - resolve(result); - }, - error() { - clearTimeout(timeout); - resolve(0); - }, - close() {}, 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(() => { - clearTimeout(timeout); - resolve(0); - }); + }).catch(() => done(undefined)); }); } catch { - return 0; - } - } - - private startPolling(): void { - this.stopPolling(); - this.pollTimer = setInterval(async () => { - if (!this._playing || !this.proc) return; - this._position = await this.getProperty("time-pos"); - if (this._duration <= 0) { - this._duration = await this.getProperty("duration"); - } - }, 500); - } - - private stopPolling(): void { - if (this.pollTimer) { - clearInterval(this.pollTimer); - this.pollTimer = null; + return undefined; } } @@ -319,7 +265,6 @@ export class MpvBackend implements AudioBackend { } async stop(): Promise { - this.stopPolling(); if (this.proc) { try { this.proc.kill(); @@ -359,12 +304,20 @@ 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; + } return this._position; } async getDuration(): Promise { if (this._duration <= 0) { - this._duration = await this.getProperty("duration"); + const dur = await this.getProperty("duration"); + if (dur !== undefined && dur > 0) this._duration = dur; } return this._duration; } diff --git a/src/utils/audio-stream-reader.ts b/src/utils/audio-stream-reader.ts index bf755cb..b6da4e8 100644 --- a/src/utils/audio-stream-reader.ts +++ b/src/utils/audio-stream-reader.ts @@ -4,10 +4,13 @@ * 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 provides them to the caller on demand. + * 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. + * read-only "tap" on the audio for FFT analysis purposes. Because it is a + * separate decoder, sync with the player is maintained by pacing decode at + * the player's clock rate (`-readrate `) and sampling the window at + * the position the player reports, never at the decode head. */ /** PCM output format constants */ @@ -15,8 +18,14 @@ const SAMPLE_RATE = 44100; const CHANNELS = 1; const BYTES_PER_SAMPLE = 2; // s16le -/** How many samples to buffer (~1 second) */ -const RING_BUFFER_SAMPLES = SAMPLE_RATE; +/** + * 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; export interface AudioStreamReaderOptions { /** Audio URL or file path to decode */ @@ -32,11 +41,14 @@ export interface AudioStreamReaderOptions { */ let globalGeneration = 0; +import type { Subprocess } from "bun"; + export class AudioStreamReader { - private proc: ReturnType | null = null; + 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; @@ -81,25 +93,41 @@ export class AudioStreamReader { // 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", - // Read input at native frame rate so decoded PCM stays in sync with - // real-time playback. Without -re, ffmpeg greedily decodes the whole - // file as fast as possible: the ring buffer fills with audio seconds - // ahead of the player (laggy bars), then the process exits when it - // hits EOF (bars freeze ~10s in). - "-re", - "-reconnect", - "1", - "-reconnect_streamed", - "1", - "-reconnect_delay_max", - "5", + // Pace input at the player's advance rate (speed× native) rather + // than native rate. Decoding slower than the player makes the + // decoded position fall behind the playback position linearly + // (bars drift away at (speed-1)s per second); decoding unthrottled + // fills the ring with audio seconds ahead of the player (laggy + // bars) and hits EOF early (bars freeze). `-readrate speed` keeps + // the decode head just ahead of the position the renderer samples, + // tracking the player clock with only mpv's startup latency as a + // constant offset — absorbed by the ring buffer. + "-readrate", + String(readRate), ]; + // `-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)); @@ -107,12 +135,9 @@ export class AudioStreamReader { args.push("-i", this.url); - // Apply speed via atempo filter if not 1x. - // ffmpeg atempo only supports 0.5–100.0; chain multiple for extremes. - if (speed !== 1 && speed > 0) { - args.push("-af", buildAtempoChain(speed)); - } - + // 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), @@ -155,31 +180,48 @@ export class AudioStreamReader { } /** - * Read available samples into the provided buffer. - * Returns the number of samples actually copied. + * 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): number { - const available = Math.min( - out.length, - this.totalSamplesWritten, - this.ringBuffer.length, + 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; - // Read the most recent `available` samples from the ring buffer - const readStart = - (this.writePos - available + this.ringBuffer.length) % - this.ringBuffer.length; - - if (readStart + available <= this.ringBuffer.length) { - out.set(this.ringBuffer.subarray(readStart, readStart + available)); - } else { - const firstChunk = this.ringBuffer.length - readStart; - out.set(this.ringBuffer.subarray(readStart, this.ringBuffer.length)); - out.set(this.ringBuffer.subarray(0, available - firstChunk), firstChunk); + const ringLen = this.ringBuffer.length; + for (let i = 0; i < available; i++) { + out[i] = this.ringBuffer[(startSample + i) % ringLen]; } return available; @@ -255,25 +297,3 @@ export class AudioStreamReader { } } } - -/** - * Build an ffmpeg atempo filter chain for a given speed. - * atempo only accepts values in [0.5, 100.0], so we chain - * multiple filters for extreme values (e.g. 0.25 = atempo=0.5,atempo=0.5). - */ -function buildAtempoChain(speed: number): string { - const parts: string[] = []; - let remaining = Math.max(0.25, Math.min(4, speed)); - - while (remaining > 100) { - parts.push("atempo=100.0"); - remaining /= 100; - } - while (remaining < 0.5) { - parts.push("atempo=0.5"); - remaining /= 0.5; - } - parts.push(`atempo=${remaining}`); - - return parts.join(","); -} diff --git a/tests/audio-stream-reader.test.ts b/tests/audio-stream-reader.test.ts new file mode 100644 index 0000000..456699d --- /dev/null +++ b/tests/audio-stream-reader.test.ts @@ -0,0 +1,172 @@ +/** + * 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(); + } + }, +);