From 9e07a0a1f34f4b88359ed35a8d963f444e22f900 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Fri, 15 May 2026 17:17:17 +0700 Subject: [PATCH] feat: add ffmpeg music player Co-Authored-By: Claude Opus 4.7 --- src/media/musicPlayer.ts | 70 ++++++++++++++++++++++++++++++++ src/player.ts | 4 ++ tests/media/musicPlayer.test.ts | 72 +++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 src/media/musicPlayer.ts create mode 100644 tests/media/musicPlayer.test.ts diff --git a/src/media/musicPlayer.ts b/src/media/musicPlayer.ts new file mode 100644 index 0000000..70848d4 --- /dev/null +++ b/src/media/musicPlayer.ts @@ -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((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", + ]; +} diff --git a/src/player.ts b/src/player.ts index 648e235..44bffa5 100644 --- a/src/player.ts +++ b/src/player.ts @@ -29,6 +29,10 @@ export class DiscordPlayer { this.connection.subscribe(this.player); } + public isConnected(): boolean { + return this.connection !== null; + } + public playStream(stream: Readable) { console.log("[player] Starting new audio stream..."); diff --git a/tests/media/musicPlayer.test.ts b/tests/media/musicPlayer.test.ts new file mode 100644 index 0000000..7bb12bc --- /dev/null +++ b/tests/media/musicPlayer.test.ts @@ -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(); + }); +}); \ No newline at end of file