# MixBoard iOS — Development Summary & Architecture Guide ## Project Overview **MixBoard iOS** is an iOS/iPadOS music player and mix preparation tool — a companion to the existing **MixBoard macOS** desktop app. The user listens to their own MP3/FLAC/OGG/Opus files on their phone, builds playlists ("mixes"), and syncs the playlist metadata back to the Mac for export to Adobe Audition or other DAWs. ### Repository Locations - **macOS app**: `/Users/vdrobkov/Misc/Documents/Copilot/MixBoard/` - **iOS app**: `/Users/vdrobkov/Misc/Documents/Copilot/Mixboard iOS/` ### Build System - **XcodeGen** (`project.yml` → generates `MixBoardiOS.xcodeproj`) - Run `xcodegen generate` after adding/removing files or changing project settings - After regenerate, must re-set signing team in Xcode (Signing & Capabilities → Team) - Build for device: `xcodebuild -scheme MixBoardiOS -destination 'generic/platform=iOS'` - Build for simulator: `xcodebuild -scheme MixBoardiOS -destination 'platform=iOS Simulator,name=iPhone 17 Pro'` - Run tests: `xcodebuild -scheme MixBoardiOSTests -destination 'platform=iOS Simulator,name=iPhone 17 Pro' test` - **49 unit tests** as of last run, all passing --- ## Architecture ### App Entry Point **`Sources/MixBoardApp.swift`** - Creates `PlayerViewModel`, `PlaylistViewModel`, `LibraryManager`, `AppTheme`, `SyncManager` - Passes them as `@Environment` / `@EnvironmentObject` to the view hierarchy - Creates `ModelContainer` for SwiftData (Track, CuePoint, Playlist, PlaylistEntry, PlaylistFolder) - Has a one-time database reset mechanism (`dbResetV6`) that nukes SQLite files on first launch after code changes — increment the version key when schema changes ### Data Models (`Sources/Models/`) **`Track.swift`** — `@Model` (SwiftData) - `id: UUID`, `title`, `artist`, `album`, `genre`, `filePath` (relative to Documents), `fileName` (just the filename for matching) - `duration`, `bpm`, `musicalKey`, `sampleRate`, `bitDepth`, `channels`, `fileFormat`, `fileSizeBytes` - `year: Int?` — release year extracted from the Date metadata tag - `dateAdded`, `lastPlayed`, `playCount`, `rating`, `color`, `notes` - `waveformData: Data?` — cached waveform - `isAnalyzed: Bool` — whether BPM/key detection ran - `cuePoints: [CuePoint]` — cascade delete relationship - `fileURL` — computed property that resolves `Documents/ + filePath`, uses `.standardizedFileURL` to handle `/private/var` vs `/var` iOS path discrepancy - **IMPORTANT**: `filePath` stores the path relative to the app's Documents directory (e.g., `12.1 Child Udigrudi/12.1.2 Flaviola/01.mp3`). The scan uses `filePath` (not `fileName`) for duplicate detection, so same-named files in different folders are imported separately. **`Playlist.swift`** — `@Model` - `id`, `name`, `notes`, `dateCreated`, `dateModified`, `targetBPM`, `color` (hex) - `groupTemplate: String` — per-playlist grouping template (e.g., `{Album} ({Date})`) - `entries: [PlaylistEntry]` — cascade delete - `folder: PlaylistFolder?` - **WARNING**: `totalDuration` traverses `.track` relationships which can crash on invalidated SwiftData models. The `PlaylistRowView` removed the duration display to avoid this. **`PlaylistEntry.swift`** — `@Model` - `position`, `track: Track?`, `crossfadeDuration`, `startOffset`, `endOffset`, `gainAdjustment`, `notes` **`CuePoint.swift`** — `@Model`, `Comparable` - `timestamp`, `endTimestamp?`, `color`, `type: CuePointType` (enum: Marker, Intro, Outro, Drop, etc.) **`PlaylistFolder.swift`** — `@Model` - Groups playlists, `nullify` delete rule **`AppTheme.swift`** — `ObservableObject` - 7 skins: Winamp, foobar Light, foobar Dark, Windows Media, Obsidian, Vinyl, Tidal - Colors, sizes, cornerRadius, preferredColorScheme - Persisted to UserDefaults **`AppState.swift`** — UserDefaults persistence for last playlist/track/playback position **`GroupTemplateResolver.swift`** - Resolves `{Artist}`, `{Album}`, `{Genre}`, `{Date}`, `{Folder}`, `{Format}`, `{BPM}`, `{Key}` placeholders - Presets: No Grouping, Album (Date), Artist — Album, Artist, Album, Genre, Folder, Format, BPM Range ### Services (`Sources/Services/`) **`AudioEngine.swift`** — `@Observable`, `@MainActor` - `AVAudioEngine` + `AVAudioPlayerNode` + 3-band EQ - Handles three playback modes: 1. **Standard files** (MP3, FLAC, WAV, M4A, etc.) → `AVAudioFile` + `scheduleSegment` 2. **OGG Vorbis** → decoded to `AVAudioPCMBuffer` via `stb_vorbis`, played with `scheduleBuffer` 3. **Opus** → decoded via `libopusfile` (compiled from source), same buffer path - Audio session configured for `.playback` category (background audio) - Handles interruptions (phone calls) and route changes (headphone unplug) - `playbackGeneration` counter prevents stale completion handlers from triggering auto-advance - Node connections use `nil` format (auto-negotiate) — **critical fix**, using fixed output format broke playback for FLAC/OGG **`MetadataService.swift`** - Reads metadata via `AVURLAsset` for standard formats - Special handling for OGG (via `OGGDecoder.fileInfo`) and Opus (via `opusfile` C library — reads Vorbis Comment tags: TITLE, ARTIST, ALBUM, GENRE, DATE) - Extracts year from DATE tag (all formats): checks `commonKeyCreationDate`, `TDRC`, `TYER`, `DATE` tags - Supported extensions: mp3, wav, aif, aiff, flac, m4a, aac, caf, alac, ogg, opus **`OGGDecoder.swift`** - Uses `stb_vorbis.c` (public domain, single-file C decoder in `Sources/OGG/`) - Decodes to non-interleaved float PCM buffer - Bridging header: `Sources/OGG/MixBoard-Bridging-Header.h` **`OpusDecoder.swift`** - Uses `libopusfile` (compiled static libraries in `Sources/OpusLib/lib/`) - `op_open_file` → `op_read_float` → deinterleave to `AVAudioPCMBuffer` - Opus always decodes at 48kHz - Libraries: `libogg.a`, `libopus.a`, `libopusfile.a` — compiled for iOS ARM64 (iphoneos) - Headers in `Sources/OpusLib/include/ogg/` and `Sources/OpusLib/include/opus/` - **NOTE**: Simulator builds will fail to link because libs are device-only. Build simulator versions from `/tmp/opus-build/install-sim/` if needed. **`LibraryManager.swift`** — `ObservableObject`, `@MainActor` - **Scan**: Recursively scans entire Documents directory for audio files, creates Track models - Duplicate detection uses `filePath` (not `fileName`) — critical for same-named files in different folders - Path computation: `fileURL.standardizedFileURL.path` minus `docsDir.path` — handles `/private/var` vs `/var` - **`fixBadPathsIfNeeded()`**: One-time migration for tracks with broken paths from earlier bug - **`scanMusicDirectory()`**: Called on library view appear (once) and manually from Settings - Import from file picker copies to `Documents/Music/` - `os.Logger` with `.notice` level for debugging **`SyncManager.swift`** — `ObservableObject`, `@MainActor` - Exports playlists to `Documents/Sync/mixboard-playlists.json` - JSON format: `SyncPayload` → `SyncPlaylist` → `SyncEntry` (matched by filename between devices) - Auto-exports when playlist count changes (debounced 2 seconds) - Import from JSON creates playlists, matches tracks by filename **`ArtworkService.swift`** — `actor` - Cache keyed by **track file path** (not folder) — each track gets its own artwork - Checks **embedded metadata first** (most accurate), then folder images (cover.jpg, etc.) - Only accesses files within app sandbox (prevents FIGSANDBOX errors) - Callers pass `URL` (captured before async) to avoid SwiftData model invalidation **`WaveformGenerator.swift`**, **`BPMDetector.swift`**, **`KeyDetector.swift`** - Accelerate framework FFT-based analysis - BPM: spectral flux onset detection + autocorrelation (60-200 BPM range) - Key: chromagram + Krumhansl-Kessler profile matching **`MediaKeyHandler.swift`** - `MPRemoteCommandCenter` for lock screen / Control Center controls - `MPNowPlayingInfoCenter` updates ### ViewModels (`Sources/ViewModels/`) **`PlayerViewModel.swift`** — `@Observable`, `@MainActor` - Wraps `AudioEngine`, syncs state at 30fps via timer - Shuffle, repeat (off/all/one), seek, skip, EQ - Waveform loading with cache - `os.Logger` debug output for playback: logs file path, exists check, success/failure **`PlaylistViewModel.swift`** — `@Observable`, `@MainActor` - **3 mix target slots** (`mixTargets: [Playlist?]` array, indices 0-2) - `quickAddToMix(slot:track:context:)` — adds track to specific mix slot - `isDuplicate` uses **`fetchCount` query** (not relationship traversal) — critical to avoid SwiftData model invalidation crashes - `addTracks` sorts by `filePath` with `.numeric` option for correct folder ordering - Persists mix targets to UserDefaults (`mixTarget0ID`, `mixTarget1ID`, `mixTarget2ID`) ### Views (`Sources/Views/`) **`ContentView.swift`** — Root view - No TabView — playlists are the main screen - Mini player at bottom (`MiniPlayerView`) - Full-screen cover for Now Playing view - Library and Settings opened as sheets from `PlaylistListView` toolbar **`PlaylistListView.swift`** — Main screen - Title: "MixBoard" - Top-left: Library (music note) and Settings (gear) buttons - Top-right: + button for new playlist - List of playlists with swipe actions (delete, set as target) **`PlaylistDetailView.swift`** - Shows playlist entries, supports grouping via `groupTemplate` - `flatEntryList` (no grouping) or `groupedEntryList` (by template) - Menu: Add Tracks, Set as Target, Grouping..., Export for Mac, Play All - `GroupTemplateEditorSheet` for editing the grouping template **`LibraryView.swift`** - 5 browse modes: **Folders** (default), Songs, Artists, Albums, Genres - Folders mode shows `FolderBrowserView` directly - Group action rows: Play, Mix 1/2/3 buttons, + (add to playlist) - Auto-scans on first appear (`hasScanned` flag) **`FolderBrowserView.swift`** — Drill-down folder navigation - Shows subfolders + audio files at each level - Action row at top with Play/Mix/Add buttons for all tracks recursively - `allTracksRecursive` cached in `@State` on appear (not computed in body — performance critical) - Context menu on folders for bulk add - Folder artwork from cover images **`NowPlayingView.swift`** — Full-screen player - Large artwork, waveform, seekbar, transport controls - 3 mix buttons (red/blue/gold) with playlist names - Skip ±10s buttons - Presented as `.fullScreenCover` **`MiniPlayerView.swift`** — Bottom bar - Progress bar, artwork thumbnail, track info, play/pause, next - Tap opens Now Playing - Buttons use `.buttonStyle(.plain)` with 44pt touch targets **`TrackRow.swift`** — Reusable track row - Artwork, title, artist, BPM, key, duration, format badge - 3 colored mix buttons (1=red, 2=blue, 3=gold) - Currently playing indicator **`SettingsView.swift`** - Mix Targets configuration (3 slots, tap row to pick playlist) - App Icon color picker (10 variants) - Skin picker (7 skins) - Sync export/import - Library stats + **Reset Library & Rescan** button - About section **`AddToPlaylistSheet.swift`**, **`AddGroupToPlaylistSheet.swift`**, **`GroupTemplateEditorSheet.swift`**, **`EntryNotesSheet.swift`** - Various action sheets for playlist management ### Tests (`Tests/ModelTests.swift`) - 49 tests covering: Track, Playlist, PlaylistEntry, CuePoint, PlaylistFolder, AppState, Sync encoding/decoding, MetadataService, AppTheme, Color hex parsing --- ## Key Technical Decisions & Known Issues ### SwiftData Model Invalidation (CRITICAL) SwiftData `@Model` objects can become invalidated when: 1. The backing store is modified by a concurrent save 2. A scan/import runs while the UI holds model references 3. Relationship traversal (`.track?.property`) on invalidated entries **Mitigations applied:** - `isDuplicate` uses `fetchCount` query instead of relationship traversal - `PlaylistRowView` removed `formattedTotalDuration` (traverses `.track`) - `ArtworkService` receives `URL` (captured before async), not `Track` model - `allTracksRecursive` cached in `@State`, not computed in `body` - Database reset mechanism for corrupted stores ### File Path Handling (iOS-specific) - iOS resolves `/var/mobile/...` to `/private/var/mobile/...` for file URLs - Must use `.standardizedFileURL` before computing relative paths - `Track.fileURL` uses `standardizedFileURL` for resolution ### Background Audio - `Info.plist` must have `UIBackgroundModes: ["audio"]` as an **array** (not string) - Configured via `Info.plist` file referenced in `project.yml` `info:` section - Audio session category set to `.playback` in `AudioEngine.init()` ### OGG/Opus Native Libraries - `stb_vorbis.c` in `Sources/OGG/` — single-file C decoder for OGG Vorbis - `libogg.a`, `libopus.a`, `libopusfile.a` in `Sources/OpusLib/lib/` — compiled for iOS ARM64 - Headers in `Sources/OpusLib/include/` - Bridging header at `Sources/OGG/MixBoard-Bridging-Header.h` - **Simulator builds require simulator-compiled libraries** (not currently included) - Build scripts at `/tmp/opus-build/build_ios.sh` (may need recreation) ### Sync Between iOS ↔ macOS - iOS auto-exports `mixboard-playlists.json` to Documents/Sync/ - macOS has `SyncImporter.swift` and `SyncWatcher.swift` (partially integrated) - Match tracks by filename between devices - Transfer via AirDrop, iCloud Drive, USB, or Files app --- ## File Structure ``` Sources/ ├── MixBoardApp.swift # App entry point ├── Models/ │ ├── Track.swift # Core track model │ ├── Playlist.swift # Playlist + PlaylistEntry models │ ├── PlaylistFolder.swift # Folder grouping │ ├── CuePoint.swift # Cue markers │ ├── AppState.swift # UserDefaults persistence │ ├── AppTheme.swift # 7 skins │ └── GroupTemplateResolver.swift # Playlist grouping templates ├── ViewModels/ │ ├── PlayerViewModel.swift # Playback state │ └── PlaylistViewModel.swift # Playlist CRUD, 3 mix targets ├── Views/ │ ├── ContentView.swift # Root view │ ├── PlaylistListView.swift # Main screen (playlists) │ ├── PlaylistDetailView.swift # Playlist detail + grouping │ ├── LibraryView.swift # Library browser (5 modes) │ ├── FolderBrowserView.swift # Drill-down folder navigation │ ├── NowPlayingView.swift # Full-screen player │ ├── MiniPlayerView.swift # Bottom player bar │ ├── TrackRow.swift # Track row + 3 mix buttons │ ├── WaveformView.swift # Canvas waveform │ ├── SettingsView.swift # Settings + mix targets + icon picker │ ├── AddToPlaylistSheet.swift # Single track → playlist │ ├── AddGroupToPlaylistSheet.swift # Multiple tracks → playlist │ └── GroupTemplateEditorSheet.swift # Grouping template editor ├── Services/ │ ├── AudioEngine.swift # AVAudioEngine playback │ ├── MetadataService.swift # Tag reading (all formats) │ ├── LibraryManager.swift # File scanning & import │ ├── SyncManager.swift # JSON sync export/import │ ├── ArtworkService.swift # Album art (embedded + folder) │ ├── OGGDecoder.swift # OGG Vorbis via stb_vorbis │ ├── OpusDecoder.swift # Opus via libopusfile │ ├── WaveformGenerator.swift # Accelerate-based waveform │ ├── BPMDetector.swift # Spectral flux BPM detection │ ├── KeyDetector.swift # Chromagram key detection │ └── MediaKeyHandler.swift # Lock screen controls ├── OGG/ │ ├── stb_vorbis.c # C decoder (public domain) │ ├── stb_vorbis_wrapper.h # Header wrapper │ └── MixBoard-Bridging-Header.h # Swift-C bridge ├── OpusLib/ │ ├── include/ogg/*.h # libogg headers │ ├── include/opus/*.h # libopus + opusfile headers │ └── lib/*.a # Static libraries (iOS ARM64) └── Resources/ └── Assets.xcassets/ # App icons (10 color variants) Tests/ └── ModelTests.swift # 49 unit tests project.yml # XcodeGen project definition Info.plist # Background audio mode MixBoardiOS.entitlements # App group entitlement Package.swift # SPM (for library builds) ``` --- ## Common Operations ### Adding a new file 1. Create the file in the appropriate `Sources/` subdirectory 2. Run `xcodegen generate` to add it to the Xcode project 3. Re-set signing team in Xcode ### Changing project settings 1. Edit `project.yml` 2. Run `xcodegen generate` 3. Re-set signing team ### Database schema changes 1. Modify `@Model` classes 2. Increment the `dbResetV*` key in `MixBoardApp.init()` to force a clean database 3. Users will need to rescan their music library ### Testing on device - `Cmd+R` in Xcode (must have team set) - Free Apple ID: app expires after 7 days, max 3 devices - Delete app from phone to clear corrupted database (loses music files!) - Use Settings → Reset Library & Rescan instead of deleting the app ### Debugging playback - Check Xcode console for `loadAndPlay:` log lines - Shows file path, exists check, success/failure - `scanMusicDirectory:` shows how many files were found on disk