# 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
