discord-audio-stream
Version:
A small Discord voice audio streaming library with managed ffmpeg playback.
387 lines (381 loc) • 12.4 kB
JavaScript
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
AudioManager: () => AudioManager,
AudioManagerConfigError: () => AudioManagerConfigError,
AudioManagerError: () => AudioManagerError,
AudioManagerStateError: () => AudioManagerStateError,
FfmpegProcessError: () => FfmpegProcessError
});
module.exports = __toCommonJS(index_exports);
// src/audio-manager.ts
var import_voice = require("@discordjs/voice");
var import_node_path = require("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
var import_node_child_process = require("child_process");
var import_node_module = require("module");
var requireFromCurrentModule = (0, import_node_module.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 = (0, import_node_child_process.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 = (0, import_voice.createAudioPlayer)({
behaviors: {
noSubscriber: import_voice.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 = (0, import_voice.joinVoiceChannel)({
guildId: this.connectionOptions.guildId,
channelId: this.connectionOptions.channelId,
adapterCreator: this.connectionOptions.adapterCreator
});
this.connection = connection;
connection.subscribe(this.audioPlayer);
try {
await (0, import_voice.entersState)(connection, import_voice.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 = (0, import_voice.createAudioResource)(ffmpeg.process.stdout, {
inputType: import_voice.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: (0, import_node_path.isAbsolute)(this.audioSource.path) ? this.audioSource.path : (0, import_node_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.");
}
}
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
AudioManager,
AudioManagerConfigError,
AudioManagerError,
AudioManagerStateError,
FfmpegProcessError
});