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:
saopig1andClaude Opus 4.6 committed 2026-03-30 00:34:57 +08:00
1 parent d66355a829
commit 5afc8c5224
7 files changed
+958

No files matched your search

+23
View File
@@ -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);
});
});
+25
View File
@@ -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);
},
};
}
+124
View File
@@ -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;
}
}
+126
View File
@@ -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");
});
});
+142
View File
@@ -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;
}
}