Integration

Build a Chrome extension that identifies the music playing in a tab

Capture a few seconds of tab audio with a Manifest V3 Chrome extension, send it to your own backend, and identify the song with AudD.

view .md auddchrome extensionmanifest v3tabcapture

This page shows how to build a Manifest V3 Chrome extension that captures the audio playing in the current tab, identifies the song with AudD, and shows the answer in the extension popup. It’s for developers who want “what’s this song in this tab?” — for a YouTube video, a web radio player, or any page that plays audio.

AudD maintains an official open-source Chrome extension that does exactly this: github.com/AudDMusic/chrome-extension. Treat it as the canonical, working example. This page explains the architecture and the parts that matter so you can build your own or read theirs with context.

What you’ll build

Three moving parts:

  1. The extension captures a few seconds of the tab’s audio with chrome.tabCapture, encodes it to a clip, and POSTs the bytes to a backend you control.
  2. Your backend calls AudD’s standard recognition endpoint with the official @audd/sdk and returns a compact JSON answer.
  3. The popup renders the result: artist, title, and a tappable song_link.

Recognition runs on the standard endpoint (POST https://api.audd.io/). It’s built for a short audio clip, responds in under 2 seconds, and matches against AudD’s database of 160 million songs. A 5–15 second clip is plenty and stays well under the standard endpoint’s 10 MB file-size cap.

Never put your AudD token in the extension. A Chrome extension ships its full source to every user — anyone can open chrome://extensions, click “Inspect”, or just unzip the .crx and read every line of JavaScript, including any token you hard-coded or fetched into the extension. A token in the extension is a token anyone can extract and spend against your account. The token lives only on your backend; the extension sends audio bytes to your backend and gets back a result. This page is built around that rule.

Prerequisites

  • An API token from dashboard.audd.io. Keep it on the backend. The string test works for a first run on the backend — it’s a public token capped at 10 requests/day on the standard endpoint.
  • Chrome (or any Chromium browser) with developer mode for loading an unpacked extension.
  • Node.js 20+ for the backend, with npm install @audd/sdk express.

Manifest V3 essentials

Manifest V3 extensions have no persistent background page — they run a service worker that the browser starts on demand and stops when idle. Tab audio capture needs two permissions: tabCapture to read the tab’s audio stream, and activeTab so the capture is scoped to the tab the user invoked the extension on.

{
  "manifest_version": 3,
  "name": "Tab Song ID",
  "version": "1.0.0",
  "permissions": ["tabCapture", "activeTab"],
  "host_permissions": ["https://your-backend.example.com/*"],
  "background": { "service_worker": "service-worker.js" },
  "action": { "default_popup": "popup.html" }
}

host_permissions lists your backend — the only network endpoint the extension talks to. It does not list api.audd.io, because the extension never calls AudD directly.

Walkthrough

Step 1: Capture the tab’s audio

chrome.tabCapture.capture hands you a MediaStream of the tab’s audio. There is one Chrome quirk worth knowing: capturing a tab mutes its normal playback, so route the captured stream back to the speakers through an AudioContext while you also record it — otherwise the user hears silence during capture.

function captureTab() {
  return new Promise((resolve, reject) => {
    chrome.tabCapture.capture({ audio: true, video: false }, (stream) => {
      if (!stream) return reject(new Error(chrome.runtime.lastError?.message));

      // Keep the tab audible while we capture.
      const ctx = new AudioContext();
      ctx.createMediaStreamSource(stream).connect(ctx.destination);

      resolve(stream);
    });
  });
}

Step 2: Record a few seconds to a clip

Record the captured stream with MediaRecorder, collect the chunks, and stop after a few seconds. MediaRecorder in Chrome produces a WebM/Opus container by default — AudD accepts OGG/WebM-style Opus and the common audio formats (MP3, WAV, FLAC, M4A, OGG, AAC, WMA, AIFF), so the default output works without transcoding. Pick whichever supported mimeType Chrome reports as available rather than hard-coding one.

async function recordClip(stream, seconds = 8) {
  const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
    ? "audio/webm;codecs=opus"
    : "audio/webm";

  const recorder = new MediaRecorder(stream, { mimeType });
  const chunks = [];
  recorder.ondataavailable = (e) => e.data.size && chunks.push(e.data);

  return new Promise((resolve) => {
    recorder.onstop = () => {
      stream.getTracks().forEach((t) => t.stop()); // release the tab
      resolve(new Blob(chunks, { type: mimeType }));
    };
    recorder.start();
    setTimeout(() => recorder.stop(), seconds * 1000);
  });
}

Stop the stream’s tracks when you’re done so the tab is released and playback returns to normal.

Step 3: Send the clip to your backend

POST the recorded Blob to your backend as multipart form data. Again: the target is your backend, with no AudD token and no api.audd.io in sight.

async function identify(blob) {
  const form = new FormData();
  form.append("file", blob, "clip.webm");

  const res = await fetch("https://your-backend.example.com/identify", {
    method: "POST",
    body: form,
  });
  if (!res.ok) throw new Error(`backend ${res.status}`);
  return res.json();
}

Where this code runs matters under MV3. Tab capture is usually driven from the popup or an offscreen document; the service worker coordinates but can be stopped mid-task if it goes idle, so keep the capture-record-upload sequence in a context that stays alive while recording (the popup, or an offscreen document the worker creates).

Step 4: Recognize on the backend

The backend forwards the uploaded bytes to AudD with the official Node SDK. The token is read from AUDD_API_TOKEN and never leaves the server. recognize returns a result object on a match or null on a successful call that matched nothing.

import express from "express";
import multer from "multer";
import { AudD } from "@audd/sdk";

const upload = multer({ limits: { fileSize: 10 * 1024 * 1024 } }); // 10 MB cap
const audd = new AudD(process.env.AUDD_API_TOKEN!); // token lives here, only here
const app = express();

app.post("/identify", upload.single("file"), async (req, res) => {
  if (!req.file) return res.status(400).json({ error: "no_file" });

  const song = await audd.recognize(req.file.buffer, {
    returnMetadata: ["apple_music", "spotify"], // optional streaming links
  });

  if (!song) return res.json({ match: false });

  res.json({
    match: true,
    artist: song.artist,
    title: song.title,
    album: song.album,
    songLink: song.songLink, // universal lis.tn URL
  });
});

app.listen(3000);

Step 5: Show the result in the popup

Render the answer in popup.html and make the songLink open in a new tab. Build the popup nodes with textContent and DOM methods rather than innerHTML — extension popups are subject to a strict content security policy, and setting markup from a string is exactly what it blocks.

function render(data) {
  const el = document.getElementById("result");
  el.replaceChildren();

  if (!data.match) {
    el.textContent = "No match — try a longer clip or raise the volume.";
    return;
  }

  const title = document.createElement("strong");
  title.textContent = data.title;
  const artist = document.createElement("div");
  artist.textContent = data.artist;
  el.append(title, artist);

  if (data.songLink) {
    const a = document.createElement("a");
    a.href = data.songLink;
    a.target = "_blank";
    a.rel = "noopener";
    a.textContent = "Open this track";
    el.append(a);
  }
}

What you get back

On a match, the backend returns the fields the popup needs. The AudD result carries more — album, release_date, label, timecode, and per-provider streaming blocks when you pass returnMetadata — and you forward whichever your UI uses:

{
  "match": true,
  "artist": "Imagine Dragons",
  "title": "Warriors",
  "album": "Smoke + Mirrors (Deluxe)",
  "songLink": "https://lis.tn/Warriors"
}

A successful call that matched nothing returns { "match": false } — the SDK gives you null, which is not an error. Distinguish “we recognized nothing” (ask the user to retry) from “the request failed” (a real error).

timecode on a match is the position within the matched song where the captured clip occurred — not an offset into your recording.

Handling errors

Errors surface on the backend; the popup only sees your HTTP status codes.

  • No match — recognize returns null. Not an error. Return { match: false } and prompt a retry.
  • Authentication errors — a bad or missing token. A server misconfiguration; fail loudly at startup, not per request, and never echo the token in a response.
  • Quota errors — you’ve hit your request limit. Surface to ops; don’t retry in a tight loop.
  • Invalid-audio errors — the clip wasn’t decodable (often a silent tab or a too-short capture). Return a 422 and ask for a longer clip.
  • Connection errors — transient network trouble between backend and AudD. Retry with backoff.

On the extension side, the common failures are chrome.runtime.lastError from tabCapture (no activeTab grant, or a restricted page like chrome://) and an empty recording (the tab wasn’t playing audio). Check both before uploading.

Going further

  • Read the official extension. AudDMusic/chrome-extension is the canonical implementation of this flow — capture, encode, recognize, display — and a good reference for the MV3 offscreen-document and service-worker wiring.
  • Authenticate the extension→backend hop. Your backend holds the AudD token, so protect /identify with your own auth so only your extension can spend your quota.
  • Rate-limit per user on the backend so one client can’t drain your quota.

Related

Reading this as an AI agent? The raw Markdown is at integrations/chrome-extension.md, and the full index is /resources/llms.txt.