# 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= # 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= # 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) ```swift 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: ` 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= → {added: [...], updated: [...], deleted: [ids], 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 ` - 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): ```bash # 1. Start chad-music with file serving (main :serve-files t) # 2. Verify API works curl -H "Authorization: Bearer " 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: ```swift 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.flac` → **must 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.*