audd-php
Recognize music in audio clips, long broadcasts, and live streams from PHP.
composer require audd/audd
<?php
require __DIR__ . '/vendor/autoload.php';
use AudD\AudD;
$audd = new AudD('test'); // 10 reqs/day; get a real token at dashboard.audd.io
$song = $audd->recognize('https://audd.tech/example.mp3');
if ($song !== null) {
echo $song->artist, ' — ', $song->title, "\n";
}
PHP 8.1+. Synchronous — one client per request scope.
Authentication
Get your API token at dashboard.audd.io.
The api_token is resolved on construction in this order:
- The first positional argument:
new AudD('your-api-token'). - The
AUDD_API_TOKENenvironment variable. - Otherwise
AudDConfigurationException, with a pointer to dashboard.audd.io.
use AudD\AudD;
$audd = new AudD('your-api-token'); // explicit
$audd = new AudD(); // reads AUDD_API_TOKEN
$audd = AudD::fromEnvironment(); // same as above, clearer at the call site
The public "test" token is capped at 10 requests/day and works only for standard recognition — fine for hello-worlds, not for production.
For long-running CLI workers or queue consumers pulling tokens from a secret manager, rotate without rebuilding the client:
$audd->setApiToken('new-token');
PHP is request-scoped, so there are no in-flight calls to coordinate with — the next call uses the new token. Passing an empty string raises AudDConfigurationException.
Recognize a clip
recognize() takes a clip up to 25 seconds / 10 MB and returns a single RecognitionResult on a match, or null when the clip processed but matched nothing.
use AudD\AudD;
use AudD\Internal\Source;
$audd = new AudD();
// URL — server fetches the audio
$result = $audd->recognize('https://audd.tech/example.mp3');
// Filesystem path
$result = $audd->recognize('/path/to/clip.mp3');
// PSR-7 stream from a framework request, an S3 SDK, or a stream factory
$result = $audd->recognize($psrStream);
// Raw bytes — explicit wrapper avoids ambiguity with file paths
$result = $audd->recognize(Source::bytes(file_get_contents('/path/to/clip.mp3')));
if ($result !== null) {
echo "{$result->artist} — {$result->title} @ {$result->timecode}\n";
echo 'song page: ', $result->song_link, "\n";
echo 'cover art: ', $result->thumbnailUrl(), "\n";
}
A RecognitionResult carries artist, title, album, release_date, label, timecode, song_link, isrc / upc (enterprise plans), and helpers — thumbnailUrl(), streamingUrl($provider), streamingUrls(), previewUrl(), isCustomMatch(), isPublicMatch(). Server fields not yet typed surface via $result->extras (or magic __get: $result->genre). Full reference: github.com/AudDMusic/audd-php#what-you-get-back.
Source-form notes: filesystem paths reopen on each retry, raw bytes copy on each retry, PSR-7 streams record tell() and seek() back — so unseekable streams cannot be retried (buffer to bytes via Source::bytes(...) first if you need that). A string that's neither an http(s):// URL nor an existing file raises InvalidArgumentException.
Process a long audio file
For broadcasts, podcasts, and full sets longer than 25 seconds, use the enterprise endpoint. It chunks the file server-side and returns every match.
$matches = $audd->recognizeEnterprise(
'/path/to/broadcast.mp3',
limit: 10, // stop after 10 matches; ALWAYS set this in development
);
foreach ($matches as $m) {
printf("%s %s — %s (score=%d)\n", $m->timecode, $m->artist, $m->title, $m->score);
}
Other accepted args: skip, every, skipFirstSeconds, useTimecode, accurateOffsets, timeout. The endpoint accepts the same source forms as recognize().
Always set limit: during development. The unbounded default can ingest many hours of audio on a single call.
Each EnterpriseMatch carries score, timecode, artist, title, album, release_date, label, song_link, isrc/upc (enterprise plans), start_offset, end_offset, plus thumbnailUrl(), streamingUrl($provider), streamingUrls().
Get streaming-service metadata
Pass return_: to populate provider sub-objects on the result. Without it, those fields are null.
$result = $audd->recognize(
'https://audd.tech/example.mp3',
return_: ['apple_music', 'spotify'],
);
if ($result !== null) {
if ($result->apple_music !== null) {
echo 'Apple Music: ', $result->apple_music->url, "\n";
}
if ($result->spotify !== null) {
echo 'Spotify URI: ', $result->spotify->uri, "\n";
}
// Or resolve any provider — direct URL when the metadata block is set,
// else the lis.tn redirect when song_link is on lis.tn.
use AudD\StreamingProvider;
echo $result->streamingUrl(StreamingProvider::SPOTIFY), "\n";
print_r($result->streamingUrls()); // all providers with a resolvable URL
echo $result->previewUrl(), "\n"; // 30-s preview, provider terms apply
}
Valid providers: apple_music, spotify, deezer, napster, musicbrainz. Each provider adds latency.
The market: argument (ISO 3166 region code) controls the Apple Music storefront:
$result = $audd->recognize($url, return_: ['apple_music'], market: 'GB');
For the field shape of each provider sub-object, see the upstream API reference: docs.audd.io and the GitHub README.
Monitor a live audio stream
A stream in AudD is a long-running audio source — typically a radio URL, HLS, Icecast, or SHOUTcast endpoint — that AudD ingests continuously and recognizes against. Each stream is identified by a radio_id you pick.
There are two ways to consume recognition events:
- Callbacks — AudD POSTs each match (and each lifecycle notification) to a URL you control. See Receive callbacks from a stream.
- Longpoll — your code polls AudD's
/longpoll/endpoint and receives matches and notifications synchronously over HTTP. See Poll for stream events (longpoll).
Both modes need a callback URL configured on the account; otherwise events are never delivered. If your consumers will only longpoll — e.g., browser widgets, mobile apps, or scripts that can't expose a public URL — set the account's callback URL to https://audd.tech/empty/ in advance; it's a placeholder URL that discards POSTs. Consumers themselves don't need to configure anything.
Receive callbacks from a stream
The full setup has three steps: configure the callback URL, register a stream, and parse incoming POSTs.
1. Configure the account callback URL
$audd->streams()->setCallbackUrl(
'https://your-app.example.com/audd-callback',
returnMetadata: ['apple_music', 'spotify'], // optional; populates each callback
);
returnMetadata is serialized into the URL as ?return=apple_music,spotify (the server reads it from the URL). If you've already baked ?return= into the URL string and also pass returnMetadata, the SDK raises rather than silently overwriting.
2. Register the stream
$radioId = 1; // any integer you choose — your handle for this stream
$audd->streams()->add(
url: 'https://radio.example.com/stream.mp3',
radioId: $radioId,
);
add() accepts direct stream URLs (DASH, Icecast, HLS, m3u/m3u8) and three shortcuts: twitch:<channel>, youtube:<video_id>, youtube-ch:<channel_id>. Pass callbacks: 'before' to deliver callbacks at song start instead of song end.
Other stream methods:
$audd->streams()->setUrl(radioId: $radioId, url: 'https://radio.example.com/new.mp3');
$audd->streams()->delete(radioId: $radioId);
$streams = $audd->streams()->list(); // list<Stream> with radio_id, url, stream_running, longpoll_category
3. Parse incoming POST bodies — Laravel controller
Each callback POST body is JSON with either a result block (recognition) or a notification block (lifecycle event). Streams::handleCallback($input) accepts a PSR-7 ServerRequestInterface, a PSR-7 StreamInterface, raw JSON string, or a pre-decoded array. Streams::parseCallback(array $body) is a pure function for already-decoded bodies — useful for queue replay or webhook proxies.
Both are static, so the route handler doesn't need an AudD instance to parse — only to manage the streams that produce the events.
<?php
namespace App\Http\Controllers;
use AudD\Streams;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
final class AudDCallbackController
{
public function __invoke(Request $request): Response
{
$event = Streams::handleCallback($request->getContent());
if ($event->isMatch()) {
$m = $event->match;
logger()->info("audd: {$m->song->artist} — {$m->song->title}", [
'radio_id' => $m->radio_id,
'score' => $m->song->score,
]);
// persist, queue, fan-out, etc.
} elseif ($event->isNotification()) {
// Stream lifecycle: "stream stopped", "can't connect", etc.
$n = $event->notification;
logger()->info("audd: {$n->notification_message}", [
'radio_id' => $n->radio_id,
'code' => $n->notification_code,
]);
}
return response()->noContent();
}
}
CallbackEvent carries exactly one of match (a StreamCallbackMatch) or notification (a StreamCallbackNotification); isMatch() / isNotification() discriminate. A match exposes radio_id, timestamp, play_length, song (the top match), alternatives (rare extra candidates — may have different artist/title from the top match), and rawResponse (the full unparsed body). A StreamCallbackSong mirrors a RecognitionResult's metadata: artist, title, score, album, release_date, label, song_link, plus optional provider blocks when returnMetadata was set. A notification carries radio_id, stream_running, notification_code, notification_message, and time.
Other patterns (bare PHP via php://input, Symfony controllers) — see the GitHub README.
Poll for stream events (longpoll)
For consumers that can't expose a public callback URL — CLI workers, scripts behind NAT, queue workers, Laravel artisan commands — poll /longpoll/ instead. Your process receives matches and notifications synchronously over HTTP.
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 AudDInvalidRequestException 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:
$audd->streams()->setCallbackUrl('https://audd.tech/empty/');
Call longpoll(radioId: ...) to get a LongpollPoll handle. Register onMatch, onNotification, and onError closures, then call run() — it blocks until close() is called or a terminal error fires.
use AudD\Models\StreamCallbackMatch;
use AudD\Models\StreamCallbackNotification;
$poll = $audd->streams()->longpoll(radioId: 1, timeout: 30);
$poll->onMatch(function (StreamCallbackMatch $m): void {
echo $m->song->artist, ' — ', $m->song->title, "\n";
});
$poll->onNotification(function (StreamCallbackNotification $n): void {
echo 'notification: ', $n->notification_message, "\n";
});
$poll->onError(function (\Throwable $e) use ($poll): void {
fwrite(STDERR, $e->getMessage() . "\n");
$poll->close();
});
$poll->run(); // blocks until close() or a terminal error
Pass the same radioId you used when adding the stream. Keepalive responses from the server are silently absorbed; the loop advances internally and continues. To stop from another thread or signal handler, call $poll->close() — it's idempotent and safe from inside any callback.
Tokenless consumers
For browser widgets, desktop apps, or any client that should not see the api_token: have your server derive an opaque per-stream identifier and ship just that string to the client. The client subscribes via LongpollConsumer and never sees the token.
On the server (with the api_token):
$category = $audd->streams()->deriveLongpollCategory(radioId: 1);
// ship $category to the client over your own channel
On the client (no api_token needed):
use AudD\LongpollConsumer;
$consumer = new LongpollConsumer(category: 'abc123def');
$poll = $consumer->iterate(timeout: 30);
$poll->onMatch(fn ($m) => print_r($m));
$poll->onError(fn ($e) => fwrite(STDERR, $e->getMessage()));
$poll->run();
The server side (which holds the token) is responsible for ensuring a callback URL is configured — LongpollConsumer can't preflight that.
Add a song to your custom catalog
The custom-catalog endpoint adds songs to a private fingerprint database for your account. After upload, future recognize() calls on the same account can match against your tracks. It is not how you submit audio for music recognition — for that, use recognize() (or recognizeEnterprise()).
The custom-catalog endpoint requires special access. Contact api@audd.io to enable it. Calls without access raise AudDCustomCatalogAccessException.
$audioId = 1; // any integer you choose — your reference to the song
$audd->customCatalog()->add(audioId: $audioId, source: 'https://my.cdn/song.mp3');
The SDK exposes only add — there is no public list or delete. Track audioId ↔ song mappings yourself. Re-using an audioId re-fingerprints that slot.
Handle errors
Every error raised by the SDK subclasses AudD\Errors\AudDException. Server-reported errors subclass AudDApiException and carry errorCode, apiMessage, httpStatus, requestId, requestedParams, requestMethod, brandedMessage, and rawResponse. Network failures raise AudDConnectionException; malformed JSON raises AudDSerializationException.
Idiomatic error handling
use AudD\AudD;
use AudD\Errors\AudDApiException;
use AudD\Errors\AudDAuthenticationException;
use AudD\Errors\AudDConnectionException;
use AudD\Errors\AudDInvalidAudioException;
use AudD\Errors\AudDQuotaException;
use AudD\Errors\AudDRateLimitException;
try {
$result = (new AudD())->recognize('/path/to/clip.mp3');
} catch (AudDAuthenticationException $e) {
exit("check your token: [#{$e->errorCode}] {$e->apiMessage}\n");
} catch (AudDQuotaException $e) {
error_log("out of quota: {$e->apiMessage}");
} catch (AudDRateLimitException $e) {
error_log("rate limited: {$e->apiMessage}");
} catch (AudDInvalidAudioException $e) {
error_log("audio rejected: {$e->apiMessage}");
} catch (AudDApiException $e) {
error_log("AudD #{$e->errorCode}: {$e->apiMessage} (request_id={$e->requestId})");
} catch (AudDConnectionException $e) {
error_log("network: {$e->getMessage()}");
}
match (true) works well for typed dispatch on the exception class:
} catch (AudDApiException $e) {
$action = match (true) {
$e instanceof AudDAuthenticationException => 'reload-token',
$e instanceof AudDRateLimitException => 'back-off',
$e instanceof AudDInvalidAudioException => 'skip',
default => 'log-and-rethrow',
};
}
For some codes — 19 (request_blocked, abuse, test_request_scope), 31337 (ip_ban), 903 (token_disabled) — the server populates branded human-readable text alongside the error. The SDK extracts that onto the exception's brandedMessage rather than surfacing it as a fake recognition match:
use AudD\Errors\AudDBlockedException;
try {
$audd->recognize('https://audd.tech/example.mp3');
} catch (AudDBlockedException $e) {
error_log("#{$e->errorCode} {$e->apiMessage}");
if ($e->brandedMessage !== null) {
error_log("server brand text: {$e->brandedMessage}");
}
}
When the server returns code 51 (deprecated parameter) with a usable result, the SDK emits a PHP-native deprecation notice via trigger_error($message, E_USER_DEPRECATED), mirrors it on the optional PSR-3 logger at warning level, and returns the result as if the call had succeeded. Code 51 with no result raises AudDInvalidRequestException.
Retry behavior
Each endpoint is classified into one of three retry classes:
- READ (
streams()->list,streams()->getCallbackUrl, longpolls) — retries on anyGuzzleHttp\Exception\TransferException, plus HTTP 408/429/5xx. - RECOGNITION (
recognize,recognizeEnterprise,advanced()->*) — retries only on pre-upload connection errors to avoid double-billing for in-progress uploads, plus 5xx. - MUTATING (
streams()->add,streams()->setUrl,streams()->delete,streams()->setCallbackUrl,customCatalog()->add) — retries only on pre-upload errors; 5xx is surfaced because the side effect may have already happened.
Defaults: maxRetries: 3, backoffFactor: 0.5. Backoff is min(backoffFactor * 2**attempt, 30 s) with 0.5x..1.5x jitter. Override per client:
$audd = new AudD('your-token', maxRetries: 5, backoffFactor: 1.0);
$audd = new AudD('your-token', maxRetries: 1); // disable retries
Configuration
Timeouts
Defaults: standard endpoint 30 s connect / 60 s read. Enterprise endpoint uses 60 minutes for read because long files take that long to fingerprint server-side; the SDK enables TCP keepalive on long-read layers to survive NAT idle-eviction. Override per call on recognize() and recognizeEnterprise():
$audd->recognize('https://audd.tech/example.mp3', timeout: 10.0); // 10 s read
The override applies only to read; the connect timeout stays at the layer default. For finer-grained control, inject a custom HTTP client.
Custom HTTP client
Inject any PSR-18 ClientInterface for proxies, custom CA bundles, mTLS, or shared connection pools. Guzzle is the default and the lowest-impedance integration:
use AudD\AudD;
use GuzzleHttp\Client;
$audd = new AudD(
'your-api-token',
httpClient: new Client([
'proxy' => 'http://corp-proxy:8080',
'verify' => '/etc/ssl/internal-ca.pem',
]),
);
The SDK uses the same injected client for both standard and enterprise endpoints, and doesn't close clients it didn't open. Symfony's PSR-18 adapter, Buzz, and php-http's curl client all work.
Observability
The constructor accepts any PSR-3 LoggerInterface. For structured per-request observability, pass an onEvent hook:
use AudD\AudD;
use AudD\AudDEvent;
use AudD\AudDEventKind;
$audd = new AudD(
'your-api-token',
onEvent: function (AudDEvent $e): void {
if ($e->kind === AudDEventKind::Response) {
error_log("audd {$e->method} -> {$e->httpStatus} ({$e->elapsedMs}ms)");
}
},
);
AudDEvent is a readonly class with kind (Request, Response, Exception), method, url, requestId, httpStatus, elapsedMs, errorCode, and a free-form extras array. Events never carry the api_token or request body. Hook exceptions are swallowed (logged at debug on the configured PSR-3 logger) so observability cannot break the request path.
Token rotation
Covered under Authentication — $audd->setApiToken('new-token') swaps the token for subsequent calls. PHP is request-scoped, so there's no concurrency to coordinate with.
Retries
Covered under Retry behavior. maxRetries: and backoffFactor: on the constructor.
Calling undocumented endpoints
For AudD endpoints not yet wrapped by typed methods on this SDK, hit them by name through $audd->advanced()->rawRequest($method, $params). It returns the raw JSON body as an associative array, runs through the same retry policy, and raises AudDSerializationException if the response isn't a JSON object. This is the supported path for anything beta or one-off — typed wrappers ship as features stabilize.
$body = $audd->advanced()->rawRequest('getLinks', ['audd_id' => 12345]);
rawRequest() does not automatically convert status: error bodies into typed exceptions — callers handling untyped endpoints inspect $body['status'] themselves.
Concurrency and lifecycle
PHP is request-scoped: one client serves one process. There is no async surface, no thread pool. The recommended pattern is one AudD instance per request — instantiate at the top of the controller / handler, let it fall out of scope at the end. __destruct calls close() automatically; PHP reclaims everything at request end either way.
If the PHP process is killed mid-upload, or if a tight timeout: aborts a call after the server already accepted the upload, you can still be billed for that recognition. Server-side metering is outside the SDK's control.