Files
2026-03-30 00:21:55 +08:00

15 KiB

TSMusicBot Phase 3: Audio Engine

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the audio pipeline: FFmpeg decodes audio from a URL → PCM → Opus encoding → 20ms frames ready to send via TS3 voice. Plus a play queue with 4 modes (sequential, loop, random, random-loop).

Architecture: src/audio/encoder.ts wraps Opus encoding, src/audio/player.ts orchestrates FFmpeg → Opus → timed frame emission, src/audio/queue.ts manages the song queue with playback modes.

Tech Stack: FFmpeg (child_process), @discordjs/opus, vitest


Task 1: Opus encoder wrapper

Files:

  • Create: src/audio/encoder.ts

  • Create: src/audio/encoder.test.ts

  • Step 1: Install opus dependency

npm install @discordjs/opus
  • Step 2: Write the failing test

Create src/audio/encoder.test.ts:

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); // compressed should be smaller
  });

  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);
  });
});
  • Step 3: Run test to verify it fails

Run: npx vitest run src/audio/encoder.test.ts Expected: FAIL

  • Step 4: Write implementation

Create src/audio/encoder.ts:

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);
    },
  };
}
  • Step 5: Run test to verify it passes

Run: npx vitest run src/audio/encoder.test.ts Expected: 2 tests PASS

  • Step 6: Commit
git add src/audio/encoder.ts src/audio/encoder.test.ts package.json package-lock.json
git commit -m "feat: add Opus encoder/decoder wrapper"

Task 2: Play queue with modes

Files:

  • Create: src/audio/queue.ts

  • Create: src/audio/queue.test.ts

  • Step 1: Write the failing test

Create src/audio/queue.test.ts:

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'); // loops back
  });

  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); // remove index 1 ("2")
    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();
    // Should always return a song
    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');
  });
});
  • Step 2: Run test to verify it fails

Run: npx vitest run src/audio/queue.test.ts Expected: FAIL

  • Step 3: Write implementation

Create src/audio/queue.ts:

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);

    // Adjust current index if needed
    if (index < this.currentIndex) {
      this.currentIndex--;
    } else if (index === this.currentIndex) {
      // Current song was removed, keep index but it now points to next song
      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: {
        // Same as Random but never returns null
        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; // wrap to end
    } 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;
  }
}
  • Step 4: Run test to verify it passes

Run: npx vitest run src/audio/queue.test.ts Expected: 12 tests PASS

  • Step 5: Commit
git add src/audio/queue.ts src/audio/queue.test.ts
git commit -m "feat: add play queue with sequential, loop, random, and random-loop modes"

Task 3: Audio player (FFmpeg → Opus → timed frames)

Files:

  • Create: src/audio/player.ts

  • Step 1: Write implementation

Create src/audio/player.ts:

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 { Logger } from '../logger.js';

export interface PlayerEvents {
  frame: (opusFrame: Buffer) => void;
  trackEnd: () => void;
  error: (err: Error) => void;
}

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: Logger;

  constructor(logger: Logger) {
    super();
    this.encoder = createOpusEncoder();
    this.logger = logger;
  }

  /**
   * Start playing audio from a URL or file path.
   * FFmpeg decodes to raw PCM (48kHz, stereo, s16le).
   */
  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',        // raw PCM output
      '-ar', '48000',        // 48kHz sample rate
      '-ac', '2',            // stereo
      '-af', `volume=${this.volume / 100}`,
      '-',                   // output to stdout
    ], {
      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') {
        // Flush remaining frames
        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';

    // Send frames every 20ms
    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));
    // Volume change takes effect on next track.
    // For live volume change, we'd need to restart FFmpeg or use a PCM gain stage.
    // TODO: implement PCM-level volume adjustment for live changes
  }

  getVolume(): number {
    return this.volume;
  }

  getState(): PlayerState {
    return this.state;
  }
}
  • Step 2: Commit
git add src/audio/player.ts
git commit -m "feat: add audio player with FFmpeg decode, Opus encode, and 20ms frame timing"

Task 4: Verify compilation and all tests

  • Step 1: Run all tests

Run: npx vitest run Expected: All tests pass (encoder: 2, queue: 12, identity: 3, commands: 6, config: 3, database: 4 = 30 tests)

  • Step 2: Verify TypeScript compilation

Run: npx tsc --noEmit Expected: No errors

  • Step 3: Commit
git add -A
git commit -m "chore: Phase 3 complete — audio engine (Opus encoder, play queue, FFmpeg player)"