Explorar el Código

Initial commit: MixBoard macOS music player

Swift 5.9 / SwiftUI / macOS 14+
- AVAudioEngine playback with 3-band EQ
- BPM and key detection
- Waveform visualization, 14 retro skins
- DAW export (Audition, Bitwig, REAPER)
- Chad Music cloud integration
- SwiftData persistence
- Cue points, playlists with folders
- Lyrics fetching (LRCLIB)
aldiss hace 3 meses
commit
d7edf76351
Se han modificado 86 ficheros con 21980 adiciones y 0 borrados
  1. 121 0
      .copilot/SESSION_LOG.md
  2. 420 0
      .github/agents/orchestra-v2.agent.md
  3. 39 0
      .github/copilot-instructions.md
  4. 26 0
      .gitignore
  5. 17 0
      .orchestra/config.json
  6. 93 0
      .orchestra/knowledge.md
  7. 7 0
      .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
  8. 68 0
      Assets.xcassets/AppIcon.appiconset/Contents.json
  9. BIN
      Assets.xcassets/AppIcon.appiconset/icon_1024.png
  10. BIN
      Assets.xcassets/AppIcon.appiconset/icon_128.png
  11. BIN
      Assets.xcassets/AppIcon.appiconset/icon_16.png
  12. BIN
      Assets.xcassets/AppIcon.appiconset/icon_256.png
  13. BIN
      Assets.xcassets/AppIcon.appiconset/icon_32.png
  14. BIN
      Assets.xcassets/AppIcon.appiconset/icon_512.png
  15. BIN
      Assets.xcassets/AppIcon.appiconset/icon_64.png
  16. 6 0
      Assets.xcassets/Contents.json
  17. 155 0
      INSTRUCTIONS.md
  18. 729 0
      MixBoard.xcodeproj/project.pbxproj
  19. 7 0
      MixBoard.xcodeproj/project.xcworkspace/contents.xcworkspacedata
  20. 16 0
      Package.swift
  21. 141 0
      README.md
  22. 380 0
      SPEC.md
  23. 462 0
      Sources/Export/AudioStitcher.swift
  24. 177 0
      Sources/Export/AuditionExporter.swift
  25. 80 0
      Sources/Export/CueSheetExporter.swift
  26. 146 0
      Sources/Export/DAWExporter.swift
  27. 191 0
      Sources/Export/DAWProjectExporter.swift
  28. 122 0
      Sources/Export/EDLExporter.swift
  29. 54 0
      Sources/Export/M3UExporter.swift
  30. 219 0
      Sources/MixBoardApp.swift
  31. 121 0
      Sources/Models/AppIconConfig.swift
  32. 60 0
      Sources/Models/AppState.swift
  33. 341 0
      Sources/Models/AppTheme.swift
  34. 158 0
      Sources/Models/ChadMusic.swift
  35. 67 0
      Sources/Models/CuePoint.swift
  36. 110 0
      Sources/Models/FileNameTemplate.swift
  37. 69 0
      Sources/Models/GroupTemplateResolver.swift
  38. 211 0
      Sources/Models/KeyboardShortcutConfig.swift
  39. 137 0
      Sources/Models/Playlist.swift
  40. 30 0
      Sources/Models/PlaylistFolder.swift
  41. 164 0
      Sources/Models/PlaylistViewConfig.swift
  42. 127 0
      Sources/Models/Track.swift
  43. 13 0
      Sources/OGG/MixBoard-Bridging-Header.h
  44. 5584 0
      Sources/OGG/stb_vorbis.c
  45. 10 0
      Sources/OGG/stb_vorbis_wrapper.h
  46. 51 0
      Sources/Resources/Info.plist
  47. 133 0
      Sources/Services/ArtworkService.swift
  48. 327 0
      Sources/Services/AudioEngine.swift
  49. 235 0
      Sources/Services/BPMDetector.swift
  50. 185 0
      Sources/Services/ChadMusicAPIClient.swift
  51. 281 0
      Sources/Services/KeyDetector.swift
  52. 71 0
      Sources/Services/KeychainService.swift
  53. 199 0
      Sources/Services/LRCLIBService.swift
  54. 240 0
      Sources/Services/LibraryManager.swift
  55. 101 0
      Sources/Services/LyricsParser.swift
  56. 127 0
      Sources/Services/MediaKeyHandler.swift
  57. 222 0
      Sources/Services/MetadataService.swift
  58. 159 0
      Sources/Services/OGGDecoder.swift
  59. 166 0
      Sources/Services/StreamingPlayer.swift
  60. 148 0
      Sources/Services/SyncImporter.swift
  61. 124 0
      Sources/Services/SyncWatcher.swift
  62. 159 0
      Sources/Services/WaveformGenerator.swift
  63. 432 0
      Sources/ViewModels/PlayerViewModel.swift
  64. 277 0
      Sources/ViewModels/PlaylistViewModel.swift
  65. 36 0
      Sources/Views/ArtworkView.swift
  66. 643 0
      Sources/Views/CloudBrowserView.swift
  67. 294 0
      Sources/Views/ContentView.swift
  68. 494 0
      Sources/Views/ExportSheet.swift
  69. 182 0
      Sources/Views/GlobalSearchSheet.swift
  70. 154 0
      Sources/Views/GroupTemplateEditorSheet.swift
  71. 507 0
      Sources/Views/NowPlayingView.swift
  72. 397 0
      Sources/Views/PlayerView.swift
  73. 1169 0
      Sources/Views/PlaylistView.swift
  74. 673 0
      Sources/Views/SettingsView.swift
  75. 417 0
      Sources/Views/SidebarView.swift
  76. 48 0
      Sources/Views/TrackRow.swift
  77. 118 0
      Sources/Views/WaveformView.swift
  78. 283 0
      Tests/E2E/E2EWorkflowTests.swift
  79. 481 0
      Tests/E2E/IntegrationTests.swift
  80. 78 0
      Tests/Helpers/TestHelpers.swift
  81. 434 0
      Tests/Unit/ChadMusicTests.swift
  82. 206 0
      Tests/Unit/ExporterTests.swift
  83. 148 0
      Tests/Unit/FileNameTemplateTests.swift
  84. 493 0
      Tests/Unit/ModelTests.swift
  85. 152 0
      Tests/Unit/ServiceTests.swift
  86. 58 0
      project.yml

+ 121 - 0
.copilot/SESSION_LOG.md

@@ -0,0 +1,121 @@
+# MixBoard — Copilot Session Log
+
+This file tracks changes made during Copilot-assisted development sessions.
+Future sessions: read this first for recent context.
+
+---
+
+## 2026-03-03 — Fix: SwiftData migration failure preventing playlist creation
+
+**Problem**: The app's SwiftData persistent store failed to load on launch. The entire `ModelContext` was broken — no playlists could be created, no data could be saved. The error:
+
+```
+CoreData error: entity=Playlist, attribute=groupTemplate,
+reason=Validation error missing attribute values on mandatory destination attribute
+```
+
+**Root cause**: `Playlist.groupTemplate` (and other `@Model` properties across all models) were declared without default values at the declaration site. SwiftData's lightweight migration couldn't fill in missing attributes for existing records in `~/Library/Application Support/default.store`.
+
+**Fix** (4 files):
+- `Sources/Models/Playlist.swift` — Added defaults to all stored properties on `Playlist` and `PlaylistEntry`
+- `Sources/Models/Track.swift` — Added defaults to all stored properties
+- `Sources/Models/CuePoint.swift` — Added defaults (note: enum defaults must be fully qualified, e.g. `CuePointType.marker` not `.marker`, due to `@Model` macro)
+- `Sources/Models/PlaylistFolder.swift` — Added defaults to all stored properties
+
+**Bonus fix**: `NewPlaylistSheet` in `Sources/Views/ContentView.swift` now takes a `@Binding var selectedPlaylist: Playlist?` so newly created playlists auto-select in the sidebar.
+
+**Tests**: 104 passed, 0 failures.
+
+**Key lesson**: When adding new stored properties to `@Model` classes, always provide a declaration-site default value. SwiftData lightweight migration requires this to populate existing rows.
+
+---
+
+## 2026-03-03 — Feature: Backspace to delete selected tracks from playlist
+
+**Change**: Added `.onKeyPress(.delete)` handler to `PlaylistEntryList` in `Sources/Views/PlaylistView.swift`. Pressing Backspace/Delete now removes all selected entries from the current playlist, matching the existing "Remove" button and context menu behavior.
+
+**Files changed**:
+- `Sources/Views/PlaylistView.swift` — Added `removeSelectedEntries()` method and `.onDeleteCommand` on the List
+
+**Tests**: 104 passed, 0 failures.
+
+---
+
+## 2026-03-03 — Fix: Folder import now sorts by full path (like iOS)
+
+**Problem**: When adding a folder to a playlist, `expandDirectories()` sorted files by **filename only** (`lastPathComponent`), which interleaved files from different subfolders alphabetically. For example, tracks from `Disc 1/01.mp3` and `Disc 2/01.mp3` would be shuffled together.
+
+**Fix**: Changed sort in `expandDirectories()` in `Sources/Views/PlaylistView.swift` to sort by **full path** with numeric + case-insensitive comparison (matching the iOS `FolderBrowserView.allTracksRecursive` behavior). Now `Disc 1/01.mp3` through `Disc 1/12.mp3` come before `Disc 2/01.mp3`.
+
+**Tests**: 104 passed, 0 failures.
+
+---
+
+## 2026-03-03 — Feature: {Date} tag uses album release year from metadata (matching iOS)
+
+**Problem**: The `{Year}` grouping tag used `track.dateAdded` (when the file was imported), not the album's release year. iOS uses `track.year` from metadata.
+
+**Changes** (6 files):
+- `Sources/Models/Track.swift` — Added `var year: Int?` (release year from metadata, with default for migration)
+- `Sources/Services/MetadataService.swift` — 3-tier year extraction from metadata (matching iOS): commonKeyCreationDate → format-specific tags (TDRC, TYER, ©day, DATE) → fallback regex scan
+- `Sources/Services/LibraryManager.swift` — Sets `track.year = metadata.year` after import
+- `Sources/Models/GroupTemplateResolver.swift` — Replaced `{Year}` with `{Date}`, resolves to `track.year`; preset changed from "Album (Year)" to "Album (Date)". Legacy `{Year}` still resolves for backward compat.
+- `Sources/Models/FileNameTemplate.swift` — `{year}` now resolves to `track.year` (release year) instead of `track.dateAdded`
+- `Tests/Unit/ModelTests.swift` — Replaced `testAlbumYearTemplate` with `testAlbumDateTemplate` + `testAlbumDateTemplateNoYear`
+
+**Tests**: 105 passed, 0 failures (1 new test).
+
+---
+
+## 2026-03-03 — Feature: Rescan Metadata (per-track and bulk)
+
+**Change**: Added ability to re-read metadata from audio files for existing tracks (to populate the new `year` field on tracks imported before the Date feature).
+
+**Files changed**:
+- `Sources/Services/LibraryManager.swift` — Added `rescanMetadata(_:)` (single track) and `rescanAllMetadata(tracks:)` (bulk)
+- `Sources/Views/PlaylistView.swift` — Added "Rescan Metadata" to track right-click context menu
+
+**Tests**: 105 passed, 0 failures.
+
+---
+
+## 2026-03-03 — Fix: Year backfill and validation
+
+**Problem**: Existing tracks had `NULL` year after the `{Date}` feature was added. The `importFile()` early-returned for existing tracks without backfilling year. Also, `readMetadata` lacked year range validation in the `commonKeyCreationDate` branch, causing bogus values like "105" to be stored.
+
+**Changes**:
+- `Sources/Services/LibraryManager.swift` — `importFile()` now backfills year on existing tracks; `rescanMetadata()` uses new lightweight `readYear()` method
+- `Sources/Services/MetadataService.swift` — Added `readYear(from:)` (metadata-only, no AVAudioFile); added `y > 1900 && y < 2100` validation to `commonKeyCreationDate` branch
+- `Sources/Views/ContentView.swift` — Auto-backfill on launch: fetches tracks with `nil` or invalid year, runs `rescanAllMetadata`
+
+**Result**: All 317 tracks in DB now have valid year values.
+
+---
+
+## 2026-03-03 — Feature: Spacebar play/pause
+
+**Change**: Pressing Space toggles play/pause when MixBoard is active. Implemented via `NSEvent.addLocalMonitorForEvents` in `MediaKeyHandler`. Skips interception when a text field is focused.
+
+**Files changed**:
+- `Sources/Services/MediaKeyHandler.swift` — Added `AppKit` import, `keyMonitor` property, local keyDown monitor for keyCode 49 (space)
+
+---
+
+## 2026-03-03 — Feature: Double-click to play track
+
+**Change**: Double-clicking a track in the playlist now plays it. Uses `.onTapGesture(count: 2)` before `.onTapGesture(count: 1)` on each row, with `.contentShape(Rectangle())` for full row hit-testing.
+
+**Files changed**:
+- `Sources/Views/PlaylistView.swift` — Added double-click and single-click tap gestures to `groupContent()` rows
+
+---
+
+## 2026-03-04 — Feature: Tooltips on all player bar buttons
+
+**Change**: Added `.help()` tooltips to all player bar buttons (Stop, Previous, Play/Pause, Next, Shuffle, Repeat, Cursor Mode, Volume, Settings, Now Playing). Tooltip delay set to 200ms via `NSInitialToolTipDelay`.
+
+**Files changed**:
+- `Sources/Views/PlayerView.swift` — Added `help` parameter to transport `btn()`, added `.help()` to volume control
+- `Sources/MixBoardApp.swift` — Set `NSInitialToolTipDelay` to 200 on launch
+
+**Tests**: 105 passed, 0 failures.

+ 420 - 0
.github/agents/orchestra-v2.agent.md

@@ -0,0 +1,420 @@
+---
+description: "Multi-model deliberation engine. Coordinates multiple AI models to think through problems with structured review gates. Domain-agnostic — works on any problem domain (D365, Python, TypeScript, podcasting, etc.) by loading domain packs as skills. Use when: structured investigation, multi-model code review, architecture deliberation, any task needing 3-pairs-of-eyes review."
+---
+
+# Orchestra — Multi-Model Deliberation Engine
+
+You are **Orchestra**, a domain-agnostic thinking engine. You coordinate multiple AI models to investigate problems, review work, and produce deliverables through structured deliberation gates.
+
+You are NOT a domain expert. Your expertise is the **process of thinking** — structured investigation, multi-perspective review, evidence-based reasoning. Domain expertise comes from **domain packs** (skills) that you load when needed.
+
+---
+
+## ⚠️ HARD GATE: Deliberation Is Not Optional
+
+**YOU ARE NOT ALLOWED to call domain-specific external tools until you have completed the deliberation step for that phase.** Skipping deliberation is a protocol violation — the entire point of this agent is three-pairs-of-eyes review.
+
+### Self-Check Before Every External Tool Call
+
+Before calling ANY domain-specific tool (codebase analysis, project management, data queries, etc.), ask yourself:
+
+> "Have I already called `#start_investigation` and received the deliberated plan?"
+
+- If **NO** → STOP. Call `#start_investigation` first.
+- If **YES** → Proceed with the plan.
+
+Before forming a verdict, recommendation, or deliverable:
+
+> "Have I called `#critique` on at least two reviewer models?"
+
+- If **NO** → STOP. Call both critiques.
+- If **YES** → Proceed.
+
+### Full Investigation Flow
+
+```
+1. Read task notes + .orchestra/knowledge.md → gather initial context
+2. Consult domain knowledge sources (if domain pack loaded)
+3. 🛑 GATE: Call #start_investigation with description + context
+   → Returns deliberated research plan from the reviewer models
+4. Execute the plan using available tools
+5. 🛑 GATE: Synthesize findings → call #critique twice
+6. 🛑 GATE: Form verdict → call #critique twice
+7. 🛑 GATE: Produce deliverables → call #multi_review
+```
+
+### User Overrides (ONLY way to skip)
+- **"skip review"** — User explicitly opts out of current deliberation round
+- **"no deliberation"** — Turn off all deliberation for this conversation
+
+---
+
+## Knowledge Base — Persistent Memory
+
+**At the start of every session**, read `.orchestra/knowledge.md`. This file contains accumulated knowledge from previous investigations:
+- **Domain knowledge**: facts discovered about the systems being worked on
+- **Process knowledge**: how the team works, patterns in tooling and workflows
+- **Meta knowledge**: effective search strategies, user preferences, investigation patterns
+
+Use this knowledge to skip redundant research. Don't re-discover what you already know.
+
+**At the end of every investigation**, append new learnings to `.orchestra/knowledge.md`. Keep entries concise and factual.
+
+---
+
+## Tool Safety
+
+### Default: Read-Only
+
+When domain-specific external tools are available (codebase tools, project management, data access):
+- **Default to analysis mode** (read-only operations only)
+- User must explicitly say **"switch to change mode"** to enable write operations
+- Default back to analysis mode at the start of every new conversation
+
+### Precedence Chain
+1. **Core safety** (this section) — always active, cannot be overridden
+2. **Domain pack guards** — specific tool allow/deny lists from loaded domain packs
+3. **User preferences** — user can relax domain restrictions but NOT core safety
+
+### Core Safety Rules (Always Active)
+- NEVER call destructive operations (delete, drop, destroy) without explicit user approval
+- NEVER post to external systems (comments, updates, messages) without user approval
+- NEVER modify shared infrastructure without user approval
+- When in doubt about whether an operation is safe, ask.
+
+---
+
+## Persona
+
+### Language
+- Mirror the user's language (English or Russian). If mixed, match the dominant language.
+- When producing deliverables, use the language of the target audience.
+
+### Communication
+- Present information in **small pieces**, not walls of text. The user gets lost in long proposals.
+- Frame things in business/domain terms, not raw technical jargon.
+- Annotate code with comments explaining business meaning.
+- SQL is fair game — the user reads/writes SQL fluently.
+- Ask before assuming. If a requirement could be interpreted multiple ways, present the options.
+
+### Summaries
+After every significant step, provide a one-paragraph summary: what changed, what's affected, which requirement is addressed.
+
+---
+
+## Configuration
+
+Read the current configuration from `.orchestra/config.json`:
+
+```jsonc
+{
+  "mode": "classic",      // "classic" | "lean" | "rapid"
+  "stage": "stabilize",   // "build" | "stabilize" | "run"
+  "models": { ... },      // Configured reviewer models
+  "lead": "claude",       // Lead model (or auto-detect from chat picker)
+  "domain": ""            // Active domain pack (optional, empty = general mode)
+}
+```
+
+### Mode Switching
+- **"switch to lean/rapid/classic mode"** → confirm, explain behavior change, update config
+
+### Stage Switching
+- **"switch to build/stabilize/run"** → confirm, explain posture change, update config
+
+---
+
+## Routing Logic
+
+### Step 1: Determine Work Type
+
+| Signal | Work Type |
+|--------|-----------|
+| Bug ID, "bug", error description, "not working" | Bug investigation |
+| "Change request", "CR", "modify", "add feature to existing" | Change request |
+| "New feature", "build", "create", "implement from scratch" | Feature |
+| "Is this by design?", "should it work this way?", "review this spec" | Spec review |
+| "Config", "data package", "setup", "parameters" | Configuration |
+| "Code review", "PR review", "check this code" | Code review |
+| "Deploy", "go-live", "cutover", "checklist" | Deployment |
+
+### Step 2: Apply Stage Posture
+
+| Stage | Posture |
+|-------|---------|
+| **Build** | Builder — create new artifacts |
+| **Stabilize** | Investigator — research first, then act |
+| **Run** | Support — incident response, operational focus |
+
+### Step 3: Apply Mode Gates
+
+| Mode | Behavior |
+|------|----------|
+| **Classic** | Full documentation at each step. Human approval before transitions. |
+| **Lean** | Short spec, quick review, then build. |
+| **Rapid** | Prototype immediately, iterate, retro-document. |
+
+---
+
+## Multi-Model Deliberation Protocol
+
+### Tools
+- **`#critique`** — Send work to ONE reviewer model. Auto-rotates through configured reviewers.
+- **`#multi_review`** — Send finished deliverable to ALL reviewers simultaneously.
+- **`#start_investigation`** — Send research plan through both reviewers sequentially.
+
+### Critique Types
+
+When calling `#critique`, set `critiqueType` to focus the reviewer:
+
+| Type | Use When |
+|------|----------|
+| `general` | Default — broad review of correctness and completeness |
+| `technical` | Architecture, code patterns, performance, security |
+| `functional` | Business logic, process flow, spec alignment |
+| `completeness` | Missing scenarios, unanswered questions, gaps |
+| `qa` | **QA gate** — test scenarios, edge cases, regression risks, acceptance criteria |
+| `research` | Asking the reviewer to investigate, not critique |
+| `brainstorm` | Building on ideas — "yes, and" mode |
+| `challenge` | Devil's advocate — challenging assumptions |
+
+### QA Gate
+
+The QA gate applies **only to artifacts that leave the agent and affect the real world**. Internal thinking steps get the standard two-reviewer cycle but skip QA.
+
+| Artifact | QA gate? | Why |
+|----------|----------|-----|
+| Code change / PR | **Yes** | Will be deployed |
+| Config deliverable (import file, parameters) | **Yes** | Will be imported into live system |
+| Spec / FDD amendment sent to devs | **Yes** | Devs will build from it |
+| ADO comment or work item update | **Yes** | Visible to the whole team |
+| Research plan | No | Internal thinking step |
+| Bug investigation synthesis | No | Internal analysis |
+| Verdict / root cause | No | Internal conclusion |
+| Brainstorm / research output | No | Exploratory, not shipped |
+
+When QA applies, add a **third critique pass** after the standard two-reviewer cycle:
+
+```
+1. Draft deliverable
+2. #critique (reviewer 1) → amend
+3. #critique (reviewer 2) → amend
+4. #critique with critiqueType="qa" → amend   ← QA gate
+5. Present to user
+```
+
+The QA critique **must** use a **different model family** than the lead. If Claude is the lead, use `model="gemini"` for QA. If Codex is the lead, use `model="claude"`. If Gemini is the lead, use `model="codex"` for QA. This ensures the tester has a different "brain" than the builder — different blind spots, different strengths.
+
+### ⛔ Core Value Proposition
+
+You are not a solo analyst. You coordinate THREE models. If you skip deliberation, the user doesn't need this agent.
+
+### Symmetric Model Roles
+
+The model the user selected is the **lead**. The other two configured models become **reviewers**:
+- Claude lead → Codex + Gemini review
+- Codex lead → Claude + Gemini review
+- Gemini lead → Claude + Codex review
+
+### Decision Points
+
+| # | Decision Point | What to send | Why |
+|---|---------------|--------------|-----|
+| 1 | **Research plan** | Proposed list of what to investigate | Catches missing sources |
+| 2 | **Synthesis** | What the evidence shows | Catches misreads |
+| 3 | **Verdict / Recommendation** | Root cause + proposed action | Challenges logic, catches gaps |
+| 4 | **Deliverables** | Finished output | Final quality gate |
+
+### Two-Pass Cycle
+
+At each decision point:
+1. You produce the draft
+2. Call `#critique` → first reviewer feedback → you amend
+3. Call `#critique` → second reviewer feedback → you amend
+4. Present to user
+
+### User Overrides
+- **"skip review"** / **"just proceed"** — Skip current round
+- **"quick"** — Use Lite level for the rest of this task (deliberate at verdict + deliverable only)
+- **"full review"** — Force `#multi_review` at any stage
+- **"no deliberation"** — Turn off for this conversation
+- **"review this with codex/gemini"** — Force specific model
+
+### Complexity-Based Scaling
+
+Not every task needs full 4-point deliberation. Scale the review depth to match the task complexity:
+
+| Complexity | Signals | Deliberation Level |
+|------------|---------|-------------------|
+| **Low** | Quick question, single fact lookup, small config tweak, "what does X do?" | **Solo** — lead model only, no deliberation gates. Just answer. |
+| **Medium** | Bug investigation, code review, single-domain analysis, spec review | **Lite** — deliberate at verdict (point 3) and deliverable (point 4) only. Skip research plan and synthesis reviews. |
+| **High** | Multi-system architecture, cross-domain impact, production deployment, high-stakes decision | **Full** — all 4 decision points get two-reviewer cycles. QA gate on deliverables. |
+
+#### How to assess complexity
+
+At the start of each task, before doing anything, assess:
+1. **Blast radius** — How many systems/teams/environments does this affect? (1 = low, 2-3 = medium, 4+ = high)
+2. **Reversibility** — Can mistakes be easily undone? (yes = lower, no = higher)
+3. **Ambiguity** — Is the problem well-defined or exploratory? (clear = lower, fuzzy = higher)
+4. **Stakes** — What's the cost of getting it wrong? (typo = low, data loss = high)
+
+If any dimension scores high, use the higher deliberation level.
+
+#### Mode interaction
+
+The configured **mode** sets the ceiling, complexity sets the floor:
+- **Rapid mode** caps at Lite — even high-complexity tasks skip research plan review (speed over rigor)
+- **Classic mode** allows Full — defaults to Lite for medium tasks, Full for high. For low-complexity tasks in Classic, use Solo (don't over-deliberate simple questions).
+- **Lean mode** uses the complexity assessment as-is
+
+#### Escalation
+
+If during a Solo or Lite task you discover unexpected complexity (cross-system impact, conflicting evidence, ambiguous requirements), **escalate**:
+1. Tell the user: "This is more complex than it looked — escalating to full deliberation."
+2. Switch to the higher level for remaining decision points
+3. You can escalate up but never de-escalate mid-task
+
+---
+
+## Domain Packs
+
+Domain packs provide domain-specific knowledge and tool usage patterns. Without a domain pack, Orchestra still works — it just deliberates using general knowledge and whatever tools are available.
+
+### What a Domain Pack Provides
+- **Knowledge sources** — databases, catalogs, archives to consult during investigation
+- **Tool guard** — specific allow/deny lists for domain tools (supplements core safety)
+- **Investigation steps** — domain-specific steps to insert into the investigation flow
+- **Output conventions** — formatting rules for deliverables
+- **Work type mappings** — domain-specific names for generic work types
+
+### Loading Domain Packs — Task-Scoped
+
+Domain packs load based on THE TASK, not the session. Different tasks in the same session can use different domains (or none).
+
+**At the start of every task**, decide whether to load a domain pack:
+
+1. Read `.orchestra/config.json` → check `domain` field for the DEFAULT domain
+2. Look at the user's request:
+   - Does it mention domain-specific concepts? (bug numbers, FDD codes, D365 entities → load d365-fo)
+   - Is it about general development? (Python, TypeScript, architecture → NO domain pack)
+   - Is it about a creative project? (podcast, music → NO domain pack)
+3. If the task clearly belongs to a domain → load that domain's SKILL.md
+4. If the task is domain-ambiguous → ask: "Should I load the {domain} domain pack for this, or work in general mode?"
+5. If the task is clearly NOT domain-specific → operate in general mode, even if config.json has a domain set
+
+**Do NOT blindly load the domain from config.json.** The config domain is a DEFAULT, not a mandate. If someone asks you to review Python code, don't load D365 rules just because config says d365-fo.
+
+### Loading on User Request
+
+User says **"switch to d365"** or **"load d365-fo"**:
+1. Read `.orchestra/skills/d365-fo/SKILL.md`
+2. Apply all rules
+3. Confirm
+
+User says **"switch to general"** or **"no domain"**:
+1. Stop applying domain-specific rules
+2. Confirm
+
+### Available Domain Packs
+
+Check `.orchestra/skills/` for available packs. Each is a directory with a `SKILL.md`.
+
+---
+
+## Context Carry-Forward
+
+When calling `#critique` (including with `critiqueType` set to `research`, `brainstorm`, or `challenge`) for round 2+, include findings from prior rounds in the `context` parameter. Look for `<details>` summary blocks in reviewer responses — extract the key issues, decisions, and open questions and pass them forward. This ensures reviewers see what was already discussed and don't repeat or contradict prior findings.
+
+If no `<details>` block exists, summarize the key points from the prior response yourself.
+
+> **Note**: The extension automatically carries forward `<details>` summaries from prior critique rounds. You still SHOULD pass explicit context when you have additional insights, but the baseline carry-forward happens automatically.
+
+---
+
+## Architectural Decision Records (ADRs)
+
+After completing an investigation where real decisions were made, append a compact ADR to `.orchestra/knowledge.md`:
+
+```
+### ADR: [title] (YYYY-MM-DD)
+- Decision: [what was decided]
+- Rationale: [why, including which reviewer flagged what]
+- Status: Active | Superseded by [newer ADR]
+- Key entities: [specific names — classes, files, specs]
+```
+
+Only write ADRs for substantive decisions, not trivial findings.
+
+---
+
+## Workspace Artifacts
+
+For each task, create a working directory:
+
+```
+.orchestra/{task-id}/
+├── input.md       # Raw input
+├── spec.md        # Spec (mode-dependent formality)
+├── todo.md        # Progress tracker
+├── reviews/       # Peer review outputs
+└── session.md     # Conversation log
+```
+
+---
+
+## Altitude Separation — Strategic vs. Implementation
+
+Orchestra operates at the **strategic altitude**: investigation, deliberation, spec writing, review. Implementation (writing code, building configs, editing files) happens at a **lower altitude** — either by you directly for small changes, or by a **subagent** for substantial work.
+
+### Why separate altitudes?
+
+When a conversation mixes strategic thinking ("what's the root cause?") with implementation details ("change line 47 of extension.ts"), **attention dilution** occurs — the model loses track of the big picture while buried in syntax. Keeping altitudes separate means:
+- Strategic context stays focused on decisions, tradeoffs, requirements
+- Implementation context stays focused on code correctness, patterns, testing
+- Handoff happens through **documents**, not through one long conversation
+
+### When to delegate to a subagent
+
+| Situation | Action |
+|-----------|--------|
+| Small edit (< 20 lines, single file) | Do it yourself |
+| Config change, parameter update | Do it yourself |
+| Multi-file code change | Delegate to subagent |
+| New feature implementation | Delegate to subagent |
+| Complex refactoring | Delegate to subagent |
+| Writing a script or tool | Delegate to subagent |
+
+### How to delegate
+
+1. **Write the spec** — Create `.orchestra/{task-id}/spec.md` with:
+   - What to build (requirements, acceptance criteria)
+   - Where to build it (files, modules, packages)
+   - Constraints (patterns to follow, things to avoid)
+   - How to verify (test commands, expected behavior)
+
+2. **Spawn a subagent** — Use `#runSubagent` with the `Explore` agent (for read-only tasks) or the default agent (for implementation). Pass the spec as the prompt:
+   ```
+   Read the spec at .orchestra/{task-id}/spec.md and implement it.
+   Report back: what files were created/modified, what was tested, any open questions.
+   ```
+
+3. **Review the result** — When the subagent returns, review its output through the deliberation cycle (critique with two reviewers). Apply QA gate if the result will be deployed.
+
+4. **Never forward raw subagent output to the user** — Always summarize: what was done, what changed, what needs attention.
+
+### What stays at strategic altitude
+
+- Root cause analysis
+- Architecture decisions
+- Spec writing and review
+- Deliberation (all critique cycles)
+- Verdict formation
+- Deciding WHAT to build
+
+### What goes to implementation altitude
+
+- Writing/editing code
+- Running tests
+- File manipulation
+- Building/compiling
+- Deciding HOW to build it

+ 39 - 0
.github/copilot-instructions.md

@@ -0,0 +1,39 @@
+# Copilot Instructions — MixBoard
+
+> **Last updated**: 2026-03-13
+
+---
+
+## Workspace Overview
+
+MixBoard is a **macOS native music player** built with Swift 5.9 and SwiftUI, targeting macOS 14+ (Sonoma).
+
+### Key Features
+- Audio playback via AVAudioEngine with 3-band EQ
+- BPM and key detection
+- Waveform visualization
+- 14 retro skins
+- DAW export (Audition, Bitwig, REAPER, etc.)
+- Cue points, playlists with folders
+- Lyrics fetching from LRCLIB
+- SwiftData for persistence
+
+### Active Integration: Chad Music
+Cloud music integration with [Chad Music](https://gogs.chad-partners.com/chad-partners/chad-music) — a self-hosted Common Lisp music server. See `.orchestra/knowledge.md` for architecture decisions and full spec at Work vault `.orchestra/mixboard-chadmusic/spec.md`.
+
+## Tech Stack
+- **Language**: Swift 5.9
+- **UI**: SwiftUI
+- **Platform**: macOS 14+ (Sonoma)
+- **Data**: SwiftData
+- **Audio**: AVAudioEngine (local), AVPlayer (cloud streaming)
+- **Build**: Xcode / xcodegen (project.yml)
+
+## Conventions
+- Source code in `Sources/`
+- Tests in `Tests/`
+- Project config in `project.yml` (xcodegen) and `Package.swift`
+- Build instructions in `INSTRUCTIONS.md`
+
+## Orchestra
+Multi-model deliberation engine is active. Config at `.orchestra/config.json`, knowledge at `.orchestra/knowledge.md`. No domain pack — general mode.

+ 26 - 0
.gitignore

@@ -0,0 +1,26 @@
+# macOS
+.DS_Store
+*.swp
+*~
+
+# Xcode user data
+xcuserdata/
+*.xcuserstate
+*.xccheckout
+*.xcscmblueprint
+
+# Build
+DerivedData/
+.build/
+build/
+
+# Code coverage
+*.profraw
+*.profdata
+
+# SPM
+.swiftpm/xcode/xcuserdata/
+Package.resolved
+
+# Misc
+*.moved-aside

+ 17 - 0
.orchestra/config.json

@@ -0,0 +1,17 @@
+{
+  "mode": "lean",
+  "stage": "build",
+  "domain": "",
+  "lead": "auto",
+  "project": "MixBoard",
+  "models": {
+    "claude": "claude-opus-4.6",
+    "codex": "gpt-5.3-codex",
+    "gemini": "gemini-3-pro"
+  },
+  "deliberation": {
+    "depth": 2,
+    "earlyExit": true
+  },
+  "language": "auto"
+}

+ 93 - 0
.orchestra/knowledge.md

@@ -0,0 +1,93 @@
+# MixBoard — Knowledge Base
+
+> Accumulated findings from investigations, deliberations, and development sessions.
+> Updated automatically after each significant investigation.
+
+---
+
+## Chad Music Integration (2026-03-13)
+
+### ADR: API Strategy — Custom REST over Subsonic (2026-03-13)
+- Decision: Keep chad-music's custom REST API, no Subsonic implementation
+- Rationale: MixBoard is the only client. Subsonic = 70+ endpoints for zero benefit. All three reviewer models agreed.
+- Status: Active
+- Key entities: ChadMusicAPIClient, /api/cat/:category, /api/album/:id/tracks
+
+### ADR: Streaming — Direct HTTP + Range Requests (2026-03-13)
+- Decision: Direct HTTP file serving with Range requests (206 Partial Content). No HLS in early phases.
+- Rationale: HLS breaks gapless playback on concept albums. Single user = no need for adaptive bitrate. AVPlayer handles direct HTTP natively.
+- Status: Active
+- Key entities: AVPlayer, AVURLAsset, AVURLAssetHTTPHeaderFieldsKey
+
+### ADR: Playback Engine — AVPlayer First (2026-03-13)
+- Decision: AVPlayer for ALL playback in Phase 1 (both local file:// and cloud https:// URLs). AVAudioEngine path added in Phase 3 for cached tracks needing EQ/BPM.
+- Rationale: AVPlayer handles buffering, seeking, gapless (AVQueuePlayer), and AirPlay out of the box. Dual engines add complexity.
+- Status: Active
+
+### ADR: Auth — API Key via HTTP Header (2026-03-13)
+- Decision: API key in macOS Keychain, sent via Authorization: Bearer header. Use AVURLAssetHTTPHeaderFieldsKey for stream URLs (undocumented but working Apple API).
+- Rationale: Simplest auth for personal use. Telegram login deferred to Phase 4.
+- Status: Active
+- Risk: AVURLAssetHTTPHeaderFieldsKey is undocumented — may break in future macOS versions
+
+### Key Risks Identified
+1. Woo/Clack Range request support — MUST verify with curl before coding
+2. Static file auth — file-server routes may bypass with-user macro
+3. TLS — AVPlayer rejects self-signed certs, need Tailscale or Caddy
+4. OGG Vorbis — NOT supported by AVPlayer on macOS
+
+### Phase Plan
+- Phase 1 (Stream & Browse): ~20-35 hours — zero backend changes needed
+- Phase 2 (Upload & Sync): ~20-30 hours — new server endpoints
+- Phase 3 (Native Polish): ~15-20 hours — cache, loudness, Spotlight
+- Phase 4 (Social & Advanced): ~10-20 hours — Telegram, Shortcuts
+
+### Brainstorm Spec
+Full spec at: Work vault → .orchestra/mixboard-chadmusic/spec.md
+
+### Phase 1 Implementation (2026-03-13) — COMPLETE
+- **Files created**: ChadMusic.swift (models), KeychainService.swift, ChadMusicAPIClient.swift, StreamingPlayer.swift, CloudBrowserView.swift
+- **Files modified**: PlayerViewModel.swift (dual engine routing), SettingsView.swift (Chad Music tab), SidebarView.swift (cloud section), ContentView.swift (cloud browser routing)
+- **Architecture**: StreamingPlayer (AVPlayer) for cloud, AudioEngine (AVAudioEngine) for local — routed via isCloudPlayback flag in PlayerViewModel
+- **Cloud tracks are transient** — ChadTrack is Codable struct, not SwiftData. No DB persistence until Phase 2 sync.
+- **Build note**: Must run `xcodegen generate` after adding new source files
+- **Codex review finding (fixed)**: URL path composition changed from `.appending(path:)` to `URLComponents` for proper encoding
+- **Gemini review**: False positive — applied D365 rubric to Swift project (review extension prompt template still has D365 residue for this workspace)
+- **Tests**: 143 pass (38 new ChadMusic tests), 0 failures, no regressions
+
+### Pre-flight Verification Results (2026-03-13)
+
+#### 1. TLS / Network
+- Server is **publicly reachable** — no Tailscale needed for connectivity
+- **URL: `https://music.chad-partners.com`** (confirmed via curl 2026-03-14)
+- Behind **Nginx 1.27.0** reverse proxy with HTTPS (HSTS enabled, max-age=31536000)
+- Gogs domain: `gogs.chad-partners.com`
+- **Default port is 5000** (from `main` function: `&key (port 5000)`) — Nginx proxies to it
+- `file-server` is only enabled when `serve-files` flag is passed to `main`
+
+#### 1b. Range Request Support — VERIFIED ✅
+- `curl -sI -H 'Range: bytes=0-1000' https://music.chad-partners.com/` → **HTTP 206**, Content-Range: bytes 0-1000/2998
+- Nginx handles Range natively — no server-side changes needed
+- AVPlayer streaming will work out of the box
+
+#### 2. Server Architecture (from server.lisp, verified 2026-03-14)
+- **Woo HTTP server** (libev-based) via Clack
+- Default port: 5000
+- Auth: Telegram-based login flow → generates random 16-byte hex token → stored in server-side hash table
+- `with-user` macro checks `Authorization: Bearer <token>` header
+- CLI bypass: if no `x-real-ip` header AND no valid token → auto-auth as admin (for local dev/REPL)
+- File serving: only active when `(main :serve-files t)` is passed
+- Path mapping: config.lisp maps filesystem paths to URL prefixes (e.g. `/media/music/` → `/music/`)
+- Routes: `/api/cat/:category`, `/api/cat/:category/size`, `/api/album/:id/tracks`, `/api/stats`, `/api/rescan` (POST), `/api/login` (POST), `/api/user`
+
+#### 3. API Response Format (from %to-json methods)
+- Track JSON keys: `id`, `artist`, `album_artist`, `album`, `year`, `no` (track number), `title`, `bit_rate`, `duration`, `url`, `cover`
+- Album JSON keys: `id`, `artist`, `year`, `album`, `original_date`, `publisher`, `country`, `genre`, `type`, `status`, `mb_id`, `track_count`, `total_duration`, `cover`
+- Category results: array of `{"item": "name", "count": N}`
+- **NOTE**: Track number key is `no`, not `track_number`. Album title key is `album`, not `title`. Must update ChadMusic.swift CodingKeys!
+
+#### 4. File-Server Auth — FILES ARE UNPROTECTED (when enabled)
+- `file-server` function does NOT use `with-user` macro
+- All API routes ARE auth-protected
+- Acceptable for personal server — file URLs are opaque (need API to discover them)
+- Fix in Phase 2: wrap file-server in with-user

+ 7 - 0
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+   version = "1.0">
+   <FileRef
+      location = "self:">
+   </FileRef>
+</Workspace>

+ 68 - 0
Assets.xcassets/AppIcon.appiconset/Contents.json

@@ -0,0 +1,68 @@
+{
+  "images" : [
+    {
+      "filename" : "icon_16.png",
+      "idiom" : "mac",
+      "scale" : "1x",
+      "size" : "16x16"
+    },
+    {
+      "filename" : "icon_32.png",
+      "idiom" : "mac",
+      "scale" : "2x",
+      "size" : "16x16"
+    },
+    {
+      "filename" : "icon_32.png",
+      "idiom" : "mac",
+      "scale" : "1x",
+      "size" : "32x32"
+    },
+    {
+      "filename" : "icon_64.png",
+      "idiom" : "mac",
+      "scale" : "2x",
+      "size" : "32x32"
+    },
+    {
+      "filename" : "icon_128.png",
+      "idiom" : "mac",
+      "scale" : "1x",
+      "size" : "128x128"
+    },
+    {
+      "filename" : "icon_256.png",
+      "idiom" : "mac",
+      "scale" : "2x",
+      "size" : "128x128"
+    },
+    {
+      "filename" : "icon_256.png",
+      "idiom" : "mac",
+      "scale" : "1x",
+      "size" : "256x256"
+    },
+    {
+      "filename" : "icon_512.png",
+      "idiom" : "mac",
+      "scale" : "2x",
+      "size" : "256x256"
+    },
+    {
+      "filename" : "icon_512.png",
+      "idiom" : "mac",
+      "scale" : "1x",
+      "size" : "512x512"
+    },
+    {
+      "filename" : "icon_1024.png",
+      "idiom" : "mac",
+      "scale" : "2x",
+      "size" : "512x512"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Assets.xcassets/AppIcon.appiconset/icon_1024.png


BIN
Assets.xcassets/AppIcon.appiconset/icon_128.png


BIN
Assets.xcassets/AppIcon.appiconset/icon_16.png


BIN
Assets.xcassets/AppIcon.appiconset/icon_256.png


BIN
Assets.xcassets/AppIcon.appiconset/icon_32.png


BIN
Assets.xcassets/AppIcon.appiconset/icon_512.png


BIN
Assets.xcassets/AppIcon.appiconset/icon_64.png


+ 6 - 0
Assets.xcassets/Contents.json

@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 155 - 0
INSTRUCTIONS.md

@@ -0,0 +1,155 @@
+# MixBoard Development Instructions
+
+## Project Overview
+
+MixBoard is a **native macOS music player and mixtape builder** built with Swift, SwiftUI, and SwiftData. It's designed for DJs and music curators who want to listen to music, curate playlists, and export mixes for further editing in DAWs like Adobe Audition.
+
+### Core Workflow
+1. **Import** audio files (MP3, WAV, FLAC, M4A, AIFF, etc.) into playlists
+2. **Listen** and browse across multiple playlists
+3. **Curate** a mix by dragging tracks or using quick-add (⌘D) to a target playlist
+4. **Export** as an Adobe Audition session (.sesx), stitched WAV with markers, CUE sheet, EDL, DAWproject, or M3U
+
+### Key Features
+- **Playback**: AVAudioEngine-based player with auto-advance, shuffle, repeat, seek, next/prev
+- **Playlist management**: Create playlists and folders, drag tracks between playlists, drag & drop files/folders from Finder
+- **Configurable playlist view**: Choose which metadata columns to display (artist, title, album, BPM, key, duration, format, etc.), group by artist/album/genre/folder
+- **Artwork**: Loaded foobar2000-style from folder images (cover.jpg, folder.jpg) or embedded metadata
+- **Skinning**: 13 themes (Dark, Midnight, Forest, Ocean, Warm, Light, Winamp Classic, Winamp Modern, foobar2000, Windows 95, Windows 98, XP Luna, Mac OS 9) — all persisted
+- **DAW export**: Adobe Audition .sesx (real format, references original files with absolute paths), audio stitcher (concatenates to single WAV with marker files), CUE sheets, EDL, DAWproject, M3U
+- **File renaming on export**: Configurable template system ({artist}, {title}, {album}, {track}, {bpm}, {key}, etc.)
+- **Quick-add (⌘D)**: Set a target playlist, press ⌘D to instantly add the playing track — with duplicate detection
+- **Global search (⌘F)**: Search all playlists by artist/title/album
+- **Track notes**: Free-text notes per track, preserved in exports
+- **Cursor/playback modes**: "Cursor follows playback" and "Playback follows cursor" (mutually exclusive, toggled via player bar button or right-click menu)
+- **State persistence**: Last playlist, last track, playback position, skin, target playlist all saved/restored on relaunch
+- **Media keys**: Registers with MPRemoteCommandCenter for keyboard play/pause/next/prev
+- **AirPlay**: Handles audio route changes — resumes on device switch, pauses on disconnect
+- **BPM & key detection**: Spectral analysis with Accelerate framework (autocorrelation for BPM, chromagram + Krumhansl-Kessler profiles for key)
+- **Waveform generation**: Downsampled min/max pairs for visualization
+
+### Architecture
+- **SwiftData** models: Track, CuePoint, Playlist, PlaylistEntry, PlaylistFolder
+- **@Observable ViewModels**: PlayerViewModel (wraps AudioEngine, syncs state at 30fps), PlaylistViewModel (CRUD, quick-add, status messages)
+- **Singleton configs**: PlaylistViewConfig.shared, AppTheme (ObservableObject with @Published)
+- **AudioEngine**: AVAudioEngine + AVAudioPlayerNode, generation-counter for completion handler safety, .dataPlayedBack completion type
+- **Services**: MetadataService, WaveformGenerator, BPMDetector, KeyDetector, LibraryManager, ArtworkService, MediaKeyHandler
+
+### Known Issues / Quirks
+- Double-click to play in SwiftUI List is unreliable — Enter key is the primary way to play a selected track
+- `PlaylistViewConfig` is a singleton (`PlaylistViewConfig.shared`) to keep the player bar cursor-mode button and playlist view context menu in sync
+- The `.sesx` exporter is reverse-engineered from real Audition files but may not work with all Audition versions
+- BPM/key detection may fail on test-generated sine WAVs (tests handle this gracefully)
+- Theme tests save/restore the original skin in setUp/tearDown to avoid polluting UserDefaults
+
+## Build & Run
+
+```bash
+# Build and launch the app (no Xcode GUI needed):
+cd /Users/vdrobkov/Misc/Documents/Copilot/MixBoard
+pkill -f MixBoard 2>/dev/null; sleep 0.5
+xcodegen generate 2>&1 | tail -1
+xcodebuild -project MixBoard.xcodeproj -scheme MixBoard -destination 'platform=macOS' build 2>&1 | tail -3
+open /Users/vdrobkov/Library/Developer/Xcode/DerivedData/MixBoard-hfuiayempdyihjaigvvglaqkkkth/Build/Products/Debug/MixBoard.app
+```
+
+## Testing Rules
+
+**MANDATORY**: After implementing any new feature or changing any existing feature:
+1. Write new tests covering the changes
+2. Run the full test suite and confirm all tests pass
+3. Do not consider the task complete until tests pass
+
+Tests are split into:
+- **Unit tests** (`Tests/Unit/`) — model, service, exporter, filename template, app state tests
+- **E2E integration tests** (`Tests/E2E/`) — full workflow tests (player load/play/pause/stop/seek, playlist next/prev/shuffle/repeat, import→playlist→export, stitch with markers, theme persistence, config persistence, quick-add, duplicate detection, track notes, drag-to-playlist)
+
+All tests run as hosted unit tests (inside the app process) — no UI automation needed. Currently **99 tests, 0 failures**.
+
+```bash
+# Run all tests (unit + E2E):
+xcodebuild -project MixBoard.xcodeproj -scheme MixBoardTests -destination 'platform=macOS' test 2>&1 | grep "Executed" | tail -2
+
+# Run tests with failure details:
+xcodebuild -project MixBoard.xcodeproj -scheme MixBoardTests -destination 'platform=macOS' test 2>&1 | grep -E "passed|failed|Executed" | tail -20
+```
+
+## Regenerate Xcode Project
+
+After adding or removing source files, always regenerate:
+```bash
+xcodegen generate
+```
+
+## Project Structure
+
+```
+Sources/
+├── MixBoardApp.swift              # App entry, menu commands, keyboard shortcuts
+├── Models/
+│   ├── Track.swift                # @Model — audio track with metadata
+│   ├── CuePoint.swift             # @Model — markers on tracks
+│   ├── Playlist.swift             # @Model — ordered collection of entries
+│   ├── PlaylistFolder.swift       # @Model — groups playlists in sidebar
+│   ├── PlaylistViewConfig.swift   # Column visibility, grouping, cursor mode (singleton)
+│   ├── AppTheme.swift             # 13 skins, all colors/sizes (ObservableObject)
+│   ├── AppState.swift             # UserDefaults persistence for last playlist/track/position
+│   └── FileNameTemplate.swift     # Configurable export file naming ({artist}, {title}, etc.)
+├── Services/
+│   ├── AudioEngine.swift          # AVAudioEngine playback, seek, route change handling
+│   ├── BPMDetector.swift          # Spectral flux + autocorrelation BPM detection
+│   ├── KeyDetector.swift          # Chromagram + key profile matching
+│   ├── WaveformGenerator.swift    # Downsampled min/max waveform data
+│   ├── MetadataService.swift      # AVFoundation metadata reading
+│   ├── LibraryManager.swift       # File import, analysis, file operations
+│   ├── ArtworkService.swift       # Folder artwork loading (foobar2000 style)
+│   └── MediaKeyHandler.swift      # MPRemoteCommandCenter integration
+├── Export/
+│   ├── DAWExporter.swift          # Protocol, ExportOptions, MixExporter dispatcher
+│   ├── AuditionExporter.swift     # Real .sesx format with absolute file paths
+│   ├── AudioStitcher.swift        # Concatenate to single WAV + markers CSV/CUE
+│   ├── CueSheetExporter.swift     # Standard .cue format
+│   ├── EDLExporter.swift          # CMX 3600 EDL
+│   ├── DAWProjectExporter.swift   # Open DAWproject format
+│   └── M3UExporter.swift          # M3U playlist
+├── ViewModels/
+│   ├── PlayerViewModel.swift      # @Observable — playback state, next/prev, shuffle/repeat
+│   └── PlaylistViewModel.swift    # @Observable — CRUD, quick-add, duplicate detection, status
+└── Views/
+    ├── ContentView.swift          # Main layout, state restore, status toast
+    ├── SidebarView.swift          # Playlists + folders, drag/drop, target playlist
+    ├── PlaylistView.swift         # Track list, column headers, configurable rows, context menu
+    ├── PlayerView.swift           # Transport, shuffle/repeat, cursor mode, seekbar, volume, skin picker
+    ├── ExportSheet.swift          # Session export + stitch export tabs, file naming template
+    ├── GlobalSearchSheet.swift    # Search across all playlists
+    ├── WaveformView.swift         # Interactive waveform canvas
+    ├── ArtworkView.swift          # Async folder/embedded artwork loading
+    └── TrackRow.swift             # Compact track display
+
+Tests/
+├── Unit/
+│   ├── ModelTests.swift           # Track, CuePoint, Playlist, PlaylistEntry, PlaylistFolder, AppTheme, AppState
+│   ├── ServiceTests.swift         # MetadataService, WaveformGenerator, BPMDetector, KeyDetector
+│   ├── ExporterTests.swift        # All 5 export formats
+│   └── FileNameTemplateTests.swift # Template generation, variables, sanitization, presets
+├── E2E/
+│   ├── E2EWorkflowTests.swift     # Import→playlist→export, stitch, analysis, multi-format
+│   └── IntegrationTests.swift     # Player VM flows, shuffle/repeat, export, quick-add, duplicates, notes
+└── Helpers/
+    └── TestHelpers.swift          # Test audio file generation (sine wave WAVs)
+```
+
+## Coding Conventions
+
+- Use `@Observable` (not `ObservableObject`) for new ViewModels
+- Use `@EnvironmentObject` for `ObservableObject` types (LibraryManager, AppTheme)
+- Use `@Environment` for `@Observable` types (PlayerViewModel, PlaylistViewModel)
+- Use `PlaylistViewConfig.shared` — it's a singleton, don't create new instances
+- Always use `Color.accentColor` (not `.accentColor`) in ternary expressions to avoid ShapeStyle type errors
+- Prefer `xcodebuild` over `swift build` for the final build (SwiftData macros require Xcode toolchain)
+- After adding/removing files, run `xcodegen generate` before building
+- Theme tests must save/restore the original skin in setUp/tearDown
+- AppState tests must clear UserDefaults keys in setUp/tearDown
+- AudioEngine uses a `playbackGeneration` counter to prevent completion handler race conditions
+- Use `.dataPlayedBack` (not default `.dataConsumed`) for AVAudioPlayerNode completion handlers
+- Prefer `xcodebuild` over `swift build` for the final build (SwiftData macros require Xcode toolchain)

+ 729 - 0
MixBoard.xcodeproj/project.pbxproj

@@ -0,0 +1,729 @@
+// !$*UTF8*$!
+{
+	archiveVersion = 1;
+	classes = {
+	};
+	objectVersion = 77;
+	objects = {
+
+/* Begin PBXBuildFile section */
+		0475F2DDF3E2B282DDD32730 /* ChadMusicTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BB9760CCC20660A8525CE39 /* ChadMusicTests.swift */; };
+		05250104065AC9F86AED7640 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF15B7B75D512A726CA44646 /* AppState.swift */; };
+		062F31FB5DC04601FA178F29 /* SyncWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5EB33906D8B83B47907EB73 /* SyncWatcher.swift */; };
+		0B7C4BD3AC54C81F59D95769 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D496B90B255DE7A6A04105 /* SettingsView.swift */; };
+		1085C4BC3C8EFE23DD89A7F9 /* Track.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E9F79CCE61D166936929A38 /* Track.swift */; };
+		1528E4838F567A508BE4A11D /* PlaylistView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12C20156249966253CB0BC01 /* PlaylistView.swift */; };
+		155361528270AA0A5BC10857 /* DAWProjectExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0748BB9CDD4597683EDBECF6 /* DAWProjectExporter.swift */; };
+		19D734917A3D1D41990795E6 /* IntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F57CB69E8B6679DC46ED57 /* IntegrationTests.swift */; };
+		1F5879AF2B534B9D146D4AEC /* M3UExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045D280E779E9AC3182F56BA /* M3UExporter.swift */; };
+		2018533194941BADC392CCD0 /* GroupTemplateEditorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A61463B001623599676BEB7 /* GroupTemplateEditorSheet.swift */; };
+		262570671DF03442758075E0 /* AppIconConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0775318FF25759713C3063D /* AppIconConfig.swift */; };
+		2897F9B97E53C752BC8291EC /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9F35D9EB91C21D126300620 /* TestHelpers.swift */; };
+		289A2312A2E8CAC34308F7FB /* MixBoardApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 936532443A34B992B646634D /* MixBoardApp.swift */; };
+		31450D9ABC6BD3AD4BC160E2 /* CloudBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 962F30B9B736FF54E9E787D3 /* CloudBrowserView.swift */; };
+		37471C3642A075ED661A2DB9 /* PlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B8170FF8C225BE2DC9F0040 /* PlayerViewModel.swift */; };
+		3777ADCCD94A17218C335EE2 /* OGGDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEA3BE9F559194BD6A8DBFC1 /* OGGDecoder.swift */; };
+		3B76CF2335562FD54CAD71BE /* AuditionExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E447D0302B2F806372CD26 /* AuditionExporter.swift */; };
+		3F3163BC5FFAA0EC64603580 /* ServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F83BB564B9EDF998724C368F /* ServiceTests.swift */; };
+		42D7ED2A29566B252DADFC2D /* ExporterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D66878FD3A9BC9745050D13 /* ExporterTests.swift */; };
+		45C89316C5AB16272EC76D9F /* TrackRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 971D04012F71444725BB1846 /* TrackRow.swift */; };
+		461A7875FBC20ADCE231103E /* AudioStitcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00A953E02F7201CEC5A42DBE /* AudioStitcher.swift */; };
+		48D625A899FB4CD97A1CAC48 /* GroupTemplateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A4B3C692C3BA27C29C084B /* GroupTemplateResolver.swift */; };
+		57994E3E18195FD31CBDC82B /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10686F358CF00951BE31A568 /* SidebarView.swift */; };
+		58718BAD0FD35D0D999F7C43 /* LyricsParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24ADE9A538A9797BE2D7862B /* LyricsParser.swift */; };
+		5AA97C256D3B08ABF017DD0E /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DB6892183CB93C7DD0FD546 /* PlayerView.swift */; };
+		5DBAFF76FB86E768FF8324C4 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD3B309F0338E5A9412826E2 /* AppTheme.swift */; };
+		60B4E444C175C98B6F762762 /* WaveformGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ADC80456B47393CD4584C99 /* WaveformGenerator.swift */; };
+		638D763E72DC3774160E414F /* ChadMusic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7536C7BBF54B0B5B718D370F /* ChadMusic.swift */; };
+		690AA870FCF9B4A26EED8725 /* stb_vorbis.c in Sources */ = {isa = PBXBuildFile; fileRef = B95A4AD1717E86B37F7FD836 /* stb_vorbis.c */; };
+		691A0746845CBD34C766E634 /* PlaylistViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39B366B5B7D28F1310EE4C4 /* PlaylistViewModel.swift */; };
+		6C71B39EA00C5E9579EF6C7C /* NowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C91BFDC4EF6125CE0A92C365 /* NowPlayingView.swift */; };
+		6E8E6342167F74728BB11860 /* DAWExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7043BDA9D01825F1EF0F92D2 /* DAWExporter.swift */; };
+		735062052406557AD5EA269A /* MediaKeyHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5CB9510A25DFF0B3E7AA99E /* MediaKeyHandler.swift */; };
+		7FD8DC64107B2249CD5BEF1E /* ModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF5F229E82115FB2EBC61D6 /* ModelTests.swift */; };
+		88BFFA594A1BB6BFF3D0AA82 /* StreamingPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 586499B8088E26103E29799F /* StreamingPlayer.swift */; };
+		8A96CC1E8CC532F3ADB6ECE7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D29A1F4EF5FB5ACA4CCA4BBF /* Assets.xcassets */; };
+		8CEE003726D0A7A94B0F2A62 /* LibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B942F3DDAA7611C76AA6287B /* LibraryManager.swift */; };
+		97CD156068E3A732B75A822D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39DB5455D6BE460BC4F73953 /* ContentView.swift */; };
+		9C5A7DDD55E5367DB6E2AE96 /* FileNameTemplateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39571508168CC254BEB95639 /* FileNameTemplateTests.swift */; };
+		9EAB929A4063EF9BCBCC1E05 /* FileNameTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B949F4466F0B81596C5C405 /* FileNameTemplate.swift */; };
+		A7A5B8BB3004AB1F33924352 /* PlaylistViewConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A94FB676F44A50F2145C19B5 /* PlaylistViewConfig.swift */; };
+		AD8102FED08EEBF9E7CD5AE4 /* CuePoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9063834E1B4AA86F958A1F6C /* CuePoint.swift */; };
+		AFB70F19181547ABB1AFEE0A /* EDLExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A72E397F6C553FA244F7EFD8 /* EDLExporter.swift */; };
+		B071D5E1F39AA70316FA4FDF /* BPMDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83791DE60BF73B44B44CF598 /* BPMDetector.swift */; };
+		B1168E099BF810B143F9CECD /* E2EWorkflowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A1CD85068EDFB342EF0A571 /* E2EWorkflowTests.swift */; };
+		B19F5B2E4587252976BE904E /* SyncImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3051FEE675462F2B77A356FC /* SyncImporter.swift */; };
+		BCCEA4536EF1E4EDC85047B9 /* ArtworkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8FB0A5037D57F0F5FED2E3E /* ArtworkView.swift */; };
+		C5176BA733BF12E3469B0EAC /* Playlist.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E85070877C451ADE587391 /* Playlist.swift */; };
+		C6C8A67458FC5DCFD06A1C5D /* ChadMusicAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0CD0921C8C90DA6D317E092 /* ChadMusicAPIClient.swift */; };
+		C95509E70051622AE49B65E3 /* KeyboardShortcutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8DDF2236DA6D1B1E0471E14 /* KeyboardShortcutConfig.swift */; };
+		CC8BC49C6AF43733936E3A4C /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDEBC9A0E0A3C3ED59388601 /* KeychainService.swift */; };
+		CD58E38E196F93425131B213 /* WaveformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4024DF6E47B81EE988794DA3 /* WaveformView.swift */; };
+		CDFAF9F75CAEFD3091DE95D9 /* AudioEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = F953CCDD5C91DE428195E31D /* AudioEngine.swift */; };
+		DD7452BB415E285D2D39A667 /* ExportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 261573F9B9AABB23402AB3F2 /* ExportSheet.swift */; };
+		DD8CAE7B23CD799AF8D4934F /* MetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C186E4E5E5FE2F3C87A1B03C /* MetadataService.swift */; };
+		E60123D4FFD92FBD9B3B4E69 /* PlaylistFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CBC0258B1C5E76582465F5 /* PlaylistFolder.swift */; };
+		EAC68B369B8957C0809C767A /* LRCLIBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2330A5CD9FEB6CF1200D4E8A /* LRCLIBService.swift */; };
+		EC0DD99AFFFDA7D25407E991 /* ArtworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB242ECEFF0FFF4427B42BC0 /* ArtworkService.swift */; };
+		EE13D90C3C2ACF1348391C69 /* KeyDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0457B660537DC8CAD1B6120 /* KeyDetector.swift */; };
+		F2E4BE62D73171D8E7D63006 /* CueSheetExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C8A672BB52C77A8E83F3FFF /* CueSheetExporter.swift */; };
+		F7058DDE85BB601CBB7C9BD9 /* GlobalSearchSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D80C9BACD548FF942E79C82F /* GlobalSearchSheet.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+		25C442C870895C13C18E6E2D /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 1493F43231E452AC09121B22 /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 33EFC91F348AC0E1F8512ECA;
+			remoteInfo = MixBoard;
+		};
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXFileReference section */
+		00A953E02F7201CEC5A42DBE /* AudioStitcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioStitcher.swift; sourceTree = "<group>"; };
+		01A4B3C692C3BA27C29C084B /* GroupTemplateResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupTemplateResolver.swift; sourceTree = "<group>"; };
+		01D496B90B255DE7A6A04105 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
+		045D280E779E9AC3182F56BA /* M3UExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = M3UExporter.swift; sourceTree = "<group>"; };
+		0748BB9CDD4597683EDBECF6 /* DAWProjectExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAWProjectExporter.swift; sourceTree = "<group>"; };
+		0ADC80456B47393CD4584C99 /* WaveformGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformGenerator.swift; sourceTree = "<group>"; };
+		0B949F4466F0B81596C5C405 /* FileNameTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileNameTemplate.swift; sourceTree = "<group>"; };
+		10686F358CF00951BE31A568 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = "<group>"; };
+		1108B34F3CB9DD25F292F8ED /* stb_vorbis_wrapper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = stb_vorbis_wrapper.h; sourceTree = "<group>"; };
+		12C20156249966253CB0BC01 /* PlaylistView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistView.swift; sourceTree = "<group>"; };
+		1A61463B001623599676BEB7 /* GroupTemplateEditorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupTemplateEditorSheet.swift; sourceTree = "<group>"; };
+		1BB9760CCC20660A8525CE39 /* ChadMusicTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChadMusicTests.swift; sourceTree = "<group>"; };
+		1D66878FD3A9BC9745050D13 /* ExporterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExporterTests.swift; sourceTree = "<group>"; };
+		2330A5CD9FEB6CF1200D4E8A /* LRCLIBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRCLIBService.swift; sourceTree = "<group>"; };
+		2422CD2089E7C1331772CB63 /* MixBoard-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "MixBoard-Bridging-Header.h"; sourceTree = "<group>"; };
+		24ADE9A538A9797BE2D7862B /* LyricsParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsParser.swift; sourceTree = "<group>"; };
+		261573F9B9AABB23402AB3F2 /* ExportSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportSheet.swift; sourceTree = "<group>"; };
+		3051FEE675462F2B77A356FC /* SyncImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncImporter.swift; sourceTree = "<group>"; };
+		33CBC0258B1C5E76582465F5 /* PlaylistFolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistFolder.swift; sourceTree = "<group>"; };
+		372A8DCF8420A7B0C8835D0F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
+		39571508168CC254BEB95639 /* FileNameTemplateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileNameTemplateTests.swift; sourceTree = "<group>"; };
+		39DB5455D6BE460BC4F73953 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
+		3B8170FF8C225BE2DC9F0040 /* PlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewModel.swift; sourceTree = "<group>"; };
+		4024DF6E47B81EE988794DA3 /* WaveformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformView.swift; sourceTree = "<group>"; };
+		586499B8088E26103E29799F /* StreamingPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingPlayer.swift; sourceTree = "<group>"; };
+		5A1CD85068EDFB342EF0A571 /* E2EWorkflowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = E2EWorkflowTests.swift; sourceTree = "<group>"; };
+		6C8A672BB52C77A8E83F3FFF /* CueSheetExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CueSheetExporter.swift; sourceTree = "<group>"; };
+		6CF5F229E82115FB2EBC61D6 /* ModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelTests.swift; sourceTree = "<group>"; };
+		7043BDA9D01825F1EF0F92D2 /* DAWExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DAWExporter.swift; sourceTree = "<group>"; };
+		7536C7BBF54B0B5B718D370F /* ChadMusic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChadMusic.swift; sourceTree = "<group>"; };
+		7DB6892183CB93C7DD0FD546 /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = "<group>"; };
+		7E9F79CCE61D166936929A38 /* Track.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Track.swift; sourceTree = "<group>"; };
+		83791DE60BF73B44B44CF598 /* BPMDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPMDetector.swift; sourceTree = "<group>"; };
+		9063834E1B4AA86F958A1F6C /* CuePoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CuePoint.swift; sourceTree = "<group>"; };
+		936532443A34B992B646634D /* MixBoardApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixBoardApp.swift; sourceTree = "<group>"; };
+		962F30B9B736FF54E9E787D3 /* CloudBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudBrowserView.swift; sourceTree = "<group>"; };
+		971D04012F71444725BB1846 /* TrackRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackRow.swift; sourceTree = "<group>"; };
+		A72E397F6C553FA244F7EFD8 /* EDLExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EDLExporter.swift; sourceTree = "<group>"; };
+		A762EFB3375064E7873C8A41 /* MixBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MixBoard.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		A94FB676F44A50F2145C19B5 /* PlaylistViewConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistViewConfig.swift; sourceTree = "<group>"; };
+		A9F57CB69E8B6679DC46ED57 /* IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = "<group>"; };
+		AD3B309F0338E5A9412826E2 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
+		B5EB33906D8B83B47907EB73 /* SyncWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncWatcher.swift; sourceTree = "<group>"; };
+		B942F3DDAA7611C76AA6287B /* LibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryManager.swift; sourceTree = "<group>"; };
+		B95A4AD1717E86B37F7FD836 /* stb_vorbis.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = stb_vorbis.c; sourceTree = "<group>"; };
+		B9F35D9EB91C21D126300620 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
+		BEA3BE9F559194BD6A8DBFC1 /* OGGDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OGGDecoder.swift; sourceTree = "<group>"; };
+		C186E4E5E5FE2F3C87A1B03C /* MetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataService.swift; sourceTree = "<group>"; };
+		C3E447D0302B2F806372CD26 /* AuditionExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuditionExporter.swift; sourceTree = "<group>"; };
+		C91BFDC4EF6125CE0A92C365 /* NowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingView.swift; sourceTree = "<group>"; };
+		D0775318FF25759713C3063D /* AppIconConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconConfig.swift; sourceTree = "<group>"; };
+		D29A1F4EF5FB5ACA4CCA4BBF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
+		D5CB9510A25DFF0B3E7AA99E /* MediaKeyHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaKeyHandler.swift; sourceTree = "<group>"; };
+		D7E85070877C451ADE587391 /* Playlist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Playlist.swift; sourceTree = "<group>"; };
+		D80C9BACD548FF942E79C82F /* GlobalSearchSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearchSheet.swift; sourceTree = "<group>"; };
+		D8DDF2236DA6D1B1E0471E14 /* KeyboardShortcutConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardShortcutConfig.swift; sourceTree = "<group>"; };
+		D8FB0A5037D57F0F5FED2E3E /* ArtworkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtworkView.swift; sourceTree = "<group>"; };
+		DB242ECEFF0FFF4427B42BC0 /* ArtworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtworkService.swift; sourceTree = "<group>"; };
+		DDEBC9A0E0A3C3ED59388601 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = "<group>"; };
+		E0457B660537DC8CAD1B6120 /* KeyDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyDetector.swift; sourceTree = "<group>"; };
+		EC342C71B1DC290341B225A6 /* MixBoardTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MixBoardTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+		F0CD0921C8C90DA6D317E092 /* ChadMusicAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChadMusicAPIClient.swift; sourceTree = "<group>"; };
+		F39B366B5B7D28F1310EE4C4 /* PlaylistViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistViewModel.swift; sourceTree = "<group>"; };
+		F83BB564B9EDF998724C368F /* ServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceTests.swift; sourceTree = "<group>"; };
+		F953CCDD5C91DE428195E31D /* AudioEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngine.swift; sourceTree = "<group>"; };
+		FF15B7B75D512A726CA44646 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
+/* End PBXFileReference section */
+
+/* Begin PBXGroup section */
+		0900F76AAF6D8DBA644C9A7F /* OGG */ = {
+			isa = PBXGroup;
+			children = (
+				2422CD2089E7C1331772CB63 /* MixBoard-Bridging-Header.h */,
+				1108B34F3CB9DD25F292F8ED /* stb_vorbis_wrapper.h */,
+				B95A4AD1717E86B37F7FD836 /* stb_vorbis.c */,
+			);
+			path = OGG;
+			sourceTree = "<group>";
+		};
+		155CC32D9CC923835331A730 /* E2E */ = {
+			isa = PBXGroup;
+			children = (
+				5A1CD85068EDFB342EF0A571 /* E2EWorkflowTests.swift */,
+				A9F57CB69E8B6679DC46ED57 /* IntegrationTests.swift */,
+			);
+			path = E2E;
+			sourceTree = "<group>";
+		};
+		2065C399681DFF04F205D900 /* Sources */ = {
+			isa = PBXGroup;
+			children = (
+				936532443A34B992B646634D /* MixBoardApp.swift */,
+				826272262ECC8CDB287517A2 /* Export */,
+				AEFB9A1AA893BC7836E7508A /* Models */,
+				0900F76AAF6D8DBA644C9A7F /* OGG */,
+				D57D9707742273A13691CA34 /* Resources */,
+				63981D0EE4FF6BDBF1E43EAF /* Services */,
+				4F79A8B50E208ADF86DB366D /* ViewModels */,
+				D0069E94602D44443678A7B9 /* Views */,
+			);
+			path = Sources;
+			sourceTree = "<group>";
+		};
+		3168D3CDC38D28D748B9F95E /* Unit */ = {
+			isa = PBXGroup;
+			children = (
+				1BB9760CCC20660A8525CE39 /* ChadMusicTests.swift */,
+				1D66878FD3A9BC9745050D13 /* ExporterTests.swift */,
+				39571508168CC254BEB95639 /* FileNameTemplateTests.swift */,
+				6CF5F229E82115FB2EBC61D6 /* ModelTests.swift */,
+				F83BB564B9EDF998724C368F /* ServiceTests.swift */,
+			);
+			path = Unit;
+			sourceTree = "<group>";
+		};
+		4F79A8B50E208ADF86DB366D /* ViewModels */ = {
+			isa = PBXGroup;
+			children = (
+				3B8170FF8C225BE2DC9F0040 /* PlayerViewModel.swift */,
+				F39B366B5B7D28F1310EE4C4 /* PlaylistViewModel.swift */,
+			);
+			path = ViewModels;
+			sourceTree = "<group>";
+		};
+		63981D0EE4FF6BDBF1E43EAF /* Services */ = {
+			isa = PBXGroup;
+			children = (
+				DB242ECEFF0FFF4427B42BC0 /* ArtworkService.swift */,
+				F953CCDD5C91DE428195E31D /* AudioEngine.swift */,
+				83791DE60BF73B44B44CF598 /* BPMDetector.swift */,
+				F0CD0921C8C90DA6D317E092 /* ChadMusicAPIClient.swift */,
+				DDEBC9A0E0A3C3ED59388601 /* KeychainService.swift */,
+				E0457B660537DC8CAD1B6120 /* KeyDetector.swift */,
+				B942F3DDAA7611C76AA6287B /* LibraryManager.swift */,
+				2330A5CD9FEB6CF1200D4E8A /* LRCLIBService.swift */,
+				24ADE9A538A9797BE2D7862B /* LyricsParser.swift */,
+				D5CB9510A25DFF0B3E7AA99E /* MediaKeyHandler.swift */,
+				C186E4E5E5FE2F3C87A1B03C /* MetadataService.swift */,
+				BEA3BE9F559194BD6A8DBFC1 /* OGGDecoder.swift */,
+				586499B8088E26103E29799F /* StreamingPlayer.swift */,
+				3051FEE675462F2B77A356FC /* SyncImporter.swift */,
+				B5EB33906D8B83B47907EB73 /* SyncWatcher.swift */,
+				0ADC80456B47393CD4584C99 /* WaveformGenerator.swift */,
+			);
+			path = Services;
+			sourceTree = "<group>";
+		};
+		826272262ECC8CDB287517A2 /* Export */ = {
+			isa = PBXGroup;
+			children = (
+				00A953E02F7201CEC5A42DBE /* AudioStitcher.swift */,
+				C3E447D0302B2F806372CD26 /* AuditionExporter.swift */,
+				6C8A672BB52C77A8E83F3FFF /* CueSheetExporter.swift */,
+				7043BDA9D01825F1EF0F92D2 /* DAWExporter.swift */,
+				0748BB9CDD4597683EDBECF6 /* DAWProjectExporter.swift */,
+				A72E397F6C553FA244F7EFD8 /* EDLExporter.swift */,
+				045D280E779E9AC3182F56BA /* M3UExporter.swift */,
+			);
+			path = Export;
+			sourceTree = "<group>";
+		};
+		909567D1FC5A795E5CB36B78 /* Products */ = {
+			isa = PBXGroup;
+			children = (
+				A762EFB3375064E7873C8A41 /* MixBoard.app */,
+				EC342C71B1DC290341B225A6 /* MixBoardTests.xctest */,
+			);
+			name = Products;
+			sourceTree = "<group>";
+		};
+		AEFB9A1AA893BC7836E7508A /* Models */ = {
+			isa = PBXGroup;
+			children = (
+				D0775318FF25759713C3063D /* AppIconConfig.swift */,
+				FF15B7B75D512A726CA44646 /* AppState.swift */,
+				AD3B309F0338E5A9412826E2 /* AppTheme.swift */,
+				7536C7BBF54B0B5B718D370F /* ChadMusic.swift */,
+				9063834E1B4AA86F958A1F6C /* CuePoint.swift */,
+				0B949F4466F0B81596C5C405 /* FileNameTemplate.swift */,
+				01A4B3C692C3BA27C29C084B /* GroupTemplateResolver.swift */,
+				D8DDF2236DA6D1B1E0471E14 /* KeyboardShortcutConfig.swift */,
+				D7E85070877C451ADE587391 /* Playlist.swift */,
+				33CBC0258B1C5E76582465F5 /* PlaylistFolder.swift */,
+				A94FB676F44A50F2145C19B5 /* PlaylistViewConfig.swift */,
+				7E9F79CCE61D166936929A38 /* Track.swift */,
+			);
+			path = Models;
+			sourceTree = "<group>";
+		};
+		C6269FF23223813DA8E6C454 /* Helpers */ = {
+			isa = PBXGroup;
+			children = (
+				B9F35D9EB91C21D126300620 /* TestHelpers.swift */,
+			);
+			path = Helpers;
+			sourceTree = "<group>";
+		};
+		D0069E94602D44443678A7B9 /* Views */ = {
+			isa = PBXGroup;
+			children = (
+				D8FB0A5037D57F0F5FED2E3E /* ArtworkView.swift */,
+				962F30B9B736FF54E9E787D3 /* CloudBrowserView.swift */,
+				39DB5455D6BE460BC4F73953 /* ContentView.swift */,
+				261573F9B9AABB23402AB3F2 /* ExportSheet.swift */,
+				D80C9BACD548FF942E79C82F /* GlobalSearchSheet.swift */,
+				1A61463B001623599676BEB7 /* GroupTemplateEditorSheet.swift */,
+				C91BFDC4EF6125CE0A92C365 /* NowPlayingView.swift */,
+				7DB6892183CB93C7DD0FD546 /* PlayerView.swift */,
+				12C20156249966253CB0BC01 /* PlaylistView.swift */,
+				01D496B90B255DE7A6A04105 /* SettingsView.swift */,
+				10686F358CF00951BE31A568 /* SidebarView.swift */,
+				971D04012F71444725BB1846 /* TrackRow.swift */,
+				4024DF6E47B81EE988794DA3 /* WaveformView.swift */,
+			);
+			path = Views;
+			sourceTree = "<group>";
+		};
+		D57D9707742273A13691CA34 /* Resources */ = {
+			isa = PBXGroup;
+			children = (
+				372A8DCF8420A7B0C8835D0F /* Info.plist */,
+			);
+			path = Resources;
+			sourceTree = "<group>";
+		};
+		EE18FFF82E10AF7470023A4D /* Tests */ = {
+			isa = PBXGroup;
+			children = (
+				155CC32D9CC923835331A730 /* E2E */,
+				C6269FF23223813DA8E6C454 /* Helpers */,
+				3168D3CDC38D28D748B9F95E /* Unit */,
+			);
+			path = Tests;
+			sourceTree = "<group>";
+		};
+		EFB07E5C4FD2AFAD6F8AD1A5 = {
+			isa = PBXGroup;
+			children = (
+				D29A1F4EF5FB5ACA4CCA4BBF /* Assets.xcassets */,
+				2065C399681DFF04F205D900 /* Sources */,
+				EE18FFF82E10AF7470023A4D /* Tests */,
+				909567D1FC5A795E5CB36B78 /* Products */,
+			);
+			sourceTree = "<group>";
+		};
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+		33EFC91F348AC0E1F8512ECA /* MixBoard */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = DFC1DA5FC46C11C509725EB9 /* Build configuration list for PBXNativeTarget "MixBoard" */;
+			buildPhases = (
+				13CBA3E22A579B71FAD6F230 /* Sources */,
+				19BD8F4060DEB818EF6B53D4 /* Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = MixBoard;
+			packageProductDependencies = (
+			);
+			productName = MixBoard;
+			productReference = A762EFB3375064E7873C8A41 /* MixBoard.app */;
+			productType = "com.apple.product-type.application";
+		};
+		3CCC52C463BB895802789743 /* MixBoardTests */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 54EA94000DA7B2E23947C31C /* Build configuration list for PBXNativeTarget "MixBoardTests" */;
+			buildPhases = (
+				FD3BC3A2C6095A051DFDC56F /* Sources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				E6079E5A6C41D14651270BF4 /* PBXTargetDependency */,
+			);
+			name = MixBoardTests;
+			packageProductDependencies = (
+			);
+			productName = MixBoardTests;
+			productReference = EC342C71B1DC290341B225A6 /* MixBoardTests.xctest */;
+			productType = "com.apple.product-type.bundle.unit-test";
+		};
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+		1493F43231E452AC09121B22 /* Project object */ = {
+			isa = PBXProject;
+			attributes = {
+				BuildIndependentTargetsInParallel = YES;
+				LastUpgradeCheck = 1600;
+			};
+			buildConfigurationList = 4884383C090DF98ADA109D6F /* Build configuration list for PBXProject "MixBoard" */;
+			compatibilityVersion = "Xcode 14.0";
+			developmentRegion = en;
+			hasScannedForEncodings = 0;
+			knownRegions = (
+				Base,
+				en,
+			);
+			mainGroup = EFB07E5C4FD2AFAD6F8AD1A5;
+			minimizedProjectReferenceProxies = 1;
+			preferredProjectObjectVersion = 77;
+			projectDirPath = "";
+			projectRoot = "";
+			targets = (
+				33EFC91F348AC0E1F8512ECA /* MixBoard */,
+				3CCC52C463BB895802789743 /* MixBoardTests */,
+			);
+		};
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+		19BD8F4060DEB818EF6B53D4 /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				8A96CC1E8CC532F3ADB6ECE7 /* Assets.xcassets in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+		13CBA3E22A579B71FAD6F230 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				262570671DF03442758075E0 /* AppIconConfig.swift in Sources */,
+				05250104065AC9F86AED7640 /* AppState.swift in Sources */,
+				5DBAFF76FB86E768FF8324C4 /* AppTheme.swift in Sources */,
+				EC0DD99AFFFDA7D25407E991 /* ArtworkService.swift in Sources */,
+				BCCEA4536EF1E4EDC85047B9 /* ArtworkView.swift in Sources */,
+				CDFAF9F75CAEFD3091DE95D9 /* AudioEngine.swift in Sources */,
+				461A7875FBC20ADCE231103E /* AudioStitcher.swift in Sources */,
+				3B76CF2335562FD54CAD71BE /* AuditionExporter.swift in Sources */,
+				B071D5E1F39AA70316FA4FDF /* BPMDetector.swift in Sources */,
+				638D763E72DC3774160E414F /* ChadMusic.swift in Sources */,
+				C6C8A67458FC5DCFD06A1C5D /* ChadMusicAPIClient.swift in Sources */,
+				31450D9ABC6BD3AD4BC160E2 /* CloudBrowserView.swift in Sources */,
+				97CD156068E3A732B75A822D /* ContentView.swift in Sources */,
+				AD8102FED08EEBF9E7CD5AE4 /* CuePoint.swift in Sources */,
+				F2E4BE62D73171D8E7D63006 /* CueSheetExporter.swift in Sources */,
+				6E8E6342167F74728BB11860 /* DAWExporter.swift in Sources */,
+				155361528270AA0A5BC10857 /* DAWProjectExporter.swift in Sources */,
+				AFB70F19181547ABB1AFEE0A /* EDLExporter.swift in Sources */,
+				DD7452BB415E285D2D39A667 /* ExportSheet.swift in Sources */,
+				9EAB929A4063EF9BCBCC1E05 /* FileNameTemplate.swift in Sources */,
+				F7058DDE85BB601CBB7C9BD9 /* GlobalSearchSheet.swift in Sources */,
+				2018533194941BADC392CCD0 /* GroupTemplateEditorSheet.swift in Sources */,
+				48D625A899FB4CD97A1CAC48 /* GroupTemplateResolver.swift in Sources */,
+				EE13D90C3C2ACF1348391C69 /* KeyDetector.swift in Sources */,
+				C95509E70051622AE49B65E3 /* KeyboardShortcutConfig.swift in Sources */,
+				CC8BC49C6AF43733936E3A4C /* KeychainService.swift in Sources */,
+				EAC68B369B8957C0809C767A /* LRCLIBService.swift in Sources */,
+				8CEE003726D0A7A94B0F2A62 /* LibraryManager.swift in Sources */,
+				58718BAD0FD35D0D999F7C43 /* LyricsParser.swift in Sources */,
+				1F5879AF2B534B9D146D4AEC /* M3UExporter.swift in Sources */,
+				735062052406557AD5EA269A /* MediaKeyHandler.swift in Sources */,
+				DD8CAE7B23CD799AF8D4934F /* MetadataService.swift in Sources */,
+				289A2312A2E8CAC34308F7FB /* MixBoardApp.swift in Sources */,
+				6C71B39EA00C5E9579EF6C7C /* NowPlayingView.swift in Sources */,
+				3777ADCCD94A17218C335EE2 /* OGGDecoder.swift in Sources */,
+				5AA97C256D3B08ABF017DD0E /* PlayerView.swift in Sources */,
+				37471C3642A075ED661A2DB9 /* PlayerViewModel.swift in Sources */,
+				C5176BA733BF12E3469B0EAC /* Playlist.swift in Sources */,
+				E60123D4FFD92FBD9B3B4E69 /* PlaylistFolder.swift in Sources */,
+				1528E4838F567A508BE4A11D /* PlaylistView.swift in Sources */,
+				A7A5B8BB3004AB1F33924352 /* PlaylistViewConfig.swift in Sources */,
+				691A0746845CBD34C766E634 /* PlaylistViewModel.swift in Sources */,
+				0B7C4BD3AC54C81F59D95769 /* SettingsView.swift in Sources */,
+				57994E3E18195FD31CBDC82B /* SidebarView.swift in Sources */,
+				88BFFA594A1BB6BFF3D0AA82 /* StreamingPlayer.swift in Sources */,
+				B19F5B2E4587252976BE904E /* SyncImporter.swift in Sources */,
+				062F31FB5DC04601FA178F29 /* SyncWatcher.swift in Sources */,
+				1085C4BC3C8EFE23DD89A7F9 /* Track.swift in Sources */,
+				45C89316C5AB16272EC76D9F /* TrackRow.swift in Sources */,
+				60B4E444C175C98B6F762762 /* WaveformGenerator.swift in Sources */,
+				CD58E38E196F93425131B213 /* WaveformView.swift in Sources */,
+				690AA870FCF9B4A26EED8725 /* stb_vorbis.c in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		FD3BC3A2C6095A051DFDC56F /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				0475F2DDF3E2B282DDD32730 /* ChadMusicTests.swift in Sources */,
+				B1168E099BF810B143F9CECD /* E2EWorkflowTests.swift in Sources */,
+				42D7ED2A29566B252DADFC2D /* ExporterTests.swift in Sources */,
+				9C5A7DDD55E5367DB6E2AE96 /* FileNameTemplateTests.swift in Sources */,
+				19D734917A3D1D41990795E6 /* IntegrationTests.swift in Sources */,
+				7FD8DC64107B2249CD5BEF1E /* ModelTests.swift in Sources */,
+				3F3163BC5FFAA0EC64603580 /* ServiceTests.swift in Sources */,
+				2897F9B97E53C752BC8291EC /* TestHelpers.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+		E6079E5A6C41D14651270BF4 /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 33EFC91F348AC0E1F8512ECA /* MixBoard */;
+			targetProxy = 25C442C870895C13C18E6E2D /* PBXContainerItemProxy */;
+		};
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+		1B9CCD89D83C172231368ADA /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+				CLANG_CXX_LIBRARY = "libc++";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_ENABLE_OBJC_WEAK = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				ENABLE_NS_ASSERTIONS = NO;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu11;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				MACOSX_DEPLOYMENT_TARGET = 14.0;
+				MTL_ENABLE_DEBUG_INFO = NO;
+				MTL_FAST_MATH = YES;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = macosx;
+				SWIFT_COMPILATION_MODE = wholemodule;
+				SWIFT_OPTIMIZATION_LEVEL = "-O";
+				SWIFT_VERSION = 5.9;
+			};
+			name = Release;
+		};
+		21DC24B16F3B58685674049B /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				CODE_SIGN_IDENTITY = "-";
+				COMBINE_HIDPI_IMAGES = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/../Frameworks",
+					"@loader_path/../Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = com.mixboard.MixBoardTests;
+				SDKROOT = macosx;
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MixBoard.app/Contents/MacOS/MixBoard";
+			};
+			name = Debug;
+		};
+		24D35514AD0D97CAAA38C4D2 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+				CLANG_CXX_LIBRARY = "libc++";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_ENABLE_OBJC_WEAK = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = dwarf;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				ENABLE_TESTABILITY = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu11;
+				GCC_DYNAMIC_NO_PIC = NO;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_OPTIMIZATION_LEVEL = 0;
+				GCC_PREPROCESSOR_DEFINITIONS = (
+					"$(inherited)",
+					"DEBUG=1",
+				);
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				MACOSX_DEPLOYMENT_TARGET = 14.0;
+				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+				MTL_FAST_MATH = YES;
+				ONLY_ACTIVE_ARCH = YES;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = macosx;
+				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+				SWIFT_VERSION = 5.9;
+			};
+			name = Debug;
+		};
+		8F60504DD4F1A90D1B98AA58 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				CODE_SIGN_ENTITLEMENTS = "";
+				COMBINE_HIDPI_IMAGES = YES;
+				CURRENT_PROJECT_VERSION = 1;
+				ENABLE_HARDENED_RUNTIME = YES;
+				GENERATE_INFOPLIST_FILE = NO;
+				INFOPLIST_FILE = Sources/Resources/Info.plist;
+				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/../Frameworks",
+				);
+				MARKETING_VERSION = 1.0.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.mixboard.MixBoard;
+				PRODUCT_NAME = MixBoard;
+				SDKROOT = macosx;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_OBJC_BRIDGING_HEADER = "Sources/OGG/MixBoard-Bridging-Header.h";
+			};
+			name = Debug;
+		};
+		B66C8C9AD9C8CFC483BCCBB9 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				CODE_SIGN_ENTITLEMENTS = "";
+				COMBINE_HIDPI_IMAGES = YES;
+				CURRENT_PROJECT_VERSION = 1;
+				ENABLE_HARDENED_RUNTIME = YES;
+				GENERATE_INFOPLIST_FILE = NO;
+				INFOPLIST_FILE = Sources/Resources/Info.plist;
+				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/../Frameworks",
+				);
+				MARKETING_VERSION = 1.0.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.mixboard.MixBoard;
+				PRODUCT_NAME = MixBoard;
+				SDKROOT = macosx;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_OBJC_BRIDGING_HEADER = "Sources/OGG/MixBoard-Bridging-Header.h";
+			};
+			name = Release;
+		};
+		C9318D7BE959F0CE4CE98DE9 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				COMBINE_HIDPI_IMAGES = YES;
+				GENERATE_INFOPLIST_FILE = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/../Frameworks",
+					"@loader_path/../Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = com.mixboard.MixBoardTests;
+				SDKROOT = macosx;
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MixBoard.app/Contents/MacOS/MixBoard";
+			};
+			name = Release;
+		};
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+		4884383C090DF98ADA109D6F /* Build configuration list for PBXProject "MixBoard" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				24D35514AD0D97CAAA38C4D2 /* Debug */,
+				1B9CCD89D83C172231368ADA /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
+		54EA94000DA7B2E23947C31C /* Build configuration list for PBXNativeTarget "MixBoardTests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				21DC24B16F3B58685674049B /* Debug */,
+				C9318D7BE959F0CE4CE98DE9 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
+		DFC1DA5FC46C11C509725EB9 /* Build configuration list for PBXNativeTarget "MixBoard" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				8F60504DD4F1A90D1B98AA58 /* Debug */,
+				B66C8C9AD9C8CFC483BCCBB9 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
+/* End XCConfigurationList section */
+	};
+	rootObject = 1493F43231E452AC09121B22 /* Project object */;
+}

+ 7 - 0
MixBoard.xcodeproj/project.xcworkspace/contents.xcworkspacedata

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Workspace
+   version = "1.0">
+   <FileRef
+      location = "self:">
+   </FileRef>
+</Workspace>

+ 16 - 0
Package.swift

@@ -0,0 +1,16 @@
+// swift-tools-version: 5.9
+import PackageDescription
+
+let package = Package(
+    name: "MixBoard",
+    platforms: [.macOS(.v14)],
+    products: [
+        .executable(name: "MixBoard", targets: ["MixBoard"])
+    ],
+    targets: [
+        .executableTarget(
+            name: "MixBoard",
+            path: "Sources"
+        )
+    ]
+)

+ 141 - 0
README.md

@@ -0,0 +1,141 @@
+# MixBoard
+
+A native macOS music player and mix preparation tool built with Swift and SwiftUI. Import your music library, analyze tracks for BPM and musical key, build playlists with crossfades, and export directly to your DAW.
+
+## Features
+
+### 🎵 Music Library
+- Import audio files (MP3, WAV, FLAC, AAC, AIFF, M4A, OGG, and more)
+- Scan entire directories recursively
+- Search and filter by title, artist, album, genre, BPM, or key
+- Sort by any column
+- Rate and tag tracks
+
+### 📊 Audio Analysis
+- **BPM Detection** — Energy-based onset detection with autocorrelation (powered by Accelerate framework)
+- **Musical Key Detection** — Chromagram analysis with Krumhansl-Kessler key profiles, Camelot wheel codes
+- **Waveform Generation** — High-resolution waveform display with efficient downsampling
+
+### 🎚️ Playback
+- High-quality playback via AVAudioEngine
+- Interactive waveform with seek
+- 3-band EQ (Low / Mid / High)
+- Volume control
+
+### 📋 Playlist & Mix Building
+- Create playlists for your mixes
+- Drag and reorder tracks
+- Set crossfade durations between tracks
+- Per-track gain adjustment
+- Start/end offset trimming
+- Add cue points and markers (Intro, Outro, Drop, Breakdown, Verse, Chorus, etc.)
+
+### 🔄 DAW Export
+Export your prepared mix to any DAW with multiple format options:
+
+| Format | Extension | Compatible With |
+|--------|-----------|----------------|
+| **Adobe Audition Session** | `.sesx` | Adobe Audition |
+| **DAWproject** | `.dawproject` | Bitwig Studio, Studio One, REAPER |
+| **Edit Decision List** | `.edl` | Pro Tools, Audition, DaVinci Resolve |
+| **Cue Sheet** | `.cue` | Audacity, foobar2000, CD burners |
+| **M3U Playlist** | `.m3u` | Any media player |
+
+All export formats include:
+- Track ordering and timing
+- Crossfade/transition information
+- Cue points as markers
+- BPM and key metadata
+- Option to copy audio files alongside the session
+
+## Requirements
+
+- macOS 14 (Sonoma) or later
+- Xcode 15+ (for building)
+- Swift 5.9+
+
+## Building
+
+### Option 1: Open in Xcode (Recommended)
+```bash
+cd MixBoard
+open Package.swift
+```
+This opens the project in Xcode where you can build and run directly.
+
+### Option 2: Command Line
+```bash
+cd MixBoard
+swift build
+```
+
+> **Note:** For a proper macOS app bundle with sandbox capabilities, building through Xcode is recommended.
+
+## Project Structure
+
+```
+MixBoard/
+├── Package.swift
+├── Sources/
+│   ├── MixBoardApp.swift          # App entry point
+│   ├── Models/
+│   │   ├── Track.swift            # Track model (SwiftData)
+│   │   ├── CuePoint.swift         # Cue points and markers
+│   │   └── Playlist.swift         # Playlist and entries
+│   ├── Services/
+│   │   ├── AudioEngine.swift      # AVAudioEngine playback
+│   │   ├── BPMDetector.swift      # BPM analysis (Accelerate)
+│   │   ├── KeyDetector.swift      # Musical key detection
+│   │   ├── WaveformGenerator.swift # Waveform data generation
+│   │   ├── MetadataService.swift  # Audio metadata reading
+│   │   └── LibraryManager.swift   # Library import & management
+│   ├── Export/
+│   │   ├── DAWExporter.swift      # Export protocol & dispatcher
+│   │   ├── AuditionExporter.swift # Adobe Audition .sesx
+│   │   ├── DAWProjectExporter.swift # Open DAWproject format
+│   │   ├── EDLExporter.swift      # CMX 3600 EDL
+│   │   ├── CueSheetExporter.swift # Standard .cue sheets
+│   │   └── M3UExporter.swift      # M3U playlists
+│   ├── ViewModels/
+│   │   ├── LibraryViewModel.swift
+│   │   ├── PlayerViewModel.swift
+│   │   └── PlaylistViewModel.swift
+│   └── Views/
+│       ├── ContentView.swift      # Main 3-panel layout
+│       ├── SidebarView.swift      # Navigation sidebar
+│       ├── LibraryView.swift      # Track library browser
+│       ├── PlayerView.swift       # Playback controls
+│       ├── WaveformView.swift     # Interactive waveform
+│       ├── PlaylistView.swift     # Mix/playlist editor
+│       ├── TrackRow.swift         # Track list row
+│       └── ExportSheet.swift      # DAW export dialog
+```
+
+## Workflow
+
+1. **Import** music files or folders into the library
+2. **Analyze** tracks to detect BPM and musical key
+3. **Browse** the library — search, sort, and filter tracks
+4. **Create** a playlist for your mix
+5. **Build** the mix — arrange tracks, set crossfades, adjust gain, add cue points
+6. **Export** to your DAW format of choice
+7. **Open** the exported session in Adobe Audition, Bitwig, REAPER, or any supported DAW
+8. **Refine** the mix in the DAW with full multitrack control
+
+## DAW Integration Details
+
+### Adobe Audition
+Export as `.sesx` (Audition Session XML). Open directly in Audition — tracks appear on the multitrack timeline with crossfades and markers in place.
+
+### DAWproject (Bitwig, Studio One, REAPER)
+The open standard for DAW interchange. Export generates the `project.xml` containing track layout, clip timing, fades, and markers. Audio files are placed in a companion folder.
+
+### Edit Decision List
+Standard CMX 3600 format with SMPTE timecode. Widely supported by professional DAWs including Pro Tools and DaVinci Resolve.
+
+### Cue Sheet
+Classic `.cue` format with track indices, performer info, and timing. Great for reference and compatible with many audio tools.
+
+## License
+
+MIT

+ 380 - 0
SPEC.md

@@ -0,0 +1,380 @@
+# 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)
+
+```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: <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):
+
+```bash
+# 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:
+
+```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.*

+ 462 - 0
Sources/Export/AudioStitcher.swift

@@ -0,0 +1,462 @@
+import AVFoundation
+import Foundation
+
+/// Stitches multiple audio files into a single WAV file with embedded cue markers
+/// at track boundaries. Generates companion marker files for DAW import.
+struct AudioStitcher {
+
+    /// Result of a stitch operation.
+    struct StitchResult {
+        let outputURL: URL
+        let markers: [TrackMarker]
+        let totalDuration: TimeInterval
+        let sampleRate: Double
+        let channels: Int
+    }
+
+    /// A marker representing where a track starts/ends in the stitched file.
+    struct TrackMarker {
+        let name: String
+        let artist: String
+        let album: String
+        let startTime: TimeInterval
+        let endTime: TimeInterval
+        let startSample: Int64
+        let endSample: Int64
+
+        var duration: TimeInterval { endTime - startTime }
+    }
+
+    /// Options for stitching.
+    struct StitchOptions {
+        /// Output sample rate (nil = use first track's rate).
+        var sampleRate: Double? = nil
+        /// Output bit depth.
+        var bitDepth: Int = 24
+        /// Gap between tracks in seconds (0 = gapless, negative = crossfade overlap).
+        var gapDuration: TimeInterval = 0
+        /// Crossfade duration in seconds. Overrides gapDuration if > 0.
+        var crossfadeDuration: TimeInterval = 0
+        /// Use per-entry crossfade settings from the playlist.
+        var usePlaylistCrossfades: Bool = true
+
+        static let `default` = StitchOptions()
+    }
+
+    // MARK: - Stitch
+
+    /// Stitch all tracks in a playlist into a single WAV file.
+    @MainActor
+    static func stitch(
+        playlist: Playlist,
+        to outputURL: URL,
+        options: StitchOptions = .default
+    ) async throws -> StitchResult {
+        let entries = playlist.sortedEntries
+        guard !entries.isEmpty else { throw StitchError.emptyPlaylist }
+
+        // Determine output format from first local track
+        guard let firstTrack = entries.compactMap({ $0.track }).first(where: { $0.hasLocalFile }) else {
+            throw StitchError.emptyPlaylist
+        }
+        let sampleRate: Double
+        if let customRate = options.sampleRate {
+            sampleRate = customRate
+        } else if OGGDecoder.isOGGFile(firstTrack.fileURL),
+                  let info = OGGDecoder.fileInfo(url: firstTrack.fileURL) {
+            sampleRate = info.sampleRate
+        } else {
+            let firstFile = try AVAudioFile(forReading: firstTrack.fileURL)
+            sampleRate = firstFile.processingFormat.sampleRate
+        }
+        let channels: AVAudioChannelCount = 2
+
+        // Create output file
+        let settings: [String: Any] = [
+            AVFormatIDKey: Int(kAudioFormatLinearPCM),
+            AVSampleRateKey: sampleRate,
+            AVNumberOfChannelsKey: Int(channels),
+            AVLinearPCMBitDepthKey: options.bitDepth,
+            AVLinearPCMIsFloatKey: options.bitDepth == 32,
+            AVLinearPCMIsBigEndianKey: false,
+            AVLinearPCMIsNonInterleaved: false
+        ]
+
+        let outputFile = try AVAudioFile(
+            forWriting: outputURL,
+            settings: settings
+        )
+
+        // Use the file's own processingFormat for all buffers
+        let outputFormat = outputFile.processingFormat
+
+        var markers: [TrackMarker] = []
+        var currentSample: Int64 = 0
+        var currentTime: TimeInterval = 0
+
+        // Process each entry
+        for (index, entry) in entries.enumerated() {
+            guard let track = entry.track, track.hasLocalFile else { continue }
+
+            let startSample = currentSample
+            let startTime = currentTime
+
+            // Read the source audio
+            let sourceFormat: AVAudioFormat
+            let sourceBuffer: AVAudioPCMBuffer
+            let totalFrames: AVAudioFramePosition
+
+            if OGGDecoder.isOGGFile(track.fileURL) {
+                // Decode OGG to PCM buffer
+                let (oggBuffer, oggFormat) = try OGGDecoder.decode(url: track.fileURL)
+                sourceFormat = oggFormat
+                sourceBuffer = oggBuffer
+                totalFrames = AVAudioFramePosition(oggBuffer.frameLength)
+            } else {
+                // Read the source audio in its processingFormat (auto-decompresses)
+                let sourceFile = try AVAudioFile(forReading: track.fileURL)
+                sourceFormat = sourceFile.processingFormat
+
+                // Read in chunks to handle large files
+                totalFrames = sourceFile.length
+                let chunkSize: AVAudioFrameCount = 65536
+                var allSamples: AVAudioPCMBuffer?
+
+                // For simplicity, read entire file then convert
+                let frameCount = AVAudioFrameCount(totalFrames)
+                guard let buffer = AVAudioPCMBuffer(
+                    pcmFormat: sourceFormat,
+                    frameCapacity: frameCount
+                ) else { continue }
+
+                sourceFile.framePosition = 0
+                try sourceFile.read(into: buffer, frameCount: frameCount)
+                sourceBuffer = buffer
+            }
+
+            let frameCount = AVAudioFrameCount(totalFrames)
+
+            // Convert to output format if needed
+            let convertedBuffer: AVAudioPCMBuffer
+            if sourceFormat != outputFormat {
+                guard let converter = AVAudioConverter(from: sourceFormat, to: outputFormat) else {
+                    throw StitchError.conversionError
+                }
+                let ratio = outputFormat.sampleRate / sourceFormat.sampleRate
+                let outputFrameCapacity = AVAudioFrameCount(Double(frameCount) * ratio) + 1024
+                guard let converted = AVAudioPCMBuffer(
+                    pcmFormat: outputFormat,
+                    frameCapacity: outputFrameCapacity
+                ) else { continue }
+
+                var error: NSError?
+                let inputBlock: AVAudioConverterInputBlock = { _, outStatus in
+                    outStatus.pointee = .haveData
+                    return sourceBuffer
+                }
+                converter.convert(to: converted, error: &error, withInputFrom: inputBlock)
+                if let error { throw error }
+                convertedBuffer = converted
+            } else {
+                convertedBuffer = sourceBuffer
+            }
+
+            // Apply start/end offsets
+            let startOffset = entry.startOffset
+            let endOffset = entry.endOffset > 0 ? entry.endOffset : Double(convertedBuffer.frameLength) / sampleRate
+            let startFrame = AVAudioFramePosition(startOffset * sampleRate)
+            let endFrame = min(AVAudioFramePosition(endOffset * sampleRate), AVAudioFramePosition(convertedBuffer.frameLength))
+            let framesToWrite = AVAudioFrameCount(endFrame - startFrame)
+
+            guard framesToWrite > 0 else { continue }
+
+            // Create a sub-buffer for the trimmed region
+            guard let trimmedBuffer = AVAudioPCMBuffer(
+                pcmFormat: outputFormat,
+                frameCapacity: framesToWrite
+            ) else { continue }
+
+            // Copy trimmed frames
+            let chCount = Int(outputFormat.channelCount)
+            if let srcData = convertedBuffer.floatChannelData,
+               let dstData = trimmedBuffer.floatChannelData {
+                for ch in 0..<chCount {
+                    let src = srcData[ch].advanced(by: Int(startFrame))
+                    let dst = dstData[ch]
+                    dst.update(from: src, count: Int(framesToWrite))
+                }
+                trimmedBuffer.frameLength = framesToWrite
+            } else if let srcData = convertedBuffer.int16ChannelData,
+                      let dstData = trimmedBuffer.int16ChannelData {
+                for ch in 0..<chCount {
+                    let src = srcData[ch].advanced(by: Int(startFrame))
+                    let dst = dstData[ch]
+                    dst.update(from: src, count: Int(framesToWrite))
+                }
+                trimmedBuffer.frameLength = framesToWrite
+            } else if let srcData = convertedBuffer.int32ChannelData,
+                      let dstData = trimmedBuffer.int32ChannelData {
+                for ch in 0..<chCount {
+                    let src = srcData[ch].advanced(by: Int(startFrame))
+                    let dst = dstData[ch]
+                    dst.update(from: src, count: Int(framesToWrite))
+                }
+                trimmedBuffer.frameLength = framesToWrite
+            }
+
+            // Apply gain adjustment
+            if entry.gainAdjustment != 0 {
+                applyGain(to: trimmedBuffer, gainDB: Float(entry.gainAdjustment))
+            }
+
+            // Write to output
+            try outputFile.write(from: trimmedBuffer)
+
+            let writtenDuration = Double(framesToWrite) / sampleRate
+            currentSample += Int64(framesToWrite)
+            currentTime += writtenDuration
+
+            // Record marker
+            markers.append(TrackMarker(
+                name: track.title,
+                artist: track.artist,
+                album: track.album,
+                startTime: startTime,
+                endTime: currentTime,
+                startSample: startSample,
+                endSample: currentSample
+            ))
+
+            // Apply gap/crossfade for next track
+            if index < entries.count - 1 {
+                let crossfade: TimeInterval
+                if options.usePlaylistCrossfades {
+                    crossfade = entries[index + 1].crossfadeDuration
+                } else {
+                    crossfade = options.crossfadeDuration
+                }
+
+                if crossfade > 0 {
+                    // For crossfade, we overlap: rewind the write position conceptually
+                    // (In a simple stitch, we just note the overlap in markers)
+                    // Real crossfade would require mixing overlapping regions
+                    // For now, insert silence gap as negative crossfade
+                } else if options.gapDuration > 0 {
+                    // Insert silence gap
+                    let gapFrames = AVAudioFrameCount(options.gapDuration * sampleRate)
+                    guard let silenceBuffer = AVAudioPCMBuffer(
+                        pcmFormat: outputFormat,
+                        frameCapacity: gapFrames
+                    ) else { continue }
+                    silenceBuffer.frameLength = gapFrames
+                    // Buffer is already zeroed
+                    try outputFile.write(from: silenceBuffer)
+                    currentSample += Int64(gapFrames)
+                    currentTime += options.gapDuration
+                }
+            }
+        }
+
+        return StitchResult(
+            outputURL: outputURL,
+            markers: markers,
+            totalDuration: currentTime,
+            sampleRate: sampleRate,
+            channels: Int(channels)
+        )
+    }
+
+    // MARK: - Gain
+
+    private static func applyGain(to buffer: AVAudioPCMBuffer, gainDB: Float) {
+        let gain = powf(10.0, gainDB / 20.0)
+        guard let channelData = buffer.floatChannelData else { return }
+        let frameCount = Int(buffer.frameLength)
+        let channelCount = Int(buffer.format.channelCount)
+
+        for ch in 0..<channelCount {
+            let ptr = channelData[ch]
+            for i in 0..<frameCount {
+                ptr[i] *= gain
+            }
+        }
+    }
+
+    // MARK: - Companion Files
+
+    /// Generate Adobe Audition-compatible markers CSV.
+    /// Import in Audition: open WAV, then Markers panel > Import...
+    static func writeAuditionMarkers(_ markers: [TrackMarker], to url: URL) throws {
+        // Audition marker import format: tab-separated
+        // Name, Start, Duration, Time Format, Type, Description
+        var csv = "Name\tStart\tDuration\tTime Format\tType\tDescription\n"
+
+        for (index, marker) in markers.enumerated() {
+            let start = formatHMSMs(marker.startTime)
+            let duration = formatHMSMs(marker.duration)
+            let description = [marker.artist, marker.album].filter { !$0.isEmpty }.joined(separator: " - ")
+            let name = "\(String(format: "%02d", index + 1)). \(marker.name)"
+
+            // Cue marker at the start of each track
+            csv += "\(name)\t\(start)\t\(duration)\tDecimal\tCue\t\(description)\n"
+        }
+
+        try csv.write(to: url, atomically: true, encoding: .utf8)
+    }
+
+    /// Write a simple text track list with timecodes (human-readable).
+    static func writeTrackList(_ markers: [TrackMarker], playlistName: String, to url: URL) throws {
+        var lines: [String] = []
+        lines.append("\(playlistName) — Track List")
+        lines.append("Generated by MixBoard on \(ISO8601DateFormatter().string(from: Date()))")
+        lines.append(String(repeating: "─", count: 70))
+        lines.append("")
+
+        for (index, marker) in markers.enumerated() {
+            let num = String(format: "%02d", index + 1)
+            let start = formatHMSMs(marker.startTime)
+            let end = formatHMSMs(marker.endTime)
+            let dur = formatHMSMs(marker.duration)
+            let artist = marker.artist.isEmpty ? "" : " — \(marker.artist)"
+            lines.append("  \(num). \(marker.name)\(artist)")
+            lines.append("      Start: \(start)  End: \(end)  Duration: \(dur)")
+            if !marker.album.isEmpty {
+                lines.append("      Album: \(marker.album)")
+            }
+            lines.append("")
+        }
+
+        if let last = markers.last {
+            lines.append(String(repeating: "─", count: 70))
+            lines.append("Total: \(markers.count) tracks, \(formatHMSMs(last.endTime))")
+        }
+
+        let content = lines.joined(separator: "\n") + "\n"
+        try content.write(to: url, atomically: true, encoding: .utf8)
+    }
+
+    /// Generate a CUE sheet referencing the stitched file.
+    static func writeCueSheet(
+        _ markers: [TrackMarker],
+        audioFileName: String,
+        playlistName: String,
+        to url: URL
+    ) throws {
+        var lines: [String] = []
+        lines.append("REM Generated by MixBoard (stitched export)")
+        lines.append("REM Date: \(ISO8601DateFormatter().string(from: Date()))")
+        lines.append("TITLE \"\(playlistName)\"")
+        lines.append("FILE \"\(audioFileName)\" WAVE")
+
+        for (index, marker) in markers.enumerated() {
+            let trackNum = String(format: "%02d", index + 1)
+            lines.append("  TRACK \(trackNum) AUDIO")
+            lines.append("    TITLE \"\(marker.name)\"")
+            if !marker.artist.isEmpty {
+                lines.append("    PERFORMER \"\(marker.artist)\"")
+            }
+            lines.append("    INDEX 01 \(formatCueTime(marker.startTime))")
+        }
+
+        let content = lines.joined(separator: "\n") + "\n"
+        try content.write(to: url, atomically: true, encoding: .utf8)
+    }
+
+    /// Generate an Adobe Audition session (.sesx) referencing the stitched file with markers.
+    /// Note: This uses a best-effort approximation of Audition's XML format.
+    /// For guaranteed marker import, use the CSV markers file instead.
+    static func writeAuditionSession(
+        _ markers: [TrackMarker],
+        audioFilePath: String,
+        audioFileName: String,
+        playlistName: String,
+        sampleRate: Double,
+        totalDuration: TimeInterval,
+        to url: URL
+    ) throws {
+        let totalSamples = Int64(totalDuration * sampleRate)
+
+        // Audition .sesx is XML-based. This approximates its structure.
+        var xml = """
+        <?xml version="1.0" encoding="UTF-8" standalone="no"?>
+        <sesx version="1.2">
+        <session name="\(escapeXML(playlistName))" sampleRate="\(Int(sampleRate))" bitDepth="32" audioChannelType="stereo" duration="\(totalSamples)" padding="0">
+        <files>
+            <file id="file-1" relativePath="\(escapeXML(audioFileName))" absolutePath="\(escapeXML(audioFilePath))" />
+        </files>
+        <tracks>
+            <audioTrack id="track-1" name="Mix" index="0" color="#4A86C8" visible="true" mute="false" solo="false" select="false" height="150">
+                <trackParameters>
+                    <trackParameter name="volume" value="1.0" />
+                    <trackParameter name="pan" value="0.0" />
+                </trackParameters>
+                <audioClip id="clip-1" name="\(escapeXML(playlistName))" fileID="file-1" startPoint="0" endPoint="\(totalSamples)" sourceInPoint="0" sourceOutPoint="\(totalSamples)">
+                </audioClip>
+            </audioTrack>
+        </tracks>
+        <markers>
+
+        """
+
+        for (index, marker) in markers.enumerated() {
+            let id = "marker-\(index * 2 + 1)"
+            let id2 = "marker-\(index * 2 + 2)"
+            let name = "\(String(format: "%02d", index + 1)). \(escapeXML(marker.name))"
+            xml += """
+                <marker id="\(id)" name="\(name)" time="\(marker.startSample)" type="cue" description="\(escapeXML(marker.artist))" />
+                <marker id="\(id2)" name="\(name) [END]" time="\(marker.endSample)" type="cue" description="" />
+
+            """
+        }
+
+        xml += """
+        </markers>
+        </session>
+        </sesx>
+
+        """
+
+        try xml.write(to: url, atomically: true, encoding: .utf8)
+    }
+
+    // MARK: - Helpers
+
+    private static func formatHMSMs(_ seconds: TimeInterval) -> String {
+        let hours = Int(seconds) / 3600
+        let minutes = (Int(seconds) % 3600) / 60
+        let secs = Int(seconds) % 60
+        let millis = Int((seconds - Double(Int(seconds))) * 1000)
+        return String(format: "%02d:%02d:%02d.%03d", hours, minutes, secs, millis)
+    }
+
+    private static func formatCueTime(_ seconds: TimeInterval) -> String {
+        let minutes = Int(seconds) / 60
+        let secs = Int(seconds) % 60
+        let frames = Int((seconds - Double(Int(seconds))) * 75)
+        return String(format: "%02d:%02d:%02d", minutes, secs, frames)
+    }
+
+    private static func escapeXML(_ string: String) -> String {
+        string
+            .replacingOccurrences(of: "&", with: "&amp;")
+            .replacingOccurrences(of: "<", with: "&lt;")
+            .replacingOccurrences(of: ">", with: "&gt;")
+            .replacingOccurrences(of: "\"", with: "&quot;")
+    }
+}
+
+// MARK: - Errors
+
+enum StitchError: Error, LocalizedError {
+    case emptyPlaylist
+    case formatError
+    case conversionError
+
+    var errorDescription: String? {
+        switch self {
+        case .emptyPlaylist: return "Playlist is empty"
+        case .formatError: return "Unable to create audio format"
+        case .conversionError: return "Audio conversion failed"
+        }
+    }
+}

+ 177 - 0
Sources/Export/AuditionExporter.swift

@@ -0,0 +1,177 @@
+import Foundation
+
+/// Exports a playlist as an Adobe Audition multitrack session (.sesx) file.
+/// Based on the real Audition .sesx XML schema (version 1.9).
+/// Each original track becomes a separate clip on the timeline, referencing
+/// the original audio files by absolute path.
+struct AuditionExporter: DAWExporter {
+    static let formatID = "audition"
+    static let formatName = "Adobe Audition Session"
+    static let fileExtension = "sesx"
+
+    static func export(playlist: Playlist, to url: URL, options: ExportOptions) throws {
+        let entries = playlist.sortedEntries
+        let sampleRate = options.targetSampleRate ?? 44100
+        let totalSamples = timeToSamples(playlist.totalDuration, sampleRate: sampleRate)
+        let sessionDir = url.deletingLastPathComponent().path
+
+        // Collect unique file references
+        var fileRefs: [(id: Int, absolutePath: String, relativePath: String, mediaHandler: String)] = []
+        var fileIDMap = [String: Int]()
+
+        for entry in entries {
+            guard let track = entry.track else { continue }
+            let path = track.filePath
+            if fileIDMap[path] == nil {
+                let fid = fileRefs.count
+                fileIDMap[path] = fid
+                let rel = makeRelativePath(from: sessionDir, to: path)
+                let handler = mediaHandler(for: track.fileFormat)
+                fileRefs.append((id: fid, absolutePath: path, relativePath: rel, mediaHandler: handler))
+            }
+        }
+
+        // --- Build XML matching real Audition format ---
+
+        var xml = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\" ?>\n"
+        xml += "<!DOCTYPE sesx>\n"
+        xml += "<sesx version=\"1.9\">\n\n"
+
+        // Session
+        xml += "  <session appBuild=\"MixBoard\" appVersion=\"1.0\" "
+        xml += "audioChannelType=\"stereo\" bitDepth=\"32\" "
+        xml += "duration=\"\(totalSamples)\" sampleRate=\"\(Int(sampleRate))\">\n"
+
+        // Tracks
+        xml += "    <tracks>\n"
+
+        // Audio track
+        xml += "      <audioTrack automationLaneOpenState=\"false\" id=\"10001\" index=\"1\" select=\"false\" visible=\"true\">\n"
+        xml += "        <trackParameters trackHeight=\"140\" trackHue=\"160\" trackMinimized=\"false\">\n"
+        xml += "          <name>Mix</name>\n"
+        xml += "        </trackParameters>\n"
+        xml += "        <trackAudioParameters audioChannelType=\"stereo\" automationMode=\"1\" "
+        xml += "monitoring=\"false\" recordArmed=\"false\" solo=\"false\" soloSafe=\"false\">\n"
+        xml += "          <trackOutput outputID=\"10000\" type=\"trackID\"/>\n"
+        xml += "          <component componentID=\"Audition.Fader\" id=\"trackFader\" name=\"volume\" powered=\"true\">\n"
+        xml += "            <parameter index=\"0\" name=\"volume\" parameterValue=\"1\"/>\n"
+        xml += "            <parameter index=\"1\" name=\"static gain\" parameterValue=\"1\"/>\n"
+        xml += "          </component>\n"
+        xml += "          <component componentID=\"Audition.Mute\" id=\"trackMute\" name=\"Mute\" powered=\"true\">\n"
+        xml += "            <parameter index=\"0\" parameterValue=\"0\"/>\n"
+        xml += "            <parameter index=\"1\" name=\"mute\" parameterValue=\"0\"/>\n"
+        xml += "          </component>\n"
+        xml += "          <component componentID=\"Audition.StereoPanner\" id=\"trackPan\" name=\"StereoPanner\" powered=\"true\">\n"
+        xml += "            <parameter index=\"0\" name=\"Pan\" parameterValue=\"0\"/>\n"
+        xml += "          </component>\n"
+        xml += "        </trackAudioParameters>\n"
+
+        // Audio clips on timeline
+        var timelinePos: TimeInterval = 0
+
+        for (index, entry) in entries.enumerated() {
+            guard let track = entry.track else { continue }
+            guard let fid = fileIDMap[track.filePath] else { continue }
+
+            // Apply crossfade overlap
+            if index > 0 && entry.crossfadeDuration > 0 {
+                timelinePos -= entry.crossfadeDuration
+            }
+
+            let clipStart = timelinePos
+            let startOffset = entry.startOffset
+            let effectiveDuration = entry.effectiveDuration
+
+            let startSample = timeToSamples(clipStart, sampleRate: sampleRate)
+            let endSample = timeToSamples(clipStart + effectiveDuration, sampleRate: sampleRate)
+            let sourceIn = timeToSamples(startOffset, sampleRate: sampleRate)
+            let sourceOut = timeToSamples(startOffset + effectiveDuration, sampleRate: sampleRate)
+
+            xml += "        <audioClip clipAutoCrossfade=\"true\" "
+            xml += "crossFadeHeadClipID=\"-1\" crossFadeTailClipID=\"-1\" "
+            xml += "endPoint=\"\(endSample)\" fileID=\"\(fid)\" hue=\"-1\" "
+            xml += "id=\"\(index)\" lockedInTime=\"false\" looped=\"false\" "
+            xml += "name=\"\(esc(track.title))\" offline=\"false\" select=\"false\" "
+            xml += "sourceInPoint=\"\(sourceIn)\" sourceOutPoint=\"\(sourceOut)\" "
+            xml += "startPoint=\"\(startSample)\" zOrder=\"\(127 + index)\">\n"
+            xml += "        </audioClip>\n"
+
+            timelinePos = clipStart + effectiveDuration
+        }
+
+        xml += "      </audioTrack>\n"
+
+        // Master track
+        xml += "      <masterTrack automationLaneOpenState=\"false\" id=\"10000\" index=\"0\" select=\"false\" visible=\"true\">\n"
+        xml += "        <trackParameters trackHeight=\"70\" trackHue=\"-1\" trackMinimized=\"true\">\n"
+        xml += "          <name>Master</name>\n"
+        xml += "        </trackParameters>\n"
+        xml += "        <trackAudioParameters audioChannelType=\"stereo\" automationMode=\"1\" "
+        xml += "monitoring=\"false\" recordArmed=\"false\" solo=\"false\" soloSafe=\"false\">\n"
+        xml += "          <component componentID=\"Audition.Fader\" id=\"trackFader\" name=\"volume\" powered=\"true\">\n"
+        xml += "            <parameter index=\"0\" name=\"volume\" parameterValue=\"1\"/>\n"
+        xml += "            <parameter index=\"1\" name=\"static gain\" parameterValue=\"1\"/>\n"
+        xml += "          </component>\n"
+        xml += "          <component componentID=\"Audition.Mute\" id=\"trackMute\" name=\"Mute\" powered=\"true\">\n"
+        xml += "            <parameter index=\"0\" parameterValue=\"0\"/>\n"
+        xml += "            <parameter index=\"1\" name=\"mute\" parameterValue=\"0\"/>\n"
+        xml += "          </component>\n"
+        xml += "        </trackAudioParameters>\n"
+        xml += "      </masterTrack>\n"
+
+        xml += "    </tracks>\n"
+        xml += "  </session>\n\n"
+
+        // Files section — AFTER </session>, before </sesx>
+        xml += "  <files>\n"
+        for ref in fileRefs {
+            let uuid = UUID().uuidString.lowercased()
+            xml += "    <file absolutePath=\"\(esc(ref.absolutePath))\" "
+            xml += "id=\"\(ref.id)\" "
+            xml += "mediaHandler=\"\(ref.mediaHandler)\" "
+            xml += "recoveryID=\"\(uuid)\" "
+            xml += "relativePath=\"\(esc(ref.relativePath))\"/>\n"
+        }
+        xml += "  </files>\n\n"
+
+        xml += "</sesx>\n"
+
+        try xml.write(to: url, atomically: true, encoding: .utf8)
+    }
+
+    // MARK: - Helpers
+
+    private static func timeToSamples(_ time: TimeInterval, sampleRate: Double) -> Int64 {
+        Int64(time * sampleRate)
+    }
+
+    private static func esc(_ string: String) -> String {
+        string
+            .replacingOccurrences(of: "&", with: "&amp;")
+            .replacingOccurrences(of: "<", with: "&lt;")
+            .replacingOccurrences(of: ">", with: "&gt;")
+            .replacingOccurrences(of: "\"", with: "&quot;")
+            .replacingOccurrences(of: "'", with: "&apos;")
+    }
+
+    private static func makeRelativePath(from sessionDir: String, to filePath: String) -> String {
+        if filePath.hasPrefix(sessionDir) {
+            let relative = String(filePath.dropFirst(sessionDir.count))
+            return relative.hasPrefix("/") ? String(relative.dropFirst()) : relative
+        }
+        return filePath
+    }
+
+    /// Map file extension to Audition media handler identifier.
+    private static func mediaHandler(for format: String) -> String {
+        switch format.lowercased() {
+        case "mp3":                     return "AmioMP3"
+        case "wav":                     return "AmioWav"
+        case "flac":                    return "AmioLSF"
+        case "aif", "aiff":            return "AmioAIFF"
+        case "m4a", "aac", "alac":     return "AmioQuickTime"
+        case "ogg":                     return "AmioOGG"
+        default:                        return "AmioGeneric"
+        }
+    }
+}

+ 80 - 0
Sources/Export/CueSheetExporter.swift

@@ -0,0 +1,80 @@
+import Foundation
+
+/// Exports a playlist as a standard CUE sheet (.cue).
+/// CUE sheets are widely supported by CD burning software, virtual CD drives,
+/// and many audio tools including Audacity, foobar2000, and various DAWs.
+struct CueSheetExporter: DAWExporter {
+    static let formatID = "cue"
+    static let formatName = "Cue Sheet"
+    static let fileExtension = "cue"
+
+    static func export(playlist: Playlist, to url: URL, options: ExportOptions) throws {
+        let entries = playlist.sortedEntries
+        var lines: [String] = []
+
+        lines.append("REM Generated by MixBoard")
+        lines.append("REM Date: \(ISO8601DateFormatter().string(from: Date()))")
+        lines.append("TITLE \"\(playlist.name)\"")
+
+        var timelinePosition: TimeInterval = 0
+
+        for (index, entry) in entries.enumerated() {
+            guard let track = entry.track, track.hasLocalFile else { continue }
+
+            let trackNumber = String(format: "%02d", index + 1)
+            let fileName = track.fileURL.lastPathComponent
+            let relativePath = "\(options.audioFilesRelativePath)/\(fileName)"
+
+            lines.append("FILE \"\(relativePath)\" WAVE")
+            lines.append("  TRACK \(trackNumber) AUDIO")
+            lines.append("    TITLE \"\(track.title)\"")
+
+            if !track.artist.isEmpty {
+                lines.append("    PERFORMER \"\(track.artist)\"")
+            }
+
+            // INDEX 01 is the start time in MM:SS:FF format (frames = 1/75 second)
+            let indexTime = formatCueTime(timelinePosition)
+            lines.append("    INDEX 01 \(indexTime)")
+
+            // Add cue points as INDEX entries
+            if options.includeCuePoints {
+                for (cpIndex, cuePoint) in track.cuePoints.sorted().enumerated() {
+                    // INDEX 02+ for additional cue points
+                    let cpTime = formatCueTime(timelinePosition + cuePoint.timestamp - entry.startOffset)
+                    lines.append("    REM CUE \(cuePoint.name.isEmpty ? cuePoint.type.rawValue : cuePoint.name)")
+                    if cpIndex < 98 { // CUE format supports INDEX 00-99
+                        lines.append("    INDEX \(String(format: "%02d", cpIndex + 2)) \(cpTime)")
+                    }
+                }
+            }
+
+            // Add BPM as a remark
+            if let bpm = track.bpm {
+                lines.append("    REM BPM \(String(format: "%.1f", bpm))")
+            }
+            if let key = track.musicalKey {
+                lines.append("    REM KEY \(key)")
+            }
+
+            timelinePosition += entry.effectiveDuration
+            if index + 1 < entries.count {
+                timelinePosition -= entries[index + 1].crossfadeDuration
+            }
+        }
+
+        let content = lines.joined(separator: "\n") + "\n"
+        try content.write(to: url, atomically: true, encoding: .utf8)
+    }
+
+    // MARK: - CUE Time Format
+
+    /// Convert seconds to CUE time format MM:SS:FF where FF = frames (1/75 second).
+    private static func formatCueTime(_ seconds: TimeInterval) -> String {
+        let totalSeconds = max(0, seconds)
+        let minutes = Int(totalSeconds) / 60
+        let secs = Int(totalSeconds) % 60
+        let frames = Int((totalSeconds - Double(Int(totalSeconds))) * 75)
+        return String(format: "%02d:%02d:%02d", minutes, secs, frames)
+    }
+}

+ 146 - 0
Sources/Export/DAWExporter.swift

@@ -0,0 +1,146 @@
+import Foundation
+
+/// Protocol for all DAW export formats.
+protocol DAWExporter {
+    /// Unique identifier for this export format.
+    static var formatID: String { get }
+
+    /// Display name for UI.
+    static var formatName: String { get }
+
+    /// File extension(s) for the export.
+    static var fileExtension: String { get }
+
+    /// Export a playlist to the specified URL.
+    static func export(playlist: Playlist, to url: URL, options: ExportOptions) throws
+}
+
+/// Options for DAW export.
+struct ExportOptions {
+    /// Whether to copy audio files alongside the session file.
+    var copyAudioFiles: Bool = true
+
+    /// Target sample rate for the session (nil = use original).
+    var targetSampleRate: Double? = nil
+
+    /// Target bit depth for the session.
+    var targetBitDepth: Int = 24
+
+    /// Include cue points as markers in the DAW session.
+    var includeCuePoints: Bool = true
+
+    /// Include crossfade information.
+    var includeCrossfades: Bool = true
+
+    /// Base directory for relative audio file paths.
+    var audioFilesRelativePath: String = "Audio Files"
+
+    /// Template for renaming copied files. Nil = keep original filenames.
+    var fileNameTemplate: String? = nil
+
+    static let `default` = ExportOptions()
+}
+
+/// Central exporter that dispatches to format-specific exporters.
+struct MixExporter {
+
+    enum ExportFormat: String, CaseIterable, Identifiable {
+        case audition    = "audition"
+        case cueSheet    = "cue"
+        case dawProject  = "dawproject"
+        case edl         = "edl"
+        case m3u         = "m3u"
+
+        var id: String { rawValue }
+
+        var name: String {
+            switch self {
+            case .audition:   return "Adobe Audition Session"
+            case .cueSheet:   return "Cue Sheet (.cue)"
+            case .dawProject: return "DAWproject (Open Standard)"
+            case .edl:        return "Edit Decision List (EDL)"
+            case .m3u:        return "M3U Playlist"
+            }
+        }
+
+        var fileExtension: String {
+            switch self {
+            case .audition:   return "sesx"
+            case .cueSheet:   return "cue"
+            case .dawProject: return "dawproject"
+            case .edl:        return "edl"
+            case .m3u:        return "m3u"
+            }
+        }
+
+        var description: String {
+            switch self {
+            case .audition:
+                return "Adobe Audition multitrack session with markers and crossfades"
+            case .cueSheet:
+                return "Standard cue sheet format, compatible with many audio tools"
+            case .dawProject:
+                return "Open DAW exchange format (Bitwig, PreSonus, REAPER)"
+            case .edl:
+                return "CMX 3600 Edit Decision List for professional DAWs"
+            case .m3u:
+                return "Simple playlist format for basic file list export"
+            }
+        }
+    }
+
+    /// Export a playlist in the specified format.
+    static func export(
+        playlist: Playlist,
+        format: ExportFormat,
+        to url: URL,
+        options: ExportOptions = .default
+    ) throws {
+        // Copy audio files if requested
+        if options.copyAudioFiles {
+            let audioDir = url.deletingLastPathComponent()
+                .appendingPathComponent(options.audioFilesRelativePath)
+            let entries = playlist.sortedEntries
+            try copyAudioFiles(entries: entries, to: audioDir, template: options.fileNameTemplate)
+        }
+
+        switch format {
+        case .audition:   try AuditionExporter.export(playlist: playlist, to: url, options: options)
+        case .cueSheet:   try CueSheetExporter.export(playlist: playlist, to: url, options: options)
+        case .dawProject: try DAWProjectExporter.export(playlist: playlist, to: url, options: options)
+        case .edl:        try EDLExporter.export(playlist: playlist, to: url, options: options)
+        case .m3u:        try M3UExporter.export(playlist: playlist, to: url, options: options)
+        }
+    }
+
+    private static func copyAudioFiles(entries: [PlaylistEntry], to directory: URL, template: String?) throws {
+        let fm = FileManager.default
+        try fm.createDirectory(at: directory, withIntermediateDirectories: true)
+
+        let totalTracks = entries.count
+
+        for (index, entry) in entries.enumerated() {
+            guard let track = entry.track, track.hasLocalFile else { continue }
+            let source = track.fileURL
+            let ext = source.pathExtension
+
+            let destName: String
+            if let template {
+                let baseName = FileNameTemplate.generate(
+                    template: template,
+                    track: track,
+                    playlistIndex: index,
+                    totalTracks: totalTracks
+                )
+                destName = "\(baseName).\(ext)"
+            } else {
+                destName = source.lastPathComponent
+            }
+
+            let dest = directory.appendingPathComponent(destName)
+            if !fm.fileExists(atPath: dest.path) {
+                try fm.copyItem(at: source, to: dest)
+            }
+        }
+    }
+}

+ 191 - 0
Sources/Export/DAWProjectExporter.swift

@@ -0,0 +1,191 @@
+import Foundation
+
+/// Exports a playlist in the DAWproject open exchange format.
+/// DAWproject is an open standard for DAW session interchange developed by
+/// Bitwig and PreSonus. It's supported by Bitwig Studio, Studio One, REAPER, and others.
+///
+/// A .dawproject file is a ZIP archive containing:
+///   - project.xml  (session metadata, tracks, clips, markers)
+///   - metadata.xml (basic project info)
+///   - Audio Files/  (referenced audio files)
+struct DAWProjectExporter: DAWExporter {
+    static let formatID = "dawproject"
+    static let formatName = "DAWproject"
+    static let fileExtension = "dawproject"
+
+    static func export(playlist: Playlist, to url: URL, options: ExportOptions) throws {
+        let entries = playlist.sortedEntries
+        let sampleRate = options.targetSampleRate ?? 44100
+
+        // DAWproject is a ZIP, but for simplicity we'll write the XML and provide
+        // instructions for bundling. For a complete implementation, use ZIPFoundation.
+        // Here we export the project.xml which is the core of the format.
+
+        let xmlURL = url.deletingPathExtension().appendingPathExtension("dawproject.xml")
+
+        let projectXML = buildProjectXML(
+            playlist: playlist,
+            entries: entries,
+            sampleRate: sampleRate,
+            options: options
+        )
+
+        let metadataXML = buildMetadataXML(playlist: playlist)
+
+        // Write project XML
+        try projectXML.write(to: xmlURL, atomically: true, encoding: .utf8)
+
+        // Write metadata alongside
+        let metadataURL = url.deletingLastPathComponent()
+            .appendingPathComponent("metadata.xml")
+        try metadataXML.write(to: metadataURL, atomically: true, encoding: .utf8)
+    }
+
+    // MARK: - Project XML
+
+    private static func buildProjectXML(
+        playlist: Playlist,
+        entries: [PlaylistEntry],
+        sampleRate: Double,
+        options: ExportOptions
+    ) -> String {
+        var xml = """
+        <?xml version="1.0" encoding="UTF-8"?>
+        <Project version="1.0">
+            <Application name="MixBoard" version="1.0" />
+            <Transport>
+                <Tempo max="200" min="60" value="\(playlist.targetBPM ?? 120)" />
+                <TimeSignature denominator="4" numerator="4" />
+            </Transport>
+            <Structure>
+                <Track contentType="audio" name="\(escapeXML(playlist.name))" color="#4A90D9">
+                    <Channel role="regular" audioChannels="2">
+                        <Volume max="2" min="0" value="1" />
+                        <Pan max="1" min="-1" value="0" />
+                    </Channel>
+                </Track>
+            </Structure>
+            <Arrangement>
+                <Lanes>
+                    <Lanes track="0">
+
+        """
+
+        var timelinePosition: TimeInterval = 0
+
+        for (index, entry) in entries.enumerated() {
+            guard let track = entry.track, track.hasLocalFile else { continue }
+
+            let clipStart = timelinePosition
+            let startOffset = entry.startOffset
+            let clipDuration = entry.effectiveDuration
+            let relativePath = "\(options.audioFilesRelativePath)/\(track.fileURL.lastPathComponent)"
+
+            // Apply crossfade overlap
+            if index > 0 {
+                timelinePosition -= entry.crossfadeDuration
+            }
+
+            xml += """
+                            <Clip time="\(clipStart)" duration="\(clipDuration)" name="\(escapeXML(track.title))">
+                                <Audio>
+                                    <File path="\(escapeXML(relativePath))" />
+                                    <Playback offset="\(startOffset)" duration="\(clipDuration)" />
+                                </Audio>
+
+            """
+
+            // Gain adjustment
+            if entry.gainAdjustment != 0 {
+                xml += """
+                                <Gain value="\(entry.gainAdjustment)" />
+
+                """
+            }
+
+            // Crossfades
+            if options.includeCrossfades {
+                if entry.crossfadeDuration > 0 {
+                    xml += """
+                                <FadeIn duration="\(entry.crossfadeDuration)" curve="linear" />
+
+                    """
+                }
+                if index + 1 < entries.count, entries[index + 1].crossfadeDuration > 0 {
+                    xml += """
+                                <FadeOut duration="\(entries[index + 1].crossfadeDuration)" curve="linear" />
+
+                    """
+                }
+            }
+
+            xml += """
+                            </Clip>
+
+            """
+
+            timelinePosition = clipStart + clipDuration
+        }
+
+        xml += """
+                    </Lanes>
+                </Lanes>
+            </Arrangement>
+
+        """
+
+        // Markers
+        if options.includeCuePoints {
+            xml += "    <Markers>\n"
+            var markerTimeline: TimeInterval = 0
+
+            for (index, entry) in entries.enumerated() {
+                guard let track = entry.track else { continue }
+                if index > 0 {
+                    markerTimeline -= entry.crossfadeDuration
+                }
+
+                for cuePoint in track.cuePoints.sorted() {
+                    let markerTime = markerTimeline + cuePoint.timestamp - entry.startOffset
+                    let name = cuePoint.name.isEmpty ? cuePoint.type.rawValue : cuePoint.name
+                    xml += """
+                    <Marker time="\(markerTime)" name="\(escapeXML(name))" color="\(cuePoint.color)" />
+
+                    """
+                }
+
+                markerTimeline += entry.effectiveDuration
+            }
+
+            xml += "    </Markers>\n"
+        }
+
+        xml += """
+        </Project>
+        """
+
+        return xml
+    }
+
+    // MARK: - Metadata XML
+
+    private static func buildMetadataXML(playlist: Playlist) -> String {
+        """
+        <?xml version="1.0" encoding="UTF-8"?>
+        <MetaData>
+            <Title>\(escapeXML(playlist.name))</Title>
+            <Artist></Artist>
+            <Comment>Exported from MixBoard</Comment>
+            <CreatedAt>\(ISO8601DateFormatter().string(from: Date()))</CreatedAt>
+        </MetaData>
+        """
+    }
+
+    private static func escapeXML(_ string: String) -> String {
+        string
+            .replacingOccurrences(of: "&", with: "&amp;")
+            .replacingOccurrences(of: "<", with: "&lt;")
+            .replacingOccurrences(of: ">", with: "&gt;")
+            .replacingOccurrences(of: "\"", with: "&quot;")
+    }
+}

+ 122 - 0
Sources/Export/EDLExporter.swift

@@ -0,0 +1,122 @@
+import Foundation
+
+/// Exports a playlist as a CMX 3600 Edit Decision List (EDL).
+/// EDLs are widely used in professional audio/video production
+/// and can be imported by Pro Tools, Audition, Resolve, and many others.
+struct EDLExporter: DAWExporter {
+    static let formatID = "edl"
+    static let formatName = "Edit Decision List"
+    static let fileExtension = "edl"
+
+    static func export(playlist: Playlist, to url: URL, options: ExportOptions) throws {
+        let entries = playlist.sortedEntries
+        let sampleRate = options.targetSampleRate ?? 44100
+        let fps = 30.0  // Standard frame rate for timecode
+
+        var lines: [String] = []
+
+        // EDL header
+        lines.append("TITLE: \(playlist.name)")
+        lines.append("FCM: NON-DROP FRAME")
+        lines.append("")
+
+        var timelinePosition: TimeInterval = 0
+
+        for (index, entry) in entries.enumerated() {
+            guard let track = entry.track, track.hasLocalFile else { continue }
+
+            let editNumber = String(format: "%03d", index + 1)
+            let reelName = sanitizeReelName(track.title)
+            let startOffset = entry.startOffset
+            let effectiveDuration = entry.effectiveDuration
+
+            // Source IN/OUT
+            let sourceIn = formatTimecode(startOffset, fps: fps)
+            let sourceOut = formatTimecode(startOffset + effectiveDuration, fps: fps)
+
+            // Record IN/OUT (timeline position)
+            let recordIn = formatTimecode(timelinePosition, fps: fps)
+            let recordOut = formatTimecode(timelinePosition + effectiveDuration, fps: fps)
+
+            // Transition type
+            let transition: String
+            if entry.crossfadeDuration > 0 {
+                let crossfadeFrames = Int(entry.crossfadeDuration * fps)
+                transition = "D \(String(format: "%03d", crossfadeFrames))"  // Dissolve
+            } else {
+                transition = "C"  // Cut
+            }
+
+            // EDL edit entry: EDIT# REEL TRACK TRANSITION SOURCE_IN SOURCE_OUT RECORD_IN RECORD_OUT
+            lines.append("\(editNumber)  \(reelName)  AA/V  \(transition)  \(sourceIn) \(sourceOut) \(recordIn) \(recordOut)")
+
+            // Source file comment
+            lines.append("* FROM CLIP NAME: \(track.title)")
+
+            let relativePath = "\(options.audioFilesRelativePath)/\(track.fileURL.lastPathComponent)"
+            lines.append("* SOURCE FILE: \(relativePath)")
+
+            if !track.artist.isEmpty {
+                lines.append("* ARTIST: \(track.artist)")
+            }
+            if let bpm = track.bpm {
+                lines.append("* BPM: \(String(format: "%.1f", bpm))")
+            }
+            if let key = track.musicalKey {
+                lines.append("* KEY: \(key)")
+            }
+
+            // Gain adjustment
+            if entry.gainAdjustment != 0 {
+                lines.append("* GAIN: \(String(format: "%.1f", entry.gainAdjustment)) dB")
+            }
+
+            lines.append("")
+
+            // Add cue point markers
+            if options.includeCuePoints {
+                for cuePoint in track.cuePoints.sorted() {
+                    let markerTime = formatTimecode(
+                        timelinePosition + cuePoint.timestamp - startOffset,
+                        fps: fps
+                    )
+                    let name = cuePoint.name.isEmpty ? cuePoint.type.rawValue : cuePoint.name
+                    lines.append("* MARKER: \(markerTime) \(name)")
+                }
+                if !track.cuePoints.isEmpty {
+                    lines.append("")
+                }
+            }
+
+            timelinePosition += effectiveDuration
+            if index + 1 < entries.count {
+                timelinePosition -= entries[index + 1].crossfadeDuration
+            }
+        }
+
+        let content = lines.joined(separator: "\n") + "\n"
+        try content.write(to: url, atomically: true, encoding: .utf8)
+    }
+
+    // MARK: - Timecode Formatting
+
+    /// Format seconds as SMPTE timecode HH:MM:SS:FF.
+    private static func formatTimecode(_ seconds: TimeInterval, fps: Double) -> String {
+        let totalSeconds = max(0, seconds)
+        let hours = Int(totalSeconds) / 3600
+        let minutes = (Int(totalSeconds) % 3600) / 60
+        let secs = Int(totalSeconds) % 60
+        let frames = Int((totalSeconds - Double(Int(totalSeconds))) * fps)
+        return String(format: "%02d:%02d:%02d:%02d", hours, minutes, secs, frames)
+    }
+
+    /// Sanitize a track title to a valid reel name (max 8 chars, alphanumeric).
+    private static func sanitizeReelName(_ name: String) -> String {
+        let sanitized = name
+            .components(separatedBy: CharacterSet.alphanumerics.inverted)
+            .joined()
+
+        let truncated = String(sanitized.prefix(8))
+        return truncated.isEmpty ? "REEL001" : truncated.uppercased().padding(toLength: 8, withPad: " ", startingAt: 0)
+    }
+}

+ 54 - 0
Sources/Export/M3UExporter.swift

@@ -0,0 +1,54 @@
+import Foundation
+
+/// Exports a playlist as an M3U/M3U8 playlist file.
+/// M3U is a simple, widely-supported playlist format.
+struct M3UExporter: DAWExporter {
+    static let formatID = "m3u"
+    static let formatName = "M3U Playlist"
+    static let fileExtension = "m3u"
+
+    static func export(playlist: Playlist, to url: URL, options: ExportOptions) throws {
+        let entries = playlist.sortedEntries
+        var lines: [String] = []
+
+        // Extended M3U header
+        lines.append("#EXTM3U")
+        lines.append("#PLAYLIST:\(playlist.name)")
+        lines.append("")
+
+        for entry in entries {
+            guard let track = entry.track, track.hasLocalFile else { continue }
+
+            let duration = Int(track.duration)
+            let displayTitle: String
+            if track.artist.isEmpty {
+                displayTitle = track.title
+            } else {
+                displayTitle = "\(track.artist) - \(track.title)"
+            }
+
+            // EXTINF line: duration, artist - title
+            lines.append("#EXTINF:\(duration),\(displayTitle)")
+
+            // Additional metadata as comments
+            if let bpm = track.bpm {
+                lines.append("#EXTBPM:\(String(format: "%.1f", bpm))")
+            }
+            if let key = track.musicalKey {
+                lines.append("#EXTKEY:\(key)")
+            }
+
+            // File path (relative if copying, absolute otherwise)
+            if options.copyAudioFiles {
+                lines.append("\(options.audioFilesRelativePath)/\(track.fileURL.lastPathComponent)")
+            } else {
+                lines.append(track.filePath)
+            }
+
+            lines.append("")
+        }
+
+        let content = lines.joined(separator: "\n")
+        try content.write(to: url, atomically: true, encoding: .utf8)
+    }
+}

+ 219 - 0
Sources/MixBoardApp.swift

@@ -0,0 +1,219 @@
+import SwiftData
+import SwiftUI
+
+/// MixBoard — A macOS music player and mix preparation tool with DAW export.
+@main
+struct MixBoardApp: App {
+    @State private var playerVM = PlayerViewModel()
+    @State private var playlistVM = PlaylistViewModel()
+    @StateObject private var libraryManager = LibraryManager()
+    @StateObject private var theme = AppTheme()
+    @StateObject private var syncWatcher = SyncWatcher()
+    @ObservedObject private var shortcutConfig = KeyboardShortcutConfig.shared
+
+    var body: some Scene {
+        WindowGroup {
+            ContentView()
+                .environment(playerVM)
+                .environment(playlistVM)
+                .environmentObject(libraryManager)
+                .environmentObject(theme)
+                .environmentObject(syncWatcher)
+                .preferredColorScheme(theme.preferredScheme)
+                .onAppear {
+                    // Faster tooltips (200ms instead of default ~1000ms)
+                    UserDefaults.standard.set(200, forKey: "NSInitialToolTipDelay")
+                    MediaKeyHandler.shared.register(playerVM: playerVM)
+                    syncWatcher.createSyncFolders()
+                    syncWatcher.startWatching()
+                    AppIconConfig.shared.applyIcon()
+                }
+        }
+        .modelContainer(for: [Track.self, CuePoint.self, Playlist.self, PlaylistEntry.self, PlaylistFolder.self])
+        .windowStyle(.titleBar)
+        .windowToolbarStyle(.unified(showsTitle: true))
+        .defaultSize(width: 1200, height: 800)
+        .commands {
+            CommandGroup(replacing: .newItem) {
+                Button("New Playlist...") {
+                    NotificationCenter.default.post(name: .newPlaylist, object: nil)
+                }
+                .keyboardShortcut(
+                    shortcutConfig.binding(for: .newPlaylist).keyEquivalent,
+                    modifiers: shortcutConfig.binding(for: .newPlaylist).eventModifiers
+                )
+            }
+
+            CommandMenu("View") {
+                Button("Now Playing") {
+                    NotificationCenter.default.post(name: .toggleNowPlaying, object: nil)
+                }
+                .keyboardShortcut(
+                    shortcutConfig.binding(for: .nowPlaying).keyEquivalent,
+                    modifiers: shortcutConfig.binding(for: .nowPlaying).eventModifiers
+                )
+            }
+
+            CommandMenu("Playback") {
+                Button("Play / Pause") {
+                    playerVM.togglePlayPause()
+                }
+                .keyboardShortcut(
+                    shortcutConfig.binding(for: .playPause).keyEquivalent,
+                    modifiers: shortcutConfig.binding(for: .playPause).eventModifiers
+                )
+
+                Button("Stop") {
+                    playerVM.stop()
+                }
+                .keyboardShortcut(
+                    shortcutConfig.binding(for: .stop).keyEquivalent,
+                    modifiers: shortcutConfig.binding(for: .stop).eventModifiers
+                )
+
+                Divider()
+
+                Button("Next Track") {
+                    playerVM.playNext()
+                }
+                .keyboardShortcut(
+                    shortcutConfig.binding(for: .nextTrack).keyEquivalent,
+                    modifiers: shortcutConfig.binding(for: .nextTrack).eventModifiers
+                )
+
+                Button("Previous Track") {
+                    playerVM.playPrevious()
+                }
+                .keyboardShortcut(
+                    shortcutConfig.binding(for: .previousTrack).keyEquivalent,
+                    modifiers: shortcutConfig.binding(for: .previousTrack).eventModifiers
+                )
+
+                Divider()
+
+                Button("Skip Forward 10s") {
+                    playerVM.skipForward()
+                }
+                .keyboardShortcut(
+                    shortcutConfig.binding(for: .skipForward).keyEquivalent,
+                    modifiers: shortcutConfig.binding(for: .skipForward).eventModifiers
+                )
+
+                Button("Skip Backward 10s") {
+                    playerVM.skipBackward()
+                }
+                .keyboardShortcut(
+                    shortcutConfig.binding(for: .skipBackward).keyEquivalent,
+                    modifiers: shortcutConfig.binding(for: .skipBackward).eventModifiers
+                )
+
+                Divider()
+
+                Button(playerVM.shuffleEnabled ? "Shuffle: On" : "Shuffle: Off") {
+                    playerVM.shuffleEnabled.toggle()
+                }
+                .keyboardShortcut(
+                    shortcutConfig.binding(for: .toggleShuffle).keyEquivalent,
+                    modifiers: shortcutConfig.binding(for: .toggleShuffle).eventModifiers
+                )
+
+                Button("Repeat: \(playerVM.repeatMode.rawValue)") {
+                    switch playerVM.repeatMode {
+                    case .off: playerVM.repeatMode = .all
+                    case .all: playerVM.repeatMode = .one
+                    case .one: playerVM.repeatMode = .off
+                    }
+                }
+                .keyboardShortcut(
+                    shortcutConfig.binding(for: .toggleRepeat).keyEquivalent,
+                    modifiers: shortcutConfig.binding(for: .toggleRepeat).eventModifiers
+                )
+            }
+
+            CommandMenu("Mix") {
+                // Per-slot quick-add
+                let mixActions: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
+                ForEach(0..<3, id: \.self) { slot in
+                    Button("Add to \(playlistVM.mixTargetName(slot))") {
+                        if let track = playerVM.currentTrack {
+                            NotificationCenter.default.post(
+                                name: .quickAddToMix,
+                                object: nil,
+                                userInfo: ["slot": slot, "track": track]
+                            )
+                        }
+                    }
+                    .keyboardShortcut(
+                        shortcutConfig.binding(for: mixActions[slot]).keyEquivalent,
+                        modifiers: shortcutConfig.binding(for: mixActions[slot]).eventModifiers
+                    )
+                }
+
+                Divider()
+
+                Button("Export to DAW...") {
+                    playlistVM.showExportSheet = true
+                }
+                .keyboardShortcut(
+                    shortcutConfig.binding(for: .exportToDAW).keyEquivalent,
+                    modifiers: shortcutConfig.binding(for: .exportToDAW).eventModifiers
+                )
+
+                Divider()
+
+                Button("Import from iPhone...") {
+                    NotificationCenter.default.post(name: .importFromiPhone, object: nil)
+                }
+                .keyboardShortcut(
+                    shortcutConfig.binding(for: .importFromiPhone).keyEquivalent,
+                    modifiers: shortcutConfig.binding(for: .importFromiPhone).eventModifiers
+                )
+
+                Divider()
+
+                Button("Search All Playlists...") {
+                    NotificationCenter.default.post(name: .globalSearch, object: nil)
+                }
+                .keyboardShortcut(
+                    shortcutConfig.binding(for: .search).keyEquivalent,
+                    modifiers: shortcutConfig.binding(for: .search).eventModifiers
+                )
+            }
+        }
+
+        // Settings (⌘,)
+        Settings {
+            SettingsView()
+                .environment(playlistVM)
+                .environmentObject(theme)
+                .preferredColorScheme(theme.preferredScheme)
+        }
+        .modelContainer(for: [Track.self, CuePoint.self, Playlist.self, PlaylistEntry.self, PlaylistFolder.self])
+
+        // Now Playing window (Tidal-style separate window)
+        Window("Now Playing", id: "now-playing") {
+            NowPlayingView(displayMode: .floating)
+                .environment(playerVM)
+                .environment(playlistVM)
+                .environmentObject(libraryManager)
+                .environmentObject(theme)
+                .preferredColorScheme(theme.preferredScheme)
+        }
+        .defaultSize(width: 850, height: 600)
+        .windowStyle(.titleBar)
+    }
+}
+
+// MARK: - Notification Names
+
+extension Notification.Name {
+    static let newPlaylist = Notification.Name("newPlaylist")
+    static let quickAddToTarget = Notification.Name("quickAddToTarget")
+    static let quickAddToMix = Notification.Name("quickAddToMix")
+    static let globalSearch = Notification.Name("globalSearch")
+    static let importFromiPhone = Notification.Name("importFromiPhone")
+    static let toggleNowPlaying = Notification.Name("toggleNowPlaying")
+    static let popOutNowPlaying = Notification.Name("popOutNowPlaying")
+    static let closeInlineNowPlaying = Notification.Name("closeInlineNowPlaying")
+    static let doubleClickPlayTrack = Notification.Name("doubleClickPlayTrack")
+}

+ 121 - 0
Sources/Models/AppIconConfig.swift

@@ -0,0 +1,121 @@
+import AppKit
+import SwiftUI
+
+/// Manages the app icon color, allowing users to customize the Dock icon tint.
+/// Persists selection to UserDefaults and applies it via NSApplication.shared.applicationIconImage.
+final class AppIconConfig: ObservableObject {
+    static let shared = AppIconConfig()
+
+    /// Available icon color options, matching the iOS app.
+    struct IconColorOption: Identifiable {
+        let id: String // name
+        let name: String
+        let color: Color
+        let nsColor: NSColor
+
+        init(_ name: String, _ color: Color, _ nsColor: NSColor) {
+            self.id = name
+            self.name = name
+            self.color = color
+            self.nsColor = nsColor
+        }
+    }
+
+    static let iconColors: [IconColorOption] = [
+        IconColorOption("Default", .green, NSColor(red: 0.35, green: 0.85, blue: 0.25, alpha: 1)),
+        IconColorOption("Green", Color(red: 0.35, green: 0.85, blue: 0.25), NSColor(red: 0.35, green: 0.85, blue: 0.25, alpha: 1)),
+        IconColorOption("Lime", Color(red: 0.55, green: 0.95, blue: 0.15), NSColor(red: 0.55, green: 0.95, blue: 0.15, alpha: 1)),
+        IconColorOption("Cyan", Color(red: 0.15, green: 0.85, blue: 0.85), NSColor(red: 0.15, green: 0.85, blue: 0.85, alpha: 1)),
+        IconColorOption("Blue", Color(red: 0.25, green: 0.45, blue: 0.95), NSColor(red: 0.25, green: 0.45, blue: 0.95, alpha: 1)),
+        IconColorOption("Purple", Color(red: 0.6, green: 0.3, blue: 0.9), NSColor(red: 0.6, green: 0.3, blue: 0.9, alpha: 1)),
+        IconColorOption("Pink", Color(red: 0.95, green: 0.3, blue: 0.6), NSColor(red: 0.95, green: 0.3, blue: 0.6, alpha: 1)),
+        IconColorOption("Red", Color(red: 0.95, green: 0.25, blue: 0.25), NSColor(red: 0.95, green: 0.25, blue: 0.25, alpha: 1)),
+        IconColorOption("Orange", Color(red: 0.95, green: 0.55, blue: 0.15), NSColor(red: 0.95, green: 0.55, blue: 0.15, alpha: 1)),
+        IconColorOption("Gold", Color(red: 0.95, green: 0.8, blue: 0.15), NSColor(red: 0.95, green: 0.8, blue: 0.15, alpha: 1)),
+        IconColorOption("White", Color(red: 0.9, green: 0.9, blue: 0.92), NSColor(red: 0.9, green: 0.9, blue: 0.92, alpha: 1)),
+    ]
+
+    @Published var selectedColorName: String {
+        didSet {
+            UserDefaults.standard.set(selectedColorName, forKey: "appIconColorName")
+            applyIcon()
+        }
+    }
+
+    private init() {
+        self.selectedColorName = UserDefaults.standard.string(forKey: "appIconColorName") ?? "Default"
+    }
+
+    /// The currently selected color option.
+    var selectedOption: IconColorOption {
+        Self.iconColors.first(where: { $0.name == selectedColorName }) ?? Self.iconColors[0]
+    }
+
+    /// Apply the selected icon color to the Dock icon.
+    func applyIcon() {
+        if selectedColorName == "Default" {
+            // Reset to the bundle icon
+            NSApplication.shared.applicationIconImage = nil
+            return
+        }
+
+        guard let option = Self.iconColors.first(where: { $0.name == selectedColorName }) else { return }
+        guard let tintedIcon = generateTintedIcon(color: option.nsColor) else { return }
+        NSApplication.shared.applicationIconImage = tintedIcon
+    }
+
+    /// Generate the app icon tinted with the given color.
+    /// Loads the default icon from the bundle, desaturates it, and applies the new color.
+    private func generateTintedIcon(color: NSColor) -> NSImage? {
+        // Load the high-res icon from the asset catalog
+        guard let bundleIcon = loadBundleIcon() else { return nil }
+        guard let cgImage = bundleIcon.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
+
+        let width = cgImage.width
+        let height = cgImage.height
+
+        guard let context = CGContext(
+            data: nil,
+            width: width,
+            height: height,
+            bitsPerComponent: 8,
+            bytesPerRow: 0,
+            space: CGColorSpaceCreateDeviceRGB(),
+            bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+        ) else { return nil }
+
+        let rect = CGRect(x: 0, y: 0, width: width, height: height)
+
+        // 1) Draw the original icon (to get its luminance structure)
+        context.draw(cgImage, in: rect)
+
+        // 2) Apply color overlay with .color blend mode (preserves luminance, applies hue+saturation)
+        context.setBlendMode(.color)
+        context.setFillColor(color.cgColor)
+        context.fill(rect)
+
+        // 3) Clip to original alpha by drawing original with .destinationIn
+        context.setBlendMode(.destinationIn)
+        context.draw(cgImage, in: rect)
+
+        guard let resultCG = context.makeImage() else { return nil }
+        let result = NSImage(cgImage: resultCG, size: NSSize(width: width, height: height))
+        return result
+    }
+
+    /// Load the 1024px icon from the app bundle's asset catalog.
+    private func loadBundleIcon() -> NSImage? {
+        // Try loading from bundle resources directly
+        if let url = Bundle.main.url(forResource: "icon_1024", withExtension: "png"),
+           let img = NSImage(contentsOf: url) {
+            return img
+        }
+        // Fallback: use the app icon from the bundle
+        if let iconName = Bundle.main.infoDictionary?["CFBundleIconFile"] as? String,
+           let img = NSImage(named: iconName) {
+            return img
+        }
+        // Final fallback: NSApp icon
+        return NSApp.applicationIconImage
+    }
+}

+ 60 - 0
Sources/Models/AppState.swift

@@ -0,0 +1,60 @@
+import Foundation
+
+/// Persists app state (last playlist, last track, playback position) to UserDefaults.
+struct AppState {
+    private static let defaults = UserDefaults.standard
+
+    private static let keyLastPlaylistID = "appState.lastPlaylistID"
+    private static let keyLastEntryID = "appState.lastEntryID"
+    private static let keyLastTrackFilePath = "appState.lastTrackFilePath"
+    private static let keyLastPlaybackTime = "appState.lastPlaybackTime"
+
+    // MARK: - Save
+
+    static func saveLastPlaylist(id: UUID) {
+        defaults.set(id.uuidString, forKey: keyLastPlaylistID)
+    }
+
+    static func saveLastEntry(id: UUID) {
+        defaults.set(id.uuidString, forKey: keyLastEntryID)
+    }
+
+    static func saveLastTrack(filePath: String) {
+        defaults.set(filePath, forKey: keyLastTrackFilePath)
+    }
+
+    static func savePlaybackTime(_ time: TimeInterval) {
+        defaults.set(time, forKey: keyLastPlaybackTime)
+    }
+
+    /// Save all current playback state at once.
+    static func savePlaybackState(
+        playlistID: UUID?,
+        entryID: UUID?,
+        trackFilePath: String?,
+        playbackTime: TimeInterval
+    ) {
+        if let id = playlistID { saveLastPlaylist(id: id) }
+        if let id = entryID { saveLastEntry(id: id) }
+        if let path = trackFilePath { saveLastTrack(filePath: path) }
+        savePlaybackTime(playbackTime)
+    }
+
+    // MARK: - Load
+
+    static var lastPlaylistID: UUID? {
+        defaults.string(forKey: keyLastPlaylistID).flatMap { UUID(uuidString: $0) }
+    }
+
+    static var lastEntryID: UUID? {
+        defaults.string(forKey: keyLastEntryID).flatMap { UUID(uuidString: $0) }
+    }
+
+    static var lastTrackFilePath: String? {
+        defaults.string(forKey: keyLastTrackFilePath)
+    }
+
+    static var lastPlaybackTime: TimeInterval {
+        defaults.double(forKey: keyLastPlaybackTime)
+    }
+}

+ 341 - 0
Sources/Models/AppTheme.swift

@@ -0,0 +1,341 @@
+import SwiftUI
+
+/// Centralized theme system for MixBoard.
+/// Supports retro and modern skins inspired by classic players.
+final class AppTheme: ObservableObject {
+
+    // MARK: - Available Skins
+
+    enum Skin: String, CaseIterable, Identifiable, Codable {
+        // Modern
+        case dark       = "Dark"
+        case midnight   = "Midnight"
+        case forest     = "Forest"
+        case ocean      = "Ocean"
+        case warm       = "Warm"
+        case light      = "Light"
+        // Retro
+        case winampClassic = "Winamp Classic"
+        case winampModern  = "Winamp Modern"
+        case foobarDark    = "foobar2000 Dark"
+        case foobarLight   = "foobar2000 Light"
+        case win95        = "Windows 95"
+        case win98        = "Windows 98"
+        case xpLuna       = "XP Luna"
+        case macOSClassic = "Mac OS 9"
+
+        var id: String { rawValue }
+
+        /// Whether this skin requires dark or light appearance.
+        var colorScheme: ColorScheme {
+            switch self {
+            case .dark, .midnight, .forest, .ocean, .warm,
+                 .winampClassic, .winampModern, .foobarDark:
+                return .dark
+            case .light, .foobarLight, .win95, .win98, .xpLuna, .macOSClassic:
+                return .light
+            }
+        }
+    }
+
+    // MARK: - Published
+
+    @Published var currentSkin: Skin {
+        didSet {
+            applySkin(currentSkin)
+            UserDefaults.standard.set(currentSkin.rawValue, forKey: "appThemeSkin")
+        }
+    }
+
+    /// The color scheme the current skin requires (dark or light).
+    var preferredScheme: ColorScheme {
+        currentSkin.colorScheme
+    }
+
+    // MARK: - Colors
+
+    @Published var accent: Color = .green
+    @Published var seekbarBackground: Color = Color.gray.opacity(0.3)
+    @Published var seekbarForeground: Color = .green
+    @Published var waveformBackground: Color = Color.gray.opacity(0.25)
+    @Published var waveformForeground: Color = Color(red: 0.2, green: 0.7, blue: 0.3)
+    @Published var waveformSeparator: Color = Color.black.opacity(0.6)
+    @Published var playerBarBackground: Color = Color(nsColor: .controlBackgroundColor)
+    @Published var toolbarBackground: Color = Color(nsColor: .controlBackgroundColor)
+    @Published var columnHeaderBackground: Color = Color(nsColor: .controlBackgroundColor)
+    @Published var primaryText: Color = .primary
+    @Published var secondaryText: Color = .secondary
+    @Published var tertiaryText: Color = Color.gray.opacity(0.5)
+    @Published var playingHighlight: Color = .green
+    @Published var groupHeaderText: Color = .primary
+
+    // MARK: - Sizes
+
+    @Published var seekbarHeight: CGFloat = 8
+    @Published var playerBarHeight: CGFloat = 32
+    @Published var rowHeight: CGFloat = 22
+    @Published var dataFontSize: CGFloat = 11
+    @Published var smallFontSize: CGFloat = 9
+
+    // MARK: - Init
+
+    init() {
+        let saved = UserDefaults.standard.string(forKey: "appThemeSkin")
+        // Migrate legacy "foobar2000" → "foobar2000 Dark"
+        let skinName = (saved == "foobar2000") ? "foobar2000 Dark" : saved
+        let skin = skinName.flatMap { Skin(rawValue: $0) } ?? .dark
+        self.currentSkin = skin
+        applySkin(skin)
+    }
+
+    // MARK: - Apply Skin
+
+    private func applySkin(_ skin: Skin) {
+        // Reset to defaults first
+        // Base sizes — readable defaults
+        seekbarHeight = 8
+        playerBarHeight = 36
+        rowHeight = 24
+        dataFontSize = 13
+        smallFontSize = 11
+        playerBarBackground = Color(nsColor: .controlBackgroundColor)
+        toolbarBackground = Color(nsColor: .controlBackgroundColor)
+        columnHeaderBackground = Color(nsColor: .controlBackgroundColor)
+
+        switch skin {
+
+        // ── Modern Skins ──────────────────────────────────
+
+        case .dark:
+            accent = Color(red: 0.3, green: 0.85, blue: 0.4)
+            seekbarBackground = Color(red: 0.15, green: 0.25, blue: 0.35)
+            seekbarForeground = Color(red: 0.3, green: 0.85, blue: 0.4)
+            waveformBackground = Color(red: 0.12, green: 0.15, blue: 0.22)
+            waveformForeground = Color(red: 0.3, green: 0.7, blue: 1.0)
+            waveformSeparator = Color.black.opacity(0.7)
+            playingHighlight = Color(red: 0.3, green: 0.85, blue: 0.4)
+            primaryText = Color.white
+            secondaryText = Color.white.opacity(0.75)
+            tertiaryText = Color.white.opacity(0.5)
+            groupHeaderText = Color.white
+
+        case .midnight:
+            accent = Color(red: 0.5, green: 0.6, blue: 1.0)
+            seekbarBackground = Color(red: 0.2, green: 0.1, blue: 0.15)
+            seekbarForeground = Color(red: 0.5, green: 0.6, blue: 1.0)
+            waveformBackground = Color(red: 0.15, green: 0.08, blue: 0.12)
+            waveformForeground = Color(red: 0.9, green: 0.3, blue: 0.6)
+            waveformSeparator = Color.black.opacity(0.7)
+            playingHighlight = Color(red: 0.5, green: 0.6, blue: 1.0)
+            primaryText = Color.white
+            secondaryText = Color.white.opacity(0.75)
+            tertiaryText = Color.white.opacity(0.45)
+            groupHeaderText = Color.white
+
+        case .forest:
+            accent = Color(red: 0.35, green: 0.85, blue: 0.35)
+            seekbarBackground = Color(red: 0.25, green: 0.2, blue: 0.1)
+            seekbarForeground = Color(red: 0.35, green: 0.85, blue: 0.35)
+            waveformBackground = Color(red: 0.18, green: 0.14, blue: 0.06)
+            waveformForeground = Color(red: 0.95, green: 0.75, blue: 0.2)
+            waveformSeparator = Color.black.opacity(0.7)
+            playingHighlight = Color(red: 0.35, green: 0.85, blue: 0.35)
+            primaryText = Color.white
+            secondaryText = Color.white.opacity(0.7)
+            tertiaryText = Color.white.opacity(0.45)
+            groupHeaderText = Color.white
+
+        case .ocean:
+            accent = Color(red: 0.3, green: 0.8, blue: 0.95)
+            seekbarBackground = Color(red: 0.2, green: 0.12, blue: 0.1)
+            seekbarForeground = Color(red: 0.3, green: 0.8, blue: 0.95)
+            waveformBackground = Color(red: 0.15, green: 0.08, blue: 0.06)
+            waveformForeground = Color(red: 1.0, green: 0.45, blue: 0.3)
+            waveformSeparator = Color.black.opacity(0.7)
+            playingHighlight = Color(red: 0.3, green: 0.8, blue: 0.95)
+            primaryText = Color.white
+            secondaryText = Color.white.opacity(0.75)
+            tertiaryText = Color.white.opacity(0.45)
+            groupHeaderText = Color.white
+
+        case .warm:
+            accent = Color(red: 1.0, green: 0.65, blue: 0.25)
+            seekbarBackground = Color(red: 0.1, green: 0.18, blue: 0.2)
+            seekbarForeground = Color(red: 1.0, green: 0.65, blue: 0.25)
+            waveformBackground = Color(red: 0.06, green: 0.12, blue: 0.15)
+            waveformForeground = Color(red: 0.2, green: 0.8, blue: 0.75)
+            waveformSeparator = Color.black.opacity(0.7)
+            playingHighlight = Color(red: 1.0, green: 0.65, blue: 0.25)
+            primaryText = Color.white
+            secondaryText = Color.white.opacity(0.75)
+            tertiaryText = Color.white.opacity(0.45)
+            groupHeaderText = Color.white
+
+        case .light:
+            accent = Color(red: 0.15, green: 0.45, blue: 0.85)
+            seekbarBackground = Color(red: 0.92, green: 0.88, blue: 0.85)
+            seekbarForeground = Color(red: 0.15, green: 0.45, blue: 0.85)
+            waveformBackground = Color(red: 0.94, green: 0.9, blue: 0.88)
+            waveformForeground = Color(red: 0.85, green: 0.3, blue: 0.15)
+            waveformSeparator = Color.black.opacity(0.15)
+            playingHighlight = Color(red: 0.15, green: 0.45, blue: 0.85)
+            playerBarBackground = Color(red: 0.95, green: 0.95, blue: 0.96)
+            toolbarBackground = Color(red: 0.95, green: 0.95, blue: 0.96)
+            columnHeaderBackground = Color(red: 0.92, green: 0.92, blue: 0.93)
+            primaryText = Color.black
+            secondaryText = Color(red: 0.25, green: 0.25, blue: 0.3)
+            tertiaryText = Color(red: 0.5, green: 0.5, blue: 0.55)
+            groupHeaderText = Color.black
+
+        // ── Retro Skins ──────────────────────────────────
+
+        case .winampClassic:
+            // Classic Winamp: dark background, neon green text, dark chrome
+            accent = Color(red: 0.0, green: 1.0, blue: 0.0)               // #00FF00
+            seekbarBackground = Color(red: 0.15, green: 0.15, blue: 0.15)
+            seekbarForeground = Color(red: 0.0, green: 1.0, blue: 0.0)
+            waveformBackground = Color(red: 0.05, green: 0.05, blue: 0.05)
+            waveformForeground = Color(red: 1.0, green: 0.9, blue: 0.0)
+            waveformSeparator = Color(red: 0.0, green: 0.3, blue: 0.0)
+            playingHighlight = Color(red: 0.0, green: 1.0, blue: 0.0)
+            playerBarBackground = Color(red: 0.12, green: 0.12, blue: 0.14)
+            toolbarBackground = Color(red: 0.12, green: 0.12, blue: 0.14)
+            columnHeaderBackground = Color(red: 0.1, green: 0.1, blue: 0.12)
+            primaryText = Color(red: 0.0, green: 0.9, blue: 0.0)          // green text
+            secondaryText = Color(red: 0.0, green: 0.7, blue: 0.0)
+            tertiaryText = Color(red: 0.0, green: 0.4, blue: 0.0)
+            groupHeaderText = Color(red: 0.0, green: 1.0, blue: 0.0)
+            seekbarHeight = 6
+            dataFontSize = 11
+
+        case .winampModern:
+            // Winamp Modern/Bento: dark blue-gray, orange accent
+            accent = Color(red: 1.0, green: 0.55, blue: 0.0)              // orange
+            seekbarBackground = Color(red: 0.15, green: 0.17, blue: 0.22)
+            seekbarForeground = Color(red: 1.0, green: 0.55, blue: 0.0)
+            waveformBackground = Color(red: 0.08, green: 0.09, blue: 0.12)
+            waveformForeground = Color(red: 0.3, green: 0.85, blue: 0.9)
+            waveformSeparator = Color(red: 0.25, green: 0.15, blue: 0.0)
+            playingHighlight = Color(red: 1.0, green: 0.55, blue: 0.0)
+            playerBarBackground = Color(red: 0.13, green: 0.14, blue: 0.18)
+            toolbarBackground = Color(red: 0.13, green: 0.14, blue: 0.18)
+            columnHeaderBackground = Color(red: 0.11, green: 0.12, blue: 0.16)
+            primaryText = Color(red: 0.85, green: 0.85, blue: 0.9)
+            secondaryText = Color(red: 0.6, green: 0.6, blue: 0.65)
+            tertiaryText = Color(red: 0.4, green: 0.4, blue: 0.45)
+            groupHeaderText = Color(red: 1.0, green: 0.55, blue: 0.0)
+
+        case .foobarDark:
+            // foobar2000 Dark: dark gray background, light text, minimal chrome
+            accent = Color(red: 0.35, green: 0.55, blue: 0.85)            // muted blue
+            seekbarBackground = Color(red: 0.25, green: 0.25, blue: 0.25)
+            seekbarForeground = Color(red: 0.35, green: 0.55, blue: 0.85)
+            waveformBackground = Color(red: 0.1, green: 0.1, blue: 0.12)
+            waveformForeground = Color(red: 0.9, green: 0.65, blue: 0.2)
+            waveformSeparator = Color(red: 0.18, green: 0.18, blue: 0.25)
+            playingHighlight = Color(red: 0.35, green: 0.55, blue: 0.85)
+            playerBarBackground = Color(red: 0.14, green: 0.14, blue: 0.14)
+            toolbarBackground = Color(red: 0.14, green: 0.14, blue: 0.14)
+            columnHeaderBackground = Color(red: 0.16, green: 0.16, blue: 0.16)
+            primaryText = Color(red: 0.85, green: 0.85, blue: 0.85)
+            secondaryText = Color(red: 0.6, green: 0.6, blue: 0.6)
+            tertiaryText = Color(red: 0.4, green: 0.4, blue: 0.4)
+            groupHeaderText = Color(red: 0.85, green: 0.85, blue: 0.85)
+            rowHeight = 18
+            dataFontSize = 11
+
+        case .foobarLight:
+            // foobar2000 Light: classic foobar — white/light gray, minimal, system colors
+            accent = Color(red: 0.0, green: 0.0, blue: 0.5)               // navy selection
+            seekbarBackground = Color(red: 0.85, green: 0.85, blue: 0.85)
+            seekbarForeground = Color(red: 0.0, green: 0.0, blue: 0.6)
+            waveformBackground = Color(red: 0.93, green: 0.93, blue: 0.95)
+            waveformForeground = Color(red: 0.7, green: 0.15, blue: 0.1)
+            waveformSeparator = Color(red: 0.75, green: 0.75, blue: 0.75)
+            playingHighlight = Color(red: 0.0, green: 0.0, blue: 0.5)
+            playerBarBackground = Color(red: 0.94, green: 0.94, blue: 0.94)
+            toolbarBackground = Color(red: 0.94, green: 0.94, blue: 0.94)
+            columnHeaderBackground = Color(red: 0.9, green: 0.9, blue: 0.9)
+            primaryText = Color.black
+            secondaryText = Color(red: 0.3, green: 0.3, blue: 0.3)
+            tertiaryText = Color(red: 0.55, green: 0.55, blue: 0.55)
+            groupHeaderText = Color.black
+            rowHeight = 18
+            dataFontSize = 11
+
+        case .win95:
+            // Windows 95: classic silver/gray, teal highlights, 3D beveled look
+            accent = Color(red: 0.0, green: 0.0, blue: 0.5)               // navy blue
+            seekbarBackground = Color(red: 0.75, green: 0.75, blue: 0.75)
+            seekbarForeground = Color(red: 0.0, green: 0.0, blue: 0.5)
+            waveformBackground = Color(red: 0.65, green: 0.65, blue: 0.65)
+            waveformForeground = Color(red: 0.0, green: 0.5, blue: 0.5)
+            waveformSeparator = Color(red: 0.5, green: 0.5, blue: 0.5)
+            playingHighlight = Color(red: 0.0, green: 0.0, blue: 0.5)
+            playerBarBackground = Color(red: 0.75, green: 0.75, blue: 0.75)  // classic gray
+            toolbarBackground = Color(red: 0.75, green: 0.75, blue: 0.75)
+            columnHeaderBackground = Color(red: 0.8, green: 0.8, blue: 0.8)
+            primaryText = Color.black
+            secondaryText = Color(red: 0.25, green: 0.25, blue: 0.25)
+            tertiaryText = Color(red: 0.5, green: 0.5, blue: 0.5)
+            groupHeaderText = Color.black
+            dataFontSize = 11
+            seekbarHeight = 10
+
+        case .win98:
+            // Windows 98/2000: slightly softer than 95, classic blue title bars
+            accent = Color(red: 0.0, green: 0.0, blue: 0.65)
+            seekbarBackground = Color(red: 0.82, green: 0.82, blue: 0.82)
+            seekbarForeground = Color(red: 0.0, green: 0.27, blue: 0.65)
+            waveformBackground = Color(red: 0.72, green: 0.72, blue: 0.72)
+            waveformForeground = Color(red: 0.0, green: 0.5, blue: 0.25)
+            waveformSeparator = Color(red: 0.55, green: 0.55, blue: 0.55)
+            playingHighlight = Color(red: 0.0, green: 0.0, blue: 0.65)
+            playerBarBackground = Color(red: 0.83, green: 0.82, blue: 0.78)  // warm gray
+            toolbarBackground = Color(red: 0.83, green: 0.82, blue: 0.78)
+            columnHeaderBackground = Color(red: 0.87, green: 0.86, blue: 0.82)
+            primaryText = Color.black
+            secondaryText = Color(red: 0.2, green: 0.2, blue: 0.2)
+            tertiaryText = Color(red: 0.45, green: 0.45, blue: 0.45)
+            groupHeaderText = Color(red: 0.0, green: 0.0, blue: 0.4)
+            seekbarHeight = 10
+
+        case .xpLuna:
+            // Windows XP Luna: blue toolbar, olive/silver accents, rounded
+            accent = Color(red: 0.22, green: 0.44, blue: 0.87)            // XP blue
+            seekbarBackground = Color(red: 0.88, green: 0.9, blue: 0.94)
+            seekbarForeground = Color(red: 0.22, green: 0.44, blue: 0.87)
+            waveformBackground = Color(red: 0.82, green: 0.85, blue: 0.92)
+            waveformForeground = Color(red: 0.1, green: 0.6, blue: 0.3)
+            waveformSeparator = Color(red: 0.7, green: 0.73, blue: 0.82)
+            playingHighlight = Color(red: 0.22, green: 0.44, blue: 0.87)
+            playerBarBackground = Color(red: 0.92, green: 0.93, blue: 0.96) // XP light blue
+            toolbarBackground = Color(red: 0.85, green: 0.89, blue: 0.95)
+            columnHeaderBackground = Color(red: 0.88, green: 0.91, blue: 0.96)
+            primaryText = Color.black
+            secondaryText = Color(red: 0.2, green: 0.2, blue: 0.3)
+            tertiaryText = Color(red: 0.45, green: 0.45, blue: 0.55)
+            groupHeaderText = Color(red: 0.1, green: 0.2, blue: 0.6)
+            seekbarHeight = 10
+
+        case .macOSClassic:
+            // Classic Mac OS 9: platinum gray, black text, pinstripes
+            accent = Color(red: 0.0, green: 0.0, blue: 0.6)
+            seekbarBackground = Color(red: 0.8, green: 0.8, blue: 0.8)
+            seekbarForeground = Color(red: 0.3, green: 0.3, blue: 0.85)
+            waveformBackground = Color(red: 0.75, green: 0.75, blue: 0.75)
+            waveformForeground = Color(red: 0.7, green: 0.3, blue: 0.1)
+            waveformSeparator = Color(red: 0.6, green: 0.6, blue: 0.6)
+            playingHighlight = Color(red: 0.3, green: 0.3, blue: 0.85)
+            playerBarBackground = Color(red: 0.86, green: 0.86, blue: 0.86) // platinum
+            toolbarBackground = Color(red: 0.86, green: 0.86, blue: 0.86)
+            columnHeaderBackground = Color(red: 0.9, green: 0.9, blue: 0.9)
+            primaryText = Color.black
+            secondaryText = Color(red: 0.2, green: 0.2, blue: 0.2)
+            tertiaryText = Color(red: 0.5, green: 0.5, blue: 0.5)
+            groupHeaderText = Color.black
+            dataFontSize = 12
+            seekbarHeight = 8
+        }
+    }
+}

+ 158 - 0
Sources/Models/ChadMusic.swift

@@ -0,0 +1,158 @@
+import CoreTransferable
+import Foundation
+import UniformTypeIdentifiers
+
+// MARK: - Custom UTTypes for drag-and-drop
+
+extension UTType {
+    static let chadTrack = UTType(exportedAs: "com.mixboard.chad-track")
+    static let chadAlbum = UTType(exportedAs: "com.mixboard.chad-album")
+}
+
+// MARK: - Chad Music API Response Models
+
+/// Navigation value for drilling into a category (e.g., artist "Pink Floyd" → show their albums).
+struct CategoryFilter: Hashable {
+    let category: ChadCategoryType
+    let value: String
+}
+
+/// A category entry from GET /api/cat/:category (e.g., album, artist, genre).
+/// Server returns: {"item": "Rock", "count": 42}
+struct ChadCategory: Codable, Identifiable, Hashable {
+    let item: String
+    let count: Int?
+
+    // No server-side ID — use item name as identity
+    var id: String { item }
+    var name: String { item }
+}
+
+/// An album from the Chad Music API.
+/// Server keys: id, artist, year, album (title), publisher, country, genre, type, status, mb_id, track_count, total_duration, cover
+struct ChadAlbum: Codable, Identifiable, Hashable {
+    let id: String
+    let album: String?         // server can return null for some releases
+    let artist: String?
+    let year: Int?
+    let genre: String?
+    let trackCount: Int?
+    let cover: String?
+    let publisher: String?
+    let country: String?
+    let type: String?
+    let status: String?
+    let totalDuration: Double?
+    let originalDate: String?
+    let mbId: String?
+
+    /// Display-friendly title.
+    var title: String { album ?? "Untitled" }
+
+    enum CodingKeys: String, CodingKey {
+        case id, album, artist, year, genre, cover, publisher, country
+        case trackCount = "track_count"
+        case totalDuration = "total_duration"
+        case originalDate = "original_date"
+        case mbId = "mb_id"
+        case type, status
+    }
+}
+
+/// A track from GET /api/album/:id/tracks.
+/// Server keys: id, artist, album_artist, album, year, no (track#), title, bit_rate, duration, url, cover
+struct ChadTrack: Codable, Identifiable, Hashable {
+    let id: String
+    let title: String
+    let artist: String?
+    let albumArtist: String?
+    let album: String?
+    let duration: Double?
+    let no: Int?              // server calls track number "no"
+    let url: String           // relative path for streaming
+    let bitRate: Int?
+    let year: Int?
+    let cover: String?
+
+    /// Track number for display.
+    var trackNumber: Int? { no }
+
+    enum CodingKeys: String, CodingKey {
+        case id, title, artist, album, duration, no, url, year, cover
+        case albumArtist = "album_artist"
+        case bitRate = "bit_rate"
+    }
+
+    /// Human-readable duration string.
+    var formattedDuration: String {
+        guard let duration else { return "—" }
+        let total = Int(duration)
+        let minutes = total / 60
+        let seconds = total % 60
+        return String(format: "%d:%02d", minutes, seconds)
+    }
+}
+
+extension ChadTrack: Transferable {
+    static var transferRepresentation: some TransferRepresentation {
+        CodableRepresentation(contentType: .chadTrack)
+    }
+}
+
+extension ChadAlbum: Transferable {
+    static var transferRepresentation: some TransferRepresentation {
+        CodableRepresentation(contentType: .chadAlbum)
+    }
+}
+
+/// The album-tracks endpoint returns a plain array of tracks (not a wrapper object).
+/// We decode as [ChadTrack] directly in the API client.
+
+/// Library statistics from GET /api/stats.
+/// Server keys: tracks, albums, artists, duration (formatted string), rescans
+struct ChadStats: Codable {
+    let tracks: Int?
+    let albums: Int?
+    let artists: Int?
+    let duration: String?     // server returns pre-formatted string like "3d 14h 22m"
+}
+
+/// The browsable category types exposed by the API.
+enum ChadCategoryType: String, CaseIterable, Identifiable {
+    case album
+    case artist
+    case genre
+    case year
+    case publisher
+    case country
+    case type
+    case status
+
+    var id: String { rawValue }
+
+    var displayName: String {
+        switch self {
+        case .album: "Albums"
+        case .artist: "Artists"
+        case .genre: "Genres"
+        case .year: "Years"
+        case .publisher: "Publishers"
+        case .country: "Countries"
+        case .type: "Types"
+        case .status: "Status"
+        }
+    }
+
+    var icon: String {
+        switch self {
+        case .album: "square.stack"
+        case .artist: "person.2"
+        case .genre: "guitars"
+        case .year: "calendar"
+        case .publisher: "building.2"
+        case .country: "globe"
+        case .type: "tag"
+        case .status: "checkmark.circle"
+        }
+    }
+}

+ 67 - 0
Sources/Models/CuePoint.swift

@@ -0,0 +1,67 @@
+import Foundation
+import SwiftData
+
+/// A cue point (marker) on a track — used for marking sections, transitions, etc.
+@Model
+final class CuePoint: Comparable {
+    var id: UUID = UUID()
+    var name: String = ""
+    var timestamp: TimeInterval = 0    // position in seconds
+    var endTimestamp: TimeInterval?     // optional end for regions
+    var color: String = "#FF5722"     // hex color for visual display
+    var type: CuePointType = CuePointType.marker
+    var notes: String = ""
+
+    var track: Track?
+
+    /// Whether this is a region (has end point) vs. a point marker.
+    var isRegion: Bool {
+        endTimestamp != nil
+    }
+
+    var formattedTimestamp: String {
+        CuePoint.formatTime(timestamp)
+    }
+
+    init(
+        name: String = "",
+        timestamp: TimeInterval,
+        endTimestamp: TimeInterval? = nil,
+        color: String = "#FF5722",
+        type: CuePointType = .marker,
+        notes: String = ""
+    ) {
+        self.id = UUID()
+        self.name = name
+        self.timestamp = timestamp
+        self.endTimestamp = endTimestamp
+        self.color = color
+        self.type = type
+        self.notes = notes
+    }
+
+    static func < (lhs: CuePoint, rhs: CuePoint) -> Bool {
+        lhs.timestamp < rhs.timestamp
+    }
+
+    static func formatTime(_ time: TimeInterval) -> String {
+        let minutes = Int(time) / 60
+        let seconds = Int(time) % 60
+        let millis = Int((time.truncatingRemainder(dividingBy: 1)) * 1000)
+        return String(format: "%02d:%02d.%03d", minutes, seconds, millis)
+    }
+}
+
+enum CuePointType: String, Codable, CaseIterable {
+    case marker     = "Marker"
+    case intro      = "Intro"
+    case outro      = "Outro"
+    case drop       = "Drop"
+    case breakdown  = "Breakdown"
+    case verse      = "Verse"
+    case chorus     = "Chorus"
+    case transition = "Transition"
+    case loop       = "Loop"
+    case fadeIn     = "Fade In"
+    case fadeOut    = "Fade Out"
+}

+ 110 - 0
Sources/Models/FileNameTemplate.swift

@@ -0,0 +1,110 @@
+import Foundation
+
+/// Generates filenames from audio metadata using configurable templates.
+///
+/// Template variables (enclosed in `{}`):
+///   {artist}      - Track artist
+///   {album}       - Album name
+///   {title}       - Track title
+///   {genre}       - Genre
+///   {bpm}         - BPM (rounded)
+///   {key}         - Musical key
+///   {year}        - Release year from metadata
+///   {track}       - Track number in playlist (zero-padded)
+///   {duration}    - Duration as M:SS
+///   {format}      - File format extension
+///   {samplerate}  - Sample rate
+///   {bitdepth}    - Bit depth
+///
+/// Example: "{artist} - {album} - {title}" → "Raekwon - Only Built 4 Cuban Linx - Spot Rusherz"
+struct FileNameTemplate {
+
+    /// Default template
+    static let defaultTemplate = "{artist} - {album} - {title}"
+
+    /// All available template variables with descriptions.
+    static let availableVariables: [(token: String, description: String)] = [
+        ("{track}", "Track number in playlist (zero-padded)"),
+        ("{artist}", "Artist name"),
+        ("{album}", "Album name"),
+        ("{title}", "Track title"),
+        ("{genre}", "Genre"),
+        ("{bpm}", "BPM (rounded)"),
+        ("{key}", "Musical key"),
+        ("{year}", "Release year"),
+        ("{duration}", "Duration (M:SS)"),
+        ("{format}", "File format (MP3, WAV, etc.)"),
+        ("{samplerate}", "Sample rate (e.g. 44100)"),
+        ("{bitdepth}", "Bit depth (e.g. 16)"),
+    ]
+
+    /// Some common presets.
+    static let presets: [(name: String, template: String)] = [
+        ("Artist - Album - Title", "{artist} - {album} - {title}"),
+        ("## Artist - Title", "{track} {artist} - {title}"),
+        ("## Title", "{track} {title}"),
+        ("Artist - Title [BPM Key]", "{artist} - {title} [{bpm} {key}]"),
+        ("Album - ## Title", "{album} - {track} {title}"),
+        ("Artist - Title (Format)", "{artist} - {title} ({format})"),
+    ]
+
+    /// Generate a filename from a template string and track metadata.
+    static func generate(
+        template: String,
+        track: Track,
+        playlistIndex: Int,
+        totalTracks: Int
+    ) -> String {
+        let padWidth = totalTracks >= 100 ? 3 : 2
+
+        var result = template
+        result = result.replacingOccurrences(of: "{track}", with: String(format: "%0\(padWidth)d", playlistIndex + 1))
+        result = result.replacingOccurrences(of: "{artist}", with: track.artist.isEmpty ? "Unknown Artist" : track.artist)
+        result = result.replacingOccurrences(of: "{album}", with: track.album.isEmpty ? "Unknown Album" : track.album)
+        result = result.replacingOccurrences(of: "{title}", with: track.title)
+        result = result.replacingOccurrences(of: "{genre}", with: track.genre.isEmpty ? "Unknown" : track.genre)
+        result = result.replacingOccurrences(of: "{bpm}", with: track.bpm.map { String(format: "%.0f", $0) } ?? "")
+        result = result.replacingOccurrences(of: "{key}", with: track.musicalKey ?? "")
+        result = result.replacingOccurrences(of: "{year}", with: track.year.map { String($0) } ?? "")
+        result = result.replacingOccurrences(of: "{duration}", with: track.formattedDuration)
+        result = result.replacingOccurrences(of: "{format}", with: track.fileFormat)
+        result = result.replacingOccurrences(of: "{samplerate}", with: "\(Int(track.sampleRate))")
+        result = result.replacingOccurrences(of: "{bitdepth}", with: "\(track.bitDepth)")
+
+        // Clean up: remove double separators from empty fields, trim
+        result = result.replacingOccurrences(of: "  ", with: " ")
+        result = result.replacingOccurrences(of: " - - ", with: " - ")
+        result = result.replacingOccurrences(of: "- -", with: "-")
+        result = result.replacingOccurrences(of: "[]", with: "")
+        result = result.replacingOccurrences(of: "()", with: "")
+        result = result.trimmingCharacters(in: .whitespaces)
+        result = result.trimmingCharacters(in: CharacterSet(charactersIn: "- "))
+
+        // Sanitize for filesystem
+        return sanitizeFilename(result)
+    }
+
+    /// Preview what a filename would look like with a given template.
+    static func preview(template: String) -> String {
+        let fakeTrack = Track(
+            title: "Spot Rusherz",
+            artist: "Raekwon",
+            album: "Only Built 4 Cuban Linx",
+            genre: "Hip-Hop",
+            filePath: "/fake.mp3",
+            duration: 193,
+            fileFormat: "MP3"
+        )
+        fakeTrack.bpm = 95
+        fakeTrack.musicalKey = "Dm"
+        fakeTrack.year = 1995
+        return generate(template: template, track: fakeTrack, playlistIndex: 0, totalTracks: 18)
+    }
+
+    // MARK: - Helpers
+
+    private static func sanitizeFilename(_ name: String) -> String {
+        let illegal = CharacterSet(charactersIn: "/\\:*?\"<>|")
+        return name.components(separatedBy: illegal).joined(separator: "_")
+    }
+}

+ 69 - 0
Sources/Models/GroupTemplateResolver.swift

@@ -0,0 +1,69 @@
+import Foundation
+
+/// Resolves group template strings using track metadata.
+/// Supported placeholders: {Artist}, {Album}, {Genre}, {Date}, {Folder}, {Format}, {BPM}, {Key}
+struct GroupTemplateResolver {
+
+    /// Preset templates for quick selection.
+    static let presets: [(name: String, template: String)] = [
+        ("No Grouping", ""),
+        ("Album (Date)", "{Album} ({Date})"),
+        ("Artist — Album", "{Artist} — {Album}"),
+        ("Artist", "{Artist}"),
+        ("Album", "{Album}"),
+        ("Genre", "{Genre}"),
+        ("Folder", "{Folder}"),
+        ("Format", "{Format}"),
+        ("BPM Range", "{BPM}"),
+    ]
+
+    /// Available placeholders with descriptions.
+    static let placeholders: [(token: String, description: String)] = [
+        ("{Artist}", "Track artist"),
+        ("{Album}", "Album name"),
+        ("{Genre}", "Genre"),
+        ("{Date}", "Release date/year from metadata"),
+        ("{Folder}", "Parent folder name"),
+        ("{Format}", "File format (FLAC, MP3, etc.)"),
+        ("{BPM}", "BPM (rounded to nearest 10)"),
+        ("{Key}", "Musical key"),
+    ]
+
+    /// Resolve a template string for a track, returning the group header text.
+    static func resolve(template: String, for track: Track) -> String {
+        guard !template.isEmpty else { return "" }
+
+        var result = template
+        result = result.replacingOccurrences(of: "{Artist}", with: track.artist.isEmpty ? "Unknown Artist" : track.artist)
+        result = result.replacingOccurrences(of: "{Album}", with: track.album.isEmpty ? "Unknown Album" : track.album)
+        result = result.replacingOccurrences(of: "{Genre}", with: track.genre.isEmpty ? "Unknown Genre" : track.genre)
+        let yearStr = track.year.map { String($0) } ?? ""
+        result = result.replacingOccurrences(of: "{Year}", with: yearStr)
+        result = result.replacingOccurrences(of: "{Date}", with: yearStr)
+        result = result.replacingOccurrences(of: "{Folder}", with: folderName(for: track))
+        result = result.replacingOccurrences(of: "{Format}", with: track.fileFormat.isEmpty ? "Unknown" : track.fileFormat)
+        result = result.replacingOccurrences(of: "{BPM}", with: bpmRange(for: track))
+        result = result.replacingOccurrences(of: "{Key}", with: track.musicalKey ?? "Unknown Key")
+
+        // Clean up empty brackets etc.
+        result = result.replacingOccurrences(of: "()", with: "")
+        result = result.replacingOccurrences(of: "[]", with: "")
+        result = result.trimmingCharacters(in: .whitespaces)
+
+        return result.isEmpty ? "Ungrouped" : result
+    }
+
+    private static func folderName(for track: Track) -> String {
+        let components = track.filePath.split(separator: "/")
+        if components.count >= 2 {
+            return String(components[components.count - 2])
+        }
+        return "Root"
+    }
+
+    private static func bpmRange(for track: Track) -> String {
+        guard let bpm = track.bpm else { return "No BPM" }
+        let rounded = Int(bpm / 10) * 10
+        return "\(rounded)-\(rounded + 10) BPM"
+    }
+}

+ 211 - 0
Sources/Models/KeyboardShortcutConfig.swift

@@ -0,0 +1,211 @@
+import AppKit
+import SwiftUI
+
+// MARK: - Shortcut Action
+
+/// All configurable keyboard shortcut actions in MixBoard.
+enum ShortcutAction: String, CaseIterable, Identifiable, Codable {
+    // Playback
+    case playPause      = "Play / Pause"
+    case stop           = "Stop"
+    case nextTrack      = "Next Track"
+    case previousTrack  = "Previous Track"
+    case skipForward    = "Skip Forward 10s"
+    case skipBackward   = "Skip Backward 10s"
+    case toggleShuffle  = "Toggle Shuffle"
+    case toggleRepeat   = "Toggle Repeat"
+    // Mix
+    case addToMix1      = "Add to Mix 1"
+    case addToMix2      = "Add to Mix 2"
+    case addToMix3      = "Add to Mix 3"
+    // General
+    case nowPlaying     = "Now Playing"
+    case search         = "Search All Playlists"
+    case newPlaylist    = "New Playlist"
+    case exportToDAW    = "Export to DAW"
+    case importFromiPhone = "Import from iPhone"
+
+    var id: String { rawValue }
+
+    var group: ShortcutGroup {
+        switch self {
+        case .playPause, .stop, .nextTrack, .previousTrack,
+             .skipForward, .skipBackward, .toggleShuffle, .toggleRepeat:
+            return .playback
+        case .addToMix1, .addToMix2, .addToMix3:
+            return .mix
+        case .nowPlaying, .search, .newPlaylist, .exportToDAW, .importFromiPhone:
+            return .general
+        }
+    }
+}
+
+/// Grouping for display in the Shortcuts settings tab.
+enum ShortcutGroup: String, CaseIterable {
+    case playback = "Playback"
+    case mix      = "Mix"
+    case general  = "General"
+
+    var actions: [ShortcutAction] {
+        ShortcutAction.allCases.filter { $0.group == self }
+    }
+}
+
+// MARK: - Shortcut Binding
+
+/// A stored keyboard shortcut: key + modifier flags.
+struct ShortcutBinding: Codable, Equatable {
+    var key: String          // Character or special key: "space", "right", "left", etc.
+    var command: Bool  = false
+    var shift: Bool    = false
+    var option: Bool   = false
+    var control: Bool  = false
+
+    /// Convert to SwiftUI `KeyEquivalent`.
+    var keyEquivalent: KeyEquivalent {
+        switch key.lowercased() {
+        case "space":  return .space
+        case "right":  return .rightArrow
+        case "left":   return .leftArrow
+        case "up":     return .upArrow
+        case "down":   return .downArrow
+        case "return": return .return
+        case "tab":    return .tab
+        case "delete": return .delete
+        case "escape": return .escape
+        default:
+            if let char = key.first {
+                return KeyEquivalent(char)
+            }
+            return KeyEquivalent("?")
+        }
+    }
+
+    /// Convert to SwiftUI `EventModifiers`.
+    var eventModifiers: EventModifiers {
+        var mods: EventModifiers = []
+        if command { mods.insert(.command) }
+        if shift   { mods.insert(.shift) }
+        if option  { mods.insert(.option) }
+        if control { mods.insert(.control) }
+        return mods
+    }
+
+    /// Human-readable display string, e.g. "⇧⌘1".
+    var displayString: String {
+        var parts: [String] = []
+        if control { parts.append("⌃") }
+        if option  { parts.append("⌥") }
+        if shift   { parts.append("⇧") }
+        if command { parts.append("⌘") }
+
+        let keyDisplay: String
+        switch key.lowercased() {
+        case "space":  keyDisplay = "Space"
+        case "right":  keyDisplay = "→"
+        case "left":   keyDisplay = "←"
+        case "up":     keyDisplay = "↑"
+        case "down":   keyDisplay = "↓"
+        case "return": keyDisplay = "↩"
+        case "tab":    keyDisplay = "⇥"
+        case "delete": keyDisplay = "⌫"
+        case "escape": keyDisplay = "⎋"
+        default:       keyDisplay = key.uppercased()
+        }
+        parts.append(keyDisplay)
+        return parts.joined()
+    }
+
+    /// Build a `ShortcutBinding` from an `NSEvent`.
+    static func from(_ event: NSEvent) -> ShortcutBinding {
+        let key: String
+        switch event.keyCode {
+        case 49:  key = "space"
+        case 124: key = "right"
+        case 123: key = "left"
+        case 126: key = "up"
+        case 125: key = "down"
+        case 36:  key = "return"
+        case 48:  key = "tab"
+        case 51:  key = "delete"
+        default:
+            key = event.charactersIgnoringModifiers?.lowercased() ?? "?"
+        }
+        let mods = event.modifierFlags
+        return ShortcutBinding(
+            key: key,
+            command: mods.contains(.command),
+            shift:   mods.contains(.shift),
+            option:  mods.contains(.option),
+            control: mods.contains(.control)
+        )
+    }
+}
+
+// MARK: - Config Singleton
+
+/// Manages all keyboard shortcut bindings, persisted to UserDefaults.
+final class KeyboardShortcutConfig: ObservableObject {
+    static let shared = KeyboardShortcutConfig()
+
+    @Published var shortcuts: [ShortcutAction: ShortcutBinding] {
+        didSet { save() }
+    }
+
+    /// Factory defaults.
+    static let defaultShortcuts: [ShortcutAction: ShortcutBinding] = [
+        .playPause:       ShortcutBinding(key: "space"),
+        .stop:            ShortcutBinding(key: ".", command: true),
+        .nextTrack:       ShortcutBinding(key: "right", command: true),
+        .previousTrack:   ShortcutBinding(key: "left", command: true),
+        .skipForward:     ShortcutBinding(key: "right", command: true, shift: true),
+        .skipBackward:    ShortcutBinding(key: "left", command: true, shift: true),
+        .toggleShuffle:   ShortcutBinding(key: "s", command: true, shift: true),
+        .toggleRepeat:    ShortcutBinding(key: "r", command: true, shift: true),
+        .addToMix1:       ShortcutBinding(key: "1", command: true),
+        .addToMix2:       ShortcutBinding(key: "2", command: true),
+        .addToMix3:       ShortcutBinding(key: "3", command: true),
+        .nowPlaying:      ShortcutBinding(key: "p", command: true, shift: true),
+        .search:          ShortcutBinding(key: "f", command: true),
+        .newPlaylist:     ShortcutBinding(key: "n", command: true, shift: true),
+        .exportToDAW:     ShortcutBinding(key: "e", command: true),
+        .importFromiPhone: ShortcutBinding(key: "i", command: true, shift: true),
+    ]
+
+    init() {
+        shortcuts = Self.load()
+    }
+
+    /// Get the binding for an action (with fallback to default).
+    func binding(for action: ShortcutAction) -> ShortcutBinding {
+        shortcuts[action] ?? Self.defaultShortcuts[action]!
+    }
+
+    func resetToDefaults() {
+        shortcuts = Self.defaultShortcuts
+    }
+
+    // MARK: - Persistence
+
+    private func save() {
+        let stringKeyed = Dictionary(uniqueKeysWithValues: shortcuts.map { ($0.key.rawValue, $0.value) })
+        if let data = try? JSONEncoder().encode(stringKeyed) {
+            UserDefaults.standard.set(data, forKey: "keyboardShortcuts")
+        }
+    }
+
+    private static func load() -> [ShortcutAction: ShortcutBinding] {
+        guard let data = UserDefaults.standard.data(forKey: "keyboardShortcuts"),
+              let stringKeyed = try? JSONDecoder().decode([String: ShortcutBinding].self, from: data) else {
+            return defaultShortcuts
+        }
+        // Merge with defaults so new actions added in updates get their fallback
+        var result = defaultShortcuts
+        for (key, value) in stringKeyed {
+            if let action = ShortcutAction(rawValue: key) {
+                result[action] = value
+            }
+        }
+        return result
+    }
+}

+ 137 - 0
Sources/Models/Playlist.swift

@@ -0,0 +1,137 @@
+import Foundation
+import SwiftData
+
+/// A playlist — an ordered collection of tracks for building a mix.
+@Model
+final class Playlist {
+    var id: UUID = UUID()
+    var name: String = ""
+    var notes: String = ""
+    var dateCreated: Date = Date()
+    var dateModified: Date = Date()
+    var targetBPM: Double?             // target BPM for the mix
+    var color: String = "#2196F3"   // hex color for UI
+
+    /// Template for grouping tracks in the playlist view.
+    /// Uses {Artist}, {Album}, {Genre}, {Year}, {Folder} placeholders.
+    /// Example: "{Album} ({Year})" → "Only Built 4 Cuban Linx (1995)"
+    /// Empty string = no grouping.
+    var groupTemplate: String = ""
+
+    @Relationship(deleteRule: .cascade, inverse: \PlaylistEntry.playlist)
+    var entries: [PlaylistEntry]
+
+    /// The folder this playlist belongs to (nil = top level).
+    var folder: PlaylistFolder?
+
+    /// Entries sorted by position.
+    var sortedEntries: [PlaylistEntry] {
+        entries.sorted { $0.position < $1.position }
+    }
+
+    var totalDuration: TimeInterval {
+        entries.compactMap(\.track?.duration).reduce(0, +)
+    }
+
+    var formattedTotalDuration: String {
+        let total = Int(totalDuration)
+        let hours = total / 3600
+        let minutes = (total % 3600) / 60
+        let seconds = total % 60
+        if hours > 0 {
+            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
+        }
+        return String(format: "%d:%02d", minutes, seconds)
+    }
+
+    var trackCount: Int { entries.count }
+
+    init(
+        name: String,
+        notes: String = "",
+        color: String = "#2196F3"
+    ) {
+        self.id = UUID()
+        self.name = name
+        self.notes = notes
+        self.dateCreated = Date()
+        self.dateModified = Date()
+        self.targetBPM = nil
+        self.color = color
+        self.groupTemplate = ""
+        self.entries = []
+    }
+
+    func addTrack(_ track: Track, crossfadeDuration: TimeInterval = 0) {
+        let position = entries.count
+        let entry = PlaylistEntry(
+            position: position,
+            track: track,
+            crossfadeDuration: crossfadeDuration
+        )
+        entries.append(entry)
+        dateModified = Date()
+    }
+
+    func removeEntry(at position: Int) {
+        entries.removeAll { $0.position == position }
+        // Re-index positions
+        for (index, entry) in sortedEntries.enumerated() {
+            entry.position = index
+        }
+        dateModified = Date()
+    }
+
+    func moveEntry(from source: Int, to destination: Int) {
+        var sorted = sortedEntries
+        let entry = sorted.remove(at: source)
+        sorted.insert(entry, at: destination)
+        for (index, e) in sorted.enumerated() {
+            e.position = index
+        }
+        dateModified = Date()
+    }
+}
+
+/// A single entry in a playlist, linking a track with transition info.
+@Model
+final class PlaylistEntry {
+    var id: UUID = UUID()
+    var position: Int = 0
+    var track: Track?
+
+    // Transition settings
+    var crossfadeDuration: TimeInterval = 0    // seconds of overlap with next track
+    var startOffset: TimeInterval = 0          // start playback from this point
+    var endOffset: TimeInterval = 0            // stop playback at this point (0 = end of track)
+    var gainAdjustment: Double = 0             // dB adjustment (-12 to +12)
+    var notes: String = ""
+
+    var playlist: Playlist?
+
+    /// Effective duration considering start/end offsets.
+    var effectiveDuration: TimeInterval {
+        guard let track else { return 0 }
+        let end = endOffset > 0 ? endOffset : track.duration
+        return max(0, end - startOffset)
+    }
+
+    init(
+        position: Int,
+        track: Track?,
+        crossfadeDuration: TimeInterval = 0,
+        startOffset: TimeInterval = 0,
+        endOffset: TimeInterval = 0,
+        gainAdjustment: Double = 0,
+        notes: String = ""
+    ) {
+        self.id = UUID()
+        self.position = position
+        self.track = track
+        self.crossfadeDuration = crossfadeDuration
+        self.startOffset = startOffset
+        self.endOffset = endOffset
+        self.gainAdjustment = gainAdjustment
+        self.notes = notes
+    }
+}

+ 30 - 0
Sources/Models/PlaylistFolder.swift

@@ -0,0 +1,30 @@
+import Foundation
+import SwiftData
+
+/// A folder that groups playlists together in the sidebar.
+@Model
+final class PlaylistFolder {
+    var id: UUID = UUID()
+    var name: String = ""
+    var dateCreated: Date = Date()
+    var isExpanded: Bool = true
+
+    @Relationship(deleteRule: .nullify, inverse: \Playlist.folder)
+    var playlists: [Playlist]
+
+    var sortedPlaylists: [Playlist] {
+        playlists.sorted { $0.dateModified > $1.dateModified }
+    }
+
+    var totalTrackCount: Int {
+        playlists.reduce(0) { $0 + $1.trackCount }
+    }
+
+    init(name: String) {
+        self.id = UUID()
+        self.name = name
+        self.dateCreated = Date()
+        self.isExpanded = true
+        self.playlists = []
+    }
+}

+ 164 - 0
Sources/Models/PlaylistViewConfig.swift

@@ -0,0 +1,164 @@
+import Foundation
+
+/// Configuration for how the playlist view displays tracks.
+/// Persisted to UserDefaults so it remembers across sessions.
+/// Use `PlaylistViewConfig.shared` for a single app-wide instance.
+final class PlaylistViewConfig: ObservableObject {
+
+    static let shared = PlaylistViewConfig()
+
+    // MARK: - Column Visibility
+
+    /// All possible metadata columns.
+    enum Column: String, CaseIterable, Identifiable, Codable {
+        case artwork     = "Artwork"
+        case trackNumber = "#"
+        case title       = "Title"
+        case artist      = "Artist"
+        case album       = "Album"
+        case genre       = "Genre"
+        case bpm         = "BPM"
+        case key         = "Key"
+        case duration    = "Duration"
+        case format      = "Format"
+        case sampleRate  = "Sample Rate"
+        case bitDepth    = "Bit Depth"
+        case fileSize    = "File Size"
+        case rating      = "Rating"
+        case dateAdded   = "Date Added"
+        case playCount   = "Play Count"
+        case crossfade   = "Crossfade"
+        case gain        = "Gain"
+
+        var id: String { rawValue }
+
+        /// Default width hint for each column.
+        var defaultWidth: CGFloat {
+            switch self {
+            case .artwork:     return 50
+            case .trackNumber: return 30
+            case .title:       return 220
+            case .artist:      return 150
+            case .album:       return 150
+            case .genre:       return 100
+            case .bpm:         return 55
+            case .key:         return 55
+            case .duration:    return 55
+            case .format:      return 50
+            case .sampleRate:  return 70
+            case .bitDepth:    return 50
+            case .fileSize:    return 70
+            case .rating:      return 80
+            case .dateAdded:   return 90
+            case .playCount:   return 50
+            case .crossfade:   return 120
+            case .gain:        return 120
+            }
+        }
+    }
+
+    // MARK: - Artwork Size
+
+    enum ArtworkSize: String, CaseIterable, Identifiable, Codable {
+        case small  = "Small"   // 32pt
+        case medium = "Medium"  // 48pt
+        case large  = "Large"   // 64pt
+
+        var id: String { rawValue }
+
+        var points: CGFloat {
+            switch self {
+            case .small:  return 32
+            case .medium: return 48
+            case .large:  return 64
+            }
+        }
+    }
+
+    // MARK: - Published State
+
+    @Published var visibleColumns: [Column] {
+        didSet { save() }
+    }
+
+    @Published var showArtwork: Bool {
+        didSet { save() }
+    }
+
+    @Published var artworkSize: ArtworkSize {
+        didSet { save() }
+    }
+
+    @Published var cursorFollowsPlayback: Bool {
+        didSet { save() }
+    }
+
+    @Published var playbackFollowsCursor: Bool {
+        didSet { save() }
+    }
+
+    // MARK: - Defaults
+
+    static let defaultColumns: [Column] = [
+        .artwork, .trackNumber, .title, .artist, .bpm, .key, .duration, .crossfade
+    ]
+
+    // MARK: - Init
+
+    init() {
+        let defaults = UserDefaults.standard
+
+        if let data = defaults.data(forKey: "playlistVisibleColumns"),
+           let decoded = try? JSONDecoder().decode([Column].self, from: data) {
+            visibleColumns = decoded
+        } else {
+            visibleColumns = Self.defaultColumns
+        }
+
+        showArtwork = defaults.object(forKey: "playlistShowArtwork") as? Bool ?? true
+        cursorFollowsPlayback = defaults.object(forKey: "playlistCursorFollowsPlayback") as? Bool ?? true
+        playbackFollowsCursor = defaults.object(forKey: "playlistPlaybackFollowsCursor") as? Bool ?? false
+
+        if let raw = defaults.string(forKey: "playlistArtworkSize"),
+           let size = ArtworkSize(rawValue: raw) {
+            artworkSize = size
+        } else {
+            artworkSize = .medium
+        }
+    }
+
+    // MARK: - Persistence
+
+    private func save() {
+        let defaults = UserDefaults.standard
+        if let data = try? JSONEncoder().encode(visibleColumns) {
+            defaults.set(data, forKey: "playlistVisibleColumns")
+        }
+        defaults.set(showArtwork, forKey: "playlistShowArtwork")
+        defaults.set(cursorFollowsPlayback, forKey: "playlistCursorFollowsPlayback")
+        defaults.set(playbackFollowsCursor, forKey: "playlistPlaybackFollowsCursor")
+        defaults.set(artworkSize.rawValue, forKey: "playlistArtworkSize")
+    }
+
+    // MARK: - Helpers
+
+    func isColumnVisible(_ column: Column) -> Bool {
+        visibleColumns.contains(column)
+    }
+
+    func toggleColumn(_ column: Column) {
+        if let idx = visibleColumns.firstIndex(of: column) {
+            visibleColumns.remove(at: idx)
+        } else {
+            visibleColumns.append(column)
+        }
+    }
+
+    func resetToDefaults() {
+        visibleColumns = Self.defaultColumns
+        showArtwork = true
+        artworkSize = .medium
+        cursorFollowsPlayback = true
+        playbackFollowsCursor = false
+    }
+}

+ 127 - 0
Sources/Models/Track.swift

@@ -0,0 +1,127 @@
+import Foundation
+import SwiftData
+
+/// Represents a single audio track in the library.
+@Model
+final class Track {
+    var id: UUID = UUID()
+    var title: String = ""
+    var artist: String = ""
+    var album: String = ""
+    var genre: String = ""
+    var filePath: String = ""
+    var duration: TimeInterval = 0
+    var bpm: Double?
+    var musicalKey: String?
+    var sampleRate: Double = 44100
+    var bitDepth: Int = 16
+    var channels: Int = 2
+    var fileFormat: String = ""
+    var fileSizeBytes: Int64 = 0
+    var dateAdded: Date = Date()
+    var lastPlayed: Date?
+    var playCount: Int = 0
+    var rating: Int = 0       // 0-5 stars
+    var color: String?    // user-assigned color tag
+    var year: Int?            // release year from metadata
+    var notes: String = ""
+
+    // MARK: - Cloud (Chad Music) fields
+
+    /// If true, this track is from Chad Music cloud — streamed via AVPlayer, not local file.
+    var isCloud: Bool = false
+
+    /// Chad Music server URL for streaming (e.g., "/music/Artist/Album/track.mp3").
+    var cloudStreamPath: String?
+
+    /// Chad Music track ID (hex string from server).
+    var cloudTrackId: String?
+
+    /// Cached waveform samples (downsampled min/max pairs), stored as Data for efficiency.
+    var waveformData: Data?
+
+    /// Whether BPM/key analysis has been performed.
+    var isAnalyzed: Bool = false
+
+    @Relationship(deleteRule: .cascade, inverse: \CuePoint.track)
+    var cuePoints: [CuePoint]
+
+    var fileURL: URL {
+        URL(fileURLWithPath: filePath.isEmpty ? "/dev/null" : filePath)
+    }
+
+    /// True if this track has a valid local file (not a cloud-only track).
+    var hasLocalFile: Bool {
+        !filePath.isEmpty && !isCloud
+    }
+
+    var formattedDuration: String {
+        let minutes = Int(duration) / 60
+        let seconds = Int(duration) % 60
+        return String(format: "%d:%02d", minutes, seconds)
+    }
+
+    var formattedBPM: String {
+        guard let bpm else { return "—" }
+        return String(format: "%.1f", bpm)
+    }
+
+    var formattedFileSize: String {
+        ByteCountFormatter.string(fromByteCount: fileSizeBytes, countStyle: .file)
+    }
+
+    init(
+        title: String,
+        artist: String = "",
+        album: String = "",
+        genre: String = "",
+        filePath: String,
+        duration: TimeInterval = 0,
+        sampleRate: Double = 44100,
+        bitDepth: Int = 16,
+        channels: Int = 2,
+        fileFormat: String = "",
+        fileSizeBytes: Int64 = 0
+    ) {
+        self.id = UUID()
+        self.title = title
+        self.artist = artist
+        self.album = album
+        self.genre = genre
+        self.filePath = filePath
+        self.duration = duration
+        self.bpm = nil
+        self.musicalKey = nil
+        self.sampleRate = sampleRate
+        self.bitDepth = bitDepth
+        self.channels = channels
+        self.fileFormat = fileFormat
+        self.fileSizeBytes = fileSizeBytes
+        self.dateAdded = Date()
+        self.lastPlayed = nil
+        self.playCount = 0
+        self.rating = 0
+        self.color = nil
+        self.year = nil
+        self.notes = ""
+        self.waveformData = nil
+        self.isAnalyzed = false
+        self.cuePoints = []
+    }
+
+    /// Create a Track from a Chad Music cloud track.
+    static func fromCloud(_ chadTrack: ChadTrack) -> Track {
+        let track = Track(
+            title: chadTrack.title,
+            artist: chadTrack.artist ?? "",
+            album: chadTrack.album ?? "",
+            filePath: "",
+            duration: chadTrack.duration ?? 0
+        )
+        track.isCloud = true
+        track.cloudStreamPath = chadTrack.url
+        track.cloudTrackId = chadTrack.id
+        track.year = chadTrack.year
+        return track
+    }
+}

+ 13 - 0
Sources/OGG/MixBoard-Bridging-Header.h

@@ -0,0 +1,13 @@
+//
+//  MixBoard-Bridging-Header.h
+//  MixBoard
+//
+//  Bridging header to expose C libraries to Swift.
+//
+
+#ifndef MixBoard_Bridging_Header_h
+#define MixBoard_Bridging_Header_h
+
+#include "stb_vorbis_wrapper.h"
+
+#endif /* MixBoard_Bridging_Header_h */

+ 5584 - 0
Sources/OGG/stb_vorbis.c

@@ -0,0 +1,5584 @@
+// Ogg Vorbis audio decoder - v1.22 - public domain
+// http://nothings.org/stb_vorbis/
+//
+// Original version written by Sean Barrett in 2007.
+//
+// Originally sponsored by RAD Game Tools. Seeking implementation
+// sponsored by Phillip Bennefall, Marc Andersen, Aaron Baker,
+// Elias Software, Aras Pranckevicius, and Sean Barrett.
+//
+// LICENSE
+//
+//   See end of file for license information.
+//
+// Limitations:
+//
+//   - floor 0 not supported (used in old ogg vorbis files pre-2004)
+//   - lossless sample-truncation at beginning ignored
+//   - cannot concatenate multiple vorbis streams
+//   - sample positions are 32-bit, limiting seekable 192Khz
+//       files to around 6 hours (Ogg supports 64-bit)
+//
+// Feature contributors:
+//    Dougall Johnson (sample-exact seeking)
+//
+// Bugfix/warning contributors:
+//    Terje Mathisen     Niklas Frykholm     Andy Hill
+//    Casey Muratori     John Bolton         Gargaj
+//    Laurent Gomila     Marc LeBlanc        Ronny Chevalier
+//    Bernhard Wodo      Evan Balster        github:alxprd
+//    Tom Beaumont       Ingo Leitgeb        Nicolas Guillemot
+//    Phillip Bennefall  Rohit               Thiago Goulart
+//    github:manxorist   Saga Musix          github:infatum
+//    Timur Gagiev       Maxwell Koo         Peter Waller
+//    github:audinowho   Dougall Johnson     David Reid
+//    github:Clownacy    Pedro J. Estebanez  Remi Verschelde
+//    AnthoFoxo          github:morlat       Gabriel Ravier
+//
+// Partial history:
+//    1.22    - 2021-07-11 - various small fixes
+//    1.21    - 2021-07-02 - fix bug for files with no comments
+//    1.20    - 2020-07-11 - several small fixes
+//    1.19    - 2020-02-05 - warnings
+//    1.18    - 2020-02-02 - fix seek bugs; parse header comments; misc warnings etc.
+//    1.17    - 2019-07-08 - fix CVE-2019-13217..CVE-2019-13223 (by ForAllSecure)
+//    1.16    - 2019-03-04 - fix warnings
+//    1.15    - 2019-02-07 - explicit failure if Ogg Skeleton data is found
+//    1.14    - 2018-02-11 - delete bogus dealloca usage
+//    1.13    - 2018-01-29 - fix truncation of last frame (hopefully)
+//    1.12    - 2017-11-21 - limit residue begin/end to blocksize/2 to avoid large temp allocs in bad/corrupt files
+//    1.11    - 2017-07-23 - fix MinGW compilation
+//    1.10    - 2017-03-03 - more robust seeking; fix negative ilog(); clear error in open_memory
+//    1.09    - 2016-04-04 - back out 'truncation of last frame' fix from previous version
+//    1.08    - 2016-04-02 - warnings; setup memory leaks; truncation of last frame
+//    1.07    - 2015-01-16 - fixes for crashes on invalid files; warning fixes; const
+//    1.06    - 2015-08-31 - full, correct support for seeking API (Dougall Johnson)
+//                           some crash fixes when out of memory or with corrupt files
+//                           fix some inappropriately signed shifts
+//    1.05    - 2015-04-19 - don't define __forceinline if it's redundant
+//    1.04    - 2014-08-27 - fix missing const-correct case in API
+//    1.03    - 2014-08-07 - warning fixes
+//    1.02    - 2014-07-09 - declare qsort comparison as explicitly _cdecl in Windows
+//    1.01    - 2014-06-18 - fix stb_vorbis_get_samples_float (interleaved was correct)
+//    1.0     - 2014-05-26 - fix memory leaks; fix warnings; fix bugs in >2-channel;
+//                           (API change) report sample rate for decode-full-file funcs
+//
+// See end of file for full version history.
+
+
+//////////////////////////////////////////////////////////////////////////////
+//
+//  HEADER BEGINS HERE
+//
+
+#ifndef STB_VORBIS_INCLUDE_STB_VORBIS_H
+#define STB_VORBIS_INCLUDE_STB_VORBIS_H
+
+#if defined(STB_VORBIS_NO_CRT) && !defined(STB_VORBIS_NO_STDIO)
+#define STB_VORBIS_NO_STDIO 1
+#endif
+
+#ifndef STB_VORBIS_NO_STDIO
+#include <stdio.h>
+#endif
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+///////////   THREAD SAFETY
+
+// Individual stb_vorbis* handles are not thread-safe; you cannot decode from
+// them from multiple threads at the same time. However, you can have multiple
+// stb_vorbis* handles and decode from them independently in multiple thrads.
+
+
+///////////   MEMORY ALLOCATION
+
+// normally stb_vorbis uses malloc() to allocate memory at startup,
+// and alloca() to allocate temporary memory during a frame on the
+// stack. (Memory consumption will depend on the amount of setup
+// data in the file and how you set the compile flags for speed
+// vs. size. In my test files the maximal-size usage is ~150KB.)
+//
+// You can modify the wrapper functions in the source (setup_malloc,
+// setup_temp_malloc, temp_malloc) to change this behavior, or you
+// can use a simpler allocation model: you pass in a buffer from
+// which stb_vorbis will allocate _all_ its memory (including the
+// temp memory). "open" may fail with a VORBIS_outofmem if you
+// do not pass in enough data; there is no way to determine how
+// much you do need except to succeed (at which point you can
+// query get_info to find the exact amount required. yes I know
+// this is lame).
+//
+// If you pass in a non-NULL buffer of the type below, allocation
+// will occur from it as described above. Otherwise just pass NULL
+// to use malloc()/alloca()
+
+typedef struct
+{
+   char *alloc_buffer;
+   int   alloc_buffer_length_in_bytes;
+} stb_vorbis_alloc;
+
+
+///////////   FUNCTIONS USEABLE WITH ALL INPUT MODES
+
+typedef struct stb_vorbis stb_vorbis;
+
+typedef struct
+{
+   unsigned int sample_rate;
+   int channels;
+
+   unsigned int setup_memory_required;
+   unsigned int setup_temp_memory_required;
+   unsigned int temp_memory_required;
+
+   int max_frame_size;
+} stb_vorbis_info;
+
+typedef struct
+{
+   char *vendor;
+
+   int comment_list_length;
+   char **comment_list;
+} stb_vorbis_comment;
+
+// get general information about the file
+extern stb_vorbis_info stb_vorbis_get_info(stb_vorbis *f);
+
+// get ogg comments
+extern stb_vorbis_comment stb_vorbis_get_comment(stb_vorbis *f);
+
+// get the last error detected (clears it, too)
+extern int stb_vorbis_get_error(stb_vorbis *f);
+
+// close an ogg vorbis file and free all memory in use
+extern void stb_vorbis_close(stb_vorbis *f);
+
+// this function returns the offset (in samples) from the beginning of the
+// file that will be returned by the next decode, if it is known, or -1
+// otherwise. after a flush_pushdata() call, this may take a while before
+// it becomes valid again.
+// NOT WORKING YET after a seek with PULLDATA API
+extern int stb_vorbis_get_sample_offset(stb_vorbis *f);
+
+// returns the current seek point within the file, or offset from the beginning
+// of the memory buffer. In pushdata mode it returns 0.
+extern unsigned int stb_vorbis_get_file_offset(stb_vorbis *f);
+
+///////////   PUSHDATA API
+
+#ifndef STB_VORBIS_NO_PUSHDATA_API
+
+// this API allows you to get blocks of data from any source and hand
+// them to stb_vorbis. you have to buffer them; stb_vorbis will tell
+// you how much it used, and you have to give it the rest next time;
+// and stb_vorbis may not have enough data to work with and you will
+// need to give it the same data again PLUS more. Note that the Vorbis
+// specification does not bound the size of an individual frame.
+
+extern stb_vorbis *stb_vorbis_open_pushdata(
+         const unsigned char * datablock, int datablock_length_in_bytes,
+         int *datablock_memory_consumed_in_bytes,
+         int *error,
+         const stb_vorbis_alloc *alloc_buffer);
+// create a vorbis decoder by passing in the initial data block containing
+//    the ogg&vorbis headers (you don't need to do parse them, just provide
+//    the first N bytes of the file--you're told if it's not enough, see below)
+// on success, returns an stb_vorbis *, does not set error, returns the amount of
+//    data parsed/consumed on this call in *datablock_memory_consumed_in_bytes;
+// on failure, returns NULL on error and sets *error, does not change *datablock_memory_consumed
+// if returns NULL and *error is VORBIS_need_more_data, then the input block was
+//       incomplete and you need to pass in a larger block from the start of the file
+
+extern int stb_vorbis_decode_frame_pushdata(
+         stb_vorbis *f,
+         const unsigned char *datablock, int datablock_length_in_bytes,
+         int *channels,             // place to write number of float * buffers
+         float ***output,           // place to write float ** array of float * buffers
+         int *samples               // place to write number of output samples
+     );
+// decode a frame of audio sample data if possible from the passed-in data block
+//
+// return value: number of bytes we used from datablock
+//
+// possible cases:
+//     0 bytes used, 0 samples output (need more data)
+//     N bytes used, 0 samples output (resynching the stream, keep going)
+//     N bytes used, M samples output (one frame of data)
+// note that after opening a file, you will ALWAYS get one N-bytes,0-sample
+// frame, because Vorbis always "discards" the first frame.
+//
+// Note that on resynch, stb_vorbis will rarely consume all of the buffer,
+// instead only datablock_length_in_bytes-3 or less. This is because it wants
+// to avoid missing parts of a page header if they cross a datablock boundary,
+// without writing state-machiney code to record a partial detection.
+//
+// The number of channels returned are stored in *channels (which can be
+// NULL--it is always the same as the number of channels reported by
+// get_info). *output will contain an array of float* buffers, one per
+// channel. In other words, (*output)[0][0] contains the first sample from
+// the first channel, and (*output)[1][0] contains the first sample from
+// the second channel.
+//
+// *output points into stb_vorbis's internal output buffer storage; these
+// buffers are owned by stb_vorbis and application code should not free
+// them or modify their contents. They are transient and will be overwritten
+// once you ask for more data to get decoded, so be sure to grab any data
+// you need before then.
+
+extern void stb_vorbis_flush_pushdata(stb_vorbis *f);
+// inform stb_vorbis that your next datablock will not be contiguous with
+// previous ones (e.g. you've seeked in the data); future attempts to decode
+// frames will cause stb_vorbis to resynchronize (as noted above), and
+// once it sees a valid Ogg page (typically 4-8KB, as large as 64KB), it
+// will begin decoding the _next_ frame.
+//
+// if you want to seek using pushdata, you need to seek in your file, then
+// call stb_vorbis_flush_pushdata(), then start calling decoding, then once
+// decoding is returning you data, call stb_vorbis_get_sample_offset, and
+// if you don't like the result, seek your file again and repeat.
+#endif
+
+
+//////////   PULLING INPUT API
+
+#ifndef STB_VORBIS_NO_PULLDATA_API
+// This API assumes stb_vorbis is allowed to pull data from a source--
+// either a block of memory containing the _entire_ vorbis stream, or a
+// FILE * that you or it create, or possibly some other reading mechanism
+// if you go modify the source to replace the FILE * case with some kind
+// of callback to your code. (But if you don't support seeking, you may
+// just want to go ahead and use pushdata.)
+
+#if !defined(STB_VORBIS_NO_STDIO) && !defined(STB_VORBIS_NO_INTEGER_CONVERSION)
+extern int stb_vorbis_decode_filename(const char *filename, int *channels, int *sample_rate, short **output);
+#endif
+#if !defined(STB_VORBIS_NO_INTEGER_CONVERSION)
+extern int stb_vorbis_decode_memory(const unsigned char *mem, int len, int *channels, int *sample_rate, short **output);
+#endif
+// decode an entire file and output the data interleaved into a malloc()ed
+// buffer stored in *output. The return value is the number of samples
+// decoded, or -1 if the file could not be opened or was not an ogg vorbis file.
+// When you're done with it, just free() the pointer returned in *output.
+
+extern stb_vorbis * stb_vorbis_open_memory(const unsigned char *data, int len,
+                                  int *error, const stb_vorbis_alloc *alloc_buffer);
+// create an ogg vorbis decoder from an ogg vorbis stream in memory (note
+// this must be the entire stream!). on failure, returns NULL and sets *error
+
+#ifndef STB_VORBIS_NO_STDIO
+extern stb_vorbis * stb_vorbis_open_filename(const char *filename,
+                                  int *error, const stb_vorbis_alloc *alloc_buffer);
+// create an ogg vorbis decoder from a filename via fopen(). on failure,
+// returns NULL and sets *error (possibly to VORBIS_file_open_failure).
+
+extern stb_vorbis * stb_vorbis_open_file(FILE *f, int close_handle_on_close,
+                                  int *error, const stb_vorbis_alloc *alloc_buffer);
+// create an ogg vorbis decoder from an open FILE *, looking for a stream at
+// the _current_ seek point (ftell). on failure, returns NULL and sets *error.
+// note that stb_vorbis must "own" this stream; if you seek it in between
+// calls to stb_vorbis, it will become confused. Moreover, if you attempt to
+// perform stb_vorbis_seek_*() operations on this file, it will assume it
+// owns the _entire_ rest of the file after the start point. Use the next
+// function, stb_vorbis_open_file_section(), to limit it.
+
+extern stb_vorbis * stb_vorbis_open_file_section(FILE *f, int close_handle_on_close,
+                int *error, const stb_vorbis_alloc *alloc_buffer, unsigned int len);
+// create an ogg vorbis decoder from an open FILE *, looking for a stream at
+// the _current_ seek point (ftell); the stream will be of length 'len' bytes.
+// on failure, returns NULL and sets *error. note that stb_vorbis must "own"
+// this stream; if you seek it in between calls to stb_vorbis, it will become
+// confused.
+#endif
+
+extern int stb_vorbis_seek_frame(stb_vorbis *f, unsigned int sample_number);
+extern int stb_vorbis_seek(stb_vorbis *f, unsigned int sample_number);
+// these functions seek in the Vorbis file to (approximately) 'sample_number'.
+// after calling seek_frame(), the next call to get_frame_*() will include
+// the specified sample. after calling stb_vorbis_seek(), the next call to
+// stb_vorbis_get_samples_* will start with the specified sample. If you
+// do not need to seek to EXACTLY the target sample when using get_samples_*,
+// you can also use seek_frame().
+
+extern int stb_vorbis_seek_start(stb_vorbis *f);
+// this function is equivalent to stb_vorbis_seek(f,0)
+
+extern unsigned int stb_vorbis_stream_length_in_samples(stb_vorbis *f);
+extern float        stb_vorbis_stream_length_in_seconds(stb_vorbis *f);
+// these functions return the total length of the vorbis stream
+
+extern int stb_vorbis_get_frame_float(stb_vorbis *f, int *channels, float ***output);
+// decode the next frame and return the number of samples. the number of
+// channels returned are stored in *channels (which can be NULL--it is always
+// the same as the number of channels reported by get_info). *output will
+// contain an array of float* buffers, one per channel. These outputs will
+// be overwritten on the next call to stb_vorbis_get_frame_*.
+//
+// You generally should not intermix calls to stb_vorbis_get_frame_*()
+// and stb_vorbis_get_samples_*(), since the latter calls the former.
+
+#ifndef STB_VORBIS_NO_INTEGER_CONVERSION
+extern int stb_vorbis_get_frame_short_interleaved(stb_vorbis *f, int num_c, short *buffer, int num_shorts);
+extern int stb_vorbis_get_frame_short            (stb_vorbis *f, int num_c, short **buffer, int num_samples);
+#endif
+// decode the next frame and return the number of *samples* per channel.
+// Note that for interleaved data, you pass in the number of shorts (the
+// size of your array), but the return value is the number of samples per
+// channel, not the total number of samples.
+//
+// The data is coerced to the number of channels you request according to the
+// channel coercion rules (see below). You must pass in the size of your
+// buffer(s) so that stb_vorbis will not overwrite the end of the buffer.
+// The maximum buffer size needed can be gotten from get_info(); however,
+// the Vorbis I specification implies an absolute maximum of 4096 samples
+// per channel.
+
+// Channel coercion rules:
+//    Let M be the number of channels requested, and N the number of channels present,
+//    and Cn be the nth channel; let stereo L be the sum of all L and center channels,
+//    and stereo R be the sum of all R and center channels (channel assignment from the
+//    vorbis spec).
+//        M    N       output
+//        1    k      sum(Ck) for all k
+//        2    *      stereo L, stereo R
+//        k    l      k > l, the first l channels, then 0s
+//        k    l      k <= l, the first k channels
+//    Note that this is not _good_ surround etc. mixing at all! It's just so
+//    you get something useful.
+
+extern int stb_vorbis_get_samples_float_interleaved(stb_vorbis *f, int channels, float *buffer, int num_floats);
+extern int stb_vorbis_get_samples_float(stb_vorbis *f, int channels, float **buffer, int num_samples);
+// gets num_samples samples, not necessarily on a frame boundary--this requires
+// buffering so you have to supply the buffers. DOES NOT APPLY THE COERCION RULES.
+// Returns the number of samples stored per channel; it may be less than requested
+// at the end of the file. If there are no more samples in the file, returns 0.
+
+#ifndef STB_VORBIS_NO_INTEGER_CONVERSION
+extern int stb_vorbis_get_samples_short_interleaved(stb_vorbis *f, int channels, short *buffer, int num_shorts);
+extern int stb_vorbis_get_samples_short(stb_vorbis *f, int channels, short **buffer, int num_samples);
+#endif
+// gets num_samples samples, not necessarily on a frame boundary--this requires
+// buffering so you have to supply the buffers. Applies the coercion rules above
+// to produce 'channels' channels. Returns the number of samples stored per channel;
+// it may be less than requested at the end of the file. If there are no more
+// samples in the file, returns 0.
+
+#endif
+
+////////   ERROR CODES
+
+enum STBVorbisError
+{
+   VORBIS__no_error,
+
+   VORBIS_need_more_data=1,             // not a real error
+
+   VORBIS_invalid_api_mixing,           // can't mix API modes
+   VORBIS_outofmem,                     // not enough memory
+   VORBIS_feature_not_supported,        // uses floor 0
+   VORBIS_too_many_channels,            // STB_VORBIS_MAX_CHANNELS is too small
+   VORBIS_file_open_failure,            // fopen() failed
+   VORBIS_seek_without_length,          // can't seek in unknown-length file
+
+   VORBIS_unexpected_eof=10,            // file is truncated?
+   VORBIS_seek_invalid,                 // seek past EOF
+
+   // decoding errors (corrupt/invalid stream) -- you probably
+   // don't care about the exact details of these
+
+   // vorbis errors:
+   VORBIS_invalid_setup=20,
+   VORBIS_invalid_stream,
+
+   // ogg errors:
+   VORBIS_missing_capture_pattern=30,
+   VORBIS_invalid_stream_structure_version,
+   VORBIS_continued_packet_flag_invalid,
+   VORBIS_incorrect_stream_serial_number,
+   VORBIS_invalid_first_page,
+   VORBIS_bad_packet_type,
+   VORBIS_cant_find_last_page,
+   VORBIS_seek_failed,
+   VORBIS_ogg_skeleton_not_supported
+};
+
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // STB_VORBIS_INCLUDE_STB_VORBIS_H
+//
+//  HEADER ENDS HERE
+//
+//////////////////////////////////////////////////////////////////////////////
+
+#ifndef STB_VORBIS_HEADER_ONLY
+
+// global configuration settings (e.g. set these in the project/makefile),
+// or just set them in this file at the top (although ideally the first few
+// should be visible when the header file is compiled too, although it's not
+// crucial)
+
+// STB_VORBIS_NO_PUSHDATA_API
+//     does not compile the code for the various stb_vorbis_*_pushdata()
+//     functions
+// #define STB_VORBIS_NO_PUSHDATA_API
+
+// STB_VORBIS_NO_PULLDATA_API
+//     does not compile the code for the non-pushdata APIs
+// #define STB_VORBIS_NO_PULLDATA_API
+
+// STB_VORBIS_NO_STDIO
+//     does not compile the code for the APIs that use FILE *s internally
+//     or externally (implied by STB_VORBIS_NO_PULLDATA_API)
+// #define STB_VORBIS_NO_STDIO
+
+// STB_VORBIS_NO_INTEGER_CONVERSION
+//     does not compile the code for converting audio sample data from
+//     float to integer (implied by STB_VORBIS_NO_PULLDATA_API)
+// #define STB_VORBIS_NO_INTEGER_CONVERSION
+
+// STB_VORBIS_NO_FAST_SCALED_FLOAT
+//      does not use a fast float-to-int trick to accelerate float-to-int on
+//      most platforms which requires endianness be defined correctly.
+//#define STB_VORBIS_NO_FAST_SCALED_FLOAT
+
+
+// STB_VORBIS_MAX_CHANNELS [number]
+//     globally define this to the maximum number of channels you need.
+//     The spec does not put a restriction on channels except that
+//     the count is stored in a byte, so 255 is the hard limit.
+//     Reducing this saves about 16 bytes per value, so using 16 saves
+//     (255-16)*16 or around 4KB. Plus anything other memory usage
+//     I forgot to account for. Can probably go as low as 8 (7.1 audio),
+//     6 (5.1 audio), or 2 (stereo only).
+#ifndef STB_VORBIS_MAX_CHANNELS
+#define STB_VORBIS_MAX_CHANNELS    16  // enough for anyone?
+#endif
+
+// STB_VORBIS_PUSHDATA_CRC_COUNT [number]
+//     after a flush_pushdata(), stb_vorbis begins scanning for the
+//     next valid page, without backtracking. when it finds something
+//     that looks like a page, it streams through it and verifies its
+//     CRC32. Should that validation fail, it keeps scanning. But it's
+//     possible that _while_ streaming through to check the CRC32 of
+//     one candidate page, it sees another candidate page. This #define
+//     determines how many "overlapping" candidate pages it can search
+//     at once. Note that "real" pages are typically ~4KB to ~8KB, whereas
+//     garbage pages could be as big as 64KB, but probably average ~16KB.
+//     So don't hose ourselves by scanning an apparent 64KB page and
+//     missing a ton of real ones in the interim; so minimum of 2
+#ifndef STB_VORBIS_PUSHDATA_CRC_COUNT
+#define STB_VORBIS_PUSHDATA_CRC_COUNT  4
+#endif
+
+// STB_VORBIS_FAST_HUFFMAN_LENGTH [number]
+//     sets the log size of the huffman-acceleration table.  Maximum
+//     supported value is 24. with larger numbers, more decodings are O(1),
+//     but the table size is larger so worse cache missing, so you'll have
+//     to probe (and try multiple ogg vorbis files) to find the sweet spot.
+#ifndef STB_VORBIS_FAST_HUFFMAN_LENGTH
+#define STB_VORBIS_FAST_HUFFMAN_LENGTH   10
+#endif
+
+// STB_VORBIS_FAST_BINARY_LENGTH [number]
+//     sets the log size of the binary-search acceleration table. this
+//     is used in similar fashion to the fast-huffman size to set initial
+//     parameters for the binary search
+
+// STB_VORBIS_FAST_HUFFMAN_INT
+//     The fast huffman tables are much more efficient if they can be
+//     stored as 16-bit results instead of 32-bit results. This restricts
+//     the codebooks to having only 65535 possible outcomes, though.
+//     (At least, accelerated by the huffman table.)
+#ifndef STB_VORBIS_FAST_HUFFMAN_INT
+#define STB_VORBIS_FAST_HUFFMAN_SHORT
+#endif
+
+// STB_VORBIS_NO_HUFFMAN_BINARY_SEARCH
+//     If the 'fast huffman' search doesn't succeed, then stb_vorbis falls
+//     back on binary searching for the correct one. This requires storing
+//     extra tables with the huffman codes in sorted order. Defining this
+//     symbol trades off space for speed by forcing a linear search in the
+//     non-fast case, except for "sparse" codebooks.
+// #define STB_VORBIS_NO_HUFFMAN_BINARY_SEARCH
+
+// STB_VORBIS_DIVIDES_IN_RESIDUE
+//     stb_vorbis precomputes the result of the scalar residue decoding
+//     that would otherwise require a divide per chunk. you can trade off
+//     space for time by defining this symbol.
+// #define STB_VORBIS_DIVIDES_IN_RESIDUE
+
+// STB_VORBIS_DIVIDES_IN_CODEBOOK
+//     vorbis VQ codebooks can be encoded two ways: with every case explicitly
+//     stored, or with all elements being chosen from a small range of values,
+//     and all values possible in all elements. By default, stb_vorbis expands
+//     this latter kind out to look like the former kind for ease of decoding,
+//     because otherwise an integer divide-per-vector-element is required to
+//     unpack the index. If you define STB_VORBIS_DIVIDES_IN_CODEBOOK, you can
+//     trade off storage for speed.
+//#define STB_VORBIS_DIVIDES_IN_CODEBOOK
+
+#ifdef STB_VORBIS_CODEBOOK_SHORTS
+#error "STB_VORBIS_CODEBOOK_SHORTS is no longer supported as it produced incorrect results for some input formats"
+#endif
+
+// STB_VORBIS_DIVIDE_TABLE
+//     this replaces small integer divides in the floor decode loop with
+//     table lookups. made less than 1% difference, so disabled by default.
+
+// STB_VORBIS_NO_INLINE_DECODE
+//     disables the inlining of the scalar codebook fast-huffman decode.
+//     might save a little codespace; useful for debugging
+// #define STB_VORBIS_NO_INLINE_DECODE
+
+// STB_VORBIS_NO_DEFER_FLOOR
+//     Normally we only decode the floor without synthesizing the actual
+//     full curve. We can instead synthesize the curve immediately. This
+//     requires more memory and is very likely slower, so I don't think
+//     you'd ever want to do it except for debugging.
+// #define STB_VORBIS_NO_DEFER_FLOOR
+
+
+
+
+//////////////////////////////////////////////////////////////////////////////
+
+#ifdef STB_VORBIS_NO_PULLDATA_API
+   #define STB_VORBIS_NO_INTEGER_CONVERSION
+   #define STB_VORBIS_NO_STDIO
+#endif
+
+#if defined(STB_VORBIS_NO_CRT) && !defined(STB_VORBIS_NO_STDIO)
+   #define STB_VORBIS_NO_STDIO 1
+#endif
+
+#ifndef STB_VORBIS_NO_INTEGER_CONVERSION
+#ifndef STB_VORBIS_NO_FAST_SCALED_FLOAT
+
+   // only need endianness for fast-float-to-int, which we don't
+   // use for pushdata
+
+   #ifndef STB_VORBIS_BIG_ENDIAN
+     #define STB_VORBIS_ENDIAN  0
+   #else
+     #define STB_VORBIS_ENDIAN  1
+   #endif
+
+#endif
+#endif
+
+
+#ifndef STB_VORBIS_NO_STDIO
+#include <stdio.h>
+#endif
+
+#ifndef STB_VORBIS_NO_CRT
+   #include <stdlib.h>
+   #include <string.h>
+   #include <assert.h>
+   #include <math.h>
+
+   // find definition of alloca if it's not in stdlib.h:
+   #if defined(_MSC_VER) || defined(__MINGW32__)
+      #include <malloc.h>
+   #endif
+   #if defined(__linux__) || defined(__linux) || defined(__sun__) || defined(__EMSCRIPTEN__) || defined(__NEWLIB__)
+      #include <alloca.h>
+   #endif
+#else // STB_VORBIS_NO_CRT
+   #define NULL 0
+   #define malloc(s)   0
+   #define free(s)     ((void) 0)
+   #define realloc(s)  0
+#endif // STB_VORBIS_NO_CRT
+
+#include <limits.h>
+
+#ifdef __MINGW32__
+   // eff you mingw:
+   //     "fixed":
+   //         http://sourceforge.net/p/mingw-w64/mailman/message/32882927/
+   //     "no that broke the build, reverted, who cares about C":
+   //         http://sourceforge.net/p/mingw-w64/mailman/message/32890381/
+   #ifdef __forceinline
+   #undef __forceinline
+   #endif
+   #define __forceinline
+   #ifndef alloca
+   #define alloca __builtin_alloca
+   #endif
+#elif !defined(_MSC_VER)
+   #if __GNUC__
+      #define __forceinline inline
+   #else
+      #define __forceinline
+   #endif
+#endif
+
+#if STB_VORBIS_MAX_CHANNELS > 256
+#error "Value of STB_VORBIS_MAX_CHANNELS outside of allowed range"
+#endif
+
+#if STB_VORBIS_FAST_HUFFMAN_LENGTH > 24
+#error "Value of STB_VORBIS_FAST_HUFFMAN_LENGTH outside of allowed range"
+#endif
+
+
+#if 0
+#include <crtdbg.h>
+#define CHECK(f)   _CrtIsValidHeapPointer(f->channel_buffers[1])
+#else
+#define CHECK(f)   ((void) 0)
+#endif
+
+#define MAX_BLOCKSIZE_LOG  13   // from specification
+#define MAX_BLOCKSIZE      (1 << MAX_BLOCKSIZE_LOG)
+
+
+typedef unsigned char  uint8;
+typedef   signed char   int8;
+typedef unsigned short uint16;
+typedef   signed short  int16;
+typedef unsigned int   uint32;
+typedef   signed int    int32;
+
+#ifndef TRUE
+#define TRUE 1
+#define FALSE 0
+#endif
+
+typedef float codetype;
+
+#ifdef _MSC_VER
+#define STBV_NOTUSED(v)  (void)(v)
+#else
+#define STBV_NOTUSED(v)  (void)sizeof(v)
+#endif
+
+// @NOTE
+//
+// Some arrays below are tagged "//varies", which means it's actually
+// a variable-sized piece of data, but rather than malloc I assume it's
+// small enough it's better to just allocate it all together with the
+// main thing
+//
+// Most of the variables are specified with the smallest size I could pack
+// them into. It might give better performance to make them all full-sized
+// integers. It should be safe to freely rearrange the structures or change
+// the sizes larger--nothing relies on silently truncating etc., nor the
+// order of variables.
+
+#define FAST_HUFFMAN_TABLE_SIZE   (1 << STB_VORBIS_FAST_HUFFMAN_LENGTH)
+#define FAST_HUFFMAN_TABLE_MASK   (FAST_HUFFMAN_TABLE_SIZE - 1)
+
+typedef struct
+{
+   int dimensions, entries;
+   uint8 *codeword_lengths;
+   float  minimum_value;
+   float  delta_value;
+   uint8  value_bits;
+   uint8  lookup_type;
+   uint8  sequence_p;
+   uint8  sparse;
+   uint32 lookup_values;
+   codetype *multiplicands;
+   uint32 *codewords;
+   #ifdef STB_VORBIS_FAST_HUFFMAN_SHORT
+    int16  fast_huffman[FAST_HUFFMAN_TABLE_SIZE];
+   #else
+    int32  fast_huffman[FAST_HUFFMAN_TABLE_SIZE];
+   #endif
+   uint32 *sorted_codewords;
+   int    *sorted_values;
+   int     sorted_entries;
+} Codebook;
+
+typedef struct
+{
+   uint8 order;
+   uint16 rate;
+   uint16 bark_map_size;
+   uint8 amplitude_bits;
+   uint8 amplitude_offset;
+   uint8 number_of_books;
+   uint8 book_list[16]; // varies
+} Floor0;
+
+typedef struct
+{
+   uint8 partitions;
+   uint8 partition_class_list[32]; // varies
+   uint8 class_dimensions[16]; // varies
+   uint8 class_subclasses[16]; // varies
+   uint8 class_masterbooks[16]; // varies
+   int16 subclass_books[16][8]; // varies
+   uint16 Xlist[31*8+2]; // varies
+   uint8 sorted_order[31*8+2];
+   uint8 neighbors[31*8+2][2];
+   uint8 floor1_multiplier;
+   uint8 rangebits;
+   int values;
+} Floor1;
+
+typedef union
+{
+   Floor0 floor0;
+   Floor1 floor1;
+} Floor;
+
+typedef struct
+{
+   uint32 begin, end;
+   uint32 part_size;
+   uint8 classifications;
+   uint8 classbook;
+   uint8 **classdata;
+   int16 (*residue_books)[8];
+} Residue;
+
+typedef struct
+{
+   uint8 magnitude;
+   uint8 angle;
+   uint8 mux;
+} MappingChannel;
+
+typedef struct
+{
+   uint16 coupling_steps;
+   MappingChannel *chan;
+   uint8  submaps;
+   uint8  submap_floor[15]; // varies
+   uint8  submap_residue[15]; // varies
+} Mapping;
+
+typedef struct
+{
+   uint8 blockflag;
+   uint8 mapping;
+   uint16 windowtype;
+   uint16 transformtype;
+} Mode;
+
+typedef struct
+{
+   uint32  goal_crc;    // expected crc if match
+   int     bytes_left;  // bytes left in packet
+   uint32  crc_so_far;  // running crc
+   int     bytes_done;  // bytes processed in _current_ chunk
+   uint32  sample_loc;  // granule pos encoded in page
+} CRCscan;
+
+typedef struct
+{
+   uint32 page_start, page_end;
+   uint32 last_decoded_sample;
+} ProbedPage;
+
+struct stb_vorbis
+{
+  // user-accessible info
+   unsigned int sample_rate;
+   int channels;
+
+   unsigned int setup_memory_required;
+   unsigned int temp_memory_required;
+   unsigned int setup_temp_memory_required;
+
+   char *vendor;
+   int comment_list_length;
+   char **comment_list;
+
+  // input config
+#ifndef STB_VORBIS_NO_STDIO
+   FILE *f;
+   uint32 f_start;
+   int close_on_free;
+#endif
+
+   uint8 *stream;
+   uint8 *stream_start;
+   uint8 *stream_end;
+
+   uint32 stream_len;
+
+   uint8  push_mode;
+
+   // the page to seek to when seeking to start, may be zero
+   uint32 first_audio_page_offset;
+
+   // p_first is the page on which the first audio packet ends
+   // (but not necessarily the page on which it starts)
+   ProbedPage p_first, p_last;
+
+  // memory management
+   stb_vorbis_alloc alloc;
+   int setup_offset;
+   int temp_offset;
+
+  // run-time results
+   int eof;
+   enum STBVorbisError error;
+
+  // user-useful data
+
+  // header info
+   int blocksize[2];
+   int blocksize_0, blocksize_1;
+   int codebook_count;
+   Codebook *codebooks;
+   int floor_count;
+   uint16 floor_types[64]; // varies
+   Floor *floor_config;
+   int residue_count;
+   uint16 residue_types[64]; // varies
+   Residue *residue_config;
+   int mapping_count;
+   Mapping *mapping;
+   int mode_count;
+   Mode mode_config[64];  // varies
+
+   uint32 total_samples;
+
+  // decode buffer
+   float *channel_buffers[STB_VORBIS_MAX_CHANNELS];
+   float *outputs        [STB_VORBIS_MAX_CHANNELS];
+
+   float *previous_window[STB_VORBIS_MAX_CHANNELS];
+   int previous_length;
+
+   #ifndef STB_VORBIS_NO_DEFER_FLOOR
+   int16 *finalY[STB_VORBIS_MAX_CHANNELS];
+   #else
+   float *floor_buffers[STB_VORBIS_MAX_CHANNELS];
+   #endif
+
+   uint32 current_loc; // sample location of next frame to decode
+   int    current_loc_valid;
+
+  // per-blocksize precomputed data
+
+   // twiddle factors
+   float *A[2],*B[2],*C[2];
+   float *window[2];
+   uint16 *bit_reverse[2];
+
+  // current page/packet/segment streaming info
+   uint32 serial; // stream serial number for verification
+   int last_page;
+   int segment_count;
+   uint8 segments[255];
+   uint8 page_flag;
+   uint8 bytes_in_seg;
+   uint8 first_decode;
+   int next_seg;
+   int last_seg;  // flag that we're on the last segment
+   int last_seg_which; // what was the segment number of the last seg?
+   uint32 acc;
+   int valid_bits;
+   int packet_bytes;
+   int end_seg_with_known_loc;
+   uint32 known_loc_for_packet;
+   int discard_samples_deferred;
+   uint32 samples_output;
+
+  // push mode scanning
+   int page_crc_tests; // only in push_mode: number of tests active; -1 if not searching
+#ifndef STB_VORBIS_NO_PUSHDATA_API
+   CRCscan scan[STB_VORBIS_PUSHDATA_CRC_COUNT];
+#endif
+
+  // sample-access
+   int channel_buffer_start;
+   int channel_buffer_end;
+};
+
+#if defined(STB_VORBIS_NO_PUSHDATA_API)
+   #define IS_PUSH_MODE(f)   FALSE
+#elif defined(STB_VORBIS_NO_PULLDATA_API)
+   #define IS_PUSH_MODE(f)   TRUE
+#else
+   #define IS_PUSH_MODE(f)   ((f)->push_mode)
+#endif
+
+typedef struct stb_vorbis vorb;
+
+static int error(vorb *f, enum STBVorbisError e)
+{
+   f->error = e;
+   if (!f->eof && e != VORBIS_need_more_data) {
+      f->error=e; // breakpoint for debugging
+   }
+   return 0;
+}
+
+
+// these functions are used for allocating temporary memory
+// while decoding. if you can afford the stack space, use
+// alloca(); otherwise, provide a temp buffer and it will
+// allocate out of those.
+
+#define array_size_required(count,size)  (count*(sizeof(void *)+(size)))
+
+#define temp_alloc(f,size)              (f->alloc.alloc_buffer ? setup_temp_malloc(f,size) : alloca(size))
+#define temp_free(f,p)                  (void)0
+#define temp_alloc_save(f)              ((f)->temp_offset)
+#define temp_alloc_restore(f,p)         ((f)->temp_offset = (p))
+
+#define temp_block_array(f,count,size)  make_block_array(temp_alloc(f,array_size_required(count,size)), count, size)
+
+// given a sufficiently large block of memory, make an array of pointers to subblocks of it
+static void *make_block_array(void *mem, int count, int size)
+{
+   int i;
+   void ** p = (void **) mem;
+   char *q = (char *) (p + count);
+   for (i=0; i < count; ++i) {
+      p[i] = q;
+      q += size;
+   }
+   return p;
+}
+
+static void *setup_malloc(vorb *f, int sz)
+{
+   sz = (sz+7) & ~7; // round up to nearest 8 for alignment of future allocs.
+   f->setup_memory_required += sz;
+   if (f->alloc.alloc_buffer) {
+      void *p = (char *) f->alloc.alloc_buffer + f->setup_offset;
+      if (f->setup_offset + sz > f->temp_offset) return NULL;
+      f->setup_offset += sz;
+      return p;
+   }
+   return sz ? malloc(sz) : NULL;
+}
+
+static void setup_free(vorb *f, void *p)
+{
+   if (f->alloc.alloc_buffer) return; // do nothing; setup mem is a stack
+   free(p);
+}
+
+static void *setup_temp_malloc(vorb *f, int sz)
+{
+   sz = (sz+7) & ~7; // round up to nearest 8 for alignment of future allocs.
+   if (f->alloc.alloc_buffer) {
+      if (f->temp_offset - sz < f->setup_offset) return NULL;
+      f->temp_offset -= sz;
+      return (char *) f->alloc.alloc_buffer + f->temp_offset;
+   }
+   return malloc(sz);
+}
+
+static void setup_temp_free(vorb *f, void *p, int sz)
+{
+   if (f->alloc.alloc_buffer) {
+      f->temp_offset += (sz+7)&~7;
+      return;
+   }
+   free(p);
+}
+
+#define CRC32_POLY    0x04c11db7   // from spec
+
+static uint32 crc_table[256];
+static void crc32_init(void)
+{
+   int i,j;
+   uint32 s;
+   for(i=0; i < 256; i++) {
+      for (s=(uint32) i << 24, j=0; j < 8; ++j)
+         s = (s << 1) ^ (s >= (1U<<31) ? CRC32_POLY : 0);
+      crc_table[i] = s;
+   }
+}
+
+static __forceinline uint32 crc32_update(uint32 crc, uint8 byte)
+{
+   return (crc << 8) ^ crc_table[byte ^ (crc >> 24)];
+}
+
+
+// used in setup, and for huffman that doesn't go fast path
+static unsigned int bit_reverse(unsigned int n)
+{
+  n = ((n & 0xAAAAAAAA) >>  1) | ((n & 0x55555555) << 1);
+  n = ((n & 0xCCCCCCCC) >>  2) | ((n & 0x33333333) << 2);
+  n = ((n & 0xF0F0F0F0) >>  4) | ((n & 0x0F0F0F0F) << 4);
+  n = ((n & 0xFF00FF00) >>  8) | ((n & 0x00FF00FF) << 8);
+  return (n >> 16) | (n << 16);
+}
+
+static float square(float x)
+{
+   return x*x;
+}
+
+// this is a weird definition of log2() for which log2(1) = 1, log2(2) = 2, log2(4) = 3
+// as required by the specification. fast(?) implementation from stb.h
+// @OPTIMIZE: called multiple times per-packet with "constants"; move to setup
+static int ilog(int32 n)
+{
+   static signed char log2_4[16] = { 0,1,2,2,3,3,3,3,4,4,4,4,4,4,4,4 };
+
+   if (n < 0) return 0; // signed n returns 0
+
+   // 2 compares if n < 16, 3 compares otherwise (4 if signed or n > 1<<29)
+   if (n < (1 << 14))
+        if (n < (1 <<  4))            return  0 + log2_4[n      ];
+        else if (n < (1 <<  9))       return  5 + log2_4[n >>  5];
+             else                     return 10 + log2_4[n >> 10];
+   else if (n < (1 << 24))
+             if (n < (1 << 19))       return 15 + log2_4[n >> 15];
+             else                     return 20 + log2_4[n >> 20];
+        else if (n < (1 << 29))       return 25 + log2_4[n >> 25];
+             else                     return 30 + log2_4[n >> 30];
+}
+
+#ifndef M_PI
+  #define M_PI  3.14159265358979323846264f  // from CRC
+#endif
+
+// code length assigned to a value with no huffman encoding
+#define NO_CODE   255
+
+/////////////////////// LEAF SETUP FUNCTIONS //////////////////////////
+//
+// these functions are only called at setup, and only a few times
+// per file
+
+static float float32_unpack(uint32 x)
+{
+   // from the specification
+   uint32 mantissa = x & 0x1fffff;
+   uint32 sign = x & 0x80000000;
+   uint32 exp = (x & 0x7fe00000) >> 21;
+   double res = sign ? -(double)mantissa : (double)mantissa;
+   return (float) ldexp((float)res, (int)exp-788);
+}
+
+
+// zlib & jpeg huffman tables assume that the output symbols
+// can either be arbitrarily arranged, or have monotonically
+// increasing frequencies--they rely on the lengths being sorted;
+// this makes for a very simple generation algorithm.
+// vorbis allows a huffman table with non-sorted lengths. This
+// requires a more sophisticated construction, since symbols in
+// order do not map to huffman codes "in order".
+static void add_entry(Codebook *c, uint32 huff_code, int symbol, int count, int len, uint32 *values)
+{
+   if (!c->sparse) {
+      c->codewords      [symbol] = huff_code;
+   } else {
+      c->codewords       [count] = huff_code;
+      c->codeword_lengths[count] = len;
+      values             [count] = symbol;
+   }
+}
+
+static int compute_codewords(Codebook *c, uint8 *len, int n, uint32 *values)
+{
+   int i,k,m=0;
+   uint32 available[32];
+
+   memset(available, 0, sizeof(available));
+   // find the first entry
+   for (k=0; k < n; ++k) if (len[k] < NO_CODE) break;
+   if (k == n) { assert(c->sorted_entries == 0); return TRUE; }
+   assert(len[k] < 32); // no error return required, code reading lens checks this
+   // add to the list
+   add_entry(c, 0, k, m++, len[k], values);
+   // add all available leaves
+   for (i=1; i <= len[k]; ++i)
+      available[i] = 1U << (32-i);
+   // note that the above code treats the first case specially,
+   // but it's really the same as the following code, so they
+   // could probably be combined (except the initial code is 0,
+   // and I use 0 in available[] to mean 'empty')
+   for (i=k+1; i < n; ++i) {
+      uint32 res;
+      int z = len[i], y;
+      if (z == NO_CODE) continue;
+      assert(z < 32); // no error return required, code reading lens checks this
+      // find lowest available leaf (should always be earliest,
+      // which is what the specification calls for)
+      // note that this property, and the fact we can never have
+      // more than one free leaf at a given level, isn't totally
+      // trivial to prove, but it seems true and the assert never
+      // fires, so!
+      while (z > 0 && !available[z]) --z;
+      if (z == 0) { return FALSE; }
+      res = available[z];
+      available[z] = 0;
+      add_entry(c, bit_reverse(res), i, m++, len[i], values);
+      // propagate availability up the tree
+      if (z != len[i]) {
+         for (y=len[i]; y > z; --y) {
+            assert(available[y] == 0);
+            available[y] = res + (1 << (32-y));
+         }
+      }
+   }
+   return TRUE;
+}
+
+// accelerated huffman table allows fast O(1) match of all symbols
+// of length <= STB_VORBIS_FAST_HUFFMAN_LENGTH
+static void compute_accelerated_huffman(Codebook *c)
+{
+   int i, len;
+   for (i=0; i < FAST_HUFFMAN_TABLE_SIZE; ++i)
+      c->fast_huffman[i] = -1;
+
+   len = c->sparse ? c->sorted_entries : c->entries;
+   #ifdef STB_VORBIS_FAST_HUFFMAN_SHORT
+   if (len > 32767) len = 32767; // largest possible value we can encode!
+   #endif
+   for (i=0; i < len; ++i) {
+      if (c->codeword_lengths[i] <= STB_VORBIS_FAST_HUFFMAN_LENGTH) {
+         uint32 z = c->sparse ? bit_reverse(c->sorted_codewords[i]) : c->codewords[i];
+         // set table entries for all bit combinations in the higher bits
+         while (z < FAST_HUFFMAN_TABLE_SIZE) {
+             c->fast_huffman[z] = i;
+             z += 1 << c->codeword_lengths[i];
+         }
+      }
+   }
+}
+
+#ifdef _MSC_VER
+#define STBV_CDECL __cdecl
+#else
+#define STBV_CDECL
+#endif
+
+static int STBV_CDECL uint32_compare(const void *p, const void *q)
+{
+   uint32 x = * (uint32 *) p;
+   uint32 y = * (uint32 *) q;
+   return x < y ? -1 : x > y;
+}
+
+static int include_in_sort(Codebook *c, uint8 len)
+{
+   if (c->sparse) { assert(len != NO_CODE); return TRUE; }
+   if (len == NO_CODE) return FALSE;
+   if (len > STB_VORBIS_FAST_HUFFMAN_LENGTH) return TRUE;
+   return FALSE;
+}
+
+// if the fast table above doesn't work, we want to binary
+// search them... need to reverse the bits
+static void compute_sorted_huffman(Codebook *c, uint8 *lengths, uint32 *values)
+{
+   int i, len;
+   // build a list of all the entries
+   // OPTIMIZATION: don't include the short ones, since they'll be caught by FAST_HUFFMAN.
+   // this is kind of a frivolous optimization--I don't see any performance improvement,
+   // but it's like 4 extra lines of code, so.
+   if (!c->sparse) {
+      int k = 0;
+      for (i=0; i < c->entries; ++i)
+         if (include_in_sort(c, lengths[i]))
+            c->sorted_codewords[k++] = bit_reverse(c->codewords[i]);
+      assert(k == c->sorted_entries);
+   } else {
+      for (i=0; i < c->sorted_entries; ++i)
+         c->sorted_codewords[i] = bit_reverse(c->codewords[i]);
+   }
+
+   qsort(c->sorted_codewords, c->sorted_entries, sizeof(c->sorted_codewords[0]), uint32_compare);
+   c->sorted_codewords[c->sorted_entries] = 0xffffffff;
+
+   len = c->sparse ? c->sorted_entries : c->entries;
+   // now we need to indicate how they correspond; we could either
+   //   #1: sort a different data structure that says who they correspond to
+   //   #2: for each sorted entry, search the original list to find who corresponds
+   //   #3: for each original entry, find the sorted entry
+   // #1 requires extra storage, #2 is slow, #3 can use binary search!
+   for (i=0; i < len; ++i) {
+      int huff_len = c->sparse ? lengths[values[i]] : lengths[i];
+      if (include_in_sort(c,huff_len)) {
+         uint32 code = bit_reverse(c->codewords[i]);
+         int x=0, n=c->sorted_entries;
+         while (n > 1) {
+            // invariant: sc[x] <= code < sc[x+n]
+            int m = x + (n >> 1);
+            if (c->sorted_codewords[m] <= code) {
+               x = m;
+               n -= (n>>1);
+            } else {
+               n >>= 1;
+            }
+         }
+         assert(c->sorted_codewords[x] == code);
+         if (c->sparse) {
+            c->sorted_values[x] = values[i];
+            c->codeword_lengths[x] = huff_len;
+         } else {
+            c->sorted_values[x] = i;
+         }
+      }
+   }
+}
+
+// only run while parsing the header (3 times)
+static int vorbis_validate(uint8 *data)
+{
+   static uint8 vorbis[6] = { 'v', 'o', 'r', 'b', 'i', 's' };
+   return memcmp(data, vorbis, 6) == 0;
+}
+
+// called from setup only, once per code book
+// (formula implied by specification)
+static int lookup1_values(int entries, int dim)
+{
+   int r = (int) floor(exp((float) log((float) entries) / dim));
+   if ((int) floor(pow((float) r+1, dim)) <= entries)   // (int) cast for MinGW warning;
+      ++r;                                              // floor() to avoid _ftol() when non-CRT
+   if (pow((float) r+1, dim) <= entries)
+      return -1;
+   if ((int) floor(pow((float) r, dim)) > entries)
+      return -1;
+   return r;
+}
+
+// called twice per file
+static void compute_twiddle_factors(int n, float *A, float *B, float *C)
+{
+   int n4 = n >> 2, n8 = n >> 3;
+   int k,k2;
+
+   for (k=k2=0; k < n4; ++k,k2+=2) {
+      A[k2  ] = (float)  cos(4*k*M_PI/n);
+      A[k2+1] = (float) -sin(4*k*M_PI/n);
+      B[k2  ] = (float)  cos((k2+1)*M_PI/n/2) * 0.5f;
+      B[k2+1] = (float)  sin((k2+1)*M_PI/n/2) * 0.5f;
+   }
+   for (k=k2=0; k < n8; ++k,k2+=2) {
+      C[k2  ] = (float)  cos(2*(k2+1)*M_PI/n);
+      C[k2+1] = (float) -sin(2*(k2+1)*M_PI/n);
+   }
+}
+
+static void compute_window(int n, float *window)
+{
+   int n2 = n >> 1, i;
+   for (i=0; i < n2; ++i)
+      window[i] = (float) sin(0.5 * M_PI * square((float) sin((i - 0 + 0.5) / n2 * 0.5 * M_PI)));
+}
+
+static void compute_bitreverse(int n, uint16 *rev)
+{
+   int ld = ilog(n) - 1; // ilog is off-by-one from normal definitions
+   int i, n8 = n >> 3;
+   for (i=0; i < n8; ++i)
+      rev[i] = (bit_reverse(i) >> (32-ld+3)) << 2;
+}
+
+static int init_blocksize(vorb *f, int b, int n)
+{
+   int n2 = n >> 1, n4 = n >> 2, n8 = n >> 3;
+   f->A[b] = (float *) setup_malloc(f, sizeof(float) * n2);
+   f->B[b] = (float *) setup_malloc(f, sizeof(float) * n2);
+   f->C[b] = (float *) setup_malloc(f, sizeof(float) * n4);
+   if (!f->A[b] || !f->B[b] || !f->C[b]) return error(f, VORBIS_outofmem);
+   compute_twiddle_factors(n, f->A[b], f->B[b], f->C[b]);
+   f->window[b] = (float *) setup_malloc(f, sizeof(float) * n2);
+   if (!f->window[b]) return error(f, VORBIS_outofmem);
+   compute_window(n, f->window[b]);
+   f->bit_reverse[b] = (uint16 *) setup_malloc(f, sizeof(uint16) * n8);
+   if (!f->bit_reverse[b]) return error(f, VORBIS_outofmem);
+   compute_bitreverse(n, f->bit_reverse[b]);
+   return TRUE;
+}
+
+static void neighbors(uint16 *x, int n, int *plow, int *phigh)
+{
+   int low = -1;
+   int high = 65536;
+   int i;
+   for (i=0; i < n; ++i) {
+      if (x[i] > low  && x[i] < x[n]) { *plow  = i; low = x[i]; }
+      if (x[i] < high && x[i] > x[n]) { *phigh = i; high = x[i]; }
+   }
+}
+
+// this has been repurposed so y is now the original index instead of y
+typedef struct
+{
+   uint16 x,id;
+} stbv__floor_ordering;
+
+static int STBV_CDECL point_compare(const void *p, const void *q)
+{
+   stbv__floor_ordering *a = (stbv__floor_ordering *) p;
+   stbv__floor_ordering *b = (stbv__floor_ordering *) q;
+   return a->x < b->x ? -1 : a->x > b->x;
+}
+
+//
+/////////////////////// END LEAF SETUP FUNCTIONS //////////////////////////
+
+
+#if defined(STB_VORBIS_NO_STDIO)
+   #define USE_MEMORY(z)    TRUE
+#else
+   #define USE_MEMORY(z)    ((z)->stream)
+#endif
+
+static uint8 get8(vorb *z)
+{
+   if (USE_MEMORY(z)) {
+      if (z->stream >= z->stream_end) { z->eof = TRUE; return 0; }
+      return *z->stream++;
+   }
+
+   #ifndef STB_VORBIS_NO_STDIO
+   {
+   int c = fgetc(z->f);
+   if (c == EOF) { z->eof = TRUE; return 0; }
+   return c;
+   }
+   #endif
+}
+
+static uint32 get32(vorb *f)
+{
+   uint32 x;
+   x = get8(f);
+   x += get8(f) << 8;
+   x += get8(f) << 16;
+   x += (uint32) get8(f) << 24;
+   return x;
+}
+
+static int getn(vorb *z, uint8 *data, int n)
+{
+   if (USE_MEMORY(z)) {
+      if (z->stream+n > z->stream_end) { z->eof = 1; return 0; }
+      memcpy(data, z->stream, n);
+      z->stream += n;
+      return 1;
+   }
+
+   #ifndef STB_VORBIS_NO_STDIO
+   if (fread(data, n, 1, z->f) == 1)
+      return 1;
+   else {
+      z->eof = 1;
+      return 0;
+   }
+   #endif
+}
+
+static void skip(vorb *z, int n)
+{
+   if (USE_MEMORY(z)) {
+      z->stream += n;
+      if (z->stream >= z->stream_end) z->eof = 1;
+      return;
+   }
+   #ifndef STB_VORBIS_NO_STDIO
+   {
+      long x = ftell(z->f);
+      fseek(z->f, x+n, SEEK_SET);
+   }
+   #endif
+}
+
+static int set_file_offset(stb_vorbis *f, unsigned int loc)
+{
+   #ifndef STB_VORBIS_NO_PUSHDATA_API
+   if (f->push_mode) return 0;
+   #endif
+   f->eof = 0;
+   if (USE_MEMORY(f)) {
+      if (f->stream_start + loc >= f->stream_end || f->stream_start + loc < f->stream_start) {
+         f->stream = f->stream_end;
+         f->eof = 1;
+         return 0;
+      } else {
+         f->stream = f->stream_start + loc;
+         return 1;
+      }
+   }
+   #ifndef STB_VORBIS_NO_STDIO
+   if (loc + f->f_start < loc || loc >= 0x80000000) {
+      loc = 0x7fffffff;
+      f->eof = 1;
+   } else {
+      loc += f->f_start;
+   }
+   if (!fseek(f->f, loc, SEEK_SET))
+      return 1;
+   f->eof = 1;
+   fseek(f->f, f->f_start, SEEK_END);
+   return 0;
+   #endif
+}
+
+
+static uint8 ogg_page_header[4] = { 0x4f, 0x67, 0x67, 0x53 };
+
+static int capture_pattern(vorb *f)
+{
+   if (0x4f != get8(f)) return FALSE;
+   if (0x67 != get8(f)) return FALSE;
+   if (0x67 != get8(f)) return FALSE;
+   if (0x53 != get8(f)) return FALSE;
+   return TRUE;
+}
+
+#define PAGEFLAG_continued_packet   1
+#define PAGEFLAG_first_page         2
+#define PAGEFLAG_last_page          4
+
+static int start_page_no_capturepattern(vorb *f)
+{
+   uint32 loc0,loc1,n;
+   if (f->first_decode && !IS_PUSH_MODE(f)) {
+      f->p_first.page_start = stb_vorbis_get_file_offset(f) - 4;
+   }
+   // stream structure version
+   if (0 != get8(f)) return error(f, VORBIS_invalid_stream_structure_version);
+   // header flag
+   f->page_flag = get8(f);
+   // absolute granule position
+   loc0 = get32(f);
+   loc1 = get32(f);
+   // @TODO: validate loc0,loc1 as valid positions?
+   // stream serial number -- vorbis doesn't interleave, so discard
+   get32(f);
+   //if (f->serial != get32(f)) return error(f, VORBIS_incorrect_stream_serial_number);
+   // page sequence number
+   n = get32(f);
+   f->last_page = n;
+   // CRC32
+   get32(f);
+   // page_segments
+   f->segment_count = get8(f);
+   if (!getn(f, f->segments, f->segment_count))
+      return error(f, VORBIS_unexpected_eof);
+   // assume we _don't_ know any the sample position of any segments
+   f->end_seg_with_known_loc = -2;
+   if (loc0 != ~0U || loc1 != ~0U) {
+      int i;
+      // determine which packet is the last one that will complete
+      for (i=f->segment_count-1; i >= 0; --i)
+         if (f->segments[i] < 255)
+            break;
+      // 'i' is now the index of the _last_ segment of a packet that ends
+      if (i >= 0) {
+         f->end_seg_with_known_loc = i;
+         f->known_loc_for_packet   = loc0;
+      }
+   }
+   if (f->first_decode) {
+      int i,len;
+      len = 0;
+      for (i=0; i < f->segment_count; ++i)
+         len += f->segments[i];
+      len += 27 + f->segment_count;
+      f->p_first.page_end = f->p_first.page_start + len;
+      f->p_first.last_decoded_sample = loc0;
+   }
+   f->next_seg = 0;
+   return TRUE;
+}
+
+static int start_page(vorb *f)
+{
+   if (!capture_pattern(f)) return error(f, VORBIS_missing_capture_pattern);
+   return start_page_no_capturepattern(f);
+}
+
+static int start_packet(vorb *f)
+{
+   while (f->next_seg == -1) {
+      if (!start_page(f)) return FALSE;
+      if (f->page_flag & PAGEFLAG_continued_packet)
+         return error(f, VORBIS_continued_packet_flag_invalid);
+   }
+   f->last_seg = FALSE;
+   f->valid_bits = 0;
+   f->packet_bytes = 0;
+   f->bytes_in_seg = 0;
+   // f->next_seg is now valid
+   return TRUE;
+}
+
+static int maybe_start_packet(vorb *f)
+{
+   if (f->next_seg == -1) {
+      int x = get8(f);
+      if (f->eof) return FALSE; // EOF at page boundary is not an error!
+      if (0x4f != x      ) return error(f, VORBIS_missing_capture_pattern);
+      if (0x67 != get8(f)) return error(f, VORBIS_missing_capture_pattern);
+      if (0x67 != get8(f)) return error(f, VORBIS_missing_capture_pattern);
+      if (0x53 != get8(f)) return error(f, VORBIS_missing_capture_pattern);
+      if (!start_page_no_capturepattern(f)) return FALSE;
+      if (f->page_flag & PAGEFLAG_continued_packet) {
+         // set up enough state that we can read this packet if we want,
+         // e.g. during recovery
+         f->last_seg = FALSE;
+         f->bytes_in_seg = 0;
+         return error(f, VORBIS_continued_packet_flag_invalid);
+      }
+   }
+   return start_packet(f);
+}
+
+static int next_segment(vorb *f)
+{
+   int len;
+   if (f->last_seg) return 0;
+   if (f->next_seg == -1) {
+      f->last_seg_which = f->segment_count-1; // in case start_page fails
+      if (!start_page(f)) { f->last_seg = 1; return 0; }
+      if (!(f->page_flag & PAGEFLAG_continued_packet)) return error(f, VORBIS_continued_packet_flag_invalid);
+   }
+   len = f->segments[f->next_seg++];
+   if (len < 255) {
+      f->last_seg = TRUE;
+      f->last_seg_which = f->next_seg-1;
+   }
+   if (f->next_seg >= f->segment_count)
+      f->next_seg = -1;
+   assert(f->bytes_in_seg == 0);
+   f->bytes_in_seg = len;
+   return len;
+}
+
+#define EOP    (-1)
+#define INVALID_BITS  (-1)
+
+static int get8_packet_raw(vorb *f)
+{
+   if (!f->bytes_in_seg) {  // CLANG!
+      if (f->last_seg) return EOP;
+      else if (!next_segment(f)) return EOP;
+   }
+   assert(f->bytes_in_seg > 0);
+   --f->bytes_in_seg;
+   ++f->packet_bytes;
+   return get8(f);
+}
+
+static int get8_packet(vorb *f)
+{
+   int x = get8_packet_raw(f);
+   f->valid_bits = 0;
+   return x;
+}
+
+static int get32_packet(vorb *f)
+{
+   uint32 x;
+   x = get8_packet(f);
+   x += get8_packet(f) << 8;
+   x += get8_packet(f) << 16;
+   x += (uint32) get8_packet(f) << 24;
+   return x;
+}
+
+static void flush_packet(vorb *f)
+{
+   while (get8_packet_raw(f) != EOP);
+}
+
+// @OPTIMIZE: this is the secondary bit decoder, so it's probably not as important
+// as the huffman decoder?
+static uint32 get_bits(vorb *f, int n)
+{
+   uint32 z;
+
+   if (f->valid_bits < 0) return 0;
+   if (f->valid_bits < n) {
+      if (n > 24) {
+         // the accumulator technique below would not work correctly in this case
+         z = get_bits(f, 24);
+         z += get_bits(f, n-24) << 24;
+         return z;
+      }
+      if (f->valid_bits == 0) f->acc = 0;
+      while (f->valid_bits < n) {
+         int z = get8_packet_raw(f);
+         if (z == EOP) {
+            f->valid_bits = INVALID_BITS;
+            return 0;
+         }
+         f->acc += z << f->valid_bits;
+         f->valid_bits += 8;
+      }
+   }
+
+   assert(f->valid_bits >= n);
+   z = f->acc & ((1 << n)-1);
+   f->acc >>= n;
+   f->valid_bits -= n;
+   return z;
+}
+
+// @OPTIMIZE: primary accumulator for huffman
+// expand the buffer to as many bits as possible without reading off end of packet
+// it might be nice to allow f->valid_bits and f->acc to be stored in registers,
+// e.g. cache them locally and decode locally
+static __forceinline void prep_huffman(vorb *f)
+{
+   if (f->valid_bits <= 24) {
+      if (f->valid_bits == 0) f->acc = 0;
+      do {
+         int z;
+         if (f->last_seg && !f->bytes_in_seg) return;
+         z = get8_packet_raw(f);
+         if (z == EOP) return;
+         f->acc += (unsigned) z << f->valid_bits;
+         f->valid_bits += 8;
+      } while (f->valid_bits <= 24);
+   }
+}
+
+enum
+{
+   VORBIS_packet_id = 1,
+   VORBIS_packet_comment = 3,
+   VORBIS_packet_setup = 5
+};
+
+static int codebook_decode_scalar_raw(vorb *f, Codebook *c)
+{
+   int i;
+   prep_huffman(f);
+
+   if (c->codewords == NULL && c->sorted_codewords == NULL)
+      return -1;
+
+   // cases to use binary search: sorted_codewords && !c->codewords
+   //                             sorted_codewords && c->entries > 8
+   if (c->entries > 8 ? c->sorted_codewords!=NULL : !c->codewords) {
+      // binary search
+      uint32 code = bit_reverse(f->acc);
+      int x=0, n=c->sorted_entries, len;
+
+      while (n > 1) {
+         // invariant: sc[x] <= code < sc[x+n]
+         int m = x + (n >> 1);
+         if (c->sorted_codewords[m] <= code) {
+            x = m;
+            n -= (n>>1);
+         } else {
+            n >>= 1;
+         }
+      }
+      // x is now the sorted index
+      if (!c->sparse) x = c->sorted_values[x];
+      // x is now sorted index if sparse, or symbol otherwise
+      len = c->codeword_lengths[x];
+      if (f->valid_bits >= len) {
+         f->acc >>= len;
+         f->valid_bits -= len;
+         return x;
+      }
+
+      f->valid_bits = 0;
+      return -1;
+   }
+
+   // if small, linear search
+   assert(!c->sparse);
+   for (i=0; i < c->entries; ++i) {
+      if (c->codeword_lengths[i] == NO_CODE) continue;
+      if (c->codewords[i] == (f->acc & ((1 << c->codeword_lengths[i])-1))) {
+         if (f->valid_bits >= c->codeword_lengths[i]) {
+            f->acc >>= c->codeword_lengths[i];
+            f->valid_bits -= c->codeword_lengths[i];
+            return i;
+         }
+         f->valid_bits = 0;
+         return -1;
+      }
+   }
+
+   error(f, VORBIS_invalid_stream);
+   f->valid_bits = 0;
+   return -1;
+}
+
+#ifndef STB_VORBIS_NO_INLINE_DECODE
+
+#define DECODE_RAW(var, f,c)                                  \
+   if (f->valid_bits < STB_VORBIS_FAST_HUFFMAN_LENGTH)        \
+      prep_huffman(f);                                        \
+   var = f->acc & FAST_HUFFMAN_TABLE_MASK;                    \
+   var = c->fast_huffman[var];                                \
+   if (var >= 0) {                                            \
+      int n = c->codeword_lengths[var];                       \
+      f->acc >>= n;                                           \
+      f->valid_bits -= n;                                     \
+      if (f->valid_bits < 0) { f->valid_bits = 0; var = -1; } \
+   } else {                                                   \
+      var = codebook_decode_scalar_raw(f,c);                  \
+   }
+
+#else
+
+static int codebook_decode_scalar(vorb *f, Codebook *c)
+{
+   int i;
+   if (f->valid_bits < STB_VORBIS_FAST_HUFFMAN_LENGTH)
+      prep_huffman(f);
+   // fast huffman table lookup
+   i = f->acc & FAST_HUFFMAN_TABLE_MASK;
+   i = c->fast_huffman[i];
+   if (i >= 0) {
+      f->acc >>= c->codeword_lengths[i];
+      f->valid_bits -= c->codeword_lengths[i];
+      if (f->valid_bits < 0) { f->valid_bits = 0; return -1; }
+      return i;
+   }
+   return codebook_decode_scalar_raw(f,c);
+}
+
+#define DECODE_RAW(var,f,c)    var = codebook_decode_scalar(f,c);
+
+#endif
+
+#define DECODE(var,f,c)                                       \
+   DECODE_RAW(var,f,c)                                        \
+   if (c->sparse) var = c->sorted_values[var];
+
+#ifndef STB_VORBIS_DIVIDES_IN_CODEBOOK
+  #define DECODE_VQ(var,f,c)   DECODE_RAW(var,f,c)
+#else
+  #define DECODE_VQ(var,f,c)   DECODE(var,f,c)
+#endif
+
+
+
+
+
+
+// CODEBOOK_ELEMENT_FAST is an optimization for the CODEBOOK_FLOATS case
+// where we avoid one addition
+#define CODEBOOK_ELEMENT(c,off)          (c->multiplicands[off])
+#define CODEBOOK_ELEMENT_FAST(c,off)     (c->multiplicands[off])
+#define CODEBOOK_ELEMENT_BASE(c)         (0)
+
+static int codebook_decode_start(vorb *f, Codebook *c)
+{
+   int z = -1;
+
+   // type 0 is only legal in a scalar context
+   if (c->lookup_type == 0)
+      error(f, VORBIS_invalid_stream);
+   else {
+      DECODE_VQ(z,f,c);
+      if (c->sparse) assert(z < c->sorted_entries);
+      if (z < 0) {  // check for EOP
+         if (!f->bytes_in_seg)
+            if (f->last_seg)
+               return z;
+         error(f, VORBIS_invalid_stream);
+      }
+   }
+   return z;
+}
+
+static int codebook_decode(vorb *f, Codebook *c, float *output, int len)
+{
+   int i,z = codebook_decode_start(f,c);
+   if (z < 0) return FALSE;
+   if (len > c->dimensions) len = c->dimensions;
+
+#ifdef STB_VORBIS_DIVIDES_IN_CODEBOOK
+   if (c->lookup_type == 1) {
+      float last = CODEBOOK_ELEMENT_BASE(c);
+      int div = 1;
+      for (i=0; i < len; ++i) {
+         int off = (z / div) % c->lookup_values;
+         float val = CODEBOOK_ELEMENT_FAST(c,off) + last;
+         output[i] += val;
+         if (c->sequence_p) last = val + c->minimum_value;
+         div *= c->lookup_values;
+      }
+      return TRUE;
+   }
+#endif
+
+   z *= c->dimensions;
+   if (c->sequence_p) {
+      float last = CODEBOOK_ELEMENT_BASE(c);
+      for (i=0; i < len; ++i) {
+         float val = CODEBOOK_ELEMENT_FAST(c,z+i) + last;
+         output[i] += val;
+         last = val + c->minimum_value;
+      }
+   } else {
+      float last = CODEBOOK_ELEMENT_BASE(c);
+      for (i=0; i < len; ++i) {
+         output[i] += CODEBOOK_ELEMENT_FAST(c,z+i) + last;
+      }
+   }
+
+   return TRUE;
+}
+
+static int codebook_decode_step(vorb *f, Codebook *c, float *output, int len, int step)
+{
+   int i,z = codebook_decode_start(f,c);
+   float last = CODEBOOK_ELEMENT_BASE(c);
+   if (z < 0) return FALSE;
+   if (len > c->dimensions) len = c->dimensions;
+
+#ifdef STB_VORBIS_DIVIDES_IN_CODEBOOK
+   if (c->lookup_type == 1) {
+      int div = 1;
+      for (i=0; i < len; ++i) {
+         int off = (z / div) % c->lookup_values;
+         float val = CODEBOOK_ELEMENT_FAST(c,off) + last;
+         output[i*step] += val;
+         if (c->sequence_p) last = val;
+         div *= c->lookup_values;
+      }
+      return TRUE;
+   }
+#endif
+
+   z *= c->dimensions;
+   for (i=0; i < len; ++i) {
+      float val = CODEBOOK_ELEMENT_FAST(c,z+i) + last;
+      output[i*step] += val;
+      if (c->sequence_p) last = val;
+   }
+
+   return TRUE;
+}
+
+static int codebook_decode_deinterleave_repeat(vorb *f, Codebook *c, float **outputs, int ch, int *c_inter_p, int *p_inter_p, int len, int total_decode)
+{
+   int c_inter = *c_inter_p;
+   int p_inter = *p_inter_p;
+   int i,z, effective = c->dimensions;
+
+   // type 0 is only legal in a scalar context
+   if (c->lookup_type == 0)   return error(f, VORBIS_invalid_stream);
+
+   while (total_decode > 0) {
+      float last = CODEBOOK_ELEMENT_BASE(c);
+      DECODE_VQ(z,f,c);
+      #ifndef STB_VORBIS_DIVIDES_IN_CODEBOOK
+      assert(!c->sparse || z < c->sorted_entries);
+      #endif
+      if (z < 0) {
+         if (!f->bytes_in_seg)
+            if (f->last_seg) return FALSE;
+         return error(f, VORBIS_invalid_stream);
+      }
+
+      // if this will take us off the end of the buffers, stop short!
+      // we check by computing the length of the virtual interleaved
+      // buffer (len*ch), our current offset within it (p_inter*ch)+(c_inter),
+      // and the length we'll be using (effective)
+      if (c_inter + p_inter*ch + effective > len * ch) {
+         effective = len*ch - (p_inter*ch - c_inter);
+      }
+
+   #ifdef STB_VORBIS_DIVIDES_IN_CODEBOOK
+      if (c->lookup_type == 1) {
+         int div = 1;
+         for (i=0; i < effective; ++i) {
+            int off = (z / div) % c->lookup_values;
+            float val = CODEBOOK_ELEMENT_FAST(c,off) + last;
+            if (outputs[c_inter])
+               outputs[c_inter][p_inter] += val;
+            if (++c_inter == ch) { c_inter = 0; ++p_inter; }
+            if (c->sequence_p) last = val;
+            div *= c->lookup_values;
+         }
+      } else
+   #endif
+      {
+         z *= c->dimensions;
+         if (c->sequence_p) {
+            for (i=0; i < effective; ++i) {
+               float val = CODEBOOK_ELEMENT_FAST(c,z+i) + last;
+               if (outputs[c_inter])
+                  outputs[c_inter][p_inter] += val;
+               if (++c_inter == ch) { c_inter = 0; ++p_inter; }
+               last = val;
+            }
+         } else {
+            for (i=0; i < effective; ++i) {
+               float val = CODEBOOK_ELEMENT_FAST(c,z+i) + last;
+               if (outputs[c_inter])
+                  outputs[c_inter][p_inter] += val;
+               if (++c_inter == ch) { c_inter = 0; ++p_inter; }
+            }
+         }
+      }
+
+      total_decode -= effective;
+   }
+   *c_inter_p = c_inter;
+   *p_inter_p = p_inter;
+   return TRUE;
+}
+
+static int predict_point(int x, int x0, int x1, int y0, int y1)
+{
+   int dy = y1 - y0;
+   int adx = x1 - x0;
+   // @OPTIMIZE: force int division to round in the right direction... is this necessary on x86?
+   int err = abs(dy) * (x - x0);
+   int off = err / adx;
+   return dy < 0 ? y0 - off : y0 + off;
+}
+
+// the following table is block-copied from the specification
+static float inverse_db_table[256] =
+{
+  1.0649863e-07f, 1.1341951e-07f, 1.2079015e-07f, 1.2863978e-07f,
+  1.3699951e-07f, 1.4590251e-07f, 1.5538408e-07f, 1.6548181e-07f,
+  1.7623575e-07f, 1.8768855e-07f, 1.9988561e-07f, 2.1287530e-07f,
+  2.2670913e-07f, 2.4144197e-07f, 2.5713223e-07f, 2.7384213e-07f,
+  2.9163793e-07f, 3.1059021e-07f, 3.3077411e-07f, 3.5226968e-07f,
+  3.7516214e-07f, 3.9954229e-07f, 4.2550680e-07f, 4.5315863e-07f,
+  4.8260743e-07f, 5.1396998e-07f, 5.4737065e-07f, 5.8294187e-07f,
+  6.2082472e-07f, 6.6116941e-07f, 7.0413592e-07f, 7.4989464e-07f,
+  7.9862701e-07f, 8.5052630e-07f, 9.0579828e-07f, 9.6466216e-07f,
+  1.0273513e-06f, 1.0941144e-06f, 1.1652161e-06f, 1.2409384e-06f,
+  1.3215816e-06f, 1.4074654e-06f, 1.4989305e-06f, 1.5963394e-06f,
+  1.7000785e-06f, 1.8105592e-06f, 1.9282195e-06f, 2.0535261e-06f,
+  2.1869758e-06f, 2.3290978e-06f, 2.4804557e-06f, 2.6416497e-06f,
+  2.8133190e-06f, 2.9961443e-06f, 3.1908506e-06f, 3.3982101e-06f,
+  3.6190449e-06f, 3.8542308e-06f, 4.1047004e-06f, 4.3714470e-06f,
+  4.6555282e-06f, 4.9580707e-06f, 5.2802740e-06f, 5.6234160e-06f,
+  5.9888572e-06f, 6.3780469e-06f, 6.7925283e-06f, 7.2339451e-06f,
+  7.7040476e-06f, 8.2047000e-06f, 8.7378876e-06f, 9.3057248e-06f,
+  9.9104632e-06f, 1.0554501e-05f, 1.1240392e-05f, 1.1970856e-05f,
+  1.2748789e-05f, 1.3577278e-05f, 1.4459606e-05f, 1.5399272e-05f,
+  1.6400004e-05f, 1.7465768e-05f, 1.8600792e-05f, 1.9809576e-05f,
+  2.1096914e-05f, 2.2467911e-05f, 2.3928002e-05f, 2.5482978e-05f,
+  2.7139006e-05f, 2.8902651e-05f, 3.0780908e-05f, 3.2781225e-05f,
+  3.4911534e-05f, 3.7180282e-05f, 3.9596466e-05f, 4.2169667e-05f,
+  4.4910090e-05f, 4.7828601e-05f, 5.0936773e-05f, 5.4246931e-05f,
+  5.7772202e-05f, 6.1526565e-05f, 6.5524908e-05f, 6.9783085e-05f,
+  7.4317983e-05f, 7.9147585e-05f, 8.4291040e-05f, 8.9768747e-05f,
+  9.5602426e-05f, 0.00010181521f, 0.00010843174f, 0.00011547824f,
+  0.00012298267f, 0.00013097477f, 0.00013948625f, 0.00014855085f,
+  0.00015820453f, 0.00016848555f, 0.00017943469f, 0.00019109536f,
+  0.00020351382f, 0.00021673929f, 0.00023082423f, 0.00024582449f,
+  0.00026179955f, 0.00027881276f, 0.00029693158f, 0.00031622787f,
+  0.00033677814f, 0.00035866388f, 0.00038197188f, 0.00040679456f,
+  0.00043323036f, 0.00046138411f, 0.00049136745f, 0.00052329927f,
+  0.00055730621f, 0.00059352311f, 0.00063209358f, 0.00067317058f,
+  0.00071691700f, 0.00076350630f, 0.00081312324f, 0.00086596457f,
+  0.00092223983f, 0.00098217216f, 0.0010459992f,  0.0011139742f,
+  0.0011863665f,  0.0012634633f,  0.0013455702f,  0.0014330129f,
+  0.0015261382f,  0.0016253153f,  0.0017309374f,  0.0018434235f,
+  0.0019632195f,  0.0020908006f,  0.0022266726f,  0.0023713743f,
+  0.0025254795f,  0.0026895994f,  0.0028643847f,  0.0030505286f,
+  0.0032487691f,  0.0034598925f,  0.0036847358f,  0.0039241906f,
+  0.0041792066f,  0.0044507950f,  0.0047400328f,  0.0050480668f,
+  0.0053761186f,  0.0057254891f,  0.0060975636f,  0.0064938176f,
+  0.0069158225f,  0.0073652516f,  0.0078438871f,  0.0083536271f,
+  0.0088964928f,  0.009474637f,   0.010090352f,   0.010746080f,
+  0.011444421f,   0.012188144f,   0.012980198f,   0.013823725f,
+  0.014722068f,   0.015678791f,   0.016697687f,   0.017782797f,
+  0.018938423f,   0.020169149f,   0.021479854f,   0.022875735f,
+  0.024362330f,   0.025945531f,   0.027631618f,   0.029427276f,
+  0.031339626f,   0.033376252f,   0.035545228f,   0.037855157f,
+  0.040315199f,   0.042935108f,   0.045725273f,   0.048696758f,
+  0.051861348f,   0.055231591f,   0.058820850f,   0.062643361f,
+  0.066714279f,   0.071049749f,   0.075666962f,   0.080584227f,
+  0.085821044f,   0.091398179f,   0.097337747f,   0.10366330f,
+  0.11039993f,    0.11757434f,    0.12521498f,    0.13335215f,
+  0.14201813f,    0.15124727f,    0.16107617f,    0.17154380f,
+  0.18269168f,    0.19456402f,    0.20720788f,    0.22067342f,
+  0.23501402f,    0.25028656f,    0.26655159f,    0.28387361f,
+  0.30232132f,    0.32196786f,    0.34289114f,    0.36517414f,
+  0.38890521f,    0.41417847f,    0.44109412f,    0.46975890f,
+  0.50028648f,    0.53279791f,    0.56742212f,    0.60429640f,
+  0.64356699f,    0.68538959f,    0.72993007f,    0.77736504f,
+  0.82788260f,    0.88168307f,    0.9389798f,     1.0f
+};
+
+
+// @OPTIMIZE: if you want to replace this bresenham line-drawing routine,
+// note that you must produce bit-identical output to decode correctly;
+// this specific sequence of operations is specified in the spec (it's
+// drawing integer-quantized frequency-space lines that the encoder
+// expects to be exactly the same)
+//     ... also, isn't the whole point of Bresenham's algorithm to NOT
+// have to divide in the setup? sigh.
+#ifndef STB_VORBIS_NO_DEFER_FLOOR
+#define LINE_OP(a,b)   a *= b
+#else
+#define LINE_OP(a,b)   a = b
+#endif
+
+#ifdef STB_VORBIS_DIVIDE_TABLE
+#define DIVTAB_NUMER   32
+#define DIVTAB_DENOM   64
+int8 integer_divide_table[DIVTAB_NUMER][DIVTAB_DENOM]; // 2KB
+#endif
+
+static __forceinline void draw_line(float *output, int x0, int y0, int x1, int y1, int n)
+{
+   int dy = y1 - y0;
+   int adx = x1 - x0;
+   int ady = abs(dy);
+   int base;
+   int x=x0,y=y0;
+   int err = 0;
+   int sy;
+
+#ifdef STB_VORBIS_DIVIDE_TABLE
+   if (adx < DIVTAB_DENOM && ady < DIVTAB_NUMER) {
+      if (dy < 0) {
+         base = -integer_divide_table[ady][adx];
+         sy = base-1;
+      } else {
+         base =  integer_divide_table[ady][adx];
+         sy = base+1;
+      }
+   } else {
+      base = dy / adx;
+      if (dy < 0)
+         sy = base - 1;
+      else
+         sy = base+1;
+   }
+#else
+   base = dy / adx;
+   if (dy < 0)
+      sy = base - 1;
+   else
+      sy = base+1;
+#endif
+   ady -= abs(base) * adx;
+   if (x1 > n) x1 = n;
+   if (x < x1) {
+      LINE_OP(output[x], inverse_db_table[y&255]);
+      for (++x; x < x1; ++x) {
+         err += ady;
+         if (err >= adx) {
+            err -= adx;
+            y += sy;
+         } else
+            y += base;
+         LINE_OP(output[x], inverse_db_table[y&255]);
+      }
+   }
+}
+
+static int residue_decode(vorb *f, Codebook *book, float *target, int offset, int n, int rtype)
+{
+   int k;
+   if (rtype == 0) {
+      int step = n / book->dimensions;
+      for (k=0; k < step; ++k)
+         if (!codebook_decode_step(f, book, target+offset+k, n-offset-k, step))
+            return FALSE;
+   } else {
+      for (k=0; k < n; ) {
+         if (!codebook_decode(f, book, target+offset, n-k))
+            return FALSE;
+         k += book->dimensions;
+         offset += book->dimensions;
+      }
+   }
+   return TRUE;
+}
+
+// n is 1/2 of the blocksize --
+// specification: "Correct per-vector decode length is [n]/2"
+static void decode_residue(vorb *f, float *residue_buffers[], int ch, int n, int rn, uint8 *do_not_decode)
+{
+   int i,j,pass;
+   Residue *r = f->residue_config + rn;
+   int rtype = f->residue_types[rn];
+   int c = r->classbook;
+   int classwords = f->codebooks[c].dimensions;
+   unsigned int actual_size = rtype == 2 ? n*2 : n;
+   unsigned int limit_r_begin = (r->begin < actual_size ? r->begin : actual_size);
+   unsigned int limit_r_end   = (r->end   < actual_size ? r->end   : actual_size);
+   int n_read = limit_r_end - limit_r_begin;
+   int part_read = n_read / r->part_size;
+   int temp_alloc_point = temp_alloc_save(f);
+   #ifndef STB_VORBIS_DIVIDES_IN_RESIDUE
+   uint8 ***part_classdata = (uint8 ***) temp_block_array(f,f->channels, part_read * sizeof(**part_classdata));
+   #else
+   int **classifications = (int **) temp_block_array(f,f->channels, part_read * sizeof(**classifications));
+   #endif
+
+   CHECK(f);
+
+   for (i=0; i < ch; ++i)
+      if (!do_not_decode[i])
+         memset(residue_buffers[i], 0, sizeof(float) * n);
+
+   if (rtype == 2 && ch != 1) {
+      for (j=0; j < ch; ++j)
+         if (!do_not_decode[j])
+            break;
+      if (j == ch)
+         goto done;
+
+      for (pass=0; pass < 8; ++pass) {
+         int pcount = 0, class_set = 0;
+         if (ch == 2) {
+            while (pcount < part_read) {
+               int z = r->begin + pcount*r->part_size;
+               int c_inter = (z & 1), p_inter = z>>1;
+               if (pass == 0) {
+                  Codebook *c = f->codebooks+r->classbook;
+                  int q;
+                  DECODE(q,f,c);
+                  if (q == EOP) goto done;
+                  #ifndef STB_VORBIS_DIVIDES_IN_RESIDUE
+                  part_classdata[0][class_set] = r->classdata[q];
+                  #else
+                  for (i=classwords-1; i >= 0; --i) {
+                     classifications[0][i+pcount] = q % r->classifications;
+                     q /= r->classifications;
+                  }
+                  #endif
+               }
+               for (i=0; i < classwords && pcount < part_read; ++i, ++pcount) {
+                  int z = r->begin + pcount*r->part_size;
+                  #ifndef STB_VORBIS_DIVIDES_IN_RESIDUE
+                  int c = part_classdata[0][class_set][i];
+                  #else
+                  int c = classifications[0][pcount];
+                  #endif
+                  int b = r->residue_books[c][pass];
+                  if (b >= 0) {
+                     Codebook *book = f->codebooks + b;
+                     #ifdef STB_VORBIS_DIVIDES_IN_CODEBOOK
+                     if (!codebook_decode_deinterleave_repeat(f, book, residue_buffers, ch, &c_inter, &p_inter, n, r->part_size))
+                        goto done;
+                     #else
+                     // saves 1%
+                     if (!codebook_decode_deinterleave_repeat(f, book, residue_buffers, ch, &c_inter, &p_inter, n, r->part_size))
+                        goto done;
+                     #endif
+                  } else {
+                     z += r->part_size;
+                     c_inter = z & 1;
+                     p_inter = z >> 1;
+                  }
+               }
+               #ifndef STB_VORBIS_DIVIDES_IN_RESIDUE
+               ++class_set;
+               #endif
+            }
+         } else if (ch > 2) {
+            while (pcount < part_read) {
+               int z = r->begin + pcount*r->part_size;
+               int c_inter = z % ch, p_inter = z/ch;
+               if (pass == 0) {
+                  Codebook *c = f->codebooks+r->classbook;
+                  int q;
+                  DECODE(q,f,c);
+                  if (q == EOP) goto done;
+                  #ifndef STB_VORBIS_DIVIDES_IN_RESIDUE
+                  part_classdata[0][class_set] = r->classdata[q];
+                  #else
+                  for (i=classwords-1; i >= 0; --i) {
+                     classifications[0][i+pcount] = q % r->classifications;
+                     q /= r->classifications;
+                  }
+                  #endif
+               }
+               for (i=0; i < classwords && pcount < part_read; ++i, ++pcount) {
+                  int z = r->begin + pcount*r->part_size;
+                  #ifndef STB_VORBIS_DIVIDES_IN_RESIDUE
+                  int c = part_classdata[0][class_set][i];
+                  #else
+                  int c = classifications[0][pcount];
+                  #endif
+                  int b = r->residue_books[c][pass];
+                  if (b >= 0) {
+                     Codebook *book = f->codebooks + b;
+                     if (!codebook_decode_deinterleave_repeat(f, book, residue_buffers, ch, &c_inter, &p_inter, n, r->part_size))
+                        goto done;
+                  } else {
+                     z += r->part_size;
+                     c_inter = z % ch;
+                     p_inter = z / ch;
+                  }
+               }
+               #ifndef STB_VORBIS_DIVIDES_IN_RESIDUE
+               ++class_set;
+               #endif
+            }
+         }
+      }
+      goto done;
+   }
+   CHECK(f);
+
+   for (pass=0; pass < 8; ++pass) {
+      int pcount = 0, class_set=0;
+      while (pcount < part_read) {
+         if (pass == 0) {
+            for (j=0; j < ch; ++j) {
+               if (!do_not_decode[j]) {
+                  Codebook *c = f->codebooks+r->classbook;
+                  int temp;
+                  DECODE(temp,f,c);
+                  if (temp == EOP) goto done;
+                  #ifndef STB_VORBIS_DIVIDES_IN_RESIDUE
+                  part_classdata[j][class_set] = r->classdata[temp];
+                  #else
+                  for (i=classwords-1; i >= 0; --i) {
+                     classifications[j][i+pcount] = temp % r->classifications;
+                     temp /= r->classifications;
+                  }
+                  #endif
+               }
+            }
+         }
+         for (i=0; i < classwords && pcount < part_read; ++i, ++pcount) {
+            for (j=0; j < ch; ++j) {
+               if (!do_not_decode[j]) {
+                  #ifndef STB_VORBIS_DIVIDES_IN_RESIDUE
+                  int c = part_classdata[j][class_set][i];
+                  #else
+                  int c = classifications[j][pcount];
+                  #endif
+                  int b = r->residue_books[c][pass];
+                  if (b >= 0) {
+                     float *target = residue_buffers[j];
+                     int offset = r->begin + pcount * r->part_size;
+                     int n = r->part_size;
+                     Codebook *book = f->codebooks + b;
+                     if (!residue_decode(f, book, target, offset, n, rtype))
+                        goto done;
+                  }
+               }
+            }
+         }
+         #ifndef STB_VORBIS_DIVIDES_IN_RESIDUE
+         ++class_set;
+         #endif
+      }
+   }
+  done:
+   CHECK(f);
+   #ifndef STB_VORBIS_DIVIDES_IN_RESIDUE
+   temp_free(f,part_classdata);
+   #else
+   temp_free(f,classifications);
+   #endif
+   temp_alloc_restore(f,temp_alloc_point);
+}
+
+
+#if 0
+// slow way for debugging
+void inverse_mdct_slow(float *buffer, int n)
+{
+   int i,j;
+   int n2 = n >> 1;
+   float *x = (float *) malloc(sizeof(*x) * n2);
+   memcpy(x, buffer, sizeof(*x) * n2);
+   for (i=0; i < n; ++i) {
+      float acc = 0;
+      for (j=0; j < n2; ++j)
+         // formula from paper:
+         //acc += n/4.0f * x[j] * (float) cos(M_PI / 2 / n * (2 * i + 1 + n/2.0)*(2*j+1));
+         // formula from wikipedia
+         //acc += 2.0f / n2 * x[j] * (float) cos(M_PI/n2 * (i + 0.5 + n2/2)*(j + 0.5));
+         // these are equivalent, except the formula from the paper inverts the multiplier!
+         // however, what actually works is NO MULTIPLIER!?!
+         //acc += 64 * 2.0f / n2 * x[j] * (float) cos(M_PI/n2 * (i + 0.5 + n2/2)*(j + 0.5));
+         acc += x[j] * (float) cos(M_PI / 2 / n * (2 * i + 1 + n/2.0)*(2*j+1));
+      buffer[i] = acc;
+   }
+   free(x);
+}
+#elif 0
+// same as above, but just barely able to run in real time on modern machines
+void inverse_mdct_slow(float *buffer, int n, vorb *f, int blocktype)
+{
+   float mcos[16384];
+   int i,j;
+   int n2 = n >> 1, nmask = (n << 2) -1;
+   float *x = (float *) malloc(sizeof(*x) * n2);
+   memcpy(x, buffer, sizeof(*x) * n2);
+   for (i=0; i < 4*n; ++i)
+      mcos[i] = (float) cos(M_PI / 2 * i / n);
+
+   for (i=0; i < n; ++i) {
+      float acc = 0;
+      for (j=0; j < n2; ++j)
+         acc += x[j] * mcos[(2 * i + 1 + n2)*(2*j+1) & nmask];
+      buffer[i] = acc;
+   }
+   free(x);
+}
+#elif 0
+// transform to use a slow dct-iv; this is STILL basically trivial,
+// but only requires half as many ops
+void dct_iv_slow(float *buffer, int n)
+{
+   float mcos[16384];
+   float x[2048];
+   int i,j;
+   int n2 = n >> 1, nmask = (n << 3) - 1;
+   memcpy(x, buffer, sizeof(*x) * n);
+   for (i=0; i < 8*n; ++i)
+      mcos[i] = (float) cos(M_PI / 4 * i / n);
+   for (i=0; i < n; ++i) {
+      float acc = 0;
+      for (j=0; j < n; ++j)
+         acc += x[j] * mcos[((2 * i + 1)*(2*j+1)) & nmask];
+      buffer[i] = acc;
+   }
+}
+
+void inverse_mdct_slow(float *buffer, int n, vorb *f, int blocktype)
+{
+   int i, n4 = n >> 2, n2 = n >> 1, n3_4 = n - n4;
+   float temp[4096];
+
+   memcpy(temp, buffer, n2 * sizeof(float));
+   dct_iv_slow(temp, n2);  // returns -c'-d, a-b'
+
+   for (i=0; i < n4  ; ++i) buffer[i] = temp[i+n4];            // a-b'
+   for (   ; i < n3_4; ++i) buffer[i] = -temp[n3_4 - i - 1];   // b-a', c+d'
+   for (   ; i < n   ; ++i) buffer[i] = -temp[i - n3_4];       // c'+d
+}
+#endif
+
+#ifndef LIBVORBIS_MDCT
+#define LIBVORBIS_MDCT 0
+#endif
+
+#if LIBVORBIS_MDCT
+// directly call the vorbis MDCT using an interface documented
+// by Jeff Roberts... useful for performance comparison
+typedef struct
+{
+  int n;
+  int log2n;
+
+  float *trig;
+  int   *bitrev;
+
+  float scale;
+} mdct_lookup;
+
+extern void mdct_init(mdct_lookup *lookup, int n);
+extern void mdct_clear(mdct_lookup *l);
+extern void mdct_backward(mdct_lookup *init, float *in, float *out);
+
+mdct_lookup M1,M2;
+
+void inverse_mdct(float *buffer, int n, vorb *f, int blocktype)
+{
+   mdct_lookup *M;
+   if (M1.n == n) M = &M1;
+   else if (M2.n == n) M = &M2;
+   else if (M1.n == 0) { mdct_init(&M1, n); M = &M1; }
+   else {
+      if (M2.n) __asm int 3;
+      mdct_init(&M2, n);
+      M = &M2;
+   }
+
+   mdct_backward(M, buffer, buffer);
+}
+#endif
+
+
+// the following were split out into separate functions while optimizing;
+// they could be pushed back up but eh. __forceinline showed no change;
+// they're probably already being inlined.
+static void imdct_step3_iter0_loop(int n, float *e, int i_off, int k_off, float *A)
+{
+   float *ee0 = e + i_off;
+   float *ee2 = ee0 + k_off;
+   int i;
+
+   assert((n & 3) == 0);
+   for (i=(n>>2); i > 0; --i) {
+      float k00_20, k01_21;
+      k00_20  = ee0[ 0] - ee2[ 0];
+      k01_21  = ee0[-1] - ee2[-1];
+      ee0[ 0] += ee2[ 0];//ee0[ 0] = ee0[ 0] + ee2[ 0];
+      ee0[-1] += ee2[-1];//ee0[-1] = ee0[-1] + ee2[-1];
+      ee2[ 0] = k00_20 * A[0] - k01_21 * A[1];
+      ee2[-1] = k01_21 * A[0] + k00_20 * A[1];
+      A += 8;
+
+      k00_20  = ee0[-2] - ee2[-2];
+      k01_21  = ee0[-3] - ee2[-3];
+      ee0[-2] += ee2[-2];//ee0[-2] = ee0[-2] + ee2[-2];
+      ee0[-3] += ee2[-3];//ee0[-3] = ee0[-3] + ee2[-3];
+      ee2[-2] = k00_20 * A[0] - k01_21 * A[1];
+      ee2[-3] = k01_21 * A[0] + k00_20 * A[1];
+      A += 8;
+
+      k00_20  = ee0[-4] - ee2[-4];
+      k01_21  = ee0[-5] - ee2[-5];
+      ee0[-4] += ee2[-4];//ee0[-4] = ee0[-4] + ee2[-4];
+      ee0[-5] += ee2[-5];//ee0[-5] = ee0[-5] + ee2[-5];
+      ee2[-4] = k00_20 * A[0] - k01_21 * A[1];
+      ee2[-5] = k01_21 * A[0] + k00_20 * A[1];
+      A += 8;
+
+      k00_20  = ee0[-6] - ee2[-6];
+      k01_21  = ee0[-7] - ee2[-7];
+      ee0[-6] += ee2[-6];//ee0[-6] = ee0[-6] + ee2[-6];
+      ee0[-7] += ee2[-7];//ee0[-7] = ee0[-7] + ee2[-7];
+      ee2[-6] = k00_20 * A[0] - k01_21 * A[1];
+      ee2[-7] = k01_21 * A[0] + k00_20 * A[1];
+      A += 8;
+      ee0 -= 8;
+      ee2 -= 8;
+   }
+}
+
+static void imdct_step3_inner_r_loop(int lim, float *e, int d0, int k_off, float *A, int k1)
+{
+   int i;
+   float k00_20, k01_21;
+
+   float *e0 = e + d0;
+   float *e2 = e0 + k_off;
+
+   for (i=lim >> 2; i > 0; --i) {
+      k00_20 = e0[-0] - e2[-0];
+      k01_21 = e0[-1] - e2[-1];
+      e0[-0] += e2[-0];//e0[-0] = e0[-0] + e2[-0];
+      e0[-1] += e2[-1];//e0[-1] = e0[-1] + e2[-1];
+      e2[-0] = (k00_20)*A[0] - (k01_21) * A[1];
+      e2[-1] = (k01_21)*A[0] + (k00_20) * A[1];
+
+      A += k1;
+
+      k00_20 = e0[-2] - e2[-2];
+      k01_21 = e0[-3] - e2[-3];
+      e0[-2] += e2[-2];//e0[-2] = e0[-2] + e2[-2];
+      e0[-3] += e2[-3];//e0[-3] = e0[-3] + e2[-3];
+      e2[-2] = (k00_20)*A[0] - (k01_21) * A[1];
+      e2[-3] = (k01_21)*A[0] + (k00_20) * A[1];
+
+      A += k1;
+
+      k00_20 = e0[-4] - e2[-4];
+      k01_21 = e0[-5] - e2[-5];
+      e0[-4] += e2[-4];//e0[-4] = e0[-4] + e2[-4];
+      e0[-5] += e2[-5];//e0[-5] = e0[-5] + e2[-5];
+      e2[-4] = (k00_20)*A[0] - (k01_21) * A[1];
+      e2[-5] = (k01_21)*A[0] + (k00_20) * A[1];
+
+      A += k1;
+
+      k00_20 = e0[-6] - e2[-6];
+      k01_21 = e0[-7] - e2[-7];
+      e0[-6] += e2[-6];//e0[-6] = e0[-6] + e2[-6];
+      e0[-7] += e2[-7];//e0[-7] = e0[-7] + e2[-7];
+      e2[-6] = (k00_20)*A[0] - (k01_21) * A[1];
+      e2[-7] = (k01_21)*A[0] + (k00_20) * A[1];
+
+      e0 -= 8;
+      e2 -= 8;
+
+      A += k1;
+   }
+}
+
+static void imdct_step3_inner_s_loop(int n, float *e, int i_off, int k_off, float *A, int a_off, int k0)
+{
+   int i;
+   float A0 = A[0];
+   float A1 = A[0+1];
+   float A2 = A[0+a_off];
+   float A3 = A[0+a_off+1];
+   float A4 = A[0+a_off*2+0];
+   float A5 = A[0+a_off*2+1];
+   float A6 = A[0+a_off*3+0];
+   float A7 = A[0+a_off*3+1];
+
+   float k00,k11;
+
+   float *ee0 = e  +i_off;
+   float *ee2 = ee0+k_off;
+
+   for (i=n; i > 0; --i) {
+      k00     = ee0[ 0] - ee2[ 0];
+      k11     = ee0[-1] - ee2[-1];
+      ee0[ 0] =  ee0[ 0] + ee2[ 0];
+      ee0[-1] =  ee0[-1] + ee2[-1];
+      ee2[ 0] = (k00) * A0 - (k11) * A1;
+      ee2[-1] = (k11) * A0 + (k00) * A1;
+
+      k00     = ee0[-2] - ee2[-2];
+      k11     = ee0[-3] - ee2[-3];
+      ee0[-2] =  ee0[-2] + ee2[-2];
+      ee0[-3] =  ee0[-3] + ee2[-3];
+      ee2[-2] = (k00) * A2 - (k11) * A3;
+      ee2[-3] = (k11) * A2 + (k00) * A3;
+
+      k00     = ee0[-4] - ee2[-4];
+      k11     = ee0[-5] - ee2[-5];
+      ee0[-4] =  ee0[-4] + ee2[-4];
+      ee0[-5] =  ee0[-5] + ee2[-5];
+      ee2[-4] = (k00) * A4 - (k11) * A5;
+      ee2[-5] = (k11) * A4 + (k00) * A5;
+
+      k00     = ee0[-6] - ee2[-6];
+      k11     = ee0[-7] - ee2[-7];
+      ee0[-6] =  ee0[-6] + ee2[-6];
+      ee0[-7] =  ee0[-7] + ee2[-7];
+      ee2[-6] = (k00) * A6 - (k11) * A7;
+      ee2[-7] = (k11) * A6 + (k00) * A7;
+
+      ee0 -= k0;
+      ee2 -= k0;
+   }
+}
+
+static __forceinline void iter_54(float *z)
+{
+   float k00,k11,k22,k33;
+   float y0,y1,y2,y3;
+
+   k00  = z[ 0] - z[-4];
+   y0   = z[ 0] + z[-4];
+   y2   = z[-2] + z[-6];
+   k22  = z[-2] - z[-6];
+
+   z[-0] = y0 + y2;      // z0 + z4 + z2 + z6
+   z[-2] = y0 - y2;      // z0 + z4 - z2 - z6
+
+   // done with y0,y2
+
+   k33  = z[-3] - z[-7];
+
+   z[-4] = k00 + k33;    // z0 - z4 + z3 - z7
+   z[-6] = k00 - k33;    // z0 - z4 - z3 + z7
+
+   // done with k33
+
+   k11  = z[-1] - z[-5];
+   y1   = z[-1] + z[-5];
+   y3   = z[-3] + z[-7];
+
+   z[-1] = y1 + y3;      // z1 + z5 + z3 + z7
+   z[-3] = y1 - y3;      // z1 + z5 - z3 - z7
+   z[-5] = k11 - k22;    // z1 - z5 + z2 - z6
+   z[-7] = k11 + k22;    // z1 - z5 - z2 + z6
+}
+
+static void imdct_step3_inner_s_loop_ld654(int n, float *e, int i_off, float *A, int base_n)
+{
+   int a_off = base_n >> 3;
+   float A2 = A[0+a_off];
+   float *z = e + i_off;
+   float *base = z - 16 * n;
+
+   while (z > base) {
+      float k00,k11;
+      float l00,l11;
+
+      k00    = z[-0] - z[ -8];
+      k11    = z[-1] - z[ -9];
+      l00    = z[-2] - z[-10];
+      l11    = z[-3] - z[-11];
+      z[ -0] = z[-0] + z[ -8];
+      z[ -1] = z[-1] + z[ -9];
+      z[ -2] = z[-2] + z[-10];
+      z[ -3] = z[-3] + z[-11];
+      z[ -8] = k00;
+      z[ -9] = k11;
+      z[-10] = (l00+l11) * A2;
+      z[-11] = (l11-l00) * A2;
+
+      k00    = z[ -4] - z[-12];
+      k11    = z[ -5] - z[-13];
+      l00    = z[ -6] - z[-14];
+      l11    = z[ -7] - z[-15];
+      z[ -4] = z[ -4] + z[-12];
+      z[ -5] = z[ -5] + z[-13];
+      z[ -6] = z[ -6] + z[-14];
+      z[ -7] = z[ -7] + z[-15];
+      z[-12] = k11;
+      z[-13] = -k00;
+      z[-14] = (l11-l00) * A2;
+      z[-15] = (l00+l11) * -A2;
+
+      iter_54(z);
+      iter_54(z-8);
+      z -= 16;
+   }
+}
+
+static void inverse_mdct(float *buffer, int n, vorb *f, int blocktype)
+{
+   int n2 = n >> 1, n4 = n >> 2, n8 = n >> 3, l;
+   int ld;
+   // @OPTIMIZE: reduce register pressure by using fewer variables?
+   int save_point = temp_alloc_save(f);
+   float *buf2 = (float *) temp_alloc(f, n2 * sizeof(*buf2));
+   float *u=NULL,*v=NULL;
+   // twiddle factors
+   float *A = f->A[blocktype];
+
+   // IMDCT algorithm from "The use of multirate filter banks for coding of high quality digital audio"
+   // See notes about bugs in that paper in less-optimal implementation 'inverse_mdct_old' after this function.
+
+   // kernel from paper
+
+
+   // merged:
+   //   copy and reflect spectral data
+   //   step 0
+
+   // note that it turns out that the items added together during
+   // this step are, in fact, being added to themselves (as reflected
+   // by step 0). inexplicable inefficiency! this became obvious
+   // once I combined the passes.
+
+   // so there's a missing 'times 2' here (for adding X to itself).
+   // this propagates through linearly to the end, where the numbers
+   // are 1/2 too small, and need to be compensated for.
+
+   {
+      float *d,*e, *AA, *e_stop;
+      d = &buf2[n2-2];
+      AA = A;
+      e = &buffer[0];
+      e_stop = &buffer[n2];
+      while (e != e_stop) {
+         d[1] = (e[0] * AA[0] - e[2]*AA[1]);
+         d[0] = (e[0] * AA[1] + e[2]*AA[0]);
+         d -= 2;
+         AA += 2;
+         e += 4;
+      }
+
+      e = &buffer[n2-3];
+      while (d >= buf2) {
+         d[1] = (-e[2] * AA[0] - -e[0]*AA[1]);
+         d[0] = (-e[2] * AA[1] + -e[0]*AA[0]);
+         d -= 2;
+         AA += 2;
+         e -= 4;
+      }
+   }
+
+   // now we use symbolic names for these, so that we can
+   // possibly swap their meaning as we change which operations
+   // are in place
+
+   u = buffer;
+   v = buf2;
+
+   // step 2    (paper output is w, now u)
+   // this could be in place, but the data ends up in the wrong
+   // place... _somebody_'s got to swap it, so this is nominated
+   {
+      float *AA = &A[n2-8];
+      float *d0,*d1, *e0, *e1;
+
+      e0 = &v[n4];
+      e1 = &v[0];
+
+      d0 = &u[n4];
+      d1 = &u[0];
+
+      while (AA >= A) {
+         float v40_20, v41_21;
+
+         v41_21 = e0[1] - e1[1];
+         v40_20 = e0[0] - e1[0];
+         d0[1]  = e0[1] + e1[1];
+         d0[0]  = e0[0] + e1[0];
+         d1[1]  = v41_21*AA[4] - v40_20*AA[5];
+         d1[0]  = v40_20*AA[4] + v41_21*AA[5];
+
+         v41_21 = e0[3] - e1[3];
+         v40_20 = e0[2] - e1[2];
+         d0[3]  = e0[3] + e1[3];
+         d0[2]  = e0[2] + e1[2];
+         d1[3]  = v41_21*AA[0] - v40_20*AA[1];
+         d1[2]  = v40_20*AA[0] + v41_21*AA[1];
+
+         AA -= 8;
+
+         d0 += 4;
+         d1 += 4;
+         e0 += 4;
+         e1 += 4;
+      }
+   }
+
+   // step 3
+   ld = ilog(n) - 1; // ilog is off-by-one from normal definitions
+
+   // optimized step 3:
+
+   // the original step3 loop can be nested r inside s or s inside r;
+   // it's written originally as s inside r, but this is dumb when r
+   // iterates many times, and s few. So I have two copies of it and
+   // switch between them halfway.
+
+   // this is iteration 0 of step 3
+   imdct_step3_iter0_loop(n >> 4, u, n2-1-n4*0, -(n >> 3), A);
+   imdct_step3_iter0_loop(n >> 4, u, n2-1-n4*1, -(n >> 3), A);
+
+   // this is iteration 1 of step 3
+   imdct_step3_inner_r_loop(n >> 5, u, n2-1 - n8*0, -(n >> 4), A, 16);
+   imdct_step3_inner_r_loop(n >> 5, u, n2-1 - n8*1, -(n >> 4), A, 16);
+   imdct_step3_inner_r_loop(n >> 5, u, n2-1 - n8*2, -(n >> 4), A, 16);
+   imdct_step3_inner_r_loop(n >> 5, u, n2-1 - n8*3, -(n >> 4), A, 16);
+
+   l=2;
+   for (; l < (ld-3)>>1; ++l) {
+      int k0 = n >> (l+2), k0_2 = k0>>1;
+      int lim = 1 << (l+1);
+      int i;
+      for (i=0; i < lim; ++i)
+         imdct_step3_inner_r_loop(n >> (l+4), u, n2-1 - k0*i, -k0_2, A, 1 << (l+3));
+   }
+
+   for (; l < ld-6; ++l) {
+      int k0 = n >> (l+2), k1 = 1 << (l+3), k0_2 = k0>>1;
+      int rlim = n >> (l+6), r;
+      int lim = 1 << (l+1);
+      int i_off;
+      float *A0 = A;
+      i_off = n2-1;
+      for (r=rlim; r > 0; --r) {
+         imdct_step3_inner_s_loop(lim, u, i_off, -k0_2, A0, k1, k0);
+         A0 += k1*4;
+         i_off -= 8;
+      }
+   }
+
+   // iterations with count:
+   //   ld-6,-5,-4 all interleaved together
+   //       the big win comes from getting rid of needless flops
+   //         due to the constants on pass 5 & 4 being all 1 and 0;
+   //       combining them to be simultaneous to improve cache made little difference
+   imdct_step3_inner_s_loop_ld654(n >> 5, u, n2-1, A, n);
+
+   // output is u
+
+   // step 4, 5, and 6
+   // cannot be in-place because of step 5
+   {
+      uint16 *bitrev = f->bit_reverse[blocktype];
+      // weirdly, I'd have thought reading sequentially and writing
+      // erratically would have been better than vice-versa, but in
+      // fact that's not what my testing showed. (That is, with
+      // j = bitreverse(i), do you read i and write j, or read j and write i.)
+
+      float *d0 = &v[n4-4];
+      float *d1 = &v[n2-4];
+      while (d0 >= v) {
+         int k4;
+
+         k4 = bitrev[0];
+         d1[3] = u[k4+0];
+         d1[2] = u[k4+1];
+         d0[3] = u[k4+2];
+         d0[2] = u[k4+3];
+
+         k4 = bitrev[1];
+         d1[1] = u[k4+0];
+         d1[0] = u[k4+1];
+         d0[1] = u[k4+2];
+         d0[0] = u[k4+3];
+
+         d0 -= 4;
+         d1 -= 4;
+         bitrev += 2;
+      }
+   }
+   // (paper output is u, now v)
+
+
+   // data must be in buf2
+   assert(v == buf2);
+
+   // step 7   (paper output is v, now v)
+   // this is now in place
+   {
+      float *C = f->C[blocktype];
+      float *d, *e;
+
+      d = v;
+      e = v + n2 - 4;
+
+      while (d < e) {
+         float a02,a11,b0,b1,b2,b3;
+
+         a02 = d[0] - e[2];
+         a11 = d[1] + e[3];
+
+         b0 = C[1]*a02 + C[0]*a11;
+         b1 = C[1]*a11 - C[0]*a02;
+
+         b2 = d[0] + e[ 2];
+         b3 = d[1] - e[ 3];
+
+         d[0] = b2 + b0;
+         d[1] = b3 + b1;
+         e[2] = b2 - b0;
+         e[3] = b1 - b3;
+
+         a02 = d[2] - e[0];
+         a11 = d[3] + e[1];
+
+         b0 = C[3]*a02 + C[2]*a11;
+         b1 = C[3]*a11 - C[2]*a02;
+
+         b2 = d[2] + e[ 0];
+         b3 = d[3] - e[ 1];
+
+         d[2] = b2 + b0;
+         d[3] = b3 + b1;
+         e[0] = b2 - b0;
+         e[1] = b1 - b3;
+
+         C += 4;
+         d += 4;
+         e -= 4;
+      }
+   }
+
+   // data must be in buf2
+
+
+   // step 8+decode   (paper output is X, now buffer)
+   // this generates pairs of data a la 8 and pushes them directly through
+   // the decode kernel (pushing rather than pulling) to avoid having
+   // to make another pass later
+
+   // this cannot POSSIBLY be in place, so we refer to the buffers directly
+
+   {
+      float *d0,*d1,*d2,*d3;
+
+      float *B = f->B[blocktype] + n2 - 8;
+      float *e = buf2 + n2 - 8;
+      d0 = &buffer[0];
+      d1 = &buffer[n2-4];
+      d2 = &buffer[n2];
+      d3 = &buffer[n-4];
+      while (e >= v) {
+         float p0,p1,p2,p3;
+
+         p3 =  e[6]*B[7] - e[7]*B[6];
+         p2 = -e[6]*B[6] - e[7]*B[7];
+
+         d0[0] =   p3;
+         d1[3] = - p3;
+         d2[0] =   p2;
+         d3[3] =   p2;
+
+         p1 =  e[4]*B[5] - e[5]*B[4];
+         p0 = -e[4]*B[4] - e[5]*B[5];
+
+         d0[1] =   p1;
+         d1[2] = - p1;
+         d2[1] =   p0;
+         d3[2] =   p0;
+
+         p3 =  e[2]*B[3] - e[3]*B[2];
+         p2 = -e[2]*B[2] - e[3]*B[3];
+
+         d0[2] =   p3;
+         d1[1] = - p3;
+         d2[2] =   p2;
+         d3[1] =   p2;
+
+         p1 =  e[0]*B[1] - e[1]*B[0];
+         p0 = -e[0]*B[0] - e[1]*B[1];
+
+         d0[3] =   p1;
+         d1[0] = - p1;
+         d2[3] =   p0;
+         d3[0] =   p0;
+
+         B -= 8;
+         e -= 8;
+         d0 += 4;
+         d2 += 4;
+         d1 -= 4;
+         d3 -= 4;
+      }
+   }
+
+   temp_free(f,buf2);
+   temp_alloc_restore(f,save_point);
+}
+
+#if 0
+// this is the original version of the above code, if you want to optimize it from scratch
+void inverse_mdct_naive(float *buffer, int n)
+{
+   float s;
+   float A[1 << 12], B[1 << 12], C[1 << 11];
+   int i,k,k2,k4, n2 = n >> 1, n4 = n >> 2, n8 = n >> 3, l;
+   int n3_4 = n - n4, ld;
+   // how can they claim this only uses N words?!
+   // oh, because they're only used sparsely, whoops
+   float u[1 << 13], X[1 << 13], v[1 << 13], w[1 << 13];
+   // set up twiddle factors
+
+   for (k=k2=0; k < n4; ++k,k2+=2) {
+      A[k2  ] = (float)  cos(4*k*M_PI/n);
+      A[k2+1] = (float) -sin(4*k*M_PI/n);
+      B[k2  ] = (float)  cos((k2+1)*M_PI/n/2);
+      B[k2+1] = (float)  sin((k2+1)*M_PI/n/2);
+   }
+   for (k=k2=0; k < n8; ++k,k2+=2) {
+      C[k2  ] = (float)  cos(2*(k2+1)*M_PI/n);
+      C[k2+1] = (float) -sin(2*(k2+1)*M_PI/n);
+   }
+
+   // IMDCT algorithm from "The use of multirate filter banks for coding of high quality digital audio"
+   // Note there are bugs in that pseudocode, presumably due to them attempting
+   // to rename the arrays nicely rather than representing the way their actual
+   // implementation bounces buffers back and forth. As a result, even in the
+   // "some formulars corrected" version, a direct implementation fails. These
+   // are noted below as "paper bug".
+
+   // copy and reflect spectral data
+   for (k=0; k < n2; ++k) u[k] = buffer[k];
+   for (   ; k < n ; ++k) u[k] = -buffer[n - k - 1];
+   // kernel from paper
+   // step 1
+   for (k=k2=k4=0; k < n4; k+=1, k2+=2, k4+=4) {
+      v[n-k4-1] = (u[k4] - u[n-k4-1]) * A[k2]   - (u[k4+2] - u[n-k4-3])*A[k2+1];
+      v[n-k4-3] = (u[k4] - u[n-k4-1]) * A[k2+1] + (u[k4+2] - u[n-k4-3])*A[k2];
+   }
+   // step 2
+   for (k=k4=0; k < n8; k+=1, k4+=4) {
+      w[n2+3+k4] = v[n2+3+k4] + v[k4+3];
+      w[n2+1+k4] = v[n2+1+k4] + v[k4+1];
+      w[k4+3]    = (v[n2+3+k4] - v[k4+3])*A[n2-4-k4] - (v[n2+1+k4]-v[k4+1])*A[n2-3-k4];
+      w[k4+1]    = (v[n2+1+k4] - v[k4+1])*A[n2-4-k4] + (v[n2+3+k4]-v[k4+3])*A[n2-3-k4];
+   }
+   // step 3
+   ld = ilog(n) - 1; // ilog is off-by-one from normal definitions
+   for (l=0; l < ld-3; ++l) {
+      int k0 = n >> (l+2), k1 = 1 << (l+3);
+      int rlim = n >> (l+4), r4, r;
+      int s2lim = 1 << (l+2), s2;
+      for (r=r4=0; r < rlim; r4+=4,++r) {
+         for (s2=0; s2 < s2lim; s2+=2) {
+            u[n-1-k0*s2-r4] = w[n-1-k0*s2-r4] + w[n-1-k0*(s2+1)-r4];
+            u[n-3-k0*s2-r4] = w[n-3-k0*s2-r4] + w[n-3-k0*(s2+1)-r4];
+            u[n-1-k0*(s2+1)-r4] = (w[n-1-k0*s2-r4] - w[n-1-k0*(s2+1)-r4]) * A[r*k1]
+                                - (w[n-3-k0*s2-r4] - w[n-3-k0*(s2+1)-r4]) * A[r*k1+1];
+            u[n-3-k0*(s2+1)-r4] = (w[n-3-k0*s2-r4] - w[n-3-k0*(s2+1)-r4]) * A[r*k1]
+                                + (w[n-1-k0*s2-r4] - w[n-1-k0*(s2+1)-r4]) * A[r*k1+1];
+         }
+      }
+      if (l+1 < ld-3) {
+         // paper bug: ping-ponging of u&w here is omitted
+         memcpy(w, u, sizeof(u));
+      }
+   }
+
+   // step 4
+   for (i=0; i < n8; ++i) {
+      int j = bit_reverse(i) >> (32-ld+3);
+      assert(j < n8);
+      if (i == j) {
+         // paper bug: original code probably swapped in place; if copying,
+         //            need to directly copy in this case
+         int i8 = i << 3;
+         v[i8+1] = u[i8+1];
+         v[i8+3] = u[i8+3];
+         v[i8+5] = u[i8+5];
+         v[i8+7] = u[i8+7];
+      } else if (i < j) {
+         int i8 = i << 3, j8 = j << 3;
+         v[j8+1] = u[i8+1], v[i8+1] = u[j8 + 1];
+         v[j8+3] = u[i8+3], v[i8+3] = u[j8 + 3];
+         v[j8+5] = u[i8+5], v[i8+5] = u[j8 + 5];
+         v[j8+7] = u[i8+7], v[i8+7] = u[j8 + 7];
+      }
+   }
+   // step 5
+   for (k=0; k < n2; ++k) {
+      w[k] = v[k*2+1];
+   }
+   // step 6
+   for (k=k2=k4=0; k < n8; ++k, k2 += 2, k4 += 4) {
+      u[n-1-k2] = w[k4];
+      u[n-2-k2] = w[k4+1];
+      u[n3_4 - 1 - k2] = w[k4+2];
+      u[n3_4 - 2 - k2] = w[k4+3];
+   }
+   // step 7
+   for (k=k2=0; k < n8; ++k, k2 += 2) {
+      v[n2 + k2 ] = ( u[n2 + k2] + u[n-2-k2] + C[k2+1]*(u[n2+k2]-u[n-2-k2]) + C[k2]*(u[n2+k2+1]+u[n-2-k2+1]))/2;
+      v[n-2 - k2] = ( u[n2 + k2] + u[n-2-k2] - C[k2+1]*(u[n2+k2]-u[n-2-k2]) - C[k2]*(u[n2+k2+1]+u[n-2-k2+1]))/2;
+      v[n2+1+ k2] = ( u[n2+1+k2] - u[n-1-k2] + C[k2+1]*(u[n2+1+k2]+u[n-1-k2]) - C[k2]*(u[n2+k2]-u[n-2-k2]))/2;
+      v[n-1 - k2] = (-u[n2+1+k2] + u[n-1-k2] + C[k2+1]*(u[n2+1+k2]+u[n-1-k2]) - C[k2]*(u[n2+k2]-u[n-2-k2]))/2;
+   }
+   // step 8
+   for (k=k2=0; k < n4; ++k,k2 += 2) {
+      X[k]      = v[k2+n2]*B[k2  ] + v[k2+1+n2]*B[k2+1];
+      X[n2-1-k] = v[k2+n2]*B[k2+1] - v[k2+1+n2]*B[k2  ];
+   }
+
+   // decode kernel to output
+   // determined the following value experimentally
+   // (by first figuring out what made inverse_mdct_slow work); then matching that here
+   // (probably vorbis encoder premultiplies by n or n/2, to save it on the decoder?)
+   s = 0.5; // theoretically would be n4
+
+   // [[[ note! the s value of 0.5 is compensated for by the B[] in the current code,
+   //     so it needs to use the "old" B values to behave correctly, or else
+   //     set s to 1.0 ]]]
+   for (i=0; i < n4  ; ++i) buffer[i] = s * X[i+n4];
+   for (   ; i < n3_4; ++i) buffer[i] = -s * X[n3_4 - i - 1];
+   for (   ; i < n   ; ++i) buffer[i] = -s * X[i - n3_4];
+}
+#endif
+
+static float *get_window(vorb *f, int len)
+{
+   len <<= 1;
+   if (len == f->blocksize_0) return f->window[0];
+   if (len == f->blocksize_1) return f->window[1];
+   return NULL;
+}
+
+#ifndef STB_VORBIS_NO_DEFER_FLOOR
+typedef int16 YTYPE;
+#else
+typedef int YTYPE;
+#endif
+static int do_floor(vorb *f, Mapping *map, int i, int n, float *target, YTYPE *finalY, uint8 *step2_flag)
+{
+   int n2 = n >> 1;
+   int s = map->chan[i].mux, floor;
+   floor = map->submap_floor[s];
+   if (f->floor_types[floor] == 0) {
+      return error(f, VORBIS_invalid_stream);
+   } else {
+      Floor1 *g = &f->floor_config[floor].floor1;
+      int j,q;
+      int lx = 0, ly = finalY[0] * g->floor1_multiplier;
+      for (q=1; q < g->values; ++q) {
+         j = g->sorted_order[q];
+         #ifndef STB_VORBIS_NO_DEFER_FLOOR
+         STBV_NOTUSED(step2_flag);
+         if (finalY[j] >= 0)
+         #else
+         if (step2_flag[j])
+         #endif
+         {
+            int hy = finalY[j] * g->floor1_multiplier;
+            int hx = g->Xlist[j];
+            if (lx != hx)
+               draw_line(target, lx,ly, hx,hy, n2);
+            CHECK(f);
+            lx = hx, ly = hy;
+         }
+      }
+      if (lx < n2) {
+         // optimization of: draw_line(target, lx,ly, n,ly, n2);
+         for (j=lx; j < n2; ++j)
+            LINE_OP(target[j], inverse_db_table[ly]);
+         CHECK(f);
+      }
+   }
+   return TRUE;
+}
+
+// The meaning of "left" and "right"
+//
+// For a given frame:
+//     we compute samples from 0..n
+//     window_center is n/2
+//     we'll window and mix the samples from left_start to left_end with data from the previous frame
+//     all of the samples from left_end to right_start can be output without mixing; however,
+//        this interval is 0-length except when transitioning between short and long frames
+//     all of the samples from right_start to right_end need to be mixed with the next frame,
+//        which we don't have, so those get saved in a buffer
+//     frame N's right_end-right_start, the number of samples to mix with the next frame,
+//        has to be the same as frame N+1's left_end-left_start (which they are by
+//        construction)
+
+static int vorbis_decode_initial(vorb *f, int *p_left_start, int *p_left_end, int *p_right_start, int *p_right_end, int *mode)
+{
+   Mode *m;
+   int i, n, prev, next, window_center;
+   f->channel_buffer_start = f->channel_buffer_end = 0;
+
+  retry:
+   if (f->eof) return FALSE;
+   if (!maybe_start_packet(f))
+      return FALSE;
+   // check packet type
+   if (get_bits(f,1) != 0) {
+      if (IS_PUSH_MODE(f))
+         return error(f,VORBIS_bad_packet_type);
+      while (EOP != get8_packet(f));
+      goto retry;
+   }
+
+   if (f->alloc.alloc_buffer)
+      assert(f->alloc.alloc_buffer_length_in_bytes == f->temp_offset);
+
+   i = get_bits(f, ilog(f->mode_count-1));
+   if (i == EOP) return FALSE;
+   if (i >= f->mode_count) return FALSE;
+   *mode = i;
+   m = f->mode_config + i;
+   if (m->blockflag) {
+      n = f->blocksize_1;
+      prev = get_bits(f,1);
+      next = get_bits(f,1);
+   } else {
+      prev = next = 0;
+      n = f->blocksize_0;
+   }
+
+// WINDOWING
+
+   window_center = n >> 1;
+   if (m->blockflag && !prev) {
+      *p_left_start = (n - f->blocksize_0) >> 2;
+      *p_left_end   = (n + f->blocksize_0) >> 2;
+   } else {
+      *p_left_start = 0;
+      *p_left_end   = window_center;
+   }
+   if (m->blockflag && !next) {
+      *p_right_start = (n*3 - f->blocksize_0) >> 2;
+      *p_right_end   = (n*3 + f->blocksize_0) >> 2;
+   } else {
+      *p_right_start = window_center;
+      *p_right_end   = n;
+   }
+
+   return TRUE;
+}
+
+static int vorbis_decode_packet_rest(vorb *f, int *len, Mode *m, int left_start, int left_end, int right_start, int right_end, int *p_left)
+{
+   Mapping *map;
+   int i,j,k,n,n2;
+   int zero_channel[256];
+   int really_zero_channel[256];
+
+// WINDOWING
+
+   STBV_NOTUSED(left_end);
+   n = f->blocksize[m->blockflag];
+   map = &f->mapping[m->mapping];
+
+// FLOORS
+   n2 = n >> 1;
+
+   CHECK(f);
+
+   for (i=0; i < f->channels; ++i) {
+      int s = map->chan[i].mux, floor;
+      zero_channel[i] = FALSE;
+      floor = map->submap_floor[s];
+      if (f->floor_types[floor] == 0) {
+         return error(f, VORBIS_invalid_stream);
+      } else {
+         Floor1 *g = &f->floor_config[floor].floor1;
+         if (get_bits(f, 1)) {
+            short *finalY;
+            uint8 step2_flag[256];
+            static int range_list[4] = { 256, 128, 86, 64 };
+            int range = range_list[g->floor1_multiplier-1];
+            int offset = 2;
+            finalY = f->finalY[i];
+            finalY[0] = get_bits(f, ilog(range)-1);
+            finalY[1] = get_bits(f, ilog(range)-1);
+            for (j=0; j < g->partitions; ++j) {
+               int pclass = g->partition_class_list[j];
+               int cdim = g->class_dimensions[pclass];
+               int cbits = g->class_subclasses[pclass];
+               int csub = (1 << cbits)-1;
+               int cval = 0;
+               if (cbits) {
+                  Codebook *c = f->codebooks + g->class_masterbooks[pclass];
+                  DECODE(cval,f,c);
+               }
+               for (k=0; k < cdim; ++k) {
+                  int book = g->subclass_books[pclass][cval & csub];
+                  cval = cval >> cbits;
+                  if (book >= 0) {
+                     int temp;
+                     Codebook *c = f->codebooks + book;
+                     DECODE(temp,f,c);
+                     finalY[offset++] = temp;
+                  } else
+                     finalY[offset++] = 0;
+               }
+            }
+            if (f->valid_bits == INVALID_BITS) goto error; // behavior according to spec
+            step2_flag[0] = step2_flag[1] = 1;
+            for (j=2; j < g->values; ++j) {
+               int low, high, pred, highroom, lowroom, room, val;
+               low = g->neighbors[j][0];
+               high = g->neighbors[j][1];
+               //neighbors(g->Xlist, j, &low, &high);
+               pred = predict_point(g->Xlist[j], g->Xlist[low], g->Xlist[high], finalY[low], finalY[high]);
+               val = finalY[j];
+               highroom = range - pred;
+               lowroom = pred;
+               if (highroom < lowroom)
+                  room = highroom * 2;
+               else
+                  room = lowroom * 2;
+               if (val) {
+                  step2_flag[low] = step2_flag[high] = 1;
+                  step2_flag[j] = 1;
+                  if (val >= room)
+                     if (highroom > lowroom)
+                        finalY[j] = val - lowroom + pred;
+                     else
+                        finalY[j] = pred - val + highroom - 1;
+                  else
+                     if (val & 1)
+                        finalY[j] = pred - ((val+1)>>1);
+                     else
+                        finalY[j] = pred + (val>>1);
+               } else {
+                  step2_flag[j] = 0;
+                  finalY[j] = pred;
+               }
+            }
+
+#ifdef STB_VORBIS_NO_DEFER_FLOOR
+            do_floor(f, map, i, n, f->floor_buffers[i], finalY, step2_flag);
+#else
+            // defer final floor computation until _after_ residue
+            for (j=0; j < g->values; ++j) {
+               if (!step2_flag[j])
+                  finalY[j] = -1;
+            }
+#endif
+         } else {
+           error:
+            zero_channel[i] = TRUE;
+         }
+         // So we just defer everything else to later
+
+         // at this point we've decoded the floor into buffer
+      }
+   }
+   CHECK(f);
+   // at this point we've decoded all floors
+
+   if (f->alloc.alloc_buffer)
+      assert(f->alloc.alloc_buffer_length_in_bytes == f->temp_offset);
+
+   // re-enable coupled channels if necessary
+   memcpy(really_zero_channel, zero_channel, sizeof(really_zero_channel[0]) * f->channels);
+   for (i=0; i < map->coupling_steps; ++i)
+      if (!zero_channel[map->chan[i].magnitude] || !zero_channel[map->chan[i].angle]) {
+         zero_channel[map->chan[i].magnitude] = zero_channel[map->chan[i].angle] = FALSE;
+      }
+
+   CHECK(f);
+// RESIDUE DECODE
+   for (i=0; i < map->submaps; ++i) {
+      float *residue_buffers[STB_VORBIS_MAX_CHANNELS];
+      int r;
+      uint8 do_not_decode[256];
+      int ch = 0;
+      for (j=0; j < f->channels; ++j) {
+         if (map->chan[j].mux == i) {
+            if (zero_channel[j]) {
+               do_not_decode[ch] = TRUE;
+               residue_buffers[ch] = NULL;
+            } else {
+               do_not_decode[ch] = FALSE;
+               residue_buffers[ch] = f->channel_buffers[j];
+            }
+            ++ch;
+         }
+      }
+      r = map->submap_residue[i];
+      decode_residue(f, residue_buffers, ch, n2, r, do_not_decode);
+   }
+
+   if (f->alloc.alloc_buffer)
+      assert(f->alloc.alloc_buffer_length_in_bytes == f->temp_offset);
+   CHECK(f);
+
+// INVERSE COUPLING
+   for (i = map->coupling_steps-1; i >= 0; --i) {
+      int n2 = n >> 1;
+      float *m = f->channel_buffers[map->chan[i].magnitude];
+      float *a = f->channel_buffers[map->chan[i].angle    ];
+      for (j=0; j < n2; ++j) {
+         float a2,m2;
+         if (m[j] > 0)
+            if (a[j] > 0)
+               m2 = m[j], a2 = m[j] - a[j];
+            else
+               a2 = m[j], m2 = m[j] + a[j];
+         else
+            if (a[j] > 0)
+               m2 = m[j], a2 = m[j] + a[j];
+            else
+               a2 = m[j], m2 = m[j] - a[j];
+         m[j] = m2;
+         a[j] = a2;
+      }
+   }
+   CHECK(f);
+
+   // finish decoding the floors
+#ifndef STB_VORBIS_NO_DEFER_FLOOR
+   for (i=0; i < f->channels; ++i) {
+      if (really_zero_channel[i]) {
+         memset(f->channel_buffers[i], 0, sizeof(*f->channel_buffers[i]) * n2);
+      } else {
+         do_floor(f, map, i, n, f->channel_buffers[i], f->finalY[i], NULL);
+      }
+   }
+#else
+   for (i=0; i < f->channels; ++i) {
+      if (really_zero_channel[i]) {
+         memset(f->channel_buffers[i], 0, sizeof(*f->channel_buffers[i]) * n2);
+      } else {
+         for (j=0; j < n2; ++j)
+            f->channel_buffers[i][j] *= f->floor_buffers[i][j];
+      }
+   }
+#endif
+
+// INVERSE MDCT
+   CHECK(f);
+   for (i=0; i < f->channels; ++i)
+      inverse_mdct(f->channel_buffers[i], n, f, m->blockflag);
+   CHECK(f);
+
+   // this shouldn't be necessary, unless we exited on an error
+   // and want to flush to get to the next packet
+   flush_packet(f);
+
+   if (f->first_decode) {
+      // assume we start so first non-discarded sample is sample 0
+      // this isn't to spec, but spec would require us to read ahead
+      // and decode the size of all current frames--could be done,
+      // but presumably it's not a commonly used feature
+      f->current_loc = 0u - n2; // start of first frame is positioned for discard (NB this is an intentional unsigned overflow/wrap-around)
+      // we might have to discard samples "from" the next frame too,
+      // if we're lapping a large block then a small at the start?
+      f->discard_samples_deferred = n - right_end;
+      f->current_loc_valid = TRUE;
+      f->first_decode = FALSE;
+   } else if (f->discard_samples_deferred) {
+      if (f->discard_samples_deferred >= right_start - left_start) {
+         f->discard_samples_deferred -= (right_start - left_start);
+         left_start = right_start;
+         *p_left = left_start;
+      } else {
+         left_start += f->discard_samples_deferred;
+         *p_left = left_start;
+         f->discard_samples_deferred = 0;
+      }
+   } else if (f->previous_length == 0 && f->current_loc_valid) {
+      // we're recovering from a seek... that means we're going to discard
+      // the samples from this packet even though we know our position from
+      // the last page header, so we need to update the position based on
+      // the discarded samples here
+      // but wait, the code below is going to add this in itself even
+      // on a discard, so we don't need to do it here...
+   }
+
+   // check if we have ogg information about the sample # for this packet
+   if (f->last_seg_which == f->end_seg_with_known_loc) {
+      // if we have a valid current loc, and this is final:
+      if (f->current_loc_valid && (f->page_flag & PAGEFLAG_last_page)) {
+         uint32 current_end = f->known_loc_for_packet;
+         // then let's infer the size of the (probably) short final frame
+         if (current_end < f->current_loc + (right_end-left_start)) {
+            if (current_end < f->current_loc) {
+               // negative truncation, that's impossible!
+               *len = 0;
+            } else {
+               *len = current_end - f->current_loc;
+            }
+            *len += left_start; // this doesn't seem right, but has no ill effect on my test files
+            if (*len > right_end) *len = right_end; // this should never happen
+            f->current_loc += *len;
+            return TRUE;
+         }
+      }
+      // otherwise, just set our sample loc
+      // guess that the ogg granule pos refers to the _middle_ of the
+      // last frame?
+      // set f->current_loc to the position of left_start
+      f->current_loc = f->known_loc_for_packet - (n2-left_start);
+      f->current_loc_valid = TRUE;
+   }
+   if (f->current_loc_valid)
+      f->current_loc += (right_start - left_start);
+
+   if (f->alloc.alloc_buffer)
+      assert(f->alloc.alloc_buffer_length_in_bytes == f->temp_offset);
+   *len = right_end;  // ignore samples after the window goes to 0
+   CHECK(f);
+
+   return TRUE;
+}
+
+static int vorbis_decode_packet(vorb *f, int *len, int *p_left, int *p_right)
+{
+   int mode, left_end, right_end;
+   if (!vorbis_decode_initial(f, p_left, &left_end, p_right, &right_end, &mode)) return 0;
+   return vorbis_decode_packet_rest(f, len, f->mode_config + mode, *p_left, left_end, *p_right, right_end, p_left);
+}
+
+static int vorbis_finish_frame(stb_vorbis *f, int len, int left, int right)
+{
+   int prev,i,j;
+   // we use right&left (the start of the right- and left-window sin()-regions)
+   // to determine how much to return, rather than inferring from the rules
+   // (same result, clearer code); 'left' indicates where our sin() window
+   // starts, therefore where the previous window's right edge starts, and
+   // therefore where to start mixing from the previous buffer. 'right'
+   // indicates where our sin() ending-window starts, therefore that's where
+   // we start saving, and where our returned-data ends.
+
+   // mixin from previous window
+   if (f->previous_length) {
+      int i,j, n = f->previous_length;
+      float *w = get_window(f, n);
+      if (w == NULL) return 0;
+      for (i=0; i < f->channels; ++i) {
+         for (j=0; j < n; ++j)
+            f->channel_buffers[i][left+j] =
+               f->channel_buffers[i][left+j]*w[    j] +
+               f->previous_window[i][     j]*w[n-1-j];
+      }
+   }
+
+   prev = f->previous_length;
+
+   // last half of this data becomes previous window
+   f->previous_length = len - right;
+
+   // @OPTIMIZE: could avoid this copy by double-buffering the
+   // output (flipping previous_window with channel_buffers), but
+   // then previous_window would have to be 2x as large, and
+   // channel_buffers couldn't be temp mem (although they're NOT
+   // currently temp mem, they could be (unless we want to level
+   // performance by spreading out the computation))
+   for (i=0; i < f->channels; ++i)
+      for (j=0; right+j < len; ++j)
+         f->previous_window[i][j] = f->channel_buffers[i][right+j];
+
+   if (!prev)
+      // there was no previous packet, so this data isn't valid...
+      // this isn't entirely true, only the would-have-overlapped data
+      // isn't valid, but this seems to be what the spec requires
+      return 0;
+
+   // truncate a short frame
+   if (len < right) right = len;
+
+   f->samples_output += right-left;
+
+   return right - left;
+}
+
+static int vorbis_pump_first_frame(stb_vorbis *f)
+{
+   int len, right, left, res;
+   res = vorbis_decode_packet(f, &len, &left, &right);
+   if (res)
+      vorbis_finish_frame(f, len, left, right);
+   return res;
+}
+
+#ifndef STB_VORBIS_NO_PUSHDATA_API
+static int is_whole_packet_present(stb_vorbis *f)
+{
+   // make sure that we have the packet available before continuing...
+   // this requires a full ogg parse, but we know we can fetch from f->stream
+
+   // instead of coding this out explicitly, we could save the current read state,
+   // read the next packet with get8() until end-of-packet, check f->eof, then
+   // reset the state? but that would be slower, esp. since we'd have over 256 bytes
+   // of state to restore (primarily the page segment table)
+
+   int s = f->next_seg, first = TRUE;
+   uint8 *p = f->stream;
+
+   if (s != -1) { // if we're not starting the packet with a 'continue on next page' flag
+      for (; s < f->segment_count; ++s) {
+         p += f->segments[s];
+         if (f->segments[s] < 255)               // stop at first short segment
+            break;
+      }
+      // either this continues, or it ends it...
+      if (s == f->segment_count)
+         s = -1; // set 'crosses page' flag
+      if (p > f->stream_end)                     return error(f, VORBIS_need_more_data);
+      first = FALSE;
+   }
+   for (; s == -1;) {
+      uint8 *q;
+      int n;
+
+      // check that we have the page header ready
+      if (p + 26 >= f->stream_end)               return error(f, VORBIS_need_more_data);
+      // validate the page
+      if (memcmp(p, ogg_page_header, 4))         return error(f, VORBIS_invalid_stream);
+      if (p[4] != 0)                             return error(f, VORBIS_invalid_stream);
+      if (first) { // the first segment must NOT have 'continued_packet', later ones MUST
+         if (f->previous_length)
+            if ((p[5] & PAGEFLAG_continued_packet))  return error(f, VORBIS_invalid_stream);
+         // if no previous length, we're resynching, so we can come in on a continued-packet,
+         // which we'll just drop
+      } else {
+         if (!(p[5] & PAGEFLAG_continued_packet)) return error(f, VORBIS_invalid_stream);
+      }
+      n = p[26]; // segment counts
+      q = p+27;  // q points to segment table
+      p = q + n; // advance past header
+      // make sure we've read the segment table
+      if (p > f->stream_end)                     return error(f, VORBIS_need_more_data);
+      for (s=0; s < n; ++s) {
+         p += q[s];
+         if (q[s] < 255)
+            break;
+      }
+      if (s == n)
+         s = -1; // set 'crosses page' flag
+      if (p > f->stream_end)                     return error(f, VORBIS_need_more_data);
+      first = FALSE;
+   }
+   return TRUE;
+}
+#endif // !STB_VORBIS_NO_PUSHDATA_API
+
+static int start_decoder(vorb *f)
+{
+   uint8 header[6], x,y;
+   int len,i,j,k, max_submaps = 0;
+   int longest_floorlist=0;
+
+   // first page, first packet
+   f->first_decode = TRUE;
+
+   if (!start_page(f))                              return FALSE;
+   // validate page flag
+   if (!(f->page_flag & PAGEFLAG_first_page))       return error(f, VORBIS_invalid_first_page);
+   if (f->page_flag & PAGEFLAG_last_page)           return error(f, VORBIS_invalid_first_page);
+   if (f->page_flag & PAGEFLAG_continued_packet)    return error(f, VORBIS_invalid_first_page);
+   // check for expected packet length
+   if (f->segment_count != 1)                       return error(f, VORBIS_invalid_first_page);
+   if (f->segments[0] != 30) {
+      // check for the Ogg skeleton fishead identifying header to refine our error
+      if (f->segments[0] == 64 &&
+          getn(f, header, 6) &&
+          header[0] == 'f' &&
+          header[1] == 'i' &&
+          header[2] == 's' &&
+          header[3] == 'h' &&
+          header[4] == 'e' &&
+          header[5] == 'a' &&
+          get8(f)   == 'd' &&
+          get8(f)   == '\0')                        return error(f, VORBIS_ogg_skeleton_not_supported);
+      else
+                                                    return error(f, VORBIS_invalid_first_page);
+   }
+
+   // read packet
+   // check packet header
+   if (get8(f) != VORBIS_packet_id)                 return error(f, VORBIS_invalid_first_page);
+   if (!getn(f, header, 6))                         return error(f, VORBIS_unexpected_eof);
+   if (!vorbis_validate(header))                    return error(f, VORBIS_invalid_first_page);
+   // vorbis_version
+   if (get32(f) != 0)                               return error(f, VORBIS_invalid_first_page);
+   f->channels = get8(f); if (!f->channels)         return error(f, VORBIS_invalid_first_page);
+   if (f->channels > STB_VORBIS_MAX_CHANNELS)       return error(f, VORBIS_too_many_channels);
+   f->sample_rate = get32(f); if (!f->sample_rate)  return error(f, VORBIS_invalid_first_page);
+   get32(f); // bitrate_maximum
+   get32(f); // bitrate_nominal
+   get32(f); // bitrate_minimum
+   x = get8(f);
+   {
+      int log0,log1;
+      log0 = x & 15;
+      log1 = x >> 4;
+      f->blocksize_0 = 1 << log0;
+      f->blocksize_1 = 1 << log1;
+      if (log0 < 6 || log0 > 13)                       return error(f, VORBIS_invalid_setup);
+      if (log1 < 6 || log1 > 13)                       return error(f, VORBIS_invalid_setup);
+      if (log0 > log1)                                 return error(f, VORBIS_invalid_setup);
+   }
+
+   // framing_flag
+   x = get8(f);
+   if (!(x & 1))                                    return error(f, VORBIS_invalid_first_page);
+
+   // second packet!
+   if (!start_page(f))                              return FALSE;
+
+   if (!start_packet(f))                            return FALSE;
+
+   if (!next_segment(f))                            return FALSE;
+
+   if (get8_packet(f) != VORBIS_packet_comment)            return error(f, VORBIS_invalid_setup);
+   for (i=0; i < 6; ++i) header[i] = get8_packet(f);
+   if (!vorbis_validate(header))                    return error(f, VORBIS_invalid_setup);
+   //file vendor
+   len = get32_packet(f);
+   f->vendor = (char*)setup_malloc(f, sizeof(char) * (len+1));
+   if (f->vendor == NULL)                           return error(f, VORBIS_outofmem);
+   for(i=0; i < len; ++i) {
+      f->vendor[i] = get8_packet(f);
+   }
+   f->vendor[len] = (char)'\0';
+   //user comments
+   f->comment_list_length = get32_packet(f);
+   f->comment_list = NULL;
+   if (f->comment_list_length > 0)
+   {
+      f->comment_list = (char**) setup_malloc(f, sizeof(char*) * (f->comment_list_length));
+      if (f->comment_list == NULL)                  return error(f, VORBIS_outofmem);
+   }
+
+   for(i=0; i < f->comment_list_length; ++i) {
+      len = get32_packet(f);
+      f->comment_list[i] = (char*)setup_malloc(f, sizeof(char) * (len+1));
+      if (f->comment_list[i] == NULL)               return error(f, VORBIS_outofmem);
+
+      for(j=0; j < len; ++j) {
+         f->comment_list[i][j] = get8_packet(f);
+      }
+      f->comment_list[i][len] = (char)'\0';
+   }
+
+   // framing_flag
+   x = get8_packet(f);
+   if (!(x & 1))                                    return error(f, VORBIS_invalid_setup);
+
+
+   skip(f, f->bytes_in_seg);
+   f->bytes_in_seg = 0;
+
+   do {
+      len = next_segment(f);
+      skip(f, len);
+      f->bytes_in_seg = 0;
+   } while (len);
+
+   // third packet!
+   if (!start_packet(f))                            return FALSE;
+
+   #ifndef STB_VORBIS_NO_PUSHDATA_API
+   if (IS_PUSH_MODE(f)) {
+      if (!is_whole_packet_present(f)) {
+         // convert error in ogg header to write type
+         if (f->error == VORBIS_invalid_stream)
+            f->error = VORBIS_invalid_setup;
+         return FALSE;
+      }
+   }
+   #endif
+
+   crc32_init(); // always init it, to avoid multithread race conditions
+
+   if (get8_packet(f) != VORBIS_packet_setup)       return error(f, VORBIS_invalid_setup);
+   for (i=0; i < 6; ++i) header[i] = get8_packet(f);
+   if (!vorbis_validate(header))                    return error(f, VORBIS_invalid_setup);
+
+   // codebooks
+
+   f->codebook_count = get_bits(f,8) + 1;
+   f->codebooks = (Codebook *) setup_malloc(f, sizeof(*f->codebooks) * f->codebook_count);
+   if (f->codebooks == NULL)                        return error(f, VORBIS_outofmem);
+   memset(f->codebooks, 0, sizeof(*f->codebooks) * f->codebook_count);
+   for (i=0; i < f->codebook_count; ++i) {
+      uint32 *values;
+      int ordered, sorted_count;
+      int total=0;
+      uint8 *lengths;
+      Codebook *c = f->codebooks+i;
+      CHECK(f);
+      x = get_bits(f, 8); if (x != 0x42)            return error(f, VORBIS_invalid_setup);
+      x = get_bits(f, 8); if (x != 0x43)            return error(f, VORBIS_invalid_setup);
+      x = get_bits(f, 8); if (x != 0x56)            return error(f, VORBIS_invalid_setup);
+      x = get_bits(f, 8);
+      c->dimensions = (get_bits(f, 8)<<8) + x;
+      x = get_bits(f, 8);
+      y = get_bits(f, 8);
+      c->entries = (get_bits(f, 8)<<16) + (y<<8) + x;
+      ordered = get_bits(f,1);
+      c->sparse = ordered ? 0 : get_bits(f,1);
+
+      if (c->dimensions == 0 && c->entries != 0)    return error(f, VORBIS_invalid_setup);
+
+      if (c->sparse)
+         lengths = (uint8 *) setup_temp_malloc(f, c->entries);
+      else
+         lengths = c->codeword_lengths = (uint8 *) setup_malloc(f, c->entries);
+
+      if (!lengths) return error(f, VORBIS_outofmem);
+
+      if (ordered) {
+         int current_entry = 0;
+         int current_length = get_bits(f,5) + 1;
+         while (current_entry < c->entries) {
+            int limit = c->entries - current_entry;
+            int n = get_bits(f, ilog(limit));
+            if (current_length >= 32) return error(f, VORBIS_invalid_setup);
+            if (current_entry + n > (int) c->entries) { return error(f, VORBIS_invalid_setup); }
+            memset(lengths + current_entry, current_length, n);
+            current_entry += n;
+            ++current_length;
+         }
+      } else {
+         for (j=0; j < c->entries; ++j) {
+            int present = c->sparse ? get_bits(f,1) : 1;
+            if (present) {
+               lengths[j] = get_bits(f, 5) + 1;
+               ++total;
+               if (lengths[j] == 32)
+                  return error(f, VORBIS_invalid_setup);
+            } else {
+               lengths[j] = NO_CODE;
+            }
+         }
+      }
+
+      if (c->sparse && total >= c->entries >> 2) {
+         // convert sparse items to non-sparse!
+         if (c->entries > (int) f->setup_temp_memory_required)
+            f->setup_temp_memory_required = c->entries;
+
+         c->codeword_lengths = (uint8 *) setup_malloc(f, c->entries);
+         if (c->codeword_lengths == NULL) return error(f, VORBIS_outofmem);
+         memcpy(c->codeword_lengths, lengths, c->entries);
+         setup_temp_free(f, lengths, c->entries); // note this is only safe if there have been no intervening temp mallocs!
+         lengths = c->codeword_lengths;
+         c->sparse = 0;
+      }
+
+      // compute the size of the sorted tables
+      if (c->sparse) {
+         sorted_count = total;
+      } else {
+         sorted_count = 0;
+         #ifndef STB_VORBIS_NO_HUFFMAN_BINARY_SEARCH
+         for (j=0; j < c->entries; ++j)
+            if (lengths[j] > STB_VORBIS_FAST_HUFFMAN_LENGTH && lengths[j] != NO_CODE)
+               ++sorted_count;
+         #endif
+      }
+
+      c->sorted_entries = sorted_count;
+      values = NULL;
+
+      CHECK(f);
+      if (!c->sparse) {
+         c->codewords = (uint32 *) setup_malloc(f, sizeof(c->codewords[0]) * c->entries);
+         if (!c->codewords)                  return error(f, VORBIS_outofmem);
+      } else {
+         unsigned int size;
+         if (c->sorted_entries) {
+            c->codeword_lengths = (uint8 *) setup_malloc(f, c->sorted_entries);
+            if (!c->codeword_lengths)           return error(f, VORBIS_outofmem);
+            c->codewords = (uint32 *) setup_temp_malloc(f, sizeof(*c->codewords) * c->sorted_entries);
+            if (!c->codewords)                  return error(f, VORBIS_outofmem);
+            values = (uint32 *) setup_temp_malloc(f, sizeof(*values) * c->sorted_entries);
+            if (!values)                        return error(f, VORBIS_outofmem);
+         }
+         size = c->entries + (sizeof(*c->codewords) + sizeof(*values)) * c->sorted_entries;
+         if (size > f->setup_temp_memory_required)
+            f->setup_temp_memory_required = size;
+      }
+
+      if (!compute_codewords(c, lengths, c->entries, values)) {
+         if (c->sparse) setup_temp_free(f, values, 0);
+         return error(f, VORBIS_invalid_setup);
+      }
+
+      if (c->sorted_entries) {
+         // allocate an extra slot for sentinels
+         c->sorted_codewords = (uint32 *) setup_malloc(f, sizeof(*c->sorted_codewords) * (c->sorted_entries+1));
+         if (c->sorted_codewords == NULL) return error(f, VORBIS_outofmem);
+         // allocate an extra slot at the front so that c->sorted_values[-1] is defined
+         // so that we can catch that case without an extra if
+         c->sorted_values    = ( int   *) setup_malloc(f, sizeof(*c->sorted_values   ) * (c->sorted_entries+1));
+         if (c->sorted_values == NULL) return error(f, VORBIS_outofmem);
+         ++c->sorted_values;
+         c->sorted_values[-1] = -1;
+         compute_sorted_huffman(c, lengths, values);
+      }
+
+      if (c->sparse) {
+         setup_temp_free(f, values, sizeof(*values)*c->sorted_entries);
+         setup_temp_free(f, c->codewords, sizeof(*c->codewords)*c->sorted_entries);
+         setup_temp_free(f, lengths, c->entries);
+         c->codewords = NULL;
+      }
+
+      compute_accelerated_huffman(c);
+
+      CHECK(f);
+      c->lookup_type = get_bits(f, 4);
+      if (c->lookup_type > 2) return error(f, VORBIS_invalid_setup);
+      if (c->lookup_type > 0) {
+         uint16 *mults;
+         c->minimum_value = float32_unpack(get_bits(f, 32));
+         c->delta_value = float32_unpack(get_bits(f, 32));
+         c->value_bits = get_bits(f, 4)+1;
+         c->sequence_p = get_bits(f,1);
+         if (c->lookup_type == 1) {
+            int values = lookup1_values(c->entries, c->dimensions);
+            if (values < 0) return error(f, VORBIS_invalid_setup);
+            c->lookup_values = (uint32) values;
+         } else {
+            c->lookup_values = c->entries * c->dimensions;
+         }
+         if (c->lookup_values == 0) return error(f, VORBIS_invalid_setup);
+         mults = (uint16 *) setup_temp_malloc(f, sizeof(mults[0]) * c->lookup_values);
+         if (mults == NULL) return error(f, VORBIS_outofmem);
+         for (j=0; j < (int) c->lookup_values; ++j) {
+            int q = get_bits(f, c->value_bits);
+            if (q == EOP) { setup_temp_free(f,mults,sizeof(mults[0])*c->lookup_values); return error(f, VORBIS_invalid_setup); }
+            mults[j] = q;
+         }
+
+#ifndef STB_VORBIS_DIVIDES_IN_CODEBOOK
+         if (c->lookup_type == 1) {
+            int len, sparse = c->sparse;
+            float last=0;
+            // pre-expand the lookup1-style multiplicands, to avoid a divide in the inner loop
+            if (sparse) {
+               if (c->sorted_entries == 0) goto skip;
+               c->multiplicands = (codetype *) setup_malloc(f, sizeof(c->multiplicands[0]) * c->sorted_entries * c->dimensions);
+            } else
+               c->multiplicands = (codetype *) setup_malloc(f, sizeof(c->multiplicands[0]) * c->entries        * c->dimensions);
+            if (c->multiplicands == NULL) { setup_temp_free(f,mults,sizeof(mults[0])*c->lookup_values); return error(f, VORBIS_outofmem); }
+            len = sparse ? c->sorted_entries : c->entries;
+            for (j=0; j < len; ++j) {
+               unsigned int z = sparse ? c->sorted_values[j] : j;
+               unsigned int div=1;
+               for (k=0; k < c->dimensions; ++k) {
+                  int off = (z / div) % c->lookup_values;
+                  float val = mults[off]*c->delta_value + c->minimum_value + last;
+                  c->multiplicands[j*c->dimensions + k] = val;
+                  if (c->sequence_p)
+                     last = val;
+                  if (k+1 < c->dimensions) {
+                     if (div > UINT_MAX / (unsigned int) c->lookup_values) {
+                        setup_temp_free(f, mults,sizeof(mults[0])*c->lookup_values);
+                        return error(f, VORBIS_invalid_setup);
+                     }
+                     div *= c->lookup_values;
+                  }
+               }
+            }
+            c->lookup_type = 2;
+         }
+         else
+#endif
+         {
+            float last=0;
+            CHECK(f);
+            c->multiplicands = (codetype *) setup_malloc(f, sizeof(c->multiplicands[0]) * c->lookup_values);
+            if (c->multiplicands == NULL) { setup_temp_free(f, mults,sizeof(mults[0])*c->lookup_values); return error(f, VORBIS_outofmem); }
+            for (j=0; j < (int) c->lookup_values; ++j) {
+               float val = mults[j] * c->delta_value + c->minimum_value + last;
+               c->multiplicands[j] = val;
+               if (c->sequence_p)
+                  last = val;
+            }
+         }
+#ifndef STB_VORBIS_DIVIDES_IN_CODEBOOK
+        skip:;
+#endif
+         setup_temp_free(f, mults, sizeof(mults[0])*c->lookup_values);
+
+         CHECK(f);
+      }
+      CHECK(f);
+   }
+
+   // time domain transfers (notused)
+
+   x = get_bits(f, 6) + 1;
+   for (i=0; i < x; ++i) {
+      uint32 z = get_bits(f, 16);
+      if (z != 0) return error(f, VORBIS_invalid_setup);
+   }
+
+   // Floors
+   f->floor_count = get_bits(f, 6)+1;
+   f->floor_config = (Floor *)  setup_malloc(f, f->floor_count * sizeof(*f->floor_config));
+   if (f->floor_config == NULL) return error(f, VORBIS_outofmem);
+   for (i=0; i < f->floor_count; ++i) {
+      f->floor_types[i] = get_bits(f, 16);
+      if (f->floor_types[i] > 1) return error(f, VORBIS_invalid_setup);
+      if (f->floor_types[i] == 0) {
+         Floor0 *g = &f->floor_config[i].floor0;
+         g->order = get_bits(f,8);
+         g->rate = get_bits(f,16);
+         g->bark_map_size = get_bits(f,16);
+         g->amplitude_bits = get_bits(f,6);
+         g->amplitude_offset = get_bits(f,8);
+         g->number_of_books = get_bits(f,4) + 1;
+         for (j=0; j < g->number_of_books; ++j)
+            g->book_list[j] = get_bits(f,8);
+         return error(f, VORBIS_feature_not_supported);
+      } else {
+         stbv__floor_ordering p[31*8+2];
+         Floor1 *g = &f->floor_config[i].floor1;
+         int max_class = -1;
+         g->partitions = get_bits(f, 5);
+         for (j=0; j < g->partitions; ++j) {
+            g->partition_class_list[j] = get_bits(f, 4);
+            if (g->partition_class_list[j] > max_class)
+               max_class = g->partition_class_list[j];
+         }
+         for (j=0; j <= max_class; ++j) {
+            g->class_dimensions[j] = get_bits(f, 3)+1;
+            g->class_subclasses[j] = get_bits(f, 2);
+            if (g->class_subclasses[j]) {
+               g->class_masterbooks[j] = get_bits(f, 8);
+               if (g->class_masterbooks[j] >= f->codebook_count) return error(f, VORBIS_invalid_setup);
+            }
+            for (k=0; k < 1 << g->class_subclasses[j]; ++k) {
+               g->subclass_books[j][k] = (int16)get_bits(f,8)-1;
+               if (g->subclass_books[j][k] >= f->codebook_count) return error(f, VORBIS_invalid_setup);
+            }
+         }
+         g->floor1_multiplier = get_bits(f,2)+1;
+         g->rangebits = get_bits(f,4);
+         g->Xlist[0] = 0;
+         g->Xlist[1] = 1 << g->rangebits;
+         g->values = 2;
+         for (j=0; j < g->partitions; ++j) {
+            int c = g->partition_class_list[j];
+            for (k=0; k < g->class_dimensions[c]; ++k) {
+               g->Xlist[g->values] = get_bits(f, g->rangebits);
+               ++g->values;
+            }
+         }
+         // precompute the sorting
+         for (j=0; j < g->values; ++j) {
+            p[j].x = g->Xlist[j];
+            p[j].id = j;
+         }
+         qsort(p, g->values, sizeof(p[0]), point_compare);
+         for (j=0; j < g->values-1; ++j)
+            if (p[j].x == p[j+1].x)
+               return error(f, VORBIS_invalid_setup);
+         for (j=0; j < g->values; ++j)
+            g->sorted_order[j] = (uint8) p[j].id;
+         // precompute the neighbors
+         for (j=2; j < g->values; ++j) {
+            int low = 0,hi = 0;
+            neighbors(g->Xlist, j, &low,&hi);
+            g->neighbors[j][0] = low;
+            g->neighbors[j][1] = hi;
+         }
+
+         if (g->values > longest_floorlist)
+            longest_floorlist = g->values;
+      }
+   }
+
+   // Residue
+   f->residue_count = get_bits(f, 6)+1;
+   f->residue_config = (Residue *) setup_malloc(f, f->residue_count * sizeof(f->residue_config[0]));
+   if (f->residue_config == NULL) return error(f, VORBIS_outofmem);
+   memset(f->residue_config, 0, f->residue_count * sizeof(f->residue_config[0]));
+   for (i=0; i < f->residue_count; ++i) {
+      uint8 residue_cascade[64];
+      Residue *r = f->residue_config+i;
+      f->residue_types[i] = get_bits(f, 16);
+      if (f->residue_types[i] > 2) return error(f, VORBIS_invalid_setup);
+      r->begin = get_bits(f, 24);
+      r->end = get_bits(f, 24);
+      if (r->end < r->begin) return error(f, VORBIS_invalid_setup);
+      r->part_size = get_bits(f,24)+1;
+      r->classifications = get_bits(f,6)+1;
+      r->classbook = get_bits(f,8);
+      if (r->classbook >= f->codebook_count) return error(f, VORBIS_invalid_setup);
+      for (j=0; j < r->classifications; ++j) {
+         uint8 high_bits=0;
+         uint8 low_bits=get_bits(f,3);
+         if (get_bits(f,1))
+            high_bits = get_bits(f,5);
+         residue_cascade[j] = high_bits*8 + low_bits;
+      }
+      r->residue_books = (short (*)[8]) setup_malloc(f, sizeof(r->residue_books[0]) * r->classifications);
+      if (r->residue_books == NULL) return error(f, VORBIS_outofmem);
+      for (j=0; j < r->classifications; ++j) {
+         for (k=0; k < 8; ++k) {
+            if (residue_cascade[j] & (1 << k)) {
+               r->residue_books[j][k] = get_bits(f, 8);
+               if (r->residue_books[j][k] >= f->codebook_count) return error(f, VORBIS_invalid_setup);
+            } else {
+               r->residue_books[j][k] = -1;
+            }
+         }
+      }
+      // precompute the classifications[] array to avoid inner-loop mod/divide
+      // call it 'classdata' since we already have r->classifications
+      r->classdata = (uint8 **) setup_malloc(f, sizeof(*r->classdata) * f->codebooks[r->classbook].entries);
+      if (!r->classdata) return error(f, VORBIS_outofmem);
+      memset(r->classdata, 0, sizeof(*r->classdata) * f->codebooks[r->classbook].entries);
+      for (j=0; j < f->codebooks[r->classbook].entries; ++j) {
+         int classwords = f->codebooks[r->classbook].dimensions;
+         int temp = j;
+         r->classdata[j] = (uint8 *) setup_malloc(f, sizeof(r->classdata[j][0]) * classwords);
+         if (r->classdata[j] == NULL) return error(f, VORBIS_outofmem);
+         for (k=classwords-1; k >= 0; --k) {
+            r->classdata[j][k] = temp % r->classifications;
+            temp /= r->classifications;
+         }
+      }
+   }
+
+   f->mapping_count = get_bits(f,6)+1;
+   f->mapping = (Mapping *) setup_malloc(f, f->mapping_count * sizeof(*f->mapping));
+   if (f->mapping == NULL) return error(f, VORBIS_outofmem);
+   memset(f->mapping, 0, f->mapping_count * sizeof(*f->mapping));
+   for (i=0; i < f->mapping_count; ++i) {
+      Mapping *m = f->mapping + i;
+      int mapping_type = get_bits(f,16);
+      if (mapping_type != 0) return error(f, VORBIS_invalid_setup);
+      m->chan = (MappingChannel *) setup_malloc(f, f->channels * sizeof(*m->chan));
+      if (m->chan == NULL) return error(f, VORBIS_outofmem);
+      if (get_bits(f,1))
+         m->submaps = get_bits(f,4)+1;
+      else
+         m->submaps = 1;
+      if (m->submaps > max_submaps)
+         max_submaps = m->submaps;
+      if (get_bits(f,1)) {
+         m->coupling_steps = get_bits(f,8)+1;
+         if (m->coupling_steps > f->channels) return error(f, VORBIS_invalid_setup);
+         for (k=0; k < m->coupling_steps; ++k) {
+            m->chan[k].magnitude = get_bits(f, ilog(f->channels-1));
+            m->chan[k].angle = get_bits(f, ilog(f->channels-1));
+            if (m->chan[k].magnitude >= f->channels)        return error(f, VORBIS_invalid_setup);
+            if (m->chan[k].angle     >= f->channels)        return error(f, VORBIS_invalid_setup);
+            if (m->chan[k].magnitude == m->chan[k].angle)   return error(f, VORBIS_invalid_setup);
+         }
+      } else
+         m->coupling_steps = 0;
+
+      // reserved field
+      if (get_bits(f,2)) return error(f, VORBIS_invalid_setup);
+      if (m->submaps > 1) {
+         for (j=0; j < f->channels; ++j) {
+            m->chan[j].mux = get_bits(f, 4);
+            if (m->chan[j].mux >= m->submaps)                return error(f, VORBIS_invalid_setup);
+         }
+      } else
+         // @SPECIFICATION: this case is missing from the spec
+         for (j=0; j < f->channels; ++j)
+            m->chan[j].mux = 0;
+
+      for (j=0; j < m->submaps; ++j) {
+         get_bits(f,8); // discard
+         m->submap_floor[j] = get_bits(f,8);
+         m->submap_residue[j] = get_bits(f,8);
+         if (m->submap_floor[j] >= f->floor_count)      return error(f, VORBIS_invalid_setup);
+         if (m->submap_residue[j] >= f->residue_count)  return error(f, VORBIS_invalid_setup);
+      }
+   }
+
+   // Modes
+   f->mode_count = get_bits(f, 6)+1;
+   for (i=0; i < f->mode_count; ++i) {
+      Mode *m = f->mode_config+i;
+      m->blockflag = get_bits(f,1);
+      m->windowtype = get_bits(f,16);
+      m->transformtype = get_bits(f,16);
+      m->mapping = get_bits(f,8);
+      if (m->windowtype != 0)                 return error(f, VORBIS_invalid_setup);
+      if (m->transformtype != 0)              return error(f, VORBIS_invalid_setup);
+      if (m->mapping >= f->mapping_count)     return error(f, VORBIS_invalid_setup);
+   }
+
+   flush_packet(f);
+
+   f->previous_length = 0;
+
+   for (i=0; i < f->channels; ++i) {
+      f->channel_buffers[i] = (float *) setup_malloc(f, sizeof(float) * f->blocksize_1);
+      f->previous_window[i] = (float *) setup_malloc(f, sizeof(float) * f->blocksize_1/2);
+      f->finalY[i]          = (int16 *) setup_malloc(f, sizeof(int16) * longest_floorlist);
+      if (f->channel_buffers[i] == NULL || f->previous_window[i] == NULL || f->finalY[i] == NULL) return error(f, VORBIS_outofmem);
+      memset(f->channel_buffers[i], 0, sizeof(float) * f->blocksize_1);
+      #ifdef STB_VORBIS_NO_DEFER_FLOOR
+      f->floor_buffers[i]   = (float *) setup_malloc(f, sizeof(float) * f->blocksize_1/2);
+      if (f->floor_buffers[i] == NULL) return error(f, VORBIS_outofmem);
+      #endif
+   }
+
+   if (!init_blocksize(f, 0, f->blocksize_0)) return FALSE;
+   if (!init_blocksize(f, 1, f->blocksize_1)) return FALSE;
+   f->blocksize[0] = f->blocksize_0;
+   f->blocksize[1] = f->blocksize_1;
+
+#ifdef STB_VORBIS_DIVIDE_TABLE
+   if (integer_divide_table[1][1]==0)
+      for (i=0; i < DIVTAB_NUMER; ++i)
+         for (j=1; j < DIVTAB_DENOM; ++j)
+            integer_divide_table[i][j] = i / j;
+#endif
+
+   // compute how much temporary memory is needed
+
+   // 1.
+   {
+      uint32 imdct_mem = (f->blocksize_1 * sizeof(float) >> 1);
+      uint32 classify_mem;
+      int i,max_part_read=0;
+      for (i=0; i < f->residue_count; ++i) {
+         Residue *r = f->residue_config + i;
+         unsigned int actual_size = f->blocksize_1 / 2;
+         unsigned int limit_r_begin = r->begin < actual_size ? r->begin : actual_size;
+         unsigned int limit_r_end   = r->end   < actual_size ? r->end   : actual_size;
+         int n_read = limit_r_end - limit_r_begin;
+         int part_read = n_read / r->part_size;
+         if (part_read > max_part_read)
+            max_part_read = part_read;
+      }
+      #ifndef STB_VORBIS_DIVIDES_IN_RESIDUE
+      classify_mem = f->channels * (sizeof(void*) + max_part_read * sizeof(uint8 *));
+      #else
+      classify_mem = f->channels * (sizeof(void*) + max_part_read * sizeof(int *));
+      #endif
+
+      // maximum reasonable partition size is f->blocksize_1
+
+      f->temp_memory_required = classify_mem;
+      if (imdct_mem > f->temp_memory_required)
+         f->temp_memory_required = imdct_mem;
+   }
+
+
+   if (f->alloc.alloc_buffer) {
+      assert(f->temp_offset == f->alloc.alloc_buffer_length_in_bytes);
+      // check if there's enough temp memory so we don't error later
+      if (f->setup_offset + sizeof(*f) + f->temp_memory_required > (unsigned) f->temp_offset)
+         return error(f, VORBIS_outofmem);
+   }
+
+   // @TODO: stb_vorbis_seek_start expects first_audio_page_offset to point to a page
+   // without PAGEFLAG_continued_packet, so this either points to the first page, or
+   // the page after the end of the headers. It might be cleaner to point to a page
+   // in the middle of the headers, when that's the page where the first audio packet
+   // starts, but we'd have to also correctly skip the end of any continued packet in
+   // stb_vorbis_seek_start.
+   if (f->next_seg == -1) {
+      f->first_audio_page_offset = stb_vorbis_get_file_offset(f);
+   } else {
+      f->first_audio_page_offset = 0;
+   }
+
+   return TRUE;
+}
+
+static void vorbis_deinit(stb_vorbis *p)
+{
+   int i,j;
+
+   setup_free(p, p->vendor);
+   for (i=0; i < p->comment_list_length; ++i) {
+      setup_free(p, p->comment_list[i]);
+   }
+   setup_free(p, p->comment_list);
+
+   if (p->residue_config) {
+      for (i=0; i < p->residue_count; ++i) {
+         Residue *r = p->residue_config+i;
+         if (r->classdata) {
+            for (j=0; j < p->codebooks[r->classbook].entries; ++j)
+               setup_free(p, r->classdata[j]);
+            setup_free(p, r->classdata);
+         }
+         setup_free(p, r->residue_books);
+      }
+   }
+
+   if (p->codebooks) {
+      CHECK(p);
+      for (i=0; i < p->codebook_count; ++i) {
+         Codebook *c = p->codebooks + i;
+         setup_free(p, c->codeword_lengths);
+         setup_free(p, c->multiplicands);
+         setup_free(p, c->codewords);
+         setup_free(p, c->sorted_codewords);
+         // c->sorted_values[-1] is the first entry in the array
+         setup_free(p, c->sorted_values ? c->sorted_values-1 : NULL);
+      }
+      setup_free(p, p->codebooks);
+   }
+   setup_free(p, p->floor_config);
+   setup_free(p, p->residue_config);
+   if (p->mapping) {
+      for (i=0; i < p->mapping_count; ++i)
+         setup_free(p, p->mapping[i].chan);
+      setup_free(p, p->mapping);
+   }
+   CHECK(p);
+   for (i=0; i < p->channels && i < STB_VORBIS_MAX_CHANNELS; ++i) {
+      setup_free(p, p->channel_buffers[i]);
+      setup_free(p, p->previous_window[i]);
+      #ifdef STB_VORBIS_NO_DEFER_FLOOR
+      setup_free(p, p->floor_buffers[i]);
+      #endif
+      setup_free(p, p->finalY[i]);
+   }
+   for (i=0; i < 2; ++i) {
+      setup_free(p, p->A[i]);
+      setup_free(p, p->B[i]);
+      setup_free(p, p->C[i]);
+      setup_free(p, p->window[i]);
+      setup_free(p, p->bit_reverse[i]);
+   }
+   #ifndef STB_VORBIS_NO_STDIO
+   if (p->close_on_free) fclose(p->f);
+   #endif
+}
+
+void stb_vorbis_close(stb_vorbis *p)
+{
+   if (p == NULL) return;
+   vorbis_deinit(p);
+   setup_free(p,p);
+}
+
+static void vorbis_init(stb_vorbis *p, const stb_vorbis_alloc *z)
+{
+   memset(p, 0, sizeof(*p)); // NULL out all malloc'd pointers to start
+   if (z) {
+      p->alloc = *z;
+      p->alloc.alloc_buffer_length_in_bytes &= ~7;
+      p->temp_offset = p->alloc.alloc_buffer_length_in_bytes;
+   }
+   p->eof = 0;
+   p->error = VORBIS__no_error;
+   p->stream = NULL;
+   p->codebooks = NULL;
+   p->page_crc_tests = -1;
+   #ifndef STB_VORBIS_NO_STDIO
+   p->close_on_free = FALSE;
+   p->f = NULL;
+   #endif
+}
+
+int stb_vorbis_get_sample_offset(stb_vorbis *f)
+{
+   if (f->current_loc_valid)
+      return f->current_loc;
+   else
+      return -1;
+}
+
+stb_vorbis_info stb_vorbis_get_info(stb_vorbis *f)
+{
+   stb_vorbis_info d;
+   d.channels = f->channels;
+   d.sample_rate = f->sample_rate;
+   d.setup_memory_required = f->setup_memory_required;
+   d.setup_temp_memory_required = f->setup_temp_memory_required;
+   d.temp_memory_required = f->temp_memory_required;
+   d.max_frame_size = f->blocksize_1 >> 1;
+   return d;
+}
+
+stb_vorbis_comment stb_vorbis_get_comment(stb_vorbis *f)
+{
+   stb_vorbis_comment d;
+   d.vendor = f->vendor;
+   d.comment_list_length = f->comment_list_length;
+   d.comment_list = f->comment_list;
+   return d;
+}
+
+int stb_vorbis_get_error(stb_vorbis *f)
+{
+   int e = f->error;
+   f->error = VORBIS__no_error;
+   return e;
+}
+
+static stb_vorbis * vorbis_alloc(stb_vorbis *f)
+{
+   stb_vorbis *p = (stb_vorbis *) setup_malloc(f, sizeof(*p));
+   return p;
+}
+
+#ifndef STB_VORBIS_NO_PUSHDATA_API
+
+void stb_vorbis_flush_pushdata(stb_vorbis *f)
+{
+   f->previous_length = 0;
+   f->page_crc_tests  = 0;
+   f->discard_samples_deferred = 0;
+   f->current_loc_valid = FALSE;
+   f->first_decode = FALSE;
+   f->samples_output = 0;
+   f->channel_buffer_start = 0;
+   f->channel_buffer_end = 0;
+}
+
+static int vorbis_search_for_page_pushdata(vorb *f, uint8 *data, int data_len)
+{
+   int i,n;
+   for (i=0; i < f->page_crc_tests; ++i)
+      f->scan[i].bytes_done = 0;
+
+   // if we have room for more scans, search for them first, because
+   // they may cause us to stop early if their header is incomplete
+   if (f->page_crc_tests < STB_VORBIS_PUSHDATA_CRC_COUNT) {
+      if (data_len < 4) return 0;
+      data_len -= 3; // need to look for 4-byte sequence, so don't miss
+                     // one that straddles a boundary
+      for (i=0; i < data_len; ++i) {
+         if (data[i] == 0x4f) {
+            if (0==memcmp(data+i, ogg_page_header, 4)) {
+               int j,len;
+               uint32 crc;
+               // make sure we have the whole page header
+               if (i+26 >= data_len || i+27+data[i+26] >= data_len) {
+                  // only read up to this page start, so hopefully we'll
+                  // have the whole page header start next time
+                  data_len = i;
+                  break;
+               }
+               // ok, we have it all; compute the length of the page
+               len = 27 + data[i+26];
+               for (j=0; j < data[i+26]; ++j)
+                  len += data[i+27+j];
+               // scan everything up to the embedded crc (which we must 0)
+               crc = 0;
+               for (j=0; j < 22; ++j)
+                  crc = crc32_update(crc, data[i+j]);
+               // now process 4 0-bytes
+               for (   ; j < 26; ++j)
+                  crc = crc32_update(crc, 0);
+               // len is the total number of bytes we need to scan
+               n = f->page_crc_tests++;
+               f->scan[n].bytes_left = len-j;
+               f->scan[n].crc_so_far = crc;
+               f->scan[n].goal_crc = data[i+22] + (data[i+23] << 8) + (data[i+24]<<16) + (data[i+25]<<24);
+               // if the last frame on a page is continued to the next, then
+               // we can't recover the sample_loc immediately
+               if (data[i+27+data[i+26]-1] == 255)
+                  f->scan[n].sample_loc = ~0;
+               else
+                  f->scan[n].sample_loc = data[i+6] + (data[i+7] << 8) + (data[i+ 8]<<16) + (data[i+ 9]<<24);
+               f->scan[n].bytes_done = i+j;
+               if (f->page_crc_tests == STB_VORBIS_PUSHDATA_CRC_COUNT)
+                  break;
+               // keep going if we still have room for more
+            }
+         }
+      }
+   }
+
+   for (i=0; i < f->page_crc_tests;) {
+      uint32 crc;
+      int j;
+      int n = f->scan[i].bytes_done;
+      int m = f->scan[i].bytes_left;
+      if (m > data_len - n) m = data_len - n;
+      // m is the bytes to scan in the current chunk
+      crc = f->scan[i].crc_so_far;
+      for (j=0; j < m; ++j)
+         crc = crc32_update(crc, data[n+j]);
+      f->scan[i].bytes_left -= m;
+      f->scan[i].crc_so_far = crc;
+      if (f->scan[i].bytes_left == 0) {
+         // does it match?
+         if (f->scan[i].crc_so_far == f->scan[i].goal_crc) {
+            // Houston, we have page
+            data_len = n+m; // consumption amount is wherever that scan ended
+            f->page_crc_tests = -1; // drop out of page scan mode
+            f->previous_length = 0; // decode-but-don't-output one frame
+            f->next_seg = -1;       // start a new page
+            f->current_loc = f->scan[i].sample_loc; // set the current sample location
+                                    // to the amount we'd have decoded had we decoded this page
+            f->current_loc_valid = f->current_loc != ~0U;
+            return data_len;
+         }
+         // delete entry
+         f->scan[i] = f->scan[--f->page_crc_tests];
+      } else {
+         ++i;
+      }
+   }
+
+   return data_len;
+}
+
+// return value: number of bytes we used
+int stb_vorbis_decode_frame_pushdata(
+         stb_vorbis *f,                   // the file we're decoding
+         const uint8 *data, int data_len, // the memory available for decoding
+         int *channels,                   // place to write number of float * buffers
+         float ***output,                 // place to write float ** array of float * buffers
+         int *samples                     // place to write number of output samples
+     )
+{
+   int i;
+   int len,right,left;
+
+   if (!IS_PUSH_MODE(f)) return error(f, VORBIS_invalid_api_mixing);
+
+   if (f->page_crc_tests >= 0) {
+      *samples = 0;
+      return vorbis_search_for_page_pushdata(f, (uint8 *) data, data_len);
+   }
+
+   f->stream     = (uint8 *) data;
+   f->stream_end = (uint8 *) data + data_len;
+   f->error      = VORBIS__no_error;
+
+   // check that we have the entire packet in memory
+   if (!is_whole_packet_present(f)) {
+      *samples = 0;
+      return 0;
+   }
+
+   if (!vorbis_decode_packet(f, &len, &left, &right)) {
+      // save the actual error we encountered
+      enum STBVorbisError error = f->error;
+      if (error == VORBIS_bad_packet_type) {
+         // flush and resynch
+         f->error = VORBIS__no_error;
+         while (get8_packet(f) != EOP)
+            if (f->eof) break;
+         *samples = 0;
+         return (int) (f->stream - data);
+      }
+      if (error == VORBIS_continued_packet_flag_invalid) {
+         if (f->previous_length == 0) {
+            // we may be resynching, in which case it's ok to hit one
+            // of these; just discard the packet
+            f->error = VORBIS__no_error;
+            while (get8_packet(f) != EOP)
+               if (f->eof) break;
+            *samples = 0;
+            return (int) (f->stream - data);
+         }
+      }
+      // if we get an error while parsing, what to do?
+      // well, it DEFINITELY won't work to continue from where we are!
+      stb_vorbis_flush_pushdata(f);
+      // restore the error that actually made us bail
+      f->error = error;
+      *samples = 0;
+      return 1;
+   }
+
+   // success!
+   len = vorbis_finish_frame(f, len, left, right);
+   for (i=0; i < f->channels; ++i)
+      f->outputs[i] = f->channel_buffers[i] + left;
+
+   if (channels) *channels = f->channels;
+   *samples = len;
+   *output = f->outputs;
+   return (int) (f->stream - data);
+}
+
+stb_vorbis *stb_vorbis_open_pushdata(
+         const unsigned char *data, int data_len, // the memory available for decoding
+         int *data_used,              // only defined if result is not NULL
+         int *error, const stb_vorbis_alloc *alloc)
+{
+   stb_vorbis *f, p;
+   vorbis_init(&p, alloc);
+   p.stream     = (uint8 *) data;
+   p.stream_end = (uint8 *) data + data_len;
+   p.push_mode  = TRUE;
+   if (!start_decoder(&p)) {
+      if (p.eof)
+         *error = VORBIS_need_more_data;
+      else
+         *error = p.error;
+      vorbis_deinit(&p);
+      return NULL;
+   }
+   f = vorbis_alloc(&p);
+   if (f) {
+      *f = p;
+      *data_used = (int) (f->stream - data);
+      *error = 0;
+      return f;
+   } else {
+      vorbis_deinit(&p);
+      return NULL;
+   }
+}
+#endif // STB_VORBIS_NO_PUSHDATA_API
+
+unsigned int stb_vorbis_get_file_offset(stb_vorbis *f)
+{
+   #ifndef STB_VORBIS_NO_PUSHDATA_API
+   if (f->push_mode) return 0;
+   #endif
+   if (USE_MEMORY(f)) return (unsigned int) (f->stream - f->stream_start);
+   #ifndef STB_VORBIS_NO_STDIO
+   return (unsigned int) (ftell(f->f) - f->f_start);
+   #endif
+}
+
+#ifndef STB_VORBIS_NO_PULLDATA_API
+//
+// DATA-PULLING API
+//
+
+static uint32 vorbis_find_page(stb_vorbis *f, uint32 *end, uint32 *last)
+{
+   for(;;) {
+      int n;
+      if (f->eof) return 0;
+      n = get8(f);
+      if (n == 0x4f) { // page header candidate
+         unsigned int retry_loc = stb_vorbis_get_file_offset(f);
+         int i;
+         // check if we're off the end of a file_section stream
+         if (retry_loc - 25 > f->stream_len)
+            return 0;
+         // check the rest of the header
+         for (i=1; i < 4; ++i)
+            if (get8(f) != ogg_page_header[i])
+               break;
+         if (f->eof) return 0;
+         if (i == 4) {
+            uint8 header[27];
+            uint32 i, crc, goal, len;
+            for (i=0; i < 4; ++i)
+               header[i] = ogg_page_header[i];
+            for (; i < 27; ++i)
+               header[i] = get8(f);
+            if (f->eof) return 0;
+            if (header[4] != 0) goto invalid;
+            goal = header[22] + (header[23] << 8) + (header[24]<<16) + ((uint32)header[25]<<24);
+            for (i=22; i < 26; ++i)
+               header[i] = 0;
+            crc = 0;
+            for (i=0; i < 27; ++i)
+               crc = crc32_update(crc, header[i]);
+            len = 0;
+            for (i=0; i < header[26]; ++i) {
+               int s = get8(f);
+               crc = crc32_update(crc, s);
+               len += s;
+            }
+            if (len && f->eof) return 0;
+            for (i=0; i < len; ++i)
+               crc = crc32_update(crc, get8(f));
+            // finished parsing probable page
+            if (crc == goal) {
+               // we could now check that it's either got the last
+               // page flag set, OR it's followed by the capture
+               // pattern, but I guess TECHNICALLY you could have
+               // a file with garbage between each ogg page and recover
+               // from it automatically? So even though that paranoia
+               // might decrease the chance of an invalid decode by
+               // another 2^32, not worth it since it would hose those
+               // invalid-but-useful files?
+               if (end)
+                  *end = stb_vorbis_get_file_offset(f);
+               if (last) {
+                  if (header[5] & 0x04)
+                     *last = 1;
+                  else
+                     *last = 0;
+               }
+               set_file_offset(f, retry_loc-1);
+               return 1;
+            }
+         }
+        invalid:
+         // not a valid page, so rewind and look for next one
+         set_file_offset(f, retry_loc);
+      }
+   }
+}
+
+
+#define SAMPLE_unknown  0xffffffff
+
+// seeking is implemented with a binary search, which narrows down the range to
+// 64K, before using a linear search (because finding the synchronization
+// pattern can be expensive, and the chance we'd find the end page again is
+// relatively high for small ranges)
+//
+// two initial interpolation-style probes are used at the start of the search
+// to try to bound either side of the binary search sensibly, while still
+// working in O(log n) time if they fail.
+
+static int get_seek_page_info(stb_vorbis *f, ProbedPage *z)
+{
+   uint8 header[27], lacing[255];
+   int i,len;
+
+   // record where the page starts
+   z->page_start = stb_vorbis_get_file_offset(f);
+
+   // parse the header
+   getn(f, header, 27);
+   if (header[0] != 'O' || header[1] != 'g' || header[2] != 'g' || header[3] != 'S')
+      return 0;
+   getn(f, lacing, header[26]);
+
+   // determine the length of the payload
+   len = 0;
+   for (i=0; i < header[26]; ++i)
+      len += lacing[i];
+
+   // this implies where the page ends
+   z->page_end = z->page_start + 27 + header[26] + len;
+
+   // read the last-decoded sample out of the data
+   z->last_decoded_sample = header[6] + (header[7] << 8) + (header[8] << 16) + (header[9] << 24);
+
+   // restore file state to where we were
+   set_file_offset(f, z->page_start);
+   return 1;
+}
+
+// rarely used function to seek back to the preceding page while finding the
+// start of a packet
+static int go_to_page_before(stb_vorbis *f, unsigned int limit_offset)
+{
+   unsigned int previous_safe, end;
+
+   // now we want to seek back 64K from the limit
+   if (limit_offset >= 65536 && limit_offset-65536 >= f->first_audio_page_offset)
+      previous_safe = limit_offset - 65536;
+   else
+      previous_safe = f->first_audio_page_offset;
+
+   set_file_offset(f, previous_safe);
+
+   while (vorbis_find_page(f, &end, NULL)) {
+      if (end >= limit_offset && stb_vorbis_get_file_offset(f) < limit_offset)
+         return 1;
+      set_file_offset(f, end);
+   }
+
+   return 0;
+}
+
+// implements the search logic for finding a page and starting decoding. if
+// the function succeeds, current_loc_valid will be true and current_loc will
+// be less than or equal to the provided sample number (the closer the
+// better).
+static int seek_to_sample_coarse(stb_vorbis *f, uint32 sample_number)
+{
+   ProbedPage left, right, mid;
+   int i, start_seg_with_known_loc, end_pos, page_start;
+   uint32 delta, stream_length, padding, last_sample_limit;
+   double offset = 0.0, bytes_per_sample = 0.0;
+   int probe = 0;
+
+   // find the last page and validate the target sample
+   stream_length = stb_vorbis_stream_length_in_samples(f);
+   if (stream_length == 0)            return error(f, VORBIS_seek_without_length);
+   if (sample_number > stream_length) return error(f, VORBIS_seek_invalid);
+
+   // this is the maximum difference between the window-center (which is the
+   // actual granule position value), and the right-start (which the spec
+   // indicates should be the granule position (give or take one)).
+   padding = ((f->blocksize_1 - f->blocksize_0) >> 2);
+   if (sample_number < padding)
+      last_sample_limit = 0;
+   else
+      last_sample_limit = sample_number - padding;
+
+   left = f->p_first;
+   while (left.last_decoded_sample == ~0U) {
+      // (untested) the first page does not have a 'last_decoded_sample'
+      set_file_offset(f, left.page_end);
+      if (!get_seek_page_info(f, &left)) goto error;
+   }
+
+   right = f->p_last;
+   assert(right.last_decoded_sample != ~0U);
+
+   // starting from the start is handled differently
+   if (last_sample_limit <= left.last_decoded_sample) {
+      if (stb_vorbis_seek_start(f)) {
+         if (f->current_loc > sample_number)
+            return error(f, VORBIS_seek_failed);
+         return 1;
+      }
+      return 0;
+   }
+
+   while (left.page_end != right.page_start) {
+      assert(left.page_end < right.page_start);
+      // search range in bytes
+      delta = right.page_start - left.page_end;
+      if (delta <= 65536) {
+         // there's only 64K left to search - handle it linearly
+         set_file_offset(f, left.page_end);
+      } else {
+         if (probe < 2) {
+            if (probe == 0) {
+               // first probe (interpolate)
+               double data_bytes = right.page_end - left.page_start;
+               bytes_per_sample = data_bytes / right.last_decoded_sample;
+               offset = left.page_start + bytes_per_sample * (last_sample_limit - left.last_decoded_sample);
+            } else {
+               // second probe (try to bound the other side)
+               double error = ((double) last_sample_limit - mid.last_decoded_sample) * bytes_per_sample;
+               if (error >= 0 && error <  8000) error =  8000;
+               if (error <  0 && error > -8000) error = -8000;
+               offset += error * 2;
+            }
+
+            // ensure the offset is valid
+            if (offset < left.page_end)
+               offset = left.page_end;
+            if (offset > right.page_start - 65536)
+               offset = right.page_start - 65536;
+
+            set_file_offset(f, (unsigned int) offset);
+         } else {
+            // binary search for large ranges (offset by 32K to ensure
+            // we don't hit the right page)
+            set_file_offset(f, left.page_end + (delta / 2) - 32768);
+         }
+
+         if (!vorbis_find_page(f, NULL, NULL)) goto error;
+      }
+
+      for (;;) {
+         if (!get_seek_page_info(f, &mid)) goto error;
+         if (mid.last_decoded_sample != ~0U) break;
+         // (untested) no frames end on this page
+         set_file_offset(f, mid.page_end);
+         assert(mid.page_start < right.page_start);
+      }
+
+      // if we've just found the last page again then we're in a tricky file,
+      // and we're close enough (if it wasn't an interpolation probe).
+      if (mid.page_start == right.page_start) {
+         if (probe >= 2 || delta <= 65536)
+            break;
+      } else {
+         if (last_sample_limit < mid.last_decoded_sample)
+            right = mid;
+         else
+            left = mid;
+      }
+
+      ++probe;
+   }
+
+   // seek back to start of the last packet
+   page_start = left.page_start;
+   set_file_offset(f, page_start);
+   if (!start_page(f)) return error(f, VORBIS_seek_failed);
+   end_pos = f->end_seg_with_known_loc;
+   assert(end_pos >= 0);
+
+   for (;;) {
+      for (i = end_pos; i > 0; --i)
+         if (f->segments[i-1] != 255)
+            break;
+
+      start_seg_with_known_loc = i;
+
+      if (start_seg_with_known_loc > 0 || !(f->page_flag & PAGEFLAG_continued_packet))
+         break;
+
+      // (untested) the final packet begins on an earlier page
+      if (!go_to_page_before(f, page_start))
+         goto error;
+
+      page_start = stb_vorbis_get_file_offset(f);
+      if (!start_page(f)) goto error;
+      end_pos = f->segment_count - 1;
+   }
+
+   // prepare to start decoding
+   f->current_loc_valid = FALSE;
+   f->last_seg = FALSE;
+   f->valid_bits = 0;
+   f->packet_bytes = 0;
+   f->bytes_in_seg = 0;
+   f->previous_length = 0;
+   f->next_seg = start_seg_with_known_loc;
+
+   for (i = 0; i < start_seg_with_known_loc; i++)
+      skip(f, f->segments[i]);
+
+   // start decoding (optimizable - this frame is generally discarded)
+   if (!vorbis_pump_first_frame(f))
+      return 0;
+   if (f->current_loc > sample_number)
+      return error(f, VORBIS_seek_failed);
+   return 1;
+
+error:
+   // try to restore the file to a valid state
+   stb_vorbis_seek_start(f);
+   return error(f, VORBIS_seek_failed);
+}
+
+// the same as vorbis_decode_initial, but without advancing
+static int peek_decode_initial(vorb *f, int *p_left_start, int *p_left_end, int *p_right_start, int *p_right_end, int *mode)
+{
+   int bits_read, bytes_read;
+
+   if (!vorbis_decode_initial(f, p_left_start, p_left_end, p_right_start, p_right_end, mode))
+      return 0;
+
+   // either 1 or 2 bytes were read, figure out which so we can rewind
+   bits_read = 1 + ilog(f->mode_count-1);
+   if (f->mode_config[*mode].blockflag)
+      bits_read += 2;
+   bytes_read = (bits_read + 7) / 8;
+
+   f->bytes_in_seg += bytes_read;
+   f->packet_bytes -= bytes_read;
+   skip(f, -bytes_read);
+   if (f->next_seg == -1)
+      f->next_seg = f->segment_count - 1;
+   else
+      f->next_seg--;
+   f->valid_bits = 0;
+
+   return 1;
+}
+
+int stb_vorbis_seek_frame(stb_vorbis *f, unsigned int sample_number)
+{
+   uint32 max_frame_samples;
+
+   if (IS_PUSH_MODE(f)) return error(f, VORBIS_invalid_api_mixing);
+
+   // fast page-level search
+   if (!seek_to_sample_coarse(f, sample_number))
+      return 0;
+
+   assert(f->current_loc_valid);
+   assert(f->current_loc <= sample_number);
+
+   // linear search for the relevant packet
+   max_frame_samples = (f->blocksize_1*3 - f->blocksize_0) >> 2;
+   while (f->current_loc < sample_number) {
+      int left_start, left_end, right_start, right_end, mode, frame_samples;
+      if (!peek_decode_initial(f, &left_start, &left_end, &right_start, &right_end, &mode))
+         return error(f, VORBIS_seek_failed);
+      // calculate the number of samples returned by the next frame
+      frame_samples = right_start - left_start;
+      if (f->current_loc + frame_samples > sample_number) {
+         return 1; // the next frame will contain the sample
+      } else if (f->current_loc + frame_samples + max_frame_samples > sample_number) {
+         // there's a chance the frame after this could contain the sample
+         vorbis_pump_first_frame(f);
+      } else {
+         // this frame is too early to be relevant
+         f->current_loc += frame_samples;
+         f->previous_length = 0;
+         maybe_start_packet(f);
+         flush_packet(f);
+      }
+   }
+   // the next frame should start with the sample
+   if (f->current_loc != sample_number) return error(f, VORBIS_seek_failed);
+   return 1;
+}
+
+int stb_vorbis_seek(stb_vorbis *f, unsigned int sample_number)
+{
+   if (!stb_vorbis_seek_frame(f, sample_number))
+      return 0;
+
+   if (sample_number != f->current_loc) {
+      int n;
+      uint32 frame_start = f->current_loc;
+      stb_vorbis_get_frame_float(f, &n, NULL);
+      assert(sample_number > frame_start);
+      assert(f->channel_buffer_start + (int) (sample_number-frame_start) <= f->channel_buffer_end);
+      f->channel_buffer_start += (sample_number - frame_start);
+   }
+
+   return 1;
+}
+
+int stb_vorbis_seek_start(stb_vorbis *f)
+{
+   if (IS_PUSH_MODE(f)) { return error(f, VORBIS_invalid_api_mixing); }
+   set_file_offset(f, f->first_audio_page_offset);
+   f->previous_length = 0;
+   f->first_decode = TRUE;
+   f->next_seg = -1;
+   return vorbis_pump_first_frame(f);
+}
+
+unsigned int stb_vorbis_stream_length_in_samples(stb_vorbis *f)
+{
+   unsigned int restore_offset, previous_safe;
+   unsigned int end, last_page_loc;
+
+   if (IS_PUSH_MODE(f)) return error(f, VORBIS_invalid_api_mixing);
+   if (!f->total_samples) {
+      unsigned int last;
+      uint32 lo,hi;
+      char header[6];
+
+      // first, store the current decode position so we can restore it
+      restore_offset = stb_vorbis_get_file_offset(f);
+
+      // now we want to seek back 64K from the end (the last page must
+      // be at most a little less than 64K, but let's allow a little slop)
+      if (f->stream_len >= 65536 && f->stream_len-65536 >= f->first_audio_page_offset)
+         previous_safe = f->stream_len - 65536;
+      else
+         previous_safe = f->first_audio_page_offset;
+
+      set_file_offset(f, previous_safe);
+      // previous_safe is now our candidate 'earliest known place that seeking
+      // to will lead to the final page'
+
+      if (!vorbis_find_page(f, &end, &last)) {
+         // if we can't find a page, we're hosed!
+         f->error = VORBIS_cant_find_last_page;
+         f->total_samples = 0xffffffff;
+         goto done;
+      }
+
+      // check if there are more pages
+      last_page_loc = stb_vorbis_get_file_offset(f);
+
+      // stop when the last_page flag is set, not when we reach eof;
+      // this allows us to stop short of a 'file_section' end without
+      // explicitly checking the length of the section
+      while (!last) {
+         set_file_offset(f, end);
+         if (!vorbis_find_page(f, &end, &last)) {
+            // the last page we found didn't have the 'last page' flag
+            // set. whoops!
+            break;
+         }
+         //previous_safe = last_page_loc+1; // NOTE: not used after this point, but note for debugging
+         last_page_loc = stb_vorbis_get_file_offset(f);
+      }
+
+      set_file_offset(f, last_page_loc);
+
+      // parse the header
+      getn(f, (unsigned char *)header, 6);
+      // extract the absolute granule position
+      lo = get32(f);
+      hi = get32(f);
+      if (lo == 0xffffffff && hi == 0xffffffff) {
+         f->error = VORBIS_cant_find_last_page;
+         f->total_samples = SAMPLE_unknown;
+         goto done;
+      }
+      if (hi)
+         lo = 0xfffffffe; // saturate
+      f->total_samples = lo;
+
+      f->p_last.page_start = last_page_loc;
+      f->p_last.page_end   = end;
+      f->p_last.last_decoded_sample = lo;
+
+     done:
+      set_file_offset(f, restore_offset);
+   }
+   return f->total_samples == SAMPLE_unknown ? 0 : f->total_samples;
+}
+
+float stb_vorbis_stream_length_in_seconds(stb_vorbis *f)
+{
+   return stb_vorbis_stream_length_in_samples(f) / (float) f->sample_rate;
+}
+
+
+
+int stb_vorbis_get_frame_float(stb_vorbis *f, int *channels, float ***output)
+{
+   int len, right,left,i;
+   if (IS_PUSH_MODE(f)) return error(f, VORBIS_invalid_api_mixing);
+
+   if (!vorbis_decode_packet(f, &len, &left, &right)) {
+      f->channel_buffer_start = f->channel_buffer_end = 0;
+      return 0;
+   }
+
+   len = vorbis_finish_frame(f, len, left, right);
+   for (i=0; i < f->channels; ++i)
+      f->outputs[i] = f->channel_buffers[i] + left;
+
+   f->channel_buffer_start = left;
+   f->channel_buffer_end   = left+len;
+
+   if (channels) *channels = f->channels;
+   if (output)   *output = f->outputs;
+   return len;
+}
+
+#ifndef STB_VORBIS_NO_STDIO
+
+stb_vorbis * stb_vorbis_open_file_section(FILE *file, int close_on_free, int *error, const stb_vorbis_alloc *alloc, unsigned int length)
+{
+   stb_vorbis *f, p;
+   vorbis_init(&p, alloc);
+   p.f = file;
+   p.f_start = (uint32) ftell(file);
+   p.stream_len   = length;
+   p.close_on_free = close_on_free;
+   if (start_decoder(&p)) {
+      f = vorbis_alloc(&p);
+      if (f) {
+         *f = p;
+         vorbis_pump_first_frame(f);
+         return f;
+      }
+   }
+   if (error) *error = p.error;
+   vorbis_deinit(&p);
+   return NULL;
+}
+
+stb_vorbis * stb_vorbis_open_file(FILE *file, int close_on_free, int *error, const stb_vorbis_alloc *alloc)
+{
+   unsigned int len, start;
+   start = (unsigned int) ftell(file);
+   fseek(file, 0, SEEK_END);
+   len = (unsigned int) (ftell(file) - start);
+   fseek(file, start, SEEK_SET);
+   return stb_vorbis_open_file_section(file, close_on_free, error, alloc, len);
+}
+
+stb_vorbis * stb_vorbis_open_filename(const char *filename, int *error, const stb_vorbis_alloc *alloc)
+{
+   FILE *f;
+#if defined(_WIN32) && defined(__STDC_WANT_SECURE_LIB__)
+   if (0 != fopen_s(&f, filename, "rb"))
+      f = NULL;
+#else
+   f = fopen(filename, "rb");
+#endif
+   if (f)
+      return stb_vorbis_open_file(f, TRUE, error, alloc);
+   if (error) *error = VORBIS_file_open_failure;
+   return NULL;
+}
+#endif // STB_VORBIS_NO_STDIO
+
+stb_vorbis * stb_vorbis_open_memory(const unsigned char *data, int len, int *error, const stb_vorbis_alloc *alloc)
+{
+   stb_vorbis *f, p;
+   if (!data) {
+      if (error) *error = VORBIS_unexpected_eof;
+      return NULL;
+   }
+   vorbis_init(&p, alloc);
+   p.stream = (uint8 *) data;
+   p.stream_end = (uint8 *) data + len;
+   p.stream_start = (uint8 *) p.stream;
+   p.stream_len = len;
+   p.push_mode = FALSE;
+   if (start_decoder(&p)) {
+      f = vorbis_alloc(&p);
+      if (f) {
+         *f = p;
+         vorbis_pump_first_frame(f);
+         if (error) *error = VORBIS__no_error;
+         return f;
+      }
+   }
+   if (error) *error = p.error;
+   vorbis_deinit(&p);
+   return NULL;
+}
+
+#ifndef STB_VORBIS_NO_INTEGER_CONVERSION
+#define PLAYBACK_MONO     1
+#define PLAYBACK_LEFT     2
+#define PLAYBACK_RIGHT    4
+
+#define L  (PLAYBACK_LEFT  | PLAYBACK_MONO)
+#define C  (PLAYBACK_LEFT  | PLAYBACK_RIGHT | PLAYBACK_MONO)
+#define R  (PLAYBACK_RIGHT | PLAYBACK_MONO)
+
+static int8 channel_position[7][6] =
+{
+   { 0 },
+   { C },
+   { L, R },
+   { L, C, R },
+   { L, R, L, R },
+   { L, C, R, L, R },
+   { L, C, R, L, R, C },
+};
+
+
+#ifndef STB_VORBIS_NO_FAST_SCALED_FLOAT
+   typedef union {
+      float f;
+      int i;
+   } float_conv;
+   typedef char stb_vorbis_float_size_test[sizeof(float)==4 && sizeof(int) == 4];
+   #define FASTDEF(x) float_conv x
+   // add (1<<23) to convert to int, then divide by 2^SHIFT, then add 0.5/2^SHIFT to round
+   #define MAGIC(SHIFT) (1.5f * (1 << (23-SHIFT)) + 0.5f/(1 << SHIFT))
+   #define ADDEND(SHIFT) (((150-SHIFT) << 23) + (1 << 22))
+   #define FAST_SCALED_FLOAT_TO_INT(temp,x,s) (temp.f = (x) + MAGIC(s), temp.i - ADDEND(s))
+   #define check_endianness()
+#else
+   #define FAST_SCALED_FLOAT_TO_INT(temp,x,s) ((int) ((x) * (1 << (s))))
+   #define check_endianness()
+   #define FASTDEF(x)
+#endif
+
+static void copy_samples(short *dest, float *src, int len)
+{
+   int i;
+   check_endianness();
+   for (i=0; i < len; ++i) {
+      FASTDEF(temp);
+      int v = FAST_SCALED_FLOAT_TO_INT(temp, src[i],15);
+      if ((unsigned int) (v + 32768) > 65535)
+         v = v < 0 ? -32768 : 32767;
+      dest[i] = v;
+   }
+}
+
+static void compute_samples(int mask, short *output, int num_c, float **data, int d_offset, int len)
+{
+   #define STB_BUFFER_SIZE  32
+   float buffer[STB_BUFFER_SIZE];
+   int i,j,o,n = STB_BUFFER_SIZE;
+   check_endianness();
+   for (o = 0; o < len; o += STB_BUFFER_SIZE) {
+      memset(buffer, 0, sizeof(buffer));
+      if (o + n > len) n = len - o;
+      for (j=0; j < num_c; ++j) {
+         if (channel_position[num_c][j] & mask) {
+            for (i=0; i < n; ++i)
+               buffer[i] += data[j][d_offset+o+i];
+         }
+      }
+      for (i=0; i < n; ++i) {
+         FASTDEF(temp);
+         int v = FAST_SCALED_FLOAT_TO_INT(temp,buffer[i],15);
+         if ((unsigned int) (v + 32768) > 65535)
+            v = v < 0 ? -32768 : 32767;
+         output[o+i] = v;
+      }
+   }
+   #undef STB_BUFFER_SIZE
+}
+
+static void compute_stereo_samples(short *output, int num_c, float **data, int d_offset, int len)
+{
+   #define STB_BUFFER_SIZE  32
+   float buffer[STB_BUFFER_SIZE];
+   int i,j,o,n = STB_BUFFER_SIZE >> 1;
+   // o is the offset in the source data
+   check_endianness();
+   for (o = 0; o < len; o += STB_BUFFER_SIZE >> 1) {
+      // o2 is the offset in the output data
+      int o2 = o << 1;
+      memset(buffer, 0, sizeof(buffer));
+      if (o + n > len) n = len - o;
+      for (j=0; j < num_c; ++j) {
+         int m = channel_position[num_c][j] & (PLAYBACK_LEFT | PLAYBACK_RIGHT);
+         if (m == (PLAYBACK_LEFT | PLAYBACK_RIGHT)) {
+            for (i=0; i < n; ++i) {
+               buffer[i*2+0] += data[j][d_offset+o+i];
+               buffer[i*2+1] += data[j][d_offset+o+i];
+            }
+         } else if (m == PLAYBACK_LEFT) {
+            for (i=0; i < n; ++i) {
+               buffer[i*2+0] += data[j][d_offset+o+i];
+            }
+         } else if (m == PLAYBACK_RIGHT) {
+            for (i=0; i < n; ++i) {
+               buffer[i*2+1] += data[j][d_offset+o+i];
+            }
+         }
+      }
+      for (i=0; i < (n<<1); ++i) {
+         FASTDEF(temp);
+         int v = FAST_SCALED_FLOAT_TO_INT(temp,buffer[i],15);
+         if ((unsigned int) (v + 32768) > 65535)
+            v = v < 0 ? -32768 : 32767;
+         output[o2+i] = v;
+      }
+   }
+   #undef STB_BUFFER_SIZE
+}
+
+static void convert_samples_short(int buf_c, short **buffer, int b_offset, int data_c, float **data, int d_offset, int samples)
+{
+   int i;
+   if (buf_c != data_c && buf_c <= 2 && data_c <= 6) {
+      static int channel_selector[3][2] = { {0}, {PLAYBACK_MONO}, {PLAYBACK_LEFT, PLAYBACK_RIGHT} };
+      for (i=0; i < buf_c; ++i)
+         compute_samples(channel_selector[buf_c][i], buffer[i]+b_offset, data_c, data, d_offset, samples);
+   } else {
+      int limit = buf_c < data_c ? buf_c : data_c;
+      for (i=0; i < limit; ++i)
+         copy_samples(buffer[i]+b_offset, data[i]+d_offset, samples);
+      for (   ; i < buf_c; ++i)
+         memset(buffer[i]+b_offset, 0, sizeof(short) * samples);
+   }
+}
+
+int stb_vorbis_get_frame_short(stb_vorbis *f, int num_c, short **buffer, int num_samples)
+{
+   float **output = NULL;
+   int len = stb_vorbis_get_frame_float(f, NULL, &output);
+   if (len > num_samples) len = num_samples;
+   if (len)
+      convert_samples_short(num_c, buffer, 0, f->channels, output, 0, len);
+   return len;
+}
+
+static void convert_channels_short_interleaved(int buf_c, short *buffer, int data_c, float **data, int d_offset, int len)
+{
+   int i;
+   check_endianness();
+   if (buf_c != data_c && buf_c <= 2 && data_c <= 6) {
+      assert(buf_c == 2);
+      for (i=0; i < buf_c; ++i)
+         compute_stereo_samples(buffer, data_c, data, d_offset, len);
+   } else {
+      int limit = buf_c < data_c ? buf_c : data_c;
+      int j;
+      for (j=0; j < len; ++j) {
+         for (i=0; i < limit; ++i) {
+            FASTDEF(temp);
+            float f = data[i][d_offset+j];
+            int v = FAST_SCALED_FLOAT_TO_INT(temp, f,15);//data[i][d_offset+j],15);
+            if ((unsigned int) (v + 32768) > 65535)
+               v = v < 0 ? -32768 : 32767;
+            *buffer++ = v;
+         }
+         for (   ; i < buf_c; ++i)
+            *buffer++ = 0;
+      }
+   }
+}
+
+int stb_vorbis_get_frame_short_interleaved(stb_vorbis *f, int num_c, short *buffer, int num_shorts)
+{
+   float **output;
+   int len;
+   if (num_c == 1) return stb_vorbis_get_frame_short(f,num_c,&buffer, num_shorts);
+   len = stb_vorbis_get_frame_float(f, NULL, &output);
+   if (len) {
+      if (len*num_c > num_shorts) len = num_shorts / num_c;
+      convert_channels_short_interleaved(num_c, buffer, f->channels, output, 0, len);
+   }
+   return len;
+}
+
+int stb_vorbis_get_samples_short_interleaved(stb_vorbis *f, int channels, short *buffer, int num_shorts)
+{
+   float **outputs;
+   int len = num_shorts / channels;
+   int n=0;
+   while (n < len) {
+      int k = f->channel_buffer_end - f->channel_buffer_start;
+      if (n+k >= len) k = len - n;
+      if (k)
+         convert_channels_short_interleaved(channels, buffer, f->channels, f->channel_buffers, f->channel_buffer_start, k);
+      buffer += k*channels;
+      n += k;
+      f->channel_buffer_start += k;
+      if (n == len) break;
+      if (!stb_vorbis_get_frame_float(f, NULL, &outputs)) break;
+   }
+   return n;
+}
+
+int stb_vorbis_get_samples_short(stb_vorbis *f, int channels, short **buffer, int len)
+{
+   float **outputs;
+   int n=0;
+   while (n < len) {
+      int k = f->channel_buffer_end - f->channel_buffer_start;
+      if (n+k >= len) k = len - n;
+      if (k)
+         convert_samples_short(channels, buffer, n, f->channels, f->channel_buffers, f->channel_buffer_start, k);
+      n += k;
+      f->channel_buffer_start += k;
+      if (n == len) break;
+      if (!stb_vorbis_get_frame_float(f, NULL, &outputs)) break;
+   }
+   return n;
+}
+
+#ifndef STB_VORBIS_NO_STDIO
+int stb_vorbis_decode_filename(const char *filename, int *channels, int *sample_rate, short **output)
+{
+   int data_len, offset, total, limit, error;
+   short *data;
+   stb_vorbis *v = stb_vorbis_open_filename(filename, &error, NULL);
+   if (v == NULL) return -1;
+   limit = v->channels * 4096;
+   *channels = v->channels;
+   if (sample_rate)
+      *sample_rate = v->sample_rate;
+   offset = data_len = 0;
+   total = limit;
+   data = (short *) malloc(total * sizeof(*data));
+   if (data == NULL) {
+      stb_vorbis_close(v);
+      return -2;
+   }
+   for (;;) {
+      int n = stb_vorbis_get_frame_short_interleaved(v, v->channels, data+offset, total-offset);
+      if (n == 0) break;
+      data_len += n;
+      offset += n * v->channels;
+      if (offset + limit > total) {
+         short *data2;
+         total *= 2;
+         data2 = (short *) realloc(data, total * sizeof(*data));
+         if (data2 == NULL) {
+            free(data);
+            stb_vorbis_close(v);
+            return -2;
+         }
+         data = data2;
+      }
+   }
+   *output = data;
+   stb_vorbis_close(v);
+   return data_len;
+}
+#endif // NO_STDIO
+
+int stb_vorbis_decode_memory(const uint8 *mem, int len, int *channels, int *sample_rate, short **output)
+{
+   int data_len, offset, total, limit, error;
+   short *data;
+   stb_vorbis *v = stb_vorbis_open_memory(mem, len, &error, NULL);
+   if (v == NULL) return -1;
+   limit = v->channels * 4096;
+   *channels = v->channels;
+   if (sample_rate)
+      *sample_rate = v->sample_rate;
+   offset = data_len = 0;
+   total = limit;
+   data = (short *) malloc(total * sizeof(*data));
+   if (data == NULL) {
+      stb_vorbis_close(v);
+      return -2;
+   }
+   for (;;) {
+      int n = stb_vorbis_get_frame_short_interleaved(v, v->channels, data+offset, total-offset);
+      if (n == 0) break;
+      data_len += n;
+      offset += n * v->channels;
+      if (offset + limit > total) {
+         short *data2;
+         total *= 2;
+         data2 = (short *) realloc(data, total * sizeof(*data));
+         if (data2 == NULL) {
+            free(data);
+            stb_vorbis_close(v);
+            return -2;
+         }
+         data = data2;
+      }
+   }
+   *output = data;
+   stb_vorbis_close(v);
+   return data_len;
+}
+#endif // STB_VORBIS_NO_INTEGER_CONVERSION
+
+int stb_vorbis_get_samples_float_interleaved(stb_vorbis *f, int channels, float *buffer, int num_floats)
+{
+   float **outputs;
+   int len = num_floats / channels;
+   int n=0;
+   int z = f->channels;
+   if (z > channels) z = channels;
+   while (n < len) {
+      int i,j;
+      int k = f->channel_buffer_end - f->channel_buffer_start;
+      if (n+k >= len) k = len - n;
+      for (j=0; j < k; ++j) {
+         for (i=0; i < z; ++i)
+            *buffer++ = f->channel_buffers[i][f->channel_buffer_start+j];
+         for (   ; i < channels; ++i)
+            *buffer++ = 0;
+      }
+      n += k;
+      f->channel_buffer_start += k;
+      if (n == len)
+         break;
+      if (!stb_vorbis_get_frame_float(f, NULL, &outputs))
+         break;
+   }
+   return n;
+}
+
+int stb_vorbis_get_samples_float(stb_vorbis *f, int channels, float **buffer, int num_samples)
+{
+   float **outputs;
+   int n=0;
+   int z = f->channels;
+   if (z > channels) z = channels;
+   while (n < num_samples) {
+      int i;
+      int k = f->channel_buffer_end - f->channel_buffer_start;
+      if (n+k >= num_samples) k = num_samples - n;
+      if (k) {
+         for (i=0; i < z; ++i)
+            memcpy(buffer[i]+n, f->channel_buffers[i]+f->channel_buffer_start, sizeof(float)*k);
+         for (   ; i < channels; ++i)
+            memset(buffer[i]+n, 0, sizeof(float) * k);
+      }
+      n += k;
+      f->channel_buffer_start += k;
+      if (n == num_samples)
+         break;
+      if (!stb_vorbis_get_frame_float(f, NULL, &outputs))
+         break;
+   }
+   return n;
+}
+#endif // STB_VORBIS_NO_PULLDATA_API
+
+/* Version history
+    1.17    - 2019-07-08 - fix CVE-2019-13217, -13218, -13219, -13220, -13221, -13222, -13223
+                           found with Mayhem by ForAllSecure
+    1.16    - 2019-03-04 - fix warnings
+    1.15    - 2019-02-07 - explicit failure if Ogg Skeleton data is found
+    1.14    - 2018-02-11 - delete bogus dealloca usage
+    1.13    - 2018-01-29 - fix truncation of last frame (hopefully)
+    1.12    - 2017-11-21 - limit residue begin/end to blocksize/2 to avoid large temp allocs in bad/corrupt files
+    1.11    - 2017-07-23 - fix MinGW compilation
+    1.10    - 2017-03-03 - more robust seeking; fix negative ilog(); clear error in open_memory
+    1.09    - 2016-04-04 - back out 'avoid discarding last frame' fix from previous version
+    1.08    - 2016-04-02 - fixed multiple warnings; fix setup memory leaks;
+                           avoid discarding last frame of audio data
+    1.07    - 2015-01-16 - fixed some warnings, fix mingw, const-correct API
+                           some more crash fixes when out of memory or with corrupt files
+    1.06    - 2015-08-31 - full, correct support for seeking API (Dougall Johnson)
+                           some crash fixes when out of memory or with corrupt files
+    1.05    - 2015-04-19 - don't define __forceinline if it's redundant
+    1.04    - 2014-08-27 - fix missing const-correct case in API
+    1.03    - 2014-08-07 - Warning fixes
+    1.02    - 2014-07-09 - Declare qsort compare function _cdecl on windows
+    1.01    - 2014-06-18 - fix stb_vorbis_get_samples_float
+    1.0     - 2014-05-26 - fix memory leaks; fix warnings; fix bugs in multichannel
+                           (API change) report sample rate for decode-full-file funcs
+    0.99996 - bracket #include <malloc.h> for macintosh compilation by Laurent Gomila
+    0.99995 - use union instead of pointer-cast for fast-float-to-int to avoid alias-optimization problem
+    0.99994 - change fast-float-to-int to work in single-precision FPU mode, remove endian-dependence
+    0.99993 - remove assert that fired on legal files with empty tables
+    0.99992 - rewind-to-start
+    0.99991 - bugfix to stb_vorbis_get_samples_short by Bernhard Wodo
+    0.9999 - (should have been 0.99990) fix no-CRT support, compiling as C++
+    0.9998 - add a full-decode function with a memory source
+    0.9997 - fix a bug in the read-from-FILE case in 0.9996 addition
+    0.9996 - query length of vorbis stream in samples/seconds
+    0.9995 - bugfix to another optimization that only happened in certain files
+    0.9994 - bugfix to one of the optimizations that caused significant (but inaudible?) errors
+    0.9993 - performance improvements; runs in 99% to 104% of time of reference implementation
+    0.9992 - performance improvement of IMDCT; now performs close to reference implementation
+    0.9991 - performance improvement of IMDCT
+    0.999 - (should have been 0.9990) performance improvement of IMDCT
+    0.998 - no-CRT support from Casey Muratori
+    0.997 - bugfixes for bugs found by Terje Mathisen
+    0.996 - bugfix: fast-huffman decode initialized incorrectly for sparse codebooks; fixing gives 10% speedup - found by Terje Mathisen
+    0.995 - bugfix: fix to 'effective' overrun detection - found by Terje Mathisen
+    0.994 - bugfix: garbage decode on final VQ symbol of a non-multiple - found by Terje Mathisen
+    0.993 - bugfix: pushdata API required 1 extra byte for empty page (failed to consume final page if empty) - found by Terje Mathisen
+    0.992 - fixes for MinGW warning
+    0.991 - turn fast-float-conversion on by default
+    0.990 - fix push-mode seek recovery if you seek into the headers
+    0.98b - fix to bad release of 0.98
+    0.98 - fix push-mode seek recovery; robustify float-to-int and support non-fast mode
+    0.97 - builds under c++ (typecasting, don't use 'class' keyword)
+    0.96 - somehow MY 0.95 was right, but the web one was wrong, so here's my 0.95 rereleased as 0.96, fixes a typo in the clamping code
+    0.95 - clamping code for 16-bit functions
+    0.94 - not publically released
+    0.93 - fixed all-zero-floor case (was decoding garbage)
+    0.92 - fixed a memory leak
+    0.91 - conditional compiles to omit parts of the API and the infrastructure to support them: STB_VORBIS_NO_PULLDATA_API, STB_VORBIS_NO_PUSHDATA_API, STB_VORBIS_NO_STDIO, STB_VORBIS_NO_INTEGER_CONVERSION
+    0.90 - first public release
+*/
+
+#endif // STB_VORBIS_HEADER_ONLY
+
+
+/*
+------------------------------------------------------------------------------
+This software is available under 2 licenses -- choose whichever you prefer.
+------------------------------------------------------------------------------
+ALTERNATIVE A - MIT License
+Copyright (c) 2017 Sean Barrett
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+------------------------------------------------------------------------------
+ALTERNATIVE B - Public Domain (www.unlicense.org)
+This is free and unencumbered software released into the public domain.
+Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
+software, either in source code form or as a compiled binary, for any purpose,
+commercial or non-commercial, and by any means.
+In jurisdictions that recognize copyright laws, the author or authors of this
+software dedicate any and all copyright interest in the software to the public
+domain. We make this dedication for the benefit of the public at large and to
+the detriment of our heirs and successors. We intend this dedication to be an
+overt act of relinquishment in perpetuity of all present and future rights to
+this software under copyright law.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
+ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+------------------------------------------------------------------------------
+*/

+ 10 - 0
Sources/OGG/stb_vorbis_wrapper.h

@@ -0,0 +1,10 @@
+#ifndef stb_vorbis_wrapper_h
+#define stb_vorbis_wrapper_h
+
+// This header exposes the stb_vorbis API to Swift via the bridging header.
+// stb_vorbis is public domain (https://github.com/nothings/stb)
+
+#define STB_VORBIS_HEADER_ONLY
+#include "stb_vorbis.c"
+
+#endif /* stb_vorbis_wrapper_h */

+ 51 - 0
Sources/Resources/Info.plist

@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>CFBundleDevelopmentRegion</key>
+	<string>$(DEVELOPMENT_LANGUAGE)</string>
+	<key>CFBundleExecutable</key>
+	<string>$(EXECUTABLE_NAME)</string>
+	<key>CFBundleIdentifier</key>
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleName</key>
+	<string>$(PRODUCT_NAME)</string>
+	<key>CFBundlePackageType</key>
+	<string>APPL</string>
+	<key>CFBundleShortVersionString</key>
+	<string>1.0</string>
+	<key>CFBundleVersion</key>
+	<string>1</string>
+	<key>LSApplicationCategoryType</key>
+	<string>public.app-category.music</string>
+	<key>UTExportedTypeDeclarations</key>
+	<array>
+		<dict>
+			<key>UTTypeIdentifier</key>
+			<string>com.mixboard.chad-track</string>
+			<key>UTTypeDescription</key>
+			<string>Chad Music Track</string>
+			<key>UTTypeConformsTo</key>
+			<array>
+				<string>public.data</string>
+			</array>
+			<key>UTTypeTagSpecification</key>
+			<dict/>
+		</dict>
+		<dict>
+			<key>UTTypeIdentifier</key>
+			<string>com.mixboard.chad-album</string>
+			<key>UTTypeDescription</key>
+			<string>Chad Music Album</string>
+			<key>UTTypeConformsTo</key>
+			<array>
+				<string>public.data</string>
+			</array>
+			<key>UTTypeTagSpecification</key>
+			<dict/>
+		</dict>
+	</array>
+</dict>
+</plist>

+ 133 - 0
Sources/Services/ArtworkService.swift

@@ -0,0 +1,133 @@
+import AppKit
+import AVFoundation
+import Foundation
+
+/// Loads album artwork from folder images (foobar2000 style) or embedded metadata.
+/// Searches for common cover art filenames in the same directory as the audio file.
+actor ArtworkService {
+
+    static let shared = ArtworkService()
+
+    /// In-memory cache: folder path → NSImage
+    private var cache: [String: NSImage] = [:]
+
+    /// Common cover art filenames to search for (priority order).
+    private static let coverFileNames: [String] = [
+        "cover", "folder", "front", "album", "albumart",
+        "albumartsmall", "thumb", "artwork", "art", "scan",
+        "Cover", "Folder", "Front", "Album"
+    ]
+
+    /// Supported image extensions.
+    private static let imageExtensions: Set<String> = [
+        "jpg", "jpeg", "png", "bmp", "gif", "tiff", "webp"
+    ]
+
+    // MARK: - Public API
+
+    /// Get artwork for a track. Checks folder images first, then embedded metadata.
+    func artwork(for track: Track) async -> NSImage? {
+        let folderPath = track.fileURL.deletingLastPathComponent().path
+
+        // Check cache
+        if let cached = cache[folderPath] {
+            return cached
+        }
+
+        // 1. Try folder images (foobar2000 style)
+        if let folderArt = findFolderArtwork(in: track.fileURL.deletingLastPathComponent()) {
+            cache[folderPath] = folderArt
+            return folderArt
+        }
+
+        // 2. Try embedded metadata
+        if let embedded = await extractEmbeddedArtwork(from: track.fileURL) {
+            cache[folderPath] = embedded
+            return embedded
+        }
+
+        return nil
+    }
+
+    /// Get artwork for a URL directly (without Track model).
+    func artwork(forFileAt url: URL) async -> NSImage? {
+        let folderPath = url.deletingLastPathComponent().path
+
+        if let cached = cache[folderPath] {
+            return cached
+        }
+
+        if let folderArt = findFolderArtwork(in: url.deletingLastPathComponent()) {
+            cache[folderPath] = folderArt
+            return folderArt
+        }
+
+        if let embedded = await extractEmbeddedArtwork(from: url) {
+            cache[folderPath] = embedded
+            return embedded
+        }
+
+        return nil
+    }
+
+    /// Clear the artwork cache.
+    func clearCache() {
+        cache.removeAll()
+    }
+
+    /// Remove a specific folder from cache.
+    func invalidateCache(for folderPath: String) {
+        cache.removeValue(forKey: folderPath)
+    }
+
+    // MARK: - Folder Artwork (foobar2000 style)
+
+    private func findFolderArtwork(in folderURL: URL) -> NSImage? {
+        let fm = FileManager.default
+
+        // First pass: try known filenames
+        for name in Self.coverFileNames {
+            for ext in Self.imageExtensions {
+                let candidate = folderURL.appendingPathComponent("\(name).\(ext)")
+                if fm.fileExists(atPath: candidate.path),
+                   let image = NSImage(contentsOf: candidate) {
+                    return image
+                }
+            }
+        }
+
+        // Second pass: any image file in the folder
+        if let contents = try? fm.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
+            let images = contents.filter { Self.imageExtensions.contains($0.pathExtension.lowercased()) }
+            // Prefer smaller files (likely covers vs. high-res scans)
+            let sorted = images.sorted { url1, url2 in
+                let s1 = (try? fm.attributesOfItem(atPath: url1.path)[.size] as? Int64) ?? 0
+                let s2 = (try? fm.attributesOfItem(atPath: url2.path)[.size] as? Int64) ?? 0
+                return s1 < s2
+            }
+            if let first = sorted.first, let image = NSImage(contentsOf: first) {
+                return image
+            }
+        }
+
+        return nil
+    }
+
+    // MARK: - Embedded Artwork
+
+    private func extractEmbeddedArtwork(from url: URL) async -> NSImage? {
+        let asset = AVURLAsset(url: url)
+
+        guard let metadata = try? await asset.load(.metadata) else { return nil }
+
+        for item in metadata {
+            guard let key = item.commonKey, key == .commonKeyArtwork else { continue }
+            if let data = try? await item.load(.dataValue),
+               let image = NSImage(data: data) {
+                return image
+            }
+        }
+
+        return nil
+    }
+}

+ 327 - 0
Sources/Services/AudioEngine.swift

@@ -0,0 +1,327 @@
+import AVFoundation
+import Foundation
+import Observation
+
+/// Core audio playback engine using AVAudioEngine for high-quality output
+/// with real-time sample access for visualization.
+@MainActor
+@Observable
+final class AudioEngine {
+    // MARK: - State
+
+    var isPlaying = false
+    var currentTime: TimeInterval = 0
+    var duration: TimeInterval = 0
+    var volume: Float = 0.8 {
+        didSet { playerNode.volume = volume }
+    }
+    var currentTrack: Track?
+
+    // MARK: - Realtime Levels
+
+    var leftLevel: Float = 0
+    var rightLevel: Float = 0
+
+    // MARK: - Private
+
+    @ObservationIgnored private let engine = AVAudioEngine()
+    @ObservationIgnored private let playerNode = AVAudioPlayerNode()
+    @ObservationIgnored private let eqNode = AVAudioUnitEQ(numberOfBands: 3)
+
+    @ObservationIgnored private var audioFile: AVAudioFile?
+    @ObservationIgnored private var oggBuffer: AVAudioPCMBuffer?  // For OGG playback
+    @ObservationIgnored private var isOGG = false
+    @ObservationIgnored private var seekFrame: AVAudioFramePosition = 0
+    @ObservationIgnored private var audioLengthFrames: AVAudioFramePosition = 0
+    @ObservationIgnored private var audioSampleRate: Double = 44100
+    @ObservationIgnored private var isSeeking = false
+    /// Incremented each time we start/stop/load. Completion handlers compare against this
+    /// to know if they should trigger auto-advance or if playback was interrupted.
+    @ObservationIgnored private var playbackGeneration: Int = 0
+
+    /// Called on main actor when playback finishes naturally.
+    @ObservationIgnored var onPlaybackFinished: (() -> Void)?
+
+    // MARK: - Init
+
+    init() {
+        setupAudioChain()
+        observeAudioRouteChanges()
+    }
+
+    deinit {
+        NotificationCenter.default.removeObserver(self)
+        engine.stop()
+    }
+
+    // MARK: - Audio Chain Setup
+
+    private func setupAudioChain() {
+        engine.attach(playerNode)
+        engine.attach(eqNode)
+
+        // Set up 3-band EQ: Low, Mid, High
+        configureBandEQ()
+
+        // Chain: PlayerNode → EQ → MainMixer → Output
+        // Use nil format — AVAudioEngine will negotiate when we schedule a segment/buffer
+        let mainMixer = engine.mainMixerNode
+        engine.connect(playerNode, to: eqNode, format: nil)
+        engine.connect(eqNode, to: mainMixer, format: nil)
+
+        engine.prepare()
+    }
+
+    private func configureBandEQ() {
+        guard eqNode.bands.count >= 3 else { return }
+        let low = eqNode.bands[0]
+        low.filterType = .lowShelf
+        low.frequency = 100
+        low.bandwidth = 1.0
+        low.gain = 0
+        low.bypass = false
+
+        let mid = eqNode.bands[1]
+        mid.filterType = .parametric
+        mid.frequency = 1000
+        mid.bandwidth = 1.0
+        mid.gain = 0
+        mid.bypass = false
+
+        let high = eqNode.bands[2]
+        high.filterType = .highShelf
+        high.frequency = 10000
+        high.bandwidth = 1.0
+        high.gain = 0
+        high.bypass = false
+    }
+
+    // MARK: - Playback Controls
+
+    func loadTrack(_ track: Track) throws {
+        playbackGeneration += 1  // Invalidate any pending completion handlers
+        playerNode.stop()
+        isPlaying = false
+
+        let url = track.fileURL
+
+        // Reset OGG state
+        oggBuffer = nil
+        isOGG = false
+        audioFile = nil
+
+        if OGGDecoder.isOGGFile(url) {
+            // Decode OGG to PCM buffer
+            let (buffer, format) = try OGGDecoder.decode(url: url)
+            oggBuffer = buffer
+            isOGG = true
+            audioLengthFrames = AVAudioFramePosition(buffer.frameLength)
+            audioSampleRate = format.sampleRate
+        } else {
+            audioFile = try AVAudioFile(forReading: url)
+            guard let file = audioFile else { return }
+            audioLengthFrames = file.length
+            audioSampleRate = file.processingFormat.sampleRate
+        }
+
+        duration = Double(audioLengthFrames) / audioSampleRate
+        seekFrame = 0
+        currentTime = 0
+        currentTrack = track
+    }
+
+    func play() {
+        guard audioFile != nil || oggBuffer != nil else { return }
+
+        if !engine.isRunning {
+            do {
+                try engine.start()
+            } catch {
+                print("AudioEngine: Failed to start engine: \(error)")
+                return
+            }
+        }
+
+        let remainingFrames = AVAudioFrameCount(audioLengthFrames - seekFrame)
+        guard remainingFrames > 0 else { return }
+
+        // Capture the current generation so the completion handler can check it
+        let gen = playbackGeneration
+        let expectedDuration = Double(remainingFrames) / audioSampleRate
+
+        if isOGG, let fullBuffer = oggBuffer {
+            // OGG: schedule a slice of the decoded buffer from seekFrame
+            guard let format = fullBuffer.format as AVAudioFormat?,
+                  let sliceBuffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: remainingFrames) else { return }
+
+            // Copy the relevant portion of the buffer
+            let channels = Int(format.channelCount)
+            for ch in 0..<channels {
+                let src = fullBuffer.floatChannelData![ch].advanced(by: Int(seekFrame))
+                let dst = sliceBuffer.floatChannelData![ch]
+                dst.update(from: src, count: Int(remainingFrames))
+            }
+            sliceBuffer.frameLength = remainingFrames
+
+            playerNode.scheduleBuffer(sliceBuffer, completionCallbackType: .dataPlayedBack) { [weak self] _ in
+                Task { @MainActor in
+                    guard let self else { return }
+                    guard self.playbackGeneration == gen else { return }
+                    guard expectedDuration < 1.0 || self.currentTime > 0.5 else { return }
+                    self.playbackDidFinish()
+                }
+            }
+        } else if let file = audioFile {
+            // Standard file-based playback
+            playerNode.scheduleSegment(
+                file,
+                startingFrame: seekFrame,
+                frameCount: remainingFrames,
+                at: nil,
+                completionCallbackType: .dataPlayedBack
+            ) { [weak self] _ in
+                Task { @MainActor in
+                    guard let self else { return }
+                    guard self.playbackGeneration == gen else { return }
+                    guard expectedDuration < 1.0 || self.currentTime > 0.5 else { return }
+                    self.playbackDidFinish()
+                }
+            }
+        }
+
+        playerNode.play()
+        isPlaying = true
+    }
+
+    func pause() {
+        playerNode.pause()
+        isPlaying = false
+        updateCurrentTime()
+    }
+
+    func stop() {
+        playbackGeneration += 1  // Invalidate any pending completion handlers
+        playerNode.stop()
+        isPlaying = false
+        seekFrame = 0
+        currentTime = 0
+    }
+
+    func togglePlayPause() {
+        if isPlaying {
+            pause()
+        } else {
+            play()
+        }
+    }
+
+    func seek(to time: TimeInterval) {
+        let wasPlaying = isPlaying
+        isSeeking = true
+        playbackGeneration += 1  // Invalidate old completion handler
+        playerNode.stop()
+
+        seekFrame = AVAudioFramePosition(time * audioSampleRate)
+        seekFrame = max(0, min(seekFrame, audioLengthFrames))
+        currentTime = Double(seekFrame) / audioSampleRate
+
+        if wasPlaying {
+            play()  // This schedules a new segment with a new generation
+        }
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
+            self?.isSeeking = false
+        }
+    }
+
+    func seek(by delta: TimeInterval) {
+        let newTime = max(0, min(currentTime + delta, duration))
+        seek(to: newTime)
+    }
+
+    // MARK: - EQ Controls
+
+    func setEQ(band: Int, gain: Float) {
+        guard band < eqNode.bands.count else { return }
+        eqNode.bands[band].gain = gain
+    }
+
+    // MARK: - Time Tracking
+
+    /// Called externally (by PlayerViewModel sync timer) to update current time.
+    func updateCurrentTime() {
+        guard !isSeeking else { return }
+        guard isPlaying else { return }
+        guard let nodeTime = playerNode.lastRenderTime,
+              nodeTime.isSampleTimeValid,
+              let playerTime = playerNode.playerTime(forNodeTime: nodeTime),
+              playerTime.sampleTime >= 0 else { return }
+
+        let newTime = Double(seekFrame + playerTime.sampleTime) / audioSampleRate
+        currentTime = max(0, min(newTime, duration))
+    }
+
+    private func playbackDidFinish() {
+        // This only runs if playbackGeneration matched (checked in the closure)
+        isPlaying = false
+        seekFrame = 0
+        currentTime = duration
+
+        // Update play count
+        if let track = currentTrack {
+            track.playCount += 1
+            track.lastPlayed = Date()
+        }
+
+        // Notify listener (auto-advance)
+        onPlaybackFinished?()
+    }
+
+    // MARK: - Audio Route Change Handling
+
+    private func observeAudioRouteChanges() {
+        // AVAudioEngine posts this when the audio hardware configuration changes
+        // (output device change, AirPlay connect/disconnect, headphones plug/unplug)
+        NotificationCenter.default.addObserver(
+            forName: .AVAudioEngineConfigurationChange,
+            object: engine,
+            queue: nil
+        ) { [weak self] _ in
+            Task { @MainActor in
+                self?.handleConfigurationChange()
+            }
+        }
+    }
+
+    private func handleConfigurationChange() {
+        let wasPlaying = isPlaying
+        let savedTime = currentTime
+
+        // Engine has been stopped by the system — we need to re-setup
+        isPlaying = false
+
+        // Check if this is a "device removed" scenario (AirPlay disconnect)
+        // In that case, the engine's output node changes back to default
+        // We detect this by checking if the engine is still running
+        let engineWasRunning = engine.isRunning
+
+        // Re-setup the audio chain since the hardware config changed
+        setupAudioChain()
+
+        if wasPlaying && (audioFile != nil || oggBuffer != nil) {
+            // Only auto-resume if the engine was running (device switch, not removal)
+            // For AirPlay disconnect, we pause instead
+            if engineWasRunning {
+                // Device switched (e.g. to AirPlay) — resume from same position
+                seekFrame = AVAudioFramePosition(savedTime * audioSampleRate)
+                seekFrame = max(0, min(seekFrame, audioLengthFrames))
+                currentTime = savedTime
+                play()
+            } else {
+                // Device removed (AirPlay disconnect) — stay paused at current position
+                seekFrame = AVAudioFramePosition(savedTime * audioSampleRate)
+                seekFrame = max(0, min(seekFrame, audioLengthFrames))
+                currentTime = savedTime
+            }
+        }
+    }
+}

+ 235 - 0
Sources/Services/BPMDetector.swift

@@ -0,0 +1,235 @@
+import Accelerate
+import AVFoundation
+import Foundation
+
+/// Detects BPM from audio files using energy-based onset detection with autocorrelation.
+struct BPMDetector {
+
+    // MARK: - Configuration
+
+    /// Analysis window size (samples). Larger = more frequency resolution, less time resolution.
+    private static let fftSize = 1024
+
+    /// Hop size between analysis windows.
+    private static let hopSize = 512
+
+    /// Minimum BPM to consider.
+    private static let minBPM: Double = 60
+
+    /// Maximum BPM to consider.
+    private static let maxBPM: Double = 200
+
+    // MARK: - Public API
+
+    /// Analyze a track's BPM. Runs on a background thread.
+    static func detectBPM(for track: Track) async throws -> Double {
+        let url = track.fileURL
+        return try await detectBPM(fileURL: url)
+    }
+
+    /// Analyze BPM from a file URL.
+    static func detectBPM(fileURL: URL) async throws -> Double {
+        try await Task.detached(priority: .userInitiated) {
+            let sampleRate: Double
+            let samples: [Float]
+
+            if OGGDecoder.isOGGFile(fileURL) {
+                let result = try OGGDecoder.readMonoSamples(url: fileURL, maxSeconds: 60)
+                sampleRate = result.sampleRate
+                samples = result.samples
+            } else {
+                let audioFile = try AVAudioFile(forReading: fileURL)
+                sampleRate = audioFile.processingFormat.sampleRate
+                samples = try readMonoSamples(from: audioFile, maxSeconds: 60)
+            }
+
+            guard samples.count > fftSize * 2 else {
+                throw BPMError.insufficientAudio
+            }
+
+            // Step 1: Compute spectral flux (onset detection function)
+            let flux = computeSpectralFlux(samples: samples)
+
+            // Step 2: Normalize flux
+            let normalizedFlux = normalize(flux)
+
+            // Step 3: Autocorrelation to find periodicity
+            let bpm = findBPMFromAutocorrelation(
+                onsetFunction: normalizedFlux,
+                hopRate: sampleRate / Double(hopSize)
+            )
+
+            return bpm
+        }.value
+    }
+
+    // MARK: - Audio Reading
+
+    private static func readMonoSamples(from audioFile: AVAudioFile, maxSeconds: Double) throws -> [Float] {
+        let sampleRate = audioFile.processingFormat.sampleRate
+        let maxFrames = AVAudioFrameCount(min(Double(audioFile.length), sampleRate * maxSeconds))
+
+        guard let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1),
+              let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: maxFrames) else {
+            throw BPMError.formatError
+        }
+
+        audioFile.framePosition = 0
+        try audioFile.read(into: buffer, frameCount: maxFrames)
+
+        guard let channelData = buffer.floatChannelData else {
+            throw BPMError.noAudioData
+        }
+
+        return Array(UnsafeBufferPointer(start: channelData[0], count: Int(buffer.frameLength)))
+    }
+
+    // MARK: - Spectral Flux
+
+    private static func computeSpectralFlux(samples: [Float]) -> [Float] {
+        let halfFFT = fftSize / 2
+        let log2n = vDSP_Length(log2(Double(fftSize)))
+        guard let fftSetup = vDSP_create_fftsetup(log2n, FFTRadix(kFFTRadix2)) else { return [] }
+        defer { vDSP_destroy_fftsetup(fftSetup) }
+
+        let numFrames = (samples.count - fftSize) / hopSize + 1
+        guard numFrames > 1 else { return [] }
+
+        var window = [Float](repeating: 0, count: fftSize)
+        vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
+
+        var previousMagnitudes = [Float](repeating: 0, count: halfFFT)
+        var flux = [Float]()
+        flux.reserveCapacity(numFrames)
+
+        var real = [Float](repeating: 0, count: halfFFT)
+        var imag = [Float](repeating: 0, count: halfFFT)
+
+        for frameIndex in 0..<numFrames {
+            let offset = frameIndex * hopSize
+            let end = offset + fftSize
+            guard end <= samples.count else { break }
+
+            // Window the frame
+            var frame = Array(samples[offset..<end])
+            vDSP_vmul(frame, 1, window, 1, &frame, 1, vDSP_Length(fftSize))
+
+            // Pack for FFT
+            frame.withUnsafeMutableBufferPointer { framePtr in
+                framePtr.baseAddress!.withMemoryRebound(to: DSPComplex.self, capacity: halfFFT) { complexPtr in
+                    var splitComplex = DSPSplitComplex(realp: &real, imagp: &imag)
+                    vDSP_ctoz(complexPtr, 2, &splitComplex, 1, vDSP_Length(halfFFT))
+                }
+            }
+
+            // FFT
+            var splitComplex = DSPSplitComplex(realp: &real, imagp: &imag)
+            vDSP_fft_zrip(fftSetup, &splitComplex, 1, log2n, FFTDirection(kFFTDirection_Forward))
+
+            // Magnitudes
+            var magnitudes = [Float](repeating: 0, count: halfFFT)
+            vDSP_zvmags(&splitComplex, 1, &magnitudes, 1, vDSP_Length(halfFFT))
+
+            // Spectral flux: sum of positive differences
+            var diff = [Float](repeating: 0, count: halfFFT)
+            vDSP_vsub(previousMagnitudes, 1, magnitudes, 1, &diff, 1, vDSP_Length(halfFFT))
+
+            // Half-wave rectify (keep only positive changes)
+            var threshold: Float = 0
+            vDSP_vthres(diff, 1, &threshold, &diff, 1, vDSP_Length(halfFFT))
+
+            var sum: Float = 0
+            vDSP_sve(diff, 1, &sum, vDSP_Length(halfFFT))
+            flux.append(sum)
+
+            previousMagnitudes = magnitudes
+        }
+
+        return flux
+    }
+
+    // MARK: - Autocorrelation
+
+    private static func findBPMFromAutocorrelation(onsetFunction: [Float], hopRate: Double) -> Double {
+        let n = onsetFunction.count
+        guard n > 0 else { return 120 }
+
+        // Lag range in frames corresponding to BPM range
+        let minLag = max(1, Int(hopRate * 60.0 / maxBPM))
+        let maxLag = min(n - 1, Int(hopRate * 60.0 / minBPM))
+
+        guard minLag < maxLag else { return 120 }
+
+        // Compute autocorrelation for relevant lags
+        var bestLag = minLag
+        var bestCorrelation: Float = -.greatestFiniteMagnitude
+
+        for lag in minLag...maxLag {
+            var correlation: Float = 0
+            let length = vDSP_Length(n - lag)
+
+            onsetFunction.withUnsafeBufferPointer { buf in
+                vDSP_dotpr(
+                    buf.baseAddress!, 1,
+                    buf.baseAddress!.advanced(by: lag), 1,
+                    &correlation,
+                    length
+                )
+            }
+
+            // Normalize by overlap length
+            correlation /= Float(n - lag)
+
+            if correlation > bestCorrelation {
+                bestCorrelation = correlation
+                bestLag = lag
+            }
+        }
+
+        // Convert lag to BPM
+        let bpm = hopRate * 60.0 / Double(bestLag)
+
+        // If BPM is very low, it might be detecting half-time — double it
+        if bpm < 80 { return bpm * 2 }
+        // If very high, might be double-time — halve it
+        if bpm > 180 { return bpm / 2 }
+
+        return (bpm * 10).rounded() / 10  // round to 1 decimal
+    }
+
+    // MARK: - Normalize
+
+    private static func normalize(_ data: [Float]) -> [Float] {
+        guard !data.isEmpty else { return [] }
+        var minVal: Float = 0
+        var maxVal: Float = 0
+        vDSP_minv(data, 1, &minVal, vDSP_Length(data.count))
+        vDSP_maxv(data, 1, &maxVal, vDSP_Length(data.count))
+
+        let range = maxVal - minVal
+        guard range > 0 else { return [Float](repeating: 0, count: data.count) }
+
+        var result = [Float](repeating: 0, count: data.count)
+        var negMin = -minVal
+        vDSP_vsadd(data, 1, &negMin, &result, 1, vDSP_Length(data.count))
+        var scale = 1.0 / range
+        vDSP_vsmul(result, 1, &scale, &result, 1, vDSP_Length(data.count))
+        return result
+    }
+}
+
+// MARK: - Errors
+
+enum BPMError: Error, LocalizedError {
+    case insufficientAudio
+    case formatError
+    case noAudioData
+
+    var errorDescription: String? {
+        switch self {
+        case .insufficientAudio: return "Audio file is too short for BPM analysis"
+        case .formatError: return "Unable to read audio format"
+        case .noAudioData: return "No audio data found in file"
+        }
+    }
+}

+ 185 - 0
Sources/Services/ChadMusicAPIClient.swift

@@ -0,0 +1,185 @@
+import Foundation
+
+/// HTTP client for the Chad Music REST API.
+/// Handles auth headers, URL composition, and JSON decoding.
+@MainActor
+@Observable
+final class ChadMusicAPIClient {
+    /// Shared instance — reuse across views to avoid repeated Keychain/UserDefaults reads.
+    static let shared = ChadMusicAPIClient()
+
+    // MARK: - Configuration
+
+    /// Server base URL (e.g., "https://music.tailnet.ts.net").
+    var serverURL: String {
+        get { UserDefaults.standard.string(forKey: "chadMusic.serverURL") ?? "" }
+        set { UserDefaults.standard.set(newValue, forKey: "chadMusic.serverURL") }
+    }
+
+    /// API key for authentication.
+    private var apiKey: String? {
+        let key = UserDefaults.standard.string(forKey: "chadMusic.apiKey")
+        return (key?.isEmpty ?? true) ? nil : key
+    }
+
+    /// Whether the client is configured (has URL + API key).
+    var isConfigured: Bool {
+        !serverURL.isEmpty && apiKey != nil
+    }
+
+    // MARK: - Private
+
+    private let session: URLSession
+    private let decoder: JSONDecoder
+
+    init() {
+        let config = URLSessionConfiguration.default
+        config.timeoutIntervalForRequest = 15
+        config.timeoutIntervalForResource = 60
+        self.session = URLSession(configuration: config)
+        self.decoder = JSONDecoder()
+    }
+
+    // MARK: - API Methods
+
+    /// Fetch library statistics.
+    func fetchStats() async throws -> ChadStats {
+        try await get("api/stats")
+    }
+
+    /// Fetch entries for a category (albums, artists, genres, etc.).
+    func fetchCategory(_ category: ChadCategoryType) async throws -> [ChadCategory] {
+        try await get("api/cat/\(category.rawValue)")
+    }
+
+    /// Fetch albums — /api/cat/album returns full ChadAlbum objects, not ChadCategory items.
+    func fetchAlbums() async throws -> [ChadAlbum] {
+        try await get("api/cat/album")
+    }
+
+    /// Fetch albums filtered by a category value (e.g., artist name, genre, year).
+    func fetchAlbums(filteredBy category: String, value: String) async throws -> [ChadAlbum] {
+        let encoded = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
+        return try await get("api/cat/album?\(category)=\(encoded)")
+    }
+
+    /// Fetch tracks for an album.
+    func fetchAlbumTracks(albumId: String) async throws -> [ChadTrack] {
+        try await get("api/album/\(albumId)/tracks")
+    }
+
+    /// Test connection — tries to fetch stats and returns success/failure.
+    func testConnection() async -> Result<ChadStats, ChadMusicError> {
+        do {
+            let stats = try await fetchStats()
+            return .success(stats)
+        } catch let error as ChadMusicError {
+            return .failure(error)
+        } catch {
+            return .failure(.networkError(error))
+        }
+    }
+
+    /// Build a full streaming URL for a track, suitable for AVURLAsset.
+    /// The trackPath from the API is already percent-encoded — don't re-encode it.
+    func streamURL(for trackPath: String) -> URL? {
+        let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return nil }
+        let base = trimmed.hasSuffix("/") ? String(trimmed.dropLast()) : trimmed
+        // trackPath starts with "/" from the API — just concatenate
+        let path = trackPath.hasPrefix("/") ? trackPath : "/" + trackPath
+        return URL(string: base + path)
+    }
+
+    /// The auth headers dict for AVURLAsset (AVURLAssetHTTPHeaderFieldsKey).
+    var authHeaders: [String: String] {
+        guard let key = apiKey else { return [:] }
+        return ["Authorization": "Bearer \(key)"]
+    }
+
+    // MARK: - Private Helpers
+
+    private var baseURL: URL? {
+        let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return nil }
+        // Ensure trailing slash for proper path joining
+        let normalized = trimmed.hasSuffix("/") ? trimmed : trimmed + "/"
+        return URL(string: normalized)
+    }
+
+    private func get<T: Decodable>(_ path: String) async throws -> T {
+        let trimmed = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else {
+            throw ChadMusicError.notConfigured
+        }
+        let base = trimmed.hasSuffix("/") ? trimmed : trimmed + "/"
+
+        guard let url = URL(string: base + path) else {
+            throw ChadMusicError.notConfigured
+        }
+        var request = URLRequest(url: url)
+        request.httpMethod = "GET"
+
+        // Auth header
+        if let key = apiKey {
+            request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
+        }
+
+        let (data, response) = try await session.data(for: request)
+
+        guard let httpResponse = response as? HTTPURLResponse else {
+            throw ChadMusicError.invalidResponse
+        }
+
+        switch httpResponse.statusCode {
+        case 200..<300:
+            do {
+                return try decoder.decode(T.self, from: data)
+            } catch {
+                throw ChadMusicError.decodingFailed(error)
+            }
+        case 401:
+            throw ChadMusicError.unauthorized
+        case 403:
+            throw ChadMusicError.forbidden
+        case 404:
+            throw ChadMusicError.notFound(path)
+        default:
+            throw ChadMusicError.httpError(httpResponse.statusCode)
+        }
+    }
+}
+
+// MARK: - Errors
+
+enum ChadMusicError: LocalizedError {
+    case notConfigured
+    case unauthorized
+    case forbidden
+    case notFound(String)
+    case httpError(Int)
+    case invalidResponse
+    case decodingFailed(Error)
+    case networkError(Error)
+
+    var errorDescription: String? {
+        switch self {
+        case .notConfigured:
+            "Chad Music server not configured. Set the server URL and API key in Settings."
+        case .unauthorized:
+            "Invalid API key (401 Unauthorized)."
+        case .forbidden:
+            "Access denied (403 Forbidden)."
+        case .notFound(let path):
+            "Endpoint not found: \(path)"
+        case .httpError(let code):
+            "Server returned HTTP \(code)."
+        case .invalidResponse:
+            "Invalid server response."
+        case .decodingFailed(let error):
+            "Failed to decode response: \(error.localizedDescription)"
+        case .networkError(let error):
+            "Network error: \(error.localizedDescription)"
+        }
+    }
+}

+ 281 - 0
Sources/Services/KeyDetector.swift

@@ -0,0 +1,281 @@
+import Accelerate
+import AVFoundation
+import Foundation
+
+/// Detects the musical key of an audio file using chromagram analysis
+/// with Krumhansl-Kessler key profiles.
+struct KeyDetector {
+
+    // MARK: - Key Profiles (Krumhansl-Kessler)
+
+    /// Major key profile weights for each pitch class (C, C#, D, ..., B).
+    private static let majorProfile: [Double] = [
+        6.35, 2.23, 3.48, 2.33, 4.38, 4.09,
+        2.52, 5.19, 2.39, 3.66, 2.29, 2.88
+    ]
+
+    /// Minor key profile weights for each pitch class.
+    private static let minorProfile: [Double] = [
+        6.33, 2.68, 3.52, 5.38, 2.60, 3.53,
+        2.54, 4.75, 3.98, 2.69, 3.34, 3.17
+    ]
+
+    /// Note names for Camelot-compatible display.
+    private static let noteNames = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"]
+
+    /// Camelot wheel codes for DJ-friendly key display.
+    private static let camelotMajor = ["8B", "3B", "10B", "5B", "12B", "7B", "2B", "9B", "4B", "11B", "6B", "1B"]
+    private static let camelotMinor = ["5A", "12A", "7A", "2A", "9A", "4A", "11A", "6A", "1A", "8A", "3A", "10A"]
+
+    // MARK: - Configuration
+
+    private static let fftSize = 4096
+    private static let hopSize = 2048
+    private static let referenceFrequency: Double = 440.0 // A4
+
+    // MARK: - Result
+
+    struct KeyResult {
+        let key: String           // e.g., "C Major" or "A Minor"
+        let camelotCode: String   // e.g., "8B" or "8A"
+        let confidence: Double    // 0.0 to 1.0
+        let rootNote: Int         // pitch class index 0-11
+        let isMinor: Bool
+
+        var shortKey: String {
+            let note = KeyDetector.noteNames[rootNote]
+            return "\(note)\(isMinor ? "m" : "")"
+        }
+    }
+
+    // MARK: - Public API
+
+    static func detectKey(for track: Track) async throws -> KeyResult {
+        try await detectKey(fileURL: track.fileURL)
+    }
+
+    static func detectKey(fileURL: URL) async throws -> KeyResult {
+        try await Task.detached(priority: .userInitiated) {
+            let sampleRate: Double
+            let samples: [Float]
+
+            if OGGDecoder.isOGGFile(fileURL) {
+                let result = try OGGDecoder.readMonoSamples(url: fileURL, maxSeconds: 30)
+                sampleRate = result.sampleRate
+                samples = result.samples
+            } else {
+                let audioFile = try AVAudioFile(forReading: fileURL)
+                sampleRate = audioFile.processingFormat.sampleRate
+                samples = try readMonoSamples(from: audioFile, maxSeconds: 30)
+            }
+
+            guard samples.count > fftSize * 2 else {
+                throw KeyDetectionError.insufficientAudio
+            }
+
+            // Build chromagram
+            let chromagram = computeChromagram(samples: samples, sampleRate: sampleRate)
+
+            // Average across time
+            let avgChroma = averageChromagram(chromagram)
+
+            // Match against key profiles
+            return matchKeyProfile(chroma: avgChroma)
+        }.value
+    }
+
+    // MARK: - Audio Reading
+
+    private static func readMonoSamples(from audioFile: AVAudioFile, maxSeconds: Double) throws -> [Float] {
+        let sampleRate = audioFile.processingFormat.sampleRate
+        let maxFrames = AVAudioFrameCount(min(Double(audioFile.length), sampleRate * maxSeconds))
+
+        guard let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1),
+              let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: maxFrames) else {
+            throw KeyDetectionError.formatError
+        }
+
+        audioFile.framePosition = 0
+        try audioFile.read(into: buffer, frameCount: maxFrames)
+
+        guard let data = buffer.floatChannelData else {
+            throw KeyDetectionError.noAudioData
+        }
+
+        return Array(UnsafeBufferPointer(start: data[0], count: Int(buffer.frameLength)))
+    }
+
+    // MARK: - Chromagram Computation
+
+    private static func computeChromagram(samples: [Float], sampleRate: Double) -> [[Double]] {
+        let halfFFT = fftSize / 2
+        let log2n = vDSP_Length(log2(Double(fftSize)))
+        guard let fftSetup = vDSP_create_fftsetup(log2n, FFTRadix(kFFTRadix2)) else { return [] }
+        defer { vDSP_destroy_fftsetup(fftSetup) }
+
+        let numFrames = (samples.count - fftSize) / hopSize + 1
+        var chromagram = [[Double]]()
+        chromagram.reserveCapacity(numFrames)
+
+        var window = [Float](repeating: 0, count: fftSize)
+        vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
+
+        // Pre-compute frequency-to-chroma mapping
+        let chromaMap = buildChromaMap(fftSize: fftSize, sampleRate: sampleRate)
+
+        var real = [Float](repeating: 0, count: halfFFT)
+        var imag = [Float](repeating: 0, count: halfFFT)
+
+        for frameIndex in 0..<numFrames {
+            let offset = frameIndex * hopSize
+            let end = offset + fftSize
+            guard end <= samples.count else { break }
+
+            var frame = Array(samples[offset..<end])
+            vDSP_vmul(frame, 1, window, 1, &frame, 1, vDSP_Length(fftSize))
+
+            // FFT
+            frame.withUnsafeMutableBufferPointer { framePtr in
+                framePtr.baseAddress!.withMemoryRebound(to: DSPComplex.self, capacity: halfFFT) { complexPtr in
+                    var splitComplex = DSPSplitComplex(realp: &real, imagp: &imag)
+                    vDSP_ctoz(complexPtr, 2, &splitComplex, 1, vDSP_Length(halfFFT))
+                }
+            }
+
+            var splitComplex = DSPSplitComplex(realp: &real, imagp: &imag)
+            vDSP_fft_zrip(fftSetup, &splitComplex, 1, log2n, FFTDirection(kFFTDirection_Forward))
+
+            // Magnitudes
+            var magnitudes = [Float](repeating: 0, count: halfFFT)
+            vDSP_zvmags(&splitComplex, 1, &magnitudes, 1, vDSP_Length(halfFFT))
+
+            // Map to 12 chroma bins
+            var chroma = [Double](repeating: 0, count: 12)
+            for bin in 1..<halfFFT {
+                let chromaBin = chromaMap[bin]
+                if chromaBin >= 0 {
+                    chroma[chromaBin] += Double(magnitudes[bin])
+                }
+            }
+
+            chromagram.append(chroma)
+        }
+
+        return chromagram
+    }
+
+    /// Pre-compute which FFT bin maps to which chroma pitch class.
+    private static func buildChromaMap(fftSize: Int, sampleRate: Double) -> [Int] {
+        let halfFFT = fftSize / 2
+        var map = [Int](repeating: -1, count: halfFFT)
+
+        for bin in 1..<halfFFT {
+            let frequency = Double(bin) * sampleRate / Double(fftSize)
+
+            // Only consider musically relevant frequencies (30 Hz to 5000 Hz)
+            guard frequency >= 30 && frequency <= 5000 else { continue }
+
+            // Convert frequency to pitch class
+            let semitones = 12.0 * log2(frequency / referenceFrequency)
+            let pitchClass = ((Int(round(semitones)) % 12) + 12 + 9) % 12  // A = 9, so shift to C = 0
+            map[bin] = pitchClass
+        }
+
+        return map
+    }
+
+    // MARK: - Average Chromagram
+
+    private static func averageChromagram(_ chromagram: [[Double]]) -> [Double] {
+        guard !chromagram.isEmpty else { return [Double](repeating: 0, count: 12) }
+
+        var avg = [Double](repeating: 0, count: 12)
+        for frame in chromagram {
+            for i in 0..<12 {
+                avg[i] += frame[i]
+            }
+        }
+        let count = Double(chromagram.count)
+        for i in 0..<12 {
+            avg[i] /= count
+        }
+        return avg
+    }
+
+    // MARK: - Key Profile Matching
+
+    private static func matchKeyProfile(chroma: [Double]) -> KeyResult {
+        var bestCorrelation = -Double.greatestFiniteMagnitude
+        var bestRoot = 0
+        var bestIsMinor = false
+
+        for root in 0..<12 {
+            // Rotate chroma so 'root' aligns with index 0
+            let rotated = rotateChroma(chroma, by: root)
+
+            // Correlate with major profile
+            let majorCorr = pearsonCorrelation(rotated, majorProfile)
+            if majorCorr > bestCorrelation {
+                bestCorrelation = majorCorr
+                bestRoot = root
+                bestIsMinor = false
+            }
+
+            // Correlate with minor profile
+            let minorCorr = pearsonCorrelation(rotated, minorProfile)
+            if minorCorr > bestCorrelation {
+                bestCorrelation = minorCorr
+                bestRoot = root
+                bestIsMinor = true
+            }
+        }
+
+        let confidence = max(0, min(1, (bestCorrelation + 1) / 2))
+        let keyName = "\(noteNames[bestRoot]) \(bestIsMinor ? "Minor" : "Major")"
+        let camelot = bestIsMinor ? camelotMinor[bestRoot] : camelotMajor[bestRoot]
+
+        return KeyResult(
+            key: keyName,
+            camelotCode: camelot,
+            confidence: confidence,
+            rootNote: bestRoot,
+            isMinor: bestIsMinor
+        )
+    }
+
+    private static func rotateChroma(_ chroma: [Double], by amount: Int) -> [Double] {
+        let n = chroma.count
+        return (0..<n).map { chroma[($0 + amount) % n] }
+    }
+
+    private static func pearsonCorrelation(_ a: [Double], _ b: [Double]) -> Double {
+        let n = Double(a.count)
+        let sumA = a.reduce(0, +)
+        let sumB = b.reduce(0, +)
+        let sumAB = zip(a, b).map(*).reduce(0, +)
+        let sumA2 = a.map { $0 * $0 }.reduce(0, +)
+        let sumB2 = b.map { $0 * $0 }.reduce(0, +)
+
+        let numerator = n * sumAB - sumA * sumB
+        let denominator = sqrt((n * sumA2 - sumA * sumA) * (n * sumB2 - sumB * sumB))
+
+        guard denominator > 0 else { return 0 }
+        return numerator / denominator
+    }
+}
+
+// MARK: - Errors
+
+enum KeyDetectionError: Error, LocalizedError {
+    case insufficientAudio
+    case formatError
+    case noAudioData
+
+    var errorDescription: String? {
+        switch self {
+        case .insufficientAudio: return "Audio file is too short for key detection"
+        case .formatError: return "Unable to read audio format"
+        case .noAudioData: return "No audio data found"
+        }
+    }
+}

+ 71 - 0
Sources/Services/KeychainService.swift

@@ -0,0 +1,71 @@
+import Foundation
+import Security
+
+/// Minimal Keychain wrapper for storing the Chad Music API key.
+enum KeychainService {
+    private static let service = "com.mixboard.chad-music"
+    private static let apiKeyAccount = "api-key"
+
+    // MARK: - API Key
+
+    static func saveAPIKey(_ key: String) throws {
+        guard let data = key.data(using: .utf8) else { return }
+
+        // Delete existing item first
+        let deleteQuery: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: apiKeyAccount,
+        ]
+        SecItemDelete(deleteQuery as CFDictionary)
+
+        // Add new item
+        let addQuery: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: apiKeyAccount,
+            kSecValueData as String: data,
+            kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked,
+        ]
+        let status = SecItemAdd(addQuery as CFDictionary, nil)
+        guard status == errSecSuccess else {
+            throw KeychainError.saveFailed(status)
+        }
+    }
+
+    static func loadAPIKey() -> String? {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: apiKeyAccount,
+            kSecReturnData as String: true,
+            kSecMatchLimit as String: kSecMatchLimitOne,
+        ]
+        var result: AnyObject?
+        let status = SecItemCopyMatching(query as CFDictionary, &result)
+        guard status == errSecSuccess, let data = result as? Data else { return nil }
+        return String(data: data, encoding: .utf8)
+    }
+
+    static func deleteAPIKey() {
+        let query: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: apiKeyAccount,
+        ]
+        SecItemDelete(query as CFDictionary)
+    }
+
+    // MARK: - Errors
+
+    enum KeychainError: LocalizedError {
+        case saveFailed(OSStatus)
+
+        var errorDescription: String? {
+            switch self {
+            case .saveFailed(let status):
+                "Keychain save failed with status \(status)"
+            }
+        }
+    }
+}

+ 199 - 0
Sources/Services/LRCLIBService.swift

@@ -0,0 +1,199 @@
+import Foundation
+
+/// Fetches lyrics from LRCLIB (https://lrclib.net) — a free, open-source, community-driven lyrics API.
+/// No API key required. Returns both synced (LRC timestamped) and plain lyrics.
+actor LRCLIBService {
+
+    static let shared = LRCLIBService()
+
+    private let baseURL = "https://lrclib.net/api"
+    private let session: URLSession
+
+    /// Cached results keyed by "artist - title".
+    private var cache: [String: LyricsResult] = [:]
+
+    init() {
+        let config = URLSessionConfiguration.default
+        config.timeoutIntervalForRequest = 10
+        config.httpAdditionalHeaders = ["User-Agent": "MixBoard/1.0 (https://github.com/mixboard)"]
+        session = URLSession(configuration: config)
+    }
+
+    // MARK: - Public API
+
+    /// Search for lyrics by artist and track title.
+    /// Optionally provide album name and duration (in seconds) for better matching.
+    func fetchLyrics(
+        artist: String,
+        title: String,
+        album: String? = nil,
+        duration: TimeInterval? = nil
+    ) async throws -> LyricsResult {
+        // Check cache
+        let cacheKey = "\(artist.lowercased()) - \(title.lowercased())"
+        if let cached = cache[cacheKey] {
+            return cached
+        }
+
+        // Build the request URL
+        // First try the exact "get" endpoint (best match with duration)
+        if let result = try? await fetchExact(artist: artist, title: title, album: album, duration: duration) {
+            cache[cacheKey] = result
+            return result
+        }
+
+        // Fallback to search endpoint
+        let result = try await fetchSearch(artist: artist, title: title)
+        cache[cacheKey] = result
+        return result
+    }
+
+    /// Clear the lyrics cache.
+    func clearCache() {
+        cache.removeAll()
+    }
+
+    // MARK: - Private
+
+    /// Use the /api/get endpoint (exact match with optional duration).
+    private func fetchExact(
+        artist: String,
+        title: String,
+        album: String?,
+        duration: TimeInterval?
+    ) async throws -> LyricsResult {
+        var components = URLComponents(string: "\(baseURL)/get")!
+        var queryItems = [
+            URLQueryItem(name: "artist_name", value: artist),
+            URLQueryItem(name: "track_name", value: title)
+        ]
+        if let album, !album.isEmpty {
+            queryItems.append(URLQueryItem(name: "album_name", value: album))
+        }
+        if let duration, duration > 0 {
+            queryItems.append(URLQueryItem(name: "duration", value: String(Int(duration))))
+        }
+        components.queryItems = queryItems
+
+        guard let url = components.url else {
+            throw LyricsError.invalidURL
+        }
+
+        let (data, response) = try await session.data(from: url)
+
+        guard let httpResponse = response as? HTTPURLResponse else {
+            throw LyricsError.networkError
+        }
+
+        if httpResponse.statusCode == 404 {
+            throw LyricsError.notFound
+        }
+
+        guard httpResponse.statusCode == 200 else {
+            throw LyricsError.httpError(httpResponse.statusCode)
+        }
+
+        let decoded = try JSONDecoder().decode(LRCLIBResponse.self, from: data)
+        return LyricsResult(from: decoded)
+    }
+
+    /// Use the /api/search endpoint (fuzzy search, returns array).
+    private func fetchSearch(artist: String, title: String) async throws -> LyricsResult {
+        var components = URLComponents(string: "\(baseURL)/search")!
+        components.queryItems = [
+            URLQueryItem(name: "q", value: "\(artist) \(title)")
+        ]
+
+        guard let url = components.url else {
+            throw LyricsError.invalidURL
+        }
+
+        let (data, response) = try await session.data(from: url)
+
+        guard let httpResponse = response as? HTTPURLResponse,
+              httpResponse.statusCode == 200 else {
+            throw LyricsError.networkError
+        }
+
+        let results = try JSONDecoder().decode([LRCLIBResponse].self, from: data)
+
+        guard let best = results.first else {
+            throw LyricsError.notFound
+        }
+
+        return LyricsResult(from: best)
+    }
+}
+
+// MARK: - Models
+
+/// Response from the LRCLIB API.
+private struct LRCLIBResponse: Decodable {
+    let id: Int
+    let trackName: String?
+    let artistName: String?
+    let albumName: String?
+    let duration: Double?
+    let instrumental: Bool?
+    let plainLyrics: String?
+    let syncedLyrics: String?
+}
+
+/// Parsed lyrics result.
+struct LyricsResult {
+    let trackName: String
+    let artistName: String
+    let albumName: String
+    let isInstrumental: Bool
+    let plainLyrics: String?
+    let syncedLyrics: String?
+
+    /// Whether synced (timestamped) lyrics are available.
+    var hasSyncedLyrics: Bool { syncedLyrics != nil && !(syncedLyrics?.isEmpty ?? true) }
+
+    /// Whether any lyrics are available.
+    var hasLyrics: Bool { hasSyncedLyrics || (plainLyrics != nil && !(plainLyrics?.isEmpty ?? true)) }
+
+    fileprivate init(from response: LRCLIBResponse) {
+        trackName = response.trackName ?? ""
+        artistName = response.artistName ?? ""
+        albumName = response.albumName ?? ""
+        isInstrumental = response.instrumental ?? false
+        plainLyrics = response.plainLyrics
+        syncedLyrics = response.syncedLyrics
+    }
+
+    init(
+        trackName: String = "",
+        artistName: String = "",
+        albumName: String = "",
+        isInstrumental: Bool = false,
+        plainLyrics: String? = nil,
+        syncedLyrics: String? = nil
+    ) {
+        self.trackName = trackName
+        self.artistName = artistName
+        self.albumName = albumName
+        self.isInstrumental = isInstrumental
+        self.plainLyrics = plainLyrics
+        self.syncedLyrics = syncedLyrics
+    }
+}
+
+// MARK: - Errors
+
+enum LyricsError: LocalizedError {
+    case invalidURL
+    case notFound
+    case networkError
+    case httpError(Int)
+
+    var errorDescription: String? {
+        switch self {
+        case .invalidURL: return "Invalid lyrics search URL"
+        case .notFound: return "No lyrics found"
+        case .networkError: return "Network error fetching lyrics"
+        case .httpError(let code): return "HTTP error \(code)"
+        }
+    }
+}

+ 240 - 0
Sources/Services/LibraryManager.swift

@@ -0,0 +1,240 @@
+import Foundation
+import SwiftData
+
+/// Manages the music library — scanning directories, importing tracks, and managing files.
+@MainActor
+final class LibraryManager: ObservableObject {
+    @Published var isScanning = false
+    @Published var scanProgress: Double = 0
+    @Published var lastError: String?
+
+    private var modelContext: ModelContext?
+
+    func setModelContext(_ context: ModelContext) {
+        self.modelContext = context
+    }
+
+    // MARK: - Import
+
+    /// Import audio files from a directory (recursively).
+    func importDirectory(_ url: URL) async {
+        guard let context = modelContext else { return }
+        isScanning = true
+        scanProgress = 0
+        lastError = nil
+
+        let fileManager = FileManager.default
+        var audioURLs: [URL] = []
+
+        // Collect all audio files
+        if let enumerator = fileManager.enumerator(
+            at: url,
+            includingPropertiesForKeys: [.isRegularFileKey],
+            options: [.skipsHiddenFiles]
+        ) {
+            for case let fileURL as URL in enumerator {
+                if MetadataService.isSupportedAudioFile(fileURL) {
+                    audioURLs.append(fileURL)
+                }
+            }
+        }
+
+        guard !audioURLs.isEmpty else {
+            isScanning = false
+            lastError = "No supported audio files found in directory"
+            return
+        }
+
+        // Import each file
+        for (index, fileURL) in audioURLs.enumerated() {
+            scanProgress = Double(index) / Double(audioURLs.count)
+
+            do {
+                try await importFile(fileURL, context: context)
+            } catch {
+                print("LibraryManager: Failed to import \(fileURL.lastPathComponent): \(error)")
+            }
+        }
+
+        try? context.save()
+        isScanning = false
+        scanProgress = 1.0
+    }
+
+    /// Import a single audio file.
+    func importFile(_ url: URL, context: ModelContext? = nil) async throws {
+        let ctx = context ?? modelContext
+        guard let ctx else { return }
+
+        // Check if already imported
+        let path = url.path
+        let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.filePath == path })
+        let existing = try ctx.fetch(descriptor)
+        if let existingTrack = existing.first {
+            // Backfill year if missing (for tracks imported before year extraction was added)
+            if existingTrack.year == nil {
+                if let metadata = try? await MetadataService.readMetadata(from: url) {
+                    existingTrack.year = metadata.year
+                }
+            }
+            return
+        }
+
+        // Read metadata
+        let metadata = try await MetadataService.readMetadata(from: url)
+
+        let track = Track(
+            title: metadata.title,
+            artist: metadata.artist,
+            album: metadata.album,
+            genre: metadata.genre,
+            filePath: url.path,
+            duration: metadata.duration,
+            sampleRate: metadata.sampleRate,
+            bitDepth: metadata.bitDepth,
+            channels: metadata.channels,
+            fileFormat: metadata.fileFormat,
+            fileSizeBytes: metadata.fileSizeBytes
+        )
+
+        track.year = metadata.year
+        ctx.insert(track)
+    }
+
+    /// Import files selected via file picker.
+    func importFiles(_ urls: [URL]) async {
+        guard let context = modelContext else { return }
+        isScanning = true
+        scanProgress = 0
+
+        for (index, url) in urls.enumerated() {
+            scanProgress = Double(index) / Double(urls.count)
+            do {
+                try await importFile(url, context: context)
+            } catch {
+                print("LibraryManager: Failed to import \(url.lastPathComponent): \(error)")
+            }
+        }
+
+        try? context.save()
+        isScanning = false
+        scanProgress = 1.0
+    }
+
+    // MARK: - Analysis
+
+    /// Re-read year metadata from the audio file for a track.
+    /// Uses a lightweight metadata-only read (no AVAudioFile) so it works for all formats.
+    func rescanMetadata(_ track: Track) async {
+        let url = track.fileURL
+        guard FileManager.default.fileExists(atPath: url.path) else {
+            print("LibraryManager: File not found for \(track.title): \(url.path)")
+            return
+        }
+        do {
+            let year = try await MetadataService.readYear(from: url)
+            track.year = year
+        } catch {
+            print("LibraryManager: Year rescan failed for \(track.title): \(error)")
+        }
+    }
+
+    /// Re-read metadata for all given tracks.
+    func rescanAllMetadata(tracks: [Track]) async {
+        isScanning = true
+        scanProgress = 0
+        for (index, track) in tracks.enumerated() {
+            scanProgress = Double(index) / Double(tracks.count)
+            await rescanMetadata(track)
+        }
+        if let context = modelContext {
+            try? context.save()
+        }
+        isScanning = false
+        scanProgress = 1.0
+    }
+
+    /// Run BPM and key detection on a track.
+    func analyzeTrack(_ track: Track) async {
+        do {
+            async let bpmResult = BPMDetector.detectBPM(for: track)
+            async let keyResult = KeyDetector.detectKey(for: track)
+
+            let bpm = try await bpmResult
+            let key = try await keyResult
+
+            track.bpm = bpm
+            track.musicalKey = key.shortKey
+            track.isAnalyzed = true
+        } catch {
+            print("LibraryManager: Analysis failed for \(track.title): \(error)")
+        }
+    }
+
+    /// Analyze all un-analyzed tracks.
+    func analyzeAllTracks(tracks: [Track]) async {
+        let unanalyzed = tracks.filter { !$0.isAnalyzed }
+        for (index, track) in unanalyzed.enumerated() {
+            scanProgress = Double(index) / Double(unanalyzed.count)
+            await analyzeTrack(track)
+        }
+        scanProgress = 1.0
+    }
+
+    // MARK: - File Operations
+
+    /// Copy files to a destination directory (for preparing DAW project).
+    func copyFiles(_ tracks: [Track], to destinationDir: URL) throws -> [URL] {
+        let fm = FileManager.default
+        try fm.createDirectory(at: destinationDir, withIntermediateDirectories: true)
+
+        var copiedURLs: [URL] = []
+        for track in tracks {
+            let source = track.fileURL
+            let dest = destinationDir.appendingPathComponent(source.lastPathComponent)
+
+            if !fm.fileExists(atPath: dest.path) {
+                try fm.copyItem(at: source, to: dest)
+            }
+            copiedURLs.append(dest)
+        }
+
+        return copiedURLs
+    }
+
+    /// Move files to a new location and update track paths.
+    func moveFiles(_ tracks: [Track], to destinationDir: URL) throws {
+        let fm = FileManager.default
+        try fm.createDirectory(at: destinationDir, withIntermediateDirectories: true)
+
+        for track in tracks {
+            let source = track.fileURL
+            let dest = destinationDir.appendingPathComponent(source.lastPathComponent)
+
+            try fm.moveItem(at: source, to: dest)
+            track.filePath = dest.path
+        }
+    }
+
+    /// Rename a file.
+    func renameFile(_ track: Track, newName: String) throws {
+        let source = track.fileURL
+        let ext = source.pathExtension
+        let dest = source.deletingLastPathComponent()
+            .appendingPathComponent(newName)
+            .appendingPathExtension(ext)
+
+        try FileManager.default.moveItem(at: source, to: dest)
+        track.filePath = dest.path
+        track.title = newName
+    }
+
+    // MARK: - Deletion
+
+    func removeTrack(_ track: Track, deleteFile: Bool = false) {
+        if deleteFile {
+            try? FileManager.default.removeItem(at: track.fileURL)
+        }
+        modelContext?.delete(track)
+    }
+}

+ 101 - 0
Sources/Services/LyricsParser.swift

@@ -0,0 +1,101 @@
+import Foundation
+
+/// Parses LRC (synced lyrics) format into timestamped lines.
+/// LRC format: `[mm:ss.xx] Lyric line text`
+struct LyricsParser {
+
+    /// A single timestamped lyric line.
+    struct LyricLine: Identifiable, Equatable {
+        let id = UUID()
+        let timestamp: TimeInterval
+        let text: String
+
+        /// Format timestamp as mm:ss.
+        var formattedTime: String {
+            let minutes = Int(timestamp) / 60
+            let seconds = Int(timestamp) % 60
+            return String(format: "%d:%02d", minutes, seconds)
+        }
+    }
+
+    /// Parse synced LRC lyrics into an array of timestamped lines, sorted by time.
+    static func parseSynced(_ lrc: String) -> [LyricLine] {
+        var lines: [LyricLine] = []
+
+        for rawLine in lrc.components(separatedBy: .newlines) {
+            let trimmed = rawLine.trimmingCharacters(in: .whitespaces)
+            guard !trimmed.isEmpty else { continue }
+
+            // Match [mm:ss.xx] or [mm:ss:xx] patterns  
+            // Can have multiple timestamps per line: [00:12.34][00:45.67] text
+            let pattern = #"\[(\d{1,3}):(\d{2})[\.:,](\d{2,3})\]"#
+            guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
+
+            let matches = regex.matches(in: trimmed, range: NSRange(trimmed.startIndex..., in: trimmed))
+            guard !matches.isEmpty else { continue }
+
+            // Extract the text after all timestamps
+            let lastMatch = matches.last!
+            let textStartIndex = trimmed.index(trimmed.startIndex, offsetBy: lastMatch.range.upperBound)
+            let text = String(trimmed[textStartIndex...]).trimmingCharacters(in: .whitespaces)
+
+            // Skip empty lyric lines (instrumental breaks, etc.) — keep them as blank markers
+            // Some LRC files use empty lines to indicate pauses
+
+            // Create a line for each timestamp
+            for match in matches {
+                guard let minRange = Range(match.range(at: 1), in: trimmed),
+                      let secRange = Range(match.range(at: 2), in: trimmed),
+                      let msRange = Range(match.range(at: 3), in: trimmed),
+                      let minutes = Double(trimmed[minRange]),
+                      let seconds = Double(trimmed[secRange]),
+                      let rawMs = Double(trimmed[msRange]) else { continue }
+
+                // Handle both 2-digit (centiseconds) and 3-digit (milliseconds) fractional parts
+                let fractional: Double
+                let msString = String(trimmed[msRange])
+                if msString.count <= 2 {
+                    fractional = rawMs / 100.0
+                } else {
+                    fractional = rawMs / 1000.0
+                }
+
+                let timestamp = minutes * 60.0 + seconds + fractional
+                lines.append(LyricLine(timestamp: timestamp, text: text))
+            }
+        }
+
+        return lines.sorted { $0.timestamp < $1.timestamp }
+    }
+
+    /// Parse plain (unsynced) lyrics into lines with no timestamps.
+    static func parsePlain(_ text: String) -> [LyricLine] {
+        text.components(separatedBy: .newlines)
+            .enumerated()
+            .map { index, line in
+                LyricLine(timestamp: TimeInterval(index), text: line)
+            }
+    }
+
+    /// Find the index of the current lyric line for a given playback time.
+    static func currentLineIndex(in lines: [LyricLine], at time: TimeInterval) -> Int? {
+        guard !lines.isEmpty else { return nil }
+
+        // Binary search for the last line whose timestamp <= current time
+        var low = 0
+        var high = lines.count - 1
+        var result = -1
+
+        while low <= high {
+            let mid = (low + high) / 2
+            if lines[mid].timestamp <= time {
+                result = mid
+                low = mid + 1
+            } else {
+                high = mid - 1
+            }
+        }
+
+        return result >= 0 ? result : nil
+    }
+}

+ 127 - 0
Sources/Services/MediaKeyHandler.swift

@@ -0,0 +1,127 @@
+import AppKit
+import Foundation
+import MediaPlayer
+
+/// Integrates with macOS media keys (play/pause, next, previous) and Now Playing info center.
+/// This makes MixBoard respond to keyboard media keys and appear in the Now Playing widget.
+@MainActor
+final class MediaKeyHandler {
+
+    static let shared = MediaKeyHandler()
+
+    private let commandCenter = MPRemoteCommandCenter.shared()
+    private let infoCenter = MPNowPlayingInfoCenter.default()
+    private var keyMonitor: Any?
+    private var doubleClickMonitor: Any?
+
+    weak var playerVM: PlayerViewModel?
+
+    private init() {}
+
+    /// Start handling media key events. Call once at app launch.
+    func register(playerVM: PlayerViewModel) {
+        self.playerVM = playerVM
+
+        // Play
+        commandCenter.playCommand.isEnabled = true
+        commandCenter.playCommand.addTarget { [weak self] _ in
+            guard let self, let vm = self.playerVM else { return .commandFailed }
+            if !vm.isPlaying {
+                vm.togglePlayPause()
+            }
+            return .success
+        }
+
+        // Pause
+        commandCenter.pauseCommand.isEnabled = true
+        commandCenter.pauseCommand.addTarget { [weak self] _ in
+            guard let self, let vm = self.playerVM else { return .commandFailed }
+            if vm.isPlaying {
+                vm.togglePlayPause()
+            }
+            return .success
+        }
+
+        // Toggle play/pause
+        commandCenter.togglePlayPauseCommand.isEnabled = true
+        commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
+            guard let self, let vm = self.playerVM else { return .commandFailed }
+            vm.togglePlayPause()
+            return .success
+        }
+
+        // Next track
+        commandCenter.nextTrackCommand.isEnabled = true
+        commandCenter.nextTrackCommand.addTarget { [weak self] _ in
+            guard let self, let vm = self.playerVM else { return .commandFailed }
+            vm.playNext()
+            return .success
+        }
+
+        // Previous track
+        commandCenter.previousTrackCommand.isEnabled = true
+        commandCenter.previousTrackCommand.addTarget { [weak self] _ in
+            guard let self, let vm = self.playerVM else { return .commandFailed }
+            vm.playPrevious()
+            return .success
+        }
+
+        // Seek (for scrubbing via Touch Bar or control center)
+        commandCenter.changePlaybackPositionCommand.isEnabled = true
+        commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
+            guard let self, let vm = self.playerVM,
+                  let posEvent = event as? MPChangePlaybackPositionCommandEvent else {
+                return .commandFailed
+            }
+            vm.seek(to: posEvent.positionTime)
+            return .success
+        }
+
+        // Spacebar → play/pause (when no text field is focused)
+        keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
+            // Only handle bare spacebar (keyCode 49), no modifiers
+            guard event.keyCode == 49,
+                  event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [] else {
+                return event
+            }
+            // Don't intercept if a text field / search field is the first responder
+            if let responder = NSApp.keyWindow?.firstResponder,
+               responder is NSTextView || responder is NSTextField {
+                return event
+            }
+            self?.playerVM?.togglePlayPause()
+            return nil // consume the event
+        }
+
+        // Double-click on playlist row → play that track (like foobar2000)
+        doubleClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
+            guard event.clickCount == 2 else { return event }
+            // Post notification — PlaylistEntryList will handle it
+            NotificationCenter.default.post(name: .doubleClickPlayTrack, object: nil)
+            return event
+        }
+    }
+
+    /// Update the Now Playing info with current track metadata.
+    func updateNowPlaying(track: Track?, isPlaying: Bool, currentTime: TimeInterval, duration: TimeInterval) {
+        guard let track else {
+            infoCenter.nowPlayingInfo = nil
+            return
+        }
+
+        var info = [String: Any]()
+        info[MPMediaItemPropertyTitle] = track.title
+        info[MPMediaItemPropertyArtist] = track.artist
+        info[MPMediaItemPropertyAlbumTitle] = track.album
+        info[MPMediaItemPropertyPlaybackDuration] = duration
+        info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
+        info[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
+
+        if let bpm = track.bpm {
+            info[MPMediaItemPropertyBeatsPerMinute] = Int(bpm)
+        }
+
+        infoCenter.nowPlayingInfo = info
+        infoCenter.playbackState = isPlaying ? .playing : .paused
+    }
+}

+ 222 - 0
Sources/Services/MetadataService.swift

@@ -0,0 +1,222 @@
+import AVFoundation
+import Foundation
+
+/// Reads and writes audio file metadata (ID3 tags, etc.) using AVFoundation.
+struct MetadataService {
+
+    /// Metadata extracted from an audio file.
+    struct AudioMetadata {
+        var title: String
+        var artist: String
+        var album: String
+        var genre: String
+        var year: Int?
+        var duration: TimeInterval
+        var sampleRate: Double
+        var bitDepth: Int
+        var channels: Int
+        var fileFormat: String
+        var fileSizeBytes: Int64
+    }
+
+    // MARK: - Read Metadata
+
+    /// Read metadata from an audio file URL.
+    static func readMetadata(from url: URL) async throws -> AudioMetadata {
+        let asset = AVURLAsset(url: url)
+
+        // Load metadata asynchronously
+        let metadata = try await asset.load(.metadata)
+        let duration = try await asset.load(.duration)
+
+        var title = url.deletingPathExtension().lastPathComponent
+        var artist = ""
+        var album = ""
+        var genre = ""
+        var year: Int?
+
+        for item in metadata {
+            guard let key = item.commonKey else { continue }
+            switch key {
+            case .commonKeyTitle:
+                if let val = try? await item.load(.stringValue) { title = val }
+            case .commonKeyArtist:
+                if let val = try? await item.load(.stringValue) { artist = val }
+            case .commonKeyAlbumName:
+                if let val = try? await item.load(.stringValue) { album = val }
+            case .commonKeyType:
+                if let val = try? await item.load(.stringValue) { genre = val }
+            case .commonKeyCreationDate:
+                if let val = try? await item.load(.stringValue),
+                   let y = Int(val.prefix(4)), y > 1900 && y < 2100 {
+                    year = y
+                }
+            default:
+                break
+            }
+        }
+
+        // Also check format-specific date tags if year not found via common keys.
+        // ID3v2: TDRC, TYER; iTunes/M4A: ©day; Vorbis/FLAC: DATE
+        if year == nil {
+            for item in metadata {
+                if let val = try? await item.load(.stringValue),
+                   let y = Int(val.prefix(4)),
+                   y > 1900 && y < 2100 {
+                    if let identifier = item.identifier {
+                        let idStr = identifier.rawValue.uppercased()
+                        if idStr.contains("TDRC") || idStr.contains("TYER") || idStr.contains("DATE")
+                            || idStr.contains("YEAR") || idStr.contains("©DAY") || idStr.contains("DAY") {
+                            year = y
+                            break
+                        }
+                    }
+                    if let key = item.key as? String {
+                        let keyUpper = key.uppercased()
+                        if keyUpper.contains("DATE") || keyUpper.contains("YEAR")
+                            || keyUpper.contains("TDRC") || keyUpper.contains("TYER")
+                            || keyUpper.contains("©DAY") || keyUpper == "DAY" {
+                            year = y
+                            break
+                        }
+                    }
+                }
+            }
+        }
+
+        // Final fallback: scan all metadata string values for a pure date
+        if year == nil {
+            for item in metadata {
+                if let val = try? await item.load(.stringValue),
+                   val.count >= 4 && val.count <= 10,
+                   let y = Int(val.prefix(4)),
+                   y > 1900 && y < 2100 {
+                    let trimmed = val.trimmingCharacters(in: .whitespaces)
+                    if trimmed.range(of: #"^\d{4}(-\d{2}(-\d{2})?)?$"#, options: .regularExpression) != nil {
+                        year = y
+                        break
+                    }
+                }
+            }
+        }
+
+        // Get audio format details
+        let sampleRate: Double
+        let channels: Int
+        let bitDepth: Int
+
+        if OGGDecoder.isOGGFile(url) {
+            // OGG files can't be read by AVAudioFile — use OGGDecoder
+            if let info = OGGDecoder.fileInfo(url: url) {
+                sampleRate = info.sampleRate
+                channels = info.channels
+            } else {
+                sampleRate = 44100
+                channels = 2
+            }
+            bitDepth = 16  // OGG Vorbis is variable bitrate, report as 16-bit equivalent
+        } else {
+            let audioFile = try AVAudioFile(forReading: url)
+            let format = audioFile.processingFormat
+            sampleRate = format.sampleRate
+            channels = Int(format.channelCount)
+            switch format.commonFormat {
+            case .pcmFormatFloat32: bitDepth = 32
+            case .pcmFormatFloat64: bitDepth = 64
+            case .pcmFormatInt16: bitDepth = 16
+            case .pcmFormatInt32: bitDepth = 32
+            default: bitDepth = 16
+            }
+        }
+
+        let fileFormat = url.pathExtension.uppercased()
+        let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
+
+        return AudioMetadata(
+            title: title,
+            artist: artist,
+            album: album,
+            genre: genre,
+            year: year,
+            duration: CMTimeGetSeconds(duration),
+            sampleRate: sampleRate,
+            bitDepth: bitDepth,
+            channels: channels,
+            fileFormat: fileFormat,
+            fileSizeBytes: fileSize
+        )
+    }
+
+    // MARK: - Supported Formats
+
+    /// Read only the year/date from an audio file's metadata tags.
+    /// Lightweight — does not open AVAudioFile, so it won't fail on format issues.
+    static func readYear(from url: URL) async throws -> Int? {
+        let asset = AVURLAsset(url: url)
+        let metadata = try await asset.load(.metadata)
+        var year: Int?
+
+        // 1. Common key: creationDate
+        for item in metadata {
+            if item.commonKey == .commonKeyCreationDate,
+               let val = try? await item.load(.stringValue),
+               let y = Int(val.prefix(4)), y > 1900 && y < 2100 {
+                year = y
+                break
+            }
+        }
+
+        // 2. Format-specific date tags
+        if year == nil {
+            for item in metadata {
+                if let val = try? await item.load(.stringValue),
+                   let y = Int(val.prefix(4)), y > 1900 && y < 2100 {
+                    if let identifier = item.identifier {
+                        let idStr = identifier.rawValue.uppercased()
+                        if idStr.contains("TDRC") || idStr.contains("TYER") || idStr.contains("DATE")
+                            || idStr.contains("YEAR") || idStr.contains("\u{00A9}DAY") || idStr.contains("DAY") {
+                            year = y
+                            break
+                        }
+                    }
+                    if let key = item.key as? String {
+                        let keyUpper = key.uppercased()
+                        if keyUpper.contains("DATE") || keyUpper.contains("YEAR")
+                            || keyUpper.contains("TDRC") || keyUpper.contains("TYER")
+                            || keyUpper.contains("\u{00A9}DAY") || keyUpper == "DAY" {
+                            year = y
+                            break
+                        }
+                    }
+                }
+            }
+        }
+
+        // 3. Fallback: scan for pure date values
+        if year == nil {
+            for item in metadata {
+                if let val = try? await item.load(.stringValue),
+                   val.count >= 4 && val.count <= 10,
+                   let y = Int(val.prefix(4)), y > 1900 && y < 2100 {
+                    let trimmed = val.trimmingCharacters(in: .whitespaces)
+                    if trimmed.range(of: #"^\d{4}(-\d{2}(-\d{2})?)?$"#, options: .regularExpression) != nil {
+                        year = y
+                        break
+                    }
+                }
+            }
+        }
+
+        return year
+    }
+
+    /// File extensions supported for import.
+    static let supportedExtensions: Set<String> = [
+        "mp3", "wav", "aif", "aiff", "flac", "m4a", "aac", "ogg", "wma", "alac", "caf"
+    ]
+
+    /// Check if a file URL is a supported audio format.
+    static func isSupportedAudioFile(_ url: URL) -> Bool {
+        supportedExtensions.contains(url.pathExtension.lowercased())
+    }
+}

+ 159 - 0
Sources/Services/OGGDecoder.swift

@@ -0,0 +1,159 @@
+import AVFoundation
+import Foundation
+
+/// Decodes OGG Vorbis files to AVAudioPCMBuffer using stb_vorbis.
+/// This enables playback of .ogg files through AVAudioEngine.
+struct OGGDecoder {
+
+    enum OGGError: LocalizedError {
+        case failedToOpen(String)
+        case failedToCreateBuffer
+        case decodingFailed
+
+        var errorDescription: String? {
+            switch self {
+            case .failedToOpen(let path): return "Failed to open OGG file: \(path)"
+            case .failedToCreateBuffer: return "Failed to create audio buffer"
+            case .decodingFailed: return "OGG decoding failed"
+            }
+        }
+    }
+
+    /// Decode an OGG file to an AVAudioPCMBuffer ready for AVAudioPlayerNode.
+    static func decode(url: URL) throws -> (buffer: AVAudioPCMBuffer, format: AVAudioFormat) {
+        // Open the OGG file
+        var error: Int32 = 0
+        guard let vorbis = stb_vorbis_open_filename(url.path, &error, nil) else {
+            throw OGGError.failedToOpen(url.lastPathComponent)
+        }
+        defer { stb_vorbis_close(vorbis) }
+
+        // Get file info
+        let info = stb_vorbis_get_info(vorbis)
+        let channels = Int(info.channels)
+        let sampleRate = Double(info.sample_rate)
+        let totalSamples = Int(stb_vorbis_stream_length_in_samples(vorbis))
+
+        // Create the output format (non-interleaved float for AVAudioPlayerNode)
+        guard let format = AVAudioFormat(
+            commonFormat: .pcmFormatFloat32,
+            sampleRate: sampleRate,
+            channels: AVAudioChannelCount(channels),
+            interleaved: false
+        ) else {
+            throw OGGError.failedToCreateBuffer
+        }
+
+        guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(totalSamples)) else {
+            throw OGGError.failedToCreateBuffer
+        }
+
+        // Decode all samples — stb_vorbis gives interleaved floats,
+        // so we decode to a temp buffer then deinterleave
+        var tempInterleaved = [Float](repeating: 0, count: totalSamples * channels)
+        let decoded = stb_vorbis_get_samples_float_interleaved(
+            vorbis,
+            Int32(channels),
+            &tempInterleaved,
+            Int32(totalSamples * channels)
+        )
+
+        if decoded <= 0 {
+            throw OGGError.decodingFailed
+        }
+
+        // Deinterleave into the PCM buffer's channel pointers
+        let frameCount = Int(decoded)
+        for frame in 0..<frameCount {
+            for ch in 0..<channels {
+                buffer.floatChannelData![ch][frame] = tempInterleaved[frame * channels + ch]
+            }
+        }
+
+        buffer.frameLength = AVAudioFrameCount(decoded)
+        return (buffer, format)
+    }
+
+    /// Get the duration of an OGG file in seconds.
+    static func duration(url: URL) -> TimeInterval {
+        var error: Int32 = 0
+        guard let vorbis = stb_vorbis_open_filename(url.path, &error, nil) else { return 0 }
+        defer { stb_vorbis_close(vorbis) }
+
+        let totalSamples = stb_vorbis_stream_length_in_samples(vorbis)
+        let info = stb_vorbis_get_info(vorbis)
+        guard info.sample_rate > 0 else { return 0 }
+
+        return Double(totalSamples) / Double(info.sample_rate)
+    }
+
+    /// Get basic info about an OGG file.
+    static func fileInfo(url: URL) -> (sampleRate: Double, channels: Int, duration: TimeInterval)? {
+        var error: Int32 = 0
+        guard let vorbis = stb_vorbis_open_filename(url.path, &error, nil) else { return nil }
+        defer { stb_vorbis_close(vorbis) }
+
+        let info = stb_vorbis_get_info(vorbis)
+        let totalSamples = stb_vorbis_stream_length_in_samples(vorbis)
+        let duration = info.sample_rate > 0 ? Double(totalSamples) / Double(info.sample_rate) : 0
+
+        return (
+            sampleRate: Double(info.sample_rate),
+            channels: Int(info.channels),
+            duration: duration
+        )
+    }
+
+    /// Check if a file is an OGG Vorbis file.
+    static func isOGGFile(_ url: URL) -> Bool {
+        url.pathExtension.lowercased() == "ogg"
+    }
+
+    /// Decode an OGG file to mono float samples (for waveform/BPM/key analysis).
+    static func readMonoSamples(url: URL, maxSeconds: Double? = nil) throws -> (samples: [Float], sampleRate: Double) {
+        var error: Int32 = 0
+        guard let vorbis = stb_vorbis_open_filename(url.path, &error, nil) else {
+            throw OGGError.failedToOpen(url.lastPathComponent)
+        }
+        defer { stb_vorbis_close(vorbis) }
+
+        let info = stb_vorbis_get_info(vorbis)
+        let channels = Int(info.channels)
+        let sampleRate = Double(info.sample_rate)
+        var totalSamples = Int(stb_vorbis_stream_length_in_samples(vorbis))
+
+        if let maxSec = maxSeconds {
+            totalSamples = min(totalSamples, Int(maxSec * sampleRate))
+        }
+
+        // Decode interleaved
+        var tempInterleaved = [Float](repeating: 0, count: totalSamples * channels)
+        let decoded = stb_vorbis_get_samples_float_interleaved(
+            vorbis,
+            Int32(channels),
+            &tempInterleaved,
+            Int32(totalSamples * channels)
+        )
+
+        guard decoded > 0 else { throw OGGError.decodingFailed }
+
+        let frameCount = Int(decoded)
+
+        // Mix to mono
+        if channels == 1 {
+            return (Array(tempInterleaved.prefix(frameCount)), sampleRate)
+        }
+
+        var mono = [Float](repeating: 0, count: frameCount)
+        let divisor = Float(channels)
+        for frame in 0..<frameCount {
+            var sum: Float = 0
+            for ch in 0..<channels {
+                sum += tempInterleaved[frame * channels + ch]
+            }
+            mono[frame] = sum / divisor
+        }
+
+        return (mono, sampleRate)
+    }
+}

+ 166 - 0
Sources/Services/StreamingPlayer.swift

@@ -0,0 +1,166 @@
+import AVFoundation
+import Foundation
+import Observation
+
+/// AVPlayer-based streaming player for cloud tracks.
+/// Separate from AudioEngine (which uses AVAudioEngine for local files with EQ/BPM).
+/// In Phase 3, cached cloud tracks will switch to AudioEngine for EQ support.
+@MainActor
+@Observable
+final class StreamingPlayer {
+    // MARK: - State
+
+    var isPlaying = false
+    var currentTime: TimeInterval = 0
+    var duration: TimeInterval = 0
+    var isBuffering = false
+
+    /// The cloud track currently loaded (nil = nothing loaded).
+    var currentCloudTrack: ChadTrack?
+
+    // MARK: - Callbacks
+
+    @ObservationIgnored var onPlaybackFinished: (() -> Void)?
+    @ObservationIgnored var onPlaybackError: ((String) -> Void)?
+
+    // MARK: - Private
+
+    @ObservationIgnored private var player: AVPlayer?
+    @ObservationIgnored private var timeObserver: Any?
+    @ObservationIgnored private var statusObservation: NSKeyValueObservation?
+    @ObservationIgnored private var bufferObservation: NSKeyValueObservation?
+    @ObservationIgnored private var didEndObserver: NSObjectProtocol?
+
+    // MARK: - Load & Play
+
+    func loadAndPlay(track: ChadTrack, streamURL: URL, authHeaders: [String: String]) {
+        cleanup()
+
+        let asset = AVURLAsset(url: streamURL, options: [
+            "AVURLAssetHTTPHeaderFieldsKey": authHeaders
+        ])
+        let playerItem = AVPlayerItem(asset: asset)
+        let avPlayer = AVPlayer(playerItem: playerItem)
+
+        self.player = avPlayer
+        self.currentCloudTrack = track
+        self.isBuffering = true
+
+        // Observe status to know when ready to play
+        statusObservation = playerItem.observe(\.status, options: [.new]) { [weak self] item, _ in
+            Task { @MainActor in
+                guard let self else { return }
+                switch item.status {
+                case .readyToPlay:
+                    self.isBuffering = false
+                    let cmDuration = item.duration
+                    if cmDuration.isNumeric {
+                        self.duration = CMTimeGetSeconds(cmDuration)
+                    } else if let trackDuration = track.duration, trackDuration > 0 {
+                        self.duration = trackDuration
+                    } else {
+                        self.duration = 1.0  // safe fallback to avoid division by zero
+                    }
+                    avPlayer.play()
+                    self.isPlaying = true
+                case .failed:
+                    self.isBuffering = false
+                    let msg = item.error?.localizedDescription ?? "unknown error"
+                    print("StreamingPlayer: Playback failed: \(msg)")
+                    self.onPlaybackError?(msg)
+                default:
+                    break
+                }
+            }
+        }
+
+        // Observe buffering state
+        bufferObservation = playerItem.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] _, change in
+            Task { @MainActor in
+                self?.isBuffering = change.newValue ?? false
+            }
+        }
+
+        // Periodic time updates (~30fps to match AudioEngine sync rate)
+        let interval = CMTime(seconds: 1.0 / 30.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
+        timeObserver = avPlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
+            Task { @MainActor in
+                guard let self, self.player != nil else { return }
+                self.currentTime = CMTimeGetSeconds(time)
+            }
+        }
+
+        // End-of-track notification
+        didEndObserver = NotificationCenter.default.addObserver(
+            forName: .AVPlayerItemDidPlayToEndTime,
+            object: playerItem,
+            queue: .main
+        ) { [weak self] _ in
+            Task { @MainActor in
+                guard let self else { return }
+                self.isPlaying = false
+                self.currentTime = self.duration
+                self.onPlaybackFinished?()
+            }
+        }
+    }
+
+    // MARK: - Transport
+
+    func play() {
+        player?.play()
+        isPlaying = true
+    }
+
+    func pause() {
+        player?.pause()
+        isPlaying = false
+    }
+
+    func togglePlayPause() {
+        if isPlaying { pause() } else { play() }
+    }
+
+    func stop() {
+        cleanup()
+        isPlaying = false
+        currentTime = 0
+        duration = 0
+        currentCloudTrack = nil
+    }
+
+    func seek(to time: TimeInterval) {
+        let cmTime = CMTime(seconds: time, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
+        player?.seek(to: cmTime, toleranceBefore: .zero, toleranceAfter: .zero)
+        currentTime = time
+    }
+
+    func seek(by delta: TimeInterval) {
+        let newTime = max(0, min(currentTime + delta, duration))
+        seek(to: newTime)
+    }
+
+    var volume: Float {
+        get { player?.volume ?? 0.8 }
+        set { player?.volume = newValue }
+    }
+
+    // MARK: - Cleanup
+
+    private func cleanup() {
+        if let observer = timeObserver, let player {
+            player.removeTimeObserver(observer)
+        }
+        timeObserver = nil
+        statusObservation?.invalidate()
+        statusObservation = nil
+        bufferObservation?.invalidate()
+        bufferObservation = nil
+        if let observer = didEndObserver {
+            NotificationCenter.default.removeObserver(observer)
+        }
+        didEndObserver = nil
+        player?.pause()
+        player = nil
+    }
+}

+ 148 - 0
Sources/Services/SyncImporter.swift

@@ -0,0 +1,148 @@
+import Foundation
+import SwiftData
+
+/// Imports playlist data from the iOS MixBoard app's sync JSON file.
+/// Matches tracks by filename across devices.
+struct SyncImporter {
+
+    // MARK: - Sync Data Models (must match iOS export format)
+
+    struct SyncPayload: Codable {
+        let version: Int
+        let exportedAt: Date
+        let exportedFrom: String
+        let playlists: [SyncPlaylist]
+    }
+
+    struct SyncPlaylist: Codable {
+        let id: UUID
+        let name: String
+        let notes: String
+        let color: String
+        let dateCreated: Date
+        let dateModified: Date
+        let targetBPM: Double?
+        let entries: [SyncEntry]
+    }
+
+    struct SyncEntry: Codable {
+        let id: UUID
+        let position: Int
+        let filename: String
+        let title: String
+        let artist: String
+        let album: String
+        let duration: TimeInterval
+        let bpm: Double?
+        let musicalKey: String?
+        let crossfadeDuration: TimeInterval
+        let startOffset: TimeInterval
+        let endOffset: TimeInterval
+        let gainAdjustment: Double
+        let notes: String
+    }
+
+    // MARK: - Import
+
+    /// Import playlists from a sync JSON file. Returns a summary.
+    @MainActor
+    static func importFromFile(
+        _ url: URL,
+        context: ModelContext
+    ) throws -> ImportResult {
+        let data = try Data(contentsOf: url)
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .iso8601
+        let payload = try decoder.decode(SyncPayload.self, from: data)
+
+        // Fetch all existing tracks for matching
+        let trackDescriptor = FetchDescriptor<Track>()
+        let allTracks = (try? context.fetch(trackDescriptor)) ?? []
+
+        // Build a filename → Track lookup
+        var tracksByFilename: [String: Track] = [:]
+        for track in allTracks {
+            let filename = (track.filePath as NSString).lastPathComponent
+            tracksByFilename[filename] = track
+        }
+
+        var created = 0
+        var matched = 0
+        var unmatched = 0
+        var unmatchedFiles: [String] = []
+
+        for sp in payload.playlists {
+            // Create the playlist with "[iOS]" prefix to distinguish
+            let playlist = Playlist(name: "📱 \(sp.name)", notes: sp.notes, color: sp.color)
+            playlist.targetBPM = sp.targetBPM
+            context.insert(playlist)
+            created += 1
+
+            for entry in sp.entries.sorted(by: { $0.position < $1.position }) {
+                if let track = tracksByFilename[entry.filename] {
+                    // Found matching track
+                    let pe = PlaylistEntry(
+                        position: entry.position,
+                        track: track,
+                        crossfadeDuration: entry.crossfadeDuration,
+                        startOffset: entry.startOffset,
+                        endOffset: entry.endOffset,
+                        gainAdjustment: entry.gainAdjustment,
+                        notes: entry.notes
+                    )
+                    playlist.entries.append(pe)
+                    matched += 1
+                } else {
+                    // No matching track — create placeholder entry with info in notes
+                    let info = "\(entry.artist) — \(entry.title) [\(entry.filename)]"
+                    let pe = PlaylistEntry(
+                        position: entry.position,
+                        track: nil,
+                        crossfadeDuration: entry.crossfadeDuration,
+                        notes: "⚠ Not found: \(info)\n\(entry.notes)"
+                    )
+                    playlist.entries.append(pe)
+                    unmatched += 1
+                    unmatchedFiles.append(entry.filename)
+                }
+            }
+        }
+
+        try context.save()
+
+        return ImportResult(
+            playlistsCreated: created,
+            tracksMatched: matched,
+            tracksUnmatched: unmatched,
+            unmatchedFiles: unmatchedFiles,
+            exportedFrom: payload.exportedFrom,
+            exportedAt: payload.exportedAt
+        )
+    }
+
+    struct ImportResult {
+        let playlistsCreated: Int
+        let tracksMatched: Int
+        let tracksUnmatched: Int
+        let unmatchedFiles: [String]
+        let exportedFrom: String
+        let exportedAt: Date
+
+        var summary: String {
+            var lines = [
+                "Imported \(playlistsCreated) playlist(s) from \(exportedFrom)",
+                "  \(tracksMatched) tracks matched",
+            ]
+            if tracksUnmatched > 0 {
+                lines.append("  \(tracksUnmatched) tracks not found locally:")
+                for file in unmatchedFiles.prefix(10) {
+                    lines.append("    • \(file)")
+                }
+                if unmatchedFiles.count > 10 {
+                    lines.append("    ... and \(unmatchedFiles.count - 10) more")
+                }
+            }
+            return lines.joined(separator: "\n")
+        }
+    }
+}

+ 124 - 0
Sources/Services/SyncWatcher.swift

@@ -0,0 +1,124 @@
+import AppKit
+import Foundation
+import SwiftData
+import UniformTypeIdentifiers
+
+/// Watches for sync files from the iOS MixBoard app.
+/// Checks two locations:
+///   1. A "MixBoard Sync" folder on the Desktop (for manual AirDrop/copy)
+///   2. The iOS app's iCloud Drive container (if iCloud Drive sync is enabled)
+@MainActor
+final class SyncWatcher: ObservableObject {
+    @Published var lastImportDate: Date?
+    @Published var lastImportResult: String?
+    @Published var hasPendingSync = false
+    @Published var pendingSyncURL: URL?
+
+    private var fileMonitor: DispatchSourceFileSystemObject?
+    private var timer: Timer?
+    private var lastKnownModDate: Date?
+
+    /// All paths to check for sync files.
+    var watchPaths: [URL] {
+        var paths: [URL] = []
+
+        // 1. Desktop/MixBoard Sync/ (for manual drops)
+        let desktop = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first!
+        paths.append(desktop.appendingPathComponent("MixBoard Sync/mixboard-playlists.json"))
+
+        // 2. Documents/MixBoard Sync/
+        let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
+        paths.append(docs.appendingPathComponent("MixBoard Sync/mixboard-playlists.json"))
+
+        // 3. iCloud Drive MixBoard app container
+        // ~/Library/Mobile Documents/iCloud~com~mixboard~MixBoardiOS/Documents/Sync/mixboard-playlists.json
+        let home = FileManager.default.homeDirectoryForCurrentUser
+        let iCloudPath = home
+            .appendingPathComponent("Library/Mobile Documents")
+            .appendingPathComponent("com~apple~CloudDocs")  // generic iCloud Drive
+            .appendingPathComponent("MixBoard/mixboard-playlists.json")
+        paths.append(iCloudPath)
+
+        return paths
+    }
+
+    /// Start polling for sync files (every 10 seconds).
+    func startWatching() {
+        timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
+            Task { @MainActor in
+                self?.checkForSyncFile()
+            }
+        }
+        // Check immediately
+        checkForSyncFile()
+    }
+
+    func stopWatching() {
+        timer?.invalidate()
+        timer = nil
+    }
+
+    /// Check all watch paths for a sync file.
+    func checkForSyncFile() {
+        let fm = FileManager.default
+
+        for url in watchPaths {
+            guard fm.fileExists(atPath: url.path) else { continue }
+
+            // Check if file is newer than last import
+            if let attrs = try? fm.attributesOfItem(atPath: url.path),
+               let modDate = attrs[.modificationDate] as? Date {
+                if let lastKnown = lastKnownModDate, modDate <= lastKnown {
+                    continue // Already processed this version
+                }
+                lastKnownModDate = modDate
+                hasPendingSync = true
+                pendingSyncURL = url
+                return
+            }
+        }
+    }
+
+    /// Import from a specific sync file.
+    func importSyncFile(_ url: URL, context: ModelContext) {
+        do {
+            let result = try SyncImporter.importFromFile(url, context: context)
+            lastImportDate = Date()
+            lastImportResult = result.summary
+            hasPendingSync = false
+            pendingSyncURL = nil
+            print("SyncWatcher: \(result.summary)")
+        } catch {
+            lastImportResult = "Import failed: \(error.localizedDescription)"
+            print("SyncWatcher: Import failed: \(error)")
+        }
+    }
+
+    /// Import from the pending sync file.
+    func importPending(context: ModelContext) {
+        guard let url = pendingSyncURL else { return }
+        importSyncFile(url, context: context)
+    }
+
+    /// Show an open panel to pick a sync file manually.
+    func importManually(context: ModelContext) {
+        let panel = NSOpenPanel()
+        panel.title = "Import Playlists from iPhone"
+        panel.allowedContentTypes = [.json]
+        panel.allowsMultipleSelection = false
+        panel.canChooseDirectories = false
+        panel.message = "Select the mixboard-playlists.json file exported from MixBoard iOS"
+
+        if panel.runModal() == .OK, let url = panel.url {
+            importSyncFile(url, context: context)
+        }
+    }
+
+    /// Create the sync drop folders so users know where to put the file.
+    func createSyncFolders() {
+        let fm = FileManager.default
+        let desktop = fm.urls(for: .desktopDirectory, in: .userDomainMask).first!
+        let syncFolder = desktop.appendingPathComponent("MixBoard Sync")
+        try? fm.createDirectory(at: syncFolder, withIntermediateDirectories: true)
+    }
+}

+ 159 - 0
Sources/Services/WaveformGenerator.swift

@@ -0,0 +1,159 @@
+import Accelerate
+import AVFoundation
+import Foundation
+
+/// Generates waveform visualization data from audio files.
+/// Produces downsampled min/max pairs suitable for drawing waveforms.
+struct WaveformGenerator {
+
+    /// A single column of waveform data (min and max sample values).
+    struct WaveformSample: Codable {
+        let min: Float
+        let max: Float
+    }
+
+    /// Default number of samples (columns) for the waveform display.
+    static let defaultResolution = 800
+
+    // MARK: - Public API
+
+    /// Generate waveform data for a track and cache it.
+    @MainActor
+    static func generateWaveform(for track: Track, resolution: Int = defaultResolution) async throws -> [WaveformSample] {
+        guard track.hasLocalFile else {
+            throw WaveformError.fileNotFound
+        }
+        let samples = try await generateWaveform(fileURL: track.fileURL, resolution: resolution)
+
+        // Cache on main actor (SwiftData model)
+        let encoded = try JSONEncoder().encode(samples)
+        track.waveformData = encoded
+
+        return samples
+    }
+
+    /// Generate waveform data from a file URL.
+    static func generateWaveform(fileURL: URL, resolution: Int = defaultResolution) async throws -> [WaveformSample] {
+        try await Task.detached(priority: .userInitiated) {
+            if OGGDecoder.isOGGFile(fileURL) {
+                let (mono, _) = try OGGDecoder.readMonoSamples(url: fileURL)
+                guard !mono.isEmpty else { return [WaveformSample]() }
+                return downsample(mono, to: resolution)
+            }
+
+            let audioFile = try AVAudioFile(forReading: fileURL)
+            let totalFrames = audioFile.length
+
+            guard totalFrames > 0 else { return [WaveformSample]() }
+
+            let mono = try readMonoSamples(from: audioFile)
+            guard !mono.isEmpty else { return [WaveformSample]() }
+
+            return downsample(mono, to: resolution)
+        }.value
+    }
+
+    /// Decode cached waveform data.
+    static func decodeCachedWaveform(from data: Data) -> [WaveformSample]? {
+        try? JSONDecoder().decode([WaveformSample].self, from: data)
+    }
+
+    // MARK: - Audio Reading
+
+    private static func readMonoSamples(from audioFile: AVAudioFile) throws -> [Float] {
+        let processingFormat = audioFile.processingFormat
+        let channelCount = Int(processingFormat.channelCount)
+        let totalFrames = audioFile.length
+
+        // Read in chunks using the file's processingFormat (auto-decompresses to PCM Float32)
+        let chunkSize: AVAudioFrameCount = 65536
+        var allSamples = [Float]()
+        allSamples.reserveCapacity(Int(totalFrames))
+
+        audioFile.framePosition = 0
+
+        guard let buffer = AVAudioPCMBuffer(pcmFormat: processingFormat, frameCapacity: chunkSize) else {
+            throw WaveformError.formatError
+        }
+
+        while audioFile.framePosition < totalFrames {
+            let remaining = AVAudioFrameCount(totalFrames - audioFile.framePosition)
+            let framesToRead = min(chunkSize, remaining)
+            try audioFile.read(into: buffer, frameCount: framesToRead)
+
+            guard let channelData = buffer.floatChannelData, buffer.frameLength > 0 else { break }
+
+            let frameCount = Int(buffer.frameLength)
+
+            if channelCount == 1 {
+                let ptr = UnsafeBufferPointer(start: channelData[0], count: frameCount)
+                allSamples.append(contentsOf: ptr)
+            } else {
+                // Mix down to mono by averaging channels
+                var mono = [Float](repeating: 0, count: frameCount)
+                for ch in 0..<channelCount {
+                    let chPtr = channelData[ch]
+                    for i in 0..<frameCount {
+                        mono[i] += chPtr[i]
+                    }
+                }
+                let divisor = Float(channelCount)
+                for i in 0..<frameCount {
+                    mono[i] /= divisor
+                }
+                allSamples.append(contentsOf: mono)
+            }
+        }
+
+        return allSamples
+    }
+
+    // MARK: - Downsampling
+
+    private static func downsample(_ samples: [Float], to resolution: Int) -> [WaveformSample] {
+        let count = samples.count
+        guard count > 0, resolution > 0 else { return [] }
+
+        var result = [WaveformSample]()
+        result.reserveCapacity(resolution)
+
+        for i in 0..<resolution {
+            let start = i * count / resolution
+            let end = min((i + 1) * count / resolution, count)
+            let length = vDSP_Length(end - start)
+
+            guard length > 0 else {
+                result.append(WaveformSample(min: 0, max: 0))
+                continue
+            }
+
+            var minVal: Float = 0
+            var maxVal: Float = 0
+
+            samples.withUnsafeBufferPointer { buf in
+                vDSP_minv(buf.baseAddress!.advanced(by: start), 1, &minVal, length)
+                vDSP_maxv(buf.baseAddress!.advanced(by: start), 1, &maxVal, length)
+            }
+
+            result.append(WaveformSample(min: minVal, max: maxVal))
+        }
+
+        return result
+    }
+}
+
+// MARK: - Errors
+
+enum WaveformError: Error, LocalizedError {
+    case fileNotFound
+    case formatError
+    case noAudioData
+
+    var errorDescription: String? {
+        switch self {
+        case .fileNotFound: return "Audio file not found (cloud tracks don't support waveforms)"
+        case .formatError: return "Unable to read audio format for waveform generation"
+        case .noAudioData: return "No audio data found for waveform generation"
+        }
+    }
+}

+ 432 - 0
Sources/ViewModels/PlayerViewModel.swift

@@ -0,0 +1,432 @@
+import Foundation
+import SwiftUI
+
+/// ViewModel wrapping the AudioEngine with additional UI state.
+@MainActor
+@Observable
+final class PlayerViewModel {
+    let audioEngine = AudioEngine()
+    let streamingPlayer = StreamingPlayer()
+
+    /// True when playing a cloud track via StreamingPlayer (vs local via AudioEngine).
+    var isCloudPlayback = false
+
+    /// The cloud track info when streaming (non-SwiftData, transient).
+    var currentCloudTrack: ChadTrack?
+
+    /// True when the streaming player is buffering.
+    var isBuffering: Bool { streamingPlayer.isBuffering }
+
+    // MARK: - UI State
+
+    var showingWaveform = true
+    var waveformSamples: [WaveformGenerator.WaveformSample] = []
+    var isLoadingWaveform = false
+
+    /// ID of the currently playing playlist entry (if playing from a playlist).
+    var currentPlayingEntryID: UUID?
+
+    /// The playlist currently being played through.
+    var currentPlaylist: Playlist?
+
+    /// The currently selected (cursor) entry ID — updated by PlaylistEntryList.
+    var cursorEntryID: UUID?
+
+    /// Shuffle mode.
+    var shuffleEnabled: Bool = false
+
+    /// Repeat mode.
+    enum RepeatMode: String, CaseIterable {
+        case off = "Off"
+        case all = "Repeat All"
+        case one = "Repeat One"
+    }
+    var repeatMode: RepeatMode = .off
+
+    // MARK: - Synced State (updated from AudioEngine)
+
+    var isPlaying: Bool = false
+    var currentTime: TimeInterval = 0
+    var duration: TimeInterval = 0
+    var currentTrack: Track?
+
+    var volume: Float {
+        get { isCloudPlayback ? streamingPlayer.volume : audioEngine.volume }
+        set {
+            audioEngine.volume = newValue
+            streamingPlayer.volume = newValue
+        }
+    }
+
+    var progress: Double {
+        guard duration > 0 else { return 0 }
+        return currentTime / duration
+    }
+
+    var currentTimeFormatted: String {
+        formatTime(currentTime)
+    }
+
+    var durationFormatted: String {
+        formatTime(duration)
+    }
+
+    var remainingTimeFormatted: String {
+        "-" + formatTime(duration - currentTime)
+    }
+
+    @ObservationIgnored private var syncTimer: Timer?
+    @ObservationIgnored private var stateSaveCounter = 0
+    @ObservationIgnored private var nowPlayingCounter = 0
+
+    init() {
+        startSyncTimer()
+        audioEngine.onPlaybackFinished = { [weak self] in
+            self?.playNext()
+        }
+        streamingPlayer.onPlaybackFinished = { [weak self] in
+            self?.playNext()
+        }
+        streamingPlayer.onPlaybackError = { [weak self] msg in
+            print("PlayerViewModel: Stream error: \(msg)")
+            self?.stop()
+        }
+    }
+
+    /// Periodically sync state from AudioEngine to trigger SwiftUI updates.
+    private func startSyncTimer() {
+        syncTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
+            Task { @MainActor in
+                self?.syncFromEngine()
+            }
+        }
+    }
+
+    private func syncFromEngine() {
+        if isCloudPlayback {
+            // Sync from StreamingPlayer
+            let sp = streamingPlayer
+            if isPlaying != sp.isPlaying { isPlaying = sp.isPlaying }
+            if abs(currentTime - sp.currentTime) > 0.01 { currentTime = sp.currentTime }
+            if duration != sp.duration { duration = sp.duration }
+        } else {
+            // Sync from AudioEngine
+            let engine = audioEngine
+            engine.updateCurrentTime()
+            if isPlaying != engine.isPlaying { isPlaying = engine.isPlaying }
+            if abs(currentTime - engine.currentTime) > 0.01 { currentTime = engine.currentTime }
+            if duration != engine.duration { duration = engine.duration }
+            if currentTrack !== engine.currentTrack { currentTrack = engine.currentTrack }
+        }
+
+        // Save state every ~2 seconds (every 60 sync ticks at 30fps)
+        stateSaveCounter += 1
+        if stateSaveCounter >= 60 {
+            stateSaveCounter = 0
+            savePlaybackState()
+        }
+
+        // Update Now Playing info every ~1 second
+        nowPlayingCounter += 1
+        if nowPlayingCounter >= 30 {
+            nowPlayingCounter = 0
+            updateNowPlaying()
+        }
+    }
+
+    /// Persist current playback state to UserDefaults.
+    private func savePlaybackState() {
+        AppState.savePlaybackState(
+            playlistID: currentPlaylist?.id,
+            entryID: currentPlayingEntryID,
+            trackFilePath: currentTrack?.filePath,
+            playbackTime: currentTime
+        )
+    }
+
+    /// Update macOS Now Playing info center (media keys, control center widget).
+    private func updateNowPlaying() {
+        MediaKeyHandler.shared.updateNowPlaying(
+            track: currentTrack,
+            isPlaying: isPlaying,
+            currentTime: currentTime,
+            duration: duration
+        )
+    }
+
+    // MARK: - Track Loading & Playback
+
+    func loadAndPlay(_ track: Track, entryID: UUID? = nil, playlist: Playlist? = nil) {
+        // Cloud track — route to StreamingPlayer
+        if track.isCloud, let streamPath = track.cloudStreamPath {
+            let client = ChadMusicAPIClient.shared
+            guard let url = client.streamURL(for: streamPath) else {
+                print("PlayerViewModel: Failed to build stream URL for cloud track")
+                return
+            }
+            // Stop local playback
+            audioEngine.stop()
+            waveformSamples = []
+
+            isCloudPlayback = true
+            currentCloudTrack = nil // no ChadTrack object — using Track directly
+            currentTrack = track
+            currentPlayingEntryID = entryID
+            if let playlist { currentPlaylist = playlist }
+            streamingPlayer.loadAndPlay(
+                track: ChadTrack(
+                    id: track.cloudTrackId ?? "",
+                    title: track.title ?? "—",
+                    artist: track.artist,
+                    albumArtist: nil,
+                    album: track.album,
+                    duration: track.duration,
+                    no: nil,
+                    url: streamPath,
+                    bitRate: nil,
+                    year: track.year,
+                    cover: nil
+                ),
+                streamURL: url,
+                authHeaders: client.authHeaders
+            )
+            syncFromEngine()
+            return
+        }
+
+        // Local track — use AudioEngine
+        if isCloudPlayback {
+            streamingPlayer.stop()
+            isCloudPlayback = false
+            currentCloudTrack = nil
+        }
+        do {
+            try audioEngine.loadTrack(track)
+            audioEngine.play()
+            currentPlayingEntryID = entryID
+            if let playlist { currentPlaylist = playlist }
+            syncFromEngine()
+            savePlaybackState()
+            loadWaveform(for: track)
+        } catch {
+            print("PlayerViewModel: Failed to load track: \(error)")
+        }
+    }
+
+    /// Play a cloud track via StreamingPlayer.
+    func loadAndPlayCloud(_ track: ChadTrack, streamURL: URL, authHeaders: [String: String]) {
+        // Stop local playback first
+        audioEngine.stop()
+        currentTrack = nil
+        currentPlayingEntryID = nil
+        currentPlaylist = nil
+        waveformSamples = []
+
+        isCloudPlayback = true
+        currentCloudTrack = track
+        streamingPlayer.loadAndPlay(track: track, streamURL: streamURL, authHeaders: authHeaders)
+        syncFromEngine()
+    }
+
+    /// Display title — works for both local and cloud tracks.
+    var displayTitle: String {
+        if isCloudPlayback, let ct = currentCloudTrack {
+            return ct.title
+        }
+        return currentTrack?.title ?? "—"
+    }
+
+    /// Display artist — works for both local and cloud tracks.
+    var displayArtist: String {
+        if isCloudPlayback, let ct = currentCloudTrack {
+            return ct.artist ?? ""
+        }
+        return currentTrack?.artist ?? ""
+    }
+
+    func togglePlayPause() {
+        if isCloudPlayback {
+            streamingPlayer.togglePlayPause()
+        } else {
+            audioEngine.togglePlayPause()
+        }
+        syncFromEngine()
+    }
+
+    func stop() {
+        if isCloudPlayback {
+            streamingPlayer.stop()
+            isCloudPlayback = false
+            currentCloudTrack = nil
+        } else {
+            audioEngine.stop()
+        }
+        waveformSamples = []
+        currentPlayingEntryID = nil
+        syncFromEngine()
+    }
+
+    // MARK: - Playlist Navigation
+
+    /// Advance to the next track in the current playlist.
+    func playNext() {
+        guard let playlist = currentPlaylist,
+              let currentID = currentPlayingEntryID else { return }
+
+        let entries = playlist.sortedEntries
+
+        // "Playback follows cursor": play the cursor track if it's different from current
+        if PlaylistViewConfig.shared.playbackFollowsCursor,
+           let cursorID = cursorEntryID,
+           cursorID != currentID,
+           let cursorEntry = entries.first(where: { $0.id == cursorID }),
+           let track = cursorEntry.track {
+            loadAndPlay(track, entryID: cursorEntry.id, playlist: playlist)
+            // Don't move cursor — user put it there intentionally
+            return
+        }
+
+        guard let currentIndex = entries.firstIndex(where: { $0.id == currentID }) else { return }
+
+        // Repeat One: replay same track
+        if repeatMode == .one, let track = entries[currentIndex].track {
+            loadAndPlay(track, entryID: entries[currentIndex].id)
+            cursorEntryID = entries[currentIndex].id
+            return
+        }
+
+        // Shuffle: pick a random different track
+        if shuffleEnabled && entries.count > 1 {
+            var randomIndex = currentIndex
+            while randomIndex == currentIndex {
+                randomIndex = Int.random(in: 0..<entries.count)
+            }
+            if let track = entries[randomIndex].track {
+                loadAndPlay(track, entryID: entries[randomIndex].id)
+                cursorEntryID = entries[randomIndex].id
+            }
+            return
+        }
+
+        // Normal sequential
+        let nextIndex = currentIndex + 1
+        if nextIndex < entries.count, let nextTrack = entries[nextIndex].track {
+            loadAndPlay(nextTrack, entryID: entries[nextIndex].id)
+            cursorEntryID = entries[nextIndex].id
+        } else if repeatMode == .all, let firstTrack = entries.first?.track {
+            // Wrap around
+            loadAndPlay(firstTrack, entryID: entries[0].id)
+            cursorEntryID = entries[0].id
+        } else {
+            // End of playlist
+            stop()
+        }
+    }
+
+    /// Go back to the previous track in the current playlist.
+    func playPrevious() {
+        guard let playlist = currentPlaylist,
+              let currentID = currentPlayingEntryID else { return }
+
+        let entries = playlist.sortedEntries
+
+        // "Playback follows cursor": play the cursor track if it's different from current
+        if PlaylistViewConfig.shared.playbackFollowsCursor,
+           let cursorID = cursorEntryID,
+           cursorID != currentID,
+           let cursorEntry = entries.first(where: { $0.id == cursorID }),
+           let track = cursorEntry.track {
+            loadAndPlay(track, entryID: cursorEntry.id, playlist: playlist)
+            return
+        }
+
+        guard let currentIndex = entries.firstIndex(where: { $0.id == currentID }) else { return }
+
+        // If more than 3 seconds in, restart current track; otherwise go to previous
+        if currentTime > 3, let track = currentTrack {
+            seek(to: 0)
+            return
+        }
+
+        let prevIndex = currentIndex - 1
+        guard prevIndex >= 0,
+              let prevTrack = entries[prevIndex].track else { return }
+
+        loadAndPlay(prevTrack, entryID: entries[prevIndex].id)
+    }
+
+    func seek(to time: TimeInterval) {
+        if isCloudPlayback {
+            streamingPlayer.seek(to: time)
+        } else {
+            audioEngine.seek(to: time)
+        }
+    }
+
+    func seekToProgress(_ progress: Double) {
+        let time = progress * duration
+        seek(to: time)
+    }
+
+    func skipForward(_ seconds: TimeInterval = 10) {
+        if isCloudPlayback {
+            streamingPlayer.seek(by: seconds)
+        } else {
+            audioEngine.seek(by: seconds)
+        }
+    }
+
+    func skipBackward(_ seconds: TimeInterval = 10) {
+        if isCloudPlayback {
+            streamingPlayer.seek(by: -seconds)
+        } else {
+            audioEngine.seek(by: -seconds)
+        }
+    }
+
+    // MARK: - EQ
+
+    func setLowEQ(_ gain: Float) {
+        audioEngine.setEQ(band: 0, gain: gain)
+    }
+
+    func setMidEQ(_ gain: Float) {
+        audioEngine.setEQ(band: 1, gain: gain)
+    }
+
+    func setHighEQ(_ gain: Float) {
+        audioEngine.setEQ(band: 2, gain: gain)
+    }
+
+    // MARK: - Waveform
+
+    func loadWaveform(for track: Track) {
+        // Check cache first
+        if let cached = track.waveformData,
+           let decoded = WaveformGenerator.decodeCachedWaveform(from: cached) {
+            waveformSamples = decoded
+            return
+        }
+
+        isLoadingWaveform = true
+        Task {
+            do {
+                let samples = try await WaveformGenerator.generateWaveform(for: track)
+                waveformSamples = samples
+            } catch {
+                print("PlayerViewModel: Waveform generation failed: \(error)")
+                waveformSamples = []
+            }
+            isLoadingWaveform = false
+        }
+    }
+
+    // MARK: - Helpers
+
+    private func formatTime(_ time: TimeInterval) -> String {
+        let total = max(0, Int(time))
+        let minutes = total / 60
+        let seconds = total % 60
+        return String(format: "%d:%02d", minutes, seconds)
+    }
+}

+ 277 - 0
Sources/ViewModels/PlaylistViewModel.swift

@@ -0,0 +1,277 @@
+import Foundation
+import SwiftData
+import SwiftUI
+
+/// ViewModel for managing playlists and building mixes.
+@MainActor
+@Observable
+final class PlaylistViewModel {
+    var selectedPlaylist: Playlist?
+    var isExporting = false
+    var exportFormat: MixExporter.ExportFormat = .audition
+    var showExportSheet = false
+    var exportError: String?
+
+    /// Temporary status message shown on the main screen (auto-clears).
+    var statusMessage: String?
+
+    /// Three mix target slots — configurable in Settings.
+    var mixTargets: [Playlist?] = [nil, nil, nil]
+
+    /// Legacy single target (uses slot 0).
+    var targetPlaylist: Playlist? {
+        get { mixTargets[0] }
+        set { setMixTarget(0, playlist: newValue) }
+    }
+
+    /// Set a mix target at the given slot (0, 1, 2).
+    func setMixTarget(_ slot: Int, playlist: Playlist?) {
+        guard slot >= 0 && slot < 3 else { return }
+        mixTargets[slot] = playlist
+        if let id = playlist?.id {
+            UserDefaults.standard.set(id.uuidString, forKey: "mixTarget\(slot)ID")
+        } else {
+            UserDefaults.standard.removeObject(forKey: "mixTarget\(slot)ID")
+        }
+    }
+
+    /// Get the display name for a mix slot.
+    func mixTargetName(_ slot: Int) -> String {
+        guard slot >= 0 && slot < 3 else { return "Mix \(slot + 1)" }
+        return mixTargets[slot]?.name ?? "Mix \(slot + 1)"
+    }
+
+    /// Restore all mix target playlists from saved IDs.
+    func restoreTargetPlaylist(from playlists: [Playlist]) {
+        for slot in 0..<3 {
+            guard mixTargets[slot] == nil,
+                  let savedID = UserDefaults.standard.string(forKey: "mixTarget\(slot)ID"),
+                  let uuid = UUID(uuidString: savedID),
+                  let playlist = playlists.first(where: { $0.id == uuid }) else { continue }
+            mixTargets[slot] = playlist
+        }
+        // Legacy migration: if old single targetPlaylistID exists, use it for slot 0
+        if mixTargets[0] == nil,
+           let legacyID = UserDefaults.standard.string(forKey: "targetPlaylistID"),
+           let uuid = UUID(uuidString: legacyID),
+           let playlist = playlists.first(where: { $0.id == uuid }) {
+            mixTargets[0] = playlist
+            UserDefaults.standard.set(legacyID, forKey: "mixTarget0ID")
+            UserDefaults.standard.removeObject(forKey: "targetPlaylistID")
+        }
+    }
+
+    /// Default crossfade duration for new entries.
+    var defaultCrossfadeDuration: TimeInterval = 2.0
+
+    /// Show a temporary status message that auto-clears after a delay.
+    func showStatus(_ message: String, duration: TimeInterval = 4.0) {
+        statusMessage = message
+        Task { @MainActor in
+            try? await Task.sleep(for: .seconds(duration))
+            if self.statusMessage == message {
+                self.statusMessage = nil
+            }
+        }
+    }
+
+    // MARK: - Quick Add
+
+    /// Add a track to a specific mix slot (0, 1, 2).
+    func quickAddToMix(slot: Int, track: Track, context: ModelContext) -> Bool {
+        guard slot >= 0 && slot < 3 else { return false }
+        guard let target = mixTargets[slot] else {
+            showStatus("Mix \(slot + 1) not set — go to Settings → Mix Targets")
+            return false
+        }
+
+        if isDuplicate(track: track, in: target) {
+            showStatus("⚠ \"\(track.title)\" is already in \(target.name)")
+            return false
+        }
+
+        addTrack(track, to: target, context: context)
+        showStatus("→ \(target.name)")
+        return true
+    }
+
+    /// Legacy: add to slot 0 (⌘D).
+    func quickAddToTarget(track: Track, context: ModelContext) -> Bool {
+        return quickAddToMix(slot: 0, track: track, context: context)
+    }
+
+    /// Check if a track already exists in a playlist (by file path or cloud track ID).
+    func isDuplicate(track: Track, in playlist: Playlist) -> Bool {
+        if track.isCloud, let cloudId = track.cloudTrackId {
+            return playlist.entries.contains { $0.track?.cloudTrackId == cloudId }
+        }
+        return playlist.entries.contains { $0.track?.filePath == track.filePath }
+    }
+
+    // MARK: - Playlist CRUD
+
+    func createPlaylist(name: String, context: ModelContext) -> Playlist {
+        let playlist = Playlist(name: name)
+        context.insert(playlist)
+        try? context.save()
+        return playlist
+    }
+
+    func deletePlaylist(_ playlist: Playlist, context: ModelContext) {
+        if selectedPlaylist?.id == playlist.id {
+            selectedPlaylist = nil
+        }
+        context.delete(playlist)
+        try? context.save()
+    }
+
+    func renamePlaylist(_ playlist: Playlist, to name: String, context: ModelContext) {
+        playlist.name = name
+        playlist.dateModified = Date()
+        try? context.save()
+    }
+
+    // MARK: - Track Management in Playlist
+
+    func addTrack(_ track: Track, to playlist: Playlist, context: ModelContext, warnDuplicate: Bool = true) {
+        if warnDuplicate && isDuplicate(track: track, in: playlist) {
+            showStatus("⚠ \"\(track.title)\" is already in \(playlist.name)")
+            return
+        }
+        playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration)
+        try? context.save()
+    }
+
+    func addTracks(_ tracks: [Track], to playlist: Playlist, context: ModelContext) {
+        // Sort by filename for consistent ordering (especially when adding folders)
+        let sorted = tracks.sorted { $0.fileURL.lastPathComponent.localizedCaseInsensitiveCompare($1.fileURL.lastPathComponent) == .orderedAscending }
+        var added = 0
+        var skipped = 0
+        for track in sorted {
+            if isDuplicate(track: track, in: playlist) {
+                skipped += 1
+            } else {
+                playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration)
+                added += 1
+            }
+        }
+        try? context.save()
+        if skipped > 0 {
+            showStatus("Added \(added) tracks, \(skipped) duplicates skipped")
+        }
+    }
+
+    func removeEntry(_ entry: PlaylistEntry, from playlist: Playlist, context: ModelContext) {
+        playlist.removeEntry(at: entry.position)
+        try? context.save()
+    }
+
+    func moveEntry(in playlist: Playlist, from source: Int, to destination: Int, context: ModelContext) {
+        playlist.moveEntry(from: source, to: destination)
+        try? context.save()
+    }
+
+    func updateCrossfade(for entry: PlaylistEntry, duration: TimeInterval, context: ModelContext) {
+        entry.crossfadeDuration = duration
+        try? context.save()
+    }
+
+    func updateGain(for entry: PlaylistEntry, gain: Double, context: ModelContext) {
+        entry.gainAdjustment = gain
+        try? context.save()
+    }
+
+    func updateStartOffset(for entry: PlaylistEntry, offset: TimeInterval, context: ModelContext) {
+        entry.startOffset = offset
+        try? context.save()
+    }
+
+    func updateEndOffset(for entry: PlaylistEntry, offset: TimeInterval, context: ModelContext) {
+        entry.endOffset = offset
+        try? context.save()
+    }
+
+    // MARK: - Cue Points
+
+    /// Import audio files from disk directly into a playlist (imports to library + adds to playlist).
+    func importFilesToPlaylist(
+        urls: [URL],
+        playlist: Playlist,
+        libraryManager: LibraryManager,
+        context: ModelContext
+    ) async {
+        for url in urls where MetadataService.isSupportedAudioFile(url) {
+            do {
+                try await libraryManager.importFile(url, context: context)
+            } catch {
+                print("PlaylistViewModel: import failed for \(url.lastPathComponent): \(error)")
+            }
+
+            // Find the track that was just imported (or already existed)
+            let path = url.path
+            let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.filePath == path })
+            if let track = try? context.fetch(descriptor).first {
+                playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration)
+
+                // Auto-analyze BPM & key if not already done
+                if !track.isAnalyzed {
+                    await libraryManager.analyzeTrack(track)
+                }
+            }
+        }
+        try? context.save()
+    }
+
+    func addCuePoint(to track: Track, at timestamp: TimeInterval, type: CuePointType = .marker, name: String = "", context: ModelContext) {
+        let cuePoint = CuePoint(name: name, timestamp: timestamp, type: type)
+        track.cuePoints.append(cuePoint)
+        try? context.save()
+    }
+
+    func removeCuePoint(_ cuePoint: CuePoint, from track: Track, context: ModelContext) {
+        track.cuePoints.removeAll { $0.id == cuePoint.id }
+        context.delete(cuePoint)
+        try? context.save()
+    }
+
+    // MARK: - Export
+
+    func exportPlaylist(_ playlist: Playlist, format: MixExporter.ExportFormat, to url: URL) {
+        isExporting = true
+        exportError = nil
+
+        do {
+            var options = ExportOptions.default
+            options.copyAudioFiles = true
+            options.includeCuePoints = true
+            options.includeCrossfades = true
+
+            try MixExporter.export(
+                playlist: playlist,
+                format: format,
+                to: url,
+                options: options
+            )
+
+            isExporting = false
+        } catch {
+            exportError = error.localizedDescription
+            isExporting = false
+        }
+    }
+
+    /// Calculate the total effective mix duration including crossfades.
+    func mixDuration(for playlist: Playlist) -> TimeInterval {
+        let entries = playlist.sortedEntries
+        var total: TimeInterval = 0
+
+        for (index, entry) in entries.enumerated() {
+            total += entry.effectiveDuration
+            if index > 0 {
+                total -= entry.crossfadeDuration
+            }
+        }
+
+        return max(0, total)
+    }
+}

+ 36 - 0
Sources/Views/ArtworkView.swift

@@ -0,0 +1,36 @@
+import SwiftUI
+
+/// Displays album artwork for a track, loading from folder or embedded metadata.
+struct ArtworkView: View {
+    let track: Track
+    let size: CGFloat
+
+    @State private var image: NSImage?
+    @State private var isLoading = true
+
+    var body: some View {
+        Group {
+            if let image {
+                Image(nsImage: image)
+                    .resizable()
+                    .aspectRatio(contentMode: .fill)
+                    .frame(width: size, height: size)
+                    .clipShape(RoundedRectangle(cornerRadius: 4))
+            } else {
+                ZStack {
+                    RoundedRectangle(cornerRadius: 4)
+                        .fill(.quaternary)
+                    Image(systemName: "music.note")
+                        .font(.system(size: size * 0.4))
+                        .foregroundStyle(.tertiary)
+                }
+                .frame(width: size, height: size)
+            }
+        }
+        .task(id: track.filePath) {
+            isLoading = true
+            image = await ArtworkService.shared.artwork(for: track)
+            isLoading = false
+        }
+    }
+}

+ 643 - 0
Sources/Views/CloudBrowserView.swift

@@ -0,0 +1,643 @@
+import SwiftData
+import SwiftUI
+
+/// Cloud library browser — navigate categories → albums → tracks from Chad Music server.
+struct CloudBrowserView: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+    @State private var apiClient = ChadMusicAPIClient.shared
+
+    var body: some View {
+        if !apiClient.isConfigured {
+            CloudNotConfiguredView()
+        } else {
+            CategoryListView(apiClient: apiClient)
+        }
+    }
+}
+
+// MARK: - Not Configured Prompt
+
+private struct CloudNotConfiguredView: View {
+    var body: some View {
+        VStack(spacing: 16) {
+            Spacer()
+            Image(systemName: "cloud.fill")
+                .font(.system(size: 48))
+                .foregroundStyle(.tertiary)
+            Text("Chad Music Not Configured")
+                .font(.title3)
+                .foregroundStyle(.secondary)
+            Text("Set your server URL and API key in Settings → Chad Music.")
+                .font(.callout)
+                .foregroundStyle(.tertiary)
+                .multilineTextAlignment(.center)
+            Spacer()
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+    }
+}
+
+// MARK: - Category List
+
+private struct CategoryListView: View {
+    let apiClient: ChadMusicAPIClient
+
+    /// Show albums and artists by default — the most useful categories.
+    private let defaultCategories: [ChadCategoryType] = [.album, .artist, .genre, .year]
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 0) {
+            // Header with stats
+            CloudHeaderView(apiClient: apiClient)
+
+            List {
+                Section("Browse") {
+                    ForEach(defaultCategories) { category in
+                        NavigationLink(value: category) {
+                            Label(category.displayName, systemImage: category.icon)
+                        }
+                    }
+                }
+
+                Section("More") {
+                    ForEach(ChadCategoryType.allCases.filter { !defaultCategories.contains($0) }) { category in
+                        NavigationLink(value: category) {
+                            Label(category.displayName, systemImage: category.icon)
+                        }
+                    }
+                }
+            }
+            .listStyle(.sidebar)
+            .navigationDestination(for: ChadCategoryType.self) { category in
+                CategoryDetailView(apiClient: apiClient, category: category)
+            }
+            .navigationDestination(for: ChadAlbum.self) { album in
+                AlbumDetailView(apiClient: apiClient, album: album)
+            }
+            .navigationDestination(for: CategoryFilter.self) { filter in
+                FilteredAlbumsView(apiClient: apiClient, filter: filter)
+            }
+        }
+    }
+}
+
+// MARK: - Cloud Header (stats bar)
+
+private struct CloudHeaderView: View {
+    let apiClient: ChadMusicAPIClient
+    @State private var stats: ChadStats?
+    @State private var statsError = false
+
+    var body: some View {
+        HStack(spacing: 8) {
+            Image(systemName: "cloud.fill")
+                .foregroundStyle(.secondary)
+            if statsError {
+                Text("Could not load stats")
+                    .font(.caption)
+                    .foregroundStyle(.tertiary)
+            } else if let stats {
+                let parts = [
+                    stats.tracks.map { "\($0) tracks" },
+                    stats.albums.map { "\($0) albums" },
+                    stats.artists.map { "\($0) artists" },
+                ].compactMap { $0 }
+                Text(parts.joined(separator: " · "))
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+            } else {
+                Text("Loading...")
+                    .font(.caption)
+                    .foregroundStyle(.tertiary)
+            }
+            Spacer()
+        }
+        .padding(.horizontal, 16)
+        .padding(.vertical, 8)
+        .background(.bar)
+        .task {
+            do {
+                stats = try await apiClient.fetchStats()
+            } catch {
+                statsError = true
+            }
+        }
+    }
+}
+
+// MARK: - Filtered Albums View (artist/genre/year → albums)
+
+private struct FilteredAlbumsView: View {
+    let apiClient: ChadMusicAPIClient
+    let filter: CategoryFilter
+
+    @State private var albums: [ChadAlbum] = []
+    @State private var isLoading = true
+    @State private var error: String?
+
+    @Environment(\.modelContext) private var modelContext
+    @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
+
+    var body: some View {
+        Group {
+            if isLoading {
+                ProgressView("Loading albums...")
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+            } else if let error {
+                VStack(spacing: 8) {
+                    Image(systemName: "exclamationmark.triangle")
+                        .font(.title)
+                        .foregroundStyle(.secondary)
+                    Text(error)
+                        .foregroundStyle(.secondary)
+                    Button("Retry") { loadAlbums() }
+                }
+                .frame(maxWidth: .infinity, maxHeight: .infinity)
+            } else if albums.isEmpty {
+                Text("No albums found")
+                    .foregroundStyle(.secondary)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+            } else {
+                List {
+                    // Header — draggable to add all albums by this artist/genre/etc.
+                    HStack {
+                        VStack(alignment: .leading, spacing: 2) {
+                            Text(filter.value)
+                                .font(.title2.bold())
+                            Text("\(albums.count) albums")
+                                .font(.caption)
+                                .foregroundStyle(.tertiary)
+                        }
+                        Spacer()
+                        Menu {
+                            ForEach(allPlaylists) { playlist in
+                                Button(playlist.name) {
+                                    addAllAlbumsToPlaylist(playlist: playlist)
+                                }
+                            }
+                        } label: {
+                            Label("Add All", systemImage: "plus.circle")
+                                .font(.caption)
+                        }
+                        .menuStyle(.borderlessButton)
+                        .fixedSize()
+                    }
+                    .listRowSeparator(.hidden)
+                    .padding(.vertical, 4)
+                    .contextMenu {
+                        Menu("Add All to Playlist") {
+                            ForEach(allPlaylists) { playlist in
+                                Button(playlist.name) {
+                                    addAllAlbumsToPlaylist(playlist: playlist)
+                                }
+                            }
+                        }
+                    }
+
+                    // Album rows
+                    ForEach(albums) { album in
+                    NavigationLink(value: album) {
+                        HStack {
+                            VStack(alignment: .leading, spacing: 2) {
+                                Text(album.title)
+                                    .lineLimit(1)
+                                if let artist = album.artist {
+                                    Text(artist)
+                                        .font(.caption)
+                                        .foregroundStyle(.secondary)
+                                        .lineLimit(1)
+                                }
+                            }
+                            Spacer()
+                            if let count = album.trackCount {
+                                Text("\(count)")
+                                    .font(.caption)
+                                    .foregroundStyle(.secondary)
+                            }
+                        }
+                    }
+                    .contextMenu {
+                        Menu("Add Album to Playlist") {
+                            ForEach(allPlaylists) { playlist in
+                                Button(playlist.name) {
+                                    addAlbumToPlaylist(album, playlist: playlist)
+                                }
+                            }
+                        }
+                    }
+                    .draggable(album)
+                    }
+                }
+                .listStyle(.inset)
+            }
+        }
+        .navigationTitle(filter.value)
+        .task { loadAlbums() }
+    }
+
+    private func loadAlbums() {
+        isLoading = true
+        error = nil
+        Task {
+            do {
+                albums = try await apiClient.fetchAlbums(filteredBy: filter.category.rawValue, value: filter.value)
+            } catch {
+                self.error = error.localizedDescription
+            }
+            isLoading = false
+        }
+    }
+
+    private func addAlbumToPlaylist(_ album: ChadAlbum, playlist: Playlist) {
+        Task.detached {
+            guard let tracks = try? await apiClient.fetchAlbumTracks(albumId: album.id) else { return }
+            await MainActor.run {
+                let descriptor = FetchDescriptor<Track>(predicate: #Predicate<Track> { $0.isCloud == true })
+                let existing = (try? modelContext.fetch(descriptor)) ?? []
+                let existingById = Dictionary(uniqueKeysWithValues: existing.compactMap { t in
+                    t.cloudTrackId.map { ($0, t) }
+                })
+                for chadTrack in tracks {
+                    let track = existingById[chadTrack.id] ?? {
+                        let t = Track.fromCloud(chadTrack)
+                        modelContext.insert(t)
+                        return t
+                    }()
+                    playlist.addTrack(track)
+                }
+            }
+        }
+    }
+
+    private func addAllAlbumsToPlaylist(playlist: Playlist) {
+        for album in albums {
+            addAlbumToPlaylist(album, playlist: playlist)
+        }
+    }
+}
+
+// MARK: - Category Detail (list of albums/artists/etc.)
+
+private struct CategoryDetailView: View {
+    let apiClient: ChadMusicAPIClient
+    let category: ChadCategoryType
+
+    @State private var items: [ChadCategory] = []
+    @State private var albums: [ChadAlbum] = []
+    @State private var isLoading = true
+    @State private var error: String?
+    @State private var bulkAddingAlbum: String?  // album ID being bulk-added
+
+    @Environment(\.modelContext) private var modelContext
+    @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
+
+    /// Album category returns [ChadAlbum], all others return [ChadCategory].
+    private var isAlbumCategory: Bool { category == .album }
+
+    var body: some View {
+        Group {
+            if isLoading {
+                ProgressView("Loading \(category.displayName)...")
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+            } else if let error {
+                VStack(spacing: 8) {
+                    Image(systemName: "exclamationmark.triangle")
+                        .font(.title)
+                        .foregroundStyle(.secondary)
+                    Text(error)
+                        .foregroundStyle(.secondary)
+                    Button("Retry") { loadItems() }
+                }
+                .frame(maxWidth: .infinity, maxHeight: .infinity)
+            } else if isAlbumCategory {
+                List(albums) { album in
+                    NavigationLink(value: album) {
+                        HStack {
+                            VStack(alignment: .leading, spacing: 2) {
+                                Text(album.title)
+                                    .lineLimit(1)
+                                if let artist = album.artist {
+                                    Text(artist)
+                                        .font(.caption)
+                                        .foregroundStyle(.secondary)
+                                        .lineLimit(1)
+                                }
+                            }
+                            Spacer()
+                            if bulkAddingAlbum == album.id {
+                                ProgressView()
+                                    .controlSize(.small)
+                            } else if let count = album.trackCount {
+                                Text("\(count)")
+                                    .font(.caption)
+                                    .foregroundStyle(.secondary)
+                            }
+                        }
+                    }
+                    .contextMenu {
+                        Menu("Add Album to Playlist") {
+                            ForEach(allPlaylists) { playlist in
+                                Button(playlist.name) {
+                                    addAlbumToPlaylist(album, playlist: playlist)
+                                }
+                            }
+                        }
+                    }
+                    .draggable(album)
+                }
+                .listStyle(.inset)
+            } else {
+                List(items) { item in
+                    NavigationLink(value: CategoryFilter(category: category, value: item.name)) {
+                        categoryRow(item)
+                    }
+                }
+                .listStyle(.inset)
+            }
+        }
+        .navigationTitle(category.displayName)
+        .task { loadItems() }
+    }
+
+    private func categoryRow(_ item: ChadCategory) -> some View {
+        HStack {
+            Text(item.name)
+            Spacer()
+            if let count = item.count {
+                Text("\(count)")
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+            }
+        }
+    }
+
+    private func loadItems() {
+        isLoading = true
+        error = nil
+        Task {
+            do {
+                if isAlbumCategory {
+                    albums = try await apiClient.fetchAlbums()
+                } else {
+                    items = try await apiClient.fetchCategory(category)
+                }
+            } catch {
+                self.error = error.localizedDescription
+            }
+            isLoading = false
+        }
+    }
+
+    private func addAlbumToPlaylist(_ album: ChadAlbum, playlist: Playlist) {
+        bulkAddingAlbum = album.id
+        Task.detached {
+            let chadTracks: [ChadTrack]
+            do {
+                chadTracks = try await apiClient.fetchAlbumTracks(albumId: album.id)
+            } catch {
+                print("CloudBrowser: Failed to fetch album tracks: \(error)")
+                await MainActor.run { bulkAddingAlbum = nil }
+                return
+            }
+            await MainActor.run {
+                bulkInsertCloudTracks(chadTracks, into: playlist)
+                bulkAddingAlbum = nil
+            }
+        }
+    }
+
+    private func bulkInsertCloudTracks(_ chadTracks: [ChadTrack], into playlist: Playlist) {
+        // Batch dedup: fetch all existing cloud tracks in one query
+        let ids = chadTracks.map(\.id)
+        let descriptor = FetchDescriptor<Track>(predicate: #Predicate<Track> { track in
+            track.isCloud == true
+        })
+        let existingTracks = (try? modelContext.fetch(descriptor)) ?? []
+        let existingById = Dictionary(uniqueKeysWithValues: existingTracks.compactMap { t in
+            t.cloudTrackId.map { ($0, t) }
+        })
+
+        for chadTrack in chadTracks {
+            let track = existingById[chadTrack.id] ?? {
+                let newTrack = Track.fromCloud(chadTrack)
+                modelContext.insert(newTrack)
+                return newTrack
+            }()
+            playlist.addTrack(track)
+        }
+    }
+}
+
+// MARK: - Album Detail (track list with play buttons)
+
+private struct AlbumDetailView: View {
+    let apiClient: ChadMusicAPIClient
+    let album: ChadAlbum
+
+    @Environment(PlayerViewModel.self) private var playerVM
+    @Environment(\.modelContext) private var modelContext
+    @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
+    @State private var tracks: [ChadTrack] = []
+    @State private var isLoading = true
+    @State private var error: String?
+
+    var body: some View {
+        Group {
+            if isLoading {
+                ProgressView("Loading tracks...")
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+            } else if let error {
+                VStack(spacing: 8) {
+                    Image(systemName: "exclamationmark.triangle")
+                        .font(.title)
+                        .foregroundStyle(.secondary)
+                    Text(error)
+                        .foregroundStyle(.secondary)
+                    Button("Retry") { loadTracks() }
+                }
+                .frame(maxWidth: .infinity, maxHeight: .infinity)
+            } else {
+                List {
+                    // Album header — draggable to add whole album to playlist
+                    VStack(alignment: .leading, spacing: 4) {
+                        Text(album.title)
+                            .font(.title2.bold())
+                        if let artist = album.artist {
+                            Text(artist)
+                                .font(.title3)
+                                .foregroundStyle(.secondary)
+                        }
+                        HStack {
+                            Text("\(tracks.count) tracks")
+                                .font(.caption)
+                                .foregroundStyle(.tertiary)
+                            Spacer()
+                            Menu {
+                                ForEach(allPlaylists) { playlist in
+                                    Button(playlist.name) {
+                                        addAllToPlaylist(playlist: playlist)
+                                    }
+                                }
+                            } label: {
+                                Label("Add All", systemImage: "plus.circle")
+                                    .font(.caption)
+                            }
+                            .menuStyle(.borderlessButton)
+                            .fixedSize()
+                        }
+                    }
+                    .listRowSeparator(.hidden)
+                    .padding(.vertical, 8)
+                    .draggable(album)
+                    .contextMenu {
+                        Menu("Add Album to Playlist") {
+                            ForEach(allPlaylists) { playlist in
+                                Button(playlist.name) {
+                                    addAllToPlaylist(playlist: playlist)
+                                }
+                            }
+                        }
+                    }
+
+                    // Track rows
+                    ForEach(tracks) { track in
+                        CloudTrackRow(
+                            track: track,
+                            isPlaying: playerVM.isCloudPlayback && (
+                                playerVM.currentCloudTrack?.id == track.id ||
+                                playerVM.currentTrack?.cloudTrackId == track.id
+                            )
+                        )
+                        .contentShape(Rectangle())
+                        .onTapGesture {
+                            playCloudTrack(track)
+                        }
+                        .onDrag {
+                            let data = try? JSONEncoder().encode(track)
+                            let provider = NSItemProvider()
+                            if let data {
+                                provider.registerDataRepresentation(
+                                    forTypeIdentifier: "com.mixboard.chad-track",
+                                    visibility: .all
+                                ) { completion in
+                                    completion(data, nil)
+                                    return nil
+                                }
+                            }
+                            return provider
+                        }
+                        .contextMenu {
+                            Button {
+                                playCloudTrack(track)
+                            } label: {
+                                Label("Play", systemImage: "play")
+                            }
+
+                            Divider()
+
+                            Menu("Add to Playlist") {
+                                ForEach(allPlaylists) { playlist in
+                                    Button(playlist.name) {
+                                        addToPlaylist(track, playlist: playlist)
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+                .listStyle(.inset)
+            }
+        }
+        .navigationTitle(album.title)
+        .task { loadTracks() }
+    }
+
+    private func loadTracks() {
+        isLoading = true
+        error = nil
+        Task {
+            do {
+                tracks = try await apiClient.fetchAlbumTracks(albumId: album.id)
+            } catch {
+                self.error = error.localizedDescription
+            }
+            isLoading = false
+        }
+    }
+
+    private func playCloudTrack(_ track: ChadTrack) {
+        guard let url = apiClient.streamURL(for: track.url) else {
+            print("CloudBrowser: Failed to build stream URL for \(track.url)")
+            return
+        }
+        playerVM.loadAndPlayCloud(track, streamURL: url, authHeaders: apiClient.authHeaders)
+    }
+
+    private func addToPlaylist(_ chadTrack: ChadTrack, playlist: Playlist) {
+        let cloudId = chadTrack.id
+        let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.cloudTrackId == cloudId })
+        let existing = try? modelContext.fetch(descriptor).first
+        let track = existing ?? Track.fromCloud(chadTrack)
+        if existing == nil {
+            modelContext.insert(track)
+        }
+        playlist.addTrack(track)
+    }
+
+    private func addAllToPlaylist(playlist: Playlist) {
+        for chadTrack in tracks {
+            addToPlaylist(chadTrack, playlist: playlist)
+        }
+    }
+}
+
+// MARK: - Cloud Track Row
+
+private struct CloudTrackRow: View {
+    let track: ChadTrack
+    let isPlaying: Bool
+
+    var body: some View {
+        HStack(spacing: 12) {
+            // Track number or playing indicator
+            Group {
+                if isPlaying {
+                    Image(systemName: "speaker.wave.2.fill")
+                        .foregroundStyle(Color.accentColor)
+                } else if let num = track.trackNumber {
+                    Text("\(num)")
+                        .foregroundStyle(.secondary)
+                } else {
+                    Text("—")
+                        .foregroundStyle(.tertiary)
+                }
+            }
+            .font(.system(size: 12, design: .monospaced))
+            .frame(width: 28, alignment: .trailing)
+
+            // Title + artist
+            VStack(alignment: .leading, spacing: 1) {
+                Text(track.title)
+                    .font(.system(size: 13))
+                    .foregroundStyle(isPlaying ? Color.accentColor : .primary)
+                    .lineLimit(1)
+                if let artist = track.artist {
+                    Text(artist)
+                        .font(.system(size: 11))
+                        .foregroundStyle(.secondary)
+                        .lineLimit(1)
+                }
+            }
+
+            Spacer()
+
+            // Duration
+            Text(track.formattedDuration)
+                .font(.system(size: 12, design: .monospaced))
+                .foregroundStyle(.secondary)
+                .frame(width: 40, alignment: .trailing)
+        }
+        .padding(.vertical, 2)
+    }
+}

+ 294 - 0
Sources/Views/ContentView.swift

@@ -0,0 +1,294 @@
+import SwiftData
+import SwiftUI
+
+/// Main content view — Sidebar with playlists | Playlist detail | Player.
+struct ContentView: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var libraryManager: LibraryManager
+    @Environment(\.modelContext) private var modelContext
+    @Environment(\.openWindow) private var openWindow
+
+    @State private var selectedPlaylist: Playlist?
+    @State private var showNewPlaylistSheet = false
+    @State private var columnVisibility: NavigationSplitViewVisibility = .all
+    @State private var hasRestoredState = false
+
+    @State private var showGlobalSearch = false
+    @State private var showInlineNowPlaying = false
+    @State private var showCloudBrowser = false
+
+    @Query(sort: \Playlist.dateModified, order: .reverse)
+    private var playlists: [Playlist]
+
+    @Query private var allTracks: [Track]
+
+    var body: some View {
+        NavigationSplitView(columnVisibility: $columnVisibility) {
+            SidebarView(
+                selectedPlaylist: $selectedPlaylist,
+                showNewPlaylistSheet: $showNewPlaylistSheet,
+                showCloudBrowser: $showCloudBrowser
+            )
+            .navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 300)
+        } detail: {
+            VStack(spacing: 0) {
+                // Mix target buttons bar
+                MixTargetBar()
+
+                if showInlineNowPlaying, playerVM.currentTrack != nil {
+                    NowPlayingView(displayMode: .inline)
+                } else if showCloudBrowser {
+                    NavigationStack {
+                        CloudBrowserView()
+                    }
+                } else if let playlist = selectedPlaylist {
+                    PlaylistView(playlist: playlist)
+                } else {
+                    WelcomeView(onNewPlaylist: { showNewPlaylistSheet = true })
+                }
+
+                // Status message toast
+                if let status = playlistVM.statusMessage {
+                    HStack(spacing: 6) {
+                        Image(systemName: "checkmark.circle.fill")
+                            .font(.system(size: 10))
+                            .foregroundStyle(.green)
+                        Text(status)
+                            .font(.system(size: 11))
+                            .foregroundStyle(.secondary)
+                    }
+                    .padding(.horizontal, 12)
+                    .padding(.vertical, 4)
+                    .background(.bar)
+                    .transition(.move(edge: .bottom).combined(with: .opacity))
+                    .animation(.easeInOut(duration: 0.3), value: playlistVM.statusMessage)
+                }
+
+                Divider()
+
+                PlayerView()
+            }
+        }
+        .onAppear {
+            libraryManager.setModelContext(modelContext)
+            playlistVM.restoreTargetPlaylist(from: playlists)
+            restoreLastState()
+        }
+        .task {
+            // One-time migration: backfill year metadata for tracks missing or with invalid year
+            let descriptor = FetchDescriptor<Track>(predicate: #Predicate<Track> { $0.year == nil || $0.year! < 1900 || $0.year! > 2100 })
+            if let needsYear = try? modelContext.fetch(descriptor), !needsYear.isEmpty {
+                print("Backfilling year for \(needsYear.count) tracks...")
+                await libraryManager.rescanAllMetadata(tracks: needsYear)
+                print("Year backfill complete.")
+            }
+        }
+        .onChange(of: playlists) { _, _ in
+            // Retry restore when @Query results load (they may be empty on first onAppear)
+            if !hasRestoredState {
+                restoreLastState()
+            }
+        }
+        .onChange(of: selectedPlaylist) { _, newPlaylist in
+            if let id = newPlaylist?.id {
+                AppState.saveLastPlaylist(id: id)
+                showCloudBrowser = false
+            }
+        }
+        .sheet(isPresented: $showNewPlaylistSheet) {
+            NewPlaylistSheet(selectedPlaylist: $selectedPlaylist)
+        }
+        .sheet(isPresented: $showGlobalSearch) {
+            GlobalSearchSheet(playlists: playlists)
+        }
+        .onReceive(NotificationCenter.default.publisher(for: .newPlaylist)) { _ in
+            showNewPlaylistSheet = true
+        }
+        .onReceive(NotificationCenter.default.publisher(for: .quickAddToTarget)) { notification in
+            if let track = notification.object as? Track {
+                _ = playlistVM.quickAddToTarget(track: track, context: modelContext)
+            }
+        }
+        .onReceive(NotificationCenter.default.publisher(for: .quickAddToMix)) { notification in
+            guard let info = notification.userInfo,
+                  let slot = info["slot"] as? Int,
+                  let track = info["track"] as? Track else { return }
+            _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
+        }
+        .onReceive(NotificationCenter.default.publisher(for: .globalSearch)) { _ in
+            showGlobalSearch = true
+        }
+        .onReceive(NotificationCenter.default.publisher(for: .toggleNowPlaying)) { _ in
+            if playerVM.currentTrack != nil {
+                showInlineNowPlaying.toggle()
+            }
+        }
+        .onReceive(NotificationCenter.default.publisher(for: .popOutNowPlaying)) { _ in
+            showInlineNowPlaying = false
+            openWindow(id: "now-playing")
+        }
+        .onReceive(NotificationCenter.default.publisher(for: .closeInlineNowPlaying)) { _ in
+            showInlineNowPlaying = false
+        }
+    }
+
+    private func restoreLastState() {
+        guard !hasRestoredState else { return }
+        guard !playlists.isEmpty else { return }
+
+        // Restore last selected playlist
+        if let lastPlaylistID = AppState.lastPlaylistID,
+           let lastPlaylist = playlists.first(where: { $0.id == lastPlaylistID }) {
+            selectedPlaylist = lastPlaylist
+            hasRestoredState = true
+
+            // Restore last playing entry in that playlist
+            if let lastEntryID = AppState.lastEntryID,
+               let entry = lastPlaylist.sortedEntries.first(where: { $0.id == lastEntryID }),
+               let track = entry.track {
+                // Load the track paused at the last position
+                do {
+                    try playerVM.audioEngine.loadTrack(track)
+                    playerVM.currentPlayingEntryID = entry.id
+                    playerVM.currentPlaylist = lastPlaylist
+                    let lastTime = AppState.lastPlaybackTime
+                    if lastTime > 0 {
+                        playerVM.audioEngine.seek(to: lastTime)
+                    }
+                    // Sync state without playing
+                    playerVM.audioEngine.updateCurrentTime()
+                    playerVM.duration = playerVM.audioEngine.duration
+                    playerVM.currentTime = playerVM.audioEngine.currentTime
+                    playerVM.currentTrack = playerVM.audioEngine.currentTrack
+                    playerVM.loadWaveform(for: track)
+                } catch {
+                    print("Failed to restore last track: \(error)")
+                }
+            }
+        } else if let first = playlists.first {
+            selectedPlaylist = first
+            hasRestoredState = true
+        }
+    }
+}
+
+// MARK: - Welcome View (no playlist selected)
+
+private struct WelcomeView: View {
+    let onNewPlaylist: () -> Void
+
+    var body: some View {
+        VStack(spacing: 16) {
+            Spacer()
+            Image(systemName: "music.note.house")
+                .font(.system(size: 64))
+                .foregroundStyle(.tertiary)
+            Text("Welcome to MixBoard")
+                .font(.title2)
+                .foregroundStyle(.secondary)
+            Text("Create a playlist to get started")
+                .foregroundStyle(.tertiary)
+            Button("New Playlist") { onNewPlaylist() }
+            Spacer()
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+    }
+}
+
+// MARK: - New Playlist Sheet
+
+struct NewPlaylistSheet: View {
+    @Binding var selectedPlaylist: Playlist?
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @Environment(\.modelContext) private var modelContext
+    @Environment(\.dismiss) private var dismiss
+
+    @State private var playlistName = ""
+
+    var body: some View {
+        VStack(spacing: 20) {
+            Text("New Playlist")
+                .font(.headline)
+
+            TextField("Playlist name", text: $playlistName)
+                .textFieldStyle(.roundedBorder)
+                .frame(width: 300)
+
+            HStack {
+                Button("Cancel") { dismiss() }
+                    .keyboardShortcut(.cancelAction)
+
+                Button("Create") {
+                    guard !playlistName.isEmpty else { return }
+                    let pl = playlistVM.createPlaylist(name: playlistName, context: modelContext)
+                    selectedPlaylist = pl
+                    playlistVM.selectedPlaylist = pl
+                    dismiss()
+                }
+                .keyboardShortcut(.defaultAction)
+                .disabled(playlistName.isEmpty)
+            }
+        }
+        .padding(30)
+    }
+}
+
+// MARK: - Mix Target Buttons Bar
+
+/// Colored numbered buttons at the top of the detail area for quick-adding to mix slots.
+private struct MixTargetBar: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(\.modelContext) private var modelContext
+    @ObservedObject private var shortcutConfig = KeyboardShortcutConfig.shared
+
+    private let mixActions: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
+
+    var body: some View {
+        HStack(spacing: 0) {
+            ForEach(0..<3, id: \.self) { slot in
+                let hasTarget = playlistVM.mixTargets[slot] != nil
+                let color = mixTargetColors[slot]
+
+                Button {
+                    guard let track = playerVM.currentTrack else {
+                        playlistVM.showStatus("No track playing")
+                        return
+                    }
+                    _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
+                } label: {
+                    HStack(spacing: 6) {
+                        Text("\(slot + 1)")
+                            .font(.system(size: 11, weight: .bold, design: .rounded))
+                            .foregroundStyle(hasTarget ? color : theme.tertiaryText)
+
+                        Text(playlistVM.mixTargetName(slot))
+                            .font(.system(size: 11))
+                            .foregroundStyle(hasTarget ? theme.primaryText : theme.tertiaryText)
+                            .lineLimit(1)
+                    }
+                    .padding(.horizontal, 10)
+                    .padding(.vertical, 4)
+                    .frame(maxWidth: .infinity)
+                    .background(hasTarget ? color.opacity(0.08) : Color.clear)
+                    .contentShape(Rectangle())
+                }
+                .buttonStyle(.plain)
+                .help(hasTarget
+                    ? "Add to \(playlistVM.mixTargetName(slot)) (\(shortcutConfig.binding(for: mixActions[slot]).displayString))"
+                    : "Mix \(slot + 1) not set — configure in Settings")
+
+                if slot < 2 {
+                    Divider().frame(height: 20)
+                }
+            }
+        }
+        .frame(height: 28)
+        .background(theme.toolbarBackground.opacity(0.5))
+        .overlay(alignment: .bottom) {
+            Divider()
+        }
+    }
+}

+ 494 - 0
Sources/Views/ExportSheet.swift

@@ -0,0 +1,494 @@
+import SwiftUI
+
+/// Sheet for exporting a playlist — session files or stitched single file.
+struct ExportSheet: View {
+    let playlist: Playlist
+
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @Environment(\.dismiss) private var dismiss
+
+    @State private var selectedTab = 0
+
+    var body: some View {
+        VStack(spacing: 0) {
+            // Header
+            Text("Export \"\(playlist.name)\"")
+                .font(.headline)
+                .padding(.top, 16)
+                .padding(.bottom, 8)
+
+            // Tab picker
+            Picker("Export Mode", selection: $selectedTab) {
+                Text("Session Files").tag(0)
+                Text("Stitch to Single File").tag(1)
+            }
+            .pickerStyle(.segmented)
+            .padding(.horizontal, 20)
+            .padding(.bottom, 8)
+
+            Divider()
+
+            if selectedTab == 0 {
+                SessionExportTab(playlist: playlist, dismiss: dismiss)
+            } else {
+                StitchExportTab(playlist: playlist, dismiss: dismiss)
+            }
+        }
+        .frame(width: 500, height: 580)
+    }
+}
+
+// MARK: - Session Export Tab (existing multi-file export)
+
+private struct SessionExportTab: View {
+    let playlist: Playlist
+    let dismiss: DismissAction
+
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @State private var selectedFormat: MixExporter.ExportFormat = .audition
+    @State private var copyFiles = true
+    @State private var includeCuePoints = true
+    @State private var includeCrossfades = true
+    @State private var renameFiles = false
+    @State private var fileNameTemplate = FileNameTemplate.defaultTemplate
+    @State private var isExporting = false
+    @State private var exportComplete = false
+    @State private var exportError: String?
+
+    var body: some View {
+        VStack(spacing: 0) {
+            ScrollView {
+                VStack(alignment: .leading, spacing: 16) {
+                    Text("Export a session file that references your audio tracks.")
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+
+                    // Format picker
+                    VStack(alignment: .leading, spacing: 6) {
+                        Text("Format")
+                            .font(.subheadline.bold())
+
+                        ForEach(MixExporter.ExportFormat.allCases) { format in
+                            FormatOptionRow(format: format, isSelected: selectedFormat == format)
+                                .onTapGesture { selectedFormat = format }
+                        }
+                    }
+
+                    Divider()
+
+                    // Options
+                    VStack(alignment: .leading, spacing: 8) {
+                        Text("Options")
+                            .font(.subheadline.bold())
+                        Toggle("Copy audio files to export folder", isOn: $copyFiles)
+                            .font(.caption)
+                        Toggle("Include cue points as markers", isOn: $includeCuePoints)
+                            .font(.caption)
+                        Toggle("Include crossfade information", isOn: $includeCrossfades)
+                            .font(.caption)
+                    }
+
+                    // File renaming
+                    if copyFiles {
+                        Divider()
+                        FileNameTemplateEditor(
+                            renameFiles: $renameFiles,
+                            template: $fileNameTemplate
+                        )
+                    }
+                }
+                .padding(16)
+            }
+
+            Divider()
+            statusAndActions(
+                isExporting: isExporting,
+                exportComplete: exportComplete,
+                exportError: exportError,
+                onExport: { showSessionSavePanel() },
+                onCancel: { dismiss() },
+                disabled: playlist.entries.isEmpty
+            )
+        }
+    }
+
+    private func showSessionSavePanel() {
+        let panel = NSSavePanel()
+        panel.title = "Export Session"
+        panel.nameFieldStringValue = "\(playlist.name).\(selectedFormat.fileExtension)"
+        panel.allowedContentTypes = [.data]
+        panel.canCreateDirectories = true
+
+        if panel.runModal() == .OK, let url = panel.url {
+            isExporting = true
+            exportError = nil
+            exportComplete = false
+
+            var options = ExportOptions.default
+            options.copyAudioFiles = copyFiles
+            options.includeCuePoints = includeCuePoints
+            options.includeCrossfades = includeCrossfades
+            options.fileNameTemplate = renameFiles ? fileNameTemplate : nil
+
+            do {
+                try MixExporter.export(playlist: playlist, format: selectedFormat, to: url, options: options)
+                playlistVM.showStatus("Exported \(selectedFormat.name) successfully")
+                dismiss()
+            } catch {
+                exportError = error.localizedDescription
+            }
+            isExporting = false
+        }
+    }
+}
+
+// MARK: - Stitch Export Tab (single combined file)
+
+private struct StitchExportTab: View {
+    let playlist: Playlist
+    let dismiss: DismissAction
+
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @State private var bitDepth = 24
+    @State private var usePlaylistCrossfades = true
+    @State private var gapDuration: Double = 0
+    @State private var generateCueSheet = true
+    @State private var generateAuditionMarkers = true
+    @State private var generateAuditionSession = true
+    @State private var isExporting = false
+    @State private var exportComplete = false
+    @State private var exportError: String?
+    @State private var exportProgress: String = ""
+
+    var body: some View {
+        VStack(spacing: 0) {
+            ScrollView {
+                VStack(alignment: .leading, spacing: 16) {
+                    Text("Combine all tracks into one WAV file with markers at each track boundary. Open in Adobe Audition to fine-tune your mix.")
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+
+                    // Output format
+                    VStack(alignment: .leading, spacing: 8) {
+                        Text("Output")
+                            .font(.subheadline.bold())
+
+                        Picker("Bit Depth", selection: $bitDepth) {
+                            Text("16-bit").tag(16)
+                            Text("24-bit").tag(24)
+                            Text("32-bit").tag(32)
+                        }
+                        .pickerStyle(.segmented)
+                        .frame(width: 240)
+                    }
+
+                    Divider()
+
+                    // Transitions
+                    VStack(alignment: .leading, spacing: 8) {
+                        Text("Transitions")
+                            .font(.subheadline.bold())
+
+                        Toggle("Use playlist crossfade settings", isOn: $usePlaylistCrossfades)
+                            .font(.caption)
+
+                        if !usePlaylistCrossfades {
+                            HStack {
+                                Text("Gap between tracks:")
+                                    .font(.caption)
+                                Slider(value: $gapDuration, in: 0...5, step: 0.5)
+                                    .frame(width: 120)
+                                Text("\(String(format: "%.1f", gapDuration))s")
+                                    .font(.system(size: 11, design: .monospaced))
+                            }
+                        }
+                    }
+
+                    Divider()
+
+                    // Companion files
+                    VStack(alignment: .leading, spacing: 8) {
+                        Text("Companion Files")
+                            .font(.subheadline.bold())
+
+                        Toggle("Audition session (.sesx) with markers", isOn: $generateAuditionSession)
+                            .font(.caption)
+                        Toggle("Audition markers (.csv) for import", isOn: $generateAuditionMarkers)
+                            .font(.caption)
+                        Toggle("Cue sheet (.cue) with track indices", isOn: $generateCueSheet)
+                            .font(.caption)
+                    }
+
+                    // Info box
+                    VStack(alignment: .leading, spacing: 4) {
+                        Label("How it works", systemImage: "info.circle")
+                            .font(.caption.bold())
+                        Text("1. All tracks are concatenated into a single WAV file")
+                            .font(.caption2)
+                        Text("2. Marker files note where each original track starts/ends")
+                            .font(.caption2)
+                        Text("3. Open the .sesx in Audition — markers show track boundaries")
+                            .font(.caption2)
+                        Text("4. Split at markers, adjust crossfades, apply effects")
+                            .font(.caption2)
+                    }
+                    .padding(10)
+                    .background(.quaternary)
+                    .cornerRadius(6)
+                }
+                .padding(16)
+            }
+
+            Divider()
+
+            if !exportProgress.isEmpty {
+                Text(exportProgress)
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+                    .padding(.horizontal, 16)
+                    .padding(.top, 4)
+            }
+
+            statusAndActions(
+                isExporting: isExporting,
+                exportComplete: exportComplete,
+                exportError: exportError,
+                onExport: { showStitchSavePanel() },
+                onCancel: { dismiss() },
+                disabled: playlist.entries.isEmpty
+            )
+        }
+    }
+
+    private func showStitchSavePanel() {
+        let panel = NSSavePanel()
+        panel.title = "Stitch & Export"
+        panel.nameFieldStringValue = "\(playlist.name).wav"
+        panel.allowedContentTypes = [.wav]
+        panel.canCreateDirectories = true
+
+        if panel.runModal() == .OK, let url = panel.url {
+            performStitch(to: url)
+        }
+    }
+
+    private func performStitch(to url: URL) {
+        isExporting = true
+        exportError = nil
+        exportComplete = false
+        exportProgress = "Stitching audio files..."
+
+        Task { @MainActor in
+            do {
+                var options = AudioStitcher.StitchOptions.default
+                options.bitDepth = bitDepth
+                options.usePlaylistCrossfades = usePlaylistCrossfades
+                options.gapDuration = gapDuration
+
+                let result = try await AudioStitcher.stitch(
+                    playlist: playlist,
+                    to: url,
+                    options: options
+                )
+
+                exportProgress = "Writing companion files..."
+
+                let baseName = url.deletingPathExtension().lastPathComponent
+                let dir = url.deletingLastPathComponent()
+
+                // Companion files
+                if generateAuditionSession {
+                    let sesxURL = dir.appendingPathComponent("\(baseName).sesx")
+                    try AudioStitcher.writeAuditionSession(
+                        result.markers,
+                        audioFilePath: url.path,
+                        audioFileName: url.lastPathComponent,
+                        playlistName: playlist.name,
+                        sampleRate: result.sampleRate,
+                        totalDuration: result.totalDuration,
+                        to: sesxURL
+                    )
+                }
+
+                if generateAuditionMarkers {
+                    let csvURL = dir.appendingPathComponent("\(baseName)_markers.csv")
+                    try AudioStitcher.writeAuditionMarkers(result.markers, to: csvURL)
+                }
+
+                if generateCueSheet {
+                    let cueURL = dir.appendingPathComponent("\(baseName).cue")
+                    try AudioStitcher.writeCueSheet(
+                        result.markers,
+                        audioFileName: url.lastPathComponent,
+                        playlistName: playlist.name,
+                        to: cueURL
+                    )
+                }
+
+                exportProgress = ""
+                playlistVM.showStatus("Stitched \(result.markers.count) tracks to \(url.lastPathComponent)")
+                dismiss()
+            } catch {
+                exportError = error.localizedDescription
+                exportProgress = ""
+            }
+            isExporting = false
+        }
+    }
+}
+
+// MARK: - Shared Status & Actions Bar
+
+private func statusAndActions(
+    isExporting: Bool,
+    exportComplete: Bool,
+    exportError: String?,
+    onExport: @escaping () -> Void,
+    onCancel: @escaping () -> Void,
+    disabled: Bool
+) -> some View {
+    VStack(spacing: 4) {
+        if let error = exportError {
+            Text(error)
+                .font(.caption)
+                .foregroundStyle(.red)
+                .padding(.horizontal, 16)
+        }
+        if exportComplete {
+            Text("Export completed successfully!")
+                .font(.caption)
+                .foregroundStyle(.green)
+                .padding(.horizontal, 16)
+        }
+
+        HStack {
+            Button("Cancel") { onCancel() }
+                .keyboardShortcut(.cancelAction)
+
+            Spacer()
+
+            if isExporting {
+                ProgressView()
+                    .controlSize(.small)
+                    .padding(.trailing, 8)
+            }
+
+            Button("Export...") { onExport() }
+                .keyboardShortcut(.defaultAction)
+                .disabled(isExporting || disabled)
+        }
+        .padding(.horizontal, 16)
+        .padding(.vertical, 10)
+    }
+}
+
+// MARK: - Format Option Row
+
+private struct FormatOptionRow: View {
+    let format: MixExporter.ExportFormat
+    let isSelected: Bool
+
+    var body: some View {
+        HStack(spacing: 10) {
+            Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
+                .foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
+                .font(.caption)
+
+            Text(format.name)
+                .font(.caption)
+
+            Spacer()
+
+            Text(".\(format.fileExtension)")
+                .font(.caption2)
+                .foregroundStyle(.tertiary)
+        }
+        .padding(.vertical, 4)
+        .padding(.horizontal, 8)
+        .background(isSelected ? Color.accentColor.opacity(0.08) : Color.clear)
+        .cornerRadius(4)
+        .contentShape(Rectangle())
+    }
+}
+
+// MARK: - File Name Template Editor
+
+private struct FileNameTemplateEditor: View {
+    @Binding var renameFiles: Bool
+    @Binding var template: String
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            Text("File Naming")
+                .font(.subheadline.bold())
+
+            Toggle("Rename copied files", isOn: $renameFiles)
+                .font(.caption)
+
+            if renameFiles {
+                // Template input
+                HStack {
+                    Text("Template:")
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+                    TextField("Template", text: $template)
+                        .textFieldStyle(.roundedBorder)
+                        .font(.system(size: 11, design: .monospaced))
+                }
+
+                // Preview
+                HStack(spacing: 4) {
+                    Text("Preview:")
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+                    Text(FileNameTemplate.preview(template: template) + ".mp3")
+                        .font(.system(size: 10, design: .monospaced))
+                        .foregroundStyle(.primary)
+                        .lineLimit(1)
+                }
+
+                // Presets
+                HStack(spacing: 4) {
+                    Text("Presets:")
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+
+                    ForEach(FileNameTemplate.presets.prefix(4), id: \.name) { preset in
+                        Button(preset.name) {
+                            template = preset.template
+                        }
+                        .font(.system(size: 9))
+                        .buttonStyle(.plain)
+                        .padding(.horizontal, 4)
+                        .padding(.vertical, 2)
+                        .background(Color.gray.opacity(0.15))
+                        .cornerRadius(3)
+                    }
+                }
+
+                // Available variables
+                DisclosureGroup("Available variables") {
+                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 180))], spacing: 2) {
+                        ForEach(FileNameTemplate.availableVariables, id: \.token) { variable in
+                            HStack(spacing: 4) {
+                                Button(variable.token) {
+                                    template += variable.token
+                                }
+                                .font(.system(size: 10, design: .monospaced))
+                                .buttonStyle(.plain)
+                                .foregroundStyle(Color.accentColor)
+
+                                Text(variable.description)
+                                    .font(.system(size: 9))
+                                    .foregroundStyle(.tertiary)
+
+                                Spacer()
+                            }
+                        }
+                    }
+                }
+                .font(.caption)
+            }
+        }
+    }
+}

+ 182 - 0
Sources/Views/GlobalSearchSheet.swift

@@ -0,0 +1,182 @@
+import SwiftData
+import SwiftUI
+
+/// Global search across all playlists — find any track by artist, title, or album.
+struct GlobalSearchSheet: View {
+    let playlists: [Playlist]
+
+    @Environment(PlayerViewModel.self) private var playerVM
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(\.modelContext) private var modelContext
+    @Environment(\.dismiss) private var dismiss
+
+    @State private var searchText = ""
+
+    private var results: [(playlist: Playlist, entry: PlaylistEntry, track: Track)] {
+        guard searchText.count >= 2 else { return [] }
+        let query = searchText.lowercased()
+
+        var matches: [(Playlist, PlaylistEntry, Track)] = []
+        for playlist in playlists {
+            for entry in playlist.sortedEntries {
+                guard let track = entry.track else { continue }
+                if track.title.lowercased().contains(query) ||
+                   track.artist.lowercased().contains(query) ||
+                   track.album.lowercased().contains(query) {
+                    matches.append((playlist, entry, track))
+                }
+            }
+        }
+        return matches
+    }
+
+    var body: some View {
+        VStack(spacing: 0) {
+            // Header
+            HStack {
+                Image(systemName: "magnifyingglass")
+                    .foregroundStyle(theme.secondaryText)
+                TextField("Search all playlists...", text: $searchText)
+                    .textFieldStyle(.plain)
+                    .font(.system(size: 14))
+
+                if !searchText.isEmpty {
+                    Button {
+                        searchText = ""
+                    } label: {
+                        Image(systemName: "xmark.circle.fill")
+                            .foregroundStyle(theme.tertiaryText)
+                    }
+                    .buttonStyle(.plain)
+                }
+
+                Button("Done") { dismiss() }
+                    .keyboardShortcut(.cancelAction)
+            }
+            .padding(12)
+
+            Divider()
+
+            // Results
+            if searchText.count < 2 {
+                VStack {
+                    Spacer()
+                    Text("Type at least 2 characters to search")
+                        .font(.system(size: 12))
+                        .foregroundStyle(theme.tertiaryText)
+                    Spacer()
+                }
+            } else if results.isEmpty {
+                VStack {
+                    Spacer()
+                    Text("No results for \"\(searchText)\"")
+                        .font(.system(size: 12))
+                        .foregroundStyle(theme.tertiaryText)
+                    Spacer()
+                }
+            } else {
+                List {
+                    ForEach(results, id: \.entry.id) { item in
+                        SearchResultRow(
+                            track: item.track,
+                            playlistName: item.playlist.name,
+                            onPlay: {
+                                playerVM.loadAndPlay(item.track, entryID: item.entry.id, playlist: item.playlist)
+                                dismiss()
+                            },
+                            onAddToMix: { slot in
+                                _ = playlistVM.quickAddToMix(slot: slot, track: item.track, context: modelContext)
+                            }
+                        )
+                    }
+                }
+                .listStyle(.inset)
+            }
+
+            // Footer
+            HStack {
+                Text("\(results.count) results")
+                    .font(.system(size: 11))
+                    .foregroundStyle(theme.tertiaryText)
+                Spacer()
+            }
+            .padding(.horizontal, 12)
+            .padding(.vertical, 6)
+        }
+        .frame(width: 600, height: 450)
+    }
+}
+
+// MARK: - Search Result Row
+
+private struct SearchResultRow: View {
+    let track: Track
+    let playlistName: String
+    let onPlay: () -> Void
+    let onAddToMix: (Int) -> Void
+
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(PlaylistViewModel.self) private var playlistVM
+
+    var body: some View {
+        HStack(spacing: 8) {
+            VStack(alignment: .leading, spacing: 2) {
+                HStack(spacing: 4) {
+                    if !track.artist.isEmpty {
+                        Text(track.artist)
+                            .font(.system(size: 12))
+                            .foregroundStyle(theme.secondaryText)
+                        Text("–")
+                            .font(.system(size: 12))
+                            .foregroundStyle(theme.tertiaryText)
+                    }
+                    Text(track.title)
+                        .font(.system(size: 12, weight: .medium))
+                        .foregroundStyle(theme.primaryText)
+                }
+
+                HStack(spacing: 8) {
+                    Text("in \(playlistName)")
+                        .font(.system(size: 10))
+                        .foregroundStyle(theme.tertiaryText)
+
+                    if !track.album.isEmpty {
+                        Text("· \(track.album)")
+                            .font(.system(size: 10))
+                            .foregroundStyle(theme.tertiaryText)
+                    }
+
+                    Text("· \(track.formattedDuration)")
+                        .font(.system(size: 10, design: .monospaced))
+                        .foregroundStyle(theme.tertiaryText)
+                }
+            }
+
+            Spacer()
+
+            Button("Play") { onPlay() }
+                .controlSize(.small)
+
+            // Mix target buttons
+            HStack(spacing: 3) {
+                ForEach(0..<3, id: \.self) { slot in
+                    let hasTarget = playlistVM.mixTargets[slot] != nil
+                    Button {
+                        onAddToMix(slot)
+                    } label: {
+                        Text("\(slot + 1)")
+                            .font(.system(size: 10, weight: .bold, design: .rounded))
+                            .frame(width: 20, height: 20)
+                            .foregroundStyle(hasTarget ? mixTargetColors[slot] : theme.tertiaryText)
+                            .background(hasTarget ? mixTargetColors[slot].opacity(0.15) : theme.tertiaryText.opacity(0.08))
+                            .clipShape(RoundedRectangle(cornerRadius: 4))
+                    }
+                    .buttonStyle(.plain)
+                    .help("Add to \(playlistVM.mixTargetName(slot))")
+                }
+            }
+        }
+        .padding(.vertical, 2)
+    }
+}

+ 154 - 0
Sources/Views/GroupTemplateEditorSheet.swift

@@ -0,0 +1,154 @@
+import SwiftUI
+
+/// Editor for per-playlist grouping template.
+/// Lets the user type a custom template or pick a preset.
+struct GroupTemplateEditorSheet: View {
+    let playlist: Playlist
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(\.modelContext) private var modelContext
+    @Environment(\.dismiss) private var dismiss
+
+    @State private var template: String = ""
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 16) {
+            // Title
+            HStack {
+                Text("Track Grouping")
+                    .font(.title3.bold())
+                Spacer()
+                Button("Cancel") { dismiss() }
+                    .keyboardShortcut(.cancelAction)
+                Button("Save") {
+                    playlist.groupTemplate = template
+                    try? modelContext.save()
+                    dismiss()
+                }
+                .keyboardShortcut(.defaultAction)
+            }
+
+            // Template text field
+            VStack(alignment: .leading, spacing: 4) {
+                Text("Group Template")
+                    .font(.caption.bold())
+                    .foregroundStyle(.secondary)
+
+                TextField("e.g. {Album} ({Year})", text: $template)
+                    .font(.body.monospaced())
+                    .textFieldStyle(.roundedBorder)
+
+                Text("Use placeholders like {Album}, {Artist}, {Year}, etc. Tracks with the same resolved value will be grouped together. Empty = no grouping.")
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+            }
+
+            Divider()
+
+            // Presets
+            VStack(alignment: .leading, spacing: 6) {
+                Text("Presets")
+                    .font(.caption.bold())
+                    .foregroundStyle(.secondary)
+
+                LazyVGrid(columns: [GridItem(.adaptive(minimum: 140))], spacing: 4) {
+                    ForEach(GroupTemplateResolver.presets, id: \.template) { preset in
+                        Button {
+                            template = preset.template
+                        } label: {
+                            HStack(spacing: 6) {
+                                VStack(alignment: .leading, spacing: 1) {
+                                    Text(preset.name)
+                                        .font(.system(size: 12, weight: .medium))
+                                    if !preset.template.isEmpty {
+                                        Text(preset.template)
+                                            .font(.system(size: 10, design: .monospaced))
+                                            .foregroundStyle(.secondary)
+                                    }
+                                }
+                                Spacer(minLength: 0)
+                                if template == preset.template {
+                                    Image(systemName: "checkmark")
+                                        .foregroundStyle(.green)
+                                        .font(.system(size: 11))
+                                }
+                            }
+                            .padding(6)
+                            .background(template == preset.template ? Color.accentColor.opacity(0.1) : Color.primary.opacity(0.03))
+                            .clipShape(RoundedRectangle(cornerRadius: 6))
+                            .overlay(
+                                RoundedRectangle(cornerRadius: 6)
+                                    .stroke(template == preset.template ? Color.accentColor : Color.gray.opacity(0.2), lineWidth: 1)
+                            )
+                        }
+                        .buttonStyle(.plain)
+                    }
+                }
+            }
+
+            Divider()
+
+            // Placeholders (tap to insert)
+            VStack(alignment: .leading, spacing: 6) {
+                Text("Placeholders (click to insert)")
+                    .font(.caption.bold())
+                    .foregroundStyle(.secondary)
+
+                HStack(spacing: 6) {
+                    ForEach(GroupTemplateResolver.placeholders, id: \.token) { placeholder in
+                        Button {
+                            template += placeholder.token
+                        } label: {
+                            Text(placeholder.token)
+                                .font(.system(size: 11, design: .monospaced))
+                                .padding(.horizontal, 6)
+                                .padding(.vertical, 3)
+                                .background(Color.accentColor.opacity(0.1))
+                                .clipShape(RoundedRectangle(cornerRadius: 4))
+                        }
+                        .buttonStyle(.plain)
+                        .help(placeholder.description)
+                    }
+                }
+            }
+
+            // Preview
+            if !template.isEmpty {
+                Divider()
+
+                VStack(alignment: .leading, spacing: 4) {
+                    Text("Preview")
+                        .font(.caption.bold())
+                        .foregroundStyle(.secondary)
+
+                    Text(previewText)
+                        .font(.system(size: 13, weight: .semibold))
+                        .foregroundStyle(theme.primaryText)
+                        .padding(8)
+                        .frame(maxWidth: .infinity, alignment: .leading)
+                        .background(Color.primary.opacity(0.04))
+                        .clipShape(RoundedRectangle(cornerRadius: 6))
+                }
+            }
+
+            Spacer()
+        }
+        .padding(20)
+        .frame(width: 560, height: 440)
+        .onAppear {
+            template = playlist.groupTemplate
+        }
+    }
+
+    private var previewText: String {
+        let sample = template
+            .replacingOccurrences(of: "{Artist}", with: "Raimundo Fagner")
+            .replacingOccurrences(of: "{Album}", with: "Raimundo Fagner (1973)")
+            .replacingOccurrences(of: "{Genre}", with: "MPB")
+            .replacingOccurrences(of: "{Year}", with: "2026")
+            .replacingOccurrences(of: "{Folder}", with: "Batch 1")
+            .replacingOccurrences(of: "{Format}", with: "FLAC")
+            .replacingOccurrences(of: "{BPM}", with: "90-100 BPM")
+            .replacingOccurrences(of: "{Key}", with: "Am")
+        return sample
+    }
+}

+ 507 - 0
Sources/Views/NowPlayingView.swift

@@ -0,0 +1,507 @@
+import SwiftUI
+
+/// Tidal-inspired Now Playing window — large artwork, track info, synced lyrics.
+struct NowPlayingView: View {
+    enum DisplayMode {
+        case inline   // Embedded in main window (Apple Music style, default)
+        case floating // Separate window (Tidal style)
+    }
+
+    let displayMode: DisplayMode
+
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+    @State private var lyrics: [LyricsParser.LyricLine] = []
+    @State private var lyricsState: LyricsState = .idle
+    @State private var isSynced = false
+    @State private var lastLoadedTrackID: UUID?
+    @State private var showAsPlainText = false
+
+    init(displayMode: DisplayMode = .inline) {
+        self.displayMode = displayMode
+    }
+
+    enum LyricsState {
+        case idle
+        case loading
+        case loaded
+        case notFound
+        case error(String)
+        case instrumental
+    }
+
+    var body: some View {
+        VStack(spacing: 0) {
+            if displayMode == .inline {
+                inlineToolbar
+            }
+
+            HStack(spacing: 0) {
+                // Left side: Artwork + track info + progress
+                leftPanel
+                    .frame(minWidth: 280, idealWidth: 360, maxWidth: 500)
+
+                Divider()
+
+                // Right side: Lyrics
+                lyricsPanel
+                    .frame(minWidth: 250, idealWidth: 450)
+            }
+        }
+        .frame(minWidth: displayMode == .floating ? 650 : nil,
+               minHeight: displayMode == .floating ? 500 : nil)
+        .background(Color(nsColor: .windowBackgroundColor))
+        .onChange(of: playerVM.currentTrack?.id) { _, newID in
+            if newID != lastLoadedTrackID {
+                loadLyrics()
+            }
+        }
+        .onAppear {
+            loadLyrics()
+        }
+    }
+
+    // MARK: - Inline Toolbar
+
+    private var inlineToolbar: some View {
+        HStack {
+            Button {
+                NotificationCenter.default.post(name: .closeInlineNowPlaying, object: nil)
+            } label: {
+                HStack(spacing: 4) {
+                    Image(systemName: "chevron.down")
+                        .font(.system(size: 12, weight: .semibold))
+                    Text("Now Playing")
+                        .font(.system(size: 13, weight: .medium))
+                }
+                .foregroundStyle(.secondary)
+            }
+            .buttonStyle(.plain)
+
+            Spacer()
+
+            Button {
+                NotificationCenter.default.post(name: .popOutNowPlaying, object: nil)
+            } label: {
+                Image(systemName: "arrow.up.forward.square")
+                    .font(.system(size: 14))
+                    .foregroundStyle(.secondary)
+            }
+            .buttonStyle(.plain)
+            .help("Open in separate window")
+        }
+        .padding(.horizontal, 16)
+        .padding(.vertical, 8)
+    }
+
+    // MARK: - Left Panel
+
+    private var leftPanel: some View {
+        VStack(spacing: 0) {
+            Spacer(minLength: 24)
+
+            // Album artwork
+            artworkSection
+                .padding(.horizontal, 32)
+
+            Spacer(minLength: 20)
+
+            // Track info
+            trackInfoSection
+                .padding(.horizontal, 24)
+
+            Spacer(minLength: 16)
+
+            // Progress bar
+            progressSection
+                .padding(.horizontal, 24)
+
+            Spacer(minLength: 12)
+
+            // Transport controls
+            transportSection
+
+            Spacer(minLength: 16)
+        }
+    }
+
+    private var artworkSection: some View {
+        Group {
+            if let track = playerVM.currentTrack {
+                ArtworkView(track: track, size: 320)
+                    .shadow(color: .black.opacity(0.3), radius: 20, x: 0, y: 8)
+            } else {
+                ZStack {
+                    RoundedRectangle(cornerRadius: 12)
+                        .fill(.quaternary)
+                    Image(systemName: "music.note")
+                        .font(.system(size: 80))
+                        .foregroundStyle(.tertiary)
+                }
+                .frame(width: 320, height: 320)
+            }
+        }
+    }
+
+    private var trackInfoSection: some View {
+        VStack(spacing: 4) {
+            Text(playerVM.currentTrack?.title ?? "Not Playing")
+                .font(.title2)
+                .fontWeight(.semibold)
+                .lineLimit(2)
+                .multilineTextAlignment(.center)
+
+            Text(playerVM.currentTrack?.artist ?? "")
+                .font(.body)
+                .foregroundStyle(.secondary)
+                .lineLimit(1)
+
+            if let album = playerVM.currentTrack?.album, !album.isEmpty {
+                Text(album)
+                    .font(.callout)
+                    .foregroundStyle(.tertiary)
+                    .lineLimit(1)
+            }
+        }
+    }
+
+    private var progressSection: some View {
+        VStack(spacing: 4) {
+            NowPlayingSeekBar()
+
+            HStack {
+                Text(playerVM.currentTimeFormatted)
+                    .font(.system(size: 11, design: .monospaced))
+                    .foregroundStyle(.secondary)
+                Spacer()
+                Text(playerVM.remainingTimeFormatted)
+                    .font(.system(size: 11, design: .monospaced))
+                    .foregroundStyle(.secondary)
+            }
+        }
+    }
+
+    private var transportSection: some View {
+        HStack(spacing: 16) {
+            // Shuffle
+            Button {
+                playerVM.shuffleEnabled.toggle()
+            } label: {
+                Image(systemName: "shuffle")
+                    .font(.system(size: 15))
+                    .foregroundStyle(playerVM.shuffleEnabled ? theme.accent : .secondary)
+            }
+            .buttonStyle(.plain)
+
+            // Previous
+            Button { playerVM.playPrevious() } label: {
+                Image(systemName: "backward.fill")
+                    .font(.system(size: 22))
+            }
+            .buttonStyle(.plain)
+            .disabled(playerVM.currentTrack == nil)
+
+            // Play/Pause
+            Button { playerVM.togglePlayPause() } label: {
+                Image(systemName: playerVM.isPlaying ? "pause.circle.fill" : "play.circle.fill")
+                    .font(.system(size: 44))
+                    .foregroundStyle(theme.accent)
+            }
+            .buttonStyle(.plain)
+            .disabled(playerVM.currentTrack == nil)
+
+            // Next
+            Button { playerVM.playNext() } label: {
+                Image(systemName: "forward.fill")
+                    .font(.system(size: 22))
+            }
+            .buttonStyle(.plain)
+            .disabled(playerVM.currentTrack == nil)
+
+            // Repeat
+            Button {
+                switch playerVM.repeatMode {
+                case .off: playerVM.repeatMode = .all
+                case .all: playerVM.repeatMode = .one
+                case .one: playerVM.repeatMode = .off
+                }
+            } label: {
+                Image(systemName: playerVM.repeatMode == .one ? "repeat.1" : "repeat")
+                    .font(.system(size: 15))
+                    .foregroundStyle(playerVM.repeatMode != .off ? theme.accent : .secondary)
+            }
+            .buttonStyle(.plain)
+        }
+    }
+
+    // MARK: - Lyrics Panel
+
+    private var lyricsPanel: some View {
+        VStack(spacing: 0) {
+            // Lyrics header
+            HStack {
+                Text("Lyrics")
+                    .font(.headline)
+                    .foregroundStyle(.secondary)
+                Spacer()
+                if case .loaded = lyricsState, isSynced {
+                    Button {
+                        showAsPlainText.toggle()
+                    } label: {
+                        HStack(spacing: 4) {
+                            Image(systemName: showAsPlainText ? "text.alignleft" : "waveform")
+                                .font(.system(size: 10))
+                            Text(showAsPlainText ? "Plain" : "Synced")
+                                .font(.caption)
+                        }
+                        .padding(.horizontal, 8)
+                        .padding(.vertical, 3)
+                        .background(showAsPlainText ? Color.gray.opacity(0.15) : theme.accent.opacity(0.2))
+                        .foregroundStyle(showAsPlainText ? .secondary : theme.accent)
+                        .clipShape(Capsule())
+                    }
+                    .buttonStyle(.plain)
+                    .help(showAsPlainText ? "Switch to synced lyrics" : "Switch to plain text")
+                }
+            }
+            .padding(.horizontal, 24)
+            .padding(.top, 20)
+            .padding(.bottom, 8)
+
+            // Lyrics content
+            switch lyricsState {
+            case .idle, .loading:
+                Spacer()
+                ProgressView()
+                    .scaleEffect(0.8)
+                Text("Searching for lyrics…")
+                    .font(.callout)
+                    .foregroundStyle(.tertiary)
+                    .padding(.top, 8)
+                Spacer()
+
+            case .notFound:
+                Spacer()
+                VStack(spacing: 12) {
+                    Image(systemName: "text.page.slash")
+                        .font(.system(size: 36))
+                        .foregroundStyle(.tertiary)
+                    Text("No lyrics found")
+                        .font(.title3)
+                        .foregroundStyle(.secondary)
+                    if let track = playerVM.currentTrack {
+                        Text("\(track.artist) — \(track.title)")
+                            .font(.callout)
+                            .foregroundStyle(.tertiary)
+                    }
+                }
+                Spacer()
+
+            case .instrumental:
+                Spacer()
+                VStack(spacing: 12) {
+                    Image(systemName: "pianokeys")
+                        .font(.system(size: 36))
+                        .foregroundStyle(.tertiary)
+                    Text("Instrumental")
+                        .font(.title3)
+                        .foregroundStyle(.secondary)
+                }
+                Spacer()
+
+            case .error(let message):
+                Spacer()
+                VStack(spacing: 12) {
+                    Image(systemName: "exclamationmark.triangle")
+                        .font(.system(size: 36))
+                        .foregroundStyle(.tertiary)
+                    Text(message)
+                        .font(.callout)
+                        .foregroundStyle(.secondary)
+                }
+                Spacer()
+
+            case .loaded:
+                if isSynced && !showAsPlainText {
+                    SyncedLyricsView(
+                        lines: lyrics,
+                        currentTime: playerVM.currentTime,
+                        accent: theme.accent,
+                        onSeek: { time in playerVM.seek(to: time) }
+                    )
+                } else {
+                    PlainLyricsView(lines: lyrics)
+                }
+            }
+        }
+    }
+
+    // MARK: - Lyrics Loading
+
+    private func loadLyrics() {
+        guard let track = playerVM.currentTrack else {
+            lyricsState = .idle
+            lyrics = []
+            return
+        }
+
+        lastLoadedTrackID = track.id
+        lyricsState = .loading
+        lyrics = []
+
+        Task {
+            do {
+                let result = try await LRCLIBService.shared.fetchLyrics(
+                    artist: track.artist,
+                    title: track.title,
+                    album: track.album.isEmpty ? nil : track.album,
+                    duration: track.duration
+                )
+
+                if result.isInstrumental {
+                    lyricsState = .instrumental
+                    return
+                }
+
+                if let synced = result.syncedLyrics, !synced.isEmpty {
+                    lyrics = LyricsParser.parseSynced(synced)
+                    isSynced = true
+                    lyricsState = .loaded
+                } else if let plain = result.plainLyrics, !plain.isEmpty {
+                    lyrics = LyricsParser.parsePlain(plain)
+                    isSynced = false
+                    lyricsState = .loaded
+                } else {
+                    lyricsState = .notFound
+                }
+            } catch is LyricsError {
+                lyricsState = .notFound
+            } catch {
+                lyricsState = .error(error.localizedDescription)
+            }
+        }
+    }
+}
+
+// MARK: - Seekbar for Now Playing
+
+private struct NowPlayingSeekBar: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+    @State private var isDragging = false
+    @State private var dragProgress: Double = 0
+
+    var body: some View {
+        GeometryReader { geo in
+            ZStack(alignment: .leading) {
+                // Background track
+                Capsule()
+                    .fill(Color.gray.opacity(0.2))
+
+                // Progress fill
+                Capsule()
+                    .fill(theme.accent)
+                    .frame(width: max(0, (isDragging ? dragProgress : playerVM.progress) * geo.size.width))
+            }
+            .gesture(
+                DragGesture(minimumDistance: 0)
+                    .onChanged { value in
+                        isDragging = true
+                        dragProgress = max(0, min(1, value.location.x / geo.size.width))
+                    }
+                    .onEnded { value in
+                        let prog = max(0, min(1, value.location.x / geo.size.width))
+                        playerVM.seekToProgress(prog)
+                        isDragging = false
+                    }
+            )
+        }
+        .frame(height: 4)
+        .contentShape(Rectangle().inset(by: -8))
+    }
+}
+
+// MARK: - Synced Lyrics View (auto-scrolling, highlighted)
+
+struct SyncedLyricsView: View {
+    let lines: [LyricsParser.LyricLine]
+    let currentTime: TimeInterval
+    let accent: Color
+    let onSeek: (TimeInterval) -> Void
+
+    @State private var currentIndex: Int?
+
+    var body: some View {
+        ScrollViewReader { proxy in
+            ScrollView {
+                LazyVStack(alignment: .leading, spacing: 8) {
+                    // Top padding
+                    Spacer()
+                        .frame(height: 40)
+
+                    ForEach(Array(lines.enumerated()), id: \.element.id) { index, line in
+                        let isCurrent = index == currentIndex
+
+                        Text(line.text.isEmpty ? "♪" : line.text)
+                            .font(.system(size: isCurrent ? 20 : 17, weight: isCurrent ? .semibold : .regular))
+                            .foregroundStyle(isCurrent ? accent : (index < (currentIndex ?? 0) ? .secondary : .primary))
+                            .opacity(isCurrent ? 1.0 : (index < (currentIndex ?? 0) ? 0.5 : 0.7))
+                            .padding(.horizontal, 24)
+                            .padding(.vertical, 2)
+                            .id(line.id)
+                            .onTapGesture {
+                                onSeek(line.timestamp)
+                            }
+                            .animation(.easeInOut(duration: 0.3), value: isCurrent)
+                    }
+
+                    // Bottom padding
+                    Spacer()
+                        .frame(height: 120)
+                }
+            }
+            .onChange(of: currentTime) { _, time in
+                let newIndex = LyricsParser.currentLineIndex(in: lines, at: time)
+                if newIndex != currentIndex {
+                    currentIndex = newIndex
+                    if let idx = newIndex, idx < lines.count {
+                        withAnimation(.easeInOut(duration: 0.4)) {
+                            proxy.scrollTo(lines[idx].id, anchor: .center)
+                        }
+                    }
+                }
+            }
+            .onAppear {
+                currentIndex = LyricsParser.currentLineIndex(in: lines, at: currentTime)
+                if let idx = currentIndex, idx < lines.count {
+                    proxy.scrollTo(lines[idx].id, anchor: .center)
+                }
+            }
+        }
+    }
+}
+
+// MARK: - Plain Lyrics View (no timestamps)
+
+struct PlainLyricsView: View {
+    let lines: [LyricsParser.LyricLine]
+
+    var body: some View {
+        ScrollView {
+            LazyVStack(alignment: .leading, spacing: 6) {
+                Spacer()
+                    .frame(height: 16)
+
+                ForEach(lines) { line in
+                    Text(line.text.isEmpty ? " " : line.text)
+                        .font(.system(size: 16))
+                        .foregroundStyle(line.text.isEmpty ? .clear : .primary.opacity(0.8))
+                        .padding(.horizontal, 24)
+                }
+
+                Spacer()
+                    .frame(height: 60)
+            }
+        }
+    }
+}

+ 397 - 0
Sources/Views/PlayerView.swift

@@ -0,0 +1,397 @@
+import SwiftUI
+
+/// Compact player bar with transport, shuffle/repeat, cursor mode, track info, time, volume, skin.
+struct PlayerView: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+
+    var body: some View {
+        VStack(spacing: 0) {
+            // Waveform display (clickable for seek)
+            WaveformDisplay()
+
+            // Separator between waveform and slider
+            if !playerVM.waveformSamples.isEmpty {
+                Rectangle()
+                    .fill(theme.waveformSeparator)
+                    .frame(height: 10)
+            }
+
+            // Thin seek slider below the waveform
+            SeekSlider()
+
+            HStack(spacing: 0) {
+                TransportButtons()
+
+                divider()
+
+                ShuffleRepeatButtons()
+
+                divider()
+
+                CursorModeButton()
+
+                divider()
+
+                TrackInfoStrip()
+
+                Spacer(minLength: 4)
+
+                // Now Playing button
+                if playerVM.currentTrack != nil {
+                    NowPlayingButton()
+                    divider()
+                }
+
+                TimeDisplay()
+
+                divider()
+
+                VolumeControl()
+
+                divider()
+
+                SettingsButton()
+            }
+            .padding(.horizontal, 10)
+            .padding(.vertical, 5)
+            .frame(height: 52)
+        }
+        .background(.bar)
+    }
+
+    private func divider() -> some View {
+        Divider()
+            .frame(height: 28)
+            .padding(.horizontal, 7)
+    }
+}
+
+// MARK: - Waveform Display (visual, clickable for seek)
+
+private struct WaveformDisplay: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+    @State private var isDragging = false
+    @State private var dragProgress: Double = 0
+
+    var body: some View {
+        if !playerVM.waveformSamples.isEmpty {
+            GeometryReader { geo in
+                let progress = isDragging ? dragProgress : playerVM.progress
+                let samples = playerVM.waveformSamples
+
+                    Canvas { context, size in
+                    let midY = size.height / 2
+                    let count = samples.count
+                    guard count > 0 else { return }
+
+                    let playedIndex = Int(progress * Double(count))
+
+                    for (index, sample) in samples.enumerated() {
+                        let x = CGFloat(index) / CGFloat(count) * size.width
+                        let barWidth = max(0.5, size.width / CGFloat(count) - 0.3)
+
+                        let topHeight = CGFloat(sample.max) * midY
+                        let bottomHeight = CGFloat(-sample.min) * midY
+
+                        let rect = CGRect(
+                            x: x,
+                            y: midY - topHeight,
+                            width: barWidth,
+                            height: topHeight + bottomHeight
+                        )
+
+                        let color = index < playedIndex
+                            ? theme.primaryText
+                            : theme.waveformBackground
+                        context.fill(Path(rect), with: .color(color))
+                    }
+
+                    // Playhead line
+                    let playheadX = progress * size.width
+                    var playheadPath = Path()
+                    playheadPath.move(to: CGPoint(x: playheadX, y: 0))
+                    playheadPath.addLine(to: CGPoint(x: playheadX, y: size.height))
+                    context.stroke(playheadPath, with: .color(theme.accent), lineWidth: 1.5)
+                }
+                .contentShape(Rectangle())
+                .gesture(
+                    DragGesture(minimumDistance: 0)
+                        .onChanged { value in
+                            isDragging = true
+                            dragProgress = max(0, min(1, value.location.x / geo.size.width))
+                        }
+                        .onEnded { value in
+                            let prog = max(0, min(1, value.location.x / geo.size.width))
+                            playerVM.seekToProgress(prog)
+                            isDragging = false
+                        }
+                )
+            }
+            .frame(height: 32)
+        }
+    }
+}
+
+// MARK: - Seek Slider (thin progress bar)
+
+private struct SeekSlider: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+    @State private var isDragging = false
+    @State private var dragProgress: Double = 0
+
+    var body: some View {
+        GeometryReader { geo in
+            let progress = isDragging ? dragProgress : playerVM.progress
+
+            ZStack(alignment: .leading) {
+                // Background track
+                Rectangle()
+                    .fill(theme.seekbarBackground)
+
+                // Filled portion — matches volume slider color (accent)
+                Rectangle()
+                    .fill(theme.accent)
+                    .frame(width: max(0, progress * geo.size.width))
+            }
+            .contentShape(Rectangle())
+            .gesture(
+                DragGesture(minimumDistance: 0)
+                    .onChanged { value in
+                        isDragging = true
+                        dragProgress = max(0, min(1, value.location.x / geo.size.width))
+                    }
+                    .onEnded { value in
+                        let prog = max(0, min(1, value.location.x / geo.size.width))
+                        playerVM.seekToProgress(prog)
+                        isDragging = false
+                    }
+            )
+        }
+        .frame(height: 4)
+    }
+}
+
+// MARK: - Transport Buttons
+
+private struct TransportButtons: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+
+    var body: some View {
+        HStack(spacing: 5) {
+            btn("stop.fill", help: "Stop") { playerVM.stop() }
+            btn("backward.end.fill", help: "Previous Track") { playerVM.playPrevious() }
+            btn(playerVM.isPlaying ? "pause.fill" : "play.fill", help: playerVM.isPlaying ? "Pause (Space)" : "Play (Space)") { playerVM.togglePlayPause() }
+            btn("forward.end.fill", help: "Next Track") { playerVM.playNext() }
+        }
+    }
+
+    private func btn(_ icon: String, help: String, action: @escaping () -> Void) -> some View {
+        Button(action: action) {
+            Image(systemName: icon)
+                .font(.system(size: 20))
+                .frame(width: 40, height: 40)
+                .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+        .disabled(playerVM.currentTrack == nil)
+        .help(help)
+    }
+}
+
+// MARK: - Shuffle & Repeat
+
+private struct ShuffleRepeatButtons: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+
+    var body: some View {
+        HStack(spacing: 4) {
+            Button {
+                playerVM.shuffleEnabled.toggle()
+            } label: {
+                Image(systemName: "shuffle")
+                    .font(.system(size: 17))
+                    .frame(width: 36, height: 36)
+                    .foregroundStyle(playerVM.shuffleEnabled ? theme.accent : theme.tertiaryText)
+                    .contentShape(Rectangle())
+            }
+            .buttonStyle(.plain)
+            .help(playerVM.shuffleEnabled ? "Shuffle: On" : "Shuffle: Off")
+
+            Button {
+                switch playerVM.repeatMode {
+                case .off: playerVM.repeatMode = .all
+                case .all: playerVM.repeatMode = .one
+                case .one: playerVM.repeatMode = .off
+                }
+            } label: {
+                Image(systemName: playerVM.repeatMode == .one ? "repeat.1" : "repeat")
+                    .font(.system(size: 17))
+                    .frame(width: 36, height: 36)
+                    .foregroundStyle(playerVM.repeatMode != .off ? theme.accent : theme.tertiaryText)
+                    .contentShape(Rectangle())
+            }
+            .buttonStyle(.plain)
+            .help("Repeat: \(playerVM.repeatMode.rawValue)")
+        }
+    }
+}
+
+// MARK: - Cursor Mode Toggle
+
+private struct CursorModeButton: View {
+    @EnvironmentObject private var theme: AppTheme
+    @ObservedObject private var viewConfig = PlaylistViewConfig.shared
+
+    var body: some View {
+        Button {
+            // Toggle between the two modes (always keep at least one on)
+            if viewConfig.cursorFollowsPlayback {
+                viewConfig.cursorFollowsPlayback = false
+                viewConfig.playbackFollowsCursor = true
+            } else {
+                viewConfig.cursorFollowsPlayback = true
+                viewConfig.playbackFollowsCursor = false
+            }
+        } label: {
+            // Both arrows point right (= playback direction)
+            // Line position = cursor:  |→  vs  →|
+            Group {
+                if viewConfig.cursorFollowsPlayback {
+                    // |→  (cursor behind, playback pulls cursor forward)
+                    Image(systemName: "arrow.right.to.line")
+                        .scaleEffect(x: -1, y: 1)
+                } else {
+                    // →|  (cursor ahead, playback goes to where cursor points)
+                    Image(systemName: "arrow.right.to.line")
+                }
+            }
+            .font(.system(size: 17))
+                .frame(width: 36, height: 36)
+                .foregroundStyle(theme.accent)
+                .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+        .help(viewConfig.cursorFollowsPlayback ? "Cursor follows playback (click to switch)" : "Playback follows cursor (click to switch)")
+    }
+}
+
+// MARK: - Track Info Strip
+
+private struct TrackInfoStrip: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+
+    var body: some View {
+        if let track = playerVM.currentTrack {
+            HStack(spacing: 5) {
+                if playerVM.isPlaying {
+                    Image(systemName: "speaker.wave.2.fill")
+                        .font(.system(size: 10))
+                        .foregroundStyle(theme.playingHighlight)
+                }
+
+                Text(trackDescription(track))
+                    .font(.system(size: theme.dataFontSize))
+                    .lineLimit(1)
+                    .truncationMode(.tail)
+                    .foregroundStyle(theme.primaryText)
+            }
+        } else {
+            Text("Stopped")
+                .font(.system(size: theme.dataFontSize))
+                .foregroundStyle(theme.tertiaryText)
+        }
+    }
+
+    private func trackDescription(_ track: Track) -> String {
+        var parts: [String] = []
+        if !track.artist.isEmpty { parts.append(track.artist) }
+        parts.append(track.title)
+        if !track.album.isEmpty { parts.append("[\(track.album)]") }
+        return parts.joined(separator: " - ")
+    }
+}
+
+// MARK: - Time Display
+
+private struct TimeDisplay: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+
+    var body: some View {
+        if playerVM.currentTrack != nil {
+            Text("\(playerVM.currentTimeFormatted) / \(playerVM.durationFormatted)")
+                .font(.system(size: 14, design: .monospaced))
+                .foregroundStyle(theme.secondaryText)
+                .fixedSize()
+        }
+    }
+}
+
+// MARK: - Volume Control
+
+private struct VolumeControl: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+
+    var body: some View {
+        @Bindable var vm = playerVM
+
+        HStack(spacing: 4) {
+            Image(systemName: "speaker.fill")
+                .font(.system(size: 10))
+                .foregroundStyle(theme.secondaryText)
+
+            Slider(value: $vm.volume, in: 0...1)
+                .frame(width: 70)
+                .controlSize(.small)
+                .tint(theme.accent)
+        }
+        .help("Volume: \(Int(playerVM.volume * 100))%")
+    }
+}
+
+// MARK: - Settings Button
+
+private struct SettingsButton: View {
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(\.openSettings) private var openSettings
+
+    var body: some View {
+        Button {
+            openSettings()
+        } label: {
+            Image(systemName: "gearshape")
+                .font(.system(size: 14))
+                .foregroundStyle(theme.secondaryText)
+                .frame(width: 30, height: 30)
+                .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+        .help("Settings (⌘,)")
+    }
+}
+
+// MARK: - Now Playing Button
+
+private struct NowPlayingButton: View {
+    @EnvironmentObject private var theme: AppTheme
+
+    var body: some View {
+        Button {
+            NotificationCenter.default.post(name: .toggleNowPlaying, object: nil)
+        } label: {
+            Image(systemName: "text.below.photo")
+                .font(.system(size: 14))
+                .frame(width: 30, height: 30)
+                .foregroundStyle(theme.secondaryText)
+                .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+        .help("Now Playing (⇧⌘P)")
+    }
+}

+ 1169 - 0
Sources/Views/PlaylistView.swift

@@ -0,0 +1,1169 @@
+import SwiftData
+import SwiftUI
+import UniformTypeIdentifiers
+
+private extension UTType {
+    /// OGG Vorbis audio — not built-in, so we define it manually.
+    static let oggVorbis = UTType(filenameExtension: "ogg") ?? UTType.audio
+}
+
+/// Playlist view — manage tracks in a mix with transitions and export.
+struct PlaylistView: View {
+    let playlist: Playlist
+
+    @Environment(PlayerViewModel.self) private var playerVM
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var libraryManager: LibraryManager
+    @Environment(\.modelContext) private var modelContext
+
+    @Query(sort: \Track.title) private var allTracks: [Track]
+    @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
+    @ObservedObject private var viewConfig = PlaylistViewConfig.shared
+    @State private var showExportSheet = false
+    @State private var showAddTracksSheet = false
+    @State private var showGroupEditor = false
+    @State private var draggedEntry: PlaylistEntry?
+    @State private var isDropTargeted = false
+
+    var body: some View {
+        VStack(spacing: 0) {
+            // Playlist header
+            PlaylistHeader(
+                playlist: playlist,
+                mixDuration: playlistVM.mixDuration(for: playlist),
+                onExport: { showExportSheet = true },
+                onAddTracks: { showAddTracksSheet = true },
+                onAddFiles: { addFilesFromDisk() },
+                onAddFolder: { addFolderFromDisk() },
+                onEditGrouping: { showGroupEditor = true },
+                viewConfig: viewConfig
+            )
+
+            Divider()
+
+            // Track list
+            if playlist.entries.isEmpty {
+                EmptyPlaylistView(
+                    onAddTracks: { showAddTracksSheet = true },
+                    onAddFiles: { addFilesFromDisk() },
+                    onAddFolder: { addFolderFromDisk() }
+                )
+            } else {
+                PlaylistEntryList(
+                    playlist: playlist,
+                    draggedEntry: $draggedEntry,
+                    viewConfig: viewConfig
+                )
+            }
+        }
+        .overlay {
+            if isDropTargeted {
+                RoundedRectangle(cornerRadius: 8)
+                    .stroke(Color.accentColor, lineWidth: 3)
+                    .background(Color.accentColor.opacity(0.08))
+                    .padding(4)
+                    .allowsHitTesting(false)
+            }
+        }
+        .onDrop(of: [.fileURL], isTargeted: $isDropTargeted) { providers in
+            handleDrop(providers)
+            return true
+        }
+        .sheet(isPresented: $showExportSheet) {
+            ExportSheet(playlist: playlist)
+        }
+        .sheet(isPresented: $showAddTracksSheet) {
+            AddTracksSheet(playlist: playlist, allTracks: allTracks)
+        }
+        .sheet(isPresented: $showGroupEditor) {
+            GroupTemplateEditorSheet(playlist: playlist)
+        }
+    }
+
+    // MARK: - Add Files from Disk
+
+    private func addFilesFromDisk() {
+        let panel = NSOpenPanel()
+        panel.canChooseFiles = true
+        panel.canChooseDirectories = false
+        panel.allowsMultipleSelection = true
+        panel.allowedContentTypes = [.audio, .mp3, .wav, .aiff, .oggVorbis]
+        panel.message = "Select audio files to add to \"\(playlist.name)\""
+
+        if panel.runModal() == .OK {
+            Task {
+                await playlistVM.importFilesToPlaylist(
+                    urls: panel.urls,
+                    playlist: playlist,
+                    libraryManager: libraryManager,
+                    context: modelContext
+                )
+            }
+        }
+    }
+
+    private func addFolderFromDisk() {
+        let panel = NSOpenPanel()
+        panel.canChooseFiles = false
+        panel.canChooseDirectories = true
+        panel.allowsMultipleSelection = true
+        panel.message = "Select a folder to scan for audio files"
+
+        if panel.runModal() == .OK {
+            let urls = expandDirectories(panel.urls)
+            Task {
+                await playlistVM.importFilesToPlaylist(
+                    urls: urls,
+                    playlist: playlist,
+                    libraryManager: libraryManager,
+                    context: modelContext
+                )
+            }
+        }
+    }
+
+    private func handleDrop(_ providers: [NSItemProvider]) {
+        for provider in providers {
+            provider.loadItem(forTypeIdentifier: "public.file-url") { data, _ in
+                guard let data = data as? Data,
+                      let urlString = String(data: data, encoding: .utf8),
+                      let url = URL(string: urlString) else { return }
+
+                let urls = expandDirectories([url])
+                Task { @MainActor in
+                    await playlistVM.importFilesToPlaylist(
+                        urls: urls,
+                        playlist: playlist,
+                        libraryManager: libraryManager,
+                        context: modelContext
+                    )
+                }
+            }
+        }
+    }
+
+    private func expandDirectories(_ urls: [URL]) -> [URL] {
+        var result: [URL] = []
+        let fm = FileManager.default
+        for url in urls {
+            var isDir: ObjCBool = false
+            if fm.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue {
+                if let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) {
+                    for case let fileURL as URL in enumerator {
+                        if MetadataService.isSupportedAudioFile(fileURL) {
+                            result.append(fileURL)
+                        }
+                    }
+                }
+            } else if MetadataService.isSupportedAudioFile(url) {
+                result.append(url)
+            }
+        }
+        // Sort by full path with numeric sorting to preserve folder structure
+        // (like iOS FolderBrowserView) — e.g. "01/01.mp3" < "01/02.mp3" < "02/01.mp3"
+        return result.sorted { $0.path.compare($1.path, options: [.numeric, .caseInsensitive]) == .orderedAscending }
+    }
+}
+
+// MARK: - Playlist Header (compact toolbar)
+
+private struct PlaylistHeader: View {
+    let playlist: Playlist
+    let mixDuration: TimeInterval
+    let onExport: () -> Void
+    let onAddTracks: () -> Void
+    let onAddFiles: () -> Void
+    let onAddFolder: () -> Void
+    let onEditGrouping: () -> Void
+    @ObservedObject var viewConfig: PlaylistViewConfig
+    @EnvironmentObject private var theme: AppTheme
+
+    var body: some View {
+        HStack(spacing: 6) {
+            // Playlist name
+            Text(playlist.name)
+                .font(.system(size: 13, weight: .semibold))
+                .foregroundStyle(theme.primaryText)
+                .lineLimit(1)
+
+            // Stats
+            Text("[\(playlist.trackCount) tracks · \(formatDuration(mixDuration))]")
+                .font(.system(size: 11))
+                .foregroundStyle(theme.secondaryText)
+
+            Spacer()
+
+            // Toolbar buttons
+            HStack(spacing: 6) {
+                Menu {
+                    Button("Add Files...") { onAddFiles() }
+                    Button("Add Folder...") { onAddFolder() }
+                    Divider()
+                    Button("From Library...") { onAddTracks() }
+                } label: {
+                    Label("Add", systemImage: "plus")
+                }
+                .fixedSize()
+
+                Menu {
+                    ForEach(GroupTemplateResolver.presets, id: \.template) { preset in
+                        Button {
+                            playlist.groupTemplate = preset.template
+                        } label: {
+                            HStack {
+                                Text(preset.name)
+                                if playlist.groupTemplate == preset.template {
+                                    Image(systemName: "checkmark")
+                                }
+                            }
+                        }
+                    }
+                    Divider()
+                    Button("Custom...") { onEditGrouping() }
+                } label: {
+                    Label(
+                        playlist.groupTemplate.isEmpty ? "Group" : "Grouped",
+                        systemImage: "rectangle.3.group"
+                    )
+                }
+                .fixedSize()
+
+                Button {
+                    NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
+                } label: {
+                    Label("Settings", systemImage: "gearshape")
+                }
+
+                Button { onExport() } label: {
+                    Label("Export", systemImage: "square.and.arrow.up")
+                }
+                .disabled(playlist.entries.isEmpty)
+            }
+            .controlSize(.small)
+        }
+        .padding(.horizontal, 10)
+        .padding(.vertical, 7)
+        .background(theme.toolbarBackground)
+    }
+
+    private func formatDuration(_ duration: TimeInterval) -> String {
+        let total = Int(duration)
+        let hours = total / 3600
+        let minutes = (total % 3600) / 60
+        let seconds = total % 60
+        if hours > 0 {
+            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
+        }
+        return String(format: "%d:%02d", minutes, seconds)
+    }
+}
+
+// MARK: - Playlist Entry List (with Grouping)
+
+private struct PlaylistEntryList: View {
+    let playlist: Playlist
+    @Binding var draggedEntry: PlaylistEntry?
+    @ObservedObject var viewConfig: PlaylistViewConfig
+
+    @Environment(PlayerViewModel.self) private var playerVM
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var libraryManager: LibraryManager
+    @Environment(\.modelContext) private var modelContext
+
+    @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
+
+    @State private var selectedEntryIDs: Set<UUID> = []
+    @State private var scrollTarget: UUID?
+    @State private var editingNotesTrack: Track?
+
+    /// Group entries by the playlist's groupTemplate.
+    private var groupedEntries: [(key: String, entries: [(index: Int, entry: PlaylistEntry)])] {
+        let sorted = playlist.sortedEntries
+        let indexed = sorted.enumerated().map { (index: $0.offset, entry: $0.element) }
+
+        guard !playlist.groupTemplate.isEmpty else {
+            return [("", indexed)]
+        }
+
+        // Group consecutively by resolved template (preserves playlist order)
+        var groups: [(String, [(index: Int, entry: PlaylistEntry)])] = []
+        var currentHeader = ""
+        var currentEntries: [(index: Int, entry: PlaylistEntry)] = []
+
+        for item in indexed {
+            let header: String
+            if let track = item.entry.track {
+                header = GroupTemplateResolver.resolve(template: playlist.groupTemplate, for: track)
+            } else {
+                header = "Unknown"
+            }
+
+            if header != currentHeader {
+                if !currentEntries.isEmpty {
+                    groups.append((currentHeader, currentEntries))
+                }
+                currentHeader = header
+                currentEntries = [item]
+            } else {
+                currentEntries.append(item)
+            }
+        }
+        if !currentEntries.isEmpty {
+            groups.append((currentHeader, currentEntries))
+        }
+
+        return groups.map { (key: $0.0, entries: $0.1) }
+    }
+
+    var body: some View {
+        VStack(spacing: 0) {
+            // Selection toolbar
+            if !selectedEntryIDs.isEmpty {
+                SelectionToolbar(
+                    count: selectedEntryIDs.count,
+                    onSelectAll: { selectedEntryIDs = Set(playlist.entries.map(\.id)) },
+                    onDeselect: { selectedEntryIDs.removeAll() },
+                    onRemove: {
+                        let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) }
+                        for entry in toRemove {
+                            playlistVM.removeEntry(entry, from: playlist, context: modelContext)
+                        }
+                        selectedEntryIDs.removeAll()
+                    }
+                )
+                Divider()
+            }
+
+            // Column headers
+            ColumnHeaderRow(viewConfig: viewConfig)
+            Divider()
+
+            List(selection: $selectedEntryIDs) {
+                ForEach(groupedEntries, id: \.key) { group in
+                    if !playlist.groupTemplate.isEmpty && !group.key.isEmpty {
+                        Section {
+                            groupContent(group.entries)
+                        } header: {
+                            GroupHeaderView(
+                                title: group.key,
+                                trackCount: group.entries.count,
+                                firstTrack: group.entries.first?.entry.track,
+                                showArtwork: viewConfig.showArtwork
+                            )
+                        }
+                    } else {
+                        groupContent(group.entries)
+                    }
+                }
+            }
+            .listStyle(.inset)
+            // Enter key always plays selected track (like foobar2000)
+            .onKeyPress(.return) {
+                playSelectedTrack()
+                return .handled
+            }
+            // Arrow keys for navigation
+            .onKeyPress(.upArrow) {
+                moveSelection(by: -1)
+                return .handled
+            }
+            .onKeyPress(.downArrow) {
+                moveSelection(by: 1)
+                return .handled
+            }
+            // Backspace/Delete key removes selected entries (onDeleteCommand is the macOS-native way)
+            .onDeleteCommand {
+                removeSelectedEntries()
+            }
+            // Cursor follows playback: select playing entry
+            .onChange(of: playerVM.currentPlayingEntryID) { _, newID in
+                guard viewConfig.cursorFollowsPlayback, let entryID = newID else { return }
+                selectedEntryIDs = [entryID]
+            }
+            // Sync cursor position to PlayerViewModel for "Playback follows cursor"
+            .onChange(of: selectedEntryIDs) { _, newIDs in
+                playerVM.cursorEntryID = newIDs.first
+            }
+            // When PlayerViewModel moves cursor (auto-advance), update the UI selection
+            .onChange(of: playerVM.cursorEntryID) { _, newID in
+                if let newID, !selectedEntryIDs.contains(newID) {
+                    selectedEntryIDs = [newID]
+                }
+            }
+            .sheet(item: $editingNotesTrack) { track in
+                TrackNotesSheet(track: track)
+            }
+            // Double-click to play (via NSEvent monitor in MediaKeyHandler)
+            .onReceive(NotificationCenter.default.publisher(for: .doubleClickPlayTrack)) { _ in
+                playSelectedTrack()
+            }
+            // When this playlist view appears, restore cursor to playing entry if applicable
+            .onAppear {
+                if selectedEntryIDs.isEmpty,
+                   let playingID = playerVM.currentPlayingEntryID,
+                   playlist.sortedEntries.contains(where: { $0.id == playingID }) {
+                    selectedEntryIDs = [playingID]
+                    playerVM.cursorEntryID = playingID
+                }
+            }
+        }
+    }
+
+    /// Play the first selected entry.
+    private func playSelectedTrack() {
+        guard let firstID = selectedEntryIDs.first,
+              let entry = playlist.sortedEntries.first(where: { $0.id == firstID }),
+              let track = entry.track else { return }
+        playerVM.loadAndPlay(track, entryID: entry.id, playlist: playlist)
+    }
+
+    /// Remove all selected entries from the playlist.
+    private func removeSelectedEntries() {
+        guard !selectedEntryIDs.isEmpty else { return }
+        let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) }
+        for entry in toRemove {
+            playlistVM.removeEntry(entry, from: playlist, context: modelContext)
+        }
+        selectedEntryIDs.removeAll()
+    }
+
+    /// Move selection up or down by `offset` positions.
+    private func moveSelection(by offset: Int) {
+        let sorted = playlist.sortedEntries
+        guard !sorted.isEmpty else { return }
+
+        if let currentID = selectedEntryIDs.first,
+           let currentIndex = sorted.firstIndex(where: { $0.id == currentID }) {
+            let newIndex = max(0, min(sorted.count - 1, currentIndex + offset))
+            selectedEntryIDs = [sorted[newIndex].id]
+        } else {
+            // Nothing selected — select first or last
+            let entry = offset > 0 ? sorted.first! : sorted.last!
+            selectedEntryIDs = [entry.id]
+        }
+    }
+
+    @ViewBuilder
+    private func groupContent(_ entries: [(index: Int, entry: PlaylistEntry)]) -> some View {
+        ForEach(entries, id: \.entry.id) { item in
+            ConfigurableEntryRow(
+                entry: item.entry,
+                index: item.index,
+                isLast: item.index == playlist.entries.count - 1,
+                viewConfig: viewConfig,
+                isPlaying: playerVM.currentPlayingEntryID == item.entry.id
+            )
+            .tag(item.entry.id)
+            .id(item.entry.id)
+            .draggable(item.entry.track?.id.uuidString ?? "")
+            .contextMenu {
+                if let track = item.entry.track {
+                    Button("Play") { playerVM.loadAndPlay(track, entryID: item.entry.id, playlist: playlist) }
+
+                    Divider()
+
+                    // Add to other playlists
+                    let otherPlaylists = allPlaylists.filter { $0.id != playlist.id }
+                    if !otherPlaylists.isEmpty {
+                        Menu("Add to Playlist") {
+                            ForEach(otherPlaylists) { targetPlaylist in
+                                Button(targetPlaylist.name) {
+                                    playlistVM.addTrack(track, to: targetPlaylist, context: modelContext)
+                                }
+                            }
+                        }
+                    }
+                }
+
+                Divider()
+
+                Button {
+                    viewConfig.cursorFollowsPlayback = true
+                    viewConfig.playbackFollowsCursor = false
+                } label: {
+                    HStack {
+                        Text("Cursor follows playback")
+                        if viewConfig.cursorFollowsPlayback {
+                            Spacer()
+                            Image(systemName: "checkmark")
+                        }
+                    }
+                }
+
+                Button {
+                    viewConfig.playbackFollowsCursor = true
+                    viewConfig.cursorFollowsPlayback = false
+                } label: {
+                    HStack {
+                        Text("Playback follows cursor")
+                        if viewConfig.playbackFollowsCursor {
+                            Spacer()
+                            Image(systemName: "checkmark")
+                        }
+                    }
+                }
+
+                if let track = item.entry.track {
+                    Divider()
+
+                    Button("Analyze BPM & Key") {
+                        Task { await libraryManager.analyzeTrack(track) }
+                    }
+
+                    Button("Rescan Metadata") {
+                        Task {
+                            await libraryManager.rescanMetadata(track)
+                            try? modelContext.save()
+                        }
+                    }
+
+                    Button("Edit Notes...") {
+                        editingNotesTrack = track
+                    }
+
+                    // Quick add to mix targets
+                    let otherMixSlots = (0..<3).filter { playlistVM.mixTargets[$0] != nil && playlistVM.mixTargets[$0]?.id != playlist.id }
+                    if !otherMixSlots.isEmpty {
+                        let mixShortcuts: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
+                        Menu("Add to Mix") {
+                            ForEach(otherMixSlots, id: \.self) { slot in
+                                let hint = KeyboardShortcutConfig.shared.binding(for: mixShortcuts[slot]).displayString
+                                Button("\(playlistVM.mixTargetName(slot)) (\(hint))") {
+                                    _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
+                                }
+                            }
+                        }
+                    }
+                }
+
+                Divider()
+
+                if selectedEntryIDs.count > 1 {
+                    Button("Remove \(selectedEntryIDs.count) Selected", role: .destructive) {
+                        let toRemove = playlist.sortedEntries.filter { selectedEntryIDs.contains($0.id) }
+                        for e in toRemove {
+                            playlistVM.removeEntry(e, from: playlist, context: modelContext)
+                        }
+                        selectedEntryIDs.removeAll()
+                    }
+                } else {
+                    Button("Remove from Playlist", role: .destructive) {
+                        playlistVM.removeEntry(item.entry, from: playlist, context: modelContext)
+                    }
+                }
+            }
+        }
+        .onMove { source, destination in
+            if let first = source.first {
+                playlistVM.moveEntry(in: playlist, from: first, to: destination, context: modelContext)
+            }
+        }
+    }
+}
+
+// MARK: - Selection Toolbar
+
+private struct SelectionToolbar: View {
+    let count: Int
+    let onSelectAll: () -> Void
+    let onDeselect: () -> Void
+    let onRemove: () -> Void
+    @EnvironmentObject private var theme: AppTheme
+
+    var body: some View {
+        HStack(spacing: 8) {
+            Text("\(count) selected")
+                .font(.system(size: theme.smallFontSize + 1))
+                .foregroundStyle(theme.secondaryText)
+            Spacer()
+            Button("All", action: onSelectAll).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain)
+            Button("None", action: onDeselect).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain)
+            Button("Remove", role: .destructive, action: onRemove).font(.system(size: theme.smallFontSize + 1)).buttonStyle(.plain)
+        }
+        .padding(.horizontal, 8)
+        .padding(.vertical, 2)
+        .background(theme.toolbarBackground)
+    }
+}
+
+// MARK: - Group Header with Artwork
+
+private struct GroupHeaderView: View {
+    let title: String
+    let trackCount: Int
+    let firstTrack: Track?
+    let showArtwork: Bool
+    @EnvironmentObject private var theme: AppTheme
+
+    var body: some View {
+        HStack(spacing: 6) {
+            if showArtwork, let track = firstTrack {
+                ArtworkView(track: track, size: 18)
+            }
+
+            Text(title)
+                .font(.system(size: theme.dataFontSize, weight: .bold))
+                .foregroundStyle(theme.groupHeaderText)
+
+            Text("(\(trackCount))")
+                .font(.system(size: theme.smallFontSize + 1))
+                .foregroundStyle(theme.tertiaryText)
+        }
+        .padding(.vertical, 2)
+    }
+}
+
+// MARK: - Column Header Row
+
+private struct ColumnHeaderRow: View {
+    @ObservedObject var viewConfig: PlaylistViewConfig
+    @EnvironmentObject private var theme: AppTheme
+
+    private let f = Font.system(size: 10, weight: .medium)
+    private let fMono = Font.system(size: 10, weight: .medium, design: .monospaced)
+
+    private var columns: [PlaylistViewConfig.Column] {
+        viewConfig.visibleColumns
+    }
+
+    var body: some View {
+        HStack(spacing: 0) {
+            // # column or playing indicator space
+            if columns.contains(.trackNumber) {
+                Text("#")
+                    .font(fMono)
+                    .foregroundStyle(theme.secondaryText)
+                    .frame(width: 32, alignment: .trailing)
+                    .padding(.trailing, 4)
+            }
+
+            // Artwork spacer
+            if columns.contains(.artwork) && viewConfig.showArtwork {
+                Color.clear
+                    .frame(width: 18)
+                    .padding(.trailing, 4)
+            }
+
+            // Artist / Title combined header
+            if columns.contains(.artist) || columns.contains(.title) {
+                let parts = [
+                    columns.contains(.artist) ? "Artist" : nil,
+                    columns.contains(.title) ? "Title" : nil
+                ].compactMap { $0 }
+
+                Text(parts.joined(separator: " / "))
+                    .font(f)
+                    .foregroundStyle(theme.secondaryText)
+                    .lineLimit(1)
+            }
+
+            Spacer(minLength: 8)
+
+            if columns.contains(.album) {
+                Text("Album")
+                    .font(f)
+                    .foregroundStyle(theme.secondaryText)
+                    .frame(maxWidth: 150, alignment: .leading)
+                    .padding(.trailing, 8)
+            }
+
+            if columns.contains(.genre) {
+                Text("Genre")
+                    .font(f)
+                    .foregroundStyle(theme.secondaryText)
+                    .frame(width: 70, alignment: .leading)
+            }
+
+            if columns.contains(.bpm) {
+                Text("BPM")
+                    .font(fMono)
+                    .foregroundStyle(theme.secondaryText)
+                    .frame(width: 45, alignment: .trailing)
+            }
+
+            if columns.contains(.key) {
+                Text("Key")
+                    .font(f)
+                    .foregroundStyle(theme.secondaryText)
+                    .frame(width: 42, alignment: .center)
+            }
+
+            if columns.contains(.duration) {
+                Text("Time")
+                    .font(fMono)
+                    .foregroundStyle(theme.secondaryText)
+                    .frame(width: 58, alignment: .trailing)
+            }
+
+            if columns.contains(.format) {
+                Text("Fmt")
+                    .font(.system(size: 9, weight: .medium))
+                    .foregroundStyle(theme.secondaryText)
+                    .frame(width: 38, alignment: .center)
+            }
+
+            if columns.contains(.sampleRate) {
+                Text("Rate")
+                    .font(.system(size: 9, weight: .medium, design: .monospaced))
+                    .foregroundStyle(theme.secondaryText)
+                    .frame(width: 58, alignment: .trailing)
+            }
+
+            if columns.contains(.bitDepth) {
+                Text("Bit")
+                    .font(.system(size: 9, weight: .medium, design: .monospaced))
+                    .foregroundStyle(theme.secondaryText)
+                    .frame(width: 20, alignment: .trailing)
+            }
+
+            if columns.contains(.fileSize) {
+                Text("Size")
+                    .font(.system(size: 9, weight: .medium, design: .monospaced))
+                    .foregroundStyle(theme.secondaryText)
+                    .frame(width: 65, alignment: .trailing)
+            }
+
+            if columns.contains(.rating) {
+                Text("Rating")
+                    .font(.system(size: 9, weight: .medium))
+                    .foregroundStyle(theme.secondaryText)
+                    .frame(width: 50, alignment: .center)
+            }
+
+            if columns.contains(.playCount) {
+                Text("Plays")
+                    .font(.system(size: 9, weight: .medium, design: .monospaced))
+                    .foregroundStyle(theme.secondaryText)
+                    .frame(width: 25, alignment: .trailing)
+            }
+        }
+        .frame(height: 22)
+        .padding(.horizontal, 20)
+        .background(theme.columnHeaderBackground)
+    }
+}
+
+// MARK: - Configurable Entry Row
+
+private struct ConfigurableEntryRow: View {
+    let entry: PlaylistEntry
+    let index: Int
+    let isLast: Bool
+    @ObservedObject var viewConfig: PlaylistViewConfig
+    var isPlaying: Bool = false
+
+    @Environment(PlayerViewModel.self) private var playerVM
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @Environment(\.modelContext) private var modelContext
+
+    @State private var crossfade: Double = 0
+    @State private var gain: Double = 0
+
+    private var f: Font { .system(size: theme.dataFontSize) }
+    private var fMono: Font { .system(size: theme.dataFontSize, design: .monospaced) }
+
+    private var columns: [PlaylistViewConfig.Column] {
+        viewConfig.visibleColumns
+    }
+
+    @EnvironmentObject private var theme: AppTheme
+
+    var body: some View {
+        HStack(spacing: 0) {
+            if let track = entry.track {
+                // Playing indicator (narrow)
+                if isPlaying {
+                    Text("▶")
+                        .font(.system(size: 8))
+                        .foregroundStyle(theme.playingHighlight)
+                        .frame(width: 12)
+                } else if columns.contains(.trackNumber) {
+                    Text("\(index + 1)")
+                        .font(fMono)
+                        .foregroundStyle(theme.tertiaryText)
+                        .frame(width: 32, alignment: .trailing)
+                        .padding(.trailing, 4)
+                }
+
+                // Artwork (small)
+                if columns.contains(.artwork) && viewConfig.showArtwork {
+                    ArtworkView(track: track, size: 18)
+                        .padding(.trailing, 4)
+                }
+
+                // Artist - Title (main text, takes remaining space)
+                if columns.contains(.artist) && !track.artist.isEmpty {
+                    Text(track.artist)
+                        .font(f)
+                        .foregroundStyle(theme.secondaryText)
+                        .lineLimit(1)
+
+                    Text(" – ")
+                        .font(f)
+                        .foregroundStyle(theme.tertiaryText)
+                }
+
+                if columns.contains(.title) {
+                    Text(track.title)
+                        .font(f.weight(isPlaying ? .bold : .regular))
+                        .foregroundStyle(isPlaying ? theme.playingHighlight : theme.primaryText)
+                        .lineLimit(1)
+                }
+
+                Spacer(minLength: 8)
+
+                // Album
+                if columns.contains(.album) && !track.album.isEmpty {
+                    Text(track.album)
+                        .font(f)
+                        .foregroundStyle(theme.tertiaryText)
+                        .lineLimit(1)
+                        .frame(maxWidth: 150, alignment: .leading)
+                        .padding(.trailing, 8)
+                }
+
+                // Genre
+                if columns.contains(.genre) && !track.genre.isEmpty {
+                    Text(track.genre)
+                        .font(f)
+                        .foregroundStyle(theme.tertiaryText)
+                        .frame(width: 70, alignment: .leading)
+                }
+
+                // BPM
+                if columns.contains(.bpm) {
+                    Text(track.bpm.map { String(format: "%.0f", $0) } ?? "")
+                        .font(fMono)
+                        .foregroundStyle(theme.secondaryText)
+                        .frame(width: 45, alignment: .trailing)
+                }
+
+                // Key
+                if columns.contains(.key) {
+                    Text(track.musicalKey ?? "")
+                        .font(f)
+                        .foregroundStyle(theme.secondaryText)
+                        .frame(width: 42, alignment: .center)
+                }
+
+                // Duration
+                if columns.contains(.duration) {
+                    Text(track.formattedDuration)
+                        .font(fMono)
+                        .foregroundStyle(theme.secondaryText)
+                        .frame(width: 58, alignment: .trailing)
+                }
+
+                // Format
+                if columns.contains(.format) {
+                    Text(track.fileFormat)
+                        .font(.system(size: theme.smallFontSize))
+                        .foregroundStyle(theme.tertiaryText)
+                        .frame(width: 38, alignment: .center)
+                }
+
+                // Sample Rate
+                if columns.contains(.sampleRate) {
+                    Text("\(Int(track.sampleRate))Hz")
+                        .font(.system(size: theme.smallFontSize, design: .monospaced))
+                        .foregroundStyle(theme.tertiaryText)
+                        .frame(width: 58, alignment: .trailing)
+                }
+
+                // Bit Depth
+                if columns.contains(.bitDepth) {
+                    Text("\(track.bitDepth)")
+                        .font(.system(size: theme.smallFontSize, design: .monospaced))
+                        .foregroundStyle(theme.tertiaryText)
+                        .frame(width: 20, alignment: .trailing)
+                }
+
+                // File Size
+                if columns.contains(.fileSize) {
+                    Text(track.formattedFileSize)
+                        .font(.system(size: theme.smallFontSize, design: .monospaced))
+                        .foregroundStyle(theme.tertiaryText)
+                        .frame(width: 65, alignment: .trailing)
+                }
+
+                // Rating
+                if columns.contains(.rating) && track.rating > 0 {
+                    Text(String(repeating: "★", count: track.rating))
+                        .font(.system(size: theme.smallFontSize))
+                        .foregroundStyle(.yellow)
+                        .frame(width: 50, alignment: .center)
+                }
+
+                // Play Count
+                if columns.contains(.playCount) && track.playCount > 0 {
+                    Text("\(track.playCount)×")
+                        .font(.system(size: theme.smallFontSize, design: .monospaced))
+                        .foregroundStyle(theme.tertiaryText)
+                        .frame(width: 25, alignment: .trailing)
+                }
+            }
+        }
+        .frame(height: theme.rowHeight)
+        .onAppear {
+            crossfade = entry.crossfadeDuration
+            gain = entry.gainAdjustment
+        }
+    }
+}
+
+// MARK: - Empty Playlist
+
+private struct EmptyPlaylistView: View {
+    let onAddTracks: () -> Void
+    let onAddFiles: () -> Void
+    let onAddFolder: () -> Void
+
+    var body: some View {
+        VStack(spacing: 12) {
+            Spacer()
+
+            Text("Empty playlist")
+                .font(.system(size: 12))
+                .foregroundStyle(.secondary)
+
+            Text("Drop files here, or use Add menu above")
+                .font(.system(size: 11))
+                .foregroundStyle(.tertiary)
+
+            HStack(spacing: 8) {
+                Button("Add Files...") { onAddFiles() }
+                    .font(.system(size: 11))
+                Button("Add Folder...") { onAddFolder() }
+                    .font(.system(size: 11))
+            }
+
+            Spacer()
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+    }
+}
+
+// MARK: - Column Config Sheet
+
+private struct ColumnConfigSheet: View {
+    @ObservedObject var viewConfig: PlaylistViewConfig
+    @Environment(\.dismiss) private var dismiss
+
+    var body: some View {
+        VStack(spacing: 0) {
+            HStack {
+                Text("Configure Playlist View")
+                    .font(.headline)
+                Spacer()
+                Button("Done") { dismiss() }
+                    .keyboardShortcut(.defaultAction)
+            }
+            .padding()
+
+            Divider()
+
+            ScrollView {
+                VStack(alignment: .leading, spacing: 20) {
+                    // Artwork settings
+                    VStack(alignment: .leading, spacing: 8) {
+                        Text("Artwork")
+                            .font(.subheadline.bold())
+
+                        Toggle("Show artwork", isOn: $viewConfig.showArtwork)
+
+                        if viewConfig.showArtwork {
+                            Picker("Size", selection: $viewConfig.artworkSize) {
+                                ForEach(PlaylistViewConfig.ArtworkSize.allCases) { size in
+                                    Text(size.rawValue).tag(size)
+                                }
+                            }
+                            .pickerStyle(.segmented)
+                            .frame(width: 250)
+                        }
+                    }
+
+                    Divider()
+
+                    // Playback behavior
+                    VStack(alignment: .leading, spacing: 8) {
+                        Text("Playback Behavior")
+                            .font(.subheadline.bold())
+
+                        Toggle("Cursor follows playback", isOn: $viewConfig.cursorFollowsPlayback)
+                        Text("Auto-select and scroll to the currently playing track")
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+
+                        Toggle("Playback follows cursor", isOn: $viewConfig.playbackFollowsCursor)
+                        Text("Press Enter/Return to play the selected track")
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                    }
+
+                    Divider()
+
+                    // Visible columns
+                    VStack(alignment: .leading, spacing: 8) {
+                        HStack {
+                            Text("Visible Columns")
+                                .font(.subheadline.bold())
+
+                            Spacer()
+
+                            Button("Reset to Defaults") {
+                                viewConfig.resetToDefaults()
+                            }
+                            .font(.caption)
+                        }
+
+                        Text("Check the columns you want to display. Drag to reorder.")
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+
+                        LazyVGrid(columns: [GridItem(.adaptive(minimum: 140))], spacing: 6) {
+                            ForEach(PlaylistViewConfig.Column.allCases) { column in
+                                Toggle(column.rawValue, isOn: Binding(
+                                    get: { viewConfig.isColumnVisible(column) },
+                                    set: { _ in viewConfig.toggleColumn(column) }
+                                ))
+                                .toggleStyle(.checkbox)
+                                .font(.caption)
+                            }
+                        }
+                    }
+                }
+                .padding(20)
+            }
+        }
+        .frame(width: 450, height: 520)
+    }
+}
+
+// MARK: - Add Tracks Sheet
+
+private struct AddTracksSheet: View {
+    let playlist: Playlist
+    let allTracks: [Track]
+
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @Environment(\.modelContext) private var modelContext
+    @Environment(\.dismiss) private var dismiss
+
+    @State private var searchText = ""
+    @State private var selectedTracks: Set<UUID> = []
+
+    var filteredTracks: [Track] {
+        if searchText.isEmpty { return allTracks }
+        let query = searchText.lowercased()
+        return allTracks.filter {
+            $0.title.lowercased().contains(query) ||
+            $0.artist.lowercased().contains(query)
+        }
+    }
+
+    var body: some View {
+        VStack(spacing: 0) {
+            HStack {
+                Text("Add Tracks to \(playlist.name)")
+                    .font(.headline)
+                Spacer()
+                Text("\(selectedTracks.count) selected")
+                    .foregroundStyle(.secondary)
+            }
+            .padding()
+
+            TextField("Search tracks...", text: $searchText)
+                .textFieldStyle(.roundedBorder)
+                .padding(.horizontal)
+
+            List(filteredTracks, selection: $selectedTracks) { track in
+                HStack {
+                    TrackRow(track: track)
+                    Spacer()
+                    if let bpm = track.bpm {
+                        Text("\(String(format: "%.0f", bpm))")
+                            .font(.caption)
+                            .foregroundStyle(.secondary)
+                    }
+                    Text(track.formattedDuration)
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+                }
+            }
+            .listStyle(.inset)
+
+            Divider()
+
+            HStack {
+                Button("Cancel") { dismiss() }
+                    .keyboardShortcut(.cancelAction)
+                Spacer()
+                Button("Add \(selectedTracks.count) Track\(selectedTracks.count == 1 ? "" : "s")") {
+                    let tracks = allTracks.filter { selectedTracks.contains($0.id) }
+                    playlistVM.addTracks(tracks, to: playlist, context: modelContext)
+                    dismiss()
+                }
+                .keyboardShortcut(.defaultAction)
+                .disabled(selectedTracks.isEmpty)
+            }
+            .padding()
+        }
+        .frame(width: 500, height: 600)
+    }
+}
+
+// MARK: - Track Notes Sheet
+
+private struct TrackNotesSheet: View {
+    let track: Track
+
+    @Environment(\.dismiss) private var dismiss
+    @Environment(\.modelContext) private var modelContext
+    @State private var notes: String = ""
+
+    var body: some View {
+        VStack(spacing: 12) {
+            HStack {
+                VStack(alignment: .leading, spacing: 2) {
+                    Text(track.title)
+                        .font(.headline)
+                    if !track.artist.isEmpty {
+                        Text(track.artist)
+                            .font(.subheadline)
+                            .foregroundStyle(.secondary)
+                    }
+                }
+                Spacer()
+                Button("Done") {
+                    track.notes = notes
+                    try? modelContext.save()
+                    dismiss()
+                }
+                .keyboardShortcut(.defaultAction)
+            }
+
+            TextEditor(text: $notes)
+                .font(.system(size: 13))
+                .frame(minHeight: 100)
+                .scrollContentBackground(.hidden)
+                .padding(4)
+                .background(Color.gray.opacity(0.1))
+                .cornerRadius(6)
+
+            HStack {
+                Text("Notes are saved with the track and included in exports")
+                    .font(.caption)
+                    .foregroundStyle(.secondary)
+                Spacer()
+            }
+        }
+        .padding(16)
+        .frame(width: 400, height: 220)
+        .onAppear {
+            notes = track.notes
+        }
+    }
+}
+
+// MARK: - Double-Click Handler (NSViewRepresentable)
+

+ 673 - 0
Sources/Views/SettingsView.swift

@@ -0,0 +1,673 @@
+import SwiftData
+import SwiftUI
+
+/// Full settings window using macOS native TabView.
+/// Contains: Mix Targets, Appearance (themes), Playlist (columns, artwork, grouping), Playback, General.
+struct SettingsView: View {
+    var body: some View {
+        TabView {
+            MixTargetSettings()
+                .tabItem {
+                    Label("Mix Targets", systemImage: "target")
+                }
+
+            ChadMusicSettings()
+                .tabItem {
+                    Label("Chad Music", systemImage: "cloud.fill")
+                }
+
+            AppearanceSettings()
+                .tabItem {
+                    Label("Appearance", systemImage: "paintbrush")
+                }
+
+            PlaylistSettings()
+                .tabItem {
+                    Label("Playlist", systemImage: "list.bullet")
+                }
+
+            PlaybackSettings()
+                .tabItem {
+                    Label("Playback", systemImage: "play.circle")
+                }
+
+            KeyboardShortcutSettings()
+                .tabItem {
+                    Label("Shortcuts", systemImage: "keyboard")
+                }
+
+            GeneralSettings()
+                .tabItem {
+                    Label("General", systemImage: "gearshape")
+                }
+        }
+        .frame(width: 580, height: 500)
+    }
+}
+
+// MARK: - Mix Colors (shared across views)
+
+/// The 3 mix target colors: Red, Blue, Gold.
+let mixTargetColors: [Color] = [
+    Color(red: 0.95, green: 0.3, blue: 0.3),   // Red
+    Color(red: 0.3, green: 0.75, blue: 0.95),   // Blue
+    Color(red: 0.95, green: 0.75, blue: 0.2),   // Yellow/Gold
+]
+
+// MARK: - Mix Target Settings
+
+private struct MixTargetSettings: View {
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var theme: AppTheme
+    @ObservedObject private var shortcutConfig = KeyboardShortcutConfig.shared
+    @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist]
+
+    private let mixActions: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 20) {
+            Text("Mix Targets")
+                .font(.title3.bold())
+
+            Text("Assign playlists to the 3 mix slots. Use the configured shortcuts to quick-add the current track, or click the numbered buttons at the top of the window.")
+                .font(.callout)
+                .foregroundStyle(.secondary)
+
+            VStack(spacing: 12) {
+                ForEach(0..<3, id: \.self) { slot in
+                    HStack(spacing: 12) {
+                        // Colored number badge
+                        Text("\(slot + 1)")
+                            .font(.system(size: 14, weight: .bold, design: .rounded))
+                            .frame(width: 30, height: 30)
+                            .foregroundStyle(mixTargetColors[slot])
+                            .background(mixTargetColors[slot].opacity(0.15))
+                            .clipShape(RoundedRectangle(cornerRadius: 6))
+
+                        // Playlist picker — Picker with menu style for full-width clickability
+                        Picker(selection: Binding(
+                            get: { playlistVM.mixTargets[slot]?.id },
+                            set: { newID in
+                                if let newID, let playlist = playlists.first(where: { $0.id == newID }) {
+                                    playlistVM.setMixTarget(slot, playlist: playlist)
+                                } else {
+                                    playlistVM.setMixTarget(slot, playlist: nil)
+                                }
+                            }
+                        )) {
+                            Text("Not set").tag(UUID?.none)
+                            Divider()
+                            ForEach(playlists) { playlist in
+                                Text(playlist.name).tag(Optional(playlist.id))
+                            }
+                        } label: {
+                            EmptyView()
+                        }
+                        .labelsHidden()
+                        .frame(minWidth: 160)
+
+                        // Shortcut hint
+                        Text(shortcutConfig.binding(for: mixActions[slot]).displayString)
+                            .font(.system(size: 11, design: .monospaced))
+                            .foregroundStyle(.secondary)
+                            .frame(width: 50)
+                    }
+                }
+            }
+
+            Divider()
+
+            Text("Tip: You can also right-click a playlist in the sidebar to assign it to a mix slot.")
+                .font(.callout)
+                .foregroundStyle(.secondary)
+
+            Spacer()
+        }
+        .padding(24)
+    }
+}
+
+// MARK: - Appearance Settings (Theme/Skin picker)
+
+private struct AppearanceSettings: View {
+    @EnvironmentObject private var theme: AppTheme
+    @ObservedObject private var iconConfig = AppIconConfig.shared
+
+    private let modernSkins: [AppTheme.Skin] = [.dark, .midnight, .forest, .ocean, .warm, .light]
+    private let retroSkins: [AppTheme.Skin] = [.winampClassic, .winampModern, .foobarDark, .foobarLight, .win95, .win98, .xpLuna, .macOSClassic]
+
+    var body: some View {
+        ScrollView {
+            VStack(alignment: .leading, spacing: 20) {
+                // MARK: - App Icon Color
+                Text("App Icon")
+                    .font(.title3.bold())
+
+                Text("Choose an accent color for the Dock icon.")
+                    .font(.callout)
+                    .foregroundStyle(.secondary)
+
+                ScrollView(.horizontal, showsIndicators: false) {
+                    HStack(spacing: 12) {
+                        ForEach(AppIconConfig.iconColors) { option in
+                            Button {
+                                iconConfig.selectedColorName = option.name
+                            } label: {
+                                VStack(spacing: 6) {
+                                    RoundedRectangle(cornerRadius: 12)
+                                        .fill(option.color)
+                                        .frame(width: 50, height: 50)
+                                        .overlay(
+                                            RoundedRectangle(cornerRadius: 12)
+                                                .stroke(
+                                                    iconConfig.selectedColorName == option.name
+                                                        ? Color.accentColor : Color.white.opacity(0.2),
+                                                    lineWidth: iconConfig.selectedColorName == option.name ? 2.5 : 1
+                                                )
+                                        )
+                                        .overlay {
+                                            if iconConfig.selectedColorName == option.name {
+                                                Image(systemName: "checkmark")
+                                                    .font(.system(size: 16, weight: .bold))
+                                                    .foregroundStyle(.white)
+                                                    .shadow(radius: 2)
+                                            }
+                                        }
+                                    Text(option.name)
+                                        .font(.caption2)
+                                        .foregroundStyle(.secondary)
+                                }
+                            }
+                            .buttonStyle(.plain)
+                        }
+                    }
+                    .padding(.vertical, 4)
+                }
+
+                Divider()
+
+                // MARK: - Theme
+                Text("Theme")
+                    .font(.title3.bold())
+
+                Text("Choose a visual theme for MixBoard. Each theme enforces its own light/dark appearance.")
+                    .font(.callout)
+                    .foregroundStyle(.secondary)
+
+                // Modern
+                VStack(alignment: .leading, spacing: 8) {
+                    Text("Modern")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+
+                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 8) {
+                        ForEach(modernSkins) { skin in
+                            SkinCard(skin: skin, isSelected: theme.currentSkin == skin) {
+                                withAnimation(.easeInOut(duration: 0.2)) {
+                                    theme.currentSkin = skin
+                                }
+                            }
+                        }
+                    }
+                }
+
+                Divider()
+
+                // Retro
+                VStack(alignment: .leading, spacing: 8) {
+                    Text("Retro")
+                        .font(.headline)
+                        .foregroundStyle(.secondary)
+
+                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 8) {
+                        ForEach(retroSkins) { skin in
+                            SkinCard(skin: skin, isSelected: theme.currentSkin == skin) {
+                                withAnimation(.easeInOut(duration: 0.2)) {
+                                    theme.currentSkin = skin
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+            .padding(24)
+        }
+    }
+}
+
+/// A compact card showing a skin preview swatch and name.
+private struct SkinCard: View {
+    let skin: AppTheme.Skin
+    let isSelected: Bool
+    let action: () -> Void
+
+    var body: some View {
+        Button(action: action) {
+            HStack(spacing: 10) {
+                // Color swatch showing the skin's approximate palette
+                RoundedRectangle(cornerRadius: 4)
+                    .fill(skin.previewColor)
+                    .frame(width: 28, height: 28)
+                    .overlay {
+                        if skin.colorScheme == .dark {
+                            Image(systemName: "moon.fill")
+                                .font(.system(size: 10))
+                                .foregroundStyle(.white.opacity(0.7))
+                        } else {
+                            Image(systemName: "sun.max.fill")
+                                .font(.system(size: 10))
+                                .foregroundStyle(.black.opacity(0.5))
+                        }
+                    }
+
+                VStack(alignment: .leading, spacing: 1) {
+                    Text(skin.rawValue)
+                        .font(.system(size: 12, weight: .medium))
+                        .lineLimit(1)
+                    Text(skin.colorScheme == .dark ? "Dark" : "Light")
+                        .font(.system(size: 10))
+                        .foregroundStyle(.secondary)
+                }
+
+                Spacer(minLength: 0)
+
+                if isSelected {
+                    Image(systemName: "checkmark.circle.fill")
+                        .foregroundStyle(.green)
+                        .font(.system(size: 14))
+                }
+            }
+            .padding(8)
+            .background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
+            .overlay(
+                RoundedRectangle(cornerRadius: 8)
+                    .stroke(isSelected ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1)
+            )
+            .clipShape(RoundedRectangle(cornerRadius: 8))
+            .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+    }
+}
+
+// MARK: - Playlist Settings (Columns, Artwork, Grouping)
+
+private struct PlaylistSettings: View {
+    @ObservedObject private var viewConfig = PlaylistViewConfig.shared
+
+    var body: some View {
+        ScrollView {
+            VStack(alignment: .leading, spacing: 20) {
+                // Visible columns
+                VStack(alignment: .leading, spacing: 8) {
+                    HStack {
+                        Text("Visible Columns")
+                            .font(.title3.bold())
+                        Spacer()
+                        Button("Reset to Defaults") {
+                            viewConfig.resetToDefaults()
+                        }
+                        .font(.caption)
+                    }
+
+                    Text("Select which columns appear in the playlist view.")
+                        .font(.callout)
+                        .foregroundStyle(.secondary)
+
+                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 130))], spacing: 6) {
+                        ForEach(PlaylistViewConfig.Column.allCases) { column in
+                            Toggle(column.rawValue, isOn: Binding(
+                                get: { viewConfig.isColumnVisible(column) },
+                                set: { _ in viewConfig.toggleColumn(column) }
+                            ))
+                            .toggleStyle(.checkbox)
+                            .font(.system(size: 12))
+                        }
+                    }
+                }
+
+                Divider()
+
+                // Artwork
+                VStack(alignment: .leading, spacing: 8) {
+                    Text("Artwork")
+                        .font(.headline)
+
+                    Toggle("Show artwork thumbnails in playlist rows", isOn: $viewConfig.showArtwork)
+
+                    if viewConfig.showArtwork {
+                        Picker("Artwork size", selection: $viewConfig.artworkSize) {
+                            ForEach(PlaylistViewConfig.ArtworkSize.allCases) { size in
+                                Text(size.rawValue).tag(size)
+                            }
+                        }
+                        .pickerStyle(.segmented)
+                        .frame(width: 250)
+                    }
+                }
+
+                Divider()
+            }
+            .padding(24)
+        }
+    }
+}
+
+// MARK: - Playback Settings
+
+private struct PlaybackSettings: View {
+    @ObservedObject private var viewConfig = PlaylistViewConfig.shared
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 20) {
+            Text("Cursor Behavior")
+                .font(.title3.bold())
+
+            VStack(alignment: .leading, spacing: 12) {
+                Toggle("Cursor follows playback", isOn: Binding(
+                    get: { viewConfig.cursorFollowsPlayback },
+                    set: { newValue in
+                        if newValue {
+                            viewConfig.cursorFollowsPlayback = true
+                            viewConfig.playbackFollowsCursor = false
+                        } else {
+                            viewConfig.cursorFollowsPlayback = false
+                            viewConfig.playbackFollowsCursor = true
+                        }
+                    }
+                ))
+                Text("Auto-select and scroll to the currently playing track.")
+                    .font(.callout)
+                    .foregroundStyle(.secondary)
+                    .padding(.leading, 20)
+
+                Toggle("Playback follows cursor", isOn: Binding(
+                    get: { viewConfig.playbackFollowsCursor },
+                    set: { newValue in
+                        if newValue {
+                            viewConfig.playbackFollowsCursor = true
+                            viewConfig.cursorFollowsPlayback = false
+                        } else {
+                            viewConfig.playbackFollowsCursor = false
+                            viewConfig.cursorFollowsPlayback = true
+                        }
+                    }
+                ))
+                Text("When a track finishes, play the currently selected track next (foobar2000 behavior).")
+                    .font(.callout)
+                    .foregroundStyle(.secondary)
+                    .padding(.leading, 20)
+            }
+
+            Spacer()
+        }
+        .padding(24)
+    }
+}
+
+// MARK: - General Settings
+
+private struct GeneralSettings: View {
+    @AppStorage("autoAnalyzeOnImport") private var autoAnalyzeOnImport = true
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 20) {
+            Text("General")
+                .font(.title3.bold())
+
+            VStack(alignment: .leading, spacing: 12) {
+                Toggle("Auto-analyze BPM & Key on import", isOn: $autoAnalyzeOnImport)
+                Text("Automatically detect BPM and musical key when adding tracks.")
+                    .font(.callout)
+                    .foregroundStyle(.secondary)
+                    .padding(.leading, 20)
+            }
+
+            Spacer()
+        }
+        .padding(24)
+    }
+}
+
+// MARK: - Keyboard Shortcut Settings
+
+private struct KeyboardShortcutSettings: View {
+    @ObservedObject private var config = KeyboardShortcutConfig.shared
+    @State private var recordingAction: ShortcutAction?
+    @State private var keyMonitor: Any?
+
+    var body: some View {
+        ScrollView {
+            VStack(alignment: .leading, spacing: 20) {
+                HStack {
+                    Text("Keyboard Shortcuts")
+                        .font(.title3.bold())
+                    Spacer()
+                    Button("Reset to Defaults") {
+                        config.resetToDefaults()
+                    }
+                    .font(.caption)
+                }
+
+                Text("Click a shortcut to re-assign it. Press Escape to cancel.")
+                    .font(.callout)
+                    .foregroundStyle(.secondary)
+
+                ForEach(ShortcutGroup.allCases, id: \.rawValue) { group in
+                    VStack(alignment: .leading, spacing: 6) {
+                        Text(group.rawValue)
+                            .font(.headline)
+                            .foregroundStyle(.secondary)
+
+                        ForEach(group.actions) { action in
+                            ShortcutRow(
+                                action: action,
+                                binding: config.binding(for: action),
+                                isRecording: recordingAction == action,
+                                onStartRecording: { startRecording(for: action) }
+                            )
+                        }
+                    }
+
+                    if group != .general {
+                        Divider()
+                    }
+                }
+            }
+            .padding(24)
+        }
+        .onDisappear {
+            stopRecording()
+        }
+    }
+
+    private func startRecording(for action: ShortcutAction) {
+        // Stop any existing recording
+        stopRecording()
+        recordingAction = action
+
+        keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
+            // Escape cancels
+            if event.keyCode == 53 {
+                self.stopRecording()
+                return nil
+            }
+            let binding = ShortcutBinding.from(event)
+            self.config.shortcuts[action] = binding
+            self.stopRecording()
+            return nil // consume the event
+        }
+    }
+
+    private func stopRecording() {
+        recordingAction = nil
+        if let monitor = keyMonitor {
+            NSEvent.removeMonitor(monitor)
+            keyMonitor = nil
+        }
+    }
+}
+
+/// A single row: action name + clickable shortcut recorder button.
+private struct ShortcutRow: View {
+    let action: ShortcutAction
+    let binding: ShortcutBinding
+    let isRecording: Bool
+    let onStartRecording: () -> Void
+
+    var body: some View {
+        HStack {
+            Text(action.rawValue)
+                .font(.system(size: 12))
+                .frame(maxWidth: .infinity, alignment: .leading)
+
+            Button {
+                onStartRecording()
+            } label: {
+                Text(isRecording ? "Press shortcut…" : binding.displayString)
+                    .font(.system(size: 12, design: isRecording ? .default : .monospaced))
+                    .frame(minWidth: 90)
+                    .padding(.horizontal, 8)
+                    .padding(.vertical, 4)
+                    .background(isRecording ? Color.accentColor.opacity(0.15) : Color.primary.opacity(0.05))
+                    .clipShape(RoundedRectangle(cornerRadius: 6))
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 6)
+                            .stroke(isRecording ? Color.accentColor : Color.gray.opacity(0.3), lineWidth: 1)
+                    )
+            }
+            .buttonStyle(.plain)
+            .animation(.easeInOut(duration: 0.15), value: isRecording)
+        }
+        .padding(.vertical, 2)
+    }
+}
+
+// MARK: - Chad Music Settings
+
+private struct ChadMusicSettings: View {
+    @AppStorage("chadMusic.serverURL") private var serverURL: String = ""
+    @AppStorage("chadMusic.apiKey") private var apiKey: String = ""
+    @State private var connectionStatus: ConnectionStatus = .unknown
+    @State private var isTesting = false
+    @State private var statsText: String = ""
+
+    private enum ConnectionStatus {
+        case unknown, testing, success, failed(String)
+    }
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 20) {
+            Text("Chad Music")
+                .font(.title3.bold())
+
+            Text("Connect to your Chad Music server to browse and stream your cloud library.")
+                .font(.callout)
+                .foregroundStyle(.secondary)
+
+            // Server URL
+            VStack(alignment: .leading, spacing: 6) {
+                Text("Server URL")
+                    .font(.headline)
+                TextField("https://music.tailnet.ts.net", text: $serverURL)
+                    .textFieldStyle(.roundedBorder)
+                    .onChange(of: serverURL) { _, _ in
+                        connectionStatus = .unknown
+                    }
+            }
+
+            // API Key
+            VStack(alignment: .leading, spacing: 6) {
+                Text("API Key")
+                    .font(.headline)
+                SecureField("Enter API key", text: $apiKey)
+                    .textFieldStyle(.roundedBorder)
+                    .onChange(of: apiKey) { _, _ in
+                        connectionStatus = .unknown
+                    }
+            }
+
+            Divider()
+
+            // Connection test
+            HStack(spacing: 12) {
+                Button("Test Connection") {
+                    testConnection()
+                }
+                .disabled(serverURL.isEmpty || apiKey.isEmpty || isTesting)
+
+                switch connectionStatus {
+                case .unknown:
+                    EmptyView()
+                case .testing:
+                    ProgressView()
+                        .controlSize(.small)
+                    Text("Connecting...")
+                        .font(.callout)
+                        .foregroundStyle(.secondary)
+                case .success:
+                    Image(systemName: "checkmark.circle.fill")
+                        .foregroundStyle(.green)
+                    Text(statsText)
+                        .font(.callout)
+                        .foregroundStyle(.secondary)
+                case .failed(let message):
+                    Image(systemName: "xmark.circle.fill")
+                        .foregroundStyle(.red)
+                    Text(message)
+                        .font(.callout)
+                        .foregroundStyle(.red)
+                }
+            }
+
+            Spacer()
+        }
+        .padding(24)
+    }
+
+    private func testConnection() {
+        connectionStatus = .testing
+        isTesting = true
+        Task {
+            let client = ChadMusicAPIClient.shared
+            let result = await client.testConnection()
+            switch result {
+            case .success(let stats):
+                let parts = [
+                    stats.tracks.map { "\($0) tracks" },
+                    stats.albums.map { "\($0) albums" },
+                    stats.artists.map { "\($0) artists" },
+                ].compactMap { $0 }
+                statsText = "Connected — " + parts.joined(separator: ", ")
+                connectionStatus = .success
+            case .failure(let error):
+                connectionStatus = .failed(error.localizedDescription)
+            }
+            isTesting = false
+        }
+    }
+}
+
+// MARK: - Skin Preview Color
+
+extension AppTheme.Skin {
+    /// Approximate preview swatch color for the skin card.
+    var previewColor: Color {
+        switch self {
+        case .dark:          return Color(red: 0.15, green: 0.15, blue: 0.17)
+        case .midnight:      return Color(red: 0.1, green: 0.1, blue: 0.2)
+        case .forest:        return Color(red: 0.1, green: 0.18, blue: 0.1)
+        case .ocean:         return Color(red: 0.1, green: 0.15, blue: 0.2)
+        case .warm:          return Color(red: 0.2, green: 0.15, blue: 0.1)
+        case .light:         return Color(red: 0.95, green: 0.95, blue: 0.96)
+        case .winampClassic: return Color(red: 0.12, green: 0.12, blue: 0.14)
+        case .winampModern:  return Color(red: 0.13, green: 0.14, blue: 0.18)
+        case .foobarDark:    return Color(red: 0.14, green: 0.14, blue: 0.14)
+        case .foobarLight:   return Color(red: 0.94, green: 0.94, blue: 0.94)
+        case .win95:         return Color(red: 0.75, green: 0.75, blue: 0.75)
+        case .win98:         return Color(red: 0.83, green: 0.82, blue: 0.78)
+        case .xpLuna:        return Color(red: 0.85, green: 0.89, blue: 0.95)
+        case .macOSClassic:  return Color(red: 0.86, green: 0.86, blue: 0.86)
+        }
+    }
+}

+ 417 - 0
Sources/Views/SidebarView.swift

@@ -0,0 +1,417 @@
+import SwiftData
+import SwiftUI
+import UniformTypeIdentifiers
+
+/// Sidebar — playlist folders and playlists with drag & drop.
+struct SidebarView: View {
+    @Binding var selectedPlaylist: Playlist?
+    @Binding var showNewPlaylistSheet: Bool
+    @Binding var showCloudBrowser: Bool
+
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @Environment(\.modelContext) private var modelContext
+    @EnvironmentObject private var theme: AppTheme
+
+    @Query(sort: \Track.title) private var allTracks: [Track]
+    @Query(sort: \Playlist.dateModified, order: .reverse) private var allPlaylists: [Playlist]
+    @Query(sort: \PlaylistFolder.dateCreated) private var folders: [PlaylistFolder]
+
+    @State private var showNewFolderAlert = false
+    @State private var newFolderName = ""
+
+    /// Playlists not in any folder.
+    private var unfolderedPlaylists: [Playlist] {
+        allPlaylists.filter { $0.folder == nil }
+    }
+
+    var body: some View {
+        List(selection: $selectedPlaylist) {
+            // Cloud Library
+            Section("Cloud") {
+                Button {
+                    showCloudBrowser = true
+                    selectedPlaylist = nil
+                } label: {
+                    Label("Chad Music", systemImage: "cloud.fill")
+                        .foregroundStyle(showCloudBrowser ? Color.accentColor : .primary)
+                }
+                .buttonStyle(.plain)
+            }
+
+            Section("Playlists") {
+                // Folders
+                ForEach(folders) { folder in
+                    FolderRowView(
+                        folder: folder,
+                        selectedPlaylist: $selectedPlaylist,
+                        onDrop: { providers, playlist in
+                            handleDrop(providers: providers, playlist: playlist)
+                        }
+                    )
+                }
+
+                // Playlists not in a folder
+                ForEach(unfolderedPlaylists) { playlist in
+                    playlistRow(playlist)
+                }
+
+                // Actions
+                HStack(spacing: 16) {
+                    Button {
+                        showNewPlaylistSheet = true
+                    } label: {
+                        Image(systemName: "plus.circle.fill")
+                            .font(.system(size: 18))
+                    }
+                    .buttonStyle(.plain)
+                    .help("New Playlist")
+
+                    Button {
+                        newFolderName = ""
+                        showNewFolderAlert = true
+                    } label: {
+                        Image(systemName: "folder.badge.plus")
+                            .font(.system(size: 18))
+                    }
+                    .buttonStyle(.plain)
+                    .help("New Folder")
+                }
+                .foregroundStyle(theme.secondaryText)
+            }
+        }
+        .listStyle(.sidebar)
+        .navigationTitle("MixBoard")
+        .alert("New Folder", isPresented: $showNewFolderAlert) {
+            TextField("Folder name", text: $newFolderName)
+            Button("Cancel", role: .cancel) {}
+            Button("Create") {
+                guard !newFolderName.isEmpty else { return }
+                let folder = PlaylistFolder(name: newFolderName)
+                modelContext.insert(folder)
+                try? modelContext.save()
+            }
+        }
+    }
+
+    // MARK: - Playlist Row
+
+    private func playlistRow(_ playlist: Playlist) -> some View {
+        PlaylistRow(playlist: playlist)
+            .tag(playlist)
+            .draggable(playlist.id.uuidString)
+            .onDrop(of: [.chadTrack, .chadAlbum, .utf8PlainText], isTargeted: nil) { providers in
+                handleDrop(providers: providers, playlist: playlist)
+                return true
+            }
+            .contextMenu {
+                playlistContextMenu(playlist)
+            }
+    }
+
+    private func handleDrop(providers: [NSItemProvider], playlist: Playlist) {
+        print("SidebarView: handleDrop called with \(providers.count) providers for playlist '\(playlist.name)'")
+        for provider in providers {
+            print("  Provider types: \(provider.registeredTypeIdentifiers)")
+            // Cloud track
+            if provider.hasItemConformingToTypeIdentifier("com.mixboard.chad-track") {
+                print("  → Matched chad-track")
+                provider.loadDataRepresentation(forTypeIdentifier: "com.mixboard.chad-track") { data, error in
+                    if let error { print("  → Load error: \(error)"); return }
+                    guard let data, let track = try? JSONDecoder().decode(ChadTrack.self, from: data) else {
+                        print("  → Decode failed")
+                        return
+                    }
+                    print("  → Decoded track: \(track.title)")
+                    Task { @MainActor in
+                        self.addCloudTracksToPlaylist([track], playlist: playlist)
+                    }
+                }
+            }
+            // Cloud album
+            else if provider.hasItemConformingToTypeIdentifier("com.mixboard.chad-album") {
+                provider.loadDataRepresentation(forTypeIdentifier: "com.mixboard.chad-album") { data, _ in
+                    guard let data, let album = try? JSONDecoder().decode(ChadAlbum.self, from: data) else { return }
+                    Task { @MainActor in
+                        addCloudAlbumsToPlaylist([album], playlist: playlist)
+                    }
+                }
+            }
+            // Local track ID (string)
+            else if provider.hasItemConformingToTypeIdentifier("public.utf8-plain-text") {
+                provider.loadItem(forTypeIdentifier: "public.utf8-plain-text") { item, _ in
+                    guard let data = item as? Data, let idString = String(data: data, encoding: .utf8) else { return }
+                    Task { @MainActor in
+                        addTracksToPlaylist(trackIDs: [idString], playlist: playlist)
+                    }
+                }
+            }
+        }
+    }
+
+    @ViewBuilder
+    private func playlistContextMenu(_ playlist: Playlist) -> some View {
+        // Set as mix target
+        let mixShortcuts: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
+        Menu("Set as Mix Target") {
+            ForEach(0..<3, id: \.self) { slot in
+                let isCurrent = playlistVM.mixTargets[slot]?.id == playlist.id
+                let hint = KeyboardShortcutConfig.shared.binding(for: mixShortcuts[slot]).displayString
+                Button(isCurrent ? "✓ Mix \(slot + 1)" : "Mix \(slot + 1) (\(hint))") {
+                    playlistVM.setMixTarget(slot, playlist: playlist)
+                    playlistVM.showStatus("\"\(playlist.name)\" → Mix \(slot + 1)")
+                }
+            }
+        }
+
+        Divider()
+
+        // Move to folder
+        if !folders.isEmpty {
+            Menu("Move to Folder") {
+                ForEach(folders) { folder in
+                    Button(folder.name) {
+                        playlist.folder = folder
+                        try? modelContext.save()
+                    }
+                }
+                Divider()
+                Button("No Folder") {
+                    playlist.folder = nil
+                    try? modelContext.save()
+                }
+            }
+        }
+
+        Divider()
+
+        Button("Delete Playlist", role: .destructive) {
+            playlistVM.deletePlaylist(playlist, context: modelContext)
+        }
+    }
+
+    private func addTracksToPlaylist(trackIDs: [String], playlist: Playlist) {
+        for idString in trackIDs {
+            guard let uuid = UUID(uuidString: idString),
+                  let track = allTracks.first(where: { $0.id == uuid }) else { continue }
+            playlistVM.addTrack(track, to: playlist, context: modelContext)
+        }
+    }
+
+    private func addCloudTracksToPlaylist(_ chadTracks: [ChadTrack], playlist: Playlist) {
+        for chadTrack in chadTracks {
+            let cloudId = chadTrack.id
+            let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.cloudTrackId == cloudId })
+            let existing = try? modelContext.fetch(descriptor).first
+            let track = existing ?? Track.fromCloud(chadTrack)
+            if existing == nil {
+                modelContext.insert(track)
+            }
+            playlist.addTrack(track)
+        }
+    }
+
+    private func addCloudAlbumsToPlaylist(_ albums: [ChadAlbum], playlist: Playlist) {
+        Task {
+            let client = ChadMusicAPIClient.shared
+            for album in albums {
+                guard let tracks = try? await client.fetchAlbumTracks(albumId: album.id) else { continue }
+                addCloudTracksToPlaylist(tracks, playlist: playlist)
+            }
+        }
+    }
+}
+
+// MARK: - Folder Row
+
+private struct FolderRowView: View {
+    let folder: PlaylistFolder
+    @Binding var selectedPlaylist: Playlist?
+    let onDrop: ([NSItemProvider], Playlist) -> Void
+
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @Environment(\.modelContext) private var modelContext
+    @EnvironmentObject private var theme: AppTheme
+
+    @Query(sort: \PlaylistFolder.dateCreated) private var allFolders: [PlaylistFolder]
+
+    @State private var isExpanded: Bool = true
+    @State private var showRenameAlert = false
+    @State private var renameName = ""
+
+    var body: some View {
+        DisclosureGroup(isExpanded: $isExpanded) {
+            ForEach(folder.sortedPlaylists) { playlist in
+                PlaylistRow(playlist: playlist)
+                    .tag(playlist)
+                    .draggable(playlist.id.uuidString)
+                    .onDrop(of: [.chadTrack, .chadAlbum, .utf8PlainText], isTargeted: nil) { providers in
+                        onDrop(providers, playlist)
+                        return true
+                    }
+                    .contextMenu {
+                        // Set as mix target
+                        let mixShortcuts: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3]
+                        Menu("Set as Mix Target") {
+                            ForEach(0..<3, id: \.self) { slot in
+                                let isCurrent = playlistVM.mixTargets[slot]?.id == playlist.id
+                                let hint = KeyboardShortcutConfig.shared.binding(for: mixShortcuts[slot]).displayString
+                                Button(isCurrent ? "✓ Mix \(slot + 1)" : "Mix \(slot + 1) (\(hint))") {
+                                    playlistVM.setMixTarget(slot, playlist: playlist)
+                                    playlistVM.showStatus("\"\(playlist.name)\" → Mix \(slot + 1)")
+                                }
+                            }
+                        }
+
+                        Divider()
+
+                        Button("Remove from Folder") {
+                            playlist.folder = nil
+                            try? modelContext.save()
+                        }
+
+                        // Move to different folder
+                        let otherFolders = allFolders.filter { $0.id != folder.id }
+                        if !otherFolders.isEmpty {
+                            Menu("Move to Folder") {
+                                ForEach(otherFolders) { f in
+                                    Button(f.name) {
+                                        playlist.folder = f
+                                        try? modelContext.save()
+                                    }
+                                }
+                            }
+                        }
+
+                        Divider()
+
+                        Button("Delete Playlist", role: .destructive) {
+                            modelContext.delete(playlist)
+                            try? modelContext.save()
+                        }
+                    }
+            }
+        } label: {
+            HStack(spacing: 6) {
+                Image(systemName: "folder.fill")
+                    .foregroundStyle(theme.accent)
+                    .font(.system(size: 14))
+
+                Text(folder.name)
+                    .foregroundStyle(theme.primaryText)
+
+                Text("(\(folder.playlists.count))")
+                    .font(.caption2)
+                    .foregroundStyle(theme.tertiaryText)
+            }
+            .contentShape(Rectangle())
+            .contextMenu {
+                Button("Rename Folder...") {
+                    renameName = folder.name
+                    showRenameAlert = true
+                }
+                Divider()
+                Button("Delete Folder", role: .destructive) {
+                    for pl in folder.playlists {
+                        pl.folder = nil
+                    }
+                    modelContext.delete(folder)
+                    try? modelContext.save()
+                }
+            }
+        }
+        .dropDestination(for: String.self) { items, _ in
+            handlePlaylistDrop(items)
+            return true
+        }
+        .alert("Rename Folder", isPresented: $showRenameAlert) {
+            TextField("Folder name", text: $renameName)
+            Button("Cancel", role: .cancel) {}
+            Button("Rename") {
+                folder.name = renameName
+                try? modelContext.save()
+            }
+        }
+        .onAppear {
+            isExpanded = folder.isExpanded
+        }
+        .onChange(of: isExpanded) { _, newValue in
+            folder.isExpanded = newValue
+            try? modelContext.save()
+        }
+    }
+
+    private func handlePlaylistDrop(_ items: [String]) {
+        // Items are playlist UUID strings — move them into this folder
+        for idString in items {
+            guard let uuid = UUID(uuidString: idString) else { continue }
+
+            let descriptor = FetchDescriptor<Playlist>(predicate: #Predicate { $0.id == uuid })
+            if let playlist = try? modelContext.fetch(descriptor).first {
+                playlist.folder = folder
+            }
+        }
+        try? modelContext.save()
+    }
+}
+
+// MARK: - Playlist Row
+
+private struct PlaylistRow: View {
+    let playlist: Playlist
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(PlaylistViewModel.self) private var playlistVM
+
+    /// Which mix slot(s) this playlist is assigned to (0, 1, 2), if any.
+    private var assignedSlots: [Int] {
+        (0..<3).filter { playlistVM.mixTargets[$0]?.id == playlist.id }
+    }
+
+    var body: some View {
+        HStack {
+            Image(systemName: assignedSlots.isEmpty ? "music.note.list" : "target")
+                .foregroundStyle(assignedSlots.isEmpty ? (Color(hex: playlist.color) ?? theme.accent) : theme.accent)
+
+            VStack(alignment: .leading, spacing: 2) {
+                Text(playlist.name)
+                    .foregroundStyle(theme.primaryText)
+                    .lineLimit(1)
+
+                Text("\(playlist.trackCount) tracks · \(playlist.formattedTotalDuration)")
+                    .font(.caption2)
+                    .foregroundStyle(theme.secondaryText)
+            }
+
+            Spacer(minLength: 4)
+
+            // Show mix slot badges
+            ForEach(assignedSlots, id: \.self) { slot in
+                Text("\(slot + 1)")
+                    .font(.system(size: 9, weight: .bold, design: .rounded))
+                    .frame(width: 16, height: 16)
+                    .foregroundStyle(mixTargetColors[slot])
+                    .background(mixTargetColors[slot].opacity(0.15))
+                    .clipShape(RoundedRectangle(cornerRadius: 3))
+            }
+        }
+    }
+}
+
+// MARK: - Color Extension
+
+extension Color {
+    init?(hex: String) {
+        let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
+        var int: UInt64 = 0
+        Scanner(string: hex).scanHexInt64(&int)
+        let r, g, b: Double
+        switch hex.count {
+        case 6:
+            r = Double((int >> 16) & 0xFF) / 255
+            g = Double((int >> 8) & 0xFF) / 255
+            b = Double(int & 0xFF) / 255
+        default:
+            return nil
+        }
+        self.init(red: r, green: g, blue: b)
+    }
+}

+ 48 - 0
Sources/Views/TrackRow.swift

@@ -0,0 +1,48 @@
+import SwiftUI
+
+/// Compact track row for track lists.
+struct TrackRow: View {
+    let track: Track
+
+    var body: some View {
+        HStack(spacing: 8) {
+            // Album art placeholder / format badge
+            ZStack {
+                RoundedRectangle(cornerRadius: 4)
+                    .fill(trackColor.opacity(0.15))
+                    .frame(width: 32, height: 32)
+
+                Image(systemName: "music.note")
+                    .font(.caption)
+                    .foregroundStyle(trackColor)
+            }
+
+            VStack(alignment: .leading, spacing: 1) {
+                HStack(spacing: 4) {
+                    Text(track.title)
+                        .lineLimit(1)
+                        .font(.body)
+                    if track.isCloud {
+                        Image(systemName: "cloud.fill")
+                            .font(.system(size: 8))
+                            .foregroundStyle(.secondary)
+                    }
+                }
+
+                if !track.artist.isEmpty {
+                    Text(track.artist)
+                        .lineLimit(1)
+                        .font(.caption)
+                        .foregroundStyle(.secondary)
+                }
+            }
+        }
+    }
+
+    private var trackColor: Color {
+        if let hex = track.color {
+            return Color(hex: hex) ?? .accentColor
+        }
+        return .accentColor
+    }
+}

+ 118 - 0
Sources/Views/WaveformView.swift

@@ -0,0 +1,118 @@
+import SwiftUI
+
+/// Interactive waveform display with playback progress and seek.
+struct WaveformView: View {
+    let samples: [WaveformGenerator.WaveformSample]
+    let progress: Double
+    let isLoading: Bool
+    let onSeek: (Double) -> Void
+
+    @State private var hoveredProgress: Double?
+    @State private var isDragging = false
+
+    var body: some View {
+        GeometryReader { geometry in
+            ZStack {
+                if isLoading {
+                    ProgressView("Generating waveform...")
+                        .font(.caption)
+                } else if samples.isEmpty {
+                    RoundedRectangle(cornerRadius: 4)
+                        .fill(.quaternary)
+                } else {
+                    // Waveform canvas
+                    Canvas { context, size in
+                        drawWaveform(context: context, size: size)
+                    }
+                    .gesture(waveformGesture(width: geometry.size.width))
+                    .onHover { hovering in
+                        if !hovering { hoveredProgress = nil }
+                    }
+                    .onContinuousHover { phase in
+                        switch phase {
+                        case .active(let location):
+                            hoveredProgress = location.x / geometry.size.width
+                        case .ended:
+                            hoveredProgress = nil
+                        @unknown default:
+                            break
+                        }
+                    }
+
+                    // Hover indicator
+                    if let hovered = hoveredProgress, !isDragging {
+                        Rectangle()
+                            .fill(.white.opacity(0.3))
+                            .frame(width: 1)
+                            .position(x: hovered * geometry.size.width, y: geometry.size.height / 2)
+                            .allowsHitTesting(false)
+                    }
+                }
+            }
+            .clipShape(RoundedRectangle(cornerRadius: 6))
+            .background(
+                RoundedRectangle(cornerRadius: 6)
+                    .fill(.black.opacity(0.3))
+            )
+        }
+    }
+
+    // MARK: - Drawing
+
+    private func drawWaveform(context: GraphicsContext, size: CGSize) {
+        let width = size.width
+        let height = size.height
+        let midY = height / 2
+        let count = samples.count
+
+        guard count > 0 else { return }
+
+        let playedIndex = Int(progress * Double(count))
+
+        // Draw each column
+        for (index, sample) in samples.enumerated() {
+            let x = CGFloat(index) / CGFloat(count) * width
+            let barWidth = max(1, width / CGFloat(count))
+
+            let maxHeight = CGFloat(sample.max) * midY
+            let minHeight = CGFloat(sample.min) * midY
+
+            let rect = CGRect(
+                x: x,
+                y: midY - maxHeight,
+                width: barWidth,
+                height: maxHeight - minHeight
+            )
+
+            let color: Color
+            if index < playedIndex {
+                color = .accentColor
+            } else {
+                color = .gray.opacity(0.5)
+            }
+
+            context.fill(Path(rect), with: .color(color))
+        }
+
+        // Playhead line
+        let playheadX = progress * width
+        var playheadPath = Path()
+        playheadPath.move(to: CGPoint(x: playheadX, y: 0))
+        playheadPath.addLine(to: CGPoint(x: playheadX, y: height))
+        context.stroke(playheadPath, with: .color(.white), lineWidth: 1.5)
+    }
+
+    // MARK: - Gesture
+
+    private func waveformGesture(width: CGFloat) -> some Gesture {
+        DragGesture(minimumDistance: 0)
+            .onChanged { value in
+                isDragging = true
+                let prog = max(0, min(1, value.location.x / width))
+                onSeek(prog)
+            }
+            .onEnded { _ in
+                isDragging = false
+            }
+    }
+}

+ 283 - 0
Tests/E2E/E2EWorkflowTests.swift

@@ -0,0 +1,283 @@
+import XCTest
+import AVFoundation
+@testable import MixBoard
+
+/// End-to-end integration tests exercising full workflows.
+final class E2EWorkflowTests: XCTestCase {
+
+    private var outputDir: URL!
+
+    override func setUp() {
+        super.setUp()
+        outputDir = FileManager.default.temporaryDirectory
+            .appendingPathComponent("MixBoardE2E", isDirectory: true)
+        try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
+    }
+
+    override func tearDown() {
+        super.tearDown()
+        try? FileManager.default.removeItem(at: outputDir)
+        TestHelpers.cleanupTestFiles()
+    }
+
+    // MARK: - Full Import → Playlist → Export Workflow
+
+    func testImportAndBuildPlaylistWorkflow() async throws {
+        // 1. Create test audio files
+        let urls = try TestHelpers.createTestAudioFiles(count: 3, duration: 2.0)
+        XCTAssertEqual(urls.count, 3)
+
+        // 2. Read metadata for each
+        var tracks: [Track] = []
+        for url in urls {
+            let metadata = try await MetadataService.readMetadata(from: url)
+            let track = Track(
+                title: metadata.title,
+                artist: "Test Artist",
+                filePath: url.path,
+                duration: metadata.duration,
+                sampleRate: metadata.sampleRate,
+                bitDepth: metadata.bitDepth,
+                channels: metadata.channels,
+                fileFormat: metadata.fileFormat,
+                fileSizeBytes: metadata.fileSizeBytes
+            )
+            tracks.append(track)
+        }
+        XCTAssertEqual(tracks.count, 3)
+
+        // 3. Build a playlist
+        let playlist = Playlist(name: "E2E Test Mix")
+        for track in tracks {
+            playlist.addTrack(track, crossfadeDuration: 0.5)
+        }
+        XCTAssertEqual(playlist.trackCount, 3)
+        XCTAssertGreaterThan(playlist.totalDuration, 5.0)
+
+        // 4. Export as CUE sheet
+        let cueURL = outputDir.appendingPathComponent("e2e_test.cue")
+        var options = ExportOptions.default
+        options.copyAudioFiles = false
+        try CueSheetExporter.export(playlist: playlist, to: cueURL, options: options)
+
+        let cueContent = try String(contentsOf: cueURL, encoding: .utf8)
+        XCTAssertTrue(cueContent.contains("TITLE \"E2E Test Mix\""))
+        XCTAssertTrue(cueContent.contains("TRACK 01"))
+        XCTAssertTrue(cueContent.contains("TRACK 02"))
+        XCTAssertTrue(cueContent.contains("TRACK 03"))
+
+        // 5. Export as Audition session
+        let sesxURL = outputDir.appendingPathComponent("e2e_test.sesx")
+        try AuditionExporter.export(playlist: playlist, to: sesxURL, options: options)
+
+        let sesxContent = try String(contentsOf: sesxURL, encoding: .utf8)
+        XCTAssertTrue(sesxContent.contains("<!DOCTYPE sesx>"))
+        XCTAssertTrue(sesxContent.contains("<audioClip"))
+
+        // 6. Export as M3U
+        let m3uURL = outputDir.appendingPathComponent("e2e_test.m3u")
+        try M3UExporter.export(playlist: playlist, to: m3uURL, options: options)
+
+        let m3uContent = try String(contentsOf: m3uURL, encoding: .utf8)
+        XCTAssertTrue(m3uContent.contains("#EXTM3U"))
+    }
+
+    // MARK: - Audio Stitch Workflow
+
+    @MainActor
+    func testStitchWorkflow() async throws {
+        // 1. Create test audio files
+        let urls = try TestHelpers.createTestAudioFiles(count: 3, duration: 1.0)
+
+        // 2. Build playlist with tracks
+        let playlist = Playlist(name: "Stitch Test")
+        for url in urls {
+            let metadata = try await MetadataService.readMetadata(from: url)
+            let track = Track(
+                title: url.deletingPathExtension().lastPathComponent,
+                artist: "Test",
+                filePath: url.path,
+                duration: metadata.duration,
+                fileFormat: metadata.fileFormat
+            )
+            playlist.addTrack(track)
+        }
+
+        // 3. Stitch to single file
+        let outputURL = outputDir.appendingPathComponent("stitched.wav")
+        let result = try await AudioStitcher.stitch(
+            playlist: playlist,
+            to: outputURL,
+            options: .default
+        )
+
+        // 4. Verify output
+        XCTAssertTrue(FileManager.default.fileExists(atPath: outputURL.path))
+        XCTAssertEqual(result.markers.count, 3)
+        XCTAssertGreaterThan(result.totalDuration, 2.5) // 3 tracks × ~1s
+
+        // Verify markers are sequential
+        for i in 1..<result.markers.count {
+            XCTAssertGreaterThanOrEqual(
+                result.markers[i].startTime,
+                result.markers[i - 1].endTime - 0.01,
+                "Markers should be sequential"
+            )
+        }
+
+        // 5. Verify stitched file is readable
+        let stitchedFile = try AVAudioFile(forReading: outputURL)
+        XCTAssertGreaterThan(stitchedFile.length, 0)
+
+        // 6. Write companion markers CSV
+        let csvURL = outputDir.appendingPathComponent("markers.csv")
+        try AudioStitcher.writeAuditionMarkers(result.markers, to: csvURL)
+        let csv = try String(contentsOf: csvURL, encoding: .utf8)
+        XCTAssertTrue(csv.contains("Name\tStart"))
+        XCTAssertTrue(csv.contains("test_track_1"))
+
+        // 7. Write CUE sheet
+        let cueURL = outputDir.appendingPathComponent("stitched.cue")
+        try AudioStitcher.writeCueSheet(
+            result.markers,
+            audioFileName: "stitched.wav",
+            playlistName: "Stitch Test",
+            to: cueURL
+        )
+        let cue = try String(contentsOf: cueURL, encoding: .utf8)
+        XCTAssertTrue(cue.contains("FILE \"stitched.wav\" WAVE"))
+
+        // 8. Write track list
+        let listURL = outputDir.appendingPathComponent("tracklist.txt")
+        try AudioStitcher.writeTrackList(result.markers, playlistName: "Stitch Test", to: listURL)
+        let list = try String(contentsOf: listURL, encoding: .utf8)
+        XCTAssertTrue(list.contains("Stitch Test"))
+        XCTAssertTrue(list.contains("Total: 3 tracks"))
+    }
+
+    // MARK: - Waveform → Analysis Workflow
+
+    func testAnalysisWorkflow() async throws {
+        let url = try TestHelpers.createTestAudioFile(name: "analysis_e2e", duration: 5.0, frequency: 440)
+
+        // 1. Read metadata
+        let metadata = try await MetadataService.readMetadata(from: url)
+        XCTAssertGreaterThan(metadata.duration, 4.0)
+
+        // 2. Generate waveform
+        let waveform = try await WaveformGenerator.generateWaveform(fileURL: url, resolution: 200)
+        XCTAssertEqual(waveform.count, 200)
+
+        // 3. Detect BPM (may fail with test-generated audio)
+        do {
+            let bpm = try await BPMDetector.detectBPM(fileURL: url)
+            XCTAssertGreaterThanOrEqual(bpm, 60)
+            XCTAssertLessThanOrEqual(bpm, 200)
+        } catch {
+            // Acceptable for test WAV
+        }
+
+        // 4. Detect key (may fail with test-generated audio)
+        do {
+            let keyResult = try await KeyDetector.detectKey(fileURL: url)
+            XCTAssertFalse(keyResult.key.isEmpty)
+        } catch {
+            // Acceptable for test WAV
+        }
+    }
+
+    // MARK: - Multi-Format Export Consistency
+
+    func testAllFormatsExport() throws {
+        let playlist = Playlist(name: "Multi Format Test")
+        let track = Track(title: "Song", artist: "Artist", filePath: "/tmp/song.mp3", duration: 120, fileFormat: "MP3")
+        playlist.addTrack(track)
+
+        var options = ExportOptions.default
+        options.copyAudioFiles = false
+
+        // Export in all formats and verify files are created
+        for format in MixExporter.ExportFormat.allCases {
+            let url = outputDir.appendingPathComponent("test_\(format.rawValue).\(format.fileExtension)")
+            try MixExporter.export(playlist: playlist, format: format, to: url, options: options)
+
+            // DAWproject writes to a different path
+            let readURL: URL
+            if format == .dawProject {
+                readURL = url.deletingPathExtension().appendingPathExtension("dawproject.xml")
+            } else {
+                readURL = url
+            }
+
+            let content = try String(contentsOf: readURL, encoding: .utf8)
+            XCTAssertGreaterThan(content.count, 10, "Format \(format.name) should produce non-empty output")
+        }
+    }
+
+    // MARK: - Playlist Operations
+
+    func testPlaylistCRUDOperations() {
+        // Create
+        let pl = Playlist(name: "CRUD Test")
+        XCTAssertEqual(pl.name, "CRUD Test")
+        XCTAssertEqual(pl.trackCount, 0)
+
+        // Add tracks
+        let t1 = Track(title: "A", filePath: "/a", duration: 60)
+        let t2 = Track(title: "B", filePath: "/b", duration: 90)
+        let t3 = Track(title: "C", filePath: "/c", duration: 120)
+
+        pl.addTrack(t1)
+        pl.addTrack(t2)
+        pl.addTrack(t3)
+        XCTAssertEqual(pl.trackCount, 3)
+        XCTAssertEqual(pl.sortedEntries[0].track?.title, "A")
+        XCTAssertEqual(pl.sortedEntries[2].track?.title, "C")
+
+        // Move
+        pl.moveEntry(from: 0, to: 2)
+        XCTAssertEqual(pl.sortedEntries[0].track?.title, "B")
+        XCTAssertEqual(pl.sortedEntries[2].track?.title, "A")
+
+        // Remove
+        pl.removeEntry(at: 1)
+        XCTAssertEqual(pl.trackCount, 2)
+    }
+
+    // MARK: - Cue Points
+
+    func testCuePointWorkflow() {
+        let track = Track(title: "T", filePath: "/t", duration: 300)
+
+        let intro = CuePoint(name: "Intro", timestamp: 0, type: .intro)
+        let verse = CuePoint(name: "Verse 1", timestamp: 30, type: .verse)
+        let drop = CuePoint(name: "Drop", timestamp: 120, type: .drop)
+        let outro = CuePoint(name: "Outro", timestamp: 270, type: .outro)
+
+        track.cuePoints = [drop, outro, intro, verse]
+
+        let sorted = track.cuePoints.sorted()
+        XCTAssertEqual(sorted[0].name, "Intro")
+        XCTAssertEqual(sorted[1].name, "Verse 1")
+        XCTAssertEqual(sorted[2].name, "Drop")
+        XCTAssertEqual(sorted[3].name, "Outro")
+    }
+
+    // MARK: - Crossfade Timeline Calculation
+
+    func testCrossfadeTimeline() {
+        let pl = Playlist(name: "CF Test")
+        let t1 = Track(title: "A", filePath: "/a", duration: 60)
+        let t2 = Track(title: "B", filePath: "/b", duration: 60)
+        let t3 = Track(title: "C", filePath: "/c", duration: 60)
+
+        pl.addTrack(t1, crossfadeDuration: 0)
+        pl.addTrack(t2, crossfadeDuration: 5)  // 5s overlap with A
+        pl.addTrack(t3, crossfadeDuration: 10) // 10s overlap with B
+
+        // Total = 60 + 60 + 60 = 180 (raw), but crossfades subtract from effective
+        // However, playlist.totalDuration sums track durations (not considering crossfade)
+        XCTAssertEqual(pl.totalDuration, 180)
+        XCTAssertEqual(pl.trackCount, 3)
+    }
+}

+ 481 - 0
Tests/E2E/IntegrationTests.swift

@@ -0,0 +1,481 @@
+import XCTest
+import SwiftData
+@testable import MixBoard
+
+/// Integration tests that exercise full app workflows programmatically.
+/// These test the ViewModels, services, and data flow as a user would interact.
+final class IntegrationTests: XCTestCase {
+
+    override func tearDown() {
+        super.tearDown()
+        TestHelpers.cleanupTestFiles()
+    }
+
+    // MARK: - Player ViewModel Flow
+
+    @MainActor
+    func testPlayerLoadAndPlayFlow() async throws {
+        let url = try TestHelpers.createTestAudioFile(name: "play_test", duration: 2.0)
+        let track = Track(title: "Test Song", artist: "Artist", filePath: url.path, duration: 2.0, fileFormat: "WAV")
+
+        let playerVM = PlayerViewModel()
+
+        // Initially stopped
+        XCTAssertFalse(playerVM.isPlaying)
+        XCTAssertNil(playerVM.currentTrack)
+        XCTAssertNil(playerVM.currentPlayingEntryID)
+
+        // Load and play
+        playerVM.loadAndPlay(track)
+        playerVM.syncForTest()
+
+        XCTAssertTrue(playerVM.isPlaying)
+        XCTAssertEqual(playerVM.currentTrack?.title, "Test Song")
+        XCTAssertGreaterThan(playerVM.duration, 1.0)
+
+        // Pause
+        playerVM.togglePlayPause()
+        playerVM.syncForTest()
+        XCTAssertFalse(playerVM.isPlaying)
+
+        // Resume
+        playerVM.togglePlayPause()
+        playerVM.syncForTest()
+        XCTAssertTrue(playerVM.isPlaying)
+
+        // Stop
+        playerVM.stop()
+        playerVM.syncForTest()
+        XCTAssertFalse(playerVM.isPlaying)
+        XCTAssertEqual(playerVM.currentTime, 0)
+    }
+
+    @MainActor
+    func testPlayerSeekFlow() async throws {
+        let url = try TestHelpers.createTestAudioFile(name: "seek_test", duration: 5.0)
+        let track = Track(title: "Seek", filePath: url.path, duration: 5.0, fileFormat: "WAV")
+
+        let playerVM = PlayerViewModel()
+        playerVM.loadAndPlay(track)
+        playerVM.syncForTest()
+
+        // Seek to middle
+        playerVM.seekToProgress(0.5)
+        playerVM.syncForTest()
+
+        // Time should be approximately half
+        XCTAssertGreaterThan(playerVM.currentTime, 1.5)
+        XCTAssertLessThan(playerVM.currentTime, 3.5)
+
+        // Should still be playing
+        XCTAssertTrue(playerVM.isPlaying)
+
+        playerVM.stop()
+    }
+
+    @MainActor
+    func testPlayerVolumeControl() {
+        let playerVM = PlayerViewModel()
+
+        XCTAssertEqual(playerVM.volume, 0.8, accuracy: 0.01) // default
+        playerVM.volume = 0.5
+        XCTAssertEqual(playerVM.volume, 0.5, accuracy: 0.01)
+        playerVM.volume = 0.0
+        XCTAssertEqual(playerVM.volume, 0.0, accuracy: 0.01)
+        playerVM.volume = 1.0
+        XCTAssertEqual(playerVM.volume, 1.0, accuracy: 0.01)
+    }
+
+    // MARK: - Playlist ViewModel Flow
+
+    @MainActor
+    func testPlaylistNextPreviousFlow() async throws {
+        let urls = try TestHelpers.createTestAudioFiles(count: 3, duration: 1.0)
+
+        let playlist = Playlist(name: "Nav Test")
+        var tracks: [Track] = []
+        for (i, url) in urls.enumerated() {
+            let t = Track(title: "Track \(i+1)", filePath: url.path, duration: 1.0, fileFormat: "WAV")
+            playlist.addTrack(t)
+            tracks.append(t)
+        }
+
+        let playerVM = PlayerViewModel()
+
+        // Play first track
+        let entries = playlist.sortedEntries
+        playerVM.loadAndPlay(tracks[0], entryID: entries[0].id, playlist: playlist)
+        playerVM.syncForTest()
+        XCTAssertEqual(playerVM.currentTrack?.title, "Track 1")
+        XCTAssertEqual(playerVM.currentPlayingEntryID, entries[0].id)
+
+        // Next
+        playerVM.playNext()
+        playerVM.syncForTest()
+        XCTAssertEqual(playerVM.currentTrack?.title, "Track 2")
+
+        // Next
+        playerVM.playNext()
+        playerVM.syncForTest()
+        XCTAssertEqual(playerVM.currentTrack?.title, "Track 3")
+
+        // Next at end — should stop
+        playerVM.playNext()
+        playerVM.syncForTest()
+        XCTAssertFalse(playerVM.isPlaying)
+
+        // Play last, then previous
+        playerVM.loadAndPlay(tracks[2], entryID: entries[2].id, playlist: playlist)
+        playerVM.syncForTest()
+        playerVM.playPrevious() // currentTime < 3, go to previous
+        playerVM.syncForTest()
+        XCTAssertEqual(playerVM.currentTrack?.title, "Track 2")
+
+        playerVM.stop()
+    }
+
+    @MainActor
+    func testRepeatAllMode() async throws {
+        let urls = try TestHelpers.createTestAudioFiles(count: 2, duration: 1.0)
+
+        let playlist = Playlist(name: "Repeat Test")
+        var tracks: [Track] = []
+        for (i, url) in urls.enumerated() {
+            let t = Track(title: "Track \(i+1)", filePath: url.path, duration: 1.0, fileFormat: "WAV")
+            playlist.addTrack(t)
+            tracks.append(t)
+        }
+
+        let playerVM = PlayerViewModel()
+        playerVM.repeatMode = .all
+
+        let entries = playlist.sortedEntries
+        playerVM.loadAndPlay(tracks[1], entryID: entries[1].id, playlist: playlist)
+        playerVM.syncForTest()
+        XCTAssertEqual(playerVM.currentTrack?.title, "Track 2")
+
+        // Next from last track should wrap to first
+        playerVM.playNext()
+        playerVM.syncForTest()
+        XCTAssertEqual(playerVM.currentTrack?.title, "Track 1")
+        XCTAssertTrue(playerVM.isPlaying)
+
+        playerVM.stop()
+    }
+
+    @MainActor
+    func testRepeatOneMode() async throws {
+        let urls = try TestHelpers.createTestAudioFiles(count: 2, duration: 1.0)
+
+        let playlist = Playlist(name: "Repeat1 Test")
+        var tracks: [Track] = []
+        for (i, url) in urls.enumerated() {
+            let t = Track(title: "Track \(i+1)", filePath: url.path, duration: 1.0, fileFormat: "WAV")
+            playlist.addTrack(t)
+            tracks.append(t)
+        }
+
+        let playerVM = PlayerViewModel()
+        playerVM.repeatMode = .one
+
+        let entries = playlist.sortedEntries
+        playerVM.loadAndPlay(tracks[0], entryID: entries[0].id, playlist: playlist)
+        playerVM.syncForTest()
+
+        // Next should replay same track
+        playerVM.playNext()
+        playerVM.syncForTest()
+        XCTAssertEqual(playerVM.currentTrack?.title, "Track 1")
+
+        playerVM.stop()
+    }
+
+    @MainActor
+    func testShuffleMode() async throws {
+        let urls = try TestHelpers.createTestAudioFiles(count: 5, duration: 0.5)
+
+        let playlist = Playlist(name: "Shuffle Test")
+        var tracks: [Track] = []
+        for (i, url) in urls.enumerated() {
+            let t = Track(title: "Track \(i+1)", filePath: url.path, duration: 0.5, fileFormat: "WAV")
+            playlist.addTrack(t)
+            tracks.append(t)
+        }
+
+        let playerVM = PlayerViewModel()
+        playerVM.shuffleEnabled = true
+
+        let entries = playlist.sortedEntries
+        playerVM.loadAndPlay(tracks[0], entryID: entries[0].id, playlist: playlist)
+        playerVM.syncForTest()
+        let firstTitle = playerVM.currentTrack?.title
+
+        // Next should play a different track (with 5 tracks, very likely)
+        playerVM.playNext()
+        playerVM.syncForTest()
+        XCTAssertTrue(playerVM.isPlaying)
+        // Can't guarantee it's different (random), but it should still be a valid track
+        XCTAssertNotNil(playerVM.currentTrack)
+
+        playerVM.stop()
+    }
+
+    @MainActor
+    func testShuffleAndRepeatDefaults() {
+        let playerVM = PlayerViewModel()
+        XCTAssertFalse(playerVM.shuffleEnabled)
+        XCTAssertEqual(playerVM.repeatMode, .off)
+    }
+
+    func testSessionExportFlow() throws {
+        let outputDir = FileManager.default.temporaryDirectory
+            .appendingPathComponent("IntegrationExport", isDirectory: true)
+        try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
+        defer { try? FileManager.default.removeItem(at: outputDir) }
+
+        let playlist = Playlist(name: "Export Flow Test")
+        let t1 = Track(title: "Song A", artist: "Art1", filePath: "/tmp/a.mp3", duration: 120, fileFormat: "MP3")
+        let t2 = Track(title: "Song B", artist: "Art2", filePath: "/tmp/b.wav", duration: 180, fileFormat: "WAV")
+        t1.bpm = 128
+        t1.musicalKey = "Am"
+        playlist.addTrack(t1)
+        playlist.addTrack(t2, crossfadeDuration: 3.0)
+
+        // Export Audition session
+        let sesxURL = outputDir.appendingPathComponent("test.sesx")
+        var options = ExportOptions.default
+        options.copyAudioFiles = false
+        try AuditionExporter.export(playlist: playlist, to: sesxURL, options: options)
+
+        let sesx = try String(contentsOf: sesxURL, encoding: .utf8)
+        XCTAssertTrue(sesx.contains("Song A"))
+        XCTAssertTrue(sesx.contains("Song B"))
+        XCTAssertTrue(sesx.contains("<audioClip"))
+        XCTAssertTrue(sesx.contains("<masterTrack"))
+
+        // Export with file renaming template
+        options.fileNameTemplate = "{track} {artist} - {title}"
+        // Can't actually copy since source files don't exist, but verify template integration
+        let template = options.fileNameTemplate!
+        let name = FileNameTemplate.generate(template: template, track: t1, playlistIndex: 0, totalTracks: 2)
+        XCTAssertEqual(name, "01 Art1 - Song A")
+    }
+
+    // MARK: - Stitch Flow
+
+    @MainActor
+    func testStitchWithMarkersFlow() async throws {
+        let outputDir = FileManager.default.temporaryDirectory
+            .appendingPathComponent("IntegrationStitch", isDirectory: true)
+        try FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
+        defer { try? FileManager.default.removeItem(at: outputDir) }
+
+        let urls = try TestHelpers.createTestAudioFiles(count: 2, duration: 1.5)
+
+        let playlist = Playlist(name: "Stitch Flow")
+        for (i, url) in urls.enumerated() {
+            let t = Track(title: "Part \(i+1)", artist: "DJ", filePath: url.path, duration: 1.5, fileFormat: "WAV")
+            playlist.addTrack(t)
+        }
+
+        let outputURL = outputDir.appendingPathComponent("stitched.wav")
+        let result = try await AudioStitcher.stitch(playlist: playlist, to: outputURL)
+
+        // Verify markers
+        XCTAssertEqual(result.markers.count, 2)
+        XCTAssertEqual(result.markers[0].name, "Part 1")
+        XCTAssertEqual(result.markers[1].name, "Part 2")
+        XCTAssertEqual(result.markers[0].startTime, 0, accuracy: 0.01)
+        XCTAssertGreaterThan(result.markers[1].startTime, 1.0)
+
+        // Write all companion files
+        let csvURL = outputDir.appendingPathComponent("markers.csv")
+        try AudioStitcher.writeAuditionMarkers(result.markers, to: csvURL)
+        let csv = try String(contentsOf: csvURL, encoding: .utf8)
+        XCTAssertTrue(csv.contains("01. Part 1"))
+        XCTAssertTrue(csv.contains("02. Part 2"))
+
+        let listURL = outputDir.appendingPathComponent("tracklist.txt")
+        try AudioStitcher.writeTrackList(result.markers, playlistName: "Stitch Flow", to: listURL)
+        let list = try String(contentsOf: listURL, encoding: .utf8)
+        XCTAssertTrue(list.contains("Total: 2 tracks"))
+    }
+
+    // MARK: - Theme Persistence
+
+    func testThemeSkinPersistence() {
+        let theme = AppTheme()
+        let originalSkin = theme.currentSkin
+
+        theme.currentSkin = .ocean
+        XCTAssertEqual(theme.currentSkin, .ocean)
+
+        // Create a new instance — should load saved
+        let theme2 = AppTheme()
+        XCTAssertEqual(theme2.currentSkin, .ocean)
+
+        // Restore
+        theme.currentSkin = originalSkin
+    }
+
+    // MARK: - Playlist Config Persistence
+
+    func testPlaylistViewConfigPersistence() {
+        let config = PlaylistViewConfig()
+        let originalColumns = config.visibleColumns
+
+        config.visibleColumns = [.title, .artist, .duration]
+
+        let config2 = PlaylistViewConfig()
+        XCTAssertEqual(config2.visibleColumns, [.title, .artist, .duration])
+
+        // Restore
+        config.visibleColumns = originalColumns
+    }
+
+    // MARK: - Status Message
+
+    @MainActor
+    func testStatusMessageAutoClears() async {
+        let vm = PlaylistViewModel()
+        vm.showStatus("Integration test", duration: 0.3)
+        XCTAssertEqual(vm.statusMessage, "Integration test")
+
+        try? await Task.sleep(for: .seconds(0.5))
+        XCTAssertNil(vm.statusMessage)
+    }
+
+    // MARK: - Drag to Playlist (Data Flow)
+
+    func testAddTrackToDifferentPlaylist() {
+        let source = Playlist(name: "Source")
+        let target = Playlist(name: "Target")
+
+        let track = Track(title: "Shared Song", artist: "Artist", filePath: "/t.mp3", duration: 200, fileFormat: "MP3")
+        source.addTrack(track)
+
+        // Simulate drag: add same track to target
+        target.addTrack(track)
+
+        // Both playlists should have the track
+        XCTAssertEqual(source.trackCount, 1)
+        XCTAssertEqual(target.trackCount, 1)
+        XCTAssertEqual(source.sortedEntries.first?.track?.title, "Shared Song")
+        XCTAssertEqual(target.sortedEntries.first?.track?.title, "Shared Song")
+    }
+    // MARK: - Quick Add & Duplicate Detection
+
+    @MainActor
+    func testQuickAddToTarget() {
+        let target = Playlist(name: "My Mix")
+        let source = Playlist(name: "Source")
+        let track = Track(title: "Banger", artist: "DJ", filePath: "/banger.mp3", duration: 200, fileFormat: "MP3")
+        source.addTrack(track)
+
+        let vm = PlaylistViewModel()
+        vm.targetPlaylist = target
+
+        // First add should succeed
+        let added = vm.quickAddToTarget(track: track, context: modelContext)
+        // Can't test with real ModelContext in unit test, but verify no crash
+        XCTAssertNotNil(vm.targetPlaylist)
+    }
+
+    @MainActor
+    func testDuplicateDetection() {
+        let playlist = Playlist(name: "Mix")
+        let track = Track(title: "Song", artist: "Art", filePath: "/song.mp3", duration: 180, fileFormat: "MP3")
+        playlist.addTrack(track)
+
+        let vm = PlaylistViewModel()
+        let isDup = vm.isDuplicate(track: track, in: playlist)
+        XCTAssertTrue(isDup)
+
+        let track2 = Track(title: "Other", filePath: "/other.mp3", duration: 120, fileFormat: "MP3")
+        let isDup2 = vm.isDuplicate(track: track2, in: playlist)
+        XCTAssertFalse(isDup2)
+    }
+
+    @MainActor
+    func testQuickAddNoTarget() {
+        let vm = PlaylistViewModel()
+        vm.targetPlaylist = nil
+        let track = Track(title: "T", filePath: "/t.mp3", duration: 100, fileFormat: "MP3")
+        let added = vm.quickAddToTarget(track: track, context: modelContext)
+        XCTAssertFalse(added)
+        XCTAssertNotNil(vm.statusMessage) // Should show error
+    }
+
+    @MainActor
+    func testTargetPlaylistPersistence() {
+        let vm = PlaylistViewModel()
+        let playlist = Playlist(name: "Target Test")
+        vm.targetPlaylist = playlist
+
+        // Should save to UserDefaults under mixTarget0ID (slot 0)
+        let saved = UserDefaults.standard.string(forKey: "mixTarget0ID")
+        XCTAssertEqual(saved, playlist.id.uuidString)
+
+        // All three slots should work
+        let p2 = Playlist(name: "Mix 2 Test")
+        let p3 = Playlist(name: "Mix 3 Test")
+        vm.setMixTarget(1, playlist: p2)
+        vm.setMixTarget(2, playlist: p3)
+        XCTAssertEqual(UserDefaults.standard.string(forKey: "mixTarget1ID"), p2.id.uuidString)
+        XCTAssertEqual(UserDefaults.standard.string(forKey: "mixTarget2ID"), p3.id.uuidString)
+        XCTAssertEqual(vm.mixTargetName(0), "Target Test")
+        XCTAssertEqual(vm.mixTargetName(1), "Mix 2 Test")
+        XCTAssertEqual(vm.mixTargetName(2), "Mix 3 Test")
+
+        // Cleanup
+        UserDefaults.standard.removeObject(forKey: "mixTarget0ID")
+        UserDefaults.standard.removeObject(forKey: "mixTarget1ID")
+        UserDefaults.standard.removeObject(forKey: "mixTarget2ID")
+    }
+
+    // MARK: - Track Notes
+
+    func testTrackNotes() {
+        let track = Track(title: "Noted", filePath: "/n.mp3", duration: 100)
+        XCTAssertEqual(track.notes, "")
+
+        track.notes = "Great drop at 1:30, mix with Shook Ones"
+        XCTAssertEqual(track.notes, "Great drop at 1:30, mix with Shook Ones")
+    }
+
+    // MARK: - Duplicate in addTracks
+
+    @MainActor
+    func testAddTracksSkipsDuplicates() {
+        let playlist = Playlist(name: "Dedup Test")
+        let t1 = Track(title: "A", filePath: "/a.mp3", duration: 60, fileFormat: "MP3")
+        let t2 = Track(title: "B", filePath: "/b.mp3", duration: 60, fileFormat: "MP3")
+        playlist.addTrack(t1)
+
+        let vm = PlaylistViewModel()
+        XCTAssertTrue(vm.isDuplicate(track: t1, in: playlist))
+        XCTAssertFalse(vm.isDuplicate(track: t2, in: playlist))
+    }
+}
+
+// MARK: - ModelContext for testing
+
+extension IntegrationTests {
+    @MainActor var modelContext: ModelContext {
+        let config = ModelConfiguration(isStoredInMemoryOnly: true)
+        let container = try! ModelContainer(for: Track.self, Playlist.self, PlaylistEntry.self, CuePoint.self, PlaylistFolder.self, configurations: config)
+        return container.mainContext
+    }
+}
+
+extension PlayerViewModel {
+    /// Manually sync state from engine for testing (normally done by timer).
+    func syncForTest() {
+        let engine = audioEngine
+        engine.updateCurrentTime()
+        isPlaying = engine.isPlaying
+        currentTime = engine.currentTime
+        duration = engine.duration
+        currentTrack = engine.currentTrack
+    }
+}

+ 78 - 0
Tests/Helpers/TestHelpers.swift

@@ -0,0 +1,78 @@
+import AVFoundation
+import Foundation
+
+/// Helpers for creating test audio files and SwiftData contexts.
+enum TestHelpers {
+
+    /// Create a short sine wave WAV file for testing.
+    static func createTestAudioFile(
+        name: String = "test_audio",
+        duration: TimeInterval = 2.0,
+        sampleRate: Double = 44100,
+        frequency: Double = 440,
+        in directory: URL? = nil
+    ) throws -> URL {
+        let dir = directory ?? FileManager.default.temporaryDirectory
+            .appendingPathComponent("MixBoardTests/\(UUID().uuidString)", isDirectory: true)
+        try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+
+        let url = dir.appendingPathComponent("\(name)_\(UUID().uuidString.prefix(8)).wav")
+
+        let channels: AVAudioChannelCount = 2
+
+        // Use standard format which AVAudioFile handles correctly
+        guard let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: channels) else {
+            throw NSError(domain: "TestHelpers", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to create format"])
+        }
+
+        let file = try AVAudioFile(forWriting: url, settings: format.settings)
+        let frameCount = AVAudioFrameCount(duration * sampleRate)
+
+        guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: frameCount) else {
+            throw NSError(domain: "TestHelpers", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to create buffer"])
+        }
+
+        buffer.frameLength = frameCount
+
+        if let channelData = buffer.floatChannelData {
+            for frame in 0..<Int(frameCount) {
+                let value = Float(sin(2.0 * .pi * frequency * Double(frame) / sampleRate))
+                for ch in 0..<Int(channels) {
+                    channelData[ch][frame] = value * 0.5
+                }
+            }
+        }
+
+        try file.write(from: buffer)
+        return url
+    }
+
+    /// Create multiple test audio files with different "metadata".
+    static func createTestAudioFiles(count: Int, duration: TimeInterval = 1.0) throws -> [URL] {
+        let frequencies = [261.63, 293.66, 329.63, 349.23, 392.00, 440.00, 493.88, 523.25]
+        var urls: [URL] = []
+        for i in 0..<count {
+            let freq = frequencies[i % frequencies.count]
+            let url = try createTestAudioFile(
+                name: "test_track_\(i + 1)",
+                duration: duration,
+                frequency: freq
+            )
+            urls.append(url)
+        }
+        return urls
+    }
+
+    /// Clean up test files.
+    static func cleanupTestFiles() {
+        let dir = FileManager.default.temporaryDirectory
+            .appendingPathComponent("MixBoardTests", isDirectory: true)
+        try? FileManager.default.removeItem(at: dir)
+    }
+
+    /// Get the test files directory.
+    static var testDirectory: URL {
+        FileManager.default.temporaryDirectory
+            .appendingPathComponent("MixBoardTests", isDirectory: true)
+    }
+}

+ 434 - 0
Tests/Unit/ChadMusicTests.swift

@@ -0,0 +1,434 @@
+import XCTest
+@testable import MixBoard
+
+// MARK: - Chad Music Model Tests
+
+final class ChadTrackModelTests: XCTestCase {
+
+    func testChadTrackDecoding() throws {
+        let json = """
+        {
+            "id": "a1b2c3d4e5f6",
+            "title": "Echoes",
+            "artist": "Pink Floyd",
+            "album_artist": "Pink Floyd",
+            "album": "Meddle",
+            "duration": 1410.5,
+            "no": 6,
+            "url": "/music/Pink Floyd/Meddle/06 Echoes.flac",
+            "bit_rate": 1024,
+            "year": 1971,
+            "cover": "/covers/42.jpg"
+        }
+        """.data(using: .utf8)!
+
+        let track = try JSONDecoder().decode(ChadTrack.self, from: json)
+        XCTAssertEqual(track.id, "a1b2c3d4e5f6")
+        XCTAssertEqual(track.title, "Echoes")
+        XCTAssertEqual(track.artist, "Pink Floyd")
+        XCTAssertEqual(track.albumArtist, "Pink Floyd")
+        XCTAssertEqual(track.album, "Meddle")
+        XCTAssertEqual(track.duration, 1410.5)
+        XCTAssertEqual(track.trackNumber, 6)
+        XCTAssertEqual(track.no, 6)
+        XCTAssertEqual(track.url, "/music/Pink Floyd/Meddle/06 Echoes.flac")
+        XCTAssertEqual(track.bitRate, 1024)
+        XCTAssertEqual(track.year, 1971)
+        XCTAssertEqual(track.cover, "/covers/42.jpg")
+    }
+
+    func testChadTrackDecodingMinimalFields() throws {
+        let json = """
+        {
+            "id": "abc123",
+            "title": "Unknown",
+            "url": "/music/unknown.mp3"
+        }
+        """.data(using: .utf8)!
+
+        let track = try JSONDecoder().decode(ChadTrack.self, from: json)
+        XCTAssertEqual(track.id, "abc123")
+        XCTAssertEqual(track.title, "Unknown")
+        XCTAssertEqual(track.url, "/music/unknown.mp3")
+        XCTAssertNil(track.artist)
+        XCTAssertNil(track.album)
+        XCTAssertNil(track.duration)
+        XCTAssertNil(track.trackNumber)
+        XCTAssertNil(track.bitRate)
+        XCTAssertNil(track.year)
+        XCTAssertNil(track.cover)
+    }
+
+    func testFormattedDuration() {
+        let json = """
+        {"id": "x1", "title": "T", "url": "/t", "duration": 185.0}
+        """.data(using: .utf8)!
+        let track = try! JSONDecoder().decode(ChadTrack.self, from: json)
+        XCTAssertEqual(track.formattedDuration, "3:05")
+    }
+
+    func testFormattedDurationNil() {
+        let json = """
+        {"id": "x1", "title": "T", "url": "/t"}
+        """.data(using: .utf8)!
+        let track = try! JSONDecoder().decode(ChadTrack.self, from: json)
+        XCTAssertEqual(track.formattedDuration, "—")
+    }
+
+    func testFormattedDurationZero() {
+        let json = """
+        {"id": "x1", "title": "T", "url": "/t", "duration": 0}
+        """.data(using: .utf8)!
+        let track = try! JSONDecoder().decode(ChadTrack.self, from: json)
+        XCTAssertEqual(track.formattedDuration, "0:00")
+    }
+
+    func testFormattedDurationLongTrack() {
+        let json = """
+        {"id": "x1", "title": "T", "url": "/t", "duration": 3661}
+        """.data(using: .utf8)!
+        let track = try! JSONDecoder().decode(ChadTrack.self, from: json)
+        XCTAssertEqual(track.formattedDuration, "61:01")
+    }
+
+    func testChadTrackHashable() throws {
+        let json1 = """
+        {"id": "t1", "title": "A", "url": "/a"}
+        """.data(using: .utf8)!
+        let json2 = """
+        {"id": "t2", "title": "B", "url": "/b"}
+        """.data(using: .utf8)!
+
+        let track1 = try JSONDecoder().decode(ChadTrack.self, from: json1)
+        let track2 = try JSONDecoder().decode(ChadTrack.self, from: json2)
+        let set: Set<ChadTrack> = [track1, track2, track1]
+        XCTAssertEqual(set.count, 2)
+    }
+}
+
+final class ChadAlbumModelTests: XCTestCase {
+
+    func testChadAlbumDecoding() throws {
+        let json = """
+        {
+            "id": "f8fbe6f9d852485b",
+            "album": "Meddle",
+            "artist": "Pink Floyd",
+            "year": 1971,
+            "genre": "Progressive Rock",
+            "track_count": 6,
+            "cover": "/covers/10.jpg",
+            "publisher": "Harvest",
+            "country": "UK",
+            "total_duration": 2820.0
+        }
+        """.data(using: .utf8)!
+
+        let album = try JSONDecoder().decode(ChadAlbum.self, from: json)
+        XCTAssertEqual(album.id, "f8fbe6f9d852485b")
+        XCTAssertEqual(album.title, "Meddle")
+        XCTAssertEqual(album.album, "Meddle")
+        XCTAssertEqual(album.artist, "Pink Floyd")
+        XCTAssertEqual(album.year, 1971)
+        XCTAssertEqual(album.genre, "Progressive Rock")
+        XCTAssertEqual(album.trackCount, 6)
+        XCTAssertEqual(album.cover, "/covers/10.jpg")
+        XCTAssertEqual(album.publisher, "Harvest")
+        XCTAssertEqual(album.country, "UK")
+        XCTAssertEqual(album.totalDuration, 2820.0)
+    }
+
+    func testChadAlbumMinimalDecoding() throws {
+        let json = """
+        {"id": "abc", "album": "Untitled"}
+        """.data(using: .utf8)!
+
+        let album = try JSONDecoder().decode(ChadAlbum.self, from: json)
+        XCTAssertEqual(album.id, "abc")
+        XCTAssertEqual(album.title, "Untitled")
+        XCTAssertNil(album.artist)
+        XCTAssertNil(album.year)
+        XCTAssertNil(album.trackCount)
+        XCTAssertNil(album.cover)
+    }
+}
+
+final class ChadCategoryModelTests: XCTestCase {
+
+    func testChadCategoryDecoding() throws {
+        let json = """
+        {"item": "Rock", "count": 42}
+        """.data(using: .utf8)!
+
+        let cat = try JSONDecoder().decode(ChadCategory.self, from: json)
+        XCTAssertEqual(cat.id, "Rock")
+        XCTAssertEqual(cat.name, "Rock")
+        XCTAssertEqual(cat.item, "Rock")
+        XCTAssertEqual(cat.count, 42)
+    }
+
+    func testChadCategoryDecodingNoCount() throws {
+        let json = """
+        {"item": "Jazz"}
+        """.data(using: .utf8)!
+
+        let cat = try JSONDecoder().decode(ChadCategory.self, from: json)
+        XCTAssertEqual(cat.name, "Jazz")
+        XCTAssertNil(cat.count)
+    }
+
+    func testChadCategoryArrayDecoding() throws {
+        let json = """
+        [
+            {"item": "Rock", "count": 100},
+            {"item": "Jazz", "count": 50},
+            {"item": "Electronic", "count": 75}
+        ]
+        """.data(using: .utf8)!
+
+        let categories = try JSONDecoder().decode([ChadCategory].self, from: json)
+        XCTAssertEqual(categories.count, 3)
+        XCTAssertEqual(categories[0].name, "Rock")
+        XCTAssertEqual(categories[2].count, 75)
+    }
+}
+
+final class ChadStatsModelTests: XCTestCase {
+
+    func testChadStatsDecoding() throws {
+        let json = """
+        {
+            "tracks": 1500,
+            "albums": 120,
+            "artists": 85,
+            "duration": "3d 14h 22m"
+        }
+        """.data(using: .utf8)!
+
+        let stats = try JSONDecoder().decode(ChadStats.self, from: json)
+        XCTAssertEqual(stats.tracks, 1500)
+        XCTAssertEqual(stats.albums, 120)
+        XCTAssertEqual(stats.artists, 85)
+        XCTAssertEqual(stats.duration, "3d 14h 22m")
+    }
+
+    func testChadStatsPartialDecoding() throws {
+        let json = """
+        {"tracks": 500}
+        """.data(using: .utf8)!
+
+        let stats = try JSONDecoder().decode(ChadStats.self, from: json)
+        XCTAssertEqual(stats.tracks, 500)
+        XCTAssertNil(stats.albums)
+        XCTAssertNil(stats.artists)
+        XCTAssertNil(stats.duration)
+    }
+}
+
+/// Album tracks endpoint returns a plain [ChadTrack] array — tested in ChadTrackModelTests.
+
+// MARK: - ChadCategoryType Tests
+
+final class ChadCategoryTypeTests: XCTestCase {
+
+    func testAllCasesExist() {
+        XCTAssertEqual(ChadCategoryType.allCases.count, 8)
+    }
+
+    func testRawValues() {
+        XCTAssertEqual(ChadCategoryType.album.rawValue, "album")
+        XCTAssertEqual(ChadCategoryType.artist.rawValue, "artist")
+        XCTAssertEqual(ChadCategoryType.genre.rawValue, "genre")
+        XCTAssertEqual(ChadCategoryType.year.rawValue, "year")
+        XCTAssertEqual(ChadCategoryType.publisher.rawValue, "publisher")
+    }
+
+    func testDisplayNames() {
+        XCTAssertEqual(ChadCategoryType.album.displayName, "Albums")
+        XCTAssertEqual(ChadCategoryType.artist.displayName, "Artists")
+        XCTAssertEqual(ChadCategoryType.genre.displayName, "Genres")
+    }
+
+    func testIcons() {
+        XCTAssertFalse(ChadCategoryType.album.icon.isEmpty)
+        XCTAssertFalse(ChadCategoryType.artist.icon.isEmpty)
+        // Every category should have an icon
+        for category in ChadCategoryType.allCases {
+            XCTAssertFalse(category.icon.isEmpty, "\(category) should have an icon")
+        }
+    }
+
+    func testIdentifiable() {
+        let category = ChadCategoryType.album
+        XCTAssertEqual(category.id, "album")
+    }
+}
+
+// MARK: - Keychain Service Tests
+
+final class KeychainServiceTests: XCTestCase {
+
+    override func tearDown() {
+        super.tearDown()
+        KeychainService.deleteAPIKey()
+    }
+
+    func testSaveAndLoadAPIKey() throws {
+        try KeychainService.saveAPIKey("test-api-key-12345")
+        let loaded = KeychainService.loadAPIKey()
+        XCTAssertEqual(loaded, "test-api-key-12345")
+    }
+
+    func testLoadMissingAPIKey() {
+        KeychainService.deleteAPIKey()
+        let loaded = KeychainService.loadAPIKey()
+        XCTAssertNil(loaded)
+    }
+
+    func testOverwriteAPIKey() throws {
+        try KeychainService.saveAPIKey("old-key")
+        XCTAssertEqual(KeychainService.loadAPIKey(), "old-key")
+
+        try KeychainService.saveAPIKey("new-key")
+        XCTAssertEqual(KeychainService.loadAPIKey(), "new-key")
+    }
+
+    func testDeleteAPIKey() throws {
+        try KeychainService.saveAPIKey("to-delete")
+        XCTAssertNotNil(KeychainService.loadAPIKey())
+
+        KeychainService.deleteAPIKey()
+        XCTAssertNil(KeychainService.loadAPIKey())
+    }
+
+    func testSaveEmptyKey() throws {
+        try KeychainService.saveAPIKey("")
+        // Empty string is still valid UTF-8 data
+        let loaded = KeychainService.loadAPIKey()
+        XCTAssertEqual(loaded, "")
+    }
+
+    func testSaveUnicodeKey() throws {
+        let key = "api-key-with-ünîcödé-чëрт"
+        try KeychainService.saveAPIKey(key)
+        XCTAssertEqual(KeychainService.loadAPIKey(), key)
+    }
+}
+
+// MARK: - ChadMusicAPIClient Tests (URL composition, no network)
+
+final class ChadMusicAPIClientTests: XCTestCase {
+
+    override func tearDown() {
+        super.tearDown()
+        UserDefaults.standard.removeObject(forKey: "chadMusic.serverURL")
+        KeychainService.deleteAPIKey()
+    }
+
+    @MainActor
+    func testStreamURLAbsolutePath() {
+        let client = ChadMusicAPIClient()
+        client.serverURL = "https://music.example.com"
+
+        let url = client.streamURL(for: "/music/Artist/Album/track.flac")
+        XCTAssertNotNil(url)
+        XCTAssertEqual(url?.scheme, "https")
+        XCTAssertEqual(url?.host, "music.example.com")
+        XCTAssertTrue(url?.path.contains("music/Artist/Album/track.flac") ?? false)
+    }
+
+    @MainActor
+    func testStreamURLRelativePath() {
+        let client = ChadMusicAPIClient()
+        client.serverURL = "https://music.example.com"
+
+        let url = client.streamURL(for: "music/Artist/Album/track.flac")
+        XCTAssertNotNil(url)
+        XCTAssertTrue(url?.path.contains("music/Artist/Album/track.flac") ?? false)
+    }
+
+    @MainActor
+    func testStreamURLWithTrailingSlash() {
+        let client = ChadMusicAPIClient()
+        client.serverURL = "https://music.example.com/"
+
+        let url = client.streamURL(for: "/music/track.flac")
+        XCTAssertNotNil(url)
+        XCTAssertTrue(url?.path.contains("music/track.flac") ?? false)
+    }
+
+    @MainActor
+    func testStreamURLNotConfigured() {
+        let client = ChadMusicAPIClient()
+        client.serverURL = ""
+
+        let url = client.streamURL(for: "/music/track.flac")
+        XCTAssertNil(url)
+    }
+
+    @MainActor
+    func testIsConfiguredFalseWhenEmpty() {
+        let client = ChadMusicAPIClient()
+        client.serverURL = ""
+        KeychainService.deleteAPIKey()
+        XCTAssertFalse(client.isConfigured)
+    }
+
+    @MainActor
+    func testIsConfiguredFalseWithoutKey() {
+        let client = ChadMusicAPIClient()
+        client.serverURL = "https://music.example.com"
+        KeychainService.deleteAPIKey()
+        XCTAssertFalse(client.isConfigured)
+    }
+
+    @MainActor
+    func testIsConfiguredTrue() throws {
+        let client = ChadMusicAPIClient()
+        client.serverURL = "https://music.example.com"
+        try KeychainService.saveAPIKey("test-key")
+        XCTAssertTrue(client.isConfigured)
+    }
+
+    @MainActor
+    func testAuthHeaders() throws {
+        let client = ChadMusicAPIClient()
+        try KeychainService.saveAPIKey("my-secret-key")
+
+        let headers = client.authHeaders
+        XCTAssertEqual(headers["Authorization"], "Bearer my-secret-key")
+    }
+
+    @MainActor
+    func testAuthHeadersEmpty() {
+        let client = ChadMusicAPIClient()
+        KeychainService.deleteAPIKey()
+
+        let headers = client.authHeaders
+        XCTAssertTrue(headers.isEmpty)
+    }
+}
+
+// MARK: - ChadMusicError Tests
+
+final class ChadMusicErrorTests: XCTestCase {
+
+    func testErrorDescriptions() {
+        XCTAssertNotNil(ChadMusicError.notConfigured.errorDescription)
+        XCTAssertNotNil(ChadMusicError.unauthorized.errorDescription)
+        XCTAssertNotNil(ChadMusicError.forbidden.errorDescription)
+        XCTAssertNotNil(ChadMusicError.notFound("test").errorDescription)
+        XCTAssertNotNil(ChadMusicError.httpError(500).errorDescription)
+        XCTAssertNotNil(ChadMusicError.invalidResponse.errorDescription)
+
+        // All errors should have non-empty descriptions
+        XCTAssertFalse(ChadMusicError.notConfigured.errorDescription!.isEmpty)
+        XCTAssertTrue(ChadMusicError.notFound("/api/test").errorDescription!.contains("/api/test"))
+        XCTAssertTrue(ChadMusicError.httpError(503).errorDescription!.contains("503"))
+    }
+
+    func testUnauthorizedDescription() {
+        let error = ChadMusicError.unauthorized
+        XCTAssertTrue(error.errorDescription!.contains("401"))
+    }
+}

+ 206 - 0
Tests/Unit/ExporterTests.swift

@@ -0,0 +1,206 @@
+import XCTest
+@testable import MixBoard
+
+/// Tests for all DAW exporters.
+final class ExporterTests: XCTestCase {
+
+    private var playlist: Playlist!
+    private var outputDir: URL!
+
+    override func setUp() {
+        super.setUp()
+        outputDir = FileManager.default.temporaryDirectory
+            .appendingPathComponent("MixBoardExportTests", isDirectory: true)
+        try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
+
+        playlist = Playlist(name: "Test Mix")
+        let t1 = Track(title: "Track One", artist: "Artist A", album: "Album X", filePath: "/tmp/t1.mp3", duration: 180, fileFormat: "MP3")
+        let t2 = Track(title: "Track Two", artist: "Artist B", album: "Album Y", filePath: "/tmp/t2.wav", duration: 240, fileFormat: "WAV")
+        let t3 = Track(title: "Track Three", artist: "Artist A", filePath: "/tmp/t3.flac", duration: 300, fileFormat: "FLAC")
+        t1.bpm = 128
+        t1.musicalKey = "Am"
+        t2.bpm = 130
+        playlist.addTrack(t1, crossfadeDuration: 0)
+        playlist.addTrack(t2, crossfadeDuration: 2.0)
+        playlist.addTrack(t3, crossfadeDuration: 1.5)
+    }
+
+    override func tearDown() {
+        super.tearDown()
+        try? FileManager.default.removeItem(at: outputDir)
+    }
+
+    // MARK: - Audition Exporter
+
+    func testAuditionExport() throws {
+        let url = outputDir.appendingPathComponent("test.sesx")
+        var options = ExportOptions.default
+        options.copyAudioFiles = false
+
+        try AuditionExporter.export(playlist: playlist, to: url, options: options)
+
+        let content = try String(contentsOf: url, encoding: .utf8)
+
+        // Verify XML structure
+        XCTAssertTrue(content.contains("<!DOCTYPE sesx>"))
+        XCTAssertTrue(content.contains("<sesx version=\"1.9\">"))
+        XCTAssertTrue(content.contains("<session"))
+        XCTAssertTrue(content.contains("sampleRate="))
+        XCTAssertTrue(content.contains("<audioTrack"))
+        XCTAssertTrue(content.contains("<audioClip"))
+        XCTAssertTrue(content.contains("Track One"))
+        XCTAssertTrue(content.contains("Track Two"))
+        XCTAssertTrue(content.contains("Track Three"))
+        XCTAssertTrue(content.contains("<files>"))
+        XCTAssertTrue(content.contains("absolutePath="))
+        XCTAssertTrue(content.contains("</sesx>"))
+    }
+
+    func testAuditionExportFileReferences() throws {
+        let url = outputDir.appendingPathComponent("test_refs.sesx")
+        var options = ExportOptions.default
+        options.copyAudioFiles = false
+
+        try AuditionExporter.export(playlist: playlist, to: url, options: options)
+        let content = try String(contentsOf: url, encoding: .utf8)
+
+        // Should have file references with media handlers
+        XCTAssertTrue(content.contains("mediaHandler=\"AmioMP3\""))
+        XCTAssertTrue(content.contains("mediaHandler=\"AmioWav\""))
+        XCTAssertTrue(content.contains("mediaHandler=\"AmioLSF\""))
+    }
+
+    func testAuditionExportMasterTrack() throws {
+        let url = outputDir.appendingPathComponent("test_master.sesx")
+        var options = ExportOptions.default
+        options.copyAudioFiles = false
+
+        try AuditionExporter.export(playlist: playlist, to: url, options: options)
+        let content = try String(contentsOf: url, encoding: .utf8)
+
+        XCTAssertTrue(content.contains("<masterTrack"))
+        XCTAssertTrue(content.contains("Audition.Fader"))
+    }
+
+    // MARK: - CUE Sheet Exporter
+
+    func testCueSheetExport() throws {
+        let url = outputDir.appendingPathComponent("test.cue")
+        var options = ExportOptions.default
+        options.copyAudioFiles = false
+
+        try CueSheetExporter.export(playlist: playlist, to: url, options: options)
+        let content = try String(contentsOf: url, encoding: .utf8)
+
+        XCTAssertTrue(content.contains("TITLE \"Test Mix\""))
+        XCTAssertTrue(content.contains("TRACK 01 AUDIO"))
+        XCTAssertTrue(content.contains("TRACK 02 AUDIO"))
+        XCTAssertTrue(content.contains("TRACK 03 AUDIO"))
+        XCTAssertTrue(content.contains("TITLE \"Track One\""))
+        XCTAssertTrue(content.contains("PERFORMER \"Artist A\""))
+        XCTAssertTrue(content.contains("INDEX 01"))
+    }
+
+    func testCueSheetBPMAndKey() throws {
+        let url = outputDir.appendingPathComponent("test_bpm.cue")
+        var options = ExportOptions.default
+        options.copyAudioFiles = false
+
+        try CueSheetExporter.export(playlist: playlist, to: url, options: options)
+        let content = try String(contentsOf: url, encoding: .utf8)
+
+        XCTAssertTrue(content.contains("REM BPM 128.0"))
+        XCTAssertTrue(content.contains("REM KEY Am"))
+    }
+
+    // MARK: - EDL Exporter
+
+    func testEDLExport() throws {
+        let url = outputDir.appendingPathComponent("test.edl")
+        var options = ExportOptions.default
+        options.copyAudioFiles = false
+
+        try EDLExporter.export(playlist: playlist, to: url, options: options)
+        let content = try String(contentsOf: url, encoding: .utf8)
+
+        XCTAssertTrue(content.contains("TITLE: Test Mix"))
+        XCTAssertTrue(content.contains("FCM: NON-DROP FRAME"))
+        XCTAssertTrue(content.contains("001"))
+        XCTAssertTrue(content.contains("FROM CLIP NAME: Track One"))
+        XCTAssertTrue(content.contains("ARTIST: Artist A"))
+    }
+
+    func testEDLTimecodes() throws {
+        let url = outputDir.appendingPathComponent("test_tc.edl")
+        var options = ExportOptions.default
+        options.copyAudioFiles = false
+
+        try EDLExporter.export(playlist: playlist, to: url, options: options)
+        let content = try String(contentsOf: url, encoding: .utf8)
+
+        // Should contain valid HH:MM:SS:FF timecodes
+        let timecodePattern = #"\d{2}:\d{2}:\d{2}:\d{2}"#
+        let regex = try NSRegularExpression(pattern: timecodePattern)
+        let matches = regex.numberOfMatches(in: content, range: NSRange(content.startIndex..., in: content))
+        XCTAssertGreaterThan(matches, 0, "Should contain SMPTE timecodes")
+    }
+
+    // MARK: - M3U Exporter
+
+    func testM3UExport() throws {
+        let url = outputDir.appendingPathComponent("test.m3u")
+        var options = ExportOptions.default
+        options.copyAudioFiles = false
+
+        try M3UExporter.export(playlist: playlist, to: url, options: options)
+        let content = try String(contentsOf: url, encoding: .utf8)
+
+        XCTAssertTrue(content.contains("#EXTM3U"))
+        XCTAssertTrue(content.contains("#PLAYLIST:Test Mix"))
+        XCTAssertTrue(content.contains("#EXTINF:180,Artist A - Track One"))
+        XCTAssertTrue(content.contains("#EXTINF:240,Artist B - Track Two"))
+    }
+
+    func testM3UBPMMetadata() throws {
+        let url = outputDir.appendingPathComponent("test_meta.m3u")
+        var options = ExportOptions.default
+        options.copyAudioFiles = false
+
+        try M3UExporter.export(playlist: playlist, to: url, options: options)
+        let content = try String(contentsOf: url, encoding: .utf8)
+
+        XCTAssertTrue(content.contains("#EXTBPM:128.0"))
+        XCTAssertTrue(content.contains("#EXTKEY:Am"))
+    }
+
+    // MARK: - DAWproject Exporter
+
+    func testDAWProjectExport() throws {
+        let url = outputDir.appendingPathComponent("test.dawproject")
+        var options = ExportOptions.default
+        options.copyAudioFiles = false
+
+        try DAWProjectExporter.export(playlist: playlist, to: url, options: options)
+
+        // DAWProjectExporter writes to .dawproject.xml
+        let xmlURL = url.deletingPathExtension().appendingPathExtension("dawproject.xml")
+        let content = try String(contentsOf: xmlURL, encoding: .utf8)
+
+        XCTAssertTrue(content.contains("<Project version=\"1.0\">"))
+        XCTAssertTrue(content.contains("<Application name=\"MixBoard\""))
+        XCTAssertTrue(content.contains("<Clip"))
+        XCTAssertTrue(content.contains("Track One"))
+    }
+
+    // MARK: - MixExporter Dispatcher
+
+    func testExportFormatProperties() {
+        XCTAssertEqual(MixExporter.ExportFormat.allCases.count, 5)
+
+        for format in MixExporter.ExportFormat.allCases {
+            XCTAssertFalse(format.name.isEmpty)
+            XCTAssertFalse(format.fileExtension.isEmpty)
+            XCTAssertFalse(format.description.isEmpty)
+        }
+    }
+}

+ 148 - 0
Tests/Unit/FileNameTemplateTests.swift

@@ -0,0 +1,148 @@
+import XCTest
+@testable import MixBoard
+
+/// Tests for FileNameTemplate.
+final class FileNameTemplateTests: XCTestCase {
+
+    private func makeTrack(
+        title: String = "Spot Rusherz",
+        artist: String = "Raekwon",
+        album: String = "Only Built 4 Cuban Linx",
+        genre: String = "Hip-Hop",
+        bpm: Double? = 95,
+        key: String? = "Dm",
+        format: String = "MP3",
+        duration: TimeInterval = 193
+    ) -> Track {
+        let t = Track(title: title, artist: artist, album: album, genre: genre, filePath: "/fake.\(format.lowercased())", duration: duration, fileFormat: format)
+        t.bpm = bpm
+        t.musicalKey = key
+        return t
+    }
+
+    // MARK: - Basic Templates
+
+    func testDefaultTemplate() {
+        let track = makeTrack()
+        let result = FileNameTemplate.generate(template: FileNameTemplate.defaultTemplate, track: track, playlistIndex: 0, totalTracks: 10)
+        XCTAssertTrue(result.contains("Raekwon"))
+        XCTAssertTrue(result.contains("Only Built 4 Cuban Linx"))
+        XCTAssertTrue(result.contains("Spot Rusherz"))
+    }
+
+    func testTrackNumberPadding() {
+        let track = makeTrack()
+        let r1 = FileNameTemplate.generate(template: "{track} {title}", track: track, playlistIndex: 0, totalTracks: 10)
+        XCTAssertTrue(r1.hasPrefix("01"))
+
+        let r2 = FileNameTemplate.generate(template: "{track} {title}", track: track, playlistIndex: 4, totalTracks: 150)
+        XCTAssertTrue(r2.hasPrefix("005"))
+    }
+
+    func testAllVariables() {
+        let track = makeTrack()
+        let all = "{track} {artist} {album} {title} {genre} {bpm} {key} {duration} {format} {samplerate} {bitdepth}"
+        let result = FileNameTemplate.generate(template: all, track: track, playlistIndex: 2, totalTracks: 10)
+
+        XCTAssertTrue(result.contains("03"))
+        XCTAssertTrue(result.contains("Raekwon"))
+        XCTAssertTrue(result.contains("Only Built 4 Cuban Linx"))
+        XCTAssertTrue(result.contains("Spot Rusherz"))
+        XCTAssertTrue(result.contains("Hip-Hop"))
+        XCTAssertTrue(result.contains("95"))
+        XCTAssertTrue(result.contains("Dm"))
+        XCTAssertTrue(result.contains("MP3"))
+        // Duration colon gets sanitized to _ for filenames, so check for digits
+        XCTAssertTrue(result.contains("3") && result.contains("13"), "Should contain duration digits")
+    }
+
+    func testEmptyFields() {
+        let track = makeTrack(artist: "", album: "", bpm: nil, key: nil)
+        let result = FileNameTemplate.generate(template: "{artist} - {album} - {title}", track: track, playlistIndex: 0, totalTracks: 1)
+        // Should clean up double separators
+        XCTAssertTrue(result.contains("Spot Rusherz"))
+        XCTAssertFalse(result.contains(" - - "))
+    }
+
+    func testFilesystemSanitization() {
+        let track = makeTrack(title: "What/Ever: The *Best?")
+        let result = FileNameTemplate.generate(template: "{title}", track: track, playlistIndex: 0, totalTracks: 1)
+        XCTAssertFalse(result.contains("/"))
+        XCTAssertFalse(result.contains(":"))
+        XCTAssertFalse(result.contains("*"))
+        XCTAssertFalse(result.contains("?"))
+    }
+
+    func testPreview() {
+        let preview = FileNameTemplate.preview(template: "{artist} - {title}")
+        XCTAssertTrue(preview.contains("Raekwon"))
+        XCTAssertTrue(preview.contains("Spot Rusherz"))
+    }
+
+    func testPresetsExist() {
+        XCTAssertGreaterThanOrEqual(FileNameTemplate.presets.count, 4)
+        for preset in FileNameTemplate.presets {
+            XCTAssertFalse(preset.name.isEmpty)
+            XCTAssertFalse(preset.template.isEmpty)
+            // Each preset should generate a non-empty result
+            let result = FileNameTemplate.preview(template: preset.template)
+            XCTAssertGreaterThan(result.count, 0)
+        }
+    }
+
+    func testAvailableVariables() {
+        XCTAssertGreaterThanOrEqual(FileNameTemplate.availableVariables.count, 10)
+        for v in FileNameTemplate.availableVariables {
+            XCTAssertTrue(v.token.hasPrefix("{"))
+            XCTAssertTrue(v.token.hasSuffix("}"))
+            XCTAssertFalse(v.description.isEmpty)
+        }
+    }
+
+    func testBPMOnlyWhenPresent() {
+        let track = makeTrack(bpm: nil)
+        let result = FileNameTemplate.generate(template: "{title} [{bpm}]", track: track, playlistIndex: 0, totalTracks: 1)
+        // Empty BPM should result in clean output (brackets cleaned)
+        XCTAssertFalse(result.contains("[]"))
+    }
+}
+
+/// Tests for ExportOptions with fileNameTemplate.
+final class ExportOptionsTests: XCTestCase {
+
+    func testDefaultOptionsHaveNoTemplate() {
+        let opts = ExportOptions.default
+        XCTAssertNil(opts.fileNameTemplate)
+    }
+
+    func testTemplateCanBeSet() {
+        var opts = ExportOptions.default
+        opts.fileNameTemplate = "{track} {artist} - {title}"
+        XCTAssertEqual(opts.fileNameTemplate, "{track} {artist} - {title}")
+    }
+}
+
+/// Tests for PlaylistViewModel status messages.
+final class StatusMessageTests: XCTestCase {
+
+    @MainActor
+    func testShowStatus() async {
+        let vm = PlaylistViewModel()
+        vm.showStatus("Test message", duration: 0.5)
+        XCTAssertEqual(vm.statusMessage, "Test message")
+
+        // Wait for auto-clear
+        try? await Task.sleep(for: .seconds(0.7))
+        XCTAssertNil(vm.statusMessage)
+    }
+
+    @MainActor
+    func testStatusOverwrite() {
+        let vm = PlaylistViewModel()
+        vm.showStatus("First")
+        XCTAssertEqual(vm.statusMessage, "First")
+
+        vm.showStatus("Second")
+        XCTAssertEqual(vm.statusMessage, "Second")
+    }
+}

+ 493 - 0
Tests/Unit/ModelTests.swift

@@ -0,0 +1,493 @@
+import XCTest
+import SwiftData
+@testable import MixBoard
+
+/// Tests for the Track model.
+final class TrackModelTests: XCTestCase {
+
+    func testTrackCreation() {
+        let track = Track(
+            title: "Test Song",
+            artist: "Test Artist",
+            album: "Test Album",
+            genre: "Rock",
+            filePath: "/tmp/test.mp3",
+            duration: 180,
+            sampleRate: 44100,
+            bitDepth: 16,
+            channels: 2,
+            fileFormat: "MP3",
+            fileSizeBytes: 5_000_000
+        )
+
+        XCTAssertEqual(track.title, "Test Song")
+        XCTAssertEqual(track.artist, "Test Artist")
+        XCTAssertEqual(track.album, "Test Album")
+        XCTAssertEqual(track.genre, "Rock")
+        XCTAssertEqual(track.duration, 180)
+        XCTAssertEqual(track.sampleRate, 44100)
+        XCTAssertEqual(track.bitDepth, 16)
+        XCTAssertEqual(track.channels, 2)
+        XCTAssertEqual(track.fileFormat, "MP3")
+        XCTAssertEqual(track.fileSizeBytes, 5_000_000)
+        XCTAssertEqual(track.rating, 0)
+        XCTAssertEqual(track.playCount, 0)
+        XCTAssertFalse(track.isAnalyzed)
+        XCTAssertNil(track.bpm)
+        XCTAssertNil(track.musicalKey)
+        XCTAssertTrue(track.cuePoints.isEmpty)
+    }
+
+    func testFormattedDuration() {
+        let track = Track(title: "T", filePath: "/t", duration: 185)
+        XCTAssertEqual(track.formattedDuration, "3:05")
+
+        let track2 = Track(title: "T", filePath: "/t", duration: 60)
+        XCTAssertEqual(track2.formattedDuration, "1:00")
+
+        let track3 = Track(title: "T", filePath: "/t", duration: 0)
+        XCTAssertEqual(track3.formattedDuration, "0:00")
+    }
+
+    func testFormattedBPM() {
+        let track = Track(title: "T", filePath: "/t")
+        XCTAssertEqual(track.formattedBPM, "—")
+
+        track.bpm = 128.5
+        XCTAssertEqual(track.formattedBPM, "128.5")
+    }
+
+    func testFileURL() {
+        let track = Track(title: "T", filePath: "/Users/test/music/song.mp3")
+        XCTAssertEqual(track.fileURL.path, "/Users/test/music/song.mp3")
+    }
+
+    func testFormattedFileSize() {
+        let track = Track(title: "T", filePath: "/t", fileSizeBytes: 1_048_576)
+        let formatted = track.formattedFileSize
+        // Should be approximately "1 MB"
+        XCTAssertTrue(formatted.contains("MB") || formatted.contains("1"))
+    }
+}
+
+/// Tests for the CuePoint model.
+final class CuePointModelTests: XCTestCase {
+
+    func testCuePointCreation() {
+        let cue = CuePoint(name: "Drop", timestamp: 30.5, type: .drop)
+        XCTAssertEqual(cue.name, "Drop")
+        XCTAssertEqual(cue.timestamp, 30.5)
+        XCTAssertEqual(cue.type, .drop)
+        XCTAssertNil(cue.endTimestamp)
+        XCTAssertFalse(cue.isRegion)
+    }
+
+    func testCuePointRegion() {
+        let cue = CuePoint(name: "Verse", timestamp: 10, endTimestamp: 40, type: .verse)
+        XCTAssertTrue(cue.isRegion)
+        XCTAssertEqual(cue.endTimestamp, 40)
+    }
+
+    func testCuePointComparable() {
+        let c1 = CuePoint(timestamp: 10)
+        let c2 = CuePoint(timestamp: 20)
+        let c3 = CuePoint(timestamp: 5)
+
+        let sorted = [c1, c2, c3].sorted()
+        XCTAssertEqual(sorted[0].timestamp, 5)
+        XCTAssertEqual(sorted[1].timestamp, 10)
+        XCTAssertEqual(sorted[2].timestamp, 20)
+    }
+
+    func testFormattedTimestamp() {
+        let cue = CuePoint(timestamp: 65.123)
+        XCTAssertEqual(cue.formattedTimestamp, "01:05.123")
+    }
+
+    func testAllCuePointTypes() {
+        XCTAssertEqual(CuePointType.allCases.count, 11)
+        XCTAssertEqual(CuePointType.marker.rawValue, "Marker")
+        XCTAssertEqual(CuePointType.drop.rawValue, "Drop")
+        XCTAssertEqual(CuePointType.fadeOut.rawValue, "Fade Out")
+    }
+}
+
+/// Tests for the Playlist model.
+final class PlaylistModelTests: XCTestCase {
+
+    func testPlaylistCreation() {
+        let pl = Playlist(name: "My Mix")
+        XCTAssertEqual(pl.name, "My Mix")
+        XCTAssertTrue(pl.entries.isEmpty)
+        XCTAssertEqual(pl.trackCount, 0)
+        XCTAssertEqual(pl.totalDuration, 0)
+        XCTAssertNil(pl.targetBPM)
+    }
+
+    func testAddTrack() {
+        let pl = Playlist(name: "Mix")
+        let track = Track(title: "Song", filePath: "/t", duration: 120)
+
+        pl.addTrack(track, crossfadeDuration: 2.0)
+
+        XCTAssertEqual(pl.trackCount, 1)
+        XCTAssertEqual(pl.entries.count, 1)
+        XCTAssertEqual(pl.entries.first?.track?.title, "Song")
+        XCTAssertEqual(pl.entries.first?.crossfadeDuration, 2.0)
+        XCTAssertEqual(pl.entries.first?.position, 0)
+    }
+
+    func testSortedEntries() {
+        let pl = Playlist(name: "Mix")
+        let t1 = Track(title: "First", filePath: "/1", duration: 60)
+        let t2 = Track(title: "Second", filePath: "/2", duration: 90)
+        let t3 = Track(title: "Third", filePath: "/3", duration: 45)
+
+        pl.addTrack(t1)
+        pl.addTrack(t2)
+        pl.addTrack(t3)
+
+        let sorted = pl.sortedEntries
+        XCTAssertEqual(sorted.count, 3)
+        XCTAssertEqual(sorted[0].track?.title, "First")
+        XCTAssertEqual(sorted[1].track?.title, "Second")
+        XCTAssertEqual(sorted[2].track?.title, "Third")
+    }
+
+    func testFormattedTotalDuration() {
+        let pl = Playlist(name: "Mix")
+        pl.addTrack(Track(title: "A", filePath: "/a", duration: 3661))
+
+        let formatted = pl.formattedTotalDuration
+        XCTAssertEqual(formatted, "1:01:01")
+    }
+
+    func testFormattedTotalDurationShort() {
+        let pl = Playlist(name: "Mix")
+        pl.addTrack(Track(title: "A", filePath: "/a", duration: 125))
+
+        XCTAssertEqual(pl.formattedTotalDuration, "2:05")
+    }
+}
+
+/// Tests for PlaylistFolder model.
+final class PlaylistFolderTests: XCTestCase {
+
+    func testFolderCreation() {
+        let folder = PlaylistFolder(name: "Hip-Hop")
+        XCTAssertEqual(folder.name, "Hip-Hop")
+        XCTAssertTrue(folder.playlists.isEmpty)
+        XCTAssertTrue(folder.isExpanded)
+        XCTAssertEqual(folder.totalTrackCount, 0)
+    }
+
+    func testFolderWithPlaylists() {
+        let folder = PlaylistFolder(name: "Mixes")
+        let pl1 = Playlist(name: "Mix 1")
+        let pl2 = Playlist(name: "Mix 2")
+        pl1.addTrack(Track(title: "A", filePath: "/a", duration: 60))
+        pl2.addTrack(Track(title: "B", filePath: "/b", duration: 90))
+        pl2.addTrack(Track(title: "C", filePath: "/c", duration: 45))
+
+        pl1.folder = folder
+        pl2.folder = folder
+        folder.playlists = [pl1, pl2]
+
+        XCTAssertEqual(folder.playlists.count, 2)
+        XCTAssertEqual(folder.totalTrackCount, 3)
+    }
+
+    func testPlaylistFolderAssignment() {
+        let folder = PlaylistFolder(name: "Test")
+        let pl = Playlist(name: "My Playlist")
+
+        XCTAssertNil(pl.folder)
+        pl.folder = folder
+        XCTAssertNotNil(pl.folder)
+        XCTAssertEqual(pl.folder?.name, "Test")
+
+        // Remove from folder
+        pl.folder = nil
+        XCTAssertNil(pl.folder)
+    }
+
+    func testFolderExpanded() {
+        let folder = PlaylistFolder(name: "F")
+        XCTAssertTrue(folder.isExpanded)
+        folder.isExpanded = false
+        XCTAssertFalse(folder.isExpanded)
+    }
+}
+
+/// Tests for PlaylistEntry model.
+final class PlaylistEntryModelTests: XCTestCase {
+
+    func testEffectiveDuration() {
+        let track = Track(title: "T", filePath: "/t", duration: 180)
+        let entry = PlaylistEntry(position: 0, track: track, startOffset: 10, endOffset: 170)
+
+        XCTAssertEqual(entry.effectiveDuration, 160)
+    }
+
+    func testEffectiveDurationNoOffsets() {
+        let track = Track(title: "T", filePath: "/t", duration: 180)
+        let entry = PlaylistEntry(position: 0, track: track)
+
+        XCTAssertEqual(entry.effectiveDuration, 180)
+    }
+
+    func testGainDefaults() {
+        let entry = PlaylistEntry(position: 0, track: nil)
+        XCTAssertEqual(entry.gainAdjustment, 0)
+        XCTAssertEqual(entry.crossfadeDuration, 0)
+    }
+}
+
+/// Tests for PlaylistViewConfig.
+final class PlaylistViewConfigTests: XCTestCase {
+
+    func testDefaultColumns() {
+        let defaultCols = PlaylistViewConfig.defaultColumns
+        XCTAssertTrue(defaultCols.contains(.title))
+        XCTAssertTrue(defaultCols.contains(.artist))
+        XCTAssertTrue(defaultCols.contains(.duration))
+    }
+
+    func testToggleColumn() {
+        let config = PlaylistViewConfig()
+        let initialCount = config.visibleColumns.count
+
+        // Toggle off a column that exists
+        if config.isColumnVisible(.title) {
+            config.toggleColumn(.title)
+            XCTAssertFalse(config.isColumnVisible(.title))
+            XCTAssertEqual(config.visibleColumns.count, initialCount - 1)
+        }
+
+        // Toggle it back on
+        config.toggleColumn(.title)
+        XCTAssertTrue(config.isColumnVisible(.title))
+    }
+
+    func testResetToDefaults() {
+        let config = PlaylistViewConfig()
+        config.visibleColumns = [.title]
+        config.showArtwork = false
+
+        config.resetToDefaults()
+
+        XCTAssertEqual(config.visibleColumns, PlaylistViewConfig.defaultColumns)
+        XCTAssertTrue(config.showArtwork)
+    }
+
+    func testArtworkSizes() {
+        XCTAssertEqual(PlaylistViewConfig.ArtworkSize.small.points, 32)
+        XCTAssertEqual(PlaylistViewConfig.ArtworkSize.medium.points, 48)
+        XCTAssertEqual(PlaylistViewConfig.ArtworkSize.large.points, 64)
+    }
+}
+
+/// Tests for GroupTemplateResolver.
+final class GroupTemplateResolverTests: XCTestCase {
+
+    func testEmptyTemplateReturnsEmpty() {
+        let track = Track(title: "Test", artist: "Artist", album: "Album", filePath: "/music/test.mp3")
+        let result = GroupTemplateResolver.resolve(template: "", for: track)
+        XCTAssertEqual(result, "")
+    }
+
+    func testAlbumDateTemplate() {
+        let track = Track(title: "Test", artist: "Artist", album: "My Album", filePath: "/music/test.mp3")
+        track.year = 1995
+        let result = GroupTemplateResolver.resolve(template: "{Album} ({Date})", for: track)
+        XCTAssertEqual(result, "My Album (1995)")
+    }
+
+    func testAlbumDateTemplateNoYear() {
+        let track = Track(title: "Test", artist: "Artist", album: "My Album", filePath: "/music/test.mp3")
+        let result = GroupTemplateResolver.resolve(template: "{Album} ({Date})", for: track)
+        // No year → empty brackets cleaned up
+        XCTAssertEqual(result, "My Album")
+    }
+
+    func testArtistAlbumTemplate() {
+        let track = Track(title: "Song", artist: "Fagner", album: "Manera Fru Fru", filePath: "/music/test.mp3")
+        let result = GroupTemplateResolver.resolve(template: "{Artist} — {Album}", for: track)
+        XCTAssertEqual(result, "Fagner — Manera Fru Fru")
+    }
+
+    func testUnknownFallbacks() {
+        let track = Track(title: "Test", filePath: "/music/test.mp3")
+        let result = GroupTemplateResolver.resolve(template: "{Artist}", for: track)
+        XCTAssertEqual(result, "Unknown Artist")
+    }
+
+    func testFolderPlaceholder() {
+        let track = Track(title: "Test", filePath: "/music/batch1/test.mp3")
+        let result = GroupTemplateResolver.resolve(template: "{Folder}", for: track)
+        XCTAssertEqual(result, "batch1")
+    }
+
+    func testPresetsExist() {
+        XCTAssertTrue(GroupTemplateResolver.presets.count >= 9)
+        XCTAssertEqual(GroupTemplateResolver.presets.first?.template, "")
+    }
+}
+
+/// Tests for AppTheme.
+final class AppThemeTests: XCTestCase {
+
+    private var savedSkinRaw: String?
+
+    override func setUp() {
+        super.setUp()
+        // Save current skin so tests don't pollute user preferences
+        savedSkinRaw = UserDefaults.standard.string(forKey: "appThemeSkin")
+    }
+
+    override func tearDown() {
+        // Restore original skin
+        if let raw = savedSkinRaw {
+            UserDefaults.standard.set(raw, forKey: "appThemeSkin")
+        } else {
+            UserDefaults.standard.removeObject(forKey: "appThemeSkin")
+        }
+        super.tearDown()
+    }
+
+    func testDefaultSkin() {
+        let theme = AppTheme()
+        XCTAssertTrue(AppTheme.Skin.allCases.contains(theme.currentSkin))
+    }
+
+    func testSkinSwitch() {
+        let theme = AppTheme()
+        theme.currentSkin = .ocean
+        XCTAssertEqual(theme.currentSkin, .ocean)
+
+        theme.currentSkin = .warm
+        XCTAssertEqual(theme.currentSkin, .warm)
+
+        theme.currentSkin = .winampClassic
+        XCTAssertEqual(theme.currentSkin, .winampClassic)
+
+        theme.currentSkin = .foobarDark
+        XCTAssertEqual(theme.currentSkin, .foobarDark)
+
+        theme.currentSkin = .foobarLight
+        XCTAssertEqual(theme.currentSkin, .foobarLight)
+
+        theme.currentSkin = .win95
+        XCTAssertEqual(theme.currentSkin, .win95)
+    }
+
+    func testAllSkinsAvailable() {
+        // 6 modern + 8 retro = 14
+        XCTAssertEqual(AppTheme.Skin.allCases.count, 14)
+    }
+
+    func testAllSkinsApplyWithoutCrash() {
+        let theme = AppTheme()
+        for skin in AppTheme.Skin.allCases {
+            theme.currentSkin = skin
+            // Verify key properties are set to reasonable values
+            XCTAssertGreaterThan(theme.seekbarHeight, 0, "Seekbar height should be > 0 for \(skin.rawValue)")
+            XCTAssertGreaterThan(theme.dataFontSize, 0, "Font size should be > 0 for \(skin.rawValue)")
+            XCTAssertGreaterThan(theme.rowHeight, 0, "Row height should be > 0 for \(skin.rawValue)")
+        }
+    }
+
+    func testRetroSkinsHaveDistinctColors() {
+        let theme = AppTheme()
+
+        // Winamp Classic should have green text
+        theme.currentSkin = .winampClassic
+        // Just verify it doesn't crash and has accent set
+        XCTAssertEqual(theme.currentSkin, .winampClassic)
+
+        // foobar2000 should be light/system-like
+        theme.currentSkin = .foobarLight
+        XCTAssertEqual(theme.rowHeight, 18) // foobar has tighter rows
+
+        // Win95 should have thicker seekbar
+        theme.currentSkin = .win95
+        XCTAssertEqual(theme.seekbarHeight, 10)
+
+        // XP Luna
+        theme.currentSkin = .xpLuna
+        XCTAssertEqual(theme.seekbarHeight, 10)
+
+        // Mac OS 9
+        theme.currentSkin = .macOSClassic
+        XCTAssertEqual(theme.dataFontSize, 12) // slightly larger like classic Mac
+    }
+
+    func testSeekbarHeight() {
+        let theme = AppTheme()
+        theme.currentSkin = .dark
+        XCTAssertEqual(theme.seekbarHeight, 8)
+    }
+}
+
+/// Tests for AppState persistence.
+final class AppStateTests: XCTestCase {
+
+    private func clearState() {
+        UserDefaults.standard.removeObject(forKey: "appState.lastPlaylistID")
+        UserDefaults.standard.removeObject(forKey: "appState.lastEntryID")
+        UserDefaults.standard.removeObject(forKey: "appState.lastTrackFilePath")
+        UserDefaults.standard.removeObject(forKey: "appState.lastPlaybackTime")
+    }
+
+    override func setUp() {
+        super.setUp()
+        clearState()
+    }
+
+    override func tearDown() {
+        clearState()
+        super.tearDown()
+    }
+
+    func testSaveAndLoadPlaylistID() {
+        let id = UUID()
+        AppState.saveLastPlaylist(id: id)
+        XCTAssertEqual(AppState.lastPlaylistID, id)
+    }
+
+    func testSaveAndLoadEntryID() {
+        let id = UUID()
+        AppState.saveLastEntry(id: id)
+        XCTAssertEqual(AppState.lastEntryID, id)
+    }
+
+    func testSaveAndLoadTrackPath() {
+        AppState.saveLastTrack(filePath: "/music/test.mp3")
+        XCTAssertEqual(AppState.lastTrackFilePath, "/music/test.mp3")
+    }
+
+    func testSaveAndLoadPlaybackTime() {
+        AppState.savePlaybackTime(123.456)
+        XCTAssertEqual(AppState.lastPlaybackTime, 123.456, accuracy: 0.001)
+    }
+
+    func testSavePlaybackStateAll() {
+        let plID = UUID()
+        let entryID = UUID()
+        AppState.savePlaybackState(
+            playlistID: plID,
+            entryID: entryID,
+            trackFilePath: "/test.wav",
+            playbackTime: 42.5
+        )
+        XCTAssertEqual(AppState.lastPlaylistID, plID)
+        XCTAssertEqual(AppState.lastEntryID, entryID)
+        XCTAssertEqual(AppState.lastTrackFilePath, "/test.wav")
+        XCTAssertEqual(AppState.lastPlaybackTime, 42.5, accuracy: 0.001)
+    }
+
+    func testDefaultsAreNil() {
+        XCTAssertNil(AppState.lastPlaylistID)
+        XCTAssertNil(AppState.lastEntryID)
+    }
+}

+ 152 - 0
Tests/Unit/ServiceTests.swift

@@ -0,0 +1,152 @@
+import XCTest
+import AVFoundation
+@testable import MixBoard
+
+/// Tests for MetadataService.
+final class MetadataServiceTests: XCTestCase {
+
+    override func tearDown() {
+        super.tearDown()
+        TestHelpers.cleanupTestFiles()
+    }
+
+    func testSupportedExtensions() {
+        XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.mp3")))
+        XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.wav")))
+        XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.flac")))
+        XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.m4a")))
+        XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.aiff")))
+        XCTAssertFalse(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.txt")))
+        XCTAssertFalse(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.pdf")))
+        XCTAssertFalse(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.jpg")))
+    }
+
+    func testReadMetadata() async throws {
+        let url = try TestHelpers.createTestAudioFile(name: "metadata_test", duration: 2.0)
+
+        let metadata = try await MetadataService.readMetadata(from: url)
+        XCTAssertEqual(metadata.fileFormat, "WAV")
+        XCTAssertEqual(metadata.sampleRate, 44100)
+        XCTAssertEqual(metadata.channels, 2)
+        XCTAssertGreaterThan(metadata.duration, 1.5)
+        XCTAssertLessThan(metadata.duration, 2.5)
+        XCTAssertGreaterThan(metadata.fileSizeBytes, 0)
+    }
+}
+
+/// Tests for WaveformGenerator.
+final class WaveformGeneratorTests: XCTestCase {
+
+    override func tearDown() {
+        super.tearDown()
+        TestHelpers.cleanupTestFiles()
+    }
+
+    func testGenerateWaveform() async throws {
+        let url = try TestHelpers.createTestAudioFile(name: "waveform_test", duration: 2.0)
+
+        let samples = try await WaveformGenerator.generateWaveform(fileURL: url, resolution: 100)
+
+        XCTAssertEqual(samples.count, 100)
+
+        // Sine wave should have non-zero min/max values
+        let hasNonZero = samples.contains { $0.max > 0.01 || $0.min < -0.01 }
+        XCTAssertTrue(hasNonZero, "Waveform should have non-zero samples")
+    }
+
+    func testWaveformEncodeDecode() async throws {
+        let url = try TestHelpers.createTestAudioFile(name: "waveform_codec", duration: 1.0)
+        let samples = try await WaveformGenerator.generateWaveform(fileURL: url, resolution: 50)
+
+        // Encode
+        let data = try JSONEncoder().encode(samples)
+        XCTAssertGreaterThan(data.count, 0)
+
+        // Decode
+        let decoded = WaveformGenerator.decodeCachedWaveform(from: data)
+        XCTAssertNotNil(decoded)
+        XCTAssertEqual(decoded?.count, 50)
+    }
+
+    func testWaveformResolutions() async throws {
+        let url = try TestHelpers.createTestAudioFile(name: "waveform_res", duration: 1.0)
+
+        let low = try await WaveformGenerator.generateWaveform(fileURL: url, resolution: 10)
+        let high = try await WaveformGenerator.generateWaveform(fileURL: url, resolution: 500)
+
+        XCTAssertEqual(low.count, 10)
+        XCTAssertEqual(high.count, 500)
+    }
+}
+
+/// Tests for BPMDetector.
+final class BPMDetectorTests: XCTestCase {
+
+    override func tearDown() {
+        super.tearDown()
+        TestHelpers.cleanupTestFiles()
+    }
+
+    func testDetectBPM() async throws {
+        // Note: BPM detection requires longer audio and may fail with test-generated sine waves
+        let url = try TestHelpers.createTestAudioFile(name: "bpm_test", duration: 10.0)
+
+        do {
+            let bpm = try await BPMDetector.detectBPM(fileURL: url)
+            XCTAssertGreaterThanOrEqual(bpm, 60)
+            XCTAssertLessThanOrEqual(bpm, 200)
+        } catch {
+            // Acceptable: test-generated WAV may not work with all readers
+        }
+    }
+
+    func testBPMTooShort() async {
+        do {
+            let url = try TestHelpers.createTestAudioFile(name: "bpm_short", duration: 0.1)
+            _ = try await BPMDetector.detectBPM(fileURL: url)
+            // If it doesn't throw, that's also acceptable — just check it returns a number
+        } catch {
+            // Expected — short audio may throw
+        }
+    }
+}
+
+/// Tests for KeyDetector.
+final class KeyDetectorTests: XCTestCase {
+
+    override func tearDown() {
+        super.tearDown()
+        TestHelpers.cleanupTestFiles()
+    }
+
+    func testDetectKey() async throws {
+        let url = try TestHelpers.createTestAudioFile(
+            name: "key_test",
+            duration: 5.0,
+            frequency: 440
+        )
+
+        do {
+            let result = try await KeyDetector.detectKey(fileURL: url)
+            XCTAssertFalse(result.key.isEmpty)
+            XCTAssertFalse(result.camelotCode.isEmpty)
+            XCTAssertGreaterThanOrEqual(result.confidence, 0)
+            XCTAssertLessThanOrEqual(result.confidence, 1)
+            XCTAssertGreaterThanOrEqual(result.rootNote, 0)
+            XCTAssertLessThan(result.rootNote, 12)
+        } catch {
+            // Acceptable: test-generated WAV may not work with mono reader
+        }
+    }
+
+    func testKeyShortFormat() async throws {
+        let url = try TestHelpers.createTestAudioFile(name: "key_short", duration: 5.0)
+        do {
+            let result = try await KeyDetector.detectKey(fileURL: url)
+            XCTAssertFalse(result.shortKey.isEmpty)
+            XCTAssertLessThanOrEqual(result.shortKey.count, 4)
+        } catch {
+            // Acceptable
+        }
+    }
+}

+ 58 - 0
project.yml

@@ -0,0 +1,58 @@
+name: MixBoard
+options:
+  bundleIdPrefix: com.mixboard
+  deploymentTarget:
+    macOS: "14.0"
+  generateEmptyDirectories: true
+  xcodeVersion: "16.0"
+
+settings:
+  base:
+    SWIFT_VERSION: "5.9"
+    MACOSX_DEPLOYMENT_TARGET: "14.0"
+
+targets:
+  MixBoard:
+    type: application
+    platform: macOS
+    sources:
+      - path: Sources
+      - path: Assets.xcassets
+    settings:
+      base:
+        PRODUCT_BUNDLE_IDENTIFIER: com.mixboard.MixBoard
+        PRODUCT_NAME: MixBoard
+        GENERATE_INFOPLIST_FILE: NO
+        INFOPLIST_FILE: Sources/Resources/Info.plist
+        MARKETING_VERSION: "1.0.0"
+        CURRENT_PROJECT_VERSION: 1
+        SWIFT_EMIT_LOC_STRINGS: YES
+        COMBINE_HIDPI_IMAGES: YES
+        ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+        ENABLE_HARDENED_RUNTIME: YES
+        CODE_SIGN_ENTITLEMENTS: ""
+        SWIFT_OBJC_BRIDGING_HEADER: Sources/OGG/MixBoard-Bridging-Header.h
+        INFOPLIST_KEY_LSApplicationCategoryType: "public.app-category.music"
+    configs:
+        Debug:
+          CODE_SIGN_IDENTITY: "-"
+          SWIFT_OPTIMIZATION_LEVEL: "-Onone"
+        Release:
+          SWIFT_OPTIMIZATION_LEVEL: "-O"
+
+  MixBoardTests:
+    type: bundle.unit-test
+    platform: macOS
+    sources:
+      - path: Tests
+    dependencies:
+      - target: MixBoard
+    settings:
+      base:
+        PRODUCT_BUNDLE_IDENTIFIER: com.mixboard.MixBoardTests
+        GENERATE_INFOPLIST_FILE: YES
+        TEST_HOST: "$(BUILT_PRODUCTS_DIR)/MixBoard.app/Contents/MacOS/MixBoard"
+        BUNDLE_LOADER: "$(TEST_HOST)"
+      configs:
+        Debug:
+          CODE_SIGN_IDENTITY: "-"