feat: sync waveform to live player position (mpv time-pos, position-window reads, smooth clock)
This commit is contained in:
@@ -122,17 +122,21 @@ function registerExitTeardown(): void {
|
|||||||
function startPolling(): void {
|
function startPolling(): void {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
pollCount = 0;
|
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 () => {
|
pollTimer = setInterval(async () => {
|
||||||
if (!backend || !isPlaying()) return;
|
if (!backend || !isPlaying() || pollInFlight) return;
|
||||||
|
pollInFlight = true;
|
||||||
try {
|
try {
|
||||||
const pos = await backend.getPosition();
|
const pos = await backend.getPosition();
|
||||||
const dur = await backend.getDuration();
|
const dur = await backend.getDuration();
|
||||||
setPosition(pos);
|
setPosition(pos);
|
||||||
if (dur > 0) setDuration(dur);
|
if (dur > 0) setDuration(dur);
|
||||||
|
|
||||||
// Save progress every ~5 seconds (10 ticks * 500ms)
|
// Save progress every ~5 seconds (33 ticks * 150ms)
|
||||||
pollCount++;
|
pollCount++;
|
||||||
if (pollCount % 10 === 0) {
|
if (pollCount % 33 === 0) {
|
||||||
const ep = currentEpisode();
|
const ep = currentEpisode();
|
||||||
if (ep) {
|
if (ep) {
|
||||||
const progressStore = useProgressStore();
|
const progressStore = useProgressStore();
|
||||||
@@ -156,8 +160,10 @@ function startPolling(): void {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Backend may have been disposed
|
// Backend may have been disposed
|
||||||
|
} finally {
|
||||||
|
pollInFlight = false;
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 150);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPolling(): void {
|
function stopPolling(): void {
|
||||||
|
|||||||
@@ -88,6 +88,29 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
return true;
|
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 ──────────────────────────
|
// ── Start/stop the visualization pipeline ──────────────────────────
|
||||||
|
|
||||||
const startVisualization = (url: string, position: number, speed: number) => {
|
const startVisualization = (url: string, position: number, speed: number) => {
|
||||||
@@ -139,7 +162,11 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
const renderFrame = () => {
|
const renderFrame = () => {
|
||||||
if (!cava?.isReady || !reader?.running || !sampleBuffer) return;
|
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;
|
if (count === 0) return;
|
||||||
|
|
||||||
const input =
|
const input =
|
||||||
@@ -212,7 +239,7 @@ export function RealtimeWaveform(props: RealtimeWaveformProps) {
|
|||||||
const playedRatio = () =>
|
const playedRatio = () =>
|
||||||
audio.duration() <= 0
|
audio.duration() <= 0
|
||||||
? 0
|
? 0
|
||||||
: Math.min(1, audio.position() / audio.duration());
|
: Math.min(1, smoothPosition() / audio.duration());
|
||||||
|
|
||||||
const renderLine = () => {
|
const renderLine = () => {
|
||||||
const bars = barData();
|
const bars = barData();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { platform } from "os";
|
|||||||
import { existsSync } from "fs";
|
import { existsSync } from "fs";
|
||||||
import { tmpdir } from "os";
|
import { tmpdir } from "os";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
|
import type { Socket, Subprocess } from "bun";
|
||||||
|
|
||||||
// ── Types ────────────────────────────────────────────────────────────
|
// ── Types ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -77,14 +78,13 @@ function mpvSocketPath(): string {
|
|||||||
|
|
||||||
export class MpvBackend implements AudioBackend {
|
export class MpvBackend implements AudioBackend {
|
||||||
readonly name: BackendName = "mpv";
|
readonly name: BackendName = "mpv";
|
||||||
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
private proc: Subprocess | null = null;
|
||||||
private socketPath = mpvSocketPath();
|
private socketPath = mpvSocketPath();
|
||||||
private _playing = false;
|
private _playing = false;
|
||||||
private _position = 0;
|
private _position = 0;
|
||||||
private _duration = 0;
|
private _duration = 0;
|
||||||
private _volume = 100;
|
private _volume = 100;
|
||||||
private _speed = 1;
|
private _speed = 1;
|
||||||
private pollTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
async play(url: string, opts?: PlayOptions): Promise<void> {
|
async play(url: string, opts?: PlayOptions): Promise<void> {
|
||||||
await this.stop();
|
await this.stop();
|
||||||
@@ -129,14 +129,13 @@ export class MpvBackend implements AudioBackend {
|
|||||||
// Wait for socket to appear (mpv creates it async)
|
// Wait for socket to appear (mpv creates it async)
|
||||||
await this.waitForSocket(2000);
|
await this.waitForSocket(2000);
|
||||||
|
|
||||||
// Start polling position
|
// Position is fetched live from mpv on each getPosition() call (see
|
||||||
this.startPolling();
|
// below) — the UI polls it, so no internal poll timer is needed.
|
||||||
|
|
||||||
// Detect process exit
|
// Detect process exit
|
||||||
this.proc.exited
|
this.proc.exited
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this._playing = false;
|
this._playing = false;
|
||||||
this.stopPolling();
|
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -149,79 +148,6 @@ export class MpvBackend implements AudioBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ipc(command: unknown[]): Promise<unknown> {
|
|
||||||
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<string>((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<unknown> {
|
|
||||||
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) */
|
/** Send a fire-and-forget command (no response needed) */
|
||||||
private async send(command: unknown[]): Promise<void> {
|
private async send(command: unknown[]): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -246,65 +172,85 @@ export class MpvBackend implements AudioBackend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a property value from mpv via IPC */
|
/**
|
||||||
private async getProperty(name: string): Promise<number> {
|
* 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<number | undefined> {
|
||||||
try {
|
try {
|
||||||
return await new Promise<number>((resolve) => {
|
return await new Promise<number | undefined>((resolve) => {
|
||||||
let result = 0;
|
let settled = false;
|
||||||
const timeout = setTimeout(() => resolve(result), 300);
|
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({
|
Bun.connect({
|
||||||
unix: this.socketPath,
|
unix: this.socketPath,
|
||||||
socket: {
|
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) {
|
open(socket) {
|
||||||
|
sock = socket;
|
||||||
socket.write(
|
socket.write(
|
||||||
JSON.stringify({ command: ["get_property", name] }) + "\n",
|
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(() => {
|
}).catch(() => done(undefined));
|
||||||
clearTimeout(timeout);
|
|
||||||
resolve(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return undefined;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +265,6 @@ export class MpvBackend implements AudioBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
async stop(): Promise<void> {
|
||||||
this.stopPolling();
|
|
||||||
if (this.proc) {
|
if (this.proc) {
|
||||||
try {
|
try {
|
||||||
this.proc.kill();
|
this.proc.kill();
|
||||||
@@ -359,12 +304,20 @@ export class MpvBackend implements AudioBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getPosition(): Promise<number> {
|
async getPosition(): Promise<number> {
|
||||||
|
// Live-fetch `time-pos` so the position clock is as fresh as the
|
||||||
|
// UI's poll rate (the hook polls this at ~150ms). On a transient IPC
|
||||||
|
// failure, keep the last known value rather than returning 0.
|
||||||
|
if (this._playing && this.proc) {
|
||||||
|
const pos = await this.getProperty("time-pos");
|
||||||
|
if (pos !== undefined) this._position = pos;
|
||||||
|
}
|
||||||
return this._position;
|
return this._position;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDuration(): Promise<number> {
|
async getDuration(): Promise<number> {
|
||||||
if (this._duration <= 0) {
|
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;
|
return this._duration;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@
|
|||||||
* Spawns a separate ffmpeg process that decodes the same audio URL
|
* 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,
|
* 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
|
* 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
|
* 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 <speed>`) and sampling the window at
|
||||||
|
* the position the player reports, never at the decode head.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** PCM output format constants */
|
/** PCM output format constants */
|
||||||
@@ -15,8 +18,14 @@ const SAMPLE_RATE = 44100;
|
|||||||
const CHANNELS = 1;
|
const CHANNELS = 1;
|
||||||
const BYTES_PER_SAMPLE = 2; // s16le
|
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 {
|
export interface AudioStreamReaderOptions {
|
||||||
/** Audio URL or file path to decode */
|
/** Audio URL or file path to decode */
|
||||||
@@ -32,11 +41,14 @@ export interface AudioStreamReaderOptions {
|
|||||||
*/
|
*/
|
||||||
let globalGeneration = 0;
|
let globalGeneration = 0;
|
||||||
|
|
||||||
|
import type { Subprocess } from "bun";
|
||||||
|
|
||||||
export class AudioStreamReader {
|
export class AudioStreamReader {
|
||||||
private proc: ReturnType<typeof Bun.spawn> | null = null;
|
private proc: Subprocess | null = null;
|
||||||
private ringBuffer: Float64Array;
|
private ringBuffer: Float64Array;
|
||||||
private writePos = 0;
|
private writePos = 0;
|
||||||
private totalSamplesWritten = 0;
|
private totalSamplesWritten = 0;
|
||||||
|
private startPosition = 0;
|
||||||
private _running = false;
|
private _running = false;
|
||||||
private generation = 0;
|
private generation = 0;
|
||||||
readonly url: string;
|
readonly url: string;
|
||||||
@@ -81,25 +93,41 @@ export class AudioStreamReader {
|
|||||||
// Increment generation so any lingering read loop from a previous
|
// Increment generation so any lingering read loop from a previous
|
||||||
// start() will see a mismatch and exit.
|
// start() will see a mismatch and exit.
|
||||||
this.generation = ++globalGeneration;
|
this.generation = ++globalGeneration;
|
||||||
|
this.startPosition = Math.max(0, startPosition);
|
||||||
|
|
||||||
|
const readRate = Math.max(0.25, speed > 0 ? speed : 1);
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"-loglevel",
|
"-loglevel",
|
||||||
"quiet",
|
"quiet",
|
||||||
// Read input at native frame rate so decoded PCM stays in sync with
|
// Pace input at the player's advance rate (speed× native) rather
|
||||||
// real-time playback. Without -re, ffmpeg greedily decodes the whole
|
// than native rate. Decoding slower than the player makes the
|
||||||
// file as fast as possible: the ring buffer fills with audio seconds
|
// decoded position fall behind the playback position linearly
|
||||||
// ahead of the player (laggy bars), then the process exits when it
|
// (bars drift away at (speed-1)s per second); decoding unthrottled
|
||||||
// hits EOF (bars freeze ~10s in).
|
// fills the ring with audio seconds ahead of the player (laggy
|
||||||
"-re",
|
// bars) and hits EOF early (bars freeze). `-readrate speed` keeps
|
||||||
"-reconnect",
|
// the decode head just ahead of the position the renderer samples,
|
||||||
"1",
|
// tracking the player clock with only mpv's startup latency as a
|
||||||
"-reconnect_streamed",
|
// constant offset — absorbed by the ring buffer.
|
||||||
"1",
|
"-readrate",
|
||||||
"-reconnect_delay_max",
|
String(readRate),
|
||||||
"5",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// `-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
|
// Seek before input for network efficiency
|
||||||
if (startPosition > 0) {
|
if (startPosition > 0) {
|
||||||
args.push("-ss", String(startPosition));
|
args.push("-ss", String(startPosition));
|
||||||
@@ -107,12 +135,9 @@ export class AudioStreamReader {
|
|||||||
|
|
||||||
args.push("-i", this.url);
|
args.push("-i", this.url);
|
||||||
|
|
||||||
// Apply speed via atempo filter if not 1x.
|
// No atempo filter: the renderer samples the *source* audio at the
|
||||||
// ffmpeg atempo only supports 0.5–100.0; chain multiple for extremes.
|
// player's current position, so output samples map 1:1 to input time
|
||||||
if (speed !== 1 && speed > 0) {
|
// (stream index = (targetSeconds - startPosition) * sampleRate).
|
||||||
args.push("-af", buildAtempoChain(speed));
|
|
||||||
}
|
|
||||||
|
|
||||||
args.push(
|
args.push(
|
||||||
"-ac",
|
"-ac",
|
||||||
String(CHANNELS),
|
String(CHANNELS),
|
||||||
@@ -155,31 +180,48 @@ export class AudioStreamReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read available samples into the provided buffer.
|
* Read the visualization window ending at `targetSeconds` of playback.
|
||||||
* Returns the number of samples actually copied.
|
*
|
||||||
|
* 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 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`.
|
* @returns Number of samples written to `out`.
|
||||||
*/
|
*/
|
||||||
read(out: Float64Array): number {
|
read(out: Float64Array, targetSeconds: number): number {
|
||||||
const available = Math.min(
|
if (this.totalSamplesWritten <= 0 || out.length === 0) return 0;
|
||||||
out.length,
|
|
||||||
this.totalSamplesWritten,
|
const headSample = this.totalSamplesWritten - 1;
|
||||||
this.ringBuffer.length,
|
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;
|
if (available <= 0) return 0;
|
||||||
|
|
||||||
// Read the most recent `available` samples from the ring buffer
|
const ringLen = this.ringBuffer.length;
|
||||||
const readStart =
|
for (let i = 0; i < available; i++) {
|
||||||
(this.writePos - available + this.ringBuffer.length) %
|
out[i] = this.ringBuffer[(startSample + i) % ringLen];
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return available;
|
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(",");
|
|
||||||
}
|
|
||||||
|
|||||||
172
tests/audio-stream-reader.test.ts
Normal file
172
tests/audio-stream-reader.test.ts
Normal file
@@ -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 <speed>`), so
|
||||||
|
* the decode head keeps up with the position at any playback speed —
|
||||||
|
* native-rate pacing falls behind by (speed-1)s per second.
|
||||||
|
*
|
||||||
|
* Uses a self-generated WAV (440Hz sine, mono, 44.1kHz s16le) so the
|
||||||
|
* expected samples can be computed analytically and compared exactly.
|
||||||
|
*/
|
||||||
|
import { test, expect } from "bun:test";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import { AudioStreamReader } from "../src/utils/audio-stream-reader";
|
||||||
|
|
||||||
|
const SAMPLE_RATE = 44100;
|
||||||
|
const FREQ = 440;
|
||||||
|
const AMP = 30000;
|
||||||
|
|
||||||
|
/** Write a WAV file containing `seconds` of a 440Hz sine at AMP amplitude. */
|
||||||
|
function writeSineWav(path: string, seconds: number): void {
|
||||||
|
const total = Math.round(seconds * SAMPLE_RATE);
|
||||||
|
const dataSize = total * 2;
|
||||||
|
const buf = new Uint8Array(44 + dataSize);
|
||||||
|
const dv = new DataView(buf.buffer);
|
||||||
|
const ascii = (off: number, s: string) => {
|
||||||
|
for (let i = 0; i < s.length; i++) buf[off + i] = s.charCodeAt(i);
|
||||||
|
};
|
||||||
|
ascii(0, "RIFF");
|
||||||
|
dv.setUint32(4, 36 + dataSize, true);
|
||||||
|
ascii(8, "WAVE");
|
||||||
|
ascii(12, "fmt ");
|
||||||
|
dv.setUint32(16, 16, true);
|
||||||
|
dv.setUint16(20, 1, true); // PCM
|
||||||
|
dv.setUint16(22, 1, true); // mono
|
||||||
|
dv.setUint32(24, SAMPLE_RATE, true);
|
||||||
|
dv.setUint32(28, SAMPLE_RATE * 2, true);
|
||||||
|
dv.setUint16(32, 2, true);
|
||||||
|
dv.setUint16(34, 16, true);
|
||||||
|
ascii(36, "data");
|
||||||
|
dv.setUint32(40, dataSize, true);
|
||||||
|
for (let i = 0; i < total; i++) {
|
||||||
|
const v = Math.round(AMP * Math.sin((2 * Math.PI * FREQ * i) / SAMPLE_RATE));
|
||||||
|
dv.setInt16(44 + i * 2, v, true);
|
||||||
|
}
|
||||||
|
Bun.write(path, buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Analytic sample value at a file index, matching the writer's formula. */
|
||||||
|
function expectedAt(fileIndex: number): number {
|
||||||
|
return Math.round(AMP * Math.sin((2 * Math.PI * FREQ * fileIndex) / SAMPLE_RATE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Block until the reader's decode head has advanced past `samples` samples.
|
||||||
|
* The head advances at readrate × real time, so this bounds how long we wait.
|
||||||
|
*/
|
||||||
|
async function waitForHead(
|
||||||
|
reader: AudioStreamReader,
|
||||||
|
samples: number,
|
||||||
|
timeoutMs = 8000,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (reader.samplesWritten < samples) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error("reader decode head did not advance in time");
|
||||||
|
}
|
||||||
|
await Bun.sleep(25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasFfmpeg = !!Bun.which("ffmpeg");
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"read() serves the exact window at the requested position",
|
||||||
|
async () => {
|
||||||
|
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
|
||||||
|
writeSineWav(wav, 20);
|
||||||
|
const reader = new AudioStreamReader({ url: wav });
|
||||||
|
try {
|
||||||
|
reader.start(5, 1);
|
||||||
|
// Cover targets up to ~5.6s (head must pass the read target).
|
||||||
|
await waitForHead(reader, Math.round(0.6 * SAMPLE_RATE));
|
||||||
|
|
||||||
|
const out = new Float64Array(512);
|
||||||
|
|
||||||
|
// Window at 5.1s: the window ENDS at the target, so out[i] is at
|
||||||
|
// file index 5*SR + round((5.1-5)*SR) - (len-1) + i.
|
||||||
|
expect(reader.read(out, 5.1)).toBe(512);
|
||||||
|
for (let i = 0; i < 512; i++) {
|
||||||
|
const idx =
|
||||||
|
Math.round(5 * SAMPLE_RATE) +
|
||||||
|
Math.round((5.1 - 5) * SAMPLE_RATE) -
|
||||||
|
(out.length - 1) +
|
||||||
|
i;
|
||||||
|
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Window at 5.105s is the same stream shifted by exactly
|
||||||
|
// round(0.005*SR)=221 samples — pins that the target maps to a
|
||||||
|
// precise offset, not "whatever the decode head is at".
|
||||||
|
const later = new Float64Array(512);
|
||||||
|
expect(reader.read(later, 5.105)).toBe(512);
|
||||||
|
for (let i = 0; i <= 512 - 222; i++) {
|
||||||
|
expect(later[i]).toBe(out[i + 221]);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"decode keeps up with the player clock at 2x speed",
|
||||||
|
async () => {
|
||||||
|
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
|
||||||
|
writeSineWav(wav, 20);
|
||||||
|
const reader = new AudioStreamReader({ url: wav });
|
||||||
|
try {
|
||||||
|
reader.start(0, 2);
|
||||||
|
// At 2x pacing the head reaches 2.5s after ~1.25s of wall time.
|
||||||
|
// With native-rate pacing it would only be at ~1.25s, and the
|
||||||
|
// window at 2.5s would clamp to the head — content mismatch.
|
||||||
|
await waitForHead(reader, Math.round(2.5 * SAMPLE_RATE));
|
||||||
|
|
||||||
|
const out = new Float64Array(512);
|
||||||
|
expect(reader.read(out, 2.5)).toBe(512);
|
||||||
|
for (let i = 0; i < 512; i++) {
|
||||||
|
const idx =
|
||||||
|
Math.round(2.5 * SAMPLE_RATE) - (out.length - 1) + i;
|
||||||
|
expect(Math.abs(out[i] - expectedAt(idx))).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.skipIf(!hasFfmpeg)(
|
||||||
|
"read() clamps to the nearest samples when the target is beyond the head",
|
||||||
|
async () => {
|
||||||
|
const wav = join(tmpdir(), `podtui-reader-${process.pid}-${Date.now()}.wav`);
|
||||||
|
writeSineWav(wav, 20);
|
||||||
|
const reader = new AudioStreamReader({ url: wav });
|
||||||
|
try {
|
||||||
|
reader.start(0, 1);
|
||||||
|
await waitForHead(reader, Math.round(0.3 * SAMPLE_RATE));
|
||||||
|
|
||||||
|
// Target far beyond the decode head: serve the newest available
|
||||||
|
// window (real sine samples, never zeros or garbage).
|
||||||
|
const out = new Float64Array(512);
|
||||||
|
expect(reader.read(out, 999)).toBe(512);
|
||||||
|
const maxAbs = Math.max(...Array.from(out, Math.abs));
|
||||||
|
expect(maxAbs).toBeGreaterThan(10000);
|
||||||
|
for (const v of out) {
|
||||||
|
expect(Math.abs(v)).toBeLessThanOrEqual(AMP + 1);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.stop();
|
||||||
|
await Bun.$`rm -f ${wav}`.quiet();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user