Skip to content

Repository files navigation

SwiftMuse

CI Swift 6 SPM compatible GitHub release Platforms License: MIT

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.


What is this?

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.


Why SwiftMuse?

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 ✅

Requirements

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.


Installation

Swift Package Manager

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.


Usage

Basic setup

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.

Searching

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).

Warming up the model

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")

Scoping to a media server

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.

Listing servers

let list = try await client.listServers()
print(list.servers.map(\.id), "default:", list.defaultID ?? "—")

Recovering internal ids

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.

Error handling

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
    }
}

Custom transport (logging, cert pinning, proxies, tests)

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 full URLRequest or its Authorization header. An instance may be protected by a bearer token; log only the method, path, or response status.

Observability

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.


API surface

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).


Design principles

  • Actor-isolated. SwiftMuseClient is an actor; 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 — SwiftMuseError distinguishes 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.

Contributing

See CONTRIBUTING.md. Security reports: SECURITY.md.

License

SwiftMuse is available under the MIT license. See LICENSE.

SwiftMuse is an independent client and is not affiliated with the AudioMuse-AI project.

About

An AudioMuse API's Swift-native client.

Topics

Resources

Contributing

Security policy

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages