mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat: add audio engine — Opus encoder, play queue with 4 modes, FFmpeg player
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
d66355a829
commit
5afc8c5224
7 files changed
+958
No files matched your search
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createOpusEncoder } from "./encoder.js";
|
||||
|
||||
describe("OpusEncoder", () => {
|
||||
it("encodes PCM buffer to Opus frame", () => {
|
||||
const encoder = createOpusEncoder();
|
||||
// 20ms of silence at 48kHz stereo = 960 frames * 2 channels * 2 bytes = 3840 bytes
|
||||
const silence = Buffer.alloc(3840, 0);
|
||||
const opus = encoder.encode(silence);
|
||||
expect(opus).toBeInstanceOf(Buffer);
|
||||
expect(opus.length).toBeGreaterThan(0);
|
||||
expect(opus.length).toBeLessThan(3840);
|
||||
});
|
||||
|
||||
it("decodes Opus frame back to PCM", () => {
|
||||
const encoder = createOpusEncoder();
|
||||
const silence = Buffer.alloc(3840, 0);
|
||||
const opus = encoder.encode(silence);
|
||||
const pcm = encoder.decode(opus);
|
||||
expect(pcm).toBeInstanceOf(Buffer);
|
||||
expect(pcm.length).toBe(3840);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { OpusEncoder } from "@discordjs/opus";
|
||||
|
||||
const SAMPLE_RATE = 48000;
|
||||
const CHANNELS = 2;
|
||||
const FRAME_DURATION_MS = 20;
|
||||
export const FRAME_SIZE = (SAMPLE_RATE * FRAME_DURATION_MS) / 1000; // 960 samples
|
||||
export const PCM_FRAME_BYTES = FRAME_SIZE * CHANNELS * 2; // 3840 bytes (16-bit stereo)
|
||||
|
||||
export interface Encoder {
|
||||
encode(pcm: Buffer): Buffer;
|
||||
decode(opus: Buffer): Buffer;
|
||||
}
|
||||
|
||||
export function createOpusEncoder(): Encoder {
|
||||
const opus = new OpusEncoder(SAMPLE_RATE, CHANNELS);
|
||||
|
||||
return {
|
||||
encode(pcm: Buffer): Buffer {
|
||||
return opus.encode(pcm);
|
||||
},
|
||||
decode(opusData: Buffer): Buffer {
|
||||
return opus.decode(opusData);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { createOpusEncoder, PCM_FRAME_BYTES, type Encoder } from "./encoder.js";
|
||||
import type pino from "pino";
|
||||
|
||||
export type PlayerState = "idle" | "playing" | "paused";
|
||||
|
||||
export class AudioPlayer extends EventEmitter {
|
||||
private ffmpeg: ChildProcess | null = null;
|
||||
private encoder: Encoder;
|
||||
private state: PlayerState = "idle";
|
||||
private volume = 75; // 0-100
|
||||
private frameTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private pcmBuffer: Buffer = Buffer.alloc(0);
|
||||
private logger: pino.Logger;
|
||||
|
||||
constructor(logger: pino.Logger) {
|
||||
super();
|
||||
this.encoder = createOpusEncoder();
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
play(url: string): void {
|
||||
this.stop();
|
||||
|
||||
this.logger.info({ url }, "Starting playback");
|
||||
|
||||
this.ffmpeg = spawn(
|
||||
"ffmpeg",
|
||||
[
|
||||
"-reconnect", "1",
|
||||
"-reconnect_streamed", "1",
|
||||
"-reconnect_delay_max", "5",
|
||||
"-i", url,
|
||||
"-f", "s16le",
|
||||
"-ar", "48000",
|
||||
"-ac", "2",
|
||||
"-af", `volume=${this.volume / 100}`,
|
||||
"-",
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "ignore"] }
|
||||
);
|
||||
|
||||
this.ffmpeg.stdout!.on("data", (chunk: Buffer) => {
|
||||
this.pcmBuffer = Buffer.concat([this.pcmBuffer, chunk]);
|
||||
});
|
||||
|
||||
this.ffmpeg.on("close", (code) => {
|
||||
this.logger.debug({ code }, "FFmpeg process closed");
|
||||
if (this.state === "playing") {
|
||||
this.flushRemainingFrames();
|
||||
this.state = "idle";
|
||||
this.emit("trackEnd");
|
||||
}
|
||||
});
|
||||
|
||||
this.ffmpeg.on("error", (err) => {
|
||||
this.logger.error({ err }, "FFmpeg error");
|
||||
this.emit("error", err);
|
||||
});
|
||||
|
||||
this.state = "playing";
|
||||
|
||||
this.frameTimer = setInterval(() => {
|
||||
if (this.state !== "playing") return;
|
||||
this.sendNextFrame();
|
||||
}, 20);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const opusFrame = this.encoder.encode(Buffer.from(pcmFrame));
|
||||
this.emit("frame", opusFrame);
|
||||
}
|
||||
|
||||
private flushRemainingFrames(): void {
|
||||
while (this.pcmBuffer.length >= PCM_FRAME_BYTES) {
|
||||
this.sendNextFrame();
|
||||
}
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (this.state === "playing") {
|
||||
this.state = "paused";
|
||||
this.logger.debug("Playback paused");
|
||||
}
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (this.state === "paused") {
|
||||
this.state = "playing";
|
||||
this.logger.debug("Playback resumed");
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.frameTimer) {
|
||||
clearInterval(this.frameTimer);
|
||||
this.frameTimer = null;
|
||||
}
|
||||
if (this.ffmpeg) {
|
||||
this.ffmpeg.kill("SIGTERM");
|
||||
this.ffmpeg = null;
|
||||
}
|
||||
this.pcmBuffer = Buffer.alloc(0);
|
||||
this.state = "idle";
|
||||
}
|
||||
|
||||
setVolume(vol: number): void {
|
||||
this.volume = Math.max(0, Math.min(100, vol));
|
||||
}
|
||||
|
||||
getVolume(): number {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
getState(): PlayerState {
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { PlayQueue, type QueuedSong, PlayMode } from "./queue.js";
|
||||
|
||||
function makeSong(id: string, name: string = id): QueuedSong {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
artist: "Artist",
|
||||
album: "Album",
|
||||
platform: "netease",
|
||||
url: `https://example.com/${id}.mp3`,
|
||||
coverUrl: `https://example.com/${id}.jpg`,
|
||||
duration: 240,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PlayQueue", () => {
|
||||
let queue: PlayQueue;
|
||||
|
||||
beforeEach(() => {
|
||||
queue = new PlayQueue();
|
||||
});
|
||||
|
||||
it("starts empty", () => {
|
||||
expect(queue.isEmpty()).toBe(true);
|
||||
expect(queue.current()).toBeNull();
|
||||
expect(queue.size()).toBe(0);
|
||||
});
|
||||
|
||||
it("adds and retrieves songs", () => {
|
||||
queue.add(makeSong("1", "Song A"));
|
||||
queue.add(makeSong("2", "Song B"));
|
||||
expect(queue.size()).toBe(2);
|
||||
expect(queue.list()[0].name).toBe("Song A");
|
||||
expect(queue.list()[1].name).toBe("Song B");
|
||||
});
|
||||
|
||||
it("plays first song when starting", () => {
|
||||
queue.add(makeSong("1"));
|
||||
queue.add(makeSong("2"));
|
||||
queue.play();
|
||||
expect(queue.current()?.id).toBe("1");
|
||||
});
|
||||
|
||||
it("advances to next song in sequential mode", () => {
|
||||
queue.setMode(PlayMode.Sequential);
|
||||
queue.add(makeSong("1"));
|
||||
queue.add(makeSong("2"));
|
||||
queue.add(makeSong("3"));
|
||||
queue.play();
|
||||
expect(queue.current()?.id).toBe("1");
|
||||
const next = queue.next();
|
||||
expect(next?.id).toBe("2");
|
||||
expect(queue.current()?.id).toBe("2");
|
||||
});
|
||||
|
||||
it("returns null at end in sequential mode", () => {
|
||||
queue.setMode(PlayMode.Sequential);
|
||||
queue.add(makeSong("1"));
|
||||
queue.play();
|
||||
const next = queue.next();
|
||||
expect(next).toBeNull();
|
||||
});
|
||||
|
||||
it("loops in loop mode", () => {
|
||||
queue.setMode(PlayMode.Loop);
|
||||
queue.add(makeSong("1"));
|
||||
queue.play();
|
||||
const next = queue.next();
|
||||
expect(next?.id).toBe("1");
|
||||
});
|
||||
|
||||
it("goes to previous song", () => {
|
||||
queue.add(makeSong("1"));
|
||||
queue.add(makeSong("2"));
|
||||
queue.play();
|
||||
queue.next();
|
||||
expect(queue.current()?.id).toBe("2");
|
||||
queue.prev();
|
||||
expect(queue.current()?.id).toBe("1");
|
||||
});
|
||||
|
||||
it("removes song by index", () => {
|
||||
queue.add(makeSong("1"));
|
||||
queue.add(makeSong("2"));
|
||||
queue.add(makeSong("3"));
|
||||
queue.remove(1);
|
||||
expect(queue.size()).toBe(2);
|
||||
expect(queue.list()[1].id).toBe("3");
|
||||
});
|
||||
|
||||
it("clears all songs", () => {
|
||||
queue.add(makeSong("1"));
|
||||
queue.add(makeSong("2"));
|
||||
queue.clear();
|
||||
expect(queue.isEmpty()).toBe(true);
|
||||
expect(queue.current()).toBeNull();
|
||||
});
|
||||
|
||||
it("random mode returns a song", () => {
|
||||
queue.setMode(PlayMode.Random);
|
||||
queue.add(makeSong("1"));
|
||||
queue.add(makeSong("2"));
|
||||
queue.add(makeSong("3"));
|
||||
queue.play();
|
||||
const next = queue.next();
|
||||
expect(next).not.toBeNull();
|
||||
});
|
||||
|
||||
it("random-loop mode never returns null", () => {
|
||||
queue.setMode(PlayMode.RandomLoop);
|
||||
queue.add(makeSong("1"));
|
||||
queue.play();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(queue.next()).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("playAt jumps to specific index", () => {
|
||||
queue.add(makeSong("1"));
|
||||
queue.add(makeSong("2"));
|
||||
queue.add(makeSong("3"));
|
||||
queue.playAt(2);
|
||||
expect(queue.current()?.id).toBe("3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
export enum PlayMode {
|
||||
Sequential = "seq",
|
||||
Loop = "loop",
|
||||
Random = "random",
|
||||
RandomLoop = "rloop",
|
||||
}
|
||||
|
||||
export interface QueuedSong {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
platform: "netease" | "qq";
|
||||
url: string;
|
||||
coverUrl: string;
|
||||
duration: number; // seconds
|
||||
}
|
||||
|
||||
export class PlayQueue {
|
||||
private songs: QueuedSong[] = [];
|
||||
private currentIndex = -1;
|
||||
private mode: PlayMode = PlayMode.Sequential;
|
||||
|
||||
add(song: QueuedSong): void {
|
||||
this.songs.push(song);
|
||||
}
|
||||
|
||||
addMany(songs: QueuedSong[]): void {
|
||||
this.songs.push(...songs);
|
||||
}
|
||||
|
||||
remove(index: number): QueuedSong | null {
|
||||
if (index < 0 || index >= this.songs.length) return null;
|
||||
const [removed] = this.songs.splice(index, 1);
|
||||
|
||||
if (index < this.currentIndex) {
|
||||
this.currentIndex--;
|
||||
} else if (index === this.currentIndex) {
|
||||
if (this.currentIndex >= this.songs.length) {
|
||||
this.currentIndex = this.songs.length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.songs = [];
|
||||
this.currentIndex = -1;
|
||||
}
|
||||
|
||||
play(): QueuedSong | null {
|
||||
if (this.songs.length === 0) return null;
|
||||
this.currentIndex = 0;
|
||||
return this.songs[0];
|
||||
}
|
||||
|
||||
playAt(index: number): QueuedSong | null {
|
||||
if (index < 0 || index >= this.songs.length) return null;
|
||||
this.currentIndex = index;
|
||||
return this.songs[index];
|
||||
}
|
||||
|
||||
next(): QueuedSong | null {
|
||||
if (this.songs.length === 0) return null;
|
||||
|
||||
switch (this.mode) {
|
||||
case PlayMode.Sequential: {
|
||||
const nextIndex = this.currentIndex + 1;
|
||||
if (nextIndex >= this.songs.length) return null;
|
||||
this.currentIndex = nextIndex;
|
||||
return this.songs[nextIndex];
|
||||
}
|
||||
case PlayMode.Loop: {
|
||||
this.currentIndex = (this.currentIndex + 1) % this.songs.length;
|
||||
return this.songs[this.currentIndex];
|
||||
}
|
||||
case PlayMode.Random: {
|
||||
if (this.songs.length === 1) return this.songs[0];
|
||||
let nextIndex: number;
|
||||
do {
|
||||
nextIndex = Math.floor(Math.random() * this.songs.length);
|
||||
} while (nextIndex === this.currentIndex && this.songs.length > 1);
|
||||
this.currentIndex = nextIndex;
|
||||
return this.songs[nextIndex];
|
||||
}
|
||||
case PlayMode.RandomLoop: {
|
||||
if (this.songs.length === 1) {
|
||||
this.currentIndex = 0;
|
||||
return this.songs[0];
|
||||
}
|
||||
let idx: number;
|
||||
do {
|
||||
idx = Math.floor(Math.random() * this.songs.length);
|
||||
} while (idx === this.currentIndex);
|
||||
this.currentIndex = idx;
|
||||
return this.songs[idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prev(): QueuedSong | null {
|
||||
if (this.songs.length === 0) return null;
|
||||
const prevIndex = this.currentIndex - 1;
|
||||
if (prevIndex < 0) {
|
||||
this.currentIndex = this.songs.length - 1;
|
||||
} else {
|
||||
this.currentIndex = prevIndex;
|
||||
}
|
||||
return this.songs[this.currentIndex];
|
||||
}
|
||||
|
||||
current(): QueuedSong | null {
|
||||
if (this.currentIndex < 0 || this.currentIndex >= this.songs.length)
|
||||
return null;
|
||||
return this.songs[this.currentIndex];
|
||||
}
|
||||
|
||||
list(): QueuedSong[] {
|
||||
return [...this.songs];
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.songs.length;
|
||||
}
|
||||
|
||||
isEmpty(): boolean {
|
||||
return this.songs.length === 0;
|
||||
}
|
||||
|
||||
getMode(): PlayMode {
|
||||
return this.mode;
|
||||
}
|
||||
|
||||
setMode(mode: PlayMode): void {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
getCurrentIndex(): number {
|
||||
return this.currentIndex;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user