Skip to main content

audd-kotlin

Open .md

Recognize music in audio clips, long broadcasts, and live streams from Kotlin — coroutines-first.

// build.gradle.kts
implementation("io.audd:audd-kotlin:1.5.8")
import io.audd.AudD
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
AudD("your-api-token").use { audd -> // get yours at dashboard.audd.io
val song = audd.recognize("https://audd.tech/example.mp3")
println("${song?.artist}${song?.title}")
}
}

The Maven coordinate is io.audd:audd-kotlin — distinct from io.audd:audd, which is the Java SDK. Every public method on AudD is a suspend fun; longpoll surfaces matches/notifications/errors as three Kotlin Flows.

Authentication

Get your API token at dashboard.audd.io.

Token resolution on AudD(...) construction:

  1. The apiToken constructor argument.
  2. The AUDD_API_TOKEN environment variable.
  3. Otherwise the constructor throws IllegalArgumentException pointing at dashboard.audd.io.
val audd = AudD("your-api-token")          // explicit
val audd = AudD(apiToken = null) // read from AUDD_API_TOKEN
val audd = AudD.fromEnvironment() // discoverable factory; same as null

The public "test" token works only against the standard recognition endpoint and is capped at 10 requests/day — fine for hello-worlds, not enough for any real workload.

Token rotation

Long-running services that pull tokens from a secret manager can rotate without rebuilding the client:

audd.setApiToken("new-token")

setApiToken is thread-safe — the token cell is an AtomicReference. In-flight requests finish on the previous token, subsequent requests pick up the new one. Empty input throws IllegalArgumentException. Read the current value with audd.apiToken.

Recognize a clip

val song = audd.recognize("https://audd.tech/example.mp3")
println("${song?.artist}${song?.title}")

recognize returns null when the call succeeded but nothing matched. Source forms are accepted via the sealed Source class — the SDK auto-detects the variant and produces a per-attempt re-opener so retries don't read from an exhausted handle:

import io.audd.Source
import java.io.File

audd.recognize(Source.Url("https://audd.tech/example.mp3"))
audd.recognize(Source.FilePath(File("/clip.mp3")))
audd.recognize(Source.Bytes(File("/clip.mp3").readBytes()))
File("/clip.mp3").inputStream().use { audd.recognize(Source.Stream(it, "clip.mp3")) }

The recognize(url: String, ...) overload is a convenience for Source.Url(url).

The result projects to a sealed RecognitionMatch for exhaustive when:

when (val match = song?.toMatch()) {
is RecognitionMatch.Public -> println(match.value.artist)
is RecognitionMatch.Custom -> println("custom audio_id=${match.value.audioId}")
null -> println("no match")
}

recognize returns a RecognitionResult? carrying artist, title, album, releaseDate, label, timecode, songLink, helpers like thumbnailUrl / streamingUrl(provider) / previewUrl(), and an extras map for unwrapped server fields — see the full result reference.

Process a long audio file

For files longer than 25 seconds, use recognizeEnterprise. It returns a flat list of EnterpriseMatch spanning every chunk:

val matches = audd.recognizeEnterprise(
Source.FilePath(File("/full-show.mp3")),
limit = 10,
)
for (m in matches) {
println("${m.timecode} ${m.artist}${m.title} (score=${m.score})")
}
danger

Enterprise calls bill per 12 seconds of audio processed. The limit argument defaults to null (unbounded); pass an explicit value while exploring an unfamiliar input to cap response size.

Other knobs: skip, every, skipFirstSeconds, useTimecode, accurateOffsets. The enterprise endpoint has 1-hour read/write timeouts (vs. 60 seconds on standard); long broadcasts can take that long to fingerprint server-side.

Get streaming-service metadata

Pass returnExtras to populate provider blocks. Accepts a String ("apple_music,spotify"), a List<String>, or any value with a sensible toString():

import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive

val song = audd.recognize(
"https://audd.tech/example.mp3",
returnExtras = listOf("apple_music", "spotify"),
) ?: return

// Convenience: resolves direct URL when metadata block was requested,
// falls back to lis.tn redirect.
val appleUrl: String? = song.streamingUrl(StreamingProvider.APPLE_MUSIC)
val previewUrl: String? = song.previewUrl()

