feat: add ffmpeg music player

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-05-15 17:17:17 +07:00
parent acb43b6dac
commit 9e07a0a1f3
3 changed files with 146 additions and 0 deletions

70
src/media/musicPlayer.ts Normal file
View File

@@ -0,0 +1,70 @@
import { spawn as nodeSpawn } from "node:child_process";
import type { ChildProcessWithoutNullStreams } from "node:child_process";
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 {
const proc = spawn("ffmpeg", buildFfmpegArgs(source.source), {
stdio: ["ignore", "pipe", "pipe"],
}) as unknown as ChildProcessWithoutNullStreams;
audioPlayer.playStream(proc.stdout);
const done = new Promise<void>((resolve, reject) => {
proc.on("error", reject);
proc.on("close", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`ffmpeg exited with code ${code}`));
});
});
return {
done,
stop() {
proc.kill("SIGTERM");
audioPlayer.stop();
},
};
},
};
}
export function buildFfmpegArgs(source: string): string[] {
return [
"-hide_banner",
"-loglevel",
"warning",
"-i",
source,
"-vn",
"-acodec",
"libopus",
"-ar",
"48000",
"-ac",
"2",
"-f",
"ogg",
"pipe:1",
];
}

View File

@@ -29,6 +29,10 @@ export class DiscordPlayer {
this.connection.subscribe(this.player); this.connection.subscribe(this.player);
} }
public isConnected(): boolean {
return this.connection !== null;
}
public playStream(stream: Readable) { public playStream(stream: Readable) {
console.log("[player] Starting new audio stream..."); console.log("[player] Starting new audio stream...");

View File

@@ -0,0 +1,72 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import { createMusicPlayer } from "../../src/media/musicPlayer";
import type { DiscordAudioPlayer } from "../../src/media/mediaTypes";
class FakeProcess extends EventEmitter {
stdout = new PassThrough();
stderr = new PassThrough();
killed = false;
kill = vi.fn(() => {
this.killed = true;
this.emit("close", 0);
return true;
});
}
describe("createMusicPlayer", () => {
it("spawns ffmpeg as Ogg Opus and passes stdout to Discord", async () => {
const proc = new FakeProcess();
const spawn = vi.fn(() => proc);
const discordPlayer: DiscordAudioPlayer = {
isConnected: () => true,
playStream: vi.fn(),
stop: vi.fn(),
};
const player = createMusicPlayer({ spawn, discordPlayer });
const playback = player.play({
source: "https://example.com/song.mp3",
title: "song.mp3",
kind: "url",
});
proc.emit("close", 0);
await playback.done;
expect(spawn).toHaveBeenCalledWith("ffmpeg", [
"-hide_banner",
"-loglevel",
"warning",
"-i",
"https://example.com/song.mp3",
"-vn",
"-acodec",
"libopus",
"-ar",
"48000",
"-ac",
"2",
"-f",
"ogg",
"pipe:1",
], { stdio: ["ignore", "pipe", "pipe"] });
expect(discordPlayer.playStream).toHaveBeenCalledWith(proc.stdout);
});
it("kills ffmpeg and stops Discord playback", () => {
const proc = new FakeProcess();
const discordPlayer: DiscordAudioPlayer = {
isConnected: () => true,
playStream: vi.fn(),
stop: vi.fn(),
};
const player = createMusicPlayer({ spawn: vi.fn(() => proc), discordPlayer });
const playback = player.play({ source: "/tmp/song.ogg", title: "song.ogg", kind: "local" });
playback.stop();
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
expect(discordPlayer.stop).toHaveBeenCalled();
});
});