SPEC.md 17 KB

MixBoard × Chad Music — Brainstorm Deliverable

6 rounds of deliberation, each with Claude + Codex + Gemini
Date: March 13, 2026


Vision

A distributed auto-syncing music library. Not a Spotify clone — a system where you never stream the same song twice. MixBoard (macOS native player) connects to Chad Music (self-hosted Lisp server) to create a personal music cloud that grows automatically as you listen.

Why build this instead of using Navidrome + a Subsonic client?

  1. Owner-owner integration — you control both client and server code, enabling features no generic client can do
  2. Auto-ingest — music enters cloud automatically as you play local files
  3. Predictive caching — pre-downloads tracks based on listening patterns ("Commute Cache")
  4. EBU R 128 loudness normalization — consistent volume across 30 years of music
  5. Native macOS citizen — Spotlight search, media keys, Shortcuts, AirPlay
  6. Telegram sharing — leverage chad-music's existing bot integration for social sharing
  7. Zero external dependency architecture — everything uses built-in frameworks (AVPlayer, URLSession, SwiftData, Clack)
  8. Full ownership — no DRM, no subscriptions, no data collection, YOUR music on YOUR server

The Chad Music Backend (What Exists Today)

A Common Lisp server by Innokentiy Enikeev (enikesha). Already running and serving music:

  • Stack: SBCL + Woo HTTP + Myway router + Clack middleware + Jonathan (JSON)
  • DB: In-memory hash tables (albums, tracks), persisted as S-expressions (.sexp files)
  • Music scanning: Walks filesystem directories, parses metadata via taglib bindings
  • File serving: Maps filesystem paths → HTTP URLs, serves audio files directly
  • Auth: Telegram Bot Login (HMAC-SHA256) → Bearer token sessions
  • Existing API:
    • GET /api/cat — list categories (artist, year, album, publisher, country, genre, type, status)
    • GET /api/cat/:category — browse with filter, pagination
    • GET /api/album/:id/tracks — tracks with stream URLs
    • GET /api/stats — library statistics
    • POST /api/rescan — trigger filesystem rescan
    • POST /api/login — Telegram auth
  • Has two web frontends: front/ (older React) and web/ (newer React)
  • Repo: https://gogs.chad-partners.com/chad-partners/chad-music

The MixBoard Frontend (What Exists Today)

A macOS native music player:

  • Stack: Swift 5.9, SwiftUI, macOS 14+ (Sonoma), SwiftData, AVAudioEngine
  • Audio: AVAudioEngine → 3-band EQ → mixer → output
  • Formats: MP3, WAV, FLAC, AAC, AIFF, M4A (OGG does NOT work with AVPlayer — known limitation)
  • Features: BPM/key detection, waveform visualization, 14 retro skins, DAW export, cue points, playlists+folders, lyrics from LRCLIB
  • No existing backend/streaming integration

Architecture Decisions (Deliberated)

1. API: Custom REST, Versioned — No Subsonic

Chad Music is personal, MixBoard is the only client. Implementing Subsonic's 70+ endpoints for zero benefit is waste. Keep the clean REST API, version it (/api/v1/...), add what's needed:

# Existing (no changes needed)
GET    /api/v1/cat                           # categories
GET    /api/v1/cat/:category                 # browse
GET    /api/v1/album/:id/tracks              # album tracks with stream URLs
GET    /api/v1/stats                         # library stats
POST   /api/v1/rescan                        # trigger rescan

# New (Phase 1)
GET    /api/v1/search?q=<query>              # full-text search

# New (Phase 2)
POST   /api/v1/upload                        # multipart file upload
POST   /api/v1/exists                        # batch dedup check
GET    /api/v1/library?since=<seq>           # delta sync (sequence-based, NOT timestamp)
PATCH  /api/v1/track/:id                     # update metadata

# New (Phase 4)
POST   /api/v1/share                         # generate temp share URL → Telegram

2. Streaming: Direct HTTP + Range Requests

  • Direct HTTP file serving with Range request support (206 Partial Content) for seeking
  • No HLS in early phases — breaks gapless playback and adds transcoding complexity for zero benefit with one user
  • Auth via AVURLAssetHTTPHeaderFieldsKey header injection (works, but undocumented Apple API — see risks)

    let headers = ["Authorization": "Bearer \(token)"]
    let asset = AVURLAsset(url: streamURL, options: [
    "AVURLAssetHTTPHeaderFieldsKey": headers
    ])
    let playerItem = AVPlayerItem(asset: asset)
    player.replaceCurrentItem(with: playerItem)
    

