A modern, Swift-native client for the AudioMuse-AI CLAP search API — find tracks by how they sound, from a line of plain text.
import SwiftMuse
let config = SwiftMuseConfiguration(urlString: "http://nas.local:8000")!
let client = SwiftMuseClient(configuration: config)
let tracks = try await client.search(query: "energetic upbeat high energy", limit: 50)That's it. No setup, no singleton, no global state.
AudioMuse-AI analyses a music library with a CLAP model, which embeds audio in the same space as text. That lets you ask for "late night calm piano" or "driving workout energy" and get back tracks whose sound matches — not their tags, not a seed track, not an LLM guessing.
SwiftMuse is a thin, typed, dependency-free client for the two calls that power that: the text search and the model warmup, plus the server listing used to scope results. It runs the AudioMuse instance's own HTTP API directly — a separate service from your Subsonic/Jellyfin/Navidrome server.
Pair it with SwiftSonic to turn the returned ids into playable tracks.
| SwiftMuse | |
|---|---|
| Swift 6 strict concurrency | ✅ |
| Zero dependencies | ✅ |
| async/await native | ✅ |
| Typed error codes | ✅ |
| Injectable transport (logging, pinning, tests) | ✅ |
Actor-isolated, Sendable throughout |
✅ |
| Token redacted from logs & descriptions | ✅ |
| Platform | Minimum |
|---|---|
| iOS | 16.0 |
| macOS | 13.0 |
| tvOS | 16.0 |
| watchOS | 9.0 |
| visionOS | 1.0 |
| Swift | 6.0 |
An AudioMuse-AI instance with the CLAP feature enabled (CLAP_ENABLED=true) and its sonic analysis run.
Add the dependency to your Package.swift:
dependencies: [
.package(url: "https://github.com/CassetteLab/SwiftMuse.git", from: "0.1.0")
]Then add SwiftMuse to your target:
.target(
name: "YourApp",
dependencies: ["SwiftMuse"]
)Or in Xcode: File → Add Package Dependencies… and paste the repository URL.
import SwiftMuse
// Token is optional — omit it for an instance running with AUTH_ENABLED=false.
let config = SwiftMuseConfiguration(
urlString: "http://nas.local:8000",
token: "your-api-token"
)!
let client = SwiftMuseClient(configuration: config)SwiftMuseConfiguration is a value type; construct it once and hand it to the client. SwiftMuseClient is an actor, so it is safe to share across tasks with no extra synchronisation.
let tracks = try await client.search(query: "calm ambient rain", limit: 30)
for track in tracks {
print(track.title ?? "?", "—", track.author ?? "?", track.similarity ?? 0)
}Results come back in similarity order, most similar first, and are deterministic for a given index (no sampling, no temperature).
AudioMuse evicts the CLAP model after ~10 minutes idle, so an occasional job always finds it cold. Call warmup() before a batch of searches to pay the model load up front instead of inside the first search's timeout:
await client.warmup() // never throws; returns false if it failed
let tracks = try await client.search(query: "focus deep work")An AudioMuse instance can be wired to several media servers, and its result ids are only meaningful for one of them. Choose a scoping policy when you build the configuration:
// Single-server instance — the default, sends no `server` parameter:
serverScoping: .none
// Read GET /api/servers once, scope every search to the reported default:
serverScoping: .defaultServer
// Scope to a specific server by the id or name AudioMuse knows it under:
serverScoping: .server("navidrome-main")let config = SwiftMuseConfiguration(
urlString: "http://nas.local:8000",
serverScoping: .defaultServer
)!Why this matters. On a catalogue whose id mapping is incomplete, an unscoped request can hand back AudioMuse's internal canonical ids (prefixed
fp_) instead of your media server's own. Those resolve to nothing on the server. Naming the server is what makes AudioMuse translate to that server's ids. See Recovering internal ids below for the fallback when it happens anyway.
let list = try await client.listServers()
print(list.servers.map(\.id), "default:", list.defaultID ?? "—")Even when scoped, a result can carry an internal fp_ id the media server won't recognise. The client returns these as-is rather than dropping them — they come with title and author, which is enough to look the track up in your library by name:
let tracks = try await client.search(query: "energetic upbeat")
for track in tracks {
if track.hasInternalID {
// Media server can't match track.itemID — resolve by metadata instead.
let id = await myLibrary.resolve(title: track.title, artist: track.author)
…
} else {
// Usable directly against your media server.
play(track.itemID)
}
}SonicTrack.internalIDPrefix ("fp_") is exposed if you need it.
Every method throws SwiftMuseError, whose cases map to the conditions worth telling apart:
do {
let tracks = try await client.search(query: "calm piano")
} catch let error as SwiftMuseError {
switch error {
case .searchDisabled(let message): // HTTP 400 — CLAP_ENABLED=false, or bad params
print(message ?? "Sonic search is switched off on this instance.")
case .notAnalysed: // HTTP 503 — no sonic index built yet
print("Run the AudioMuse analysis first.")
case .unauthorized: // HTTP 401/403 — token missing or rejected
print("Check the API token.")
case .httpError(let status):
print("Unexpected status \(status).")
case .badURL, .transport, .decoding:
break
}
}The single seam between the client and the network is the HTTPTransport protocol. Inject a conformance to intercept or replace network calls:
struct LoggingTransport: HTTPTransport {
let wrapped: any HTTPTransport
func data(for request: URLRequest) async throws -> (Data, HTTPURLResponse) {
// Log only safe fields — NEVER the Authorization header.
print(request.httpMethod ?? "?", request.url?.path ?? "?")
return try await wrapped.data(for: request)
}
}
let client = SwiftMuseClient(
configuration: config,
transport: LoggingTransport(wrapped: URLSessionTransport())
)The same seam is how the test suite stubs the network without touching URLSession — see Tests/SwiftMuseTests/MockHTTPTransport.swift.
⚠️ Never log the fullURLRequestor itsAuthorizationheader. An instance may be protected by a bearer token; log only the method, path, or response status.
Logging is off by default — a library should not log on a consumer's behalf. Opt in with a subsystem:
let config = SwiftMuseConfiguration(
urlString: "http://nas.local:8000",
logSubsystem: "com.yourapp.audiomuse"
)!The client then emits os.Logger records (category SwiftMuseClient) for requests, statuses, and scope resolution — with the token always redacted.
| Method | Endpoint | Notes |
|---|---|---|
search(query:limit:) |
POST /api/clap/search |
Free-text sonic search, similarity-ordered |
warmup() |
POST /api/clap/warmup |
Loads the CLAP model; never throws |
listServers() |
GET /api/servers |
Media servers wired to the instance |
Deliberately not covered: /api/alchemy (samples with a temperature, so results wander between runs) and /chat (routes through an LLM, needs provider keys, takes minutes).
- Actor-isolated.
SwiftMuseClientis anactor; call it from anywhere with no locks. - Sendable throughout. Every public type is
Sendable; builds clean under Swift 6 strict concurrency. - Zero dependencies. Apple frameworks only.
- Typed errors. No stringly-typed failures —
SwiftMuseErrordistinguishes the cases you'd branch on. - Injectable transport. One protocol seam for logging, pinning, proxying, and tests.
- Credential-safe. The token is redacted from every description and log line.
- Honest about ids. Internal
fp_ids are surfaced, not hidden, so you can recover them by name.
See CONTRIBUTING.md. Security reports: SECURITY.md.
SwiftMuse is available under the MIT license. See LICENSE.
SwiftMuse is an independent client and is not affiliated with the AudioMuse-AI project.