---
title: "Build a Discord bot that identifies songs in voice channels"
description: "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."
slug: "/resources/recipes/discord-bot-song-id"
section: "recipes"
keywords: [audd, discord bot, song identification, voice channel, music recognition]
---

# Build a Discord bot that identifies songs in voice channels

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](https://github.com/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`](https://www.npmjs.com/package/@audd/sdk) and `discord.js`,
because the Node toolchain is the most approachable starting point. The Go
repo uses [`github.com/AudDMusic/audd-go`](https://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](https://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](https://discord.com/developers/applications),
  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:

```bash
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.

```ts
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`.

```ts
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.

```ts
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.

```ts
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.

```ts
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:

```json
{
  "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:

```ts
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.

```ts
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](https://github.com/AudDMusic/DiscordBot) 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**

- [Build a copyright scanner for user-uploaded content](/resources/recipes/ugc-copyright-scanner)
- [Scan an archive of audio files and write metadata back](/resources/recipes/bulk-audio-archive-scan)
- [Node.js SDK docs](https://docs.audd.io/sdks/node)
- [API reference](https://docs.audd.io)