← All posts

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.