3. Playback: AVPlayer for Everything (Phase 1)

  • AVPlayer handles both file:// (local) and https:// (cloud) URLs
  • Supports seeking, buffering, background audio, AirPlay, gapless via AVQueuePlayer
  • EQ/BPM/waveform features (AVAudioEngine) unavailable for streaming tracks until Phase 3
  • Loudness normalization only available in Phase 3 (requires AVAudioEngine path with AVAudioUnitEQ — AVPlayer's .volume can't boost above 1.0)

4. Upload: Multipart + Idempotency Key

  • User control: "Auto-add played tracks to cloud" toggle (OFF by default)
  • Upload queue visible in sidebar, Wi-Fi-only option
  • POST /api/v1/upload with X-Idempotency-Key: <uuid> header
  • Client pre-checks via file SHA-256 hash + duration to /api/v1/exists
  • Server computes Chromaprint fingerprint via fpcalc subprocess after upload
  • Idempotency keys stored in WAL events and replayed on recovery

5. Identity & Dedup

  • Primary: Server-side Chromaprint via fpcalc (client never computes — too expensive for battery)
  • Pre-check: Client sends SHA-256 file hash + duration to /api/v1/exists (fast, avoids unnecessary uploads)
  • Fallback: (artist, title, duration±2s) for fingerprint-less legacy tracks

6. Sync: Sequence-Based Delta Protocol

NOT timestamp-based — avoids clock drift and backup-restore issues:

Initial sync:
  GET /api/v1/library?since=0 → full library (paginated, batched SwiftData inserts per 1000 items)

Incremental sync:
  GET /api/v1/library?since=<last_seq> → {added: [...], updated: [...], deleted: [ids], seq: <new_seq>}
  Tombstone TTL: 30 days. If since < oldest tombstone → 410 Gone → client does full resync.

7. Persistence: In-Memory + WAL

Keep chad-music's fast in-memory model, make it durable:

events.log (append-only, fsync per write, SEPARATE THREAD for I/O):
  [seq:1] ADD_TRACK {id: 1234, ...}
  [seq:2] UPDATE_PLAY_COUNT {id: 1234, count: 5}
  [seq:3] IDEMPOTENCY_KEY {key: "550e8400-...", track_id: 1234}

db.sexp (periodic snapshot):
  Every 100 events or on shutdown → atomic rename (write temp → rename to db.sexp)
  Startup: load db.sexp → replay events.log since snapshot → ready

WAL I/O must happen on a separate thread to avoid blocking Woo's event loop during uploads.

8. Search: Client-Side In-Memory Index

Download entire library index on launch (~10MB JSON for 50k tracks). Store in SwiftData. Search is zero-latency — no network requests.

  • Batch SwiftData inserts (commit every 1000 items) to avoid UI freezes
  • For Phase 1, show a progress indicator during initial sync
  • Paginated server-side search planned for Phase 4 (multi-user scaling)

9. Auth: API Key (Phase 1) → Telegram Login (Phase 4)

  • Phase 1: Static API key stored in macOS Keychain, sent via Authorization: Bearer <key>
  • Phase 4: Telegram Login flow via WKWebView → POST /api/login → Bearer token
  • Token refresh: on HTTP 401, re-auth transparently (critical for long playback sessions!)

Critical Risks

# Risk Impact Mitigation Priority
1 Woo/Clack Range request support Seeking won't work in large files Test with curl -H "Range: bytes=0-1000" -v http://server/music/track.flac — must return 206. If not, add Nginx/Caddy as reverse proxy BLOCKER — test first
2 Static file auth Media files may be served without auth (file-server routes may bypass with-user macro) Verify in server.lisp. If unprotected, add auth middleware to file routes High
3 TLS AVPlayer rejects self-signed certs Use Tailscale MagicDNS (auto-TLS) or Caddy reverse proxy with Let's Encrypt High
4 AVURLAssetHTTPHeaderFieldsKey is undocumented Apple could remove it in future macOS Implement AVAssetResourceLoaderDelegate as fallback (already needed for Phase 3 cache-as-you-listen) Medium
5 OGG Vorbis AVPlayer on macOS does NOT support .ogg files Known limitation. Transcode to AAC on server during ingest, or accept as unsupported Low
6 Upload timeouts Large FLAC files (50MB+) over slow uplinks timeout in Clack/Woo Configure server timeout settings. Phase 4: add tus resumable uploads Medium
7 Concurrent upload + playback Background uploads compete for bandwidth Set URLSession QoS to .background for uploads, .userInitiated for streaming Low
8 WAL I/O blocking fsync during uploads blocks Woo event loop → audio stutter Dedicated I/O thread for WAL writes Medium

