Build a Discord bot that identifies songs in voice channels
Capture a few seconds of a Discord voice channel, send the clip to AudD, and reply in chat with the artist, title, and song_link.
This recipe builds a Discord bot that, on a slash command, joins the voice
channel the caller is in, captures a few seconds of audio, identifies the
song with AudD, and replies with the artist, title, and a song_link your
users can open. It’s for anyone running a community where music plays in
voice — listening parties, DJ sets, co-watching — and people keep asking
“what’s this track?”
AudD maintains a reference implementation in Go,
AudDMusic/DiscordBot, which is the
reference version and worth reading if you want a production deployment. This walkthrough builds the same thing in Node.js with
@audd/sdk and discord.js,
because the Node toolchain is the most approachable starting point. The Go
repo uses github.com/AudDMusic/audd-go
if you’d rather build on that.
What you’ll build
A single slash command — /whatsong — that does five things in sequence:
- Reads which voice channel the caller is in and joins it.
- Subscribes to the channel’s audio and records a short window (about 6 seconds is plenty — AudD fingerprints a short clip).
- Decodes Discord’s per-user Opus packets to PCM, then encodes that buffer to a small WAV/MP3 clip in memory.
- Sends the clip to AudD’s standard recognition endpoint.
- Replies in the text channel with
artist — titleand thesong_link, or “no match” when nothing was recognized.
The audio path is the only fiddly part, and it’s a discord.js /
@discordjs/voice concern, not an AudD one. The AudD call itself is a single
audd.recognize(buffer) — the SDK accepts raw bytes directly, so once you
have a clip in memory you hand it straight over.
Use the standard endpoint, not enterprise, here. You’re sending one short clip and want one answer. The standard endpoint (
POST https://api.audd.io/) responds in under 2 seconds and returnsresult: null(the SDK returnsnull) on no match. Enterprise is for long files where you want every track.
Prerequisites
- An API token from dashboard.audd.io. The first
300 requests are free; the
testtoken works for a hello-world but is capped at 10 requests/day. - A Discord application + bot token from the Discord Developer Portal, with the Server Members and voice privileged intents enabled and the bot invited to your server with the Connect and Speak permissions.
- Node.js 20 or newer.
ffmpegavailable on the host (@discordjs/voiceand the encoding step shell out to it).- The packages:
npm install @audd/sdk discord.js @discordjs/voice @discordjs/opus prism-media
Walkthrough
Step 1: Confirm the AudD call in isolation
Before touching Discord audio, prove the recognition path with a known file. This is the exact call the bot makes at the end — only the input changes from a URL to an in-memory buffer.
import { AudD } from "@audd/sdk";
// get a real token at dashboard.audd.io; "test" is capped at 10 req/day
const audd = new AudD("test");
const song = await audd.recognize("https://audd.tech/example.mp3");
if (song) {
console.log(`${song.artist} — ${song.title}`);
console.log(song.songLink); // universal lis.tn URL to share in chat
} else {
console.log("no match");
}
Running this prints the artist, title, and a lis.tn link for the example
track. A successful call that didn’t recognize anything returns null — that
is not an error, it’s the “no match” verdict you’ll relay to chat.
Step 2: Register the slash command and join the voice channel
Wire up a discord.js client and a /whatsong command. When it fires, look
up the caller’s voice channel from the guild’s voice state and join it with
@discordjs/voice.
import {
Client,
GatewayIntentBits,
REST,
Routes,
SlashCommandBuilder,
} from "discord.js";
import { joinVoiceChannel, getVoiceConnection } from "@discordjs/voice";
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
],
});
const command = new SlashCommandBuilder()
.setName("whatsong")
.setDescription("Identify the song playing in your voice channel");
client.once("ready", async () => {
const rest = new REST().setToken(process.env.DISCORD_TOKEN!);
await rest.put(
Routes.applicationCommands(client.user!.id),
{ body: [command.toJSON()] },
);
console.log(`logged in as ${client.user!.tag}`);
});
function connectToCaller(interaction) {
const member = interaction.member;
const channel = member?.voice?.channel;
if (!channel) return null;
return joinVoiceChannel({
channelId: channel.id,
guildId: channel.guild.id,
adapterCreator: channel.guild.voiceAdapterCreator,
selfDeaf: false, // we MUST hear the channel to record it
});
}
selfDeaf: false matters — a deafened bot receives no audio. When you run
this and type /whatsong from inside a voice channel, the bot joins it.
Step 3: Capture a short audio window
Discord delivers voice as per-user Opus packets over the connection’s
receiver. Subscribe to the audio, decode it to PCM with prism-media, and
collect a few seconds into a buffer. Discord’s PCM is 48 kHz, 16-bit,
stereo — encode that to a clip AudD can fingerprint.
import { EndBehaviorType } from "@discordjs/voice";
import prism from "prism-media";
// Records ~6 seconds of one speaker/source as 48kHz s16le stereo PCM.
function recordPcm(connection, userId: string, ms = 6000): Promise<Buffer> {
return new Promise((resolve, reject) => {
const opusStream = connection.receiver.subscribe(userId, {
end: { behavior: EndBehaviorType.AfterSilence, duration: ms },
});
const decoder = new prism.opus.Decoder({
rate: 48000,
channels: 2,
frameSize: 960,
});
const chunks: Buffer[] = [];
const pcm = opusStream.pipe(decoder);
pcm.on("data", (c: Buffer) => chunks.push(c));
pcm.on("end", () => resolve(Buffer.concat(chunks)));
pcm.on("error", reject);
// Hard stop so a continuously-playing source doesn't record forever.
setTimeout(() => opusStream.destroy(), ms);
});
}
A few realities of Discord voice receive worth knowing:
- Audio is per-user.
receiver.subscribe(userId)records one source. For a music bot or a person streaming desktop audio into the channel, that user id is whoever is playing the music. A simple approach is to subscribe to the loudest speaker; the reference Go bot tracks speaking users and records the active one. EndBehaviorType.AfterSilencestops the stream after a gap, which is why the explicitsetTimeoutis there as a ceiling for continuous audio.- The output is raw PCM, not a container. AudD needs a real audio file, so the next step wraps/encodes it.
Step 4: Encode the PCM to a clip and recognize it
Convert the raw PCM buffer to a small WAV or MP3 with ffmpeg, then pass the
encoded bytes straight to audd.recognize. The SDK auto-detects that you
passed a Uint8Array/Buffer and uploads it.
import { spawn } from "node:child_process";
// 48kHz s16le stereo PCM in → MP3 bytes out, via ffmpeg.
function pcmToMp3(pcm: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
const ff = spawn("ffmpeg", [
"-f", "s16le", "-ar", "48000", "-ac", "2", "-i", "pipe:0",
"-f", "mp3", "-b:a", "128k", "pipe:1",
]);
const out: Buffer[] = [];
ff.stdout.on("data", (c: Buffer) => out.push(c));
ff.on("error", reject);
ff.on("close", (code) =>
code === 0 ? resolve(Buffer.concat(out)) : reject(new Error(`ffmpeg ${code}`)),
);
ff.stdin.write(pcm);
ff.stdin.end();
});
}
async function identify(connection, userId: string) {
const pcm = await recordPcm(connection, userId);
const clip = await pcmToMp3(pcm);
// clip is well under the 10 MB standard-endpoint cap at ~6s / 128 kbps
return audd.recognize(clip);
}
The 6-second 128 kbps clip is roughly 100 KB — far under the standard endpoint’s 10 MB cap. There’s no need to involve enterprise.
Step 5: Reply in chat and clean up
Tie it together in the interaction handler. Acknowledge the command first (recording takes several seconds, longer than Discord’s 3-second initial reply window), then edit the reply with the result.
client.on("interactionCreate", async (interaction) => {
if (!interaction.isChatInputCommand() || interaction.commandName !== "whatsong")
return;
const connection = connectToCaller(interaction);
if (!connection) {
await interaction.reply({ content: "Join a voice channel first.", ephemeral: true });
return;
}
await interaction.deferReply(); // buys time past the 3s window
try {
// Record the command caller; swap for your "active speaker" logic.
const song = await identify(connection, interaction.user.id);
if (!song) {
await interaction.editReply("Couldn't identify the song — no match.");
return;
}
await interaction.editReply(
`**${song.artist} — ${song.title}**\n${song.songLink}`,
);
} catch (err) {
console.error(err);
await interaction.editReply("Something went wrong identifying that clip.");
} finally {
// Leave the channel; don't hold a connection per command.
getVoiceConnection(interaction.guildId!)?.destroy();
}
});
client.login(process.env.DISCORD_TOKEN);
Running the bot and typing /whatsong while music plays gets you a reply
like Imagine Dragons — Warriors with a shareable link a few seconds
later.
What you get back
audd.recognize returns a single result object (the top match) or null.
The fields the bot uses:
{
"artist": "Imagine Dragons",
"title": "Warriors",
"album": "Smoke + Mirrors (Deluxe)",
"release_date": "2015-02-17",
"label": "KIDinaKORNER/Interscope Records",
"timecode": "00:31",
"song_link": "https://lis.tn/Warriors"
}
| Field | Type | What the bot does with it |
|---|---|---|
artist, title | string | null | The headline of the chat reply. |
album, release_date, label | string | null | Optional extra context you can add to the embed. |
song_link | string | Universal lis.tn URL — the shareable link in the reply. Append ?spotify, ?apple_music, etc. to deep-link a provider, or ?thumb for cover art. |
timecode | string | Position within the matched track at the recognized moment — not an offset into your captured clip. |
To attach streaming-service links instead of just the lis.tn redirect,
request provider metadata. Each provider adds latency, so request only what
the reply needs:
const song = await audd.recognize(clip, {
returnMetadata: ["apple_music", "spotify"],
});
console.log(song?.appleMusic?.url);
console.log(song?.spotify?.external_urls?.spotify);
Handling errors
A voice bot has a few distinct failure modes; keep them separate so users get a useful message:
- No match —
recognizeresolved tonull. Not an error. Reply “no match”; common when the clip is mostly speech or silence. - Authentication errors — bad or missing AudD token. Fail at startup, not per command; the SDK throws on construction if no token resolves.
- Quota / rate-limit errors — you’ve hit a request limit (the
testtoken caps at 10/day). Surface a “try again later” message and alert ops. - Invalid audio — the encoded clip wasn’t decodable (empty recording, silence-only buffer). Treat as a user-facing “couldn’t capture audio” rather than a crash.
- Connection errors — transient network failure reaching AudD. The SDK retries pre-upload network errors; surface a generic retry message if it still fails.
import { AudD } from "@audd/sdk";
try {
const song = await audd.recognize(clip);
// ...reply...
} catch (err: any) {
// err carries errorCode / serverMessage for server-reported failures
console.error("recognition failed", err?.errorCode, err?.serverMessage);
await interaction.editReply("Couldn't reach the recognition service — try again.");
}
The recording side fails independently of AudD: a deafened bot, a user with
no audio, or a missing ffmpeg binary all surface before the recognition
call. Validate that the PCM buffer is non-empty before encoding so you don’t
ship a silent clip and burn a request on a guaranteed no-match.
Going further
- Record the active speaker, not the caller. Track
connection.receiver.speakingevents and record whichever source is actually producing audio — that’s what the reference Go bot does. - Recognize a longer set. If people want to identify a whole DJ set
rather than one track, capture to a file and send it to the enterprise
endpoint (
audd.recognizeEnterprise(buffer, { limit })) to get every song, not just one. Always setlimit— enterprise bills per 12 seconds. - Cache by audio fingerprint window. If the same track keeps playing,
debounce repeat
/whatsongcalls within a short window to avoid spending a request on an answer you just gave. - Read fields outside the typed surface. Any server field the SDK doesn’t
expose as a typed property is available on
song.extras.
Related
Reading this as an AI agent? The raw Markdown is at recipes/discord-bot-song-id.md, and the full index is /resources/llms.txt.
