UNPKG

discord-audio-stream

Version:

A small Discord voice audio streaming library with managed ffmpeg playback.

370 lines (365 loc) 11.2 kB
// node_modules/tsup/assets/esm_shims.js import path from "path"; import { fileURLToPath } from "url"; var getFilename = () => fileURLToPath(import.meta.url); var __filename = /* @__PURE__ */ getFilename(); // src/audio-manager.ts import { createAudioPlayer, createAudioResource, entersState, joinVoiceChannel, NoSubscriberBehavior, StreamType, VoiceConnectionStatus } from "@discordjs/voice"; import { isAbsolute, resolve } from "path"; // src/errors.ts var AudioManagerError = class extends Error { constructor(message) { super(message); this.name = new.target.name; } }; var AudioManagerConfigError = class extends AudioManagerError { }; var AudioManagerStateError = class extends AudioManagerError { }; var FfmpegProcessError = class extends AudioManagerError { constructor(message, cause) { super(message); this.cause = cause; } cause; }; // src/ffmpeg.ts import { spawn } from "child_process"; import { createRequire } from "module"; var requireFromCurrentModule = createRequire(__filename); var DEFAULT_INPUT_ARGS = ["-hide_banner", "-loglevel", "error", "-nostdin"]; var DEFAULT_OUTPUT_ARGS = ["-vn", "-f", "s16le", "-ar", "48000", "-ac", "2", "pipe:1"]; var FORCE_KILL_TIMEOUT_MS = 2e3; var STDERR_TAIL_BYTES = 4096; function resolveFfmpegExecutable(options = {}) { if (options.executablePath?.trim()) { return options.executablePath; } if ((options.mode ?? "native") === "native") { return "ffmpeg"; } try { const executable = requireFromCurrentModule("ffmpeg-static"); 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."); } function startFfmpeg(input, options = {}) { 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"] }); const ready = waitForFfmpegOutput(childProcess); childProcess.stderr.resume(); return { process: childProcess, ready, stop: () => { stopProcess(childProcess); } }; } function waitForFfmpegOutput(childProcess) { let stderrTail = ""; const appendStderr = (chunk) => { stderrTail = (stderrTail + String(chunk)).slice(-STDERR_TAIL_BYTES); }; childProcess.stderr.on("data", appendStderr); return new Promise((resolve2, reject) => { const cleanup = () => { childProcess.off("error", onError); childProcess.off("exit", onExit); childProcess.stdout.off("readable", onReadable); }; const fail = (message, cause) => { cleanup(); reject(new FfmpegProcessError(addStderrTail(message, stderrTail), cause)); }; const onError = (error) => { fail(`Unable to start ffmpeg. Cause: ${error.message}`, error); }; const onExit = (code, signal) => { fail(`ffmpeg exited before producing audio. Exit code: ${code ?? "none"}, signal: ${signal ?? "none"}.`); }; const onReadable = () => { cleanup(); resolve2(); }; childProcess.once("error", onError); childProcess.once("exit", onExit); childProcess.stdout.once("readable", onReadable); }); } function addStderrTail(message, stderrTail) { const trimmedTail = stderrTail.trim(); return trimmedTail ? `${message} stderr: ${trimmedTail}` : message; } function stopProcess(childProcess) { 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(); } // src/audio-manager.ts var DEFAULT_CONNECT_TIMEOUT_MS = 2e4; var DEFAULT_RENEW_INTERVAL_MS = 54e5; var AudioManager = class { audioPlayer; connection; resource; ffmpeg; renewTimer; playbackState = "idle"; connectionOptions; audioSource; options; constructor(options = {}) { 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 } }); } get state() { return this.playbackState; } get isPlaying() { return this.playbackState === "playing"; } get isConnected() { return Boolean(this.connection); } setConnection(options) { this.assertNotDisposed(); this.connectionOptions = options; } setSource(source) { this.assertNotDisposed(); this.audioSource = source; } async connect() { this.assertNotDisposed(); if (!this.connectionOptions) { throw new AudioManagerConfigError("Voice connection options are required before connecting."); } 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); try { await entersState(connection, VoiceConnectionStatus.Ready, this.options.connectTimeoutMs); } catch (error) { connection.destroy(); if (this.connection === connection) { this.connection = void 0; } this.playbackState = "stopped"; throw error; } if (this.connection !== connection) { throw new AudioManagerStateError("Voice connection was stopped before it became ready."); } this.playbackState = "ready"; this.scheduleRenewal(); } async play(source) { 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); const ffmpeg = this.ffmpeg; try { await ffmpeg.ready; if (this.ffmpeg !== ffmpeg) { throw new AudioManagerStateError("Playback was stopped before ffmpeg became ready."); } this.resource = createAudioResource(ffmpeg.process.stdout, { inputType: StreamType.Raw, inlineVolume: this.options.volume?.enabled === true }); } catch (error) { if (this.ffmpeg === ffmpeg) { this.stopCurrentPlayback(); } throw error; } if (this.options.volume?.enabled === true && this.options.volume.initialPercent !== void 0) { this.setVolume(this.options.volume.initialPercent); } try { this.audioPlayer.play(this.resource); this.playbackState = "playing"; } catch (error) { this.stopCurrentPlayback(); this.playbackState = "ready"; throw error; } } async start() { await this.connect(); await this.play(); } pause() { this.assertNotDisposed(); if (this.playbackState !== "playing") { throw new AudioManagerStateError("Audio can only be paused while it is playing."); } this.audioPlayer.pause(); this.playbackState = "paused"; } resume() { this.assertNotDisposed(); if (this.playbackState !== "paused") { throw new AudioManagerStateError("Audio can only be resumed while it is paused."); } this.audioPlayer.unpause(); this.playbackState = "playing"; } async stop() { if (this.playbackState === "disposed") { return; } this.clearRenewTimer(); this.stopCurrentPlayback(); this.audioPlayer.stop(true); this.connection?.disconnect(); this.connection?.destroy(); this.connection = void 0; this.playbackState = "stopped"; } setVolume(volumeInPercent) { this.assertNotDisposed(); if (this.options.volume?.enabled !== true) { 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."); } if (!this.resource?.volume) { throw new AudioManagerStateError("No audio resource with volume control is currently active."); } this.resource.volume.setVolume(volumeInPercent / 100); } dispose() { if (this.playbackState === "disposed") { return; } this.clearRenewTimer(); this.stopCurrentPlayback(); this.audioPlayer.stop(true); this.connection?.destroy(); this.connection = void 0; this.connectionOptions = void 0; this.audioSource = void 0; this.playbackState = "disposed"; } resolveSource() { 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)}`); } } return { input: isAbsolute(this.audioSource.path) ? this.audioSource.path : resolve(process.cwd(), this.audioSource.path), source: this.audioSource }; } scheduleRenewal() { const renewIntervalMs = this.options.renewIntervalMs ?? DEFAULT_RENEW_INTERVAL_MS; if (renewIntervalMs === false) { return; } this.renewTimer = setTimeout(() => { void this.start().catch(() => { this.clearRenewTimer(); this.stopCurrentPlayback(); this.audioPlayer.stop(true); this.connection?.disconnect(); this.connection?.destroy(); this.connection = void 0; this.playbackState = "stopped"; }); }, renewIntervalMs); if (typeof this.renewTimer.unref === "function") { this.renewTimer.unref(); } } clearRenewTimer() { if (this.renewTimer) { clearTimeout(this.renewTimer); this.renewTimer = void 0; } } stopCurrentPlayback() { this.resource?.playStream.destroy(); this.resource = void 0; this.ffmpeg?.stop(); this.ffmpeg = void 0; } assertNotDisposed() { if (this.playbackState === "disposed") { throw new AudioManagerStateError("AudioManager has been disposed."); } } }; export { AudioManager, AudioManagerConfigError, AudioManagerError, AudioManagerStateError, FfmpegProcessError };