audd-swift
Recognize music in audio clips, long broadcasts, and live streams from Swift — async/await.
In Package.swift:
.package(url: "https://github.com/AudDMusic/audd-swift", from: "1.5.9"),
Then add .product(name: "AudD", package: "audd-swift") to your target's dependencies. In Xcode: File → Add Package Dependencies… and paste https://github.com/AudDMusic/audd-swift.
import AudD
@main struct App {
static func main() async throws {
let audd = try AudD(apiToken: "test") // 10 reqs/day; get a real token at dashboard.audd.io
if let result = try await audd.recognize("https://audd.tech/example.mp3") {
print("\(result.artist ?? "?") — \(result.title ?? "?")")
}
}
}
Platforms: macOS 12+, iOS 15+, watchOS 8+, tvOS 15+, visionOS 1+, Linux. Swift 5.9 or newer. No third-party runtime dependencies.
Authentication
Get your API token at dashboard.audd.io.
The api_token is resolved on construction in this order:
- The
apiToken:argument, if non-niland non-empty. - The
AUDD_API_TOKENenvironment variable. - Otherwise the initializer throws
AudDError.configuration(...)with a pointer to dashboard.audd.io.
let audd = try AudD(apiToken: "your-api-token") // explicit
let audd = try AudD() // reads AUDD_API_TOKEN
let audd = try AudD.fromEnvironment() // env-only convenience
The public "test" token is capped at 10 requests per day on the standard recognition endpoint — fine for hello-worlds, not for production.
For long-running services pulling tokens from a secret manager, rotate without rebuilding the client:
try await audd.setApiToken("new-token")
setApiToken runs inside the AudD actor and is safe to call concurrently. In-flight requests finish on the previous token; subsequent calls use the new one. Passing an empty string throws AudDError.configuration(...).
Recognize a clip
recognize(_:) takes a 5–25-second clip and returns a single RecognitionResult? — nil when the clip processed but matched nothing.
import AudD
import Foundation
let audd = try AudD()
// URL — server fetches the audio
let result = try await audd.recognize("https://audd.tech/example.mp3")
// Filesystem path
let result = try await audd.recognize(.file(URL(fileURLWithPath: "/clip.mp3")))
// Raw bytes
let bytes = try Data(contentsOf: URL(fileURLWithPath: "/clip.mp3"))
let result = try await audd.recognize(.data(bytes))
// InputStream (drained into memory once for retry support)
let stream = InputStream(fileAtPath: "/clip.mp3")!
let result = try await audd.recognize(.stream(stream, name: "clip.mp3"))
if let r = result {
print("\(r.artist ?? "?") — \(r.title ?? "?") @ \(r.timecode)")
print("song page:", r.songLink ?? "-")
print("cover art:", r.thumbnailURL ?? "-")
}
A RecognitionResult carries artist, title, album, releaseDate, label, timecode, songLink, isrc/upc (enterprise plans), and helpers — thumbnailURL, streamingUrl(_:), streamingUrls(), previewUrl(). Server fields not yet typed surface via result.extras (per-model) or result.rawResponse (whole payload). Full reference: github.com/AudDMusic/audd-swift#what-you-get-back.
Source notes: .file reads fresh on each retry; .data reuses the buffer; .stream is drained into memory once because InputStream isn't portably rewindable on Linux. A String overload that doesn't parse as a URL throws AudDError.invalidArgument(...).
Process a long audio file
For broadcasts, podcasts, and full DJ sets longer than 25 seconds, use the enterprise endpoint. It chunks the file server-side and returns every match.
let matches = try await audd.recognizeEnterprise(
.file(URL(fileURLWithPath: "/path/to/broadcast.mp3")),
limit: 10 // stop after 10 matches; ALWAYS pass this in development
)
for m in matches {
print("\(m.timecode) \(m.artist ?? "?") — \(m.title ?? "?") score=\(m.score)")
}
Other accepted args: skip, every, skipFirstSeconds, useTimecode, accurateOffsets. The endpoint accepts the same Source forms as recognize(_:).
The SDK defaults limit: to 1 to cap unintended billing — pass an explicit limit: sized to your use case. The unbounded default on the wire can otherwise ingest many hours of audio in a single call.
Each EnterpriseMatch carries score, timecode, artist, title, album, releaseDate, label, songLink, isrc/upc (enterprise plans), startOffset, endOffset, plus thumbnailURL, streamingUrl(_:), streamingUrls().
Get streaming-service metadata
Pass return: to populate provider sub-objects on the result. Without it, those fields are nil.
let result = try await audd.recognize(
"https://audd.tech/example.mp3",
return: ["apple_music", "spotify"]
)
if let r = result {
if let am = r.appleMusic {
print("Apple Music:", am.url ?? "-")
}
if let sp = r.spotify {
print("Spotify URI:", sp.uri ?? "-")
}
// Or resolve any provider — direct URL when the metadata block is set,
// else the lis.tn redirect when songLink is on lis.tn.
print(r.streamingUrl(.spotify) ?? "-")
print(r.streamingUrls()) // every provider with a resolvable URL
print(r.previewUrl() ?? "-") // 30-second preview, provider terms apply
}
Valid providers: apple_music, spotify, deezer, napster, musicbrainz. Each provider you ask for adds latency.
The market: argument (ISO 3166 region code) controls the Apple Music storefront:
let result = try await audd.recognize(
"https://audd.tech/example.mp3",
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 radioID you choose.
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
try await 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 the URL already contains return= and returnMetadata is also non-nil, the SDK throws rather than silently overwriting.
2. Register the stream
let radioID: Int64 = 1 // any integer you choose — your handle for this stream
try await audd.streams.add(
url: "https://radio.example.com/stream.mp3",
radioID: radioID
)
Other stream methods:
try await audd.streams.setURL(radioID: radioID, url: "https://radio.example.com/new.mp3")
try await audd.streams.delete(radioID: radioID)
let streams = try await audd.streams.list() // [Stream] with radioID, url, streamRunning, longpollCategory
3. Parse incoming POST bodies
streams.parseCallback(_:) is a pure function — no I/O — that takes the raw body bytes and returns a CallbackEvent:
public enum CallbackEvent: Sendable, Equatable {
case match(StreamCallbackMatch)
case notification(StreamCallbackNotification)
}
Recognition events arrive as .match; stream-lifecycle events ("stream stopped", "can't connect", etc.) as .notification. The same parser is used by the longpoll loop internally, so the parsed shapes are identical regardless of consumption mode.
Vapor — canonical recipe
Vapor is the dominant async/await Swift web framework. Mount the AudD callback at any route and pass the request body through parseCallback:
import Vapor
import AudD
let audd = try AudD()
func routes(_ app: Application) throws {
app.post("audd-callback") { req async throws -> HTTPStatus in
let bodyBuffer = req.body.data ?? ByteBuffer()
let data = Data(buffer: bodyBuffer)
let event = try await audd.streams.parseCallback(data)
switch event {
case .match(let m):
req.logger.info(
"audd match",
metadata: [
"radio_id": "\(m.radioID)",
"artist": "\(m.song.artist)",
"title": "\(m.song.title)",
]
)
// persist, queue, fan-out, etc.
case .notification(let n):
req.logger.info(
"audd notification",
metadata: [
"radio_id": "\(n.radioID)",
"code": "\(n.notificationCode)",
"message": "\(n.notificationMessage)",
]
)
}
return .ok
}
}
A StreamCallbackMatch carries radioID, timestamp, playLength, song (the top match), alternatives (rare extra candidates — may have different artist/title than the top match), and rawResponse (the full unparsed body). A StreamCallbackSong mirrors a RecognitionResult's metadata: artist, title, score, album, releaseDate, label, songLink, plus optional provider blocks when returnMetadata was set. A StreamCallbackNotification carries radioID, streamRunning, notificationCode, notificationMessage, and time.
Other frameworks (NIO HTTP1, Hummingbird) — see the GitHub README.
Poll for stream events (longpoll)
When your process can't expose a public callback URL — iOS apps, Mac tools, scripts behind NAT, anything that can't accept inbound HTTP — call streams.longpoll(radioID:) and consume matches as an AsyncSequence.
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 throws AudDError.api(...) with kind == .invalidRequest and guidance if it's missing; pass LongpollOptions(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:
try await audd.streams.setCallbackURL("https://audd.tech/empty/")
let radioID = 1 // any integer you choose — your handle for this stream
let poll = try await audd.streams.longpoll(radioID: radioID)
defer { Task { await poll.close() } }
for await match in poll.matches {
print("\(match.song.artist) — \(match.song.title)")
}
longpoll(radioID:) returns a LongpollPoll actor with three typed AsyncStreams — matches, notifications, errors — fed by a background task. errors is single-shot: the first terminal error fires there and then all three streams close.
To consume matches, lifecycle notifications, and the terminal error concurrently, use a task group:
import AudD
let audd = try AudD()
let poll = try await audd.streams.longpoll(
radioID: 1,
options: LongpollOptions(timeout: 30)
)
await withTaskGroup(of: Void.self) { group in
group.addTask {
for await m in poll.matches {
print("match: \(m.song.artist) — \(m.song.title)")
}
}
group.addTask {
for await n in poll.notifications {
print("notif #\(n.notificationCode): \(n.notificationMessage)")
}
}
group.addTask {
for await err in poll.errors {
print("terminal: \(err)")
await poll.close()
return
}
}
}
close() is idempotent. Calling it cancels the background task and finishes all three streams so any in-flight for await exits cleanly. Task.cancel() on the enclosing task does the same.
Tokenless consumers
When the consumer is a client process that should not hold the api_token — iOS app talking to your Vapor backend, Mac frontend, browser-style widget — your server derives an opaque per-stream identifier, ships only that string to the consumer, and the consumer subscribes with LongpollConsumer. The api_token never leaves the server.
On the server (which holds the api_token):
let identifier = await audd.streams.deriveLongpollCategory(radioID: 1)
// ship `identifier` to the consumer over your own transport
In the consumer (no api_token; just the identifier):
import AudD
let consumer = LongpollConsumer(category: receivedIdentifier)
defer { consumer.close() }
let poll = consumer.iterate()
defer { Task { await poll.close() } }
for await match in poll.matches {
print("\(match.song.artist) — \(match.song.title)")
}
The server is responsible for ensuring a callback URL is configured on the account; the consumer can't preflight that without a token.
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(_:) for files longer than 25 seconds).
The custom-catalog endpoint requires special access. Contact api@audd.io to enable it. Calls without access throw AudDError.api(...) with kind == .customCatalogAccess and a contact-us message.
let audioID: Int64 = 1 // any integer you choose — your reference to the song
try await audd.customCatalog.add(
audioID: audioID,
source: .url(URL(string: "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 thrown by the SDK is a case of AudDError. The enum is Sendable and conforms to LocalizedError (so error.localizedDescription produces a human-readable summary). Pattern-match on cases — and on detail.kind for API errors — to handle whole families.
public enum AudDError: Error, Sendable {
case api(AudDAPIErrorDetail)
case serverError(httpStatus: Int, message: String, requestID: String?, rawText: String)
case connection(message: String, underlying: Error?)
case serializationError(message: String, rawText: String)
case invalidArgument(String)
case unsupportedSource(String)
case configuration(String)
}
AudDAPIErrorDetail carries kind (the classified family — see the catalog below), errorCode, message, httpStatus, requestID, requestedParams, requestMethod, brandedMessage, rawResponse.
Idiomatic catch
import AudD
do {
_ = try await audd.recognize("https://audd.tech/example.mp3")
} catch let AudDError.api(detail) where detail.kind == .authentication {
// 900 / 901 / 903
print("check your token: [#\(detail.errorCode)] \(detail.message)")
} catch let AudDError.api(detail) where detail.kind == .quota {
// 902
print("out of quota: \(detail.message)")
} catch let AudDError.api(detail) where detail.kind == .rateLimit {
// 611, HTTP 429
print("rate limited: \(detail.message)")
} catch let AudDError.api(detail) where detail.kind == .invalidAudio {
// 300 / 400 / 500
print("audio rejected: \(detail.message)")
} catch let AudDError.api(detail) where detail.kind == .customCatalogAccess {
// 904 from custom_catalog.add
print("custom catalog: \(detail.message)")
} catch let AudDError.api(detail) {
// Catch-all for any server-reported error
print("AudD #\(detail.errorCode): \(detail.message) (request_id=\(detail.requestID ?? "-"))")
} catch AudDError.connection(let message, _) {
print("connection: \(message)")
} catch AudDError.serverError(let status, let message, _, _) {
print("server error HTTP \(status): \(message)")
} catch AudDError.serializationError(let message, _) {
print("decode: \(message)")
}
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 detail.brandedMessage rather than surfacing it as a fake recognition match:
do {
_ = try await audd.recognize(...)
} catch let AudDError.api(detail) where detail.kind == .blocked {
print("[#\(detail.errorCode)] \(detail.message)")
if let branded = detail.brandedMessage {
print("server brand text: \(branded)")
}
}
When the server returns code 51 (deprecated parameter) with a usable result, the SDK logs a deprecation message via AudDLog.deprecation and returns the result as if the call had succeeded. Code 51 with no result throws AudDError.api(...) with kind == .invalidRequest.
Retry behavior
Each endpoint is classified into one of three retry classes:
.read(streams.list,streams.getCallbackURL, longpolls) — retries on anyURLError, plus HTTP 408/429/5xx..recognition(recognize,recognizeEnterprise,advanced.*) — retries only on pre-uploadURLErrors (.cannotConnectToHost,.cannotFindHost,.dnsLookupFailed,.timedOut,.secureConnectionFailed,.networkConnectionLost,.notConnectedToInternet) 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-uploadURLErrors; 5xx is surfaced because the side effect may have already happened.
Defaults: maxRetries = 3, backoffFactor = 0.5. Backoff is min(backoffFactor * 2.0^attempt, 30.0) with 0.5x..1.5x jitter. Override per client:
let audd = try AudD(apiToken: "your-token", maxRetries: 5, backoffFactor: 1.0)
let audd = try AudD(maxRetries: 1) // disable retries
Configuration
Timeouts
The SDK builds two HTTP clients with different timeout profiles:
| Layer | Connect (timeoutIntervalForRequest) | Overall (timeoutIntervalForResource) |
|---|---|---|
| Standard | 30 s | 60 s |
| Enterprise | 30 s | 3600 s |
Tokenless longpoll (LongpollConsumer) | 10 s | 120 s |
The enterprise resource timeout is one hour because long files take that long to fingerprint server-side. For finer-grained control, inject a custom URLSession (see below) with the URLSessionConfiguration you need. Use enterpriseURLSession: to assign a longer-timeout session to enterprise calls only.
Custom URLSession
Inject your own URLSession for proxies, mTLS, certificate pinning, or to share an existing pool with the rest of your app:
import AudD
import Foundation
let config = URLSessionConfiguration.default
config.connectionProxyDictionary = [
"HTTPSEnable": true,
"HTTPSProxy": "corp-proxy",
"HTTPSPort": 8080,
]
let session = URLSession(configuration: config)
let audd = try AudD(apiToken: "your-api-token", urlSession: session)
The SDK adds its User-Agent header on every request whether the session is injected or not. The injected session is not invalidated when you call audd.close() — the SDK only invalidates sessions it built itself.
Observability
The SDK emits a structured event on every request lifecycle phase via the onEvent: constructor hook:
import AudD
let audd = try AudD(
apiToken: "your-token",
onEvent: { event in
switch event.kind {
case .request:
break // about to send
case .response:
print("\(event.method) \(event.httpStatus ?? 0) request_id=\(event.requestId ?? "-") (\(event.elapsed ?? 0)s)")
case .exception:
print("\(event.method) failed: \(event.extras["error_type"]?.value ?? "unknown")")
}
}
)
AudDEvent is a Sendable struct with kind (.request / .response / .exception), method, url, requestId, httpStatus, elapsed, errorCode, and a free-form extras dict. Events never carry the api_token or request body bytes. Hook exceptions are swallowed so observability cannot break the request path.
Token rotation
Covered under Authentication — try await audd.setApiToken("new-token") swaps atomically without aborting in-flight requests.
Retries
Covered under Retry behavior. maxRetries: and backoffFactor: on the constructor.
Concurrency and lifecycle
AudD is a Swift actor — every public method is async and serializes through actor isolation. Share one instance across tasks and structured-concurrency contexts; you don't need any explicit synchronization. Streams, CustomCatalog, Advanced, and every model type are Sendable. LongpollPoll is itself an actor.
Cancellation propagates cleanly: Task.cancel() aborts the underlying URLSessionDataTask and bubbles CancellationError (or URLError(.cancelled)) back to the caller. The longpoll loop checks Task.isCancelled between iterations and exits.
close() invalidates the SDK-owned URLSessions eagerly via finishTasksAndInvalidate(). deinit also calls close(), so explicit close is only useful for determinism (CLI tools, tests, scoped resource ownership).
let audd = try AudD()
defer { Task { await audd.close() } }
Cancelling a recognize() mid-flight might still consume a request on the server — the metered work may already be in progress by the time the cancel signal lands.
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 a [String: Any], runs through the same retry policy as recognition, and throws AudDError.serializationError(...) 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.
let body = try await audd.advanced.rawRequest(
method: "getLinks",
params: ["audd_id": "12345"]
)