// Or read fields off the raw maps (forward-compatible with new server keys).
val artworkUrl: String? = song
.appleMusic?.get("artwork")
?.jsonObject?.get("url")
?.jsonPrimitive?.contentOrNull

Provider blocks (appleMusic, spotify, deezer, napster, musicbrainz) are populated only when requested via returnExtras. Valid wire values: apple_music, spotify, deezer, napster, musicbrainz, lyrics, timecode. Each adds latency; ask only for what you'll read. The full provider-ID list is in the HTTP API reference.

Monitor a live audio stream

A stream in AudD is a long-running audio source — typically a radio URL or any HLS/Icecast/SHOUTcast endpoint — that AudD ingests continuously and recognizes against. Each stream has a radioId you pick.

Two consumption modes share the same setup:

  • Callbacks — AudD POSTs each match to your server. Best for production.
  • Longpoll — your code holds a LongpollPoll and reads Flows. Best when you can't expose a public URL (mobile, browser, scripts behind NAT).

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

Three steps:

// 1. Set the callback URL (returnMetadata is baked into the URL as ?return=...).
audd.streams.setCallbackUrl(
"https://example.com/audd-callback",
returnMetadata = listOf("apple_music", "deezer"),
)

// 2. Add the stream.
val radioId = 1L // any integer you choose — your handle for this stream
audd.streams.add(
url = "https://stream.example.com/live.mp3",
radioId = radioId,
)

// 3. In your web app, parse incoming callback bodies.
when (val event = audd.streams.parseCallback(bodyString)) {
is CallbackEvent.Match -> {
val m = event.match
println("${m.song.artist}${m.song.title}")
}
is CallbackEvent.Notification -> {
val n = event.notification
println("notification ${n.notificationCode}: ${n.notificationMessage}")
}
}

CallbackEvent is a sealed interface — exhaustive when discriminates between recognition events (Match) and stream-lifecycle events (Notification, e.g. "stream stopped", "can't connect to the audiostream"). parseCallback has three overloads (JsonObject, ByteArray, String); all are pure — no I/O, no suspend. The same overloads are exported as free io.audd.parseCallback(...) functions.

Ktor

import io.audd.AudD
import io.audd.CallbackEvent
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun Application.auddCallbacks(audd: AudD) {
routing {
post("/audd-callback") {
val body = call.receiveText()
when (val event = audd.streams.parseCallback(body)) {
is CallbackEvent.Match ->
log.info("${event.match.song.artist}${event.match.song.title}")
is CallbackEvent.Notification ->
log.info("notification: ${event.notification.notificationMessage}")
}
call.respond(HttpStatusCode.NoContent)
}
}
}

Other frameworks (Spring Boot @RestController, Javalin, http4k) — see the GitHub README.

Poll for stream events (longpoll)

audd.streams.longpoll(radioId) opens a subscription and returns a LongpollPoll carrying three Kotlin Flows. Collect each in its own coroutine — coroutineScope { } keeps them alive together, and use { } cancels the background producer when the block exits.

import io.audd.AudD
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
AudD("your-api-token").use { audd ->
val radioId = 1L // any integer you choose — your handle for this stream
audd.streams.longpoll(radioId).use { poll ->
coroutineScope {
launch { poll.matches.collect { m ->
println("${m.song.artist}${m.song.title}")
} }
launch { poll.notifications.collect { n ->
println("notif ${n.notificationCode}: ${n.notificationMessage}")
} }
launch { poll.errors.collect { err -> throw err } }
}
}
}
}

LongpollPoll exposes:

  • matches: Flow<StreamCallbackMatch> — recognition events.
  • notifications: Flow<StreamCallbackNotification> — stream-lifecycle events.
  • errors: Flow<Throwable>single-shot. The first error fires here and terminates the producer; matches and notifications then complete.

Server-side timeouts (no match, no notification) are absorbed silently — the loop keeps polling and advances the since_time cursor from the server's timestamp.

Tune the per-request timeout or resume from a saved cursor with LongpollOptions:

import io.audd.LongpollOptions

