diff --git a/src/audio-manager.ts b/src/audio-manager.ts index a25b1e3..0271f35 100644 --- a/src/audio-manager.ts +++ b/src/audio-manager.ts @@ -108,6 +108,12 @@ export default class AudioManager { const resolvedSource = this.resolveSource(); this.stopCurrentPlayback(); this.ffmpeg = startFfmpeg(resolvedSource.input, this.options.ffmpeg); + try { + await this.ffmpeg.ready; + } catch (error) { + this.stopCurrentPlayback(); + throw error; + } this.resource = createAudioResource(this.ffmpeg.process.stdout, { inputType: StreamType.Raw, inlineVolume: this.options.volume?.enabled === true, diff --git a/src/ffmpeg.ts b/src/ffmpeg.ts index 7e6e891..e207920 100644 --- a/src/ffmpeg.ts +++ b/src/ffmpeg.ts @@ -3,7 +3,7 @@ import type { ChildProcessByStdio } from 'node:child_process'; import { createRequire } from 'node:module'; import type { Readable } from 'node:stream'; -import { AudioManagerConfigError } from './errors'; +import { AudioManagerConfigError, FfmpegProcessError } from './errors'; import type { FfmpegOptions } from './types'; const requireFromCurrentModule = createRequire(__filename); @@ -11,9 +11,11 @@ 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; +const STDERR_TAIL_BYTES = 4_096; export type FfmpegProcessHandle = { process: ChildProcessByStdio; + ready: Promise; stop(): void; }; @@ -50,18 +52,65 @@ export function startFfmpeg(input: string, options: FfmpegOptions = {}): FfmpegP ...(options.outputArgs ?? DEFAULT_OUTPUT_ARGS), ]; const childProcess = spawn(executable, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const ready = waitForFfmpegOutput(childProcess); - childProcess.once('error', () => undefined); childProcess.stderr.resume(); return { process: childProcess, + ready, stop: (): void => { stopProcess(childProcess); }, }; } +function waitForFfmpegOutput(childProcess: ChildProcessByStdio): Promise { + let stderrTail = ''; + + const appendStderr = (chunk: Buffer | string): void => { + stderrTail = (stderrTail + String(chunk)).slice(-STDERR_TAIL_BYTES); + }; + + childProcess.stderr.on('data', appendStderr); + + return new Promise((resolve, reject) => { + const cleanup = (): void => { + childProcess.off('error', onError); + childProcess.off('exit', onExit); + childProcess.stdout.off('readable', onReadable); + }; + + const fail = (message: string, cause?: unknown): void => { + cleanup(); + reject(new FfmpegProcessError(addStderrTail(message, stderrTail), cause)); + }; + + const onError = (error: Error): void => { + fail(`Unable to start ffmpeg. Cause: ${error.message}`, error); + }; + + const onExit = (code: number | null, signal: NodeJS.Signals | null): void => { + fail(`ffmpeg exited before producing audio. Exit code: ${code ?? 'none'}, signal: ${signal ?? 'none'}.`); + }; + + const onReadable = (): void => { + cleanup(); + resolve(); + }; + + childProcess.once('error', onError); + childProcess.once('exit', onExit); + childProcess.stdout.once('readable', onReadable); + }); +} + +function addStderrTail(message: string, stderrTail: string): string { + const trimmedTail = stderrTail.trim(); + + return trimmedTail ? `${message} stderr: ${trimmedTail}` : message; +} + function stopProcess(childProcess: ChildProcessByStdio): void { childProcess.stdout.destroy(); childProcess.stderr.destroy(); diff --git a/tests/audio-manager.test.ts b/tests/audio-manager.test.ts index 4d7a7f6..f874041 100644 --- a/tests/audio-manager.test.ts +++ b/tests/audio-manager.test.ts @@ -4,7 +4,7 @@ import { resolve } from 'node:path'; import { PassThrough } from 'node:stream'; import AudioManager from '../src/audio-manager'; -import { AudioManagerConfigError, AudioManagerStateError } from '../src'; +import { AudioManagerConfigError, AudioManagerStateError, FfmpegProcessError } from '../src'; import { startFfmpeg } from '../src/ffmpeg'; import type { AudioSource, VoiceConnectionOptions } from '../src'; @@ -31,6 +31,7 @@ const mockFfmpegHandle = { process: { stdout: new PassThrough(), }, + ready: Promise.resolve(), stop: jest.fn(), }; @@ -163,6 +164,26 @@ describe('AudioManager', () => { expect(manager.state).toBe('playing'); }); + it('does not report playback when ffmpeg startup fails', async () => { + const startupError = new FfmpegProcessError('Unable to start ffmpeg.'); + jest.mocked(startFfmpeg).mockReturnValueOnce({ + ...mockFfmpegHandle, + ready: Promise.reject(startupError), + }); + const manager = new AudioManager({ + connection: connectionOptions, + source: liveStreamSource, + renewIntervalMs: false, + }); + + await manager.connect(); + await expect(manager.play()).rejects.toBe(startupError); + + expect(manager.state).toBe('ready'); + expect(mockAudioPlayer.play).not.toHaveBeenCalled(); + expect(mockFfmpegHandle.stop).toHaveBeenCalledTimes(1); + }); + it('replaces active playback when a new source is played', async () => { const manager = new AudioManager({ connection: connectionOptions, diff --git a/tests/ffmpeg.test.ts b/tests/ffmpeg.test.ts index a68760d..d54e867 100644 --- a/tests/ffmpeg.test.ts +++ b/tests/ffmpeg.test.ts @@ -1,6 +1,7 @@ import { jest, describe, beforeEach, afterEach, it, expect } from '@jest/globals'; import { spawn } from 'node:child_process'; +import { FfmpegProcessError } from '../src'; import { resolveFfmpegExecutable, startFfmpeg } from '../src/ffmpeg'; jest.mock('node:child_process', () => ({ @@ -12,12 +13,16 @@ const mockSpawn = jest.mocked(spawn); type MockChildProcess = { stdout: { destroy: jest.Mock; + once: jest.Mock; + off: jest.Mock; }; stderr: { destroy: jest.Mock; + on: jest.Mock; resume: jest.Mock; }; once: jest.Mock; + off: jest.Mock; removeAllListeners: jest.Mock; kill: jest.Mock; killed: boolean; @@ -29,12 +34,16 @@ function createMockChildProcess(): MockChildProcess { return { stdout: { destroy: jest.fn(), + once: jest.fn(), + off: jest.fn(), }, stderr: { destroy: jest.fn(), + on: jest.fn(), resume: jest.fn(), }, once: jest.fn(), + off: jest.fn(), removeAllListeners: jest.fn(), kill: jest.fn(), killed: false, @@ -88,9 +97,39 @@ describe('ffmpeg helpers', () => { { stdio: ['ignore', 'pipe', 'pipe'] }, ); expect(childProcess.once).toHaveBeenCalledWith('error', expect.any(Function)); + expect(childProcess.once).toHaveBeenCalledWith('exit', expect.any(Function)); + expect(childProcess.stdout.once).toHaveBeenCalledWith('readable', expect.any(Function)); + expect(childProcess.stderr.on).toHaveBeenCalledWith('data', expect.any(Function)); expect(childProcess.stderr.resume).toHaveBeenCalledTimes(1); }); + it('rejects readiness when ffmpeg startup emits an error', async () => { + const childProcess = createMockChildProcess(); + mockSpawn.mockReturnValue(childProcess); + + const handle = startFfmpeg('tests/audio.mp3'); + const errorHandler = childProcess.once.mock.calls.find(([event]) => event === 'error')?.[1]; + + errorHandler(new Error('spawn ENOENT')); + + await expect(handle.ready).rejects.toThrow(FfmpegProcessError); + await expect(handle.ready).rejects.toThrow('spawn ENOENT'); + }); + + it('includes stderr when ffmpeg exits before producing audio', async () => { + const childProcess = createMockChildProcess(); + mockSpawn.mockReturnValue(childProcess); + + const handle = startFfmpeg('tests/audio.mp3'); + const stderrHandler = childProcess.stderr.on.mock.calls.find(([event]) => event === 'data')?.[1]; + const exitHandler = childProcess.once.mock.calls.find(([event]) => event === 'exit')?.[1]; + + stderrHandler('invalid input'); + exitHandler(1, null); + + await expect(handle.ready).rejects.toThrow('invalid input'); + }); + it('uses custom executable and argument overrides when provided', () => { const childProcess = createMockChildProcess(); mockSpawn.mockReturnValue(childProcess);