diff --git a/src/audio-manager.ts b/src/audio-manager.ts index cf9b8e7..fe6f5ef 100644 --- a/src/audio-manager.ts +++ b/src/audio-manager.ts @@ -4,6 +4,7 @@ import { createAudioResource, entersState, joinVoiceChannel, + AudioPlayerStatus, NoSubscriberBehavior, StreamType, VoiceConnectionStatus, @@ -21,14 +22,30 @@ import type { } from './types'; const DEFAULT_CONNECT_TIMEOUT_MS = 20_000; -const DEFAULT_RENEW_INTERVAL_MS = 5_400_000; +const DISCONNECT_RECOVERY_TIMEOUT_MS = 5_000; +const MAX_TIMER_DELAY_MS = 2_147_483_647; -export default class AudioManager { +function assertValidTimerDelay(value: number, optionName: string): void { + if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) { + throw new AudioManagerConfigError( + `${optionName} must be an integer between 1 and ${MAX_TIMER_DELAY_MS} milliseconds.`, + ); + } +} + +function assertValidVolumePercent(value: number): void { + if (!Number.isFinite(value) || value < 0 || value > 100) { + throw new AudioManagerConfigError('Volume must be between 0 and 100 percent.'); + } +} + +export default class AudioManager implements Disposable { private readonly audioPlayer: AudioPlayer; private connection: VoiceConnection | undefined; private resource: AudioResource | undefined; private ffmpeg: FfmpegProcessHandle | undefined; + private connectAttempt: AbortController | undefined; private renewTimer: NodeJS.Timeout | undefined; private playbackState: PlaybackState = 'idle'; private connectionOptions: VoiceConnectionOptions | undefined; @@ -37,9 +54,23 @@ export default class AudioManager { Omit; public constructor(options: AudioManagerOptions = {}) { + const connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; + assertValidTimerDelay(connectTimeoutMs, 'connectTimeoutMs'); + + if (typeof options.renewIntervalMs === 'number') { + assertValidTimerDelay(options.renewIntervalMs, 'renewIntervalMs'); + } + + if (options.volume?.initialPercent !== undefined) { + if (options.volume.enabled !== true) { + throw new AudioManagerConfigError('volume.initialPercent requires volume.enabled to be true.'); + } + assertValidVolumePercent(options.volume.initialPercent); + } + this.options = { ...options, - connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, + connectTimeoutMs, }; this.connectionOptions = options.connection; this.audioSource = options.source; @@ -48,6 +79,15 @@ export default class AudioManager { noSubscriber: NoSubscriberBehavior.Play, }, }); + this.audioPlayer.on('error', (error) => { + this.finishPlayback(error.resource); + this.reportError(error); + }); + this.audioPlayer.on(AudioPlayerStatus.Idle, (oldState) => { + if ('resource' in oldState) { + this.finishPlayback(oldState.resource); + } + }); } public get state(): PlaybackState { @@ -59,7 +99,7 @@ export default class AudioManager { } public get isConnected(): boolean { - return Boolean(this.connection); + return this.connection?.state.status === VoiceConnectionStatus.Ready; } public setConnection(options: VoiceConnectionOptions): void { @@ -79,30 +119,55 @@ export default class AudioManager { throw new AudioManagerConfigError('Voice connection options are required before connecting.'); } + this.cancelConnectAttempt(); + const attempt = new AbortController(); + this.connectAttempt = attempt; this.clearRenewTimer(); this.playbackState = 'connecting'; - this.connection?.destroy(); - const connection = joinVoiceChannel({ - guildId: this.connectionOptions.guildId, - channelId: this.connectionOptions.channelId, - adapterCreator: this.connectionOptions.adapterCreator, - }); - this.connection = connection; - connection.subscribe(this.audioPlayer); + + const previousConnection = this.connection; + this.connection = undefined; + let connection: VoiceConnection | undefined; try { - await entersState(connection, VoiceConnectionStatus.Ready, this.options.connectTimeoutMs); + previousConnection?.destroy(); + connection = joinVoiceChannel({ + guildId: this.connectionOptions.guildId, + channelId: this.connectionOptions.channelId, + adapterCreator: this.connectionOptions.adapterCreator, + }); + this.connection = connection; + this.observeConnection(connection); + connection.subscribe(this.audioPlayer); + + await entersState( + connection, + VoiceConnectionStatus.Ready, + AbortSignal.any([attempt.signal, AbortSignal.timeout(this.options.connectTimeoutMs)]), + ); } catch (error) { - connection.destroy(); + if (this.connectAttempt !== attempt) { + throw new AudioManagerStateError('Voice connection was stopped before it became ready.', { + cause: error, + }); + } + + this.connectAttempt = undefined; + if (connection && connection.state.status !== VoiceConnectionStatus.Destroyed) { + connection.destroy(); + } if (this.connection === connection) { this.connection = undefined; } this.playbackState = 'stopped'; throw error; } - if (this.connection !== connection) { + + if (this.connectAttempt !== attempt || this.connection !== connection) { throw new AudioManagerStateError('Voice connection was stopped before it became ready.'); } + + this.connectAttempt = undefined; this.playbackState = 'ready'; this.scheduleRenewal(); } @@ -114,7 +179,7 @@ export default class AudioManager { this.setSource(source); } - if (!this.connection) { + if (!this.isConnected) { throw new AudioManagerStateError('A voice connection is required before audio can be played.'); } @@ -132,17 +197,22 @@ export default class AudioManager { inlineVolume: this.options.volume?.enabled === true, }); } catch (error) { - if (this.ffmpeg === ffmpeg) { - this.stopCurrentPlayback(); + if (this.ffmpeg !== ffmpeg) { + throw error instanceof AudioManagerStateError + ? error + : new AudioManagerStateError('Playback was stopped before ffmpeg became ready.', { + cause: error, + }); } + + this.stopCurrentPlayback(); throw error; } - if (this.options.volume?.enabled === true && this.options.volume.initialPercent !== undefined) { - this.setVolume(this.options.volume.initialPercent); - } - try { + 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'; } catch (error) { @@ -184,6 +254,7 @@ export default class AudioManager { return; } + this.cancelConnectAttempt(); this.clearRenewTimer(); this.stopCurrentPlayback(); this.audioPlayer.stop(true); @@ -200,9 +271,7 @@ export default class AudioManager { throw new AudioManagerStateError('Volume control requires volume.enabled to be true.'); } - if (!Number.isFinite(volumeInPercent) || volumeInPercent < 0 || volumeInPercent > 100) { - throw new AudioManagerConfigError('Volume must be between 0 and 100 percent.'); - } + assertValidVolumePercent(volumeInPercent); if (!this.resource?.volume) { throw new AudioManagerStateError('No audio resource with volume control is currently active.'); @@ -216,6 +285,7 @@ export default class AudioManager { return; } + this.cancelConnectAttempt(); this.clearRenewTimer(); this.stopCurrentPlayback(); this.audioPlayer.stop(true); @@ -226,6 +296,10 @@ export default class AudioManager { this.playbackState = 'disposed'; } + public [Symbol.dispose](): void { + this.dispose(); + } + private resolveSource(): ResolvedAudioSource { if (!this.audioSource) { throw new AudioManagerConfigError('Audio source is required before playback can start.'); @@ -238,7 +312,7 @@ export default class AudioManager { source: this.audioSource, }; } catch (error) { - throw new AudioManagerConfigError(`Invalid audio source URL. Cause: ${String(error)}`); + throw new AudioManagerConfigError('Invalid audio source URL.', { cause: error }); } } @@ -251,14 +325,18 @@ export default class AudioManager { } private scheduleRenewal(): void { - const renewIntervalMs = this.options.renewIntervalMs ?? DEFAULT_RENEW_INTERVAL_MS; + const renewIntervalMs = this.options.renewIntervalMs; - if (renewIntervalMs === false) { + if (renewIntervalMs === undefined || renewIntervalMs === false) { return; } this.renewTimer = setTimeout(() => { - void this.start().catch(() => { + void this.start().catch((error: unknown) => { + if (this.playbackState === 'disposed') { + return; + } + this.clearRenewTimer(); this.stopCurrentPlayback(); this.audioPlayer.stop(true); @@ -266,6 +344,7 @@ export default class AudioManager { this.connection?.destroy(); this.connection = undefined; this.playbackState = 'stopped'; + this.reportError(error); }); }, renewIntervalMs); @@ -274,6 +353,51 @@ export default class AudioManager { } } + private observeConnection(connection: VoiceConnection): void { + connection.on('error', (error) => { + this.reportError(error); + }); + connection.on(VoiceConnectionStatus.Disconnected, () => this.handleDisconnectedConnection(connection)); + } + + private async handleDisconnectedConnection(connection: VoiceConnection): Promise { + try { + await Promise.race([ + entersState(connection, VoiceConnectionStatus.Signalling, DISCONNECT_RECOVERY_TIMEOUT_MS), + entersState(connection, VoiceConnectionStatus.Connecting, DISCONNECT_RECOVERY_TIMEOUT_MS), + ]); + } catch (error) { + if (this.connection !== connection) { + return; + } + + this.clearRenewTimer(); + this.stopCurrentPlayback(); + this.audioPlayer.stop(true); + this.connection = undefined; + if (connection.state.status !== VoiceConnectionStatus.Destroyed) { + connection.destroy(); + } + this.playbackState = 'stopped'; + this.reportError(error); + } + } + + private finishPlayback(resource: AudioResource): void { + if (this.resource !== resource) { + return; + } + + this.stopCurrentPlayback(); + if (this.playbackState !== 'disposed') { + this.playbackState = this.isConnected ? 'ready' : 'stopped'; + } + } + + private reportError(error: unknown): void { + this.options.onError?.(error instanceof Error ? error : new Error(String(error))); + } + private clearRenewTimer(): void { if (this.renewTimer) { clearTimeout(this.renewTimer); @@ -281,6 +405,11 @@ export default class AudioManager { } } + private cancelConnectAttempt(): void { + this.connectAttempt?.abort(); + this.connectAttempt = undefined; + } + private stopCurrentPlayback(): void { this.resource?.playStream.destroy(); this.resource = undefined; diff --git a/src/types.ts b/src/types.ts index 32fc04f..3d7d7fd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -143,9 +143,9 @@ export type AudioManagerOptions = { /** * Milliseconds after which the manager reconnects and restarts playback. * - * Set to `false` to disable renewal. + * Renewal is disabled unless an interval is provided. * - * @defaultValue `5_400_000` + * @defaultValue `false` */ renewIntervalMs?: number | false; @@ -156,6 +156,11 @@ export type AudioManagerOptions = { */ connectTimeoutMs?: number; + /** + * Receives asynchronous audio player and voice connection errors. + */ + onError?: (error: Error) => void; + /** * Optional inline volume configuration. */ diff --git a/tests/audio-manager.test.ts b/tests/audio-manager.test.ts index 129ec06..b266e26 100644 --- a/tests/audio-manager.test.ts +++ b/tests/audio-manager.test.ts @@ -1,5 +1,12 @@ import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; -import { createAudioResource, entersState, joinVoiceChannel } from '@discordjs/voice'; +import { + AudioPlayerStatus, + createAudioPlayer, + createAudioResource, + entersState, + joinVoiceChannel, + VoiceConnectionStatus, +} from '@discordjs/voice'; import { resolve } from 'node:path'; import { PassThrough } from 'node:stream'; @@ -10,18 +17,37 @@ import type { FfmpegProcessHandle } from '../src/ffmpeg'; import type { AudioSource, VoiceConnectionOptions } from '../src'; import type { VoiceConnection } from '@discordjs/voice'; +type MockListener = (...args: unknown[]) => unknown; + +const audioPlayerListeners = new Map(); +const connectionListeners = new Map(); +const secondConnectionListeners = new Map(); const mockAudioPlayer = { + on: jest.fn((event: string, listener: MockListener) => { + audioPlayerListeners.set(event, listener); + return mockAudioPlayer; + }), play: jest.fn(), pause: jest.fn(), unpause: jest.fn(), stop: jest.fn(), }; const mockConnection = { + state: { status: 'ready' }, + on: jest.fn((event: string, listener: MockListener) => { + connectionListeners.set(event, listener); + return mockConnection; + }), subscribe: jest.fn(), disconnect: jest.fn(), destroy: jest.fn(), }; const mockSecondConnection = { + state: { status: 'ready' }, + on: jest.fn((event: string, listener: MockListener) => { + secondConnectionListeners.set(event, listener); + return mockSecondConnection; + }), subscribe: jest.fn(), disconnect: jest.fn(), destroy: jest.fn(), @@ -45,6 +71,9 @@ const mockFfmpegHandle: FfmpegProcessHandle = { }; jest.mock('@discordjs/voice', () => ({ + AudioPlayerStatus: { + Idle: 'idle', + }, NoSubscriberBehavior: { Play: 'play', }, @@ -52,7 +81,11 @@ jest.mock('@discordjs/voice', () => ({ Raw: 'raw', }, VoiceConnectionStatus: { + Connecting: 'connecting', + Destroyed: 'destroyed', + Disconnected: 'disconnected', Ready: 'ready', + Signalling: 'signalling', }, createAudioPlayer: jest.fn(() => mockAudioPlayer), createAudioResource: jest.fn(() => mockAudioResource), @@ -79,15 +112,6 @@ const fileSourcePath = 'tests/audio.mp3'; const resolvedFileSourcePath = resolve(process.cwd(), fileSourcePath); type MockedEntersStateReturn = ReturnType; -function deferred(): { promise: Promise; resolve: (value: T) => void } { - let resolve!: (value: T) => void; - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve; - }); - - return { promise, resolve }; -} - function createMockFfmpegHandle(): FfmpegProcessHandle { return { process: { @@ -98,9 +122,24 @@ function createMockFfmpegHandle(): FfmpegProcessHandle { }; } +function listenerFor(listeners: Map, event: string): MockListener { + const listener = listeners.get(event); + + if (!listener) { + throw new Error(`No listener registered for ${event}.`); + } + + return listener; +} + describe('AudioManager', () => { beforeEach(() => { jest.clearAllMocks(); + audioPlayerListeners.clear(); + connectionListeners.clear(); + secondConnectionListeners.clear(); + mockConnection.state.status = VoiceConnectionStatus.Ready; + mockSecondConnection.state.status = VoiceConnectionStatus.Ready; }); afterEach(() => { @@ -130,6 +169,72 @@ describe('AudioManager', () => { expect(mockAudioPlayer.play).toHaveBeenCalledWith(mockAudioResource); }); + it('does not schedule connection renewal by default', async () => { + jest.useFakeTimers(); + const manager = new AudioManager({ connection: connectionOptions }); + + await manager.connect(); + + expect(jest.getTimerCount()).toBe(0); + manager.dispose(); + }); + + it('reports voice connection errors without interrupting playback', async () => { + const connectionError = new Error('voice connection failed'); + const onError = jest.fn(); + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + onError, + }); + await manager.start(); + + listenerFor(connectionListeners, 'error')(connectionError); + + expect(onError).toHaveBeenCalledWith(connectionError); + expect(manager.state).toBe('playing'); + }); + + it('keeps playback while a disconnected voice connection recovers', async () => { + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + }); + await manager.start(); + + await listenerFor(connectionListeners, VoiceConnectionStatus.Disconnected)(); + + expect(entersState).toHaveBeenCalledWith(mockVoiceConnection, VoiceConnectionStatus.Signalling, 5_000); + expect(entersState).toHaveBeenCalledWith(mockVoiceConnection, VoiceConnectionStatus.Connecting, 5_000); + expect(mockConnection.destroy).not.toHaveBeenCalled(); + expect(manager.state).toBe('playing'); + }); + + it('stops playback after an unrecoverable voice disconnect', async () => { + const disconnectError = new Error('voice connection did not recover'); + const onError = jest.fn(); + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + onError, + }); + await manager.start(); + jest.mocked(entersState).mockRejectedValueOnce(disconnectError).mockRejectedValueOnce(disconnectError); + mockConnection.state.status = VoiceConnectionStatus.Disconnected; + + await listenerFor(connectionListeners, VoiceConnectionStatus.Disconnected)(); + + expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1); + expect(mockAudioPlayer.stop).toHaveBeenCalledWith(true); + expect(mockConnection.destroy).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(disconnectError); + expect(manager.state).toBe('stopped'); + expect(manager.isConnected).toBe(false); + }); + it('cleans up when connection startup fails', async () => { jest.useFakeTimers(); @@ -148,9 +253,22 @@ describe('AudioManager', () => { expect(jest.getTimerCount()).toBe(0); }); + it('restores the stopped state when voice setup throws synchronously', async () => { + const connectionError = new Error('voice setup failed'); + jest.mocked(joinVoiceChannel).mockImplementationOnce(() => { + throw connectionError; + }); + const manager = new AudioManager({ connection: connectionOptions, renewIntervalMs: false }); + + await expect(manager.connect()).rejects.toBe(connectionError); + + expect(manager.state).toBe('stopped'); + expect(manager.isConnected).toBe(false); + }); + it('does not become ready when stopped during connection startup', async () => { jest.useFakeTimers(); - const ready = deferred(); + const ready = Promise.withResolvers(); jest.mocked(entersState).mockReturnValueOnce(ready.promise as unknown as MockedEntersStateReturn); const manager = new AudioManager({ connection: connectionOptions, @@ -168,9 +286,9 @@ describe('AudioManager', () => { expect(jest.getTimerCount()).toBe(0); }); - it('keeps the latest connection when concurrent connects resolve out of order', async () => { - const firstReady = deferred(); - const secondReady = deferred(); + it('keeps the latest connection when a stale connection attempt rejects', async () => { + const firstReady = Promise.withResolvers(); + const secondReady = Promise.withResolvers(); jest.mocked(joinVoiceChannel) .mockReturnValueOnce(mockVoiceConnection) .mockReturnValueOnce(mockSecondVoiceConnection); @@ -184,7 +302,7 @@ describe('AudioManager', () => { const firstConnect = manager.connect(); const secondConnect = manager.connect(); - firstReady.resolve(mockVoiceConnection); + firstReady.reject(new Error('stale connection failed')); secondReady.resolve(mockSecondVoiceConnection); await expect(firstConnect).rejects.toThrow(AudioManagerStateError); @@ -197,7 +315,7 @@ describe('AudioManager', () => { }); it('does not become ready when disposed during connection startup', async () => { - const ready = deferred(); + const ready = Promise.withResolvers(); jest.mocked(entersState).mockReturnValueOnce(ready.promise as unknown as MockedEntersStateReturn); const manager = new AudioManager({ connection: connectionOptions, @@ -206,7 +324,7 @@ describe('AudioManager', () => { const connectPromise = manager.connect(); manager.dispose(); - ready.resolve(mockVoiceConnection); + ready.reject(new Error('disposed connection failed')); await expect(connectPromise).rejects.toThrow(AudioManagerStateError); @@ -318,8 +436,44 @@ describe('AudioManager', () => { expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1); }); + it('cleans up and reports asynchronous audio player errors', async () => { + const playerError = Object.assign(new Error('audio stream failed'), { resource: mockAudioResource }); + const onError = jest.fn(); + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + onError, + }); + await manager.start(); + + listenerFor(audioPlayerListeners, 'error')(playerError); + + expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1); + expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(playerError); + expect(manager.state).toBe('ready'); + expect(manager.isPlaying).toBe(false); + }); + + it('returns to ready when the current audio resource becomes idle', async () => { + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + }); + await manager.start(); + + listenerFor(audioPlayerListeners, AudioPlayerStatus.Idle)({ resource: mockAudioResource }); + + expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1); + expect(mockAudioResource.playStream.destroy).toHaveBeenCalledTimes(1); + expect(manager.state).toBe('ready'); + expect(manager.isPlaying).toBe(false); + }); + it('does not report playback when stopped before ffmpeg is ready', async () => { - const ready = deferred(); + const ready = Promise.withResolvers(); jest.mocked(startFfmpeg).mockReturnValueOnce({ ...mockFfmpegHandle, ready: ready.promise, @@ -342,7 +496,7 @@ describe('AudioManager', () => { }); it('does not report playback when disposed before ffmpeg is ready', async () => { - const ready = deferred(); + const ready = Promise.withResolvers(); jest.mocked(startFfmpeg).mockReturnValueOnce({ ...mockFfmpegHandle, ready: ready.promise, @@ -365,7 +519,7 @@ describe('AudioManager', () => { }); it('keeps only the latest concurrent playback', async () => { - const firstReady = deferred(); + const firstReady = Promise.withResolvers(); const firstHandle = { ...createMockFfmpegHandle(), ready: firstReady.promise, @@ -461,6 +615,36 @@ describe('AudioManager', () => { expect(mockAudioResource.volume.setVolume).toHaveBeenCalledWith(0.35); }); + it('rejects invalid initial volume before allocating audio resources', () => { + expect( + () => + new AudioManager({ + volume: { + enabled: true, + initialPercent: 101, + }, + }), + ).toThrow(AudioManagerConfigError); + expect( + () => + new AudioManager({ + volume: { + enabled: false, + initialPercent: 50, + }, + }), + ).toThrow(AudioManagerConfigError); + + expect(createAudioPlayer).not.toHaveBeenCalled(); + expect(createAudioResource).not.toHaveBeenCalled(); + expect(startFfmpeg).not.toHaveBeenCalled(); + }); + + it('rejects timer delays that Node.js cannot schedule safely', () => { + expect(() => new AudioManager({ connectTimeoutMs: 0 })).toThrow(AudioManagerConfigError); + expect(() => new AudioManager({ renewIntervalMs: 2_147_483_648 })).toThrow(AudioManagerConfigError); + }); + it('rejects volume changes when inline volume is disabled', () => { const manager = new AudioManager({ renewIntervalMs: false }); @@ -617,6 +801,15 @@ describe('AudioManager', () => { expect(() => manager.setConnection(connectionOptions)).toThrow(AudioManagerStateError); }); + it('supports explicit resource management', () => { + const manager = (() => { + using disposableManager = new AudioManager({ renewIntervalMs: false }); + return disposableManager; + })(); + + expect(manager.state).toBe('disposed'); + }); + it('prevents use after disposal', async () => { const manager = new AudioManager({ connection: connectionOptions,