audd.streams.longpoll(radioId, LongpollOptions(timeoutSeconds = 30, sinceTime = lastSeen))
.use { poll -> /* … */ }
Longpoll requires a configured callback URL

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 AudDInvalidRequestException with 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:

audd.streams.setCallbackUrl("https://audd.tech/empty/")

Tokenless consumers

When the longpoll consumer is an Android app, desktop GUI, or browser widget that shouldn't carry the api_token, derive an opaque per-stream identifier on a trusted server and ship only that string to the client. The client subscribes to events with the identifier alone — the api_token never leaves the server.

// On the server (has the api_token):
val streamId = audd.streams.deriveLongpollCategory(radioId = 1L)
// → ship `streamId` to the client over your own channel.
// On the client (no api_token):
import io.audd.LongpollConsumer

LongpollConsumer(category = streamId).use { consumer ->
consumer.iterate().use { poll ->
coroutineScope {
launch { poll.matches.collect { m -> /* … */ } }
launch { poll.notifications.collect { n -> /* … */ } }
launch { poll.errors.collect { err -> throw err } }
}
}
}

The server side still needs a callback URL configured on the account (https://audd.tech/empty/ works as a placeholder) — LongpollConsumer can't preflight that without a token.

Add a song to your custom catalog

The custom-catalog endpoint adds a song to your private fingerprint database so AudD's recognition can identify your own audio for your account only.

danger

Custom-catalog upload requires special access. Contact api@audd.io. Calls without access throw AudDCustomCatalogAccessException (a subclass of AudDSubscriptionException) with guidance.

val audioId = 1L // any integer you choose — your reference to the song
audd.customCatalog.add(audioId = audioId, source = Source.Url("https://my.cdn/song.mp3"))

audioId is your identifier; re-uploading with the same audioId overwrites that fingerprint slot. There's no public list or delete — track audioId ↔ song mappings on your side. After upload, future recognize() calls match against your tracks (the result has audioId != null and result.toMatch() returns RecognitionMatch.Custom).

Handle errors

Idiomatic error handling

Every error raised by the SDK is a subclass of AudDException (a sealed class extending RuntimeException). Catch the specific exceptions you expect and let the rest propagate, or catch AudDException to handle the whole family:

import io.audd.*

try {
audd.recognize("https://audd.tech/example.mp3")
} catch (e: AudDAuthenticationException) {
error("check your token: [#${e.errorCode}] ${e.serverMessage}")
} catch (e: AudDQuotaException) {
error("quota exceeded: [#${e.errorCode}] ${e.serverMessage}")
} catch (e: AudDInvalidAudioException) {
println("audio rejected: ${e.serverMessage}")
} catch (e: AudDApiException) {
println("AudD #${e.errorCode}: ${e.serverMessage} (request_id=${e.requestId})")
} catch (e: AudDConnectionException) {
println("network: ${e.message}")
}

Every AudDApiException carries errorCode, serverMessage, httpStatus, requestId, requestedParams, requestMethod, brandedMessage, and rawResponse for incident reproduction. AudDConnectionException carries original: Throwable? (the underlying Ktor / Java IO exception). AudDSerializationException carries rawText: String.

For some error codes the AudD server populates result.artist / result.title with branded text alongside status: error (e.g. for ip_ban, request_blocked, token_disabled). Surfacing that as a recognition match would be misleading, so the SDK stashes it on e.brandedMessage instead.

Retry behavior

The SDK classifies every endpoint into one of three retry classes, each tuned to avoid double-billing:

ClassEndpointsRetry on exceptionRetry on response
READstreams.list, streams.getCallbackUrl, longpoll pollsany IO / connect / socket-timeout / UnknownHostExceptionHTTP 408, 429, or any 5xx
RECOGNITIONrecognize, recognizeEnterpriseonly pre-upload (ConnectTimeoutException, ConnectException, UnknownHostException) — guards against double-billing for in-progress uploadsany 5xx
MUTATINGstreams.setCallbackUrl, streams.add, streams.setUrl, streams.delete, customCatalog.addonly pre-upload connection errorsnever (the side effect may already have happened)

Defaults: maxRetries = 3, backoffFactor = 0.5. Backoff is min(backoffFactor * 2^attempt, 30) seconds with 0.5x..1.5x jitter — so attempts at 0, ~0.5–1.5, ~1.0–3.0 seconds. Override per client:

val audd = AudD("your-api-token", maxRetries = 5, backoffFactor = 1.0)

To disable retries entirely, pass maxRetries = 1.

Configuration

Timeouts

Internal defaults:

LayerConnectReadWrite
Standard30 s60 s60 s
Enterprise30 s3600 s3600 s

Enterprise read/write timeouts are one hour because long files take that long to fingerprint server-side. There is no per-call timeout argument; for finer control, inject a fully-configured HttpClient:

import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.HttpTimeout

val client = HttpClient(CIO) {
install(HttpTimeout) {
connectTimeoutMillis = 10_000
requestTimeoutMillis = 15_000
socketTimeoutMillis = 15_000
}
}
val audd = AudD("your-api-token", httpClient = client)

When you inject a single HttpClient, both standard and enterprise calls go through it — if your standard-endpoint timeouts conflict with what enterprise needs, leave the SDK to build its own clients.

Custom HTTP engine

The SDK uses Ktor and accepts either an HttpClientEngine (the SDK builds the HttpClient around it) or a fully-configured HttpClient (the SDK uses it as-is and won't close it):

import io.ktor.client.engine.okhttp.OkHttp

val audd = AudD("your-api-token", engine = OkHttp.create {
config {
retryOnConnectionFailure(false) // SDK manages its own retries
// proxy, TLS, interceptors, etc.
}
})

The default engine is Ktor CIO. Connection pooling, proxies, and TLS are all engine-specific knobs.

Coroutine context and lifecycle

All public methods are suspend — they run on whatever coroutine context the caller provides. Internally, longpoll consumers spawn a CoroutineScope(SupervisorJob() + Dispatchers.Default); that scope is cancelled by LongpollPoll.close() (or use { }). For one-off calls outside a coroutine, wrap with runBlocking { }.

A single AudD instance is safe to share across coroutines and threads. AudD implements AutoCloseable — pair it with use for short-lived flows, or call close() at shutdown for long-lived processes:

val audd = AudD("your-api-token")
Runtime.getRuntime().addShutdownHook(Thread { audd.close() })

When you injected an HttpClient, the SDK does not close it.

Server-side metering on cancellation

Coroutine cancellation propagates into the underlying Ktor request, but cancelling a recognize() mid-flight might still consume a request on the server — once your bytes have arrived, the metered work may already be in progress.

Observability

Pass an onEvent hook to receive structured per-request events:

import io.audd.AudD
import io.audd.AudDEvent

val audd = AudD(
"your-api-token",
onEvent = { event ->
println("${event.kind} ${event.method} ${event.httpStatus} ${event.elapsedMs}ms")
},
)

AudDEvent is an immutable data class with kind (REQUEST / RESPONSE / EXCEPTION), method, url, requestId, httpStatus, elapsedMs, errorCode, and an extras: Map<String, Any?> for per-event context. Events never carry the api_token or response body. Exceptions raised from the hook are swallowed via runCatching so observability cannot break the request path.

The SDK also uses the SLF4J logger name io.audd.AudD. The default onDeprecation hook logs at WARN there; override it to capture or silence:

val audd = AudD(
"your-api-token",
onDeprecation = { msg -> myMetrics.increment("audd.deprecation"); println(msg) },
)

Token rotation

Covered in Authenticationaudd.setApiToken("new-token") swaps atomically.

Calling undocumented endpoints

For endpoints not yet wrapped by typed methods, audd.advanced.rawRequest(method, params) hits any AudD endpoint by name and returns the raw JsonObject:

val body = audd.advanced.rawRequest("getLinks", mapOf("audd_id" to "12345"))

Errors decode to the typed exception hierarchy. RecognitionResult.extras (and EnterpriseMatch.extras, plus the per-provider raw Map<String, JsonElement> blocks) carries server fields the SDK doesn't yet type — read them with kotlinx.serialization.json accessors. This is the supported path for new server keys until the SDK ships a typed property.


dashboard.audd.io · HTTP API reference · Other SDKs