audd-node
Recognize music in audio clips, long broadcasts, and live streams from Node.js.
npm install @audd/sdk
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}`);
The package is dual ESM + CJS, ships its own TypeScript types (no @types/...), and requires Node.js 20 or newer. Yarn / pnpm work the same: yarn add @audd/sdk / pnpm add @audd/sdk.
Authentication
Get your API token at dashboard.audd.io.
The token is resolved on construction:
- Explicit constructor argument:
new AudD("token")ornew AudD({ apiToken: "token" }). AUDD_API_TOKENenvironment variable.- Otherwise the constructor throws, with a message pointing at the dashboard and the env-var name.
import { AudD } from "@audd/sdk";
const audd = new AudD("your-api-token");
// or, with AUDD_API_TOKEN exported:
const audd2 = new AudD();
The string "test" is a public token capped at 10 requests/day for standard recognition only — useful for the first hello-world.
Token rotation
Long-running services that pull tokens from a secret manager can rotate without rebuilding the client:
audd.setApiToken(nextToken);
In-flight requests finish on the previous token; subsequent calls use the new one. Empty strings are rejected. audd.apiToken returns the current value.
Recognize a clip
Standard recognition — clips up to 25 seconds, 10 MB. Returns a RecognitionResult on a match, null on a successful call that didn't match.
// URL
const song = await audd.recognize("https://audd.tech/example.mp3");
// Filesystem path (Node only)
await audd.recognize("/var/lib/clips/track.mp3");
// URL object
await audd.recognize(new URL("https://audd.tech/example.mp3"));
// Bytes
import { readFile } from "node:fs/promises";
const buf = await readFile("./clip.mp3");
await audd.recognize(buf);
// Blob / File (browser or Node)
const blob = new Blob([buf], { type: "audio/mpeg" });
await audd.recognize(blob);
Source is the union of every accepted form: string | URL | Blob | Uint8Array. The SDK auto-detects which one you passed and re-opens it on retry.
To get streaming-service metadata back, pass return:
const song = await audd.recognize(source, {
return: ["apple_music", "spotify"],
});
Reading the result:
const song = await audd.recognize(source, { return: ["apple_music", "spotify"] });
if (!song) return;
console.log(song.artist, song.title, song.album);
console.log(song.releaseDate, song.label, song.songLink);
console.log(song.timecode); // position in your clip
console.log(song.streamingUrl("apple_music")); // direct link
console.log(song.streamingUrls()); // every resolvable provider
console.log(song.previewUrl()); // 30-second preview
console.log(song.thumbnailUrl); // cover art, or null
// Server fields the typed schema doesn't know about:
console.log(song.extras["some_unannounced_key"]);
console.log(song.rawResponse); // full payload
recognize returns the top match. The full field reference — including provider sub-models, ISRC/UPC (enterprise plan only), and the helper methods (streamingUrl, streamingUrls, previewUrl) — lives in the GitHub README.
Process a long audio file
For files longer than 25 seconds (broadcasts, podcasts, full DJ sets), use the enterprise endpoint. The server fingerprints the upload in chunks; the SDK flattens every match into a single array.
const matches = await audd.recognizeEnterprise("./set.mp3", {
limit: 50,
accurateOffsets: true,
});
for (const m of matches) {
console.log(m.timecode, m.score, m.artist, "—", m.title);
}
Always set limit during development. The unbounded default can ingest many hours of audio in a single call.
Other useful options: skip (every Nth chunk), every (process every Nth chunk only), skipFirstSeconds, useTimecode, accurateOffsets. The default per-call timeout is one hour, because enterprise uploads stream and process server-side over minutes; override via opts.timeoutMs. Pass opts.signal (AbortSignal) to cancel a multi-hour call.
EnterpriseMatch carries score, timecode, the standard metadata fields (artist, title, album, releaseDate, label, songLink, isrc, upc), startOffset / endOffset, plus extras and rawResponse for forward compatibility.
Get streaming-service metadata
Pass return to populate provider blocks. Valid values: apple_music, spotify, deezer, napster, musicbrainz. Each adds latency.
const song = await audd.recognize(source, {
return: ["apple_music", "spotify", "deezer"],
});
console.log(song?.appleMusic?.url);
console.log(song?.spotify?.external_urls?.spotify);
console.log(song?.deezer?.link);
Provider blocks are populated only when you request them via return. Their shape mirrors the upstream provider's track object — see the upstream API docs (Apple Music, Spotify, Deezer, Napster, MusicBrainz) for full field references, or the GitHub README for the SDK's typed surface. The full provider list lives in the AudD HTTP API docs.
The convenience methods streamingUrl(provider) and streamingUrls() resolve direct provider URLs when you requested the metadata block, and fall back to a lis.tn redirect ({songLink}?{provider}) when songLink is hosted on lis.tn.
Monitor a live audio stream
A stream is a long-running upstream audio source — a radio URL, a live-TV ingest, an HLS / Icecast / SHOUTcast endpoint — that the AudD server pulls from continuously and recognizes against. You identify each stream with a radioId you pick.
There are two ways to consume recognition events:
- Callbacks — AudD POSTs each match (and each lifecycle notification) to a URL on your server. Push-style. The default and the recipe most users want.
- Longpoll — your code holds a GET open against
/longpoll/and receives matches as they happen. Pull-style. For browsers, mobile clients, and anything behind NAT.
Both consumption modes live under audd.streams. The recipes below cover each.
Receive callbacks from a stream
The full setup is three calls — register a callback URL, add the stream, and parse incoming bodies in your web app.
// 1. Register where AudD should POST recognition events
await audd.streams.setCallbackUrl(
"https://example.com/audd-callback",
{ returnMetadata: ["apple_music", "spotify"] },
);
// 2. Add the stream
const radioId = 1; // any integer you choose — your handle for this stream
await audd.streams.add({
url: "https://radio.example.com/live.mp3",
radioId,
});
// 3. In your web app, parse incoming POSTs (see the Express recipe below)
setCallbackUrl registers one callback URL per account — every stream on the account POSTs there. returnMetadata populates provider blocks in the callback payload.
add accepts url, radioId, and an optional callbacks: "before" to deliver at song start instead of song end. Use audd.streams.setUrl(radioId, url) to update an existing stream's upstream URL, audd.streams.delete(radioId) to remove it, and audd.streams.list() to enumerate them.
Express recipe
Express is the canonical Node.js framework for the receiver:
import express from "express";
import { handleCallback } from "@audd/sdk";
const app = express();
app.use(express.json()); // populates req.body
app.post("/audd-callback", async (req, res) => {
try {
const { match, notification } = await handleCallback(req);
if (match) {
console.log(match.song.artist, "—", match.song.title);
} else if (notification) {
console.log(
"notification",
notification.notificationCode,
notification.notificationMessage,
);
}
res.sendStatus(200);
} catch (err) {
console.error("invalid callback body", err);
res.sendStatus(400);
}
});
app.listen(8080);
handleCallback(req) reads the body off the request and parses it. It works on Web Request, http.IncomingMessage, Express / Fastify requests with a JSON middleware, and plain { body } shapes — duck-typed. Returns a ParsedCallback:
interface ParsedCallback {
match: StreamCallbackMatch | null;
notification: StreamCallbackNotification | null;
}
Exactly one field is non-null on success.
If you already have the body bytes (replaying from a queue, webhook proxy, etc.), use parseCallback(body) instead — pure function, no I/O. Throws AudDSerializationError if the body is malformed or carries neither result nor notification.
StreamCallbackMatch.song is always present (the top match). match.alternatives may carry variant catalog releases of the same recording — they're not lower-confidence guesses, they're alternate releases (e.g. a "feat." credit vs. the bare-artist re-release). StreamCallbackNotification carries notificationCode / notificationMessage for stream-lifecycle events.
Other frameworks (Fastify, Koa, Next.js API routes, etc.) — see the GitHub README.
Longpoll preflight
If no callback URL is configured for the account, longpoll might never return any events despite successful song recognition. The SDK preflights this on your first streams.longpoll(...) call and raises AudDInvalidRequestError with guidance if it's missing; pass skipCallbackCheck: true once you've set one.
If you only want longpoll and have no real receiver, set https://audd.tech/empty/ — it's a placeholder URL that discards incoming POSTs:
await audd.streams.setCallbackUrl("https://audd.tech/empty/");
Poll for stream events (longpoll)
When you can't expose a public callback URL — browsers, mobile clients, intranet services — open a longpoll subscription instead. Your code holds a GET open and matches arrive over HTTP.
const radioId = 1; // any integer you choose — your handle for this stream
const poll = await audd.streams.longpoll({ radioId, timeout: 50 });
for await (const m of poll.matches) {
console.log(m.song.artist, "—", m.song.title);
}
longpoll(...) returns a handle with three independent async-iterables filled by a background loop:
interface LongpollPoll {
readonly matches: AsyncIterable<StreamCallbackMatch>;
readonly notifications: AsyncIterable<StreamCallbackNotification>;
readonly errors: AsyncIterable<Error>;
close(): void;
[Symbol.asyncDispose](): Promise<void>;
}
matches carries recognition events, notifications carries stream-lifecycle events ("stream stopped", "can't connect", ...), and errors is single-shot — the first terminal failure closes all three iterables. Server keepalives are silently absorbed.
Read the three streams concurrently:
const poll = await audd.streams.longpoll({ radioId });
await Promise.all([
(async () => {
for await (const m of poll.matches) {
console.log("match", m.song.artist, m.song.title);
}
})(),
(async () => {
for await (const n of poll.notifications) {
console.log("notif", n.notificationCode, n.notificationMessage);
}
})(),
(async () => {
for await (const e of poll.errors) {
console.error("longpoll error", e);
}
})(),
]);
Stop the loop with poll.close() — idempotent. With Explicit Resource Management (Node 20.4+, TS 5.2+):
await using poll = await audd.streams.longpoll({ radioId });
for await (const m of poll.matches) { /* ... */ }
// poll.close() runs at scope exit
LongpollOptions: sinceTime (resume from a server timestamp), timeout (server-side longpoll seconds, default 50), skipCallbackCheck (bypass the preflight when you've already verified setup elsewhere — useful when running many consumers).
Tokenless consumers
When the consumer is a browser widget, a React Native app, or an Electron renderer, you don't want to ship the API token to the client. Your server mints an opaque per-stream identifier and hands only that to the client; the client subscribes with the identifier alone.
// On your server, with the api_token in scope:
import { deriveLongpollCategory } from "@audd/sdk";
app.get("/stream-handle/:radioId", (req, res) => {
const handle = deriveLongpollCategory(process.env.AUDD_API_TOKEN!, +req.params.radioId);
res.json({ handle });
});
In the browser / mobile / embedded code, import from the @audd/sdk/longpoll sub-entry — it tree-shakes the auth client and Node helpers out of the bundle, leaving a small fetch-only consumer:
import { LongpollConsumer } from "@audd/sdk/longpoll";
const { handle } = await fetch("/stream-handle/1").then((r) => r.json());
const consumer = new LongpollConsumer(handle);
const poll = consumer.iterate({ timeout: 30 });
for await (const m of poll.matches) {
document.querySelector("#now-playing")!.textContent =
`${m.song.artist} — ${m.song.title}`;
}
The handle alone authorizes the subscription. The API token never leaves your server.
Add a song to your custom catalog
The custom-catalog endpoint adds songs to a private fingerprint database for your account. After upload, recognize() calls on the same account can match against your tracks.
Custom-catalog access is gated. Contact api@audd.io to enable it. Calls without access raise AudDCustomCatalogAccessError with a contact-us message.
const audioId = 1; // any integer you choose — your reference to the song
await audd.customCatalog.add({
audioId,
source: "/var/lib/own-tracks/track-1.mp3",
});
source accepts the same forms as recognize. Re-using an audioId re-fingerprints that slot. There is no public list / delete — track audioId ↔ song mappings on your side.
Handle errors
Idiomatic error handling
Every error raised by the SDK is a subclass of AudDError. The class hierarchy lets you handle whole families with one catch:
import {
AudD,
AudDAuthenticationError,
AudDQuotaError,
AudDRateLimitError,
AudDInvalidAudioError,
AudDCustomCatalogAccessError,
AudDAPIError,
AudDConnectionError,
} from "@audd/sdk";
try {
const song = await audd.recognize(source);
// ...
} catch (err) {
if (err instanceof AudDAuthenticationError) {
throw new Error(`check your token: [#${err.errorCode}] ${err.serverMessage}`);
} else if (err instanceof AudDQuotaError) {
console.warn(`quota exhausted: ${err.serverMessage}`);
} else if (err instanceof AudDRateLimitError) {
console.warn(`rate-limited: ${err.serverMessage}`);
} else if (err instanceof AudDInvalidAudioError) {
console.warn(`audio rejected: ${err.serverMessage}`);
} else if (err instanceof AudDCustomCatalogAccessError) {
console.warn("custom catalog disabled — email api@audd.io");
} else if (err instanceof AudDAPIError) {
console.error(`AudD #${err.errorCode}: ${err.serverMessage} (request_id=${err.requestId})`);
} else if (err instanceof AudDConnectionError) {
console.error(`network: ${err.message}`);
} else {
throw err;
}
}
Every AudDAPIError carries errorCode, serverMessage, httpStatus, requestId (quote in support tickets), requestedParams, requestMethod, brandedMessage, and rawResponse. AudDConnectionError exposes the underlying error via .cause. AudDSerializationError exposes the raw body via .rawText.
Retry behavior
Every call goes through a typed retry policy. The class depends on the operation:
| Class | Methods | Retries on |
|---|---|---|
read | streams.list, streams.getCallbackUrl, longpoll polls | 408, 429, 5xx, pre-upload connection failures, post-upload aborts |
recognition | recognize, recognizeEnterprise, advanced.* | 5xx, pre-upload connection failures only |
mutating | streams.add, streams.setUrl, streams.delete, streams.setCallbackUrl, customCatalog.add | pre-upload connection failures only |
recognition and mutating deliberately don't retry once the upload has finished — repeating a metered call after the server has done the work would double-bill, and repeating a state-changing call after the side effect may have already happened could compound it.
Defaults: maxRetries = 3, backoffFactorMs = 500, backoffMaxMs = 30_000. Backoff is min(backoffFactorMs * 2^attempt, backoffMaxMs) with [0.5x, 1.5x] jitter. AudDAPIError and its subclasses are NOT retried — once the server has parsed the request enough to return a typed error, retrying is pointless.
Override at construction time:
new AudD("token", { maxRetries: 5, backoffFactorMs: 1000 });
Configuration
Timeouts
| Endpoint | Default per-call timeout |
|---|---|
Standard recognition (recognize, streams, custom catalog, advanced) | 60_000 ms |
Enterprise recognition (recognizeEnterprise) | 3_600_000 ms (1 hour) |
Override per call via opts.timeoutMs:
await audd.recognize(source, { timeoutMs: 10_000 });
await audd.recognizeEnterprise(source, { timeoutMs: 30 * 60_000 });
The timeout is an AbortController wired into fetch. When it fires the SDK raises AudDConnectionError("Request was aborted (timeout)").
Custom HTTP client
The SDK uses the global fetch by default. Inject your own (typed as FetchLike = typeof globalThis.fetch) for proxy / mTLS / instrumentation:
import { fetch as undiciFetch, ProxyAgent } from "undici";
const dispatcher = new ProxyAgent("http://proxy.local:3128");
const proxiedFetch: typeof fetch = (input, init) =>
undiciFetch(input, { ...init, dispatcher }) as unknown as Promise<Response>;
const audd = new AudD("token", { fetch: proxiedFetch });
The custom fetch is used for both the standard endpoint and the enterprise endpoint. The longpoll loop uses it too; LongpollConsumer accepts the same option.
The SDK sets the User-Agent header on every request: audd-node/<version> node/<node-version> (<platform>). AudD ops uses this to identify SDK traffic. The token is never logged or sent as a header — multipart uploads carry it as a form field.
Cancellation
Pass an AbortSignal via opts.signal to cancel an in-flight call:
const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000);
try {
await audd.recognize(source, { signal: ac.signal });
} catch (err) {
if (err instanceof AudDConnectionError) {
console.log("aborted");
}
}
For recognizeEnterprise, this is the right hook to bail out of multi-hour calls.
Cancelling a recognize() might still consume a request on the server. Once your bytes have left the SDK, the server might finish processing and bill the request anyway — AbortSignal only stops the local Promise from settling.
Observability
The onEvent constructor hook receives plain-data events at the request boundary:
type AudDEventKind = "request" | "response" | "exception";
interface AudDEvent {
kind: AudDEventKind;
method: string; // "recognize", "addStream", ...
url: string;
requestId: string | null;
httpStatus: number | null;
elapsedMs: number | null;
errorCode: number | null;
extras: Record<string, unknown>;
}
Example — feeding to an OpenTelemetry histogram:
import { metrics } from "your-otel-setup";
const histogram = metrics.createHistogram("audd_request_ms");
const audd = new AudD("token", {
onEvent: (e) => {
if (e.kind === "response" && e.elapsedMs !== null) {
histogram.record(e.elapsedMs, {
method: e.method,
status: String(e.httpStatus ?? 0),
});
}
},
});
Exceptions thrown from the hook are swallowed — observability hooks must never break a request. The hook never receives the API token or request body bytes.
Token rotation
See Token rotation under Authentication. audd.setApiToken(newToken) swaps the token under the hood without aborting in-flight requests.
Calling undocumented endpoints
For AudD endpoints not yet wrapped by typed methods on this SDK, drop down to audd.advanced.rawRequest:
const body = await audd.advanced.rawRequest("getCallbackUrl");
console.log(body.result);
The returned object is the full response envelope ({ status, result, ... }); the SDK doesn't unwrap it. status: error responses do not throw on this path — inspect body.status yourself if you need to. Use it as a typed escape hatch while the typed surface catches up.