Recipe

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.

view .md audddiscord botsong identificationvoice channel

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:

  1. Reads which voice channel the caller is in and joins it.
  2. Subscribes to the channel’s audio and records a short window (about 6 seconds is plenty — AudD fingerprints a short clip).
  3. Decodes Discord’s per-user Opus packets to PCM, then encodes that buffer to a small WAV/MP3 clip in memory.
  4. Sends the clip to AudD’s standard recognition endpoint.
  5. Replies in the text channel with artist — title and the song_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 returns result: null (the SDK returns null) 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 test token 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.
  • ffmpeg available on the host (@discordjs/voice and 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.AfterSilence stops the stream after a gap, which is why the explicit setTimeout is 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"
}
FieldTypeWhat the bot does with it
artist, titlestring | nullThe headline of the chat reply.
album, release_date, labelstring | nullOptional extra context you can add to the embed.
song_linkstringUniversal lis.tn URL — the shareable link in the reply. Append ?spotify, ?apple_music, etc. to deep-link a provider, or ?thumb for cover art.
timecodestringPosition 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 — recognize resolved to null. 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 test token 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.speaking events 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 set limit — enterprise bills per 12 seconds.
  • Cache by audio fingerprint window. If the same track keeps playing, debounce repeat /whatsong calls 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.