Build a now-playing widget for a livestream
Show the currently-playing song on a livestream overlay using AudD audio streams, with a webhook callback receiver or longpoll, plus a copy-paste HTML widget.
If you run a music livestream — a Twitch DJ set, a YouTube radio channel, an Icecast web radio — you often want to show viewers what’s playing right now. This recipe wires AudD’s audio-streams API to a small web widget: AudD recognizes songs off your stream as they play, your backend receives each match, and an overlay renders the artist, title, and cover art.
What you’ll build
Three moving parts:
- A stream registered with AudD. You tell AudD the URL of your
livestream once (a direct HLS/Icecast URL, or a
twitch:<channel>/youtube:<video_id>shortcut). AudD ingests it continuously and fingerprints the audio. - A way to consume matches. AudD delivers recognized songs to you in one of two ways: it POSTs a callback to a URL you host (the recommended path), or you longpoll an HTTP endpoint (when you can’t host a public receiver — a browser, a laptop behind NAT). This recipe shows both.
- A widget. A self-contained HTML/JS overlay that polls your backend for the current song and renders it. Drop it into an OBS browser source or embed it on a web page.
The streams API lives on https://api.audd.io/. By default AudD sends a
callback after a song finishes (so it can report total play time); pass
callbacks=before if you want the match the moment a song starts — which is
what you want for a live “now playing” display.
Prerequisites
- An API token from dashboard.audd.io. Audio
streams are a paid add-on billed per stream per month; the
testtoken does not work on the streams endpoints, so use your real token here. - Node.js 18+ with the official SDK:
npm install @audd/sdk - A livestream to recognize. For a quick test you can point AudD at any
public radio URL, e.g.
https://npr-ice.streamguys1.com/live.mp3.
Walkthrough
Step 1: Set the account callback URL (once)
A callback URL is configured per account, not per stream — every stream on the account POSTs its results to the same URL. Set it once before you add any streams.
import { AudD } from "@audd/sdk";
const audd = new AudD(process.env.AUDD_API_TOKEN!); // dashboard.audd.io
await audd.streams.setCallbackUrl(
"https://your-app.example.com/audd-callback",
{ returnMetadata: ["apple_music", "spotify"] }, // adds streaming links
);
returnMetadata is optional. Including apple_music / spotify means each
callback carries that provider’s block with streaming URLs and IDs. Cover art
doesn’t need it: every match has a songLink (a lis.tn URL), and appending
?thumb to it returns the cover image — that’s what the widget below uses, so
it works whether or not you request provider blocks.
Even if you only plan to longpoll, the account still needs a callback URL. Longpoll delivers nothing until one is set. If you have no real receiver, set it to
https://audd.tech/empty/— a no-op URL that accepts and discards the POSTs:await audd.streams.setCallbackUrl("https://audd.tech/empty/");
Step 2: Add your stream
Register the livestream with streams.add. You pick a radioId — any integer
— as your handle for this stream; you’ll see it on every match so you can
tell streams apart.
const radioId = 1; // your handle for this stream
// A direct stream URL:
await audd.streams.add({
url: "https://npr-ice.streamguys1.com/live.mp3",
radioId,
callbacks: "before", // deliver at song START — best for "now playing"
});
AudD also recognizes Twitch and YouTube live streams natively. Instead of a raw media URL, pass a shortcut:
// Twitch channel:
await audd.streams.add({ url: "twitch:monstercat", radioId: 2, callbacks: "before" });
// YouTube video id (from youtube.com/watch?v=5qap5aO4i9A):
await audd.streams.add({ url: "youtube:5qap5aO4i9A", radioId: 3, callbacks: "before" });
// YouTube channel's current live stream:
await audd.streams.add({ url: "youtube-ch:UC3zwjSYv4k5HKGXCHMpjVRg", radioId: 4, callbacks: "before" });
To change a stream’s URL later use audd.streams.setUrl(radioId, url), and
audd.streams.delete(radioId) to remove it. audd.streams.list() enumerates
everything on the account.
callbacks: "before" delivers the match as soon as a song starts, which is
what a live display needs. The tradeoff: a “before” callback doesn’t carry the
song’s total played time (AudD doesn’t know it yet). For a now-playing widget
you don’t need play time, so “before” is the right choice.
Step 3a: Receive matches with a callback (recommended)
If you can host a public HTTPS endpoint, callbacks are the lowest-latency, lowest-effort path. AudD POSTs a JSON body to your URL for every recognized song and every stream-lifecycle notification. The SDK parses the body for you.
import express from "express";
import { handleCallback } from "@audd/sdk";
const app = express();
app.use(express.json());
// In-memory "current song" per stream. Use Redis in production.
const nowPlaying = new Map<number, unknown>();
app.post("/audd-callback", async (req, res) => {
try {
const { match, notification } = await handleCallback(req);
if (match) {
const s = match.song;
nowPlaying.set(match.radioId, {
artist: s.artist,
title: s.title,
cover: s.songLink ? `${s.songLink}?thumb` : null, // lis.tn cover-art
songLink: s.songLink, // universal lis.tn link
at: Date.now(),
});
} else if (notification) {
// 650 = can't connect to the stream; 651 = only white noise
console.warn("stream", notification.notificationCode, notification.notificationMessage);
}
res.sendStatus(200); // ALWAYS 200 — see note below
} catch (err) {
console.error("bad callback body", err);
res.sendStatus(400);
}
});
// The widget polls this:
app.get("/now-playing/:radioId", (req, res) => {
res.json(nowPlaying.get(Number(req.params.radioId)) ?? null);
});
app.listen(8080);
When a song starts on the stream, AudD POSTs to /audd-callback, you cache
it, and /now-playing/1 returns the current track as JSON.
Always respond
200 OKto a callback. If your endpoint errors or is unreachable, AudD queues the callbacks and re-sends the backlog once you recover — so a transient bug won’t lose matches, but it will delay them.
Step 3b: Receive matches with longpoll (no public server)
When you can’t expose a public URL — you’re prototyping on a laptop, the
widget runs in a browser, the backend sits behind NAT — longpoll is the
alternative. Your process holds a GET https://api.audd.io/longpoll/ request
open and matches arrive over that connection.
const radioId = 1;
const poll = await audd.streams.longpoll({ radioId, timeout: 50 });
for await (const m of poll.matches) {
console.log("now playing:", m.song.artist, "—", m.song.title);
// update the same nowPlaying cache your widget reads
}
longpoll returns a handle with three independent async-iterables —
matches, notifications, and errors — fed by a background loop. Call
poll.close() to stop it. timeout is the server-side hold in seconds
(default 50); a longer hold means fewer reconnects.
Remember the rule from Step 1: longpoll returns nothing unless the account
has a callback URL set. The SDK preflights this on your first longpoll
call and raises AudDInvalidRequestError if it’s missing. Set
https://audd.tech/empty/ if you have no real receiver.
Step 4: The widget
This is a self-contained overlay. It polls your /now-playing/:radioId
endpoint every few seconds and re-renders when the song changes. Save it as
widget.html and point an OBS Browser source at the file (or host it and
load the URL).
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Now Playing</title>
<style>
body { margin: 0; background: transparent; font-family: system-ui, sans-serif; }
.np {
display: flex; align-items: center; gap: 14px;
padding: 12px 16px; border-radius: 12px;
background: rgba(0,0,0,.65); color: #fff; width: max-content;
box-shadow: 0 4px 20px rgba(0,0,0,.4);
}
.np img { width: 56px; height: 56px; border-radius: 8px; object-fit: cover; }
.np .meta { line-height: 1.3; }
.np .title { font-weight: 600; font-size: 16px; }
.np .artist { opacity: .85; font-size: 14px; }
.np.hidden { display: none; }
</style>
</head>
<body>
<div id="np" class="np hidden">
<img id="cover" alt="" />
<div class="meta">
<div id="title" class="title"></div>
<div id="artist" class="artist"></div>
</div>
</div>
<script>
const BACKEND = "https://your-app.example.com"; // your Step 3 server
const RADIO_ID = 1;
const POLL_MS = 5000;
const el = {
box: document.getElementById("np"),
cover: document.getElementById("cover"),
title: document.getElementById("title"),
artist: document.getElementById("artist"),
};
let current = null;
async function tick() {
try {
const res = await fetch(`${BACKEND}/now-playing/${RADIO_ID}`);
const song = await res.json();
if (!song) { el.box.classList.add("hidden"); current = null; return; }
const key = `${song.artist} - ${song.title}`;
if (key === current) return; // unchanged
current = key;
el.title.textContent = song.title ?? "";
el.artist.textContent = song.artist ?? "";
if (song.cover) { el.cover.src = song.cover; el.cover.style.display = ""; }
else { el.cover.style.display = "none"; }
el.box.classList.remove("hidden");
} catch (e) {
console.error("poll failed", e);
}
}
tick();
setInterval(tick, POLL_MS);
</script>
</body>
</html>
Embedding in OBS. Add a Browser source, point it at the hosted
widget.html URL (or the local file), and set the canvas width/height to fit
the overlay. The transparent background means it composites cleanly over your
scene.
AudD-hosted widgets. If you’d rather not build a frontend, AudD hosts an
embeddable widget at widget.audd.tech that renders the last recognized song
(and optionally a history strip) directly from a longpoll subscription. You
pass it a stream identifier as a URL parameter — see Going further.
What you get back
A callback body for a recognized song looks like this (the SDK parses it into
a StreamCallbackMatch):
{
"status": "success",
"result": {
"radio_id": 1,
"timestamp": "2020-04-13 10:31:43",
"play_length": 111,
"results": [
{
"artist": "Alan Walker, A$AP Rocky",
"title": "Live Fast (PUBGM)",
"album": "Live Fast (PUBGM)",
"release_date": "2019-07-25",
"label": "MER Recordings",
"score": 100,
"song_link": "https://lis.tn/LiveFastPUBGM"
}
]
}
}
| Field | Meaning for the widget |
|---|---|
radio_id | Which stream this match is for — your handle from streams.add. |
timestamp | When AudD recognized the song. |
play_length | Total seconds the song played. Absent with callbacks=before (the song hasn’t finished yet). |
results[0].artist, .title, .album | What’s playing — the core of the widget. |
results[0].song_link | A universal lis.tn link to the track; good as a click-through. |
results[0].score | Match confidence. |
A notification body arrives instead of a result when something happens to
the stream itself — code 0 means all is well, 650 means AudD can’t
connect to the stream, 651 means it’s receiving only white noise. Surface
650/651 so you know to fix the stream URL.
If you set returnMetadata in Step 1, each result also carries the requested
provider blocks (e.g. apple_music, spotify) with their streaming URLs and
IDs. Any field AudD returns that the SDK doesn’t expose as a typed property is
available on the result’s extras map.
Handling errors
For a now-playing widget the failure modes are narrow:
- Authentication errors (
AudDAuthenticationError) — bad or missing token, or the streams add-on isn’t enabled on your account. Fail at startup. - Invalid-request errors (
AudDInvalidRequestError) — most commonly the longpoll preflight firing because no callback URL is set. Set one (a real URL orhttps://audd.tech/empty/) and retry. - Stream notifications (codes
650/651) — not exceptions, but delivered as notification callbacks. A650usually means your stream URL went stale; update it withsetUrl. - Connection errors (
AudDConnectionError) — transient. The longpoll loop reconnects on its own; for callbacks, AudD queues and re-sends.
import { AudDInvalidRequestError } from "@audd/sdk";
try {
const poll = await audd.streams.longpoll({ radioId: 1 });
// ...
} catch (err) {
if (err instanceof AudDInvalidRequestError) {
// almost always: no callback URL on the account
await audd.streams.setCallbackUrl("https://audd.tech/empty/");
} else {
throw err;
}
}
Going further
- No-code widget. AudD’s hosted widget at
https://widget.audd.tech/?ch=-<category>&background&history&shadowrenders the last recognized song and a history strip without any frontend code of your own. Thechparameter is the stream’s longpoll category with a-prefix; you can derive it server-side without shipping your token to the browser. - Multiple streams on one overlay. Each stream has its own
radioId; cache perradioIdand let the widget pick which one to show. - History strip. Keep the last N matches per stream instead of just the current one, and render a “recently played” list.
- For the difference between recognizing against AudD’s public 160M-track database and your own uploaded catalog, see Public database vs. your custom catalog.
Related
Reading this as an AI agent? The raw Markdown is at recipes/now-playing-widget.md, and the full index is /resources/llms.txt.
