2026-05-15 17:17:17 +07:00
|
|
|
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
2026-05-15 18:04:39 +07:00
|
|
|
import { spawn as nodeSpawn } from "node:child_process";
|
2026-05-15 17:17:17 +07:00
|
|
|
import { discordPlayer } from "../player";
|
|
|
|
|
import type {
|
|
|
|
|
DiscordAudioPlayer,
|
|
|
|
|
MusicPlayback,
|
|
|
|
|
MusicPlayer,
|
|
|
|
|
ResolvedMediaSource,
|
|
|
|
|
} from "./mediaTypes";
|
|
|
|
|
|
|
|
|
|
export interface MusicPlayerDependencies {
|
|
|
|
|
spawn?: typeof nodeSpawn;
|
|
|
|
|
discordPlayer?: DiscordAudioPlayer;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function createMusicPlayer(
|
|
|
|
|
dependencies: MusicPlayerDependencies = {},
|
|
|
|
|
): MusicPlayer {
|
|
|
|
|
const spawn = dependencies.spawn ?? nodeSpawn;
|
|
|
|
|
const audioPlayer = dependencies.discordPlayer ?? discordPlayer;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
play(source: ResolvedMediaSource): MusicPlayback {
|
2026-05-15 17:23:36 +07:00
|
|
|
if (!audioPlayer.isConnected()) {
|
|
|
|
|
throw new Error("Discord audio player is not connected");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 17:17:17 +07:00
|
|
|
const proc = spawn("ffmpeg", buildFfmpegArgs(source.source), {
|
|
|
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
|
|
|
}) as unknown as ChildProcessWithoutNullStreams;
|
2026-05-15 17:23:36 +07:00
|
|
|
proc.stderr.resume();
|
2026-05-15 17:17:17 +07:00
|
|
|
|
2026-05-16 15:48:28 +07:00
|
|
|
audioPlayer.playStream(proc.stdout, "music");
|
2026-05-15 17:17:17 +07:00
|
|
|
|
2026-05-15 17:23:36 +07:00
|
|
|
let stopped = false;
|
2026-05-16 15:48:28 +07:00
|
|
|
let released = false;
|
|
|
|
|
const release = () => {
|
|
|
|
|
if (released) return;
|
|
|
|
|
released = true;
|
|
|
|
|
audioPlayer.stop("music");
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-15 17:17:17 +07:00
|
|
|
const done = new Promise<void>((resolve, reject) => {
|
2026-05-16 15:48:28 +07:00
|
|
|
proc.on("error", (error) => {
|
|
|
|
|
release();
|
|
|
|
|
reject(error);
|
|
|
|
|
});
|
|
|
|
|
proc.stdout.on("error", (error) => {
|
|
|
|
|
release();
|
|
|
|
|
reject(error);
|
|
|
|
|
});
|
2026-05-15 17:17:17 +07:00
|
|
|
proc.on("close", (code) => {
|
2026-05-16 15:48:28 +07:00
|
|
|
release();
|
2026-05-15 17:23:36 +07:00
|
|
|
if (code === 0 || stopped) {
|
2026-05-15 17:17:17 +07:00
|
|
|
resolve();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
reject(new Error(`ffmpeg exited with code ${code}`));
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
done,
|
|
|
|
|
stop() {
|
2026-05-15 17:23:36 +07:00
|
|
|
if (stopped) return;
|
|
|
|
|
stopped = true;
|
2026-05-15 17:17:17 +07:00
|
|
|
proc.kill("SIGTERM");
|
2026-05-16 15:48:28 +07:00
|
|
|
release();
|
2026-05-15 17:17:17 +07:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildFfmpegArgs(source: string): string[] {
|
|
|
|
|
return [
|
|
|
|
|
"-hide_banner",
|
|
|
|
|
"-loglevel",
|
|
|
|
|
"warning",
|
|
|
|
|
"-i",
|
|
|
|
|
source,
|
|
|
|
|
"-vn",
|
|
|
|
|
"-acodec",
|
|
|
|
|
"libopus",
|
|
|
|
|
"-ar",
|
|
|
|
|
"48000",
|
|
|
|
|
"-ac",
|
|
|
|
|
"2",
|
|
|
|
|
"-f",
|
|
|
|
|
"ogg",
|
|
|
|
|
"pipe:1",
|
|
|
|
|
];
|
|
|
|
|
}
|