DEVELOPMENT_SUMMARY.md 17 KB

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

  • 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_fileop_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.swiftObservableObject, @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.swiftObservableObject, @MainActor

  • Exports playlists to Documents/Sync/mixboard-playlists.json
  • JSON format: SyncPayloadSyncPlaylistSyncEntry (matched by filename between devices)
  • Auto-exports when playlist count changes (debounced 2 seconds)
  • Import from JSON creates playlists, matches tracks by filename

ArtworkService.swiftactor

  • 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