mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 13:02:49 +08:00
The ffmpeg-static npm package bundles a pre-compiled binary that passes `ffmpeg -version` but crashes with SIGSEGV during actual audio processing inside Docker containers (incompatible glibc/missing shared libraries). Changes: - Install system FFmpeg via apt-get in Docker production stage, which is always compatible with the container runtime - Reverse FFmpeg resolution priority in player.ts: prefer system FFmpeg, fall back to ffmpeg-static (for non-Docker environments like Windows) - Optimize Dockerfile multi-stage build: compile native modules (opus, better-sqlite3) in builder stage and copy to production, eliminating the need for build tools (python3, make, g++) in the production image - Fix qq-music-api dependency from file:../qq-music-api (path outside Docker build context, breaks npm ci) to npm registry ^2.2.10 https://claude.ai/code/session_01JAV8sBokoifKh4Hc8XbJws
379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
import { spawn, execSync, type ChildProcess } from "node:child_process";
|
|
import { EventEmitter } from "node:events";
|
|
import { createRequire } from "node:module";
|
|
import { accessSync, chmodSync, constants } from "node:fs";
|
|
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
|
|
import type { Logger } from "../logger.js";
|
|
|
|
// ffmpeg-static is a CJS module that exports the path to the bundled ffmpeg binary.
|
|
const require = createRequire(import.meta.url);
|
|
const ffmpegPath: string | null = require("ffmpeg-static");
|
|
|
|
/** Ensure the given binary has execute permission. */
|
|
function isExecutable(binPath: string): boolean {
|
|
try {
|
|
accessSync(binPath, constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
try {
|
|
chmodSync(binPath, 0o755);
|
|
accessSync(binPath, constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Test if an ffmpeg binary actually works by running -version. */
|
|
function ffmpegWorks(bin: string): boolean {
|
|
try {
|
|
execSync(`"${bin}" -version`, { timeout: 5000, stdio: "pipe" });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolved once at module load.
|
|
*
|
|
* Priority: system FFmpeg → bundled ffmpeg-static.
|
|
*
|
|
* System-installed FFmpeg is always compatible with the running OS/container,
|
|
* while the pre-compiled binary from ffmpeg-static can SIGSEGV in Docker
|
|
* (passes `ffmpeg -version` but crashes during actual audio processing due to
|
|
* incompatible glibc or missing shared libraries).
|
|
*/
|
|
const resolvedFfmpeg: string = (() => {
|
|
// 1. Prefer system-installed FFmpeg (always compatible with the runtime)
|
|
if (ffmpegWorks("ffmpeg")) {
|
|
return "ffmpeg";
|
|
}
|
|
|
|
// 2. Fall back to bundled ffmpeg-static binary
|
|
// On Windows, ffmpeg-static may return a path with backslashes; on Linux/macOS
|
|
// it may return a Windows .exe path if node_modules was copied cross-platform.
|
|
const isWinPath = ffmpegPath ? /\\/.test(ffmpegPath) || ffmpegPath.endsWith(".exe") : false;
|
|
const onWindows = process.platform === "win32";
|
|
|
|
if (ffmpegPath && (onWindows === isWinPath)) {
|
|
if (isExecutable(ffmpegPath) && ffmpegWorks(ffmpegPath)) {
|
|
return ffmpegPath;
|
|
}
|
|
}
|
|
|
|
// Last resort: always use "ffmpeg" so spawn error is clear, never use a cross-platform path
|
|
return "ffmpeg";
|
|
})();
|
|
|
|
/** Resolve ffmpeg binary: prefer system PATH, fall back to bundled ffmpeg-static. */
|
|
function getFfmpegCommand(): string {
|
|
return resolvedFfmpeg;
|
|
}
|
|
|
|
export interface PlayerEvents {
|
|
frame: (opusFrame: Buffer) => void;
|
|
trackEnd: () => void;
|
|
error: (err: Error) => void;
|
|
}
|
|
|
|
export type PlayerState = "idle" | "playing" | "paused";
|
|
|
|
const FRAME_DURATION_MS = 20;
|
|
|
|
export class AudioPlayer extends EventEmitter {
|
|
private ffmpeg: ChildProcess | null = null;
|
|
private encoder: Encoder;
|
|
private state: PlayerState = "idle";
|
|
private volume = 75;
|
|
private pcmBuffer: Buffer = Buffer.alloc(0);
|
|
private logger: Logger;
|
|
private frameLoopRunning = false;
|
|
private nextFrameTime = 0;
|
|
private currentUrl = "";
|
|
private seekOffset = 0;
|
|
private framesPlayed = 0; // ground truth: number of 20ms frames sent
|
|
private sessionId = 0;
|
|
private static readonly BUFFER_HIGH_WATER = 960 * 1024; // ~5s of PCM at 48kHz stereo
|
|
private static readonly BUFFER_LOW_WATER = 480 * 1024; // ~2.5s
|
|
private ffmpegPaused = false;
|
|
private spawnFailed = false; // true if ffmpeg spawn errored (prevent trackEnd cascade)
|
|
private consecutiveFailures = 0;
|
|
private static readonly MAX_CONSECUTIVE_FAILURES = 3;
|
|
|
|
constructor(logger: Logger) {
|
|
super();
|
|
this.encoder = createOpusEncoder();
|
|
this.logger = logger;
|
|
}
|
|
|
|
play(url: string, seekSeconds = 0): void {
|
|
this.stop();
|
|
this.sessionId++;
|
|
const playSessionId = this.sessionId;
|
|
this.currentUrl = url;
|
|
this.seekOffset = seekSeconds;
|
|
this.framesPlayed = 0;
|
|
this.ffmpegPaused = false;
|
|
this.spawnFailed = false;
|
|
|
|
// Prevent rapid-fire spawn attempts when ffmpeg is broken
|
|
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
|
|
this.logger.error(
|
|
{ failures: this.consecutiveFailures, ffmpeg: getFfmpegCommand() },
|
|
"Too many consecutive ffmpeg failures — ffmpeg binary may be missing or broken. Stopping playback."
|
|
);
|
|
this.state = "idle";
|
|
this.emit("error", new Error("ffmpeg unavailable after repeated failures"));
|
|
return;
|
|
}
|
|
|
|
this.logger.info({ url: url.slice(0, 80), seek: seekSeconds }, "Starting playback");
|
|
|
|
const args: string[] = [];
|
|
|
|
// BiliBili CDN requires Referer header for audio playback
|
|
if (url.includes("bilivideo") || url.includes("bilibili")) {
|
|
args.push(
|
|
"-headers",
|
|
"Referer: https://www.bilibili.com\r\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\r\n"
|
|
);
|
|
}
|
|
|
|
args.push(
|
|
"-reconnect", "1",
|
|
"-reconnect_streamed", "1",
|
|
"-reconnect_delay_max", "5",
|
|
);
|
|
if (seekSeconds > 0) {
|
|
args.push("-ss", String(seekSeconds));
|
|
}
|
|
args.push(
|
|
"-i", url,
|
|
"-f", "s16le",
|
|
"-ar", "48000",
|
|
"-ac", "2",
|
|
"-acodec", "pcm_s16le",
|
|
"-",
|
|
);
|
|
|
|
const ffmpegBin = getFfmpegCommand();
|
|
this.logger.info({ ffmpeg: ffmpegBin }, "Using ffmpeg binary");
|
|
this.ffmpeg = spawn(ffmpegBin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
|
|
// Prevent stream errors from crashing the process
|
|
this.ffmpeg.stdout!.on("error", (err) => {
|
|
this.logger.warn({ err }, "FFmpeg stdout error");
|
|
});
|
|
this.ffmpeg.stderr!.on("error", (err) => {
|
|
this.logger.warn({ err }, "FFmpeg stderr error");
|
|
});
|
|
|
|
let gotFirstData = false;
|
|
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
|
|
if (!gotFirstData) {
|
|
gotFirstData = true;
|
|
this.logger.info({ bytes: chunk.length }, "FFmpeg: first PCM data received");
|
|
}
|
|
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
|
|
// Backpressure: pause FFmpeg stdout when buffer is too large
|
|
if (this.pcmBuffer.length > AudioPlayer.BUFFER_HIGH_WATER && !this.ffmpegPaused && this.ffmpeg?.stdout) {
|
|
this.ffmpeg.stdout.pause();
|
|
this.ffmpegPaused = true;
|
|
}
|
|
});
|
|
|
|
this.ffmpeg.on("close", (code, signal) => {
|
|
this.logger.info({ exitCode: code, signal, gotData: gotFirstData, framesPlayed: this.framesPlayed }, "FFmpeg process closed");
|
|
if (this.sessionId === playSessionId) {
|
|
this.ffmpeg = null; // Signal frame loop that no more data is coming
|
|
}
|
|
});
|
|
|
|
this.ffmpeg.on("error", (err) => {
|
|
this.logger.error({ err }, "FFmpeg error");
|
|
if (this.sessionId === playSessionId) {
|
|
this.spawnFailed = true;
|
|
this.consecutiveFailures++;
|
|
this.emit("error", err);
|
|
}
|
|
});
|
|
|
|
// Log FFmpeg stderr at info level for debugging playback issues
|
|
this.ffmpeg.stderr!.on("data", (data: Buffer) => {
|
|
const msg = data.toString().trimEnd();
|
|
// Log important FFmpeg messages at info level
|
|
if (msg.includes("Error") || msg.includes("error") || msg.includes("HTTP") || msg.includes("Opening") || msg.includes("Stream")) {
|
|
this.logger.info({ ffmpegStderr: msg }, "FFmpeg stderr");
|
|
} else {
|
|
this.logger.debug({ stderr: msg }, "FFmpeg stderr");
|
|
}
|
|
});
|
|
|
|
this.state = "playing";
|
|
this.startFrameLoop();
|
|
}
|
|
|
|
private startFrameLoop(): void {
|
|
if (this.frameLoopRunning) return;
|
|
this.frameLoopRunning = true;
|
|
this.nextFrameTime = performance.now();
|
|
this.scheduleNextFrame();
|
|
}
|
|
|
|
private scheduleNextFrame(): void {
|
|
if (!this.frameLoopRunning) return;
|
|
|
|
const loopSessionId = this.sessionId;
|
|
|
|
this.nextFrameTime += FRAME_DURATION_MS;
|
|
const now = performance.now();
|
|
const delay = Math.max(0, this.nextFrameTime - now);
|
|
|
|
setTimeout(() => {
|
|
// Discard callback from a stale play session
|
|
if (loopSessionId !== this.sessionId) return;
|
|
if (!this.frameLoopRunning) return;
|
|
|
|
if (this.state === "playing") {
|
|
this.sendNextFrame();
|
|
} else if (this.state === "paused") {
|
|
this.nextFrameTime = performance.now();
|
|
}
|
|
|
|
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
|
this.frameLoopRunning = false;
|
|
if (this.state !== "idle") {
|
|
this.state = "idle";
|
|
// Don't emit trackEnd if ffmpeg spawn failed — prevents infinite retry cascade
|
|
if (this.spawnFailed) {
|
|
this.logger.warn("Suppressing trackEnd due to ffmpeg spawn failure");
|
|
} else {
|
|
this.consecutiveFailures = 0; // Reset on successful track completion
|
|
this.emit("trackEnd");
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.scheduleNextFrame();
|
|
}, delay);
|
|
}
|
|
|
|
private sendNextFrame(): void {
|
|
if (this.pcmBuffer.length < PCM_FRAME_BYTES) return;
|
|
|
|
const pcmFrame = this.pcmBuffer.subarray(0, PCM_FRAME_BYTES);
|
|
this.pcmBuffer = this.pcmBuffer.subarray(PCM_FRAME_BYTES);
|
|
|
|
// Backpressure: resume FFmpeg stdout when buffer drains below low-water mark
|
|
if (this.ffmpegPaused && this.pcmBuffer.length < AudioPlayer.BUFFER_LOW_WATER && this.ffmpeg?.stdout) {
|
|
this.ffmpeg.stdout.resume();
|
|
this.ffmpegPaused = false;
|
|
}
|
|
|
|
try {
|
|
const adjusted = this.applyVolume(pcmFrame);
|
|
const opusFrame = this.encoder.encode(adjusted);
|
|
this.emit("frame", opusFrame);
|
|
this.framesPlayed++;
|
|
|
|
if (this.framesPlayed === 1) {
|
|
this.logger.info({ opusBytes: opusFrame.length }, "First audio frame encoded and emitted");
|
|
}
|
|
// Log every ~10 seconds (500 frames * 20ms = 10s)
|
|
if (this.framesPlayed % 500 === 0) {
|
|
this.logger.debug({ framesPlayed: this.framesPlayed, elapsed: this.getElapsed() }, "Playback progress");
|
|
}
|
|
} catch (err) {
|
|
this.logger.error({ err }, "Error encoding/sending audio frame");
|
|
this.emit("error", err as Error);
|
|
}
|
|
}
|
|
|
|
private applyVolume(pcm: Buffer): Buffer {
|
|
if (this.volume === 100) return Buffer.from(pcm);
|
|
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);
|
|
if (sample > 32767) sample = 32767;
|
|
else if (sample < -32768) sample = -32768;
|
|
out.writeInt16LE(sample, i);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Actual elapsed time in seconds (ground truth from frame count) */
|
|
getElapsed(): number {
|
|
return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000;
|
|
}
|
|
|
|
seek(seconds: number): void {
|
|
if (!this.currentUrl) return;
|
|
// Reject NaN/Infinity/negative — the HTTP layer validates too, but a
|
|
// bad value here would poison seekOffset and leave getElapsed()
|
|
// returning NaN until the track ends.
|
|
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
this.logger.warn({ seek: seconds }, "Ignoring invalid seek position");
|
|
return;
|
|
}
|
|
this.logger.info({ seek: seconds }, "Seeking");
|
|
this.play(this.currentUrl, seconds);
|
|
}
|
|
|
|
getSeekOffset(): number {
|
|
return this.seekOffset;
|
|
}
|
|
|
|
pause(): void {
|
|
if (this.state === "playing") {
|
|
this.state = "paused";
|
|
this.logger.debug("Playback paused");
|
|
}
|
|
}
|
|
|
|
resume(): void {
|
|
if (this.state === "paused") {
|
|
this.state = "playing";
|
|
this.nextFrameTime = performance.now();
|
|
this.logger.debug("Playback resumed");
|
|
}
|
|
}
|
|
|
|
stop(): void {
|
|
this.sessionId++;
|
|
this.frameLoopRunning = false;
|
|
if (this.ffmpeg) {
|
|
this.ffmpeg.kill("SIGTERM");
|
|
this.ffmpeg = null;
|
|
}
|
|
this.pcmBuffer = Buffer.alloc(0);
|
|
this.ffmpegPaused = false;
|
|
this.spawnFailed = false;
|
|
this.state = "idle";
|
|
this.currentUrl = "";
|
|
this.seekOffset = 0;
|
|
this.framesPlayed = 0;
|
|
}
|
|
|
|
/** Reset the consecutive failure counter (e.g. after user action) */
|
|
resetFailures(): void {
|
|
this.consecutiveFailures = 0;
|
|
}
|
|
|
|
setVolume(vol: number): void {
|
|
this.volume = Math.max(0, Math.min(100, vol));
|
|
}
|
|
|
|
getVolume(): number {
|
|
return this.volume;
|
|
}
|
|
|
|
getState(): PlayerState {
|
|
return this.state;
|
|
}
|
|
}
|