feat: add ffmpeg music player
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
70
src/media/musicPlayer.ts
Normal file
70
src/media/musicPlayer.ts
Normal 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",
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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...");
|
||||||
|
|
||||||
|
|||||||
72
tests/media/musicPlayer.test.ts
Normal file
72
tests/media/musicPlayer.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user