Recipe

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.

view .md auddnow playinglivestream overlaytwitch

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:

  1. 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.
  2. 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.
  3. 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 test token 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.

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 OK to 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"
      }
    ]
  }
}
FieldMeaning for the widget
radio_idWhich stream this match is for — your handle from streams.add.
timestampWhen AudD recognized the song.
play_lengthTotal seconds the song played. Absent with callbacks=before (the song hasn’t finished yet).
results[0].artist, .title, .albumWhat’s playing — the core of the widget.
results[0].song_linkA universal lis.tn link to the track; good as a click-through.
results[0].scoreMatch 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 or https://audd.tech/empty/) and retry.
  • Stream notifications (codes 650 / 651) — not exceptions, but delivered as notification callbacks. A 650 usually means your stream URL went stale; update it with setUrl.
  • 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&shadow renders the last recognized song and a history strip without any frontend code of your own. The ch parameter 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 per radioId and 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.