How RuneBeats streams audio to Discord — FFmpeg, Opus, and a source router
The path a /play command takes through RuneBeats — source detection, metadata resolution, fresh stream URLs, and the FFmpeg → Opus → Discord pipeline.
When someone types /play never gonna give you up in a Discord server running
RuneBeats, a surprising amount happens before the first
note plays. Here’s the whole path, from command to voice channel.
Step 1 — figure out what the input even is
The user can paste a YouTube link, a SoundCloud set, a Spotify URL, a direct .mp3,
or just words to search for. sourceDetector.js sorts that out with a few ordered
checks:
if (/youtube\.com|youtu\.be/i.test(trimmed)) return 'youtube';
if (/soundcloud\.com/i.test(trimmed)) return 'soundcloud';
if (/spotify\.com\/(track|album|playlist)\//i...) return 'spotify';
// …a real URL ending in a known audio extension → direct_url
// …any other http(s) URL → direct_url
// …otherwise → search
Plain text falls through every URL check and becomes a search. It’s deliberately boring code — boring is good for a router that everything else depends on.
Step 2 — resolve metadata (yt-dlp first, ytdl-core as backup)
For YouTube, RuneBeats shells out to the yt-dlp binary and asks for JSON:
const args = [
'--dump-json', '--no-playlist', '--no-warnings',
'--format', 'bestaudio[ext=webm]/bestaudio/best',
input, // a URL, or "ytsearch1:<query>" for searches
];
yt-dlp is the primary path because it’s resilient and handles search, playlists,
and format selection in one call. If it fails on a direct URL, the code falls back
to the ytdl-core npm package. Two extractors, one interface — the rest of the
bot never knows or cares which one answered.
Step 3 — the detail that bites everyone: stream URLs expire
Here’s the non-obvious part. A YouTube media URL from yt-dlp is signed and
expires after a few hours. If RuneBeats resolved the stream URL when the song
was queued and you had a long queue, playback would 403 by the time it came up.
So resolution happens twice. At queue time it stores metadata; at playback time it fetches a fresh URL:
// createYouTubeStream(song) — called the moment the song actually plays
const info = await ytDlpInfo(song.url);
return info.url; // fresh, non-expired stream URL
If that re-fetch fails, it falls back to the stored URL rather than dying outright.
Step 4 — FFmpeg turns it into something Discord can speak
Discord’s voice gateway wants Opus. RuneBeats pipes the source through the
bundled ffmpeg-static binary with a deliberately small set of flags:
const args = [
'-reconnect', '1', // survive flaky network mid-stream
'-reconnect_streamed', '1',
'-reconnect_delay_max', '5',
'-i', input, // or pipe:0 for piped sources
'-vn', // drop video
'-ar', '48000', '-ac', '2', // 48 kHz stereo — Discord's native rate
'-b:a', '128k',
'-f', 'opus', 'pipe:1', // Opus out, to stdout
];
spawn(ffmpegPath, args);
The -reconnect flags matter more than they look: they let a stream survive a
brief network hiccup instead of ending the song. -ar 48000 matches Discord’s
voice sample rate exactly, so there’s no resampling surprise downstream.
Step 5 — hand the Opus stream to @discordjs/voice
FFmpeg’s stdout becomes a Discord audio resource:
const resource = createAudioResource(rawStream, {
inputType: StreamType.OggOpus,
inlineVolume: true,
});
resource.volume?.setVolumeLogarithmic(volume / 100);
inlineVolume is what makes /volume work, and logarithmic scaling is the
honest choice — it tracks how loudness is actually perceived, so 50% sounds like
half, not a quarter. From here @discordjs/voice encrypts and ships Opus packets
over UDP to Discord’s voice servers, which fan them out to everyone in the channel.
The player is created with NoSubscriberBehavior.Pause, so if everyone leaves the
channel, it pauses instead of burning CPU talking to nobody.
The shape of it
/play ─▶ detectSource ─▶ resolve (yt-dlp│ytdl-core) ─▶ Song in queue
│
(at playback) fresh URL ▼
FFmpeg ─▶ Opus ─▶ @discordjs/voice ─▶ Discord UDP
Nothing here is exotic — that’s the point. Lean on yt-dlp for the hard extraction
problem, lean on FFmpeg for the hard audio problem, and keep the glue in between
small and legible. Want to run it yourself? The
setup is on the product page, and the
source is on GitHub.