fix: replace setInterval with drift-correcting setTimeout for audio timing

- High-precision frame loop using performance.now() tracking
- PCM-level volume control (live adjustable, no FFmpeg restart)
- Proper buffer drain on track end
- Fixes audio quality and playback speed issues

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
saopig1andClaude Opus 4.6 committed 2026-03-30 02:01:16 +08:00
1 parent 9755ba2909
commit 6b725f43f9
1 file changed
+71 -33
+71 -33
View File
@@ -11,17 +11,17 @@ export interface PlayerEvents {
export type PlayerState = "idle" | "playing" | "paused"; export type PlayerState = "idle" | "playing" | "paused";
// ~5 seconds of 48kHz stereo 16-bit audio const FRAME_DURATION_MS = 20;
const PCM_HIGH_WATER_MARK = PCM_FRAME_BYTES * 250;
export class AudioPlayer extends EventEmitter { export class AudioPlayer extends EventEmitter {
private ffmpeg: ChildProcess | null = null; private ffmpeg: ChildProcess | null = null;
private encoder: Encoder; private encoder: Encoder;
private state: PlayerState = "idle"; private state: PlayerState = "idle";
private volume = 75; // 0-100 private volume = 75; // 0-100
private frameTimer: ReturnType<typeof setInterval> | null = null;
private pcmBuffer: Buffer = Buffer.alloc(0); private pcmBuffer: Buffer = Buffer.alloc(0);
private logger: Logger; private logger: Logger;
private frameLoopRunning = false;
private nextFrameTime = 0;
constructor(logger: Logger) { constructor(logger: Logger) {
super(); super();
@@ -32,7 +32,7 @@ export class AudioPlayer extends EventEmitter {
play(url: string): void { play(url: string): void {
this.stop(); this.stop();
this.logger.info({ url }, "Starting playback"); this.logger.info({ url: url.slice(0, 80) }, "Starting playback");
this.ffmpeg = spawn( this.ffmpeg = spawn(
"ffmpeg", "ffmpeg",
@@ -44,26 +44,24 @@ export class AudioPlayer extends EventEmitter {
"-f", "s16le", "-f", "s16le",
"-ar", "48000", "-ar", "48000",
"-ac", "2", "-ac", "2",
"-af", `volume=${this.volume / 100}`, "-acodec", "pcm_s16le",
"-", "-",
], ],
{ stdio: ["ignore", "pipe", "ignore"] } { stdio: ["ignore", "pipe", "pipe"] }
); );
this.ffmpeg.stderr!.on("data", () => {
// Suppress FFmpeg stderr output
});
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => { this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]); this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
// Backpressure: pause FFmpeg if buffer is too large
if (this.pcmBuffer.length > PCM_HIGH_WATER_MARK && this.ffmpeg?.stdout) {
this.ffmpeg.stdout.pause();
}
}); });
this.ffmpeg.on("close", (code) => { this.ffmpeg.on("close", (code) => {
this.logger.debug({ code }, "FFmpeg process closed"); this.logger.debug({ code }, "FFmpeg process closed");
if (this.state === "playing") { if (this.state === "playing" || this.state === "paused") {
this.flushRemainingFrames(); // Let the frame loop drain remaining frames, then it will emit trackEnd
this.state = "idle";
this.emit("trackEnd");
} }
}); });
@@ -73,11 +71,49 @@ export class AudioPlayer extends EventEmitter {
}); });
this.state = "playing"; this.state = "playing";
this.startFrameLoop();
}
this.frameTimer = setInterval(() => { /**
if (this.state !== "playing") return; * High-precision frame sending loop using drift-correcting setTimeout.
* Much more accurate than setInterval which drifts ~4-16ms on Windows.
*/
private startFrameLoop(): void {
if (this.frameLoopRunning) return;
this.frameLoopRunning = true;
this.nextFrameTime = performance.now();
this.scheduleNextFrame();
}
private scheduleNextFrame(): void {
if (!this.frameLoopRunning) return;
this.nextFrameTime += FRAME_DURATION_MS;
const now = performance.now();
const delay = Math.max(0, this.nextFrameTime - now);
setTimeout(() => {
if (!this.frameLoopRunning) return;
if (this.state === "playing") {
this.sendNextFrame(); this.sendNextFrame();
}, 20); } else if (this.state === "paused") {
// Keep the loop alive but adjust timing to avoid drift accumulation
this.nextFrameTime = performance.now();
}
// Check if we should stop (FFmpeg done + buffer empty)
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
this.frameLoopRunning = false;
if (this.state !== "idle") {
this.state = "idle";
this.emit("trackEnd");
}
return;
}
this.scheduleNextFrame();
}, delay);
} }
private sendNextFrame(): void { private sendNextFrame(): void {
@@ -86,22 +122,25 @@ export class AudioPlayer extends EventEmitter {
const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES); const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES);
this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES); this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES);
// Resume FFmpeg if buffer drained below threshold // Apply volume by scaling PCM samples directly
if ( const adjusted = this.applyVolume(pcmFrame);
this.pcmBuffer.length < PCM_HIGH_WATER_MARK / 2 && const opusFrame = this.encoder.encode(adjusted);
this.ffmpeg?.stdout?.isPaused()
) {
this.ffmpeg.stdout.resume();
}
const opusFrame = this.encoder.encode(Buffer.from(pcmFrame));
this.emit("frame", opusFrame); this.emit("frame", opusFrame);
} }
private flushRemainingFrames(): void { private applyVolume(pcm: Buffer): Buffer {
while (this.pcmBuffer.length >= PCM_FRAME_BYTES) { if (this.volume === 100) return Buffer.from(pcm);
this.sendNextFrame(); const factor = this.volume / 100;
const out = Buffer.alloc(pcm.length);
for (let i = 0; i < pcm.length; i += 2) {
let sample = pcm.readInt16LE(i);
sample = Math.round(sample * factor);
// Clamp to 16-bit range
if (sample > 32767) sample = 32767;
else if (sample < -32768) sample = -32768;
out.writeInt16LE(sample, i);
} }
return out;
} }
pause(): void { pause(): void {
@@ -114,15 +153,14 @@ export class AudioPlayer extends EventEmitter {
resume(): void { resume(): void {
if (this.state === "paused") { if (this.state === "paused") {
this.state = "playing"; this.state = "playing";
// Reset timing to avoid burst of frames after unpause
this.nextFrameTime = performance.now();
this.logger.debug("Playback resumed"); this.logger.debug("Playback resumed");
} }
} }
stop(): void { stop(): void {
if (this.frameTimer) { this.frameLoopRunning = false;
clearInterval(this.frameTimer);
this.frameTimer = null;
}
if (this.ffmpeg) { if (this.ffmpeg) {
this.ffmpeg.kill("SIGTERM"); this.ffmpeg.kill("SIGTERM");
this.ffmpeg = null; this.ffmpeg = null;