Creative Features (From Deliberation)

"The Commute Cache" (Phase 3)

Analyze play history patterns: [time_of_day → genres/playlists]. 10 minutes before predicted listening time, pre-download top candidates on Wi-Fi + charging. Rule-based heuristic, not ML.

"Drop to Friend" (Phase 4)

Generate a time-limited signed URL for a track → send to Telegram contact via chad-music's existing bot integration → recipient plays in browser (chad-music's web frontend).

"Cache-as-You-Listen" (Phase 3)

AVAssetResourceLoaderDelegate intercepts stream data and simultaneously writes to local cache. After track finishes → switch future plays to local AVAudioEngine path (enables EQ/BPM). Every cloud track is streamed exactly once.

Native macOS Integration (Phase 3)

  • Now Playing: MPNowPlayingInfoCenter for media keys, Touch Bar, AirPods controls
  • Spotlight: CoreSpotlight indexing of cloud library metadata
  • Shortcuts: "Play playlist X", "Upload current track to cloud" intents

Hello World: "The Infinite Shuffle"

The simplest possible demo proving the entire chain. ~100 lines of Swift, zero server changes.

Pre-flight checks (do these BEFORE writing any Swift):

# 1. Start chad-music with file serving
(main :serve-files t)

# 2. Verify API works
curl -H "Authorization: Bearer <key>" http://server:5000/api/cat/album

# 3. Verify file serving works
curl -v http://server:5000/music/Artist/Album/01-Track.flac

# 4. CRITICAL: Verify Range requests work
curl -H "Range: bytes=0-1000" -v http://server:5000/music/Artist/Album/01-Track.flac
# Must return HTTP 206 Partial Content

# 5. Verify TLS if remote (Tailscale)
curl -v https://chad-music.tailnet.ts.net/api/stats

The code:

import AVFoundation
import SwiftUI

class ChadMusicClient {
    let baseURL: URL
    let token: String
    
    init(baseURL: URL, token: String) {
        self.baseURL = baseURL
        self.token = token
    }
    
    func fetchAlbums() async throws -> [[String: Any]] {
        var req = URLRequest(url: baseURL.appending(path: "api/cat/album"))
        req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        let (data, _) = try await URLSession.shared.data(for: req)
        return try JSONSerialization.jsonObject(with: data) as? [[String: Any]] ?? []
    }
    
    func fetchTracks(albumId: Int) async throws -> [[String: Any]] {
        var req = URLRequest(url: baseURL.appending(path: "api/album/\(albumId)/tracks"))
        req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
        let (data, _) = try await URLSession.shared.data(for: req)
        let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
        return json?["tracks"] as? [[String: Any]] ?? []
    }
    
    func play(trackURL: String) -> AVPlayerItem {
        let url = baseURL.appending(path: trackURL)
        let headers = ["Authorization": "Bearer \(token)"]
        let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
        return AVPlayerItem(asset: asset)
    }
}

// Usage:
// let client = ChadMusicClient(baseURL: URL(string: "https://music.tailnet.ts.net")!, token: "...")
// let albums = try await client.fetchAlbums()
// let tracks = try await client.fetchTracks(albumId: 42)
// player.replaceCurrentItem(with: client.play(trackURL: tracks[0]["url"] as! String))
// player.play()

Implementation Phases

Phase 1 — Stream & Browse (20-35 hours)

Server (chad-music):

  • Search endpoint (GET /api/v1/search?q=...) — ~20 LOC Lisp
  • Verify Range request support (add if missing)
  • API key auth support (if not already working)
  • HTTPS: Tailscale MagicDNS or Caddy reverse proxy

