feat & improvements & refactor: new structure, better garbage collection, better docs, better performance, added tests

This commit is contained in:
2026-05-23 19:26:59 +02:00
parent 966db49e37
commit 6962d87f24
18 changed files with 1462 additions and 513 deletions
+10 -4
View File
@@ -15,10 +15,10 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v4
- name: use NodeJS v22.12.0
- name: use NodeJS v24.14.0
uses: actions/setup-node@v4
with:
node-version: '22.12.0'
node-version: '24.14.0'
registry-url: 'https://registry.npmjs.org'
always-auth: true
@@ -28,9 +28,15 @@ jobs:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Install Dependencies
run: npm install
run: npm ci
- name: run Rollout Build
- name: Run Checks
run: npm run check
- name: Set Release Version
run: npm run release:version
- name: Run Build
run: npm run build
- name: Publish Library
+10 -4
View File
@@ -2,6 +2,9 @@ name: Build Validation
on:
workflow_dispatch:
push:
branches:
- main
pull_request:
types: [opened, synchronize, reopened]
@@ -12,13 +15,16 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@v4
- name: use NodeJS v22.16.0
- name: use NodeJS v24.14.0
uses: actions/setup-node@v4
with:
node-version: '22.16.0'
node-version: '24.14.0'
- name: Install Dependencies
run: npm install
run: npm ci
- name: run Rollout Build
- name: Run Checks
run: npm run check
- name: Run Build
run: npm run build
+1 -1
View File
@@ -9,5 +9,5 @@
"bracketSpacing": true,
"bracketSameLine": false,
"quoteProps": "as-needed",
"endOfLine": "crlf"
"endOfLine": "auto"
}
-1
View File
@@ -1 +0,0 @@
FrauJulian
+275 -87
View File
@@ -1,127 +1,315 @@
# Discord Audio Stream
[![npm](https://img.shields.io/npm/dw/discord-audio-stream)](http://npmjs.org/package/discord-audio-stream)
![GitHub package.json version](https://img.shields.io/github/package-json/v/FrauJulian/discord-audio-stream)
![latest release](https://img.shields.io/badge/dynamic/json?label=release&query=$.name&url=https%3A%2F%2Fapi.github.com%2Frepos%2FFrauJulian%2Fdiscord-audio-stream%2Freleases%2Flatest&color=blue)
![GitHub Repo stars](https://img.shields.io/github/stars/FrauJulian/discord-audio-stream?style=social)
**This module is designed to work with [discord.js/voice](https://www.npmjs.com/package/@discordjs/voice) v0.19. This
package doesn't support older
versions!**
> **Designed for 24/7 Discord audio playback.**
> `discord-audio-stream` is a small TypeScript library for managed Discord voice playback through
> `@discordjs/voice` and ffmpeg.
> **Designed for 24/7 audio playing on discord.**
> Discord has many unwanted rate limits, especially in the audio area. This package does all the work and ensures that
> your music never stops playing due to ffmpeg or Discord, with as little effort as possible.
Discord voice streams need careful lifecycle handling. This package manages the voice connection, ffmpeg process,
raw PCM resource creation, optional reconnect renewal, and predictable cleanup so your bot code stays small and readable.
> **Recommended best Practise:**
> Create a global Map<GuildId, AudioManager> (Map<KEY, OBJ>) list. When the feature is used on a guild, add a new
> instance of AudioManager to the Map. When starting the connection or audio, overwrite the respective configuration with
> the override methods provided. To finally start, use the respective method. For each call and each change, retrieve the
> AudioManager object from the list and perform your actions. When the feature get stopped, first execute StopConnection
> and then Dispose to save as much power as possible. Garbage collection does the rest.
> **For large features:** For large systems, it is recommended to queue each start with the
> packet [p-queue](https://www.npmjs.com/package/p-queue), to give ffmpeg enough time..
> **Recommended best practice:**
> Keep one `AudioManager` per guild, usually in a `Map<string, AudioManager>`. Update the connection or source through
> `setConnection()` and `setSource()`, then call `start()`. When playback stops, call `stop()`. When the manager will not
> be reused, call `dispose()` to release timers, streams, ffmpeg, and the voice connection.
> **For large bots:**
> Queue many simultaneous starts, for example with [`p-queue`](https://www.npmjs.com/package/p-queue), so the host does
> not spawn too many ffmpeg processes in the same tick.
## 👋 Support
Please create an [issue](https://github.com/FrauJulian/DiscordAudioStreamNPM/issues) on github or write [
`fraujulian`](https://discord.com/users/860206216893693973) on discord!
Please create an [issue](https://github.com/FrauJulian/Discord-Audio-Stream/issues) on GitHub or contact
[`fraujulian`](https://discord.com/users/860206216893693973) on Discord.
## 📝 Usage
### Installation
**Node.js 22.12.0 or newer is required.**
**Node.js `22.12.0` or newer is required.**
Install the library and the required voice packages:
```bash
npm install discord-audio-stream
yarn add discord-audio-stream
pnpm add discord-audio-stream
bun add discord-audio-stream
npm install discord-audio-stream @discordjs/voice prism-media @snazzah/davey opusscript
```
#### Required Dependencies
`libsodium-wrappers` is optional. Install it only when your runtime does not support `aes-256-gcm`:
- > You only need to install [`libsodium-wrappers`](https://www.npmjs.com/package/libsodium-wrappers) if your system does not support `aes-256-gcm` (verify by running `require('node:crypto').getCiphers().includes('aes-256-gcm')`).
- [`@snazzah/davey`](https://www.npmjs.com/package/@snazzah/davey)
- [`opusscript`](https://www.npmjs.com/package/opusscript)
- [`prism-media`](https://www.npmjs.com/package/prism-media)
- [@discordjs/voice](https://www.npmjs.com/package/@discordjs/voice)
- **FFmpeg** (one of those)
- [`FFmpeg`](https://ffmpeg.org/) (environment) - *recommended*
- [`FFmpeg-static`](https://www.npmjs.com/package/ffmpeg-static) (library)
### AudioManager Instance
#### Constructor
```
AudioManager(ffmpegMode: string, renewInMs, number, connectionData: VoiceConnectionDataModel, audioData: VoiceAudioDataModel): IDisposable, IAudioManager
```bash
node -e "console.log(require('node:crypto').getCiphers().includes('aes-256-gcm'))"
npm install libsodium-wrappers
```
- `ffmpegMode`: 'Native' or 'Standalone'
- Native: [`FFmpeg`](https://ffmpeg.org/) (environment)
- Standalone: [`FFmpeg-static`](https://www.npmjs.com/package/ffmpeg-static) (library)
- `renewInMs`: renewal time, default 1,5h = 5400000ms - optional
- `connectionData`: options for voice connection - optional
- `audioData`: options for audio player - optional
For bundled ffmpeg support, install `ffmpeg-static` and use `ffmpeg.mode: 'static'`:
#### Example
```bash
npm install ffmpeg-static
```
```js
let audioManager = new AudioManager(
'Native',
5400000,
{
VoiceChannelId: 0, //voice channel id where to play music
GuildId: 0, //guild id
VoiceAdapter: 0, //guild VoiceAdapter
If ffmpeg is already available on the host PATH, use `ffmpeg.mode: 'native'`.
### AudioManager Example
```ts
import { AudioManager } from 'discord-audio-stream';
const manager = new AudioManager({
connection: {
guildId: guild.id,
channelId: voiceChannel.id,
adapterCreator: guild.voiceAdapterCreator,
},
{
ResourceType: '', //resource type like link or file
Resource: '', //auto play link or file name
source: {
type: 'url',
url: 'https://example.com/live-stream.mp3',
},
);
ffmpeg: {
mode: 'native',
},
renewIntervalMs: 5_400_000,
});
await manager.start();
```
### Fields and Methods of AudioManager
For a file source:
#### Fields
```ts
manager.setSource({
type: 'file',
path: 'audio/intro.mp3',
});
| Name | Type | Security | Description |
| --------- | -------- | --------- | ------------------------------------- |
| `Active` | **bool** | protected | To check if the connection is active. |
| `Playing` | **bool** | protected | To check if it is playing audio. |
await manager.start();
```
#### Methods
Relative file paths are resolved from `process.cwd()`. URL sources are validated before playback starts.
| Name | Parameters | Return type | Description |
| ----------------------------- | ------------------------------------------------------- | ------------- | ---------------------------------------- |
| `OverrideVoiceConnectionData` | `connectionData`: **VoiceConnectionDataModel** | void | To override intern connectionData field. |
| `OverrideVoiceAudioDataModel` | `audioData`: **VoiceAudioDataModel** | void | To override intern audioData field. |
| `CreateAndPlay` | | Promise void | Join channel and start playing audio. |
| `CreateConnection` | | void | Let it connect to voice channel. |
| `PlayAudio` | | Promise void | To start playing audio. |
| `PauseAudio` | | void | Pause the audio, if it is playing |
| `ResumeAudio` | | void | Resume the audio, if it is paused. |
| `SetVolume` | `volume`: number (0 - 100 percent) | void | To set the audio volume. |
| `StopConnection` | | Promise void | Method to disconnect the voice channel. |
| `Dispose` | | void | Dispose all data in object. |
## ⚙️ API
## 📝 Types
### Constructor
- [**VoiceConnection** by discord.js/voice](https://github.com/discordjs/discord.js/blob/main/packages/voice/src/VoiceConnection.ts#L166)
- [**AudioPlayer** by discord.js/voice](https://github.com/discordjs/discord.js/blob/main/packages/voice/src/audio/AudioPlayer.ts#L155)
- [**AudioResource** by discord.js/voice](https://github.com/discordjs/discord.js/blob/main/packages/voice/src/audio/AudioResource.ts#L44)
- [**DiscordGatewayAdapterCreator** by discord.js/voice](https://github.com/discordjs/discord.js/blob/main/packages/voice/src/util/adapter.ts#L50)
- [**VoiceConnectionDataModel** by discord-audio-stream](https://github.com/FrauJulian/Discord-Audio-Stream/blob/master/src/types.d.ts#L3)
- [**VoiceAudioDataModel** by discord-audio-stream](https://github.com/FrauJulian/Discord-Audio-Stream/blob/master/src/types.d.ts#L21)
- [**IDisposable** by discord-audio-stream](https://github.com/FrauJulian/Discord-Audio-Stream/blob/master/src/types.d.ts#L38)
- [**IAudioManager** by discord-audio-stream](https://github.com/FrauJulian/Discord-Audio-Stream/blob/master/src/types.d.ts#L45)
```ts
type AudioManagerOptions = {
ffmpeg?: {
mode?: 'native' | 'static';
executablePath?: string;
inputArgs?: readonly string[];
outputArgs?: readonly string[];
};
connection?: {
guildId: string;
channelId: string;
adapterCreator: DiscordGatewayAdapterCreator;
};
source?: { type: 'url'; url: string } | { type: 'file'; path: string };
renewIntervalMs?: number | false;
connectTimeoutMs?: number;
volume?: {
enabled?: boolean;
initialPercent?: number;
};
};
```
## 📋 Contributors:
### Defaults
| Option | Default |
| ------------------ | ----------- |
| `ffmpeg.mode` | `'native'` |
| `connectTimeoutMs` | `20_000` |
| `renewIntervalMs` | `5_400_000` |
| `volume.enabled` | `false` |
### Methods
| Method | Description |
| ------------------------ | ---------------------------------------------------------------------------------- |
| `setConnection(options)` | Replaces the voice connection target. |
| `setSource(source)` | Replaces the audio source. |
| `connect()` | Joins the configured Discord voice channel. |
| `play(source?)` | Starts playback on an existing connection. |
| `start()` | Connects and starts playback. |
| `pause()` | Pauses active playback. |
| `resume()` | Resumes paused playback. |
| `stop()` | Stops playback, clears renewal, and destroys the voice connection. |
| `setVolume(percent)` | Sets volume from `0` to `100`; requires `volume.enabled: true`. |
| `dispose()` | Idempotently releases timers, ffmpeg, streams, player state, and voice connection. |
### State
```ts
manager.state; // 'idle' | 'connecting' | 'ready' | 'playing' | 'paused' | 'stopped' | 'disposed'
manager.isPlaying;
manager.isConnected;
```
## 🔊 Audio Options
### Volume
Inline volume has a runtime cost in `@discordjs/voice`, so it is disabled by default.
```ts
const manager = new AudioManager({
connection,
source,
volume: {
enabled: true,
initialPercent: 50,
},
});
await manager.start();
manager.setVolume(25);
```
Calling `setVolume()` without `volume.enabled: true` throws `AudioManagerStateError`.
### ffmpeg Modes
- `mode: 'native'` uses the `ffmpeg` executable from the host environment.
- `mode: 'static'` resolves the optional `ffmpeg-static` package.
- `executablePath` overrides both modes and is useful for Docker images or custom ffmpeg builds.
Default output is raw Discord-compatible PCM: `s16le`, `48000 Hz`, `2 channels`.
You can override ffmpeg arguments through `ffmpeg.inputArgs` and `ffmpeg.outputArgs`. When you override them, you are
responsible for keeping the output compatible with `StreamType.Raw`.
### Renewal
By default, the manager schedules a renewal after `5_400_000 ms` so long-running streams can reconnect periodically.
Set `renewIntervalMs: false` to disable this behavior:
```ts
const manager = new AudioManager({
connection,
source,
renewIntervalMs: false,
});
```
`stop()` and `dispose()` always clear the renewal timer.
## 🧯 Errors
The package exports these error classes:
- `AudioManagerError`
- `AudioManagerConfigError`
- `AudioManagerStateError`
- `FfmpegProcessError`
Configuration problems, such as a missing source or invalid URL, throw `AudioManagerConfigError`. Invalid lifecycle
operations, such as calling `pause()` while nothing is playing, throw `AudioManagerStateError`.
## 📋 Migration From `0.7`
Version `1.0` intentionally breaks the old PascalCase API.
| Old | New |
| -------------------------------------------------------- | ------------------------------------------------------------------- |
| `new AudioManager('Native', renewMs, connection, audio)` | `new AudioManager({ ffmpeg, renewIntervalMs, connection, source })` |
| `OverrideVoiceConnectionData(...)` | `setConnection(...)` |
| `OverrideVoiceAudioDataModel(...)` | `setSource(...)` |
| `CreateConnection()` | `connect()` |
| `PlayAudio()` | `play()` |
| `CreateAndPlay()` | `start()` |
| `PauseAudio()` | `pause()` |
| `ResumeAudio()` | `resume()` |
| `StopConnection()` | `stop()` |
| `SetVolume(...)` | `setVolume(...)` |
| `Dispose()` | `dispose()` |
Old connection data:
```ts
{
VoiceChannelId: voiceChannel.id,
GuildId: guild.id,
VoiceAdapter: guild.voiceAdapterCreator,
}
```
New connection data:
```ts
{
channelId: voiceChannel.id,
guildId: guild.id,
adapterCreator: guild.voiceAdapterCreator,
}
```
Old resource data:
```ts
{
ResourceType: ('Link', Resource, 'https://example.com/audio.mp3');
}
{
ResourceType: ('File', Resource, 'audio/intro.mp3');
}
```
New source data:
```ts
{
type: ('url', url, 'https://example.com/audio.mp3');
}
{
type: ('file', path, 'audio/intro.mp3');
}
```
Old ffmpeg modes:
```ts
'Native';
'Standalone';
```
New ffmpeg modes:
```ts
{
ffmpeg: {
mode: 'native';
}
}
{
ffmpeg: {
mode: 'static';
}
}
```
## 🧑‍💻 Development
```bash
npm ci
npm run check
npm run build
```
`npm run check` runs formatting, linting, type-aware linting, TypeScript type checking, and unit tests.
Release version stamping is separate from normal builds:
```bash
npm run release:version
```
## 📋 Contributors
~ [**FrauJulian - Julian Lechner**](https://fraujulian.xyz/) - CODEOWNER
## 🤝 Enjoy the package?
Give it a star ⭐ on [github](https://github.com/FrauJulian/discord-audio-stream)!
Give it a star ⭐ on [GitHub](https://github.com/FrauJulian/discord-audio-stream)!
+2 -2
View File
@@ -36,7 +36,7 @@ const config = [
'warn',
{ vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-explicit-any': 'error',
/* Imports */
'import/first': 'error',
@@ -46,7 +46,7 @@ const config = [
'error',
{
devDependencies: [
'**/*.config.{js,cjs,mjs,ts}',
'**/*.config*.{js,cjs,mjs,ts}',
'**/scripts/**',
'**/*.test.{ts,js}',
'**/Testing.{ts,js}',
+18
View File
@@ -0,0 +1,18 @@
/** @type {import('jest').Config} */
module.exports = {
clearMocks: true,
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
moduleFileExtensions: ['ts', 'js', 'json'],
testEnvironment: 'node',
testMatch: ['<rootDir>/tests/**/*.test.ts'],
transform: {
'^.+\\.ts$': [
'ts-jest',
{
tsconfig: '<rootDir>/tsconfig.test.json',
},
],
},
};
+386 -184
View File
File diff suppressed because it is too large Load Diff
+20 -8
View File
@@ -1,8 +1,8 @@
{
"name": "discord-audio-stream",
"version": "0.7.DDDhhmm",
"version": "1.0.DDDhhmm",
"license": "LGPL-2.1-only",
"description": "Discord library to make stream any audio easier.",
"description": "A small Discord voice audio streaming library with managed ffmpeg playback.",
"exports": {
".": {
"require": {
@@ -26,13 +26,16 @@
},
"scripts": {
"update:dependencies": "npx npm-check-updates --upgrade",
"update:version": "node set-version.js",
"release:version": "node set-version.js",
"lint": "eslint . --ext .ts,.tsx,.js,.cjs,.mjs",
"lint:fix": "eslint . --ext .ts,.tsx,.js,.cjs,.mjs --fix",
"lint:types": "eslint . --config eslint.config.typeaware.cjs --ext .ts,.tsx",
"format": "prettier --write .",
"format:check": "prettier --check .",
"build": "del-cli dist && npm run update:version && tsup",
"typecheck": "tsc --noEmit",
"test": "jest --runInBand",
"check": "npm run format:check && npm run lint && npm run lint:types && npm run typecheck && npm run test",
"build": "del-cli dist && tsup",
"prepare": "husky"
},
"repository": "https://github.com/FrauJulian/Discord-Audio-Stream",
@@ -58,13 +61,12 @@
"prettier --write"
]
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "^8.50.0",
"@typescript-eslint/parser": "^8.50.0"
},
"devDependencies": {
"@jest/globals": "^30.2.0",
"@types/ejs": "^3.1.5",
"@types/node": "^25.0.2",
"@typescript-eslint/eslint-plugin": "^8.50.0",
"@typescript-eslint/parser": "^8.50.0",
"del-cli": "^7.0.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
@@ -74,6 +76,7 @@
"eslint-plugin-promise": "^7.2.1",
"eslint-plugin-unused-imports": "^4.3.0",
"husky": "^9.1.7",
"jest": "^30.2.0",
"npm-check-updates": "^19.2.0",
"prettier": "^3.7.4",
"ts-jest": "^29.4.6",
@@ -83,10 +86,19 @@
"peerDependencies": {
"@discordjs/voice": "^0.19.0",
"@snazzah/davey": "^0.1.8",
"ffmpeg-static": "^5.2.0",
"libsodium-wrappers": "^0.7.15",
"opusscript": "^0.0.8",
"prism-media": "^1.3.5"
},
"peerDependenciesMeta": {
"ffmpeg-static": {
"optional": true
},
"libsodium-wrappers": {
"optional": true
}
},
"engines": {
"node": ">=22.12.0"
},
+209 -149
View File
@@ -8,190 +8,250 @@ import {
StreamType,
VoiceConnectionStatus,
} from '@discordjs/voice';
import { join } from 'node:path';
import type { IAudioManager, IDisposable, VoiceAudioDataModel, VoiceConnectionDataModel } from './types';
import { spawn } from 'node:child_process';
import type { Readable } from 'node:stream';
import { isAbsolute, resolve } from 'node:path';
export default class AudioManager implements IAudioManager, IDisposable {
private voiceConnection?: VoiceConnection | null;
private audioPlayer?: AudioPlayer | null;
private audioResource?: AudioResource | null;
import { AudioManagerConfigError, AudioManagerStateError } from './errors';
import { startFfmpeg, type FfmpegProcessHandle } from './ffmpeg';
import type {
AudioManagerOptions,
AudioSource,
PlaybackState,
ResolvedAudioSource,
VoiceConnectionOptions,
} from './types';
protected Active?: boolean | null;
protected Playing?: boolean | null;
const DEFAULT_CONNECT_TIMEOUT_MS = 20_000;
const DEFAULT_RENEW_INTERVAL_MS = 5_400_000;
private timeout?: NodeJS.Timeout | null;
private ffmpegProcess?: ReturnType<typeof spawn> | null;
private pcmStream?: Readable | null;
export default class AudioManager {
private readonly audioPlayer: AudioPlayer;
constructor(
private readonly ffmpegMode: 'Native' | 'Standalone',
private renewMs: number | null = null,
private connectionData: VoiceConnectionDataModel | null = null,
private audioData: VoiceAudioDataModel | null = null,
) {
this.renewMs = renewMs ? renewMs : 5400000;
}
public OverrideVoiceConnectionData(connectionData: VoiceConnectionDataModel): void {
this.connectionData = connectionData;
}
public OverrideVoiceAudioDataModel(audioData: VoiceAudioDataModel): void {
this.audioData = audioData;
}
public CreateConnection(): void {
this.voiceConnection = joinVoiceChannel({
channelId: this.connectionData!.VoiceChannelId,
guildId: this.connectionData!.GuildId,
adapterCreator: this.connectionData!.VoiceAdapter,
});
this.timeout = setTimeout(async (): Promise<void> => {
await this.StopConnection();
await this.CreateAndPlay();
}, this.renewMs!);
}
public async PlayAudio(): Promise<void> {
if (!this.audioData || !this.voiceConnection) return;
await entersState(this.voiceConnection!, VoiceConnectionStatus.Ready, 20_000);
if (this.ffmpegProcess) {
this.ffmpegProcess.kill('SIGKILL');
this.ffmpegProcess = null;
}
let source: string;
if (this.audioData!.ResourceType === 'File') {
source = join(__dirname, this.audioData!.Resource);
} else if (this.audioData!.ResourceType === 'Link') {
source = this.audioData!.Resource;
} else {
throw new TypeError('Invalid resource type.');
}
this.ffmpegProcess = spawn(
this.ffmpegMode === 'Native' ? 'ffmpeg' : require('ffmpeg-static'),
[
'-loglevel',
'error',
'-i',
source,
'-analyzeduration',
'0',
'-f',
's16le',
'-ar',
'48000',
'-ac',
'2',
'pipe:1',
],
{
stdio: ['ignore', 'pipe', 'pipe'],
},
);
this.pcmStream = this.ffmpegProcess!.stdout as Readable;
this.audioResource = createAudioResource(this.pcmStream, {
inputType: StreamType.Raw,
inlineVolume: true,
});
private connection: VoiceConnection | undefined;
private resource: AudioResource | undefined;
private ffmpeg: FfmpegProcessHandle | undefined;
private renewTimer: NodeJS.Timeout | undefined;
private playbackState: PlaybackState = 'idle';
private connectionOptions: VoiceConnectionOptions | undefined;
private audioSource: AudioSource | undefined;
private readonly options: Required<Pick<AudioManagerOptions, 'connectTimeoutMs'>> &
Omit<AudioManagerOptions, 'connectTimeoutMs'>;
public constructor(options: AudioManagerOptions = {}) {
this.options = {
...options,
connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
};
this.connectionOptions = options.connection;
this.audioSource = options.source;
this.audioPlayer = createAudioPlayer({
behaviors: {
noSubscriber: NoSubscriberBehavior.Play,
},
});
this.voiceConnection!.subscribe(this.audioPlayer);
this.audioPlayer.play(this.audioResource);
this.Playing = true;
}
public PauseAudio(): void {
if (this.Playing) {
this.audioPlayer!.pause();
this.Playing = false;
} else {
throw new ReferenceError('Audio is not playing.');
}
public get state(): PlaybackState {
return this.playbackState;
}
public ResumeAudio(): void {
if (!this.Playing) {
this.audioPlayer!.unpause();
this.Playing = true;
} else {
throw new ReferenceError('Audio is playing.');
}
public get isPlaying(): boolean {
return this.playbackState === 'playing';
}
public async CreateAndPlay(): Promise<void> {
this.CreateConnection();
await this.PlayAudio();
public get isConnected(): boolean {
return Boolean(this.connection);
}
public async StopConnection(): Promise<void> {
this.voiceConnection?.disconnect();
this.voiceConnection?.destroy();
this.voiceConnection = null;
this.Active = false;
this.Playing = false;
public setConnection(options: VoiceConnectionOptions): void {
this.assertNotDisposed();
this.connectionOptions = options;
}
public SetVolume(volumeInPercent: number): void {
if (volumeInPercent < 0 || volumeInPercent > 100) throw new Error('Volume must be between 0 and 100.');
this.audioResource!.volume!.setVolume(volumeInPercent / 100);
public setSource(source: AudioSource): void {
this.assertNotDisposed();
this.audioSource = source;
}
public Dispose(): void {
try {
if (this.timeout) {
clearTimeout(this.timeout);
public async connect(): Promise<void> {
this.assertNotDisposed();
if (!this.connectionOptions) {
throw new AudioManagerConfigError('Voice connection options are required before connecting.');
}
if (this.audioPlayer) {
this.clearRenewTimer();
this.playbackState = 'connecting';
this.connection?.destroy();
this.connection = joinVoiceChannel({
guildId: this.connectionOptions.guildId,
channelId: this.connectionOptions.channelId,
adapterCreator: this.connectionOptions.adapterCreator,
});
this.connection.subscribe(this.audioPlayer);
await entersState(this.connection, VoiceConnectionStatus.Ready, this.options.connectTimeoutMs);
this.playbackState = 'ready';
this.scheduleRenewal();
}
public async play(source?: AudioSource): Promise<void> {
this.assertNotDisposed();
if (source) {
this.setSource(source);
}
if (!this.connection) {
throw new AudioManagerStateError('A voice connection is required before audio can be played.');
}
const resolvedSource = this.resolveSource();
this.stopCurrentPlayback();
this.ffmpeg = startFfmpeg(resolvedSource.input, this.options.ffmpeg);
this.resource = createAudioResource(this.ffmpeg.process.stdout, {
inputType: StreamType.Raw,
inlineVolume: this.options.volume?.enabled === true,
});
if (this.options.volume?.enabled === true && this.options.volume.initialPercent !== undefined) {
this.setVolume(this.options.volume.initialPercent);
}
this.audioPlayer.play(this.resource);
this.playbackState = 'playing';
}
public async start(): Promise<void> {
await this.connect();
await this.play();
}
public pause(): void {
this.assertNotDisposed();
if (this.playbackState !== 'playing') {
throw new AudioManagerStateError('Audio can only be paused while it is playing.');
}
this.audioPlayer.pause();
this.playbackState = 'paused';
}
public resume(): void {
this.assertNotDisposed();
if (this.playbackState !== 'paused') {
throw new AudioManagerStateError('Audio can only be resumed while it is paused.');
}
this.audioPlayer.unpause();
this.playbackState = 'playing';
}
public async stop(): Promise<void> {
if (this.playbackState === 'disposed') {
return;
}
this.clearRenewTimer();
this.stopCurrentPlayback();
this.audioPlayer.stop(true);
this.connection?.disconnect();
this.connection?.destroy();
this.connection = undefined;
this.playbackState = 'stopped';
}
if (this.audioPlayer) {
this.audioPlayer!.stop();
public setVolume(volumeInPercent: number): void {
this.assertNotDisposed();
if (this.audioResource && this.audioResource.playStream) {
this.audioResource!.playStream.destroy();
if (this.options.volume?.enabled !== true) {
throw new AudioManagerStateError('Volume control requires volume.enabled to be true.');
}
if (volumeInPercent < 0 || volumeInPercent > 100) {
throw new AudioManagerConfigError('Volume must be between 0 and 100 percent.');
}
if (!this.resource?.volume) {
throw new AudioManagerStateError('No audio resource with volume control is currently active.');
}
this.resource.volume.setVolume(volumeInPercent / 100);
}
public dispose(): void {
if (this.playbackState === 'disposed') {
return;
}
this.clearRenewTimer();
this.stopCurrentPlayback();
this.audioPlayer.stop(true);
this.connection?.destroy();
this.connection = undefined;
this.connectionOptions = undefined;
this.audioSource = undefined;
this.playbackState = 'disposed';
}
private resolveSource(): ResolvedAudioSource {
if (!this.audioSource) {
throw new AudioManagerConfigError('Audio source is required before playback can start.');
}
if (this.audioSource.type === 'url') {
try {
return {
input: new URL(this.audioSource.url).toString(),
source: this.audioSource,
};
} catch (error) {
throw new AudioManagerConfigError(`Invalid audio source URL. Cause: ${String(error)}`);
}
}
if (this.voiceConnection) {
this.voiceConnection!.destroy();
return {
input: isAbsolute(this.audioSource.path)
? this.audioSource.path
: resolve(process.cwd(), this.audioSource.path),
source: this.audioSource,
};
}
if (this.ffmpegProcess) {
this.ffmpegProcess.kill('SIGKILL');
this.ffmpegProcess = null;
private scheduleRenewal(): void {
const renewIntervalMs = this.options.renewIntervalMs ?? DEFAULT_RENEW_INTERVAL_MS;
if (renewIntervalMs === false) {
return;
}
this.pcmStream?.destroy();
this.pcmStream = null;
this.renewTimer = setTimeout(() => {
void this.start();
}, renewIntervalMs);
this.audioPlayer = null;
this.audioResource = null;
this.voiceConnection = null;
this.timeout = null;
this.Active = null;
this.Playing = null;
this.connectionData = null;
this.audioData = null;
this.renewMs = null;
} catch {}
if (typeof this.renewTimer.unref === 'function') {
this.renewTimer.unref();
}
}
private clearRenewTimer(): void {
if (this.renewTimer) {
clearTimeout(this.renewTimer);
this.renewTimer = undefined;
}
}
private stopCurrentPlayback(): void {
this.resource?.playStream.destroy();
this.resource = undefined;
this.ffmpeg?.stop();
this.ffmpeg = undefined;
}
private assertNotDisposed(): void {
if (this.playbackState === 'disposed') {
throw new AudioManagerStateError('AudioManager has been disposed.');
}
}
}
+19
View File
@@ -0,0 +1,19 @@
export class AudioManagerError extends Error {
public constructor(message: string) {
super(message);
this.name = new.target.name;
}
}
export class AudioManagerConfigError extends AudioManagerError {}
export class AudioManagerStateError extends AudioManagerError {}
export class FfmpegProcessError extends AudioManagerError {
public constructor(
message: string,
public readonly cause?: unknown,
) {
super(message);
}
}
+83
View File
@@ -0,0 +1,83 @@
import { spawn } from 'node:child_process';
import type { ChildProcessByStdio } from 'node:child_process';
import { createRequire } from 'node:module';
import type { Readable } from 'node:stream';
import { AudioManagerConfigError } from './errors';
import type { FfmpegOptions } from './types';
const requireFromCurrentModule = createRequire(__filename);
const DEFAULT_INPUT_ARGS = ['-hide_banner', '-loglevel', 'error', '-nostdin'] as const;
const DEFAULT_OUTPUT_ARGS = ['-vn', '-f', 's16le', '-ar', '48000', '-ac', '2', 'pipe:1'] as const;
const FORCE_KILL_TIMEOUT_MS = 2_000;
export type FfmpegProcessHandle = {
process: ChildProcessByStdio<null, Readable, Readable>;
stop(): void;
};
export function resolveFfmpegExecutable(options: FfmpegOptions = {}): string {
if (options.executablePath?.trim()) {
return options.executablePath;
}
if ((options.mode ?? 'native') === 'native') {
return 'ffmpeg';
}
try {
const executable = requireFromCurrentModule('ffmpeg-static') as unknown;
if (typeof executable === 'string' && executable.length > 0) {
return executable;
}
} catch (error) {
throw new AudioManagerConfigError(
`Unable to resolve ffmpeg-static. Install it or pass ffmpeg.executablePath. Cause: ${String(error)}`,
);
}
throw new AudioManagerConfigError('ffmpeg-static did not expose an executable path.');
}
export function startFfmpeg(input: string, options: FfmpegOptions = {}): FfmpegProcessHandle {
const executable = resolveFfmpegExecutable(options);
const args = [
...(options.inputArgs ?? DEFAULT_INPUT_ARGS),
'-i',
input,
...(options.outputArgs ?? DEFAULT_OUTPUT_ARGS),
];
const childProcess = spawn(executable, args, { stdio: ['ignore', 'pipe', 'pipe'] });
childProcess.once('error', () => undefined);
childProcess.stderr.resume();
return {
process: childProcess,
stop: (): void => {
stopProcess(childProcess);
},
};
}
function stopProcess(childProcess: ChildProcessByStdio<null, Readable, Readable>): void {
childProcess.stdout.destroy();
childProcess.stderr.destroy();
childProcess.removeAllListeners();
if (childProcess.killed || childProcess.exitCode !== null || childProcess.signalCode !== null) {
return;
}
childProcess.kill('SIGTERM');
const forceKillTimeout = setTimeout(() => {
if (!childProcess.killed && childProcess.exitCode === null && childProcess.signalCode === null) {
childProcess.kill('SIGKILL');
}
}, FORCE_KILL_TIMEOUT_MS);
forceKillTimeout.unref();
}
+10 -1
View File
@@ -1,2 +1,11 @@
export { default as AudioManager } from './audio-manager';
export type { VoiceAudioDataModel, VoiceConnectionDataModel } from './types';
export { AudioManagerConfigError, AudioManagerError, AudioManagerStateError, FfmpegProcessError } from './errors';
export type {
AudioManagerOptions,
AudioSource,
FfmpegMode,
FfmpegOptions,
PlaybackState,
VoiceConnectionOptions,
VolumeOptions,
} from './types';
-55
View File
@@ -1,55 +0,0 @@
import type { DiscordGatewayAdapterCreator } from '@discordjs/voice';
export type VoiceConnectionDataModel = {
/**
* The ID of the voice channel to connect to.
*/
VoiceChannelId: string;
/**
* The Id of the guild (server) to connect to.
*/
GuildId: string;
/**
* The adapter creator for the voice connection.
* Can be archived by the guild instance via the voiceAdapterCreator property.
*/
VoiceAdapter: DiscordGatewayAdapterCreator;
};
export type VoiceAudioDataModel = {
/**
* The unique identifier for the audio resource.
*/
ResourceType: 'Link' | 'File';
/**
* The URL or file path of the audio resource.
* any is to be assumed to require(filepath)
*/
Resource: string | any;
};
/**
* An interface which is implemented in audio manager
* for a proper Dispose functionality.
*/
export type IDisposable = {
Dispose(): void;
};
/**
* An interface to handle instances later on.
*/
export type IAudioManager = {
OverrideVoiceConnectionData(connectionData: VoiceConnectionDataModel): void;
OverrideVoiceAudioDataModel(audioData: VoiceAudioDataModel): void;
CreateAndPlay(): Promise<void>;
CreateConnection(): void;
PlayAudio(): Promise<void>;
PauseAudio(): void;
ResumeAudio(): void;
StopConnection(): Promise<void>;
SetVolume(volumeInPercent: number): void;
};
+178
View File
@@ -0,0 +1,178 @@
import type { DiscordGatewayAdapterCreator } from '@discordjs/voice';
/**
* Strategy used to resolve the ffmpeg executable.
*
* - `native` uses the `ffmpeg` binary available on the host PATH.
* - `static` resolves the optional `ffmpeg-static` package.
*/
export type FfmpegMode = 'native' | 'static';
/**
* Audio input consumed by ffmpeg.
*
* URL sources are validated through the built-in `URL` constructor before playback starts.
* File sources may be absolute or relative; relative paths are resolved from `process.cwd()`.
*/
export type AudioSource =
| {
/**
* Marks this source as a remote URL.
*/
type: 'url';
/**
* Fully qualified audio URL passed to ffmpeg.
*/
url: string;
}
| {
/**
* Marks this source as a local file path.
*/
type: 'file';
/**
* Absolute file path or path relative to `process.cwd()`.
*/
path: string;
};
/**
* Discord voice channel connection settings.
*/
export type VoiceConnectionOptions = {
/**
* Discord guild/server ID.
*/
guildId: string;
/**
* Discord voice channel ID where audio should be played.
*/
channelId: string;
/**
* Discord voice adapter creator, usually `guild.voiceAdapterCreator` from discord.js.
*/
adapterCreator: DiscordGatewayAdapterCreator;
};
/**
* Public playback lifecycle state exposed by `AudioManager.state`.
*/
export type PlaybackState = 'idle' | 'connecting' | 'ready' | 'playing' | 'paused' | 'stopped' | 'disposed';
/**
* ffmpeg executable and argument configuration.
*/
export type FfmpegOptions = {
/**
* ffmpeg resolution mode.
*
* @defaultValue `'native'`
*/
mode?: FfmpegMode;
/**
* Explicit ffmpeg executable path.
*
* When provided, this value takes precedence over `mode`.
*/
executablePath?: string;
/**
* Arguments placed before `-i <source>`.
*
* Override only when you need full control over ffmpeg input behavior.
*/
inputArgs?: readonly string[];
/**
* Arguments placed after `-i <source>`.
*
* If overridden, the output must remain compatible with `StreamType.Raw`.
*/
outputArgs?: readonly string[];
};
/**
* Optional inline volume control settings.
*/
export type VolumeOptions = {
/**
* Enables Discord voice inline volume support.
*
* Disabled by default because inline volume has runtime overhead.
*
* @defaultValue `false`
*/
enabled?: boolean;
/**
* Initial volume percentage applied when playback starts.
*
* Requires `enabled: true`.
*/
initialPercent?: number;
};
/**
* Constructor options for `AudioManager`.
*/
export type AudioManagerOptions = {
/**
* ffmpeg executable and argument configuration.
*/
ffmpeg?: FfmpegOptions;
/**
* Initial Discord voice connection settings.
*
* May also be supplied later through `setConnection()`.
*/
connection?: VoiceConnectionOptions;
/**
* Initial audio source.
*
* May also be supplied later through `setSource()` or `play(source)`.
*/
source?: AudioSource;
/**
* Milliseconds after which the manager reconnects and restarts playback.
*
* Set to `false` to disable renewal.
*
* @defaultValue `5_400_000`
*/
renewIntervalMs?: number | false;
/**
* Maximum milliseconds to wait for the Discord voice connection to become ready.
*
* @defaultValue `20_000`
*/
connectTimeoutMs?: number;
/**
* Optional inline volume configuration.
*/
volume?: VolumeOptions;
};
/**
* Internal normalized audio source passed to ffmpeg.
*/
export type ResolvedAudioSource = {
/**
* Validated URL or absolute file path passed as ffmpeg input.
*/
input: string;
/**
* Original source configuration.
*/
source: AudioSource;
};
+216
View File
@@ -0,0 +1,216 @@
import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals';
import { sep } from 'node:path';
import { PassThrough } from 'node:stream';
import AudioManager from '../src/audio-manager';
import { AudioManagerConfigError, AudioManagerStateError } from '@/errors';
import { startFfmpeg } from '@/ffmpeg';
import type { AudioSource, VoiceConnectionOptions } from '@/types';
const mockAudioPlayer = {
play: jest.fn(),
pause: jest.fn(),
unpause: jest.fn(),
stop: jest.fn(),
};
const mockConnection = {
subscribe: jest.fn(),
disconnect: jest.fn(),
destroy: jest.fn(),
};
const mockAudioResource = {
playStream: {
destroy: jest.fn(),
},
volume: {
setVolume: jest.fn(),
},
};
const mockFfmpegHandle = {
process: {
stdout: new PassThrough(),
},
stop: jest.fn(),
};
jest.mock('@discordjs/voice', () => ({
NoSubscriberBehavior: {
Play: 'play',
},
StreamType: {
Raw: 'raw',
},
VoiceConnectionStatus: {
Ready: 'ready',
},
createAudioPlayer: jest.fn(() => mockAudioPlayer),
createAudioResource: jest.fn(() => mockAudioResource),
entersState: jest.fn(() => Promise.resolve(mockConnection)),
joinVoiceChannel: jest.fn(() => mockConnection),
}));
jest.mock('@/ffmpeg', () => ({
startFfmpeg: jest.fn(() => mockFfmpegHandle),
}));
const connectionOptions: VoiceConnectionOptions = {
guildId: 'guild-id',
channelId: 'channel-id',
adapterCreator: jest.fn() as unknown as VoiceConnectionOptions['adapterCreator'],
};
const source: AudioSource = {
type: 'url',
url: 'https://example.com/audio.mp3',
};
describe('AudioManager', () => {
beforeEach(() => {
jest.clearAllMocks();
});
afterEach(() => {
jest.useRealTimers();
});
it('requires connection options before connecting', async () => {
const manager = new AudioManager({ renewIntervalMs: false });
await expect(manager.connect()).rejects.toThrow(AudioManagerConfigError);
});
it('connects and starts playback with resolved URL source', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source,
renewIntervalMs: false,
});
await manager.start();
expect(manager.state).toBe('playing');
expect(manager.isConnected).toBe(true);
expect(manager.isPlaying).toBe(true);
expect(mockConnection.subscribe).toHaveBeenCalledWith(mockAudioPlayer);
expect(startFfmpeg).toHaveBeenCalledWith('https://example.com/audio.mp3', undefined);
expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource);
});
it('resolves file sources against the current working directory', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: {
type: 'file',
path: 'audio/song.mp3',
},
renewIntervalMs: false,
});
await manager.start();
expect(startFfmpeg).toHaveBeenCalledWith(expect.stringContaining(`audio${sep}song.mp3`), undefined);
});
it('rejects invalid URL sources', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source: {
type: 'url',
url: 'not a url',
},
renewIntervalMs: false,
});
await manager.connect();
await expect(manager.play()).rejects.toThrow(AudioManagerConfigError);
});
it('applies initial volume only when volume support is enabled', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source,
renewIntervalMs: false,
volume: {
enabled: true,
initialPercent: 35,
},
});
await manager.start();
expect(mockAudioResource.volume.setVolume).toHaveBeenCalledWith(0.35);
});
it('rejects volume changes when inline volume is disabled', () => {
const manager = new AudioManager({ renewIntervalMs: false });
expect(() => manager.setVolume(50)).toThrow(AudioManagerStateError);
});
it('pauses and resumes only from valid playback states', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source,
renewIntervalMs: false,
});
expect(() => manager.pause()).toThrow(AudioManagerStateError);
await manager.start();
manager.pause();
manager.resume();
expect(mockAudioPlayer.pause).toHaveBeenCalledTimes(1);
expect(mockAudioPlayer.unpause).toHaveBeenCalledTimes(1);
expect(manager.state).toBe('playing');
});
it('stops playback and voice resources idempotently', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source,
renewIntervalMs: false,
});
await manager.start();
await manager.stop();
await manager.stop();
expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1);
expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1);
expect(mockConnection.destroy).toHaveBeenCalled();
expect(manager.state).toBe('stopped');
});
it('clears existing renewal timer before reconnecting', async () => {
jest.useFakeTimers();
const manager = new AudioManager({
connection: connectionOptions,
source,
renewIntervalMs: 10_000,
});
await manager.connect();
await manager.connect();
expect(jest.getTimerCount()).toBe(1);
manager.dispose();
expect(jest.getTimerCount()).toBe(0);
});
it('prevents use after disposal', async () => {
const manager = new AudioManager({
connection: connectionOptions,
source,
renewIntervalMs: false,
});
await manager.start();
manager.dispose();
expect(() => manager.pause()).toThrow(AudioManagerStateError);
expect(manager.state).toBe('disposed');
});
});
+1 -1
View File
@@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts"]
"include": ["src/**/*.ts", "tests/**/*.ts", "tsup.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"isolatedModules": true,
"outDir": "./dist-test"
},
"include": ["src/**/*.ts", "tests/**/*.ts", "tsup.config.ts"]
}