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.
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:
- 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. - Your backend calls AudD’s standard recognition endpoint with the
official
@audd/sdkand returns a compact JSON answer. - 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.crxand 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
testworks 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 —
recognizereturnsnull. 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
/identifywith 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.