Client (MixBoard):

  • ChadMusicAPIClient (URLSession, auth, JSON decoding) — ~100 LOC
  • Cloud library browser (SwiftUI list views) — ~150 LOC
  • AVPlayer streaming with auth headers — ~60 LOC
  • Settings screen (server URL, API key in Keychain) — ~50 LOC
  • Unified library view (local + cloud) — ~100 LOC

Exit criteria: Browse and play any cloud track from MixBoard

Phase 2 — Upload & Sync (20-30 hours)

Server:

  • Upload endpoint (multipart, Clack handles parsing) with timeout config — ~30 LOC
  • Exists/dedup endpoint (hash + duration) — ~20 LOC
  • Chromaprint via fpcalc subprocess — ~15 LOC
  • WAL persistence (separate I/O thread) — ~50 LOC
  • Delta sync endpoint (sequence-based, tombstones with 30d TTL) — ~40 LOC

Client:

  • UploadManager (URLSession.background, retry, idempotency) — ~200 LOC
  • Upload queue UI — ~100 LOC
  • Auto-upload-on-play toggle — ~30 LOC
  • Sync engine (batched SwiftData inserts, full resync on 410) — ~200 LOC

Exit criteria: Play local track → it appears in cloud library within 60s

Phase 3 — Native Polish (15-20 hours)

Client:

  • AVAssetResourceLoaderDelegate for cache-as-you-listen
  • AVAudioEngine path for cached tracks (EQ/BPM/waveform)
  • Loudness normalization (AVAudioUnitEQ with gain, limiter to prevent clipping)
  • Predictive pre-fetch (rule-based: most-played at time-of-day)
  • CoreSpotlight indexing
  • MPNowPlayingInfoCenter
  • Offline cache with LRU + pin

Server:

  • EBU R 128 LUFS computation during ingest (ffmpeg subprocess)

Exit criteria: Morning commute plays pre-cached tracks without network

Phase 4 — Social & Advanced (10-20 hours)

  • Telegram share links (temp signed URLs via bot API)
  • Shortcuts/Siri intents
  • Resumable uploads (tus protocol) for large files
  • Paginated server-side search (for library growth)
  • Telegram remote control (play/pause/skip via bot commands)

Tech Stack Summary

Side Component Technology Notes
Server HTTP Woo Already in chad-music
Server Routing Myway Already in chad-music
Server Middleware Clack Already in chad-music
Server JSON Jonathan Already in chad-music
Server Audio tags cl-taglib Already in chad-music
Server Multipart Clack (lack.request) Built into existing stack
Server Fingerprint fpcalc External binary (check health on startup)
Server Loudness ffmpeg External binary (Phase 3)
Client Playback AVPlayer / AVQueuePlayer Built into macOS
Client EQ/BPM AVAudioEngine Already in MixBoard
Client HTTP URLSession Built into macOS
Client Data SwiftData Already in MixBoard
Client Auth Security (Keychain) Built into macOS
Client Search CoreSpotlight Built into macOS
Client Media keys MediaPlayer Built into macOS

External binaries on server: fpcalc, ffmpeg. Add health check at startup — if missing, disable fingerprint/loudness features gracefully rather than crashing.


What Three Models Agreed On

  1. Start with Phase 1, validate Range requests before writing any client code
  2. Sequence-based sync, not timestamp-based (clock drift/backup-restore resilience)
  3. Phase 1→3 playback path is a pipeline rewrite (AVPlayer → AVAssetResourceLoaderDelegate → AVAudioEngine) — budget for it, don't treat as incremental
  4. OGG is a known limitation — don't test, just declare unsupported
  5. The Hello World is ~100 LOC of Swift and zero server changes — if Range requests work
  6. The real product moat is "never stream the same song twice" + auto-ingest

Three Things to Verify Before Coding

  1. curl -H "Range: bytes=0-1000" -v http://server/music/track.flacmust return 206
  2. Does file-server route in server.lisp require auth or not? (Check with-user macro scope)
  3. TLS: is Tailscale/Caddy already set up, or is that step zero?

If #1 fails → add Nginx/Caddy as reverse proxy (handles Range requests natively).
If #2 = unauthenticated → add auth middleware to file routes.
If #3 = not set up → set up Tailscale first (15 minutes).


Produced by 6 rounds of Claude + Codex + Gemini deliberation.