Преглед изворни кода

Initial commit: MixBoard iOS music player

Swift 5.9 / SwiftUI / iOS 17+
- AVAudioEngine playback (MP3, FLAC, OGG, Opus)
- Chad Music cloud streaming
- BPM and key detection
- Waveform visualization, 7 skins
- Playlists with folders, cue points
- Background audio, queue management
- Sync with MixBoard macOS
aldiss пре 3 месеци
комит
d1a9fa69d2
100 измењених фајлова са 23946 додато и 0 уклоњено
  1. 447 0
      .github/agents/orchestra-v2.agent.md
  2. 26 0
      .gitignore
  3. 39 0
      .orchestra/cloud-streaming/spec.md
  4. 17 0
      .orchestra/config.json
  5. 0 0
      .orchestra/knowledge.md
  6. 341 0
      DEVELOPMENT_SUMMARY.md
  7. 26 0
      Info.plist
  8. 10 0
      MixBoardiOS.entitlements
  9. 907 0
      MixBoardiOS.xcodeproj/project.pbxproj
  10. 7 0
      MixBoardiOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata
  11. 16 0
      Package.swift
  12. 59 0
      Sources/MixBoardApp.swift
  13. 59 0
      Sources/Models/AppState.swift
  14. 255 0
      Sources/Models/AppTheme.swift
  15. 120 0
      Sources/Models/ChadMusic.swift
  16. 67 0
      Sources/Models/CuePoint.swift
  17. 70 0
      Sources/Models/GroupTemplateResolver.swift
  18. 143 0
      Sources/Models/Playlist.swift
  19. 30 0
      Sources/Models/PlaylistFolder.swift
  20. 56 0
      Sources/Models/QueueEntry.swift
  21. 140 0
      Sources/Models/Track.swift
  22. 16 0
      Sources/OGG/MixBoard-Bridging-Header.h
  23. 1 0
      Sources/OGG/ogg.h
  24. 5584 0
      Sources/OGG/stb_vorbis.c
  25. 10 0
      Sources/OGG/stb_vorbis_wrapper.h
  26. 26 0
      Sources/OpusLib/include/ogg/config_types.h
  27. 209 0
      Sources/OpusLib/include/ogg/ogg.h
  28. 158 0
      Sources/OpusLib/include/ogg/os_types.h
  29. 981 0
      Sources/OpusLib/include/opus/opus.h
  30. 801 0
      Sources/OpusLib/include/opus/opus_defines.h
  31. 660 0
      Sources/OpusLib/include/opus/opus_multistream.h
  32. 568 0
      Sources/OpusLib/include/opus/opus_projection.h
  33. 166 0
      Sources/OpusLib/include/opus/opus_types.h
  34. 2164 0
      Sources/OpusLib/include/opus/opusfile.h
  35. BIN
      Sources/OpusLib/lib/libogg.a
  36. BIN
      Sources/OpusLib/lib/libopus.a
  37. BIN
      Sources/OpusLib/lib/libopusfile.a
  38. 15 0
      Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json
  39. 9 0
      Sources/Resources/Assets.xcassets/AppIcon-Blue.appiconset/Contents.json
  40. BIN
      Sources/Resources/Assets.xcassets/AppIcon-Blue.appiconset/icon_1024.png
  41. 9 0
      Sources/Resources/Assets.xcassets/AppIcon-Cyan.appiconset/Contents.json
  42. BIN
      Sources/Resources/Assets.xcassets/AppIcon-Cyan.appiconset/icon_1024.png
  43. 9 0
      Sources/Resources/Assets.xcassets/AppIcon-Gold.appiconset/Contents.json
  44. BIN
      Sources/Resources/Assets.xcassets/AppIcon-Gold.appiconset/icon_1024.png
  45. 9 0
      Sources/Resources/Assets.xcassets/AppIcon-Green.appiconset/Contents.json
  46. BIN
      Sources/Resources/Assets.xcassets/AppIcon-Green.appiconset/icon_1024.png
  47. 9 0
      Sources/Resources/Assets.xcassets/AppIcon-Lime.appiconset/Contents.json
  48. BIN
      Sources/Resources/Assets.xcassets/AppIcon-Lime.appiconset/icon_1024.png
  49. 9 0
      Sources/Resources/Assets.xcassets/AppIcon-Orange.appiconset/Contents.json
  50. BIN
      Sources/Resources/Assets.xcassets/AppIcon-Orange.appiconset/icon_1024.png
  51. 9 0
      Sources/Resources/Assets.xcassets/AppIcon-Pink.appiconset/Contents.json
  52. BIN
      Sources/Resources/Assets.xcassets/AppIcon-Pink.appiconset/icon_1024.png
  53. 9 0
      Sources/Resources/Assets.xcassets/AppIcon-Purple.appiconset/Contents.json
  54. BIN
      Sources/Resources/Assets.xcassets/AppIcon-Purple.appiconset/icon_1024.png
  55. 9 0
      Sources/Resources/Assets.xcassets/AppIcon-Red.appiconset/Contents.json
  56. BIN
      Sources/Resources/Assets.xcassets/AppIcon-Red.appiconset/icon_1024.png
  57. 9 0
      Sources/Resources/Assets.xcassets/AppIcon-White.appiconset/Contents.json
  58. BIN
      Sources/Resources/Assets.xcassets/AppIcon-White.appiconset/icon_1024.png
  59. 9 0
      Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
  60. BIN
      Sources/Resources/Assets.xcassets/AppIcon.appiconset/icon_1024.png
  61. 6 0
      Sources/Resources/Assets.xcassets/Contents.json
  62. 110 0
      Sources/Services/ArtworkService.swift
  63. 382 0
      Sources/Services/AudioEngine.swift
  64. 129 0
      Sources/Services/BPMDetector.swift
  65. 154 0
      Sources/Services/ChadMusicAPIClient.swift
  66. 161 0
      Sources/Services/KeyDetector.swift
  67. 65 0
      Sources/Services/KeychainService.swift
  68. 199 0
      Sources/Services/LRCLIBService.swift
  69. 376 0
      Sources/Services/LibraryManager.swift
  70. 98 0
      Sources/Services/LyricsParser.swift
  71. 89 0
      Sources/Services/MediaKeyHandler.swift
  72. 354 0
      Sources/Services/MetadataService.swift
  73. 130 0
      Sources/Services/OGGDecoder.swift
  74. 107 0
      Sources/Services/OpusDecoder.swift
  75. 180 0
      Sources/Services/StreamingPlayer.swift
  76. 185 0
      Sources/Services/SyncManager.swift
  77. 73 0
      Sources/Services/WaveformGenerator.swift
  78. 657 0
      Sources/ViewModels/PlayerViewModel.swift
  79. 243 0
      Sources/ViewModels/PlaylistViewModel.swift
  80. 75 0
      Sources/Views/AddGroupToPlaylistSheet.swift
  81. 77 0
      Sources/Views/AddToPlaylistSheet.swift
  82. 596 0
      Sources/Views/CloudBrowserView.swift
  83. 116 0
      Sources/Views/ContentView.swift
  84. 352 0
      Sources/Views/FolderBrowserView.swift
  85. 115 0
      Sources/Views/GroupTemplateEditorSheet.swift
  86. 526 0
      Sources/Views/LibraryView.swift
  87. 121 0
      Sources/Views/MiniPlayerView.swift
  88. 704 0
      Sources/Views/NowPlayingView.swift
  89. 480 0
      Sources/Views/PlaylistDetailView.swift
  90. 218 0
      Sources/Views/PlaylistListView.swift
  91. 144 0
      Sources/Views/QueueView.swift
  92. 428 0
      Sources/Views/SettingsView.swift
  93. 158 0
      Sources/Views/TrackRow.swift
  94. 60 0
      Sources/Views/WaveformView.swift
  95. 167 0
      Tests/AudioEngineTests.swift
  96. 350 0
      Tests/CloudStreamingTests.swift
  97. 141 0
      Tests/CodecTests.swift
  98. 199 0
      Tests/GroupTemplateResolverTests.swift
  99. 397 0
      Tests/LyricsTests.swift
  100. 531 0
      Tests/ModelTests.swift

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

@@ -0,0 +1,447 @@
+---
+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
+
+---
+
+## Operating Mode: Commit
+
+You operate in **Commit mode** by default. This means:
+
+- **Decide and execute.** You have a shaped brief from PM — build against it.
+- **Treat blocking findings seriously.** If a reviewer marks a finding as **BLOCKING**, you are instructed to treat this as a stop-work signal. You must either resolve the issue or escalate it to the user. You cannot self-override blocking findings.
+- **Advisory findings are your call.** Consider them, incorporate if you agree, defer if not. State why.
+- **Check scope against appetite.** If your implementation plan exceeds the scope/appetite defined in the PM brief (e.g., PM said "< 3 files" but you need 8), STOP and ask the user — don't just build bigger.
+
+### Blocking Finding Rubric
+
+A reviewer finding is **BLOCKING** if it involves any of:
+- **Safety**: could cause data loss, security vulnerability, or system instability
+- **Irreversibility**: change cannot be easily undone (DB migrations, public API changes)
+- **Ambiguous requirements**: acceptance criteria are unclear or contradictory
+- **Untestable**: no way to verify the change works correctly
+- **Scope violation**: implementation exceeds the PM brief's appetite
+- **Performance/scalability**: introduces O(n²) or worse patterns, unindexed queries on large tables
+
+If none of these apply, the finding is **ADVISORY**.
+
+### User Mode Override
+- **"/explore"** → Switch to Explore mode: ask more questions, challenge assumptions, generate alternatives before building.
+- **"/commit"** → Return to Commit mode (default).
+
+---
+
+## 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

+ 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

+ 39 - 0
.orchestra/cloud-streaming/spec.md

@@ -0,0 +1,39 @@
+# Cloud Music Streaming — iOS Port Spec
+
+## Overview
+Port the Chad Music cloud streaming feature from macOS MixBoard to iOS MixBoard. Users connect to a self-hosted Chad Music REST API server (Tailnet) to browse and stream their cloud music library. Cloud tracks can be mixed into the same playlists as local tracks.
+
+## Architecture
+
+### New Files (5)
+| File | Location | Purpose |
+|------|----------|---------|
+| ChadMusic.swift | Models/ | API response models (ChadCategory, ChadAlbum, ChadTrack, ChadStats, ChadCategoryType) |
+| ChadMusicAPIClient.swift | Services/ | HTTP client — Bearer auth, URL composition, JSON decoding |
+| StreamingPlayer.swift | Services/ | AVPlayer-based streaming (separate from AVAudioEngine) |
+| KeychainService.swift | Services/ | Keychain wrapper for API key storage |
+| CloudBrowserView.swift | Views/ | Browse categories → albums → tracks with play/add-to-playlist |
+
+### Modified Files (6)
+| File | Changes |
+|------|---------|
+| Track.swift | Add `isCloud`, `cloudStreamPath`, `cloudTrackId` + `fromCloud()` factory |
+| PlayerViewModel.swift | Add StreamingPlayer, dual-source routing, cloud transport |
+| SettingsView.swift | Add "Chad Music" section (URL, API key, test connection) |
+| ContentView.swift | Add cloud browser sheet trigger |
+| MiniPlayerView.swift | Buffering indicator, cloud icon |
+| MixBoardApp.swift | Bump DB reset to dbResetV7 |
+
+### How It Works
+1. User enters server URL + API key in Settings → Chad Music
+2. CloudBrowserView fetches categories/albums/tracks from the API
+3. Tapping a cloud track → PlayerViewModel routes to StreamingPlayer (AVPlayer)
+4. "Add to Playlist" creates a Track with `isCloud=true` in SwiftData
+5. Playing a cloud Track from a playlist → PlayerViewModel detects `isCloud`, builds stream URL, uses StreamingPlayer
+6. All transport controls (play/pause/seek/skip/next/prev) route based on `isCloudPlayback` flag
+
+### Navigation Pattern
+CloudBrowserView is presented as a **sheet** from a toolbar button in PlaylistListView — consistent with the existing Library/Settings sheet pattern.
+
+### DB Migration
+Nuclear reset approach (existing pattern): bump `dbResetV6` → `dbResetV7` to force schema recreation with new Track fields.

+ 17 - 0
.orchestra/config.json

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

+ 0 - 0
.orchestra/knowledge.md


+ 341 - 0
DEVELOPMENT_SUMMARY.md

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

+ 26 - 0
Info.plist

@@ -0,0 +1,26 @@
+<?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>UIBackgroundModes</key>
+	<array>
+		<string>audio</string>
+	</array>
+</dict>
+</plist>

+ 10 - 0
MixBoardiOS.entitlements

@@ -0,0 +1,10 @@
+<?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>com.apple.security.application-groups</key>
+	<array>
+		<string>group.com.mixboard</string>
+	</array>
+</dict>
+</plist>

+ 907 - 0
MixBoardiOS.xcodeproj/project.pbxproj

@@ -0,0 +1,907 @@
+// !$*UTF8*$!
+{
+	archiveVersion = 1;
+	classes = {
+	};
+	objectVersion = 63;
+	objects = {
+
+/* Begin PBXBuildFile section */
+		07CF52ADAE84DB873EACEF78 /* BPMDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF736221D49CF02BA7C8D6B9 /* BPMDetector.swift */; };
+		24383DE5713184D409FD9063 /* ChadMusic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6363FFDA55CFD993BC309249 /* ChadMusic.swift */; };
+		26A26B41B196B5BD4930DC32 /* GroupTemplateResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9976E6544585B296A1C101DA /* GroupTemplateResolver.swift */; };
+		26BB12106B505E43BF4FC9D6 /* PlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C1236BDE95EB6791D6236FB /* PlayerViewModel.swift */; };
+		2A5E4EBC04A32429A488B917 /* OpusDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AF5A8303D2C02C64E38BFFD /* OpusDecoder.swift */; };
+		3395A9BA0229770B4DB5BC87 /* StreamingPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A723E3458C238F1FD1BFD3C2 /* StreamingPlayer.swift */; };
+		379B32F8532B619ED072A027 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60ECA4A868B078D1883187AC /* ContentView.swift */; };
+		380E85DD78DF70452FD714D3 /* QueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E914A3FD78964862B2614958 /* QueueView.swift */; };
+		3BB9EDFDD0549752FF295F3E /* PlayerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6B64DCACBFBECC6891C90CC /* PlayerViewModelTests.swift */; };
+		43393F667709155B8274BCF7 /* libogg.a in Resources */ = {isa = PBXBuildFile; fileRef = CA445FC9E802A4C20E3A403D /* libogg.a */; };
+		4743395D35A8D95C547C8CB9 /* LibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F3B7C5A143DE798D4626FE8 /* LibraryManager.swift */; };
+		5628796FA14B92BBF9B43E32 /* PlaylistViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6C84B8774EB16049C5D0634 /* PlaylistViewModelTests.swift */; };
+		57711D4FCC56CF0EAA3B9AEA /* GroupTemplateEditorSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08D15D5EE07B0A62BFD840FE /* GroupTemplateEditorSheet.swift */; };
+		5D6C44C69AF7AC10EF57654F /* AudioEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE2DAEAE9E4548FEAE43DD6F /* AudioEngine.swift */; };
+		5E8CEFA7EE241802F4811825 /* AudioEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F558E3B192986DC2EBB0ED46 /* AudioEngineTests.swift */; };
+		606C5864CF7BFE0FD9D45D49 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40F598DD9BCE1FA0AE855690 /* AppTheme.swift */; };
+		69501B07F5A60CC4F3FD0FD2 /* GroupTemplateResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A92720B54E0C6F6952D1DC3 /* GroupTemplateResolverTests.swift */; };
+		70D3F6D051FF364AEE762044 /* MediaKeyHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F53966C8741493C981D95364 /* MediaKeyHandler.swift */; };
+		713F146AE128A72D7685ED85 /* SyncManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B76DAB40E97BB6491A86E6E /* SyncManagerTests.swift */; };
+		759C3290BED9FA13BC6E3826 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD7C91A9A81F45E761A50F33 /* SettingsView.swift */; };
+		7726CE9DEFF12E97426C682E /* MixBoardApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACC56A6245FB276D23559CBF /* MixBoardApp.swift */; };
+		7B2F8EAEAFFC2EC639BDD70C /* stb_vorbis.c in Sources */ = {isa = PBXBuildFile; fileRef = A7E76EF83D1D8E970E0A31A5 /* stb_vorbis.c */; };
+		7C6A84B33D7D5615EB0B05DE /* QueueEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F7DDFFB0052E82B9579FA7 /* QueueEntry.swift */; };
+		87CF06028B178836BA6DC55D /* MiniPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30AB12561B1AF730065EC608 /* MiniPlayerView.swift */; };
+		8959E71D433588DDD17678DC /* WaveformView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BD2DB1EC2209FF74C47FDA7 /* WaveformView.swift */; };
+		8C364F881121FBDC0507BE3F /* LRCLIBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 631AC23E23D3E1BDC9ADF853 /* LRCLIBService.swift */; };
+		8E08196F160224F02E37D063 /* SyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9ECC0563919D01F75D8E0B7 /* SyncManager.swift */; };
+		912A19B864DD7BA90CDACFB5 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 661D06C0F6B4159529E7CC6E /* KeychainService.swift */; };
+		92AB1107C3C9933966713ACA /* libopusfile.a in Resources */ = {isa = PBXBuildFile; fileRef = 624A8B3A36FAC5FB9DDC5E67 /* libopusfile.a */; };
+		95899377852BF2AC878090A8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 68DD53987AB941CD45442704 /* Assets.xcassets */; };
+		98815185EDE0A4BCBF17C5B9 /* PlaylistViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35E4CBA48B8B32CE1BCAD7C8 /* PlaylistViewModel.swift */; };
+		9B9F0CF0742875A907E153AA /* OGGDecoder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDE955C924C0198C7352401 /* OGGDecoder.swift */; };
+		9C3EE050D166FC5929766834 /* MixBoardUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B2F31275CB65372CA6FA5A0 /* MixBoardUITests.swift */; };
+		9E9C91E092AB64883F6EE6B0 /* KeyDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645DBC1C9CF4F140B672D7B1 /* KeyDetector.swift */; };
+		9EA1E32B38B0F9E99870EC59 /* WaveformGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAECFB0715C166BB9B07054 /* WaveformGenerator.swift */; };
+		A0784C716AA15CDE2E0B7422 /* LyricsParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D19017A4644FC0728357C3F /* LyricsParser.swift */; };
+		A51C687AE19C3E84FD54C5B9 /* AddToPlaylistSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBBA50A7582B55F231E5AA6C /* AddToPlaylistSheet.swift */; };
+		A691BE9634610830E1BB808A /* FolderBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3065C0A0D56A81C39226FE66 /* FolderBrowserView.swift */; };
+		B277B6A1AE7A7B3F3B5048BF /* TrackRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DB4B4B5F5AC588DCFDB0CD /* TrackRow.swift */; };
+		B54468EDAAEF2726A6B38C0C /* AddGroupToPlaylistSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E41DB4A612D6382448F0DD4A /* AddGroupToPlaylistSheet.swift */; };
+		B769842D41E6024B9BDAEC75 /* CodecTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2BA9BE95AB7E0120C386B49 /* CodecTests.swift */; };
+		BDC7784201348B34183BEA51 /* CuePoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B2D6AC79F54F259894E400E /* CuePoint.swift */; };
+		BEFC8982E0D4314A9DAEEBD8 /* PlaylistDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3CE708A06FA6FACD9163798 /* PlaylistDetailView.swift */; };
+		BFC987A83994155E5702AC68 /* PlaylistFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = B06CCD1797B66666B24AF57F /* PlaylistFolder.swift */; };
+		C3661CDAB1BE2C95AE69ADB1 /* ChadMusicAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3FDBF83B261F1B1F2FD07AA /* ChadMusicAPIClient.swift */; };
+		C5445BE9211773A3C765686F /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F082E02B359284DF788ECB01 /* AppState.swift */; };
+		CC051E3E0E64005C419E2A96 /* libopus.a in Resources */ = {isa = PBXBuildFile; fileRef = 449D61AC1EE4C72C87FDE11B /* libopus.a */; };
+		CCECD84E3286B2500DE1FDFE /* Playlist.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475B0D96BE1F660E43F4338F /* Playlist.swift */; };
+		D2F69A15D9EAD0B0F7AB4321 /* CloudStreamingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF1DED14695761FDB3C92ABE /* CloudStreamingTests.swift */; };
+		D408096F4D08840C966D4DC3 /* MetadataService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83502527607F43B5AAF43A5B /* MetadataService.swift */; };
+		D745E3B69D257B06ECB55735 /* PlaylistListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEFB8D4A7732D9179B80961 /* PlaylistListView.swift */; };
+		D90B76ED48494E5EFAF307AD /* Track.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2911C47EF0A790D14A41ACCD /* Track.swift */; };
+		DD56D0E534B6AC6DAC3DD238 /* CloudBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7774A282E55258E902663EB2 /* CloudBrowserView.swift */; };
+		E7254E3B096C82833E6EAC32 /* WaveformGeneratorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B402C83CB50D990A2E067E9E /* WaveformGeneratorTests.swift */; };
+		E73B39EC4AB5EE03B770ECE9 /* LyricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B407D125FA9B66C2F5AE6449 /* LyricsTests.swift */; };
+		EABC718B141E4A741CB7A338 /* ArtworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FEBDB0BB1A240BB292F64A6 /* ArtworkService.swift */; };
+		EB0AE5BCF77E33C39B2062AE /* ModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE159AE643FA6D443DA2A58 /* ModelTests.swift */; };
+		F68E77C46DA49D37AF843648 /* NowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA7FCB9E71FB67DBBBBA237E /* NowPlayingView.swift */; };
+		F9E1EC2A05D690057B963102 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE57266AAD0021383B334BCD /* LibraryView.swift */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+		47AE927601EF93344ADF48E4 /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 136856E86875B0E72B3BA10F /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 0907E09982F49B0BAE3D2CB1;
+			remoteInfo = MixBoardiOS;
+		};
+		504CB81CAC10624153757F4D /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 136856E86875B0E72B3BA10F /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 0907E09982F49B0BAE3D2CB1;
+			remoteInfo = MixBoardiOS;
+		};
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXFileReference section */
+		08D15D5EE07B0A62BFD840FE /* GroupTemplateEditorSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupTemplateEditorSheet.swift; sourceTree = "<group>"; };
+		0AF5A8303D2C02C64E38BFFD /* OpusDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpusDecoder.swift; sourceTree = "<group>"; };
+		0B2F31275CB65372CA6FA5A0 /* MixBoardUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixBoardUITests.swift; sourceTree = "<group>"; };
+		0FEBDB0BB1A240BB292F64A6 /* ArtworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtworkService.swift; sourceTree = "<group>"; };
+		1C1236BDE95EB6791D6236FB /* PlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewModel.swift; sourceTree = "<group>"; };
+		2911C47EF0A790D14A41ACCD /* Track.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Track.swift; sourceTree = "<group>"; };
+		2E5081BAC653C98E20486642 /* opus_types.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = opus_types.h; sourceTree = "<group>"; };
+		3065C0A0D56A81C39226FE66 /* FolderBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderBrowserView.swift; sourceTree = "<group>"; };
+		30AB12561B1AF730065EC608 /* MiniPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiniPlayerView.swift; sourceTree = "<group>"; };
+		35E4CBA48B8B32CE1BCAD7C8 /* PlaylistViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistViewModel.swift; sourceTree = "<group>"; };
+		38F7DDFFB0052E82B9579FA7 /* QueueEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueEntry.swift; sourceTree = "<group>"; };
+		3B76DAB40E97BB6491A86E6E /* SyncManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncManagerTests.swift; sourceTree = "<group>"; };
+		3BD2DB1EC2209FF74C47FDA7 /* WaveformView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformView.swift; sourceTree = "<group>"; };
+		40F598DD9BCE1FA0AE855690 /* AppTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppTheme.swift; sourceTree = "<group>"; };
+		449D61AC1EE4C72C87FDE11B /* libopus.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libopus.a; sourceTree = "<group>"; };
+		475B0D96BE1F660E43F4338F /* Playlist.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Playlist.swift; sourceTree = "<group>"; };
+		4B2D6AC79F54F259894E400E /* CuePoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CuePoint.swift; sourceTree = "<group>"; };
+		60ECA4A868B078D1883187AC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
+		624A8B3A36FAC5FB9DDC5E67 /* libopusfile.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libopusfile.a; sourceTree = "<group>"; };
+		631AC23E23D3E1BDC9ADF853 /* LRCLIBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LRCLIBService.swift; sourceTree = "<group>"; };
+		6363FFDA55CFD993BC309249 /* ChadMusic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChadMusic.swift; sourceTree = "<group>"; };
+		645DBC1C9CF4F140B672D7B1 /* KeyDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyDetector.swift; sourceTree = "<group>"; };
+		661D06C0F6B4159529E7CC6E /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = "<group>"; };
+		68DD53987AB941CD45442704 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
+		6A5EBAA40B5EDC89CCE2B2BB /* stb_vorbis_wrapper.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = stb_vorbis_wrapper.h; sourceTree = "<group>"; };
+		6A92720B54E0C6F6952D1DC3 /* GroupTemplateResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupTemplateResolverTests.swift; sourceTree = "<group>"; };
+		6D726B0D736F677437FEC8BA /* MixBoardiOSUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = MixBoardiOSUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+		6EE3DE980DF887C4317E1E04 /* MixBoardiOSTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = MixBoardiOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+		6F3B7C5A143DE798D4626FE8 /* LibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryManager.swift; sourceTree = "<group>"; };
+		7774A282E55258E902663EB2 /* CloudBrowserView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudBrowserView.swift; sourceTree = "<group>"; };
+		7D19017A4644FC0728357C3F /* LyricsParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsParser.swift; sourceTree = "<group>"; };
+		83502527607F43B5AAF43A5B /* MetadataService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataService.swift; sourceTree = "<group>"; };
+		88A00D973DFE61DA80CEFC63 /* opusfile.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = opusfile.h; sourceTree = "<group>"; };
+		8CE159AE643FA6D443DA2A58 /* ModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelTests.swift; sourceTree = "<group>"; };
+		97BCB55CDAD16C2AD0750458 /* opus_defines.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = opus_defines.h; sourceTree = "<group>"; };
+		9976E6544585B296A1C101DA /* GroupTemplateResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupTemplateResolver.swift; sourceTree = "<group>"; };
+		9EAECFB0715C166BB9B07054 /* WaveformGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformGenerator.swift; sourceTree = "<group>"; };
+		9EDE955C924C0198C7352401 /* OGGDecoder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OGGDecoder.swift; sourceTree = "<group>"; };
+		A50996A9FDBA63F9AEDD4902 /* opus.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = opus.h; sourceTree = "<group>"; };
+		A6C84B8774EB16049C5D0634 /* PlaylistViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistViewModelTests.swift; sourceTree = "<group>"; };
+		A723E3458C238F1FD1BFD3C2 /* StreamingPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamingPlayer.swift; sourceTree = "<group>"; };
+		A7E76EF83D1D8E970E0A31A5 /* stb_vorbis.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = stb_vorbis.c; sourceTree = "<group>"; };
+		A9ECC0563919D01F75D8E0B7 /* SyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncManager.swift; sourceTree = "<group>"; };
+		AA7FCB9E71FB67DBBBBA237E /* NowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingView.swift; sourceTree = "<group>"; };
+		ACC56A6245FB276D23559CBF /* MixBoardApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MixBoardApp.swift; sourceTree = "<group>"; };
+		AF736221D49CF02BA7C8D6B9 /* BPMDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BPMDetector.swift; sourceTree = "<group>"; };
+		B06CCD1797B66666B24AF57F /* PlaylistFolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistFolder.swift; sourceTree = "<group>"; };
+		B2120B77C1DC2A2C489C4495 /* MixBoard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MixBoard.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		B402C83CB50D990A2E067E9E /* WaveformGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaveformGeneratorTests.swift; sourceTree = "<group>"; };
+		B407D125FA9B66C2F5AE6449 /* LyricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LyricsTests.swift; sourceTree = "<group>"; };
+		B4C783FE8D72490B0C9FC434 /* config_types.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = config_types.h; sourceTree = "<group>"; };
+		BE57266AAD0021383B334BCD /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
+		BEEFB8D4A7732D9179B80961 /* PlaylistListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistListView.swift; sourceTree = "<group>"; };
+		C6B64DCACBFBECC6891C90CC /* PlayerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewModelTests.swift; sourceTree = "<group>"; };
+		CA445FC9E802A4C20E3A403D /* libogg.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libogg.a; sourceTree = "<group>"; };
+		CBBA50A7582B55F231E5AA6C /* AddToPlaylistSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToPlaylistSheet.swift; sourceTree = "<group>"; };
+		CC965C1466ACDD2E38AE5321 /* os_types.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = os_types.h; sourceTree = "<group>"; };
+		D3FDBF83B261F1B1F2FD07AA /* ChadMusicAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChadMusicAPIClient.swift; sourceTree = "<group>"; };
+		DD7C91A9A81F45E761A50F33 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
+		E3CE708A06FA6FACD9163798 /* PlaylistDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistDetailView.swift; sourceTree = "<group>"; };
+		E41DB4A612D6382448F0DD4A /* AddGroupToPlaylistSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupToPlaylistSheet.swift; sourceTree = "<group>"; };
+		E90BEC484812C2BA0DF15852 /* opus_projection.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = opus_projection.h; sourceTree = "<group>"; };
+		E914A3FD78964862B2614958 /* QueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueView.swift; sourceTree = "<group>"; };
+		EE2DAEAE9E4548FEAE43DD6F /* AudioEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngine.swift; sourceTree = "<group>"; };
+		EF1DED14695761FDB3C92ABE /* CloudStreamingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudStreamingTests.swift; sourceTree = "<group>"; };
+		F082E02B359284DF788ECB01 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
+		F0DB4B4B5F5AC588DCFDB0CD /* TrackRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackRow.swift; sourceTree = "<group>"; };
+		F2BA9BE95AB7E0120C386B49 /* CodecTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodecTests.swift; sourceTree = "<group>"; };
+		F44D9FF6356AB9C0C1F531D7 /* opus_multistream.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = opus_multistream.h; sourceTree = "<group>"; };
+		F53966C8741493C981D95364 /* MediaKeyHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaKeyHandler.swift; sourceTree = "<group>"; };
+		F53DEF563120C3F3B6EC9B17 /* ogg.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ogg.h; sourceTree = "<group>"; };
+		F558E3B192986DC2EBB0ED46 /* AudioEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEngineTests.swift; sourceTree = "<group>"; };
+		F5D297D015B8240DFA10635C /* MixBoard-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "MixBoard-Bridging-Header.h"; sourceTree = "<group>"; };
+		FC6B4D6B6FBB4F0F5CEE8827 /* ogg.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ogg.h; sourceTree = "<group>"; };
+/* End PBXFileReference section */
+
+/* Begin PBXGroup section */
+		01413B5334A21F4D1CEEE0C0 /* opus */ = {
+			isa = PBXGroup;
+			children = (
+				97BCB55CDAD16C2AD0750458 /* opus_defines.h */,
+				F44D9FF6356AB9C0C1F531D7 /* opus_multistream.h */,
+				E90BEC484812C2BA0DF15852 /* opus_projection.h */,
+				2E5081BAC653C98E20486642 /* opus_types.h */,
+				A50996A9FDBA63F9AEDD4902 /* opus.h */,
+				88A00D973DFE61DA80CEFC63 /* opusfile.h */,
+			);
+			path = opus;
+			sourceTree = "<group>";
+		};
+		09082FC16BD8EFDC6E2A576B /* lib */ = {
+			isa = PBXGroup;
+			children = (
+				CA445FC9E802A4C20E3A403D /* libogg.a */,
+				449D61AC1EE4C72C87FDE11B /* libopus.a */,
+				624A8B3A36FAC5FB9DDC5E67 /* libopusfile.a */,
+			);
+			path = lib;
+			sourceTree = "<group>";
+		};
+		1332E5B75E8FF27EC72C7E0A /* OpusLib */ = {
+			isa = PBXGroup;
+			children = (
+				FBD9885D8FBB93ECDA027235 /* include */,
+				09082FC16BD8EFDC6E2A576B /* lib */,
+			);
+			path = OpusLib;
+			sourceTree = "<group>";
+		};
+		1B9750DC80B4C7A27CDFB781 /* ogg */ = {
+			isa = PBXGroup;
+			children = (
+				B4C783FE8D72490B0C9FC434 /* config_types.h */,
+				FC6B4D6B6FBB4F0F5CEE8827 /* ogg.h */,
+				CC965C1466ACDD2E38AE5321 /* os_types.h */,
+			);
+			path = ogg;
+			sourceTree = "<group>";
+		};
+		779EB629DFF798AB1B02E145 /* Services */ = {
+			isa = PBXGroup;
+			children = (
+				0FEBDB0BB1A240BB292F64A6 /* ArtworkService.swift */,
+				EE2DAEAE9E4548FEAE43DD6F /* AudioEngine.swift */,
+				AF736221D49CF02BA7C8D6B9 /* BPMDetector.swift */,
+				D3FDBF83B261F1B1F2FD07AA /* ChadMusicAPIClient.swift */,
+				661D06C0F6B4159529E7CC6E /* KeychainService.swift */,
+				645DBC1C9CF4F140B672D7B1 /* KeyDetector.swift */,
+				6F3B7C5A143DE798D4626FE8 /* LibraryManager.swift */,
+				631AC23E23D3E1BDC9ADF853 /* LRCLIBService.swift */,
+				7D19017A4644FC0728357C3F /* LyricsParser.swift */,
+				F53966C8741493C981D95364 /* MediaKeyHandler.swift */,
+				83502527607F43B5AAF43A5B /* MetadataService.swift */,
+				9EDE955C924C0198C7352401 /* OGGDecoder.swift */,
+				0AF5A8303D2C02C64E38BFFD /* OpusDecoder.swift */,
+				A723E3458C238F1FD1BFD3C2 /* StreamingPlayer.swift */,
+				A9ECC0563919D01F75D8E0B7 /* SyncManager.swift */,
+				9EAECFB0715C166BB9B07054 /* WaveformGenerator.swift */,
+			);
+			path = Services;
+			sourceTree = "<group>";
+		};
+		79CCDC24146638948CBCEC9E = {
+			isa = PBXGroup;
+			children = (
+				C8F02BC907F648149C5B2FA4 /* Sources */,
+				B5DBC14AA87BE8FC0582278D /* Tests */,
+				E710654EDC5BEFA0243A5A12 /* UITests */,
+				FCBD4522947F6E56E803DDC6 /* Products */,
+			);
+			sourceTree = "<group>";
+		};
+		7F61E00E741F32754E1C3E81 /* Resources */ = {
+			isa = PBXGroup;
+			children = (
+				68DD53987AB941CD45442704 /* Assets.xcassets */,
+			);
+			path = Resources;
+			sourceTree = "<group>";
+		};
+		809411FB03B6B689D9DE9907 /* Models */ = {
+			isa = PBXGroup;
+			children = (
+				F082E02B359284DF788ECB01 /* AppState.swift */,
+				40F598DD9BCE1FA0AE855690 /* AppTheme.swift */,
+				6363FFDA55CFD993BC309249 /* ChadMusic.swift */,
+				4B2D6AC79F54F259894E400E /* CuePoint.swift */,
+				9976E6544585B296A1C101DA /* GroupTemplateResolver.swift */,
+				475B0D96BE1F660E43F4338F /* Playlist.swift */,
+				B06CCD1797B66666B24AF57F /* PlaylistFolder.swift */,
+				38F7DDFFB0052E82B9579FA7 /* QueueEntry.swift */,
+				2911C47EF0A790D14A41ACCD /* Track.swift */,
+			);
+			path = Models;
+			sourceTree = "<group>";
+		};
+		A0991586314D042664A33F77 /* Views */ = {
+			isa = PBXGroup;
+			children = (
+				E41DB4A612D6382448F0DD4A /* AddGroupToPlaylistSheet.swift */,
+				CBBA50A7582B55F231E5AA6C /* AddToPlaylistSheet.swift */,
+				7774A282E55258E902663EB2 /* CloudBrowserView.swift */,
+				60ECA4A868B078D1883187AC /* ContentView.swift */,
+				3065C0A0D56A81C39226FE66 /* FolderBrowserView.swift */,
+				08D15D5EE07B0A62BFD840FE /* GroupTemplateEditorSheet.swift */,
+				BE57266AAD0021383B334BCD /* LibraryView.swift */,
+				30AB12561B1AF730065EC608 /* MiniPlayerView.swift */,
+				AA7FCB9E71FB67DBBBBA237E /* NowPlayingView.swift */,
+				E3CE708A06FA6FACD9163798 /* PlaylistDetailView.swift */,
+				BEEFB8D4A7732D9179B80961 /* PlaylistListView.swift */,
+				E914A3FD78964862B2614958 /* QueueView.swift */,
+				DD7C91A9A81F45E761A50F33 /* SettingsView.swift */,
+				F0DB4B4B5F5AC588DCFDB0CD /* TrackRow.swift */,
+				3BD2DB1EC2209FF74C47FDA7 /* WaveformView.swift */,
+			);
+			path = Views;
+			sourceTree = "<group>";
+		};
+		B5DBC14AA87BE8FC0582278D /* Tests */ = {
+			isa = PBXGroup;
+			children = (
+				F558E3B192986DC2EBB0ED46 /* AudioEngineTests.swift */,
+				EF1DED14695761FDB3C92ABE /* CloudStreamingTests.swift */,
+				F2BA9BE95AB7E0120C386B49 /* CodecTests.swift */,
+				6A92720B54E0C6F6952D1DC3 /* GroupTemplateResolverTests.swift */,
+				B407D125FA9B66C2F5AE6449 /* LyricsTests.swift */,
+				8CE159AE643FA6D443DA2A58 /* ModelTests.swift */,
+				C6B64DCACBFBECC6891C90CC /* PlayerViewModelTests.swift */,
+				A6C84B8774EB16049C5D0634 /* PlaylistViewModelTests.swift */,
+				3B76DAB40E97BB6491A86E6E /* SyncManagerTests.swift */,
+				B402C83CB50D990A2E067E9E /* WaveformGeneratorTests.swift */,
+			);
+			path = Tests;
+			sourceTree = "<group>";
+		};
+		C196ADF95D5F9524ECF12DD5 /* OGG */ = {
+			isa = PBXGroup;
+			children = (
+				F5D297D015B8240DFA10635C /* MixBoard-Bridging-Header.h */,
+				F53DEF563120C3F3B6EC9B17 /* ogg.h */,
+				6A5EBAA40B5EDC89CCE2B2BB /* stb_vorbis_wrapper.h */,
+				A7E76EF83D1D8E970E0A31A5 /* stb_vorbis.c */,
+			);
+			path = OGG;
+			sourceTree = "<group>";
+		};
+		C8F02BC907F648149C5B2FA4 /* Sources */ = {
+			isa = PBXGroup;
+			children = (
+				ACC56A6245FB276D23559CBF /* MixBoardApp.swift */,
+				809411FB03B6B689D9DE9907 /* Models */,
+				C196ADF95D5F9524ECF12DD5 /* OGG */,
+				1332E5B75E8FF27EC72C7E0A /* OpusLib */,
+				7F61E00E741F32754E1C3E81 /* Resources */,
+				779EB629DFF798AB1B02E145 /* Services */,
+				DCA751450A9535DB2F2BC49D /* ViewModels */,
+				A0991586314D042664A33F77 /* Views */,
+			);
+			path = Sources;
+			sourceTree = "<group>";
+		};
+		DCA751450A9535DB2F2BC49D /* ViewModels */ = {
+			isa = PBXGroup;
+			children = (
+				1C1236BDE95EB6791D6236FB /* PlayerViewModel.swift */,
+				35E4CBA48B8B32CE1BCAD7C8 /* PlaylistViewModel.swift */,
+			);
+			path = ViewModels;
+			sourceTree = "<group>";
+		};
+		E710654EDC5BEFA0243A5A12 /* UITests */ = {
+			isa = PBXGroup;
+			children = (
+				0B2F31275CB65372CA6FA5A0 /* MixBoardUITests.swift */,
+			);
+			path = UITests;
+			sourceTree = "<group>";
+		};
+		FBD9885D8FBB93ECDA027235 /* include */ = {
+			isa = PBXGroup;
+			children = (
+				1B9750DC80B4C7A27CDFB781 /* ogg */,
+				01413B5334A21F4D1CEEE0C0 /* opus */,
+			);
+			path = include;
+			sourceTree = "<group>";
+		};
+		FCBD4522947F6E56E803DDC6 /* Products */ = {
+			isa = PBXGroup;
+			children = (
+				B2120B77C1DC2A2C489C4495 /* MixBoard.app */,
+				6EE3DE980DF887C4317E1E04 /* MixBoardiOSTests.xctest */,
+				6D726B0D736F677437FEC8BA /* MixBoardiOSUITests.xctest */,
+			);
+			name = Products;
+			sourceTree = "<group>";
+		};
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+		0907E09982F49B0BAE3D2CB1 /* MixBoardiOS */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = E0B4DD02B0BDE15BA6A3F3F5 /* Build configuration list for PBXNativeTarget "MixBoardiOS" */;
+			buildPhases = (
+				AE32C9205A6DD086CC7FF992 /* Sources */,
+				B6B7503B80E513DC3E349BB0 /* Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = MixBoardiOS;
+			packageProductDependencies = (
+			);
+			productName = MixBoardiOS;
+			productReference = B2120B77C1DC2A2C489C4495 /* MixBoard.app */;
+			productType = "com.apple.product-type.application";
+		};
+		39145F296862BC5011010CD2 /* MixBoardiOSUITests */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = F0F1A2471A6DCAC88600712E /* Build configuration list for PBXNativeTarget "MixBoardiOSUITests" */;
+			buildPhases = (
+				D8B2242123A0DEC9EE065A6A /* Sources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				F93CE1CE125D5C7BC82054FB /* PBXTargetDependency */,
+			);
+			name = MixBoardiOSUITests;
+			packageProductDependencies = (
+			);
+			productName = MixBoardiOSUITests;
+			productReference = 6D726B0D736F677437FEC8BA /* MixBoardiOSUITests.xctest */;
+			productType = "com.apple.product-type.bundle.ui-testing";
+		};
+		E27BDA59B075CF1614CF9392 /* MixBoardiOSTests */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 48EC8B6205AC728381EA57DA /* Build configuration list for PBXNativeTarget "MixBoardiOSTests" */;
+			buildPhases = (
+				907C57A0DA20F5DD051C447B /* Sources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				9C526FD964E1266FAD96E657 /* PBXTargetDependency */,
+			);
+			name = MixBoardiOSTests;
+			packageProductDependencies = (
+			);
+			productName = MixBoardiOSTests;
+			productReference = 6EE3DE980DF887C4317E1E04 /* MixBoardiOSTests.xctest */;
+			productType = "com.apple.product-type.bundle.unit-test";
+		};
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+		136856E86875B0E72B3BA10F /* Project object */ = {
+			isa = PBXProject;
+			attributes = {
+				BuildIndependentTargetsInParallel = YES;
+				LastUpgradeCheck = 1600;
+				TargetAttributes = {
+					39145F296862BC5011010CD2 = {
+						TestTargetID = 0907E09982F49B0BAE3D2CB1;
+					};
+				};
+			};
+			buildConfigurationList = 09F18186D8B0B98F03E04D01 /* Build configuration list for PBXProject "MixBoardiOS" */;
+			compatibilityVersion = "Xcode 14.0";
+			developmentRegion = en;
+			hasScannedForEncodings = 0;
+			knownRegions = (
+				Base,
+				en,
+			);
+			mainGroup = 79CCDC24146638948CBCEC9E;
+			minimizedProjectReferenceProxies = 1;
+			projectDirPath = "";
+			projectRoot = "";
+			targets = (
+				0907E09982F49B0BAE3D2CB1 /* MixBoardiOS */,
+				E27BDA59B075CF1614CF9392 /* MixBoardiOSTests */,
+				39145F296862BC5011010CD2 /* MixBoardiOSUITests */,
+			);
+		};
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+		B6B7503B80E513DC3E349BB0 /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				95899377852BF2AC878090A8 /* Assets.xcassets in Resources */,
+				43393F667709155B8274BCF7 /* libogg.a in Resources */,
+				CC051E3E0E64005C419E2A96 /* libopus.a in Resources */,
+				92AB1107C3C9933966713ACA /* libopusfile.a in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+		907C57A0DA20F5DD051C447B /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				5E8CEFA7EE241802F4811825 /* AudioEngineTests.swift in Sources */,
+				D2F69A15D9EAD0B0F7AB4321 /* CloudStreamingTests.swift in Sources */,
+				B769842D41E6024B9BDAEC75 /* CodecTests.swift in Sources */,
+				69501B07F5A60CC4F3FD0FD2 /* GroupTemplateResolverTests.swift in Sources */,
+				E73B39EC4AB5EE03B770ECE9 /* LyricsTests.swift in Sources */,
+				EB0AE5BCF77E33C39B2062AE /* ModelTests.swift in Sources */,
+				3BB9EDFDD0549752FF295F3E /* PlayerViewModelTests.swift in Sources */,
+				5628796FA14B92BBF9B43E32 /* PlaylistViewModelTests.swift in Sources */,
+				713F146AE128A72D7685ED85 /* SyncManagerTests.swift in Sources */,
+				E7254E3B096C82833E6EAC32 /* WaveformGeneratorTests.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		AE32C9205A6DD086CC7FF992 /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				B54468EDAAEF2726A6B38C0C /* AddGroupToPlaylistSheet.swift in Sources */,
+				A51C687AE19C3E84FD54C5B9 /* AddToPlaylistSheet.swift in Sources */,
+				C5445BE9211773A3C765686F /* AppState.swift in Sources */,
+				606C5864CF7BFE0FD9D45D49 /* AppTheme.swift in Sources */,
+				EABC718B141E4A741CB7A338 /* ArtworkService.swift in Sources */,
+				5D6C44C69AF7AC10EF57654F /* AudioEngine.swift in Sources */,
+				07CF52ADAE84DB873EACEF78 /* BPMDetector.swift in Sources */,
+				24383DE5713184D409FD9063 /* ChadMusic.swift in Sources */,
+				C3661CDAB1BE2C95AE69ADB1 /* ChadMusicAPIClient.swift in Sources */,
+				DD56D0E534B6AC6DAC3DD238 /* CloudBrowserView.swift in Sources */,
+				379B32F8532B619ED072A027 /* ContentView.swift in Sources */,
+				BDC7784201348B34183BEA51 /* CuePoint.swift in Sources */,
+				A691BE9634610830E1BB808A /* FolderBrowserView.swift in Sources */,
+				57711D4FCC56CF0EAA3B9AEA /* GroupTemplateEditorSheet.swift in Sources */,
+				26A26B41B196B5BD4930DC32 /* GroupTemplateResolver.swift in Sources */,
+				9E9C91E092AB64883F6EE6B0 /* KeyDetector.swift in Sources */,
+				912A19B864DD7BA90CDACFB5 /* KeychainService.swift in Sources */,
+				8C364F881121FBDC0507BE3F /* LRCLIBService.swift in Sources */,
+				4743395D35A8D95C547C8CB9 /* LibraryManager.swift in Sources */,
+				F9E1EC2A05D690057B963102 /* LibraryView.swift in Sources */,
+				A0784C716AA15CDE2E0B7422 /* LyricsParser.swift in Sources */,
+				70D3F6D051FF364AEE762044 /* MediaKeyHandler.swift in Sources */,
+				D408096F4D08840C966D4DC3 /* MetadataService.swift in Sources */,
+				87CF06028B178836BA6DC55D /* MiniPlayerView.swift in Sources */,
+				7726CE9DEFF12E97426C682E /* MixBoardApp.swift in Sources */,
+				F68E77C46DA49D37AF843648 /* NowPlayingView.swift in Sources */,
+				9B9F0CF0742875A907E153AA /* OGGDecoder.swift in Sources */,
+				2A5E4EBC04A32429A488B917 /* OpusDecoder.swift in Sources */,
+				26BB12106B505E43BF4FC9D6 /* PlayerViewModel.swift in Sources */,
+				CCECD84E3286B2500DE1FDFE /* Playlist.swift in Sources */,
+				BEFC8982E0D4314A9DAEEBD8 /* PlaylistDetailView.swift in Sources */,
+				BFC987A83994155E5702AC68 /* PlaylistFolder.swift in Sources */,
+				D745E3B69D257B06ECB55735 /* PlaylistListView.swift in Sources */,
+				98815185EDE0A4BCBF17C5B9 /* PlaylistViewModel.swift in Sources */,
+				7C6A84B33D7D5615EB0B05DE /* QueueEntry.swift in Sources */,
+				380E85DD78DF70452FD714D3 /* QueueView.swift in Sources */,
+				759C3290BED9FA13BC6E3826 /* SettingsView.swift in Sources */,
+				3395A9BA0229770B4DB5BC87 /* StreamingPlayer.swift in Sources */,
+				8E08196F160224F02E37D063 /* SyncManager.swift in Sources */,
+				D90B76ED48494E5EFAF307AD /* Track.swift in Sources */,
+				B277B6A1AE7A7B3F3B5048BF /* TrackRow.swift in Sources */,
+				9EA1E32B38B0F9E99870EC59 /* WaveformGenerator.swift in Sources */,
+				8959E71D433588DDD17678DC /* WaveformView.swift in Sources */,
+				7B2F8EAEAFFC2EC639BDD70C /* stb_vorbis.c in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		D8B2242123A0DEC9EE065A6A /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				9C3EE050D166FC5929766834 /* MixBoardUITests.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+		9C526FD964E1266FAD96E657 /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 0907E09982F49B0BAE3D2CB1 /* MixBoardiOS */;
+			targetProxy = 504CB81CAC10624153757F4D /* PBXContainerItemProxy */;
+		};
+		F93CE1CE125D5C7BC82054FB /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 0907E09982F49B0BAE3D2CB1 /* MixBoardiOS */;
+			targetProxy = 47AE927601EF93344ADF48E4 /* PBXContainerItemProxy */;
+		};
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+		054FB1A27E51816FA4063484 /* 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;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+				MTL_FAST_MATH = YES;
+				ONLY_ACTIVE_ARCH = YES;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = iphoneos;
+				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+				SWIFT_VERSION = 5.9;
+			};
+			name = Debug;
+		};
+		98514345E6F920F371FAC2C7 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				GENERATE_INFOPLIST_FILE = YES;
+				HEADER_SEARCH_PATHS = (
+					"$(SRCROOT)/Sources/OpusLib/include",
+					"$(SRCROOT)/Sources/OpusLib/include/opus",
+					"$(SRCROOT)/Sources/OpusLib/include/ogg",
+				);
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@loader_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = com.mixboard.MixBoardiOSTests;
+				SDKROOT = iphoneos;
+				SWIFT_OBJC_BRIDGING_HEADER = "";
+				TARGETED_DEVICE_FAMILY = "1,2";
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MixBoard.app/MixBoard";
+			};
+			name = Debug;
+		};
+		9D1AAD157A839ACDA7AB5B4C /* 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;
+				IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+				MTL_ENABLE_DEBUG_INFO = NO;
+				MTL_FAST_MATH = YES;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SDKROOT = iphoneos;
+				SWIFT_COMPILATION_MODE = wholemodule;
+				SWIFT_OPTIMIZATION_LEVEL = "-O";
+				SWIFT_VERSION = 5.9;
+			};
+			name = Release;
+		};
+		A0D0816F69E4CC9CEB7F805B /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				GENERATE_INFOPLIST_FILE = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@loader_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = com.mixboard.MixBoardiOSUITests;
+				SDKROOT = iphoneos;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				TEST_TARGET_NAME = MixBoardiOS;
+			};
+			name = Debug;
+		};
+		A78BE4C792293551E614E5C1 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				GENERATE_INFOPLIST_FILE = YES;
+				HEADER_SEARCH_PATHS = (
+					"$(SRCROOT)/Sources/OpusLib/include",
+					"$(SRCROOT)/Sources/OpusLib/include/opus",
+					"$(SRCROOT)/Sources/OpusLib/include/ogg",
+				);
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@loader_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = com.mixboard.MixBoardiOSTests;
+				SDKROOT = iphoneos;
+				SWIFT_OBJC_BRIDGING_HEADER = "";
+				TARGETED_DEVICE_FAMILY = "1,2";
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/MixBoard.app/MixBoard";
+			};
+			name = Release;
+		};
+		AEC03CA1B6D82279C160A07B /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				GENERATE_INFOPLIST_FILE = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+					"@loader_path/Frameworks",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = com.mixboard.MixBoardiOSUITests;
+				SDKROOT = iphoneos;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				TEST_TARGET_NAME = MixBoardiOS;
+			};
+			name = Release;
+		};
+		B639A0432641C825AA778529 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon-Green AppIcon-Lime AppIcon-Cyan AppIcon-Blue AppIcon-Purple AppIcon-Pink AppIcon-Red AppIcon-Orange AppIcon-Gold AppIcon-White";
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
+				CODE_SIGN_ENTITLEMENTS = MixBoardiOS.entitlements;
+				CODE_SIGN_IDENTITY = "iPhone Developer";
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_TEAM = ZPD66G9CB6;
+				"GCC_PREPROCESSOR_DEFINITIONS[sdk=iphonesimulator*]" = "DISABLE_OPUS=1";
+				GENERATE_INFOPLIST_FILE = YES;
+				HEADER_SEARCH_PATHS = (
+					"$(SRCROOT)/Sources/OpusLib/include",
+					"$(SRCROOT)/Sources/OpusLib/include/opus",
+					"$(SRCROOT)/Sources/OpusLib/include/ogg",
+				);
+				INFOPLIST_FILE = Info.plist;
+				INFOPLIST_GENERATION_MODE = GeneratedByXcode;
+				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
+				INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
+				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+				);
+				"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(SRCROOT)/Sources/OpusLib/lib";
+				"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "";
+				MARKETING_VERSION = 1.0.0;
+				"OTHER_LDFLAGS[sdk=iphoneos*]" = (
+					"-logg",
+					"-lopus",
+					"-lopusfile",
+				);
+				"OTHER_LDFLAGS[sdk=iphonesimulator*]" = "";
+				PRODUCT_BUNDLE_IDENTIFIER = com.mixboard.MixBoardiOS;
+				PRODUCT_NAME = MixBoard;
+				SDKROOT = iphoneos;
+				SUPPORTS_MACCATALYST = NO;
+				"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator*]" = DISABLE_OPUS;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_OBJC_BRIDGING_HEADER = "Sources/OGG/MixBoard-Bridging-Header.h";
+				SWIFT_OPTIMIZATION_LEVEL = "-O";
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Release;
+		};
+		CCC050D6A11BD5A595ED8050 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = "AppIcon-Green AppIcon-Lime AppIcon-Cyan AppIcon-Blue AppIcon-Purple AppIcon-Pink AppIcon-Red AppIcon-Orange AppIcon-Gold AppIcon-White";
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
+				CODE_SIGN_ENTITLEMENTS = MixBoardiOS.entitlements;
+				CODE_SIGN_IDENTITY = "Apple Development";
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_TEAM = ZPD66G9CB6;
+				"GCC_PREPROCESSOR_DEFINITIONS[sdk=iphonesimulator*]" = "DISABLE_OPUS=1";
+				GENERATE_INFOPLIST_FILE = YES;
+				HEADER_SEARCH_PATHS = (
+					"$(SRCROOT)/Sources/OpusLib/include",
+					"$(SRCROOT)/Sources/OpusLib/include/opus",
+					"$(SRCROOT)/Sources/OpusLib/include/ogg",
+				);
+				INFOPLIST_FILE = Info.plist;
+				INFOPLIST_GENERATION_MODE = GeneratedByXcode;
+				INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
+				INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
+				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportsDocumentBrowser = YES;
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+				);
+				"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(SRCROOT)/Sources/OpusLib/lib";
+				"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "";
+				MARKETING_VERSION = 1.0.0;
+				"OTHER_LDFLAGS[sdk=iphoneos*]" = (
+					"-logg",
+					"-lopus",
+					"-lopusfile",
+				);
+				"OTHER_LDFLAGS[sdk=iphonesimulator*]" = "";
+				PRODUCT_BUNDLE_IDENTIFIER = com.mixboard.MixBoardiOS;
+				PRODUCT_NAME = MixBoard;
+				SDKROOT = iphoneos;
+				SUPPORTS_MACCATALYST = NO;
+				"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator*]" = DISABLE_OPUS;
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_OBJC_BRIDGING_HEADER = "Sources/OGG/MixBoard-Bridging-Header.h";
+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Debug;
+		};
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+		09F18186D8B0B98F03E04D01 /* Build configuration list for PBXProject "MixBoardiOS" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				054FB1A27E51816FA4063484 /* Debug */,
+				9D1AAD157A839ACDA7AB5B4C /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
+		48EC8B6205AC728381EA57DA /* Build configuration list for PBXNativeTarget "MixBoardiOSTests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				98514345E6F920F371FAC2C7 /* Debug */,
+				A78BE4C792293551E614E5C1 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
+		E0B4DD02B0BDE15BA6A3F3F5 /* Build configuration list for PBXNativeTarget "MixBoardiOS" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				CCC050D6A11BD5A595ED8050 /* Debug */,
+				B639A0432641C825AA778529 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
+		F0F1A2471A6DCAC88600712E /* Build configuration list for PBXNativeTarget "MixBoardiOSUITests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				A0D0816F69E4CC9CEB7F805B /* Debug */,
+				AEC03CA1B6D82279C160A07B /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Debug;
+		};
+/* End XCConfigurationList section */
+	};
+	rootObject = 136856E86875B0E72B3BA10F /* Project object */;
+}

+ 7 - 0
MixBoardiOS.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: "MixBoardiOS",
+    platforms: [.iOS(.v17)],
+    products: [
+        .library(name: "MixBoardiOS", targets: ["MixBoardiOS"])
+    ],
+    targets: [
+        .target(
+            name: "MixBoardiOS",
+            path: "Sources"
+        )
+    ]
+)

+ 59 - 0
Sources/MixBoardApp.swift

@@ -0,0 +1,59 @@
+import SwiftData
+import SwiftUI
+
+/// MixBoard iOS — A mobile music player and mix preparation tool.
+/// Listen to your own MP3/FLAC files, build playlists, and sync them to the Mac app for 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 syncManager = SyncManager()
+
+    let modelContainer: ModelContainer
+
+    init() {
+        // One-time database reset to clear corrupted Track/p2 data + add cloud fields
+        let needsReset = !UserDefaults.standard.bool(forKey: "dbResetV7")
+        if needsReset {
+            // Nuclear option: find and delete ALL .store and .sqlite files in the app container
+            let fm = FileManager.default
+            let home = fm.urls(for: .libraryDirectory, in: .userDomainMask).first!
+                .deletingLastPathComponent() // gets the app container root
+            
+            if let enumerator = fm.enumerator(at: home, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) {
+                for case let fileURL as URL in enumerator {
+                    let name = fileURL.lastPathComponent
+                    if name == "default.store" || name == "default.store-shm" || name == "default.store-wal"
+                        || name.hasSuffix(".sqlite") || name.hasSuffix(".sqlite-shm") || name.hasSuffix(".sqlite-wal") {
+                        try? fm.removeItem(at: fileURL)
+                    }
+                }
+            }
+            UserDefaults.standard.set(true, forKey: "dbResetV7")
+        }
+
+        do {
+            modelContainer = try ModelContainer(for: Track.self, CuePoint.self, Playlist.self, PlaylistEntry.self, PlaylistFolder.self)
+        } catch {
+            fatalError("Failed to create ModelContainer: \(error)")
+        }
+    }
+
+    var body: some Scene {
+        WindowGroup {
+            ContentView()
+                .environment(playerVM)
+                .environment(playlistVM)
+                .environmentObject(libraryManager)
+                .environmentObject(theme)
+                .environmentObject(syncManager)
+                .preferredColorScheme(theme.preferredColorScheme)
+                .onAppear {
+                    MediaKeyHandler.shared.register(playerVM: playerVM)
+                }
+        }
+        .modelContainer(modelContainer)
+    }
+}

+ 59 - 0
Sources/Models/AppState.swift

@@ -0,0 +1,59 @@
+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)
+    }
+
+    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)
+    }
+}

+ 255 - 0
Sources/Models/AppTheme.swift

@@ -0,0 +1,255 @@
+import SwiftUI
+
+/// Theme system for MixBoard iOS.
+/// Multiple skins from retro to modern aesthetics.
+final class AppTheme: ObservableObject {
+
+    // MARK: - Available Skins
+
+    enum Skin: String, CaseIterable, Identifiable, Codable {
+        case winamp       = "Winamp"
+        case foobarLight  = "foobar Light"
+        case foobarDark   = "foobar Dark"
+        case wmp          = "Windows Media"
+        case obsidian     = "Obsidian"
+        case vinyl        = "Vinyl"
+        case tidal        = "Tidal"
+
+        var id: String { rawValue }
+
+        var icon: String {
+            switch self {
+            case .winamp:      return "bolt.fill"
+            case .foobarLight: return "list.bullet"
+            case .foobarDark:  return "list.bullet.rectangle"
+            case .wmp:         return "play.rectangle.fill"
+            case .obsidian:    return "diamond.fill"
+            case .vinyl:       return "record.circle"
+            case .tidal:       return "waveform"
+            }
+        }
+
+        var description: String {
+            switch self {
+            case .winamp:      return "Neon retro vibes"
+            case .foobarLight: return "Clean & minimal, light"
+            case .foobarDark:  return "Clean & minimal, dark"
+            case .wmp:         return "Glossy blue media player"
+            case .obsidian:    return "Deep purple, elegant"
+            case .vinyl:       return "Warm analog warmth"
+            case .tidal:       return "Dark with teal glow"
+            }
+        }
+    }
+
+    // MARK: - Published
+
+    @Published var currentSkin: Skin {
+        didSet {
+            applySkin(currentSkin)
+            UserDefaults.standard.set(currentSkin.rawValue, forKey: "appThemeSkin")
+        }
+    }
+
+    // MARK: - Colors
+
+    @Published var accent: Color = .green
+    @Published var background: Color = .black
+    @Published var secondaryBackground: Color = Color(white: 0.1)
+    @Published var seekbarBackground: Color = Color.gray.opacity(0.3)
+    @Published var seekbarForeground: Color = .green
+    @Published var playerBarBackground: Color = Color(white: 0.08)
+    @Published var cardBackground: Color = Color(white: 0.12)
+    @Published var primaryText: Color = .white
+    @Published var secondaryText: Color = .gray
+    @Published var tertiaryText: Color = Color.gray.opacity(0.5)
+    @Published var playingHighlight: Color = .green
+    @Published var groupHeaderText: Color = .white
+    @Published var tabBarBackground: Color = Color(white: 0.06)
+    @Published var separatorColor: Color = Color.white.opacity(0.1)
+
+    // MARK: - Sizes
+
+    @Published var seekbarHeight: CGFloat = 6
+    @Published var rowHeight: CGFloat = 56
+    @Published var dataFontSize: CGFloat = 15
+    @Published var smallFontSize: CGFloat = 12
+    @Published var cornerRadius: CGFloat = 8
+
+    // MARK: - Style flags
+
+    @Published var useDarkMode: Bool = true
+    @Published var preferredColorScheme: ColorScheme? = .dark
+
+    // MARK: - Init
+
+    init() {
+        let savedSkin = UserDefaults.standard.string(forKey: "appThemeSkin")
+            .flatMap { Skin(rawValue: $0) } ?? .winamp
+        self.currentSkin = savedSkin
+        applySkin(savedSkin)
+    }
+
+    // MARK: - Apply Skin
+
+    private func applySkin(_ skin: Skin) {
+        switch skin {
+
+        // ── Winamp Classic ──────────────────────────────
+        // Dark background, neon green text, retro aesthetic
+        case .winamp:
+            accent = Color(red: 0.0, green: 1.0, blue: 0.0)
+            background = Color(red: 0.06, green: 0.06, blue: 0.08)
+            secondaryBackground = Color(red: 0.1, green: 0.1, blue: 0.12)
+            seekbarBackground = Color(red: 0.15, green: 0.15, blue: 0.15)
+            seekbarForeground = Color(red: 0.0, green: 1.0, blue: 0.0)
+            playerBarBackground = Color(red: 0.08, green: 0.08, blue: 0.1)
+            cardBackground = Color(red: 0.1, green: 0.1, blue: 0.12)
+            primaryText = Color(red: 0.0, green: 0.9, blue: 0.0)
+            secondaryText = Color(red: 0.0, green: 0.65, blue: 0.0)
+            tertiaryText = Color(red: 0.0, green: 0.35, blue: 0.0)
+            playingHighlight = Color(red: 0.0, green: 1.0, blue: 0.0)
+            groupHeaderText = Color(red: 0.0, green: 1.0, blue: 0.0)
+            tabBarBackground = Color(red: 0.05, green: 0.05, blue: 0.07)
+            separatorColor = Color(red: 0.0, green: 0.3, blue: 0.0)
+            seekbarHeight = 6; rowHeight = 52; dataFontSize = 14; smallFontSize = 11; cornerRadius = 4
+            useDarkMode = true; preferredColorScheme = .dark
+
+        // ── foobar2000 Light ────────────────────────────
+        // Clean white, minimal, data-focused — default foobar aesthetic
+        case .foobarLight:
+            accent = Color(red: 0.0, green: 0.0, blue: 0.55)
+            background = Color(uiColor: .systemBackground)
+            secondaryBackground = Color(uiColor: .secondarySystemBackground)
+            seekbarBackground = Color(uiColor: .systemGray5)
+            seekbarForeground = Color(red: 0.0, green: 0.0, blue: 0.55)
+            playerBarBackground = Color(uiColor: .secondarySystemBackground)
+            cardBackground = Color(uiColor: .secondarySystemGroupedBackground)
+            primaryText = Color(uiColor: .label)
+            secondaryText = Color(uiColor: .secondaryLabel)
+            tertiaryText = Color(uiColor: .tertiaryLabel)
+            playingHighlight = Color(red: 0.0, green: 0.0, blue: 0.55)
+            groupHeaderText = Color(uiColor: .label)
+            tabBarBackground = Color(uiColor: .secondarySystemBackground)
+            separatorColor = Color(uiColor: .separator)
+            seekbarHeight = 4; rowHeight = 44; dataFontSize = 14; smallFontSize = 11; cornerRadius = 6
+            useDarkMode = false; preferredColorScheme = .light
+
+        // ── foobar2000 Dark ─────────────────────────────
+        // Dark mode foobar — charcoal gray, muted blue accent
+        case .foobarDark:
+            accent = Color(red: 0.45, green: 0.55, blue: 0.75)
+            background = Color(red: 0.11, green: 0.11, blue: 0.12)
+            secondaryBackground = Color(red: 0.15, green: 0.15, blue: 0.16)
+            seekbarBackground = Color(red: 0.2, green: 0.2, blue: 0.22)
+            seekbarForeground = Color(red: 0.45, green: 0.55, blue: 0.75)
+            playerBarBackground = Color(red: 0.13, green: 0.13, blue: 0.14)
+            cardBackground = Color(red: 0.16, green: 0.16, blue: 0.17)
+            primaryText = Color(red: 0.88, green: 0.88, blue: 0.9)
+            secondaryText = Color(red: 0.55, green: 0.55, blue: 0.58)
+            tertiaryText = Color(red: 0.38, green: 0.38, blue: 0.4)
+            playingHighlight = Color(red: 0.45, green: 0.55, blue: 0.75)
+            groupHeaderText = Color(red: 0.65, green: 0.65, blue: 0.7)
+            tabBarBackground = Color(red: 0.09, green: 0.09, blue: 0.1)
+            separatorColor = Color(red: 0.22, green: 0.22, blue: 0.24)
+            seekbarHeight = 4; rowHeight = 44; dataFontSize = 14; smallFontSize = 11; cornerRadius = 6
+            useDarkMode = true; preferredColorScheme = .dark
+
+        // ── Windows Media Player ────────────────────────
+        // Glossy dark blue, silver chrome, WMP 11 aesthetic
+        case .wmp:
+            accent = Color(red: 0.2, green: 0.5, blue: 0.95)
+            background = Color(red: 0.05, green: 0.08, blue: 0.15)
+            secondaryBackground = Color(red: 0.08, green: 0.12, blue: 0.2)
+            seekbarBackground = Color(red: 0.12, green: 0.16, blue: 0.25)
+            seekbarForeground = Color(red: 0.3, green: 0.6, blue: 1.0)
+            playerBarBackground = Color(red: 0.06, green: 0.1, blue: 0.18)
+            cardBackground = Color(red: 0.1, green: 0.14, blue: 0.22)
+            primaryText = Color(red: 0.9, green: 0.92, blue: 0.96)
+            secondaryText = Color(red: 0.55, green: 0.62, blue: 0.75)
+            tertiaryText = Color(red: 0.35, green: 0.4, blue: 0.52)
+            playingHighlight = Color(red: 0.3, green: 0.65, blue: 1.0)
+            groupHeaderText = Color(red: 0.6, green: 0.7, blue: 0.9)
+            tabBarBackground = Color(red: 0.04, green: 0.06, blue: 0.12)
+            separatorColor = Color(red: 0.15, green: 0.2, blue: 0.3)
+            seekbarHeight = 6; rowHeight = 50; dataFontSize = 14; smallFontSize = 11; cornerRadius = 8
+            useDarkMode = true; preferredColorScheme = .dark
+
+        // ── Obsidian ────────────────────────────────────
+        // Deep dark with purple/violet accent, elegant and modern
+        case .obsidian:
+            accent = Color(red: 0.6, green: 0.4, blue: 0.9)
+            background = Color(red: 0.07, green: 0.06, blue: 0.1)
+            secondaryBackground = Color(red: 0.1, green: 0.09, blue: 0.14)
+            seekbarBackground = Color(red: 0.15, green: 0.13, blue: 0.2)
+            seekbarForeground = Color(red: 0.6, green: 0.4, blue: 0.9)
+            playerBarBackground = Color(red: 0.08, green: 0.07, blue: 0.12)
+            cardBackground = Color(red: 0.12, green: 0.1, blue: 0.16)
+            primaryText = Color(red: 0.9, green: 0.88, blue: 0.95)
+            secondaryText = Color(red: 0.55, green: 0.5, blue: 0.65)
+            tertiaryText = Color(red: 0.35, green: 0.32, blue: 0.45)
+            playingHighlight = Color(red: 0.65, green: 0.45, blue: 0.95)
+            groupHeaderText = Color(red: 0.7, green: 0.6, blue: 0.85)
+            tabBarBackground = Color(red: 0.05, green: 0.04, blue: 0.08)
+            separatorColor = Color(red: 0.2, green: 0.17, blue: 0.28)
+            seekbarHeight = 5; rowHeight = 52; dataFontSize = 15; smallFontSize = 12; cornerRadius = 12
+            useDarkMode = true; preferredColorScheme = .dark
+
+        // ── Vinyl ───────────────────────────────────────
+        // Warm browns, cream text, analog feel — like vintage hi-fi equipment
+        case .vinyl:
+            accent = Color(red: 0.85, green: 0.55, blue: 0.2)              // warm amber/orange
+            background = Color(red: 0.12, green: 0.1, blue: 0.08)          // dark walnut
+            secondaryBackground = Color(red: 0.16, green: 0.13, blue: 0.1)
+            seekbarBackground = Color(red: 0.22, green: 0.18, blue: 0.14)
+            seekbarForeground = Color(red: 0.85, green: 0.55, blue: 0.2)
+            playerBarBackground = Color(red: 0.14, green: 0.11, blue: 0.09)
+            cardBackground = Color(red: 0.18, green: 0.15, blue: 0.11)
+            primaryText = Color(red: 0.92, green: 0.88, blue: 0.78)        // warm cream
+            secondaryText = Color(red: 0.65, green: 0.58, blue: 0.48)
+            tertiaryText = Color(red: 0.45, green: 0.4, blue: 0.32)
+            playingHighlight = Color(red: 0.9, green: 0.6, blue: 0.2)
+            groupHeaderText = Color(red: 0.8, green: 0.65, blue: 0.4)
+            tabBarBackground = Color(red: 0.1, green: 0.08, blue: 0.06)
+            separatorColor = Color(red: 0.25, green: 0.2, blue: 0.15)
+            seekbarHeight = 5; rowHeight = 54; dataFontSize = 15; smallFontSize = 12; cornerRadius = 8
+            useDarkMode = true; preferredColorScheme = .dark
+
+        // ── Tidal ───────────────────────────────────────
+        // Dark warm background, teal/cyan accent, like screenshot
+        case .tidal:
+            accent = Color(red: 0.2, green: 0.85, blue: 0.75)              // teal/cyan
+            background = Color(red: 0.08, green: 0.07, blue: 0.06)         // warm near-black
+            secondaryBackground = Color(red: 0.12, green: 0.11, blue: 0.09)
+            seekbarBackground = Color(red: 0.25, green: 0.24, blue: 0.22)
+            seekbarForeground = Color.white
+            playerBarBackground = Color(red: 0.1, green: 0.09, blue: 0.07)
+            cardBackground = Color(red: 0.14, green: 0.13, blue: 0.11)
+            primaryText = Color.white
+            secondaryText = Color(red: 0.65, green: 0.63, blue: 0.58)
+            tertiaryText = Color(red: 0.4, green: 0.38, blue: 0.35)
+            playingHighlight = Color(red: 0.2, green: 0.85, blue: 0.75)
+            groupHeaderText = Color(red: 0.75, green: 0.73, blue: 0.68)
+            tabBarBackground = Color(red: 0.06, green: 0.05, blue: 0.04)
+            separatorColor = Color(red: 0.2, green: 0.19, blue: 0.17)
+            seekbarHeight = 4; rowHeight = 56; dataFontSize = 15; smallFontSize = 12; cornerRadius = 10
+            useDarkMode = true; preferredColorScheme = .dark
+        }
+    }
+}
+
+// MARK: - Color hex helper
+
+extension Color {
+    init?(hex: String) {
+        var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
+        hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
+        guard hexSanitized.count == 6,
+              let rgb = UInt64(hexSanitized, radix: 16) else { return nil }
+        self.init(
+            red: Double((rgb >> 16) & 0xFF) / 255.0,
+            green: Double((rgb >> 8) & 0xFF) / 255.0,
+            blue: Double(rgb & 0xFF) / 255.0
+        )
+    }
+}

+ 120 - 0
Sources/Models/ChadMusic.swift

@@ -0,0 +1,120 @@
+import Foundation
+
+// MARK: - Chad Music API Response Models
+
+/// A category entry from GET /api/cat/:category (e.g., album, artist, genre).
+struct ChadCategory: Codable, Identifiable, Hashable {
+    let item: String
+    let count: Int?
+
+    var id: String { item }
+    var name: String { item }
+}
+
+/// An album from the Chad Music API.
+struct ChadAlbum: Codable, Identifiable, Hashable {
+    let id: String
+    let album: String?
+    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?
+
+    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.
+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?
+    let url: String
+    let bitRate: Int?
+    let year: Int?
+    let cover: String?
+
+    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"
+    }
+
+    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)
+    }
+}
+
+/// Library statistics from GET /api/stats.
+struct ChadStats: Codable {
+    let tracks: Int?
+    let albums: Int?
+    let artists: Int?
+    let duration: String?
+}
+
+/// 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
+    var name: String
+    var timestamp: TimeInterval        // position in seconds
+    var endTimestamp: TimeInterval?     // optional end for regions
+    var color: String                  // hex color for visual display
+    var type: CuePointType
+    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"
+}

+ 70 - 0
Sources/Models/GroupTemplateResolver.swift

@@ -0,0 +1,70 @@
+import Foundation
+
+/// Resolves group template strings using track metadata.
+/// Supported placeholders: {Artist}, {Album}, {Genre}, {Year}, {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.replacingOccurrences(of: " — Unknown Artist", 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"
+    }
+}

+ 143 - 0
Sources/Models/Playlist.swift

@@ -0,0 +1,143 @@
+import Foundation
+import SwiftData
+
+/// A playlist — an ordered collection of tracks for building a mix.
+@Model
+final class Playlist {
+    var id: UUID
+    var name: String
+    var notes: String
+    var dateCreated: Date
+    var dateModified: Date
+    var targetBPM: Double?             // target BPM for the mix
+    var color: String                  // 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 {
+        // Sum duration from entries that have valid tracks
+        // Note: avoid KeyPath traversal (.track?.duration) which crashes on invalidated SwiftData models
+        var total: TimeInterval = 0
+        for entry in entries {
+            guard let track = entry.track else { continue }
+            total += track.duration
+        }
+        return total
+    }
+
+    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 }
+        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
+    var position: Int
+    var track: Track?
+
+    // Transition settings
+    var crossfadeDuration: TimeInterval    // seconds of overlap with next track
+    var startOffset: TimeInterval          // start playback from this point
+    var endOffset: TimeInterval            // stop playback at this point (0 = end of track)
+    var gainAdjustment: Double             // 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.
+@Model
+final class PlaylistFolder {
+    var id: UUID
+    var name: String
+    var dateCreated: Date
+    var isExpanded: Bool
+
+    @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 = []
+    }
+}

+ 56 - 0
Sources/Models/QueueEntry.swift

@@ -0,0 +1,56 @@
+import Foundation
+
+struct QueueEntry: Identifiable, Equatable, Codable {
+    let id: UUID
+    let source: QueueSource
+    let title: String
+    let artist: String
+    let duration: Double
+
+    init(id: UUID = UUID(), source: QueueSource, title: String, artist: String, duration: Double) {
+        self.id = id
+        self.source = source
+        self.title = title
+        self.artist = artist
+        self.duration = duration
+    }
+
+    static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
+
+    enum QueueSource: Equatable, Codable {
+        case swiftDataTrack(trackPersistentID: String, isCloud: Bool, cloudStreamPath: String?)
+        case cloudDirect(chadTrackId: String, streamPath: String)
+    }
+
+    var formattedDuration: String {
+        let total = Int(duration)
+        let minutes = total / 60
+        let seconds = total % 60
+        return String(format: "%d:%02d", minutes, seconds)
+    }
+
+    static func from(track: Track) -> QueueEntry {
+        QueueEntry(
+            source: .swiftDataTrack(
+                trackPersistentID: track.id.uuidString,
+                isCloud: track.isCloud,
+                cloudStreamPath: track.cloudStreamPath
+            ),
+            title: track.title,
+            artist: track.artist,
+            duration: track.duration
+        )
+    }
+
+    static func from(cloudTrack: ChadTrack) -> QueueEntry {
+        QueueEntry(
+            source: .cloudDirect(
+                chadTrackId: cloudTrack.id,
+                streamPath: cloudTrack.url
+            ),
+            title: cloudTrack.title,
+            artist: cloudTrack.artist ?? "",
+            duration: cloudTrack.duration ?? 0
+        )
+    }
+}

+ 140 - 0
Sources/Models/Track.swift

@@ -0,0 +1,140 @@
+import Foundation
+import SwiftData
+
+/// Represents a single audio track in the library.
+@Model
+final class Track {
+    var id: UUID
+    var title: String
+    var artist: String
+    var album: String
+    var genre: String
+
+    /// Path relative to the app's documents directory (e.g. "Music/Artist - Title.mp3").
+    var filePath: String
+
+    /// Original filename without directory (used for cross-device matching).
+    var fileName: String
+
+    var duration: TimeInterval
+    var bpm: Double?
+    var musicalKey: String?
+    var sampleRate: Double
+    var bitDepth: Int
+    var channels: Int
+    var fileFormat: String
+    var fileSizeBytes: Int64
+    var year: Int?                 // release year from metadata
+    var dateAdded: Date
+    var lastPlayed: Date?
+    var playCount: Int
+    var rating: Int       // 0-5 stars
+    var color: String?    // user-assigned color tag
+    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
+
+    @Relationship(deleteRule: .cascade, inverse: \CuePoint.track)
+    var cuePoints: [CuePoint]
+
+    /// Resolve the full file URL from the app's documents directory.
+    var fileURL: URL {
+        let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
+        return docs.appendingPathComponent(filePath).standardizedFileURL
+    }
+
+    /// 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,
+        fileName: 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.fileName = fileName.isEmpty ? (filePath as NSString).lastPathComponent : fileName
+        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.year = nil
+        self.dateAdded = Date()
+        self.lastPlayed = nil
+        self.playCount = 0
+        self.rating = 0
+        self.color = nil
+        self.notes = ""
+        self.waveformData = nil
+        self.isAnalyzed = false
+        self.isCloud = false
+        self.cloudStreamPath = nil
+        self.cloudTrackId = nil
+        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
+    }
+}

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

@@ -0,0 +1,16 @@
+//
+//  MixBoard-Bridging-Header.h
+//  MixBoard iOS
+//
+//  Bridging header to expose C libraries to Swift.
+//
+
+#ifndef MixBoard_Bridging_Header_h
+#define MixBoard_Bridging_Header_h
+
+#include "stb_vorbis_wrapper.h"
+#if !defined(DISABLE_OPUS)
+#include "../OpusLib/include/opus/opusfile.h"
+#endif
+
+#endif /* MixBoard_Bridging_Header_h */

+ 1 - 0
Sources/OGG/ogg.h

@@ -0,0 +1 @@
+404: Not Found

+ 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 */

+ 26 - 0
Sources/OpusLib/include/ogg/config_types.h

@@ -0,0 +1,26 @@
+#ifndef __CONFIG_TYPES_H__
+#define __CONFIG_TYPES_H__
+
+/* these are filled in by configure or cmake*/
+#define INCLUDE_INTTYPES_H 1
+#define INCLUDE_STDINT_H 1
+#define INCLUDE_SYS_TYPES_H 1
+
+#if INCLUDE_INTTYPES_H
+#  include <inttypes.h>
+#endif
+#if INCLUDE_STDINT_H
+#  include <stdint.h>
+#endif
+#if INCLUDE_SYS_TYPES_H
+#  include <sys/types.h>
+#endif
+
+typedef int16_t ogg_int16_t;
+typedef uint16_t ogg_uint16_t;
+typedef int32_t ogg_int32_t;
+typedef uint32_t ogg_uint32_t;
+typedef int64_t ogg_int64_t;
+typedef uint64_t ogg_uint64_t;
+
+#endif

+ 209 - 0
Sources/OpusLib/include/ogg/ogg.h

@@ -0,0 +1,209 @@
+/********************************************************************
+ *                                                                  *
+ * THIS FILE IS PART OF THE OggVorbis SOFTWARE CODEC SOURCE CODE.   *
+ * USE, DISTRIBUTION AND REPRODUCTION OF THIS LIBRARY SOURCE IS     *
+ * GOVERNED BY A BSD-STYLE SOURCE LICENSE INCLUDED WITH THIS SOURCE *
+ * IN 'COPYING'. PLEASE READ THESE TERMS BEFORE DISTRIBUTING.       *
+ *                                                                  *
+ * THE OggVorbis SOURCE CODE IS (C) COPYRIGHT 1994-2007             *
+ * by the Xiph.Org Foundation http://www.xiph.org/                  *
+ *                                                                  *
+ ********************************************************************
+
+ function: toplevel libogg include
+
+ ********************************************************************/
+#ifndef _OGG_H
+#define _OGG_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#include <stddef.h>
+#include <ogg/os_types.h>
+
+typedef struct {
+  void *iov_base;
+  size_t iov_len;
+} ogg_iovec_t;
+
+typedef struct {
+  long endbyte;
+  int  endbit;
+
+  unsigned char *buffer;
+  unsigned char *ptr;
+  long storage;
+} oggpack_buffer;
+
+/* ogg_page is used to encapsulate the data in one Ogg bitstream page *****/
+
+typedef struct {
+  unsigned char *header;
+  long header_len;
+  unsigned char *body;
+  long body_len;
+} ogg_page;
+
+/* ogg_stream_state contains the current encode/decode state of a logical
+   Ogg bitstream **********************************************************/
+
+typedef struct {
+  unsigned char   *body_data;    /* bytes from packet bodies */
+  long    body_storage;          /* storage elements allocated */
+  long    body_fill;             /* elements stored; fill mark */
+  long    body_returned;         /* elements of fill returned */
+
+
+  int     *lacing_vals;      /* The values that will go to the segment table */
+  ogg_int64_t *granule_vals; /* granulepos values for headers. Not compact
+                                this way, but it is simple coupled to the
+                                lacing fifo */
+  long    lacing_storage;
+  long    lacing_fill;
+  long    lacing_packet;
+  long    lacing_returned;
+
+  unsigned char    header[282];      /* working space for header encode */
+  int              header_fill;
+
+  int     e_o_s;          /* set when we have buffered the last packet in the
+                             logical bitstream */
+  int     b_o_s;          /* set after we've written the initial page
+                             of a logical bitstream */
+  long    serialno;
+  long    pageno;
+  ogg_int64_t  packetno;  /* sequence number for decode; the framing
+                             knows where there's a hole in the data,
+                             but we need coupling so that the codec
+                             (which is in a separate abstraction
+                             layer) also knows about the gap */
+  ogg_int64_t   granulepos;
+
+} ogg_stream_state;
+
+/* ogg_packet is used to encapsulate the data and metadata belonging
+   to a single raw Ogg/Vorbis packet *************************************/
+
+typedef struct {
+  unsigned char *packet;
+  long  bytes;
+  long  b_o_s;
+  long  e_o_s;
+
+  ogg_int64_t  granulepos;
+
+  ogg_int64_t  packetno;     /* sequence number for decode; the framing
+                                knows where there's a hole in the data,
+                                but we need coupling so that the codec
+                                (which is in a separate abstraction
+                                layer) also knows about the gap */
+} ogg_packet;
+
+typedef struct {
+  unsigned char *data;
+  int storage;
+  int fill;
+  int returned;
+
+  int unsynced;
+  int headerbytes;
+  int bodybytes;
+} ogg_sync_state;
+
+/* Ogg BITSTREAM PRIMITIVES: bitstream ************************/
+
+extern void  oggpack_writeinit(oggpack_buffer *b);
+extern int   oggpack_writecheck(oggpack_buffer *b);
+extern void  oggpack_writetrunc(oggpack_buffer *b,long bits);
+extern void  oggpack_writealign(oggpack_buffer *b);
+extern void  oggpack_writecopy(oggpack_buffer *b,void *source,long bits);
+extern void  oggpack_reset(oggpack_buffer *b);
+extern void  oggpack_writeclear(oggpack_buffer *b);
+extern void  oggpack_readinit(oggpack_buffer *b,unsigned char *buf,int bytes);
+extern void  oggpack_write(oggpack_buffer *b,unsigned long value,int bits);
+extern long  oggpack_look(oggpack_buffer *b,int bits);
+extern long  oggpack_look1(oggpack_buffer *b);
+extern void  oggpack_adv(oggpack_buffer *b,int bits);
+extern void  oggpack_adv1(oggpack_buffer *b);
+extern long  oggpack_read(oggpack_buffer *b,int bits);
+extern long  oggpack_read1(oggpack_buffer *b);
+extern long  oggpack_bytes(oggpack_buffer *b);
+extern long  oggpack_bits(oggpack_buffer *b);
+extern unsigned char *oggpack_get_buffer(oggpack_buffer *b);
+
+extern void  oggpackB_writeinit(oggpack_buffer *b);
+extern int   oggpackB_writecheck(oggpack_buffer *b);
+extern void  oggpackB_writetrunc(oggpack_buffer *b,long bits);
+extern void  oggpackB_writealign(oggpack_buffer *b);
+extern void  oggpackB_writecopy(oggpack_buffer *b,void *source,long bits);
+extern void  oggpackB_reset(oggpack_buffer *b);
+extern void  oggpackB_writeclear(oggpack_buffer *b);
+extern void  oggpackB_readinit(oggpack_buffer *b,unsigned char *buf,int bytes);
+extern void  oggpackB_write(oggpack_buffer *b,unsigned long value,int bits);
+extern long  oggpackB_look(oggpack_buffer *b,int bits);
+extern long  oggpackB_look1(oggpack_buffer *b);
+extern void  oggpackB_adv(oggpack_buffer *b,int bits);
+extern void  oggpackB_adv1(oggpack_buffer *b);
+extern long  oggpackB_read(oggpack_buffer *b,int bits);
+extern long  oggpackB_read1(oggpack_buffer *b);
+extern long  oggpackB_bytes(oggpack_buffer *b);
+extern long  oggpackB_bits(oggpack_buffer *b);
+extern unsigned char *oggpackB_get_buffer(oggpack_buffer *b);
+
+/* Ogg BITSTREAM PRIMITIVES: encoding **************************/
+
+extern int      ogg_stream_packetin(ogg_stream_state *os, ogg_packet *op);
+extern int      ogg_stream_iovecin(ogg_stream_state *os, ogg_iovec_t *iov,
+                                   int count, long e_o_s, ogg_int64_t granulepos);
+extern int      ogg_stream_pageout(ogg_stream_state *os, ogg_page *og);
+extern int      ogg_stream_pageout_fill(ogg_stream_state *os, ogg_page *og, int nfill);
+extern int      ogg_stream_flush(ogg_stream_state *os, ogg_page *og);
+extern int      ogg_stream_flush_fill(ogg_stream_state *os, ogg_page *og, int nfill);
+
+/* Ogg BITSTREAM PRIMITIVES: decoding **************************/
+
+extern int      ogg_sync_init(ogg_sync_state *oy);
+extern int      ogg_sync_clear(ogg_sync_state *oy);
+extern int      ogg_sync_reset(ogg_sync_state *oy);
+extern int      ogg_sync_destroy(ogg_sync_state *oy);
+extern int      ogg_sync_check(ogg_sync_state *oy);
+
+extern char    *ogg_sync_buffer(ogg_sync_state *oy, long size);
+extern int      ogg_sync_wrote(ogg_sync_state *oy, long bytes);
+extern long     ogg_sync_pageseek(ogg_sync_state *oy,ogg_page *og);
+extern int      ogg_sync_pageout(ogg_sync_state *oy, ogg_page *og);
+extern int      ogg_stream_pagein(ogg_stream_state *os, ogg_page *og);
+extern int      ogg_stream_packetout(ogg_stream_state *os,ogg_packet *op);
+extern int      ogg_stream_packetpeek(ogg_stream_state *os,ogg_packet *op);
+
+/* Ogg BITSTREAM PRIMITIVES: general ***************************/
+
+extern int      ogg_stream_init(ogg_stream_state *os,int serialno);
+extern int      ogg_stream_clear(ogg_stream_state *os);
+extern int      ogg_stream_reset(ogg_stream_state *os);
+extern int      ogg_stream_reset_serialno(ogg_stream_state *os,int serialno);
+extern int      ogg_stream_destroy(ogg_stream_state *os);
+extern int      ogg_stream_check(ogg_stream_state *os);
+extern int      ogg_stream_eos(ogg_stream_state *os);
+
+extern void     ogg_page_checksum_set(ogg_page *og);
+
+extern int      ogg_page_version(const ogg_page *og);
+extern int      ogg_page_continued(const ogg_page *og);
+extern int      ogg_page_bos(const ogg_page *og);
+extern int      ogg_page_eos(const ogg_page *og);
+extern ogg_int64_t  ogg_page_granulepos(const ogg_page *og);
+extern int      ogg_page_serialno(const ogg_page *og);
+extern long     ogg_page_pageno(const ogg_page *og);
+extern int      ogg_page_packets(const ogg_page *og);
+
+extern void     ogg_packet_clear(ogg_packet *op);
+
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif  /* _OGG_H */

+ 158 - 0
Sources/OpusLib/include/ogg/os_types.h

@@ -0,0 +1,158 @@
+/********************************************************************
+ *                                                                  *
+ * THIS FILE IS PART OF THE OggVorbis SOFTWARE CODEC SOURCE CODE.   *
+ * USE, DISTRIBUTION AND REPRODUCTION OF THIS LIBRARY SOURCE IS     *
+ * GOVERNED BY A BSD-STYLE SOURCE LICENSE INCLUDED WITH THIS SOURCE *
+ * IN 'COPYING'. PLEASE READ THESE TERMS BEFORE DISTRIBUTING.       *
+ *                                                                  *
+ * THE OggVorbis SOURCE CODE IS (C) COPYRIGHT 1994-2002             *
+ * by the Xiph.Org Foundation http://www.xiph.org/                  *
+ *                                                                  *
+ ********************************************************************
+
+ function: Define a consistent set of types on each platform.
+
+ ********************************************************************/
+#ifndef _OS_TYPES_H
+#define _OS_TYPES_H
+
+/* make it easy on the folks that want to compile the libs with a
+   different malloc than stdlib */
+#define _ogg_malloc  malloc
+#define _ogg_calloc  calloc
+#define _ogg_realloc realloc
+#define _ogg_free    free
+
+#if defined(_WIN32)
+
+#  if defined(__CYGWIN__)
+#    include <stdint.h>
+     typedef int16_t ogg_int16_t;
+     typedef uint16_t ogg_uint16_t;
+     typedef int32_t ogg_int32_t;
+     typedef uint32_t ogg_uint32_t;
+     typedef int64_t ogg_int64_t;
+     typedef uint64_t ogg_uint64_t;
+#  elif defined(__MINGW32__)
+#    include <sys/types.h>
+     typedef short ogg_int16_t;
+     typedef unsigned short ogg_uint16_t;
+     typedef int ogg_int32_t;
+     typedef unsigned int ogg_uint32_t;
+     typedef long long ogg_int64_t;
+     typedef unsigned long long ogg_uint64_t;
+#  elif defined(__MWERKS__)
+     typedef long long ogg_int64_t;
+     typedef unsigned long long ogg_uint64_t;
+     typedef int ogg_int32_t;
+     typedef unsigned int ogg_uint32_t;
+     typedef short ogg_int16_t;
+     typedef unsigned short ogg_uint16_t;
+#  else
+#    if defined(_MSC_VER) && (_MSC_VER >= 1800) /* MSVC 2013 and newer */
+#      include <stdint.h>
+       typedef int16_t ogg_int16_t;
+       typedef uint16_t ogg_uint16_t;
+       typedef int32_t ogg_int32_t;
+       typedef uint32_t ogg_uint32_t;
+       typedef int64_t ogg_int64_t;
+       typedef uint64_t ogg_uint64_t;
+#    else
+       /* MSVC/Borland */
+       typedef __int64 ogg_int64_t;
+       typedef __int32 ogg_int32_t;
+       typedef unsigned __int32 ogg_uint32_t;
+       typedef unsigned __int64 ogg_uint64_t;
+       typedef __int16 ogg_int16_t;
+       typedef unsigned __int16 ogg_uint16_t;
+#    endif
+#  endif
+
+#elif (defined(__APPLE__) && defined(__MACH__)) /* MacOS X Framework build */
+
+#  include <sys/types.h>
+   typedef int16_t ogg_int16_t;
+   typedef u_int16_t ogg_uint16_t;
+   typedef int32_t ogg_int32_t;
+   typedef u_int32_t ogg_uint32_t;
+   typedef int64_t ogg_int64_t;
+   typedef u_int64_t ogg_uint64_t;
+
+#elif defined(__HAIKU__)
+
+  /* Haiku */
+#  include <sys/types.h>
+   typedef short ogg_int16_t;
+   typedef unsigned short ogg_uint16_t;
+   typedef int ogg_int32_t;
+   typedef unsigned int ogg_uint32_t;
+   typedef long long ogg_int64_t;
+   typedef unsigned long long ogg_uint64_t;
+
+#elif defined(__BEOS__)
+
+   /* Be */
+#  include <inttypes.h>
+   typedef int16_t ogg_int16_t;
+   typedef uint16_t ogg_uint16_t;
+   typedef int32_t ogg_int32_t;
+   typedef uint32_t ogg_uint32_t;
+   typedef int64_t ogg_int64_t;
+   typedef uint64_t ogg_uint64_t;
+
+#elif defined (__EMX__)
+
+   /* OS/2 GCC */
+   typedef short ogg_int16_t;
+   typedef unsigned short ogg_uint16_t;
+   typedef int ogg_int32_t;
+   typedef unsigned int ogg_uint32_t;
+   typedef long long ogg_int64_t;
+   typedef unsigned long long ogg_uint64_t;
+
+
+#elif defined (DJGPP)
+
+   /* DJGPP */
+   typedef short ogg_int16_t;
+   typedef int ogg_int32_t;
+   typedef unsigned int ogg_uint32_t;
+   typedef long long ogg_int64_t;
+   typedef unsigned long long ogg_uint64_t;
+
+#elif defined(R5900)
+
+   /* PS2 EE */
+   typedef long ogg_int64_t;
+   typedef unsigned long ogg_uint64_t;
+   typedef int ogg_int32_t;
+   typedef unsigned ogg_uint32_t;
+   typedef short ogg_int16_t;
+
+#elif defined(__SYMBIAN32__)
+
+   /* Symbian GCC */
+   typedef signed short ogg_int16_t;
+   typedef unsigned short ogg_uint16_t;
+   typedef signed int ogg_int32_t;
+   typedef unsigned int ogg_uint32_t;
+   typedef long long int ogg_int64_t;
+   typedef unsigned long long int ogg_uint64_t;
+
+#elif defined(__TMS320C6X__)
+
+   /* TI C64x compiler */
+   typedef signed short ogg_int16_t;
+   typedef unsigned short ogg_uint16_t;
+   typedef signed int ogg_int32_t;
+   typedef unsigned int ogg_uint32_t;
+   typedef long long int ogg_int64_t;
+   typedef unsigned long long int ogg_uint64_t;
+
+#else
+
+#  include <ogg/config_types.h>
+
+#endif
+
+#endif  /* _OS_TYPES_H */

+ 981 - 0
Sources/OpusLib/include/opus/opus.h

@@ -0,0 +1,981 @@
+/* Copyright (c) 2010-2011 Xiph.Org Foundation, Skype Limited
+   Written by Jean-Marc Valin and Koen Vos */
+/*
+   Redistribution and use in source and binary forms, with or without
+   modification, are permitted provided that the following conditions
+   are met:
+
+   - Redistributions of source code must retain the above copyright
+   notice, this list of conditions and the following disclaimer.
+
+   - Redistributions in binary form must reproduce the above copyright
+   notice, this list of conditions and the following disclaimer in the
+   documentation and/or other materials provided with the distribution.
+
+   THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+   ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+   LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+   A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
+   OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+   EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+   PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+   PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+   LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+   NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+   SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+/**
+ * @file opus.h
+ * @brief Opus reference implementation API
+ */
+
+#ifndef OPUS_H
+#define OPUS_H
+
+#include "opus_types.h"
+#include "opus_defines.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * @mainpage Opus
+ *
+ * The Opus codec is designed for interactive speech and audio transmission over the Internet.
+ * It is designed by the IETF Codec Working Group and incorporates technology from
+ * Skype's SILK codec and Xiph.Org's CELT codec.
+ *
+ * The Opus codec is designed to handle a wide range of interactive audio applications,
+ * including Voice over IP, videoconferencing, in-game chat, and even remote live music
+ * performances. It can scale from low bit-rate narrowband speech to very high quality
+ * stereo music. Its main features are:
+
+ * @li Sampling rates from 8 to 48 kHz
+ * @li Bit-rates from 6 kb/s to 510 kb/s
+ * @li Support for both constant bit-rate (CBR) and variable bit-rate (VBR)
+ * @li Audio bandwidth from narrowband to full-band
+ * @li Support for speech and music
+ * @li Support for mono and stereo
+ * @li Support for multichannel (up to 255 channels)
+ * @li Frame sizes from 2.5 ms to 60 ms
+ * @li Good loss robustness and packet loss concealment (PLC)
+ * @li Floating point and fixed-point implementation
+ *
+ * Documentation sections:
+ * @li @ref opus_encoder
+ * @li @ref opus_decoder
+ * @li @ref opus_repacketizer
+ * @li @ref opus_multistream
+ * @li @ref opus_libinfo
+ * @li @ref opus_custom
+ */
+
+/** @defgroup opus_encoder Opus Encoder
+  * @{
+  *
+  * @brief This page describes the process and functions used to encode Opus.
+  *
+  * Since Opus is a stateful codec, the encoding process starts with creating an encoder
+  * state. This can be done with:
+  *
+  * @code
+  * int          error;
+  * OpusEncoder *enc;
+  * enc = opus_encoder_create(Fs, channels, application, &error);
+  * @endcode
+  *
+  * From this point, @c enc can be used for encoding an audio stream. An encoder state
+  * @b must @b not be used for more than one stream at the same time. Similarly, the encoder
+  * state @b must @b not be re-initialized for each frame.
+  *
+  * While opus_encoder_create() allocates memory for the state, it's also possible
+  * to initialize pre-allocated memory:
+  *
+  * @code
+  * int          size;
+  * int          error;
+  * OpusEncoder *enc;
+  * size = opus_encoder_get_size(channels);
+  * enc = malloc(size);
+  * error = opus_encoder_init(enc, Fs, channels, application);
+  * @endcode
+  *
+  * where opus_encoder_get_size() returns the required size for the encoder state. Note that
+  * future versions of this code may change the size, so no assuptions should be made about it.
+  *
+  * The encoder state is always continuous in memory and only a shallow copy is sufficient
+  * to copy it (e.g. memcpy())
+  *
+  * It is possible to change some of the encoder's settings using the opus_encoder_ctl()
+  * interface. All these settings already default to the recommended value, so they should
+  * only be changed when necessary. The most common settings one may want to change are:
+  *
+  * @code
+  * opus_encoder_ctl(enc, OPUS_SET_BITRATE(bitrate));
+  * opus_encoder_ctl(enc, OPUS_SET_COMPLEXITY(complexity));
+  * opus_encoder_ctl(enc, OPUS_SET_SIGNAL(signal_type));
+  * @endcode
+  *
+  * where
+  *
+  * @arg bitrate is in bits per second (b/s)
+  * @arg complexity is a value from 1 to 10, where 1 is the lowest complexity and 10 is the highest
+  * @arg signal_type is either OPUS_AUTO (default), OPUS_SIGNAL_VOICE, or OPUS_SIGNAL_MUSIC
+  *
+  * See @ref opus_encoderctls and @ref opus_genericctls for a complete list of parameters that can be set or queried. Most parameters can be set or changed at any time during a stream.
+  *
+  * To encode a frame, opus_encode() or opus_encode_float() must be called with exactly one frame (2.5, 5, 10, 20, 40 or 60 ms) of audio data:
+  * @code
+  * len = opus_encode(enc, audio_frame, frame_size, packet, max_packet);
+  * @endcode
+  *
+  * where
+  * <ul>
+  * <li>audio_frame is the audio data in opus_int16 (or float for opus_encode_float())</li>
+  * <li>frame_size is the duration of the frame in samples (per channel)</li>
+  * <li>packet is the byte array to which the compressed data is written</li>
+  * <li>max_packet is the maximum number of bytes that can be written in the packet (4000 bytes is recommended).
+  *     Do not use max_packet to control VBR target bitrate, instead use the #OPUS_SET_BITRATE CTL.</li>
+  * </ul>
+  *
+  * opus_encode() and opus_encode_float() return the number of bytes actually written to the packet.
+  * The return value <b>can be negative</b>, which indicates that an error has occurred. If the return value
+  * is 2 bytes or less, then the packet does not need to be transmitted (DTX).
+  *
+  * Once the encoder state if no longer needed, it can be destroyed with
+  *
+  * @code
+  * opus_encoder_destroy(enc);
+  * @endcode
+  *
+  * If the encoder was created with opus_encoder_init() rather than opus_encoder_create(),
+  * then no action is required aside from potentially freeing the memory that was manually
+  * allocated for it (calling free(enc) for the example above)
+  *
+  */
+
+/** Opus encoder state.
+  * This contains the complete state of an Opus encoder.
+  * It is position independent and can be freely copied.
+  * @see opus_encoder_create,opus_encoder_init
+  */
+typedef struct OpusEncoder OpusEncoder;
+
+/** Gets the size of an <code>OpusEncoder</code> structure.
+  * @param[in] channels <tt>int</tt>: Number of channels.
+  *                                   This must be 1 or 2.
+  * @returns The size in bytes.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_encoder_get_size(int channels);
+
+/**
+ */
+
+/** Allocates and initializes an encoder state.
+ * There are three coding modes:
+ *
+ * @ref OPUS_APPLICATION_VOIP gives best quality at a given bitrate for voice
+ *    signals. It enhances the  input signal by high-pass filtering and
+ *    emphasizing formants and harmonics. Optionally  it includes in-band
+ *    forward error correction to protect against packet loss. Use this
+ *    mode for typical VoIP applications. Because of the enhancement,
+ *    even at high bitrates the output may sound different from the input.
+ *
+ * @ref OPUS_APPLICATION_AUDIO gives best quality at a given bitrate for most
+ *    non-voice signals like music. Use this mode for music and mixed
+ *    (music/voice) content, broadcast, and applications requiring less
+ *    than 15 ms of coding delay.
+ *
+ * @ref OPUS_APPLICATION_RESTRICTED_LOWDELAY configures low-delay mode that
+ *    disables the speech-optimized mode in exchange for slightly reduced delay.
+ *    This mode can only be set on an newly initialized or freshly reset encoder
+ *    because it changes the codec delay.
+ *
+ * This is useful when the caller knows that the speech-optimized modes will not be needed (use with caution).
+ * @param [in] Fs <tt>opus_int32</tt>: Sampling rate of input signal (Hz)
+ *                                     This must be one of 8000, 12000, 16000,
+ *                                     24000, or 48000.
+ * @param [in] channels <tt>int</tt>: Number of channels (1 or 2) in input signal
+ * @param [in] application <tt>int</tt>: Coding mode (one of @ref OPUS_APPLICATION_VOIP, @ref OPUS_APPLICATION_AUDIO, or @ref OPUS_APPLICATION_RESTRICTED_LOWDELAY)
+ * @param [out] error <tt>int*</tt>: @ref opus_errorcodes
+ * @note Regardless of the sampling rate and number channels selected, the Opus encoder
+ * can switch to a lower audio bandwidth or number of channels if the bitrate
+ * selected is too low. This also means that it is safe to always use 48 kHz stereo input
+ * and let the encoder optimize the encoding.
+ */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusEncoder *opus_encoder_create(
+    opus_int32 Fs,
+    int channels,
+    int application,
+    int *error
+);
+
+/** Initializes a previously allocated encoder state
+  * The memory pointed to by st must be at least the size returned by opus_encoder_get_size().
+  * This is intended for applications which use their own allocator instead of malloc.
+  * @see opus_encoder_create(),opus_encoder_get_size()
+  * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL.
+  * @param [in] st <tt>OpusEncoder*</tt>: Encoder state
+  * @param [in] Fs <tt>opus_int32</tt>: Sampling rate of input signal (Hz)
+ *                                      This must be one of 8000, 12000, 16000,
+ *                                      24000, or 48000.
+  * @param [in] channels <tt>int</tt>: Number of channels (1 or 2) in input signal
+  * @param [in] application <tt>int</tt>: Coding mode (one of OPUS_APPLICATION_VOIP, OPUS_APPLICATION_AUDIO, or OPUS_APPLICATION_RESTRICTED_LOWDELAY)
+  * @retval #OPUS_OK Success or @ref opus_errorcodes
+  */
+OPUS_EXPORT int opus_encoder_init(
+    OpusEncoder *st,
+    opus_int32 Fs,
+    int channels,
+    int application
+) OPUS_ARG_NONNULL(1);
+
+/** Encodes an Opus frame.
+  * @param [in] st <tt>OpusEncoder*</tt>: Encoder state
+  * @param [in] pcm <tt>opus_int16*</tt>: Input signal (interleaved if 2 channels). length is frame_size*channels*sizeof(opus_int16)
+  * @param [in] frame_size <tt>int</tt>: Number of samples per channel in the
+  *                                      input signal.
+  *                                      This must be an Opus frame size for
+  *                                      the encoder's sampling rate.
+  *                                      For example, at 48 kHz the permitted
+  *                                      values are 120, 240, 480, 960, 1920,
+  *                                      and 2880.
+  *                                      Passing in a duration of less than
+  *                                      10 ms (480 samples at 48 kHz) will
+  *                                      prevent the encoder from using the LPC
+  *                                      or hybrid modes.
+  * @param [out] data <tt>unsigned char*</tt>: Output payload.
+  *                                            This must contain storage for at
+  *                                            least \a max_data_bytes.
+  * @param [in] max_data_bytes <tt>opus_int32</tt>: Size of the allocated
+  *                                                 memory for the output
+  *                                                 payload. This may be
+  *                                                 used to impose an upper limit on
+  *                                                 the instant bitrate, but should
+  *                                                 not be used as the only bitrate
+  *                                                 control. Use #OPUS_SET_BITRATE to
+  *                                                 control the bitrate.
+  * @returns The length of the encoded packet (in bytes) on success or a
+  *          negative error code (see @ref opus_errorcodes) on failure.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_encode(
+    OpusEncoder *st,
+    const opus_int16 *pcm,
+    int frame_size,
+    unsigned char *data,
+    opus_int32 max_data_bytes
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4);
+
+/** Encodes an Opus frame from floating point input.
+  * @param [in] st <tt>OpusEncoder*</tt>: Encoder state
+  * @param [in] pcm <tt>float*</tt>: Input in float format (interleaved if 2 channels), with a normal range of +/-1.0.
+  *          Samples with a range beyond +/-1.0 are supported but will
+  *          be clipped by decoders using the integer API and should
+  *          only be used if it is known that the far end supports
+  *          extended dynamic range.
+  *          length is frame_size*channels*sizeof(float)
+  * @param [in] frame_size <tt>int</tt>: Number of samples per channel in the
+  *                                      input signal.
+  *                                      This must be an Opus frame size for
+  *                                      the encoder's sampling rate.
+  *                                      For example, at 48 kHz the permitted
+  *                                      values are 120, 240, 480, 960, 1920,
+  *                                      and 2880.
+  *                                      Passing in a duration of less than
+  *                                      10 ms (480 samples at 48 kHz) will
+  *                                      prevent the encoder from using the LPC
+  *                                      or hybrid modes.
+  * @param [out] data <tt>unsigned char*</tt>: Output payload.
+  *                                            This must contain storage for at
+  *                                            least \a max_data_bytes.
+  * @param [in] max_data_bytes <tt>opus_int32</tt>: Size of the allocated
+  *                                                 memory for the output
+  *                                                 payload. This may be
+  *                                                 used to impose an upper limit on
+  *                                                 the instant bitrate, but should
+  *                                                 not be used as the only bitrate
+  *                                                 control. Use #OPUS_SET_BITRATE to
+  *                                                 control the bitrate.
+  * @returns The length of the encoded packet (in bytes) on success or a
+  *          negative error code (see @ref opus_errorcodes) on failure.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_encode_float(
+    OpusEncoder *st,
+    const float *pcm,
+    int frame_size,
+    unsigned char *data,
+    opus_int32 max_data_bytes
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4);
+
+/** Frees an <code>OpusEncoder</code> allocated by opus_encoder_create().
+  * @param[in] st <tt>OpusEncoder*</tt>: State to be freed.
+  */
+OPUS_EXPORT void opus_encoder_destroy(OpusEncoder *st);
+
+/** Perform a CTL function on an Opus encoder.
+  *
+  * Generally the request and subsequent arguments are generated
+  * by a convenience macro.
+  * @param st <tt>OpusEncoder*</tt>: Encoder state.
+  * @param request This and all remaining parameters should be replaced by one
+  *                of the convenience macros in @ref opus_genericctls or
+  *                @ref opus_encoderctls.
+  * @see opus_genericctls
+  * @see opus_encoderctls
+  */
+OPUS_EXPORT int opus_encoder_ctl(OpusEncoder *st, int request, ...) OPUS_ARG_NONNULL(1);
+/**@}*/
+
+/** @defgroup opus_decoder Opus Decoder
+  * @{
+  *
+  * @brief This page describes the process and functions used to decode Opus.
+  *
+  * The decoding process also starts with creating a decoder
+  * state. This can be done with:
+  * @code
+  * int          error;
+  * OpusDecoder *dec;
+  * dec = opus_decoder_create(Fs, channels, &error);
+  * @endcode
+  * where
+  * @li Fs is the sampling rate and must be 8000, 12000, 16000, 24000, or 48000
+  * @li channels is the number of channels (1 or 2)
+  * @li error will hold the error code in case of failure (or #OPUS_OK on success)
+  * @li the return value is a newly created decoder state to be used for decoding
+  *
+  * While opus_decoder_create() allocates memory for the state, it's also possible
+  * to initialize pre-allocated memory:
+  * @code
+  * int          size;
+  * int          error;
+  * OpusDecoder *dec;
+  * size = opus_decoder_get_size(channels);
+  * dec = malloc(size);
+  * error = opus_decoder_init(dec, Fs, channels);
+  * @endcode
+  * where opus_decoder_get_size() returns the required size for the decoder state. Note that
+  * future versions of this code may change the size, so no assuptions should be made about it.
+  *
+  * The decoder state is always continuous in memory and only a shallow copy is sufficient
+  * to copy it (e.g. memcpy())
+  *
+  * To decode a frame, opus_decode() or opus_decode_float() must be called with a packet of compressed audio data:
+  * @code
+  * frame_size = opus_decode(dec, packet, len, decoded, max_size, 0);
+  * @endcode
+  * where
+  *
+  * @li packet is the byte array containing the compressed data
+  * @li len is the exact number of bytes contained in the packet
+  * @li decoded is the decoded audio data in opus_int16 (or float for opus_decode_float())
+  * @li max_size is the max duration of the frame in samples (per channel) that can fit into the decoded_frame array
+  *
+  * opus_decode() and opus_decode_float() return the number of samples (per channel) decoded from the packet.
+  * If that value is negative, then an error has occurred. This can occur if the packet is corrupted or if the audio
+  * buffer is too small to hold the decoded audio.
+  *
+  * Opus is a stateful codec with overlapping blocks and as a result Opus
+  * packets are not coded independently of each other. Packets must be
+  * passed into the decoder serially and in the correct order for a correct
+  * decode. Lost packets can be replaced with loss concealment by calling
+  * the decoder with a null pointer and zero length for the missing packet.
+  *
+  * A single codec state may only be accessed from a single thread at
+  * a time and any required locking must be performed by the caller. Separate
+  * streams must be decoded with separate decoder states and can be decoded
+  * in parallel unless the library was compiled with NONTHREADSAFE_PSEUDOSTACK
+  * defined.
+  *
+  */
+
+/** Opus decoder state.
+  * This contains the complete state of an Opus decoder.
+  * It is position independent and can be freely copied.
+  * @see opus_decoder_create,opus_decoder_init
+  */
+typedef struct OpusDecoder OpusDecoder;
+
+/** Gets the size of an <code>OpusDecoder</code> structure.
+  * @param [in] channels <tt>int</tt>: Number of channels.
+  *                                    This must be 1 or 2.
+  * @returns The size in bytes.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_decoder_get_size(int channels);
+
+/** Allocates and initializes a decoder state.
+  * @param [in] Fs <tt>opus_int32</tt>: Sample rate to decode at (Hz).
+  *                                     This must be one of 8000, 12000, 16000,
+  *                                     24000, or 48000.
+  * @param [in] channels <tt>int</tt>: Number of channels (1 or 2) to decode
+  * @param [out] error <tt>int*</tt>: #OPUS_OK Success or @ref opus_errorcodes
+  *
+  * Internally Opus stores data at 48000 Hz, so that should be the default
+  * value for Fs. However, the decoder can efficiently decode to buffers
+  * at 8, 12, 16, and 24 kHz so if for some reason the caller cannot use
+  * data at the full sample rate, or knows the compressed data doesn't
+  * use the full frequency range, it can request decoding at a reduced
+  * rate. Likewise, the decoder is capable of filling in either mono or
+  * interleaved stereo pcm buffers, at the caller's request.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusDecoder *opus_decoder_create(
+    opus_int32 Fs,
+    int channels,
+    int *error
+);
+
+/** Initializes a previously allocated decoder state.
+  * The state must be at least the size returned by opus_decoder_get_size().
+  * This is intended for applications which use their own allocator instead of malloc. @see opus_decoder_create,opus_decoder_get_size
+  * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL.
+  * @param [in] st <tt>OpusDecoder*</tt>: Decoder state.
+  * @param [in] Fs <tt>opus_int32</tt>: Sampling rate to decode to (Hz).
+  *                                     This must be one of 8000, 12000, 16000,
+  *                                     24000, or 48000.
+  * @param [in] channels <tt>int</tt>: Number of channels (1 or 2) to decode
+  * @retval #OPUS_OK Success or @ref opus_errorcodes
+  */
+OPUS_EXPORT int opus_decoder_init(
+    OpusDecoder *st,
+    opus_int32 Fs,
+    int channels
+) OPUS_ARG_NONNULL(1);
+
+/** Decode an Opus packet.
+  * @param [in] st <tt>OpusDecoder*</tt>: Decoder state
+  * @param [in] data <tt>char*</tt>: Input payload. Use a NULL pointer to indicate packet loss
+  * @param [in] len <tt>opus_int32</tt>: Number of bytes in payload*
+  * @param [out] pcm <tt>opus_int16*</tt>: Output signal (interleaved if 2 channels). length
+  *  is frame_size*channels*sizeof(opus_int16)
+  * @param [in] frame_size Number of samples per channel of available space in \a pcm.
+  *  If this is less than the maximum packet duration (120ms; 5760 for 48kHz), this function will
+  *  not be capable of decoding some packets. In the case of PLC (data==NULL) or FEC (decode_fec=1),
+  *  then frame_size needs to be exactly the duration of audio that is missing, otherwise the
+  *  decoder will not be in the optimal state to decode the next incoming packet. For the PLC and
+  *  FEC cases, frame_size <b>must</b> be a multiple of 2.5 ms.
+  * @param [in] decode_fec <tt>int</tt>: Flag (0 or 1) to request that any in-band forward error correction data be
+  *  decoded. If no such data is available, the frame is decoded as if it were lost.
+  * @returns Number of decoded samples or @ref opus_errorcodes
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_decode(
+    OpusDecoder *st,
+    const unsigned char *data,
+    opus_int32 len,
+    opus_int16 *pcm,
+    int frame_size,
+    int decode_fec
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4);
+
+/** Decode an Opus packet with floating point output.
+  * @param [in] st <tt>OpusDecoder*</tt>: Decoder state
+  * @param [in] data <tt>char*</tt>: Input payload. Use a NULL pointer to indicate packet loss
+  * @param [in] len <tt>opus_int32</tt>: Number of bytes in payload
+  * @param [out] pcm <tt>float*</tt>: Output signal (interleaved if 2 channels). length
+  *  is frame_size*channels*sizeof(float)
+  * @param [in] frame_size Number of samples per channel of available space in \a pcm.
+  *  If this is less than the maximum packet duration (120ms; 5760 for 48kHz), this function will
+  *  not be capable of decoding some packets. In the case of PLC (data==NULL) or FEC (decode_fec=1),
+  *  then frame_size needs to be exactly the duration of audio that is missing, otherwise the
+  *  decoder will not be in the optimal state to decode the next incoming packet. For the PLC and
+  *  FEC cases, frame_size <b>must</b> be a multiple of 2.5 ms.
+  * @param [in] decode_fec <tt>int</tt>: Flag (0 or 1) to request that any in-band forward error correction data be
+  *  decoded. If no such data is available the frame is decoded as if it were lost.
+  * @returns Number of decoded samples or @ref opus_errorcodes
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_decode_float(
+    OpusDecoder *st,
+    const unsigned char *data,
+    opus_int32 len,
+    float *pcm,
+    int frame_size,
+    int decode_fec
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4);
+
+/** Perform a CTL function on an Opus decoder.
+  *
+  * Generally the request and subsequent arguments are generated
+  * by a convenience macro.
+  * @param st <tt>OpusDecoder*</tt>: Decoder state.
+  * @param request This and all remaining parameters should be replaced by one
+  *                of the convenience macros in @ref opus_genericctls or
+  *                @ref opus_decoderctls.
+  * @see opus_genericctls
+  * @see opus_decoderctls
+  */
+OPUS_EXPORT int opus_decoder_ctl(OpusDecoder *st, int request, ...) OPUS_ARG_NONNULL(1);
+
+/** Frees an <code>OpusDecoder</code> allocated by opus_decoder_create().
+  * @param[in] st <tt>OpusDecoder*</tt>: State to be freed.
+  */
+OPUS_EXPORT void opus_decoder_destroy(OpusDecoder *st);
+
+/** Parse an opus packet into one or more frames.
+  * Opus_decode will perform this operation internally so most applications do
+  * not need to use this function.
+  * This function does not copy the frames, the returned pointers are pointers into
+  * the input packet.
+  * @param [in] data <tt>char*</tt>: Opus packet to be parsed
+  * @param [in] len <tt>opus_int32</tt>: size of data
+  * @param [out] out_toc <tt>char*</tt>: TOC pointer
+  * @param [out] frames <tt>char*[48]</tt> encapsulated frames
+  * @param [out] size <tt>opus_int16[48]</tt> sizes of the encapsulated frames
+  * @param [out] payload_offset <tt>int*</tt>: returns the position of the payload within the packet (in bytes)
+  * @returns number of frames
+  */
+OPUS_EXPORT int opus_packet_parse(
+   const unsigned char *data,
+   opus_int32 len,
+   unsigned char *out_toc,
+   const unsigned char *frames[48],
+   opus_int16 size[48],
+   int *payload_offset
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(5);
+
+/** Gets the bandwidth of an Opus packet.
+  * @param [in] data <tt>char*</tt>: Opus packet
+  * @retval OPUS_BANDWIDTH_NARROWBAND Narrowband (4kHz bandpass)
+  * @retval OPUS_BANDWIDTH_MEDIUMBAND Mediumband (6kHz bandpass)
+  * @retval OPUS_BANDWIDTH_WIDEBAND Wideband (8kHz bandpass)
+  * @retval OPUS_BANDWIDTH_SUPERWIDEBAND Superwideband (12kHz bandpass)
+  * @retval OPUS_BANDWIDTH_FULLBAND Fullband (20kHz bandpass)
+  * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_get_bandwidth(const unsigned char *data) OPUS_ARG_NONNULL(1);
+
+/** Gets the number of samples per frame from an Opus packet.
+  * @param [in] data <tt>char*</tt>: Opus packet.
+  *                                  This must contain at least one byte of
+  *                                  data.
+  * @param [in] Fs <tt>opus_int32</tt>: Sampling rate in Hz.
+  *                                     This must be a multiple of 400, or
+  *                                     inaccurate results will be returned.
+  * @returns Number of samples per frame.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_get_samples_per_frame(const unsigned char *data, opus_int32 Fs) OPUS_ARG_NONNULL(1);
+
+/** Gets the number of channels from an Opus packet.
+  * @param [in] data <tt>char*</tt>: Opus packet
+  * @returns Number of channels
+  * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_get_nb_channels(const unsigned char *data) OPUS_ARG_NONNULL(1);
+
+/** Gets the number of frames in an Opus packet.
+  * @param [in] packet <tt>char*</tt>: Opus packet
+  * @param [in] len <tt>opus_int32</tt>: Length of packet
+  * @returns Number of frames
+  * @retval OPUS_BAD_ARG Insufficient data was passed to the function
+  * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_get_nb_frames(const unsigned char packet[], opus_int32 len) OPUS_ARG_NONNULL(1);
+
+/** Gets the number of samples of an Opus packet.
+  * @param [in] packet <tt>char*</tt>: Opus packet
+  * @param [in] len <tt>opus_int32</tt>: Length of packet
+  * @param [in] Fs <tt>opus_int32</tt>: Sampling rate in Hz.
+  *                                     This must be a multiple of 400, or
+  *                                     inaccurate results will be returned.
+  * @returns Number of samples
+  * @retval OPUS_BAD_ARG Insufficient data was passed to the function
+  * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_packet_get_nb_samples(const unsigned char packet[], opus_int32 len, opus_int32 Fs) OPUS_ARG_NONNULL(1);
+
+/** Gets the number of samples of an Opus packet.
+  * @param [in] dec <tt>OpusDecoder*</tt>: Decoder state
+  * @param [in] packet <tt>char*</tt>: Opus packet
+  * @param [in] len <tt>opus_int32</tt>: Length of packet
+  * @returns Number of samples
+  * @retval OPUS_BAD_ARG Insufficient data was passed to the function
+  * @retval OPUS_INVALID_PACKET The compressed data passed is corrupted or of an unsupported type
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_decoder_get_nb_samples(const OpusDecoder *dec, const unsigned char packet[], opus_int32 len) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2);
+
+/** Applies soft-clipping to bring a float signal within the [-1,1] range. If
+  * the signal is already in that range, nothing is done. If there are values
+  * outside of [-1,1], then the signal is clipped as smoothly as possible to
+  * both fit in the range and avoid creating excessive distortion in the
+  * process.
+  * @param [in,out] pcm <tt>float*</tt>: Input PCM and modified PCM
+  * @param [in] frame_size <tt>int</tt> Number of samples per channel to process
+  * @param [in] channels <tt>int</tt>: Number of channels
+  * @param [in,out] softclip_mem <tt>float*</tt>: State memory for the soft clipping process (one float per channel, initialized to zero)
+  */
+OPUS_EXPORT void opus_pcm_soft_clip(float *pcm, int frame_size, int channels, float *softclip_mem);
+
+
+/**@}*/
+
+/** @defgroup opus_repacketizer Repacketizer
+  * @{
+  *
+  * The repacketizer can be used to merge multiple Opus packets into a single
+  * packet or alternatively to split Opus packets that have previously been
+  * merged. Splitting valid Opus packets is always guaranteed to succeed,
+  * whereas merging valid packets only succeeds if all frames have the same
+  * mode, bandwidth, and frame size, and when the total duration of the merged
+  * packet is no more than 120 ms. The 120 ms limit comes from the
+  * specification and limits decoder memory requirements at a point where
+  * framing overhead becomes negligible.
+  *
+  * The repacketizer currently only operates on elementary Opus
+  * streams. It will not manipualte multistream packets successfully, except in
+  * the degenerate case where they consist of data from a single stream.
+  *
+  * The repacketizing process starts with creating a repacketizer state, either
+  * by calling opus_repacketizer_create() or by allocating the memory yourself,
+  * e.g.,
+  * @code
+  * OpusRepacketizer *rp;
+  * rp = (OpusRepacketizer*)malloc(opus_repacketizer_get_size());
+  * if (rp != NULL)
+  *     opus_repacketizer_init(rp);
+  * @endcode
+  *
+  * Then the application should submit packets with opus_repacketizer_cat(),
+  * extract new packets with opus_repacketizer_out() or
+  * opus_repacketizer_out_range(), and then reset the state for the next set of
+  * input packets via opus_repacketizer_init().
+  *
+  * For example, to split a sequence of packets into individual frames:
+  * @code
+  * unsigned char *data;
+  * int len;
+  * while (get_next_packet(&data, &len))
+  * {
+  *   unsigned char out[1276];
+  *   opus_int32 out_len;
+  *   int nb_frames;
+  *   int err;
+  *   int i;
+  *   err = opus_repacketizer_cat(rp, data, len);
+  *   if (err != OPUS_OK)
+  *   {
+  *     release_packet(data);
+  *     return err;
+  *   }
+  *   nb_frames = opus_repacketizer_get_nb_frames(rp);
+  *   for (i = 0; i < nb_frames; i++)
+  *   {
+  *     out_len = opus_repacketizer_out_range(rp, i, i+1, out, sizeof(out));
+  *     if (out_len < 0)
+  *     {
+  *        release_packet(data);
+  *        return (int)out_len;
+  *     }
+  *     output_next_packet(out, out_len);
+  *   }
+  *   opus_repacketizer_init(rp);
+  *   release_packet(data);
+  * }
+  * @endcode
+  *
+  * Alternatively, to combine a sequence of frames into packets that each
+  * contain up to <code>TARGET_DURATION_MS</code> milliseconds of data:
+  * @code
+  * // The maximum number of packets with duration TARGET_DURATION_MS occurs
+  * // when the frame size is 2.5 ms, for a total of (TARGET_DURATION_MS*2/5)
+  * // packets.
+  * unsigned char *data[(TARGET_DURATION_MS*2/5)+1];
+  * opus_int32 len[(TARGET_DURATION_MS*2/5)+1];
+  * int nb_packets;
+  * unsigned char out[1277*(TARGET_DURATION_MS*2/2)];
+  * opus_int32 out_len;
+  * int prev_toc;
+  * nb_packets = 0;
+  * while (get_next_packet(data+nb_packets, len+nb_packets))
+  * {
+  *   int nb_frames;
+  *   int err;
+  *   nb_frames = opus_packet_get_nb_frames(data[nb_packets], len[nb_packets]);
+  *   if (nb_frames < 1)
+  *   {
+  *     release_packets(data, nb_packets+1);
+  *     return nb_frames;
+  *   }
+  *   nb_frames += opus_repacketizer_get_nb_frames(rp);
+  *   // If adding the next packet would exceed our target, or it has an
+  *   // incompatible TOC sequence, output the packets we already have before
+  *   // submitting it.
+  *   // N.B., The nb_packets > 0 check ensures we've submitted at least one
+  *   // packet since the last call to opus_repacketizer_init(). Otherwise a
+  *   // single packet longer than TARGET_DURATION_MS would cause us to try to
+  *   // output an (invalid) empty packet. It also ensures that prev_toc has
+  *   // been set to a valid value. Additionally, len[nb_packets] > 0 is
+  *   // guaranteed by the call to opus_packet_get_nb_frames() above, so the
+  *   // reference to data[nb_packets][0] should be valid.
+  *   if (nb_packets > 0 && (
+  *       ((prev_toc & 0xFC) != (data[nb_packets][0] & 0xFC)) ||
+  *       opus_packet_get_samples_per_frame(data[nb_packets], 48000)*nb_frames >
+  *       TARGET_DURATION_MS*48))
+  *   {
+  *     out_len = opus_repacketizer_out(rp, out, sizeof(out));
+  *     if (out_len < 0)
+  *     {
+  *        release_packets(data, nb_packets+1);
+  *        return (int)out_len;
+  *     }
+  *     output_next_packet(out, out_len);
+  *     opus_repacketizer_init(rp);
+  *     release_packets(data, nb_packets);
+  *     data[0] = data[nb_packets];
+  *     len[0] = len[nb_packets];
+  *     nb_packets = 0;
+  *   }
+  *   err = opus_repacketizer_cat(rp, data[nb_packets], len[nb_packets]);
+  *   if (err != OPUS_OK)
+  *   {
+  *     release_packets(data, nb_packets+1);
+  *     return err;
+  *   }
+  *   prev_toc = data[nb_packets][0];
+  *   nb_packets++;
+  * }
+  * // Output the final, partial packet.
+  * if (nb_packets > 0)
+  * {
+  *   out_len = opus_repacketizer_out(rp, out, sizeof(out));
+  *   release_packets(data, nb_packets);
+  *   if (out_len < 0)
+  *     return (int)out_len;
+  *   output_next_packet(out, out_len);
+  * }
+  * @endcode
+  *
+  * An alternate way of merging packets is to simply call opus_repacketizer_cat()
+  * unconditionally until it fails. At that point, the merged packet can be
+  * obtained with opus_repacketizer_out() and the input packet for which
+  * opus_repacketizer_cat() needs to be re-added to a newly reinitialized
+  * repacketizer state.
+  */
+
+typedef struct OpusRepacketizer OpusRepacketizer;
+
+/** Gets the size of an <code>OpusRepacketizer</code> structure.
+  * @returns The size in bytes.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_repacketizer_get_size(void);
+
+/** (Re)initializes a previously allocated repacketizer state.
+  * The state must be at least the size returned by opus_repacketizer_get_size().
+  * This can be used for applications which use their own allocator instead of
+  * malloc().
+  * It must also be called to reset the queue of packets waiting to be
+  * repacketized, which is necessary if the maximum packet duration of 120 ms
+  * is reached or if you wish to submit packets with a different Opus
+  * configuration (coding mode, audio bandwidth, frame size, or channel count).
+  * Failure to do so will prevent a new packet from being added with
+  * opus_repacketizer_cat().
+  * @see opus_repacketizer_create
+  * @see opus_repacketizer_get_size
+  * @see opus_repacketizer_cat
+  * @param rp <tt>OpusRepacketizer*</tt>: The repacketizer state to
+  *                                       (re)initialize.
+  * @returns A pointer to the same repacketizer state that was passed in.
+  */
+OPUS_EXPORT OpusRepacketizer *opus_repacketizer_init(OpusRepacketizer *rp) OPUS_ARG_NONNULL(1);
+
+/** Allocates memory and initializes the new repacketizer with
+ * opus_repacketizer_init().
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusRepacketizer *opus_repacketizer_create(void);
+
+/** Frees an <code>OpusRepacketizer</code> allocated by
+  * opus_repacketizer_create().
+  * @param[in] rp <tt>OpusRepacketizer*</tt>: State to be freed.
+  */
+OPUS_EXPORT void opus_repacketizer_destroy(OpusRepacketizer *rp);
+
+/** Add a packet to the current repacketizer state.
+  * This packet must match the configuration of any packets already submitted
+  * for repacketization since the last call to opus_repacketizer_init().
+  * This means that it must have the same coding mode, audio bandwidth, frame
+  * size, and channel count.
+  * This can be checked in advance by examining the top 6 bits of the first
+  * byte of the packet, and ensuring they match the top 6 bits of the first
+  * byte of any previously submitted packet.
+  * The total duration of audio in the repacketizer state also must not exceed
+  * 120 ms, the maximum duration of a single packet, after adding this packet.
+  *
+  * The contents of the current repacketizer state can be extracted into new
+  * packets using opus_repacketizer_out() or opus_repacketizer_out_range().
+  *
+  * In order to add a packet with a different configuration or to add more
+  * audio beyond 120 ms, you must clear the repacketizer state by calling
+  * opus_repacketizer_init().
+  * If a packet is too large to add to the current repacketizer state, no part
+  * of it is added, even if it contains multiple frames, some of which might
+  * fit.
+  * If you wish to be able to add parts of such packets, you should first use
+  * another repacketizer to split the packet into pieces and add them
+  * individually.
+  * @see opus_repacketizer_out_range
+  * @see opus_repacketizer_out
+  * @see opus_repacketizer_init
+  * @param rp <tt>OpusRepacketizer*</tt>: The repacketizer state to which to
+  *                                       add the packet.
+  * @param[in] data <tt>const unsigned char*</tt>: The packet data.
+  *                                                The application must ensure
+  *                                                this pointer remains valid
+  *                                                until the next call to
+  *                                                opus_repacketizer_init() or
+  *                                                opus_repacketizer_destroy().
+  * @param len <tt>opus_int32</tt>: The number of bytes in the packet data.
+  * @returns An error code indicating whether or not the operation succeeded.
+  * @retval #OPUS_OK The packet's contents have been added to the repacketizer
+  *                  state.
+  * @retval #OPUS_INVALID_PACKET The packet did not have a valid TOC sequence,
+  *                              the packet's TOC sequence was not compatible
+  *                              with previously submitted packets (because
+  *                              the coding mode, audio bandwidth, frame size,
+  *                              or channel count did not match), or adding
+  *                              this packet would increase the total amount of
+  *                              audio stored in the repacketizer state to more
+  *                              than 120 ms.
+  */
+OPUS_EXPORT int opus_repacketizer_cat(OpusRepacketizer *rp, const unsigned char *data, opus_int32 len) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2);
+
+
+/** Construct a new packet from data previously submitted to the repacketizer
+  * state via opus_repacketizer_cat().
+  * @param rp <tt>OpusRepacketizer*</tt>: The repacketizer state from which to
+  *                                       construct the new packet.
+  * @param begin <tt>int</tt>: The index of the first frame in the current
+  *                            repacketizer state to include in the output.
+  * @param end <tt>int</tt>: One past the index of the last frame in the
+  *                          current repacketizer state to include in the
+  *                          output.
+  * @param[out] data <tt>const unsigned char*</tt>: The buffer in which to
+  *                                                 store the output packet.
+  * @param maxlen <tt>opus_int32</tt>: The maximum number of bytes to store in
+  *                                    the output buffer. In order to guarantee
+  *                                    success, this should be at least
+  *                                    <code>1276</code> for a single frame,
+  *                                    or for multiple frames,
+  *                                    <code>1277*(end-begin)</code>.
+  *                                    However, <code>1*(end-begin)</code> plus
+  *                                    the size of all packet data submitted to
+  *                                    the repacketizer since the last call to
+  *                                    opus_repacketizer_init() or
+  *                                    opus_repacketizer_create() is also
+  *                                    sufficient, and possibly much smaller.
+  * @returns The total size of the output packet on success, or an error code
+  *          on failure.
+  * @retval #OPUS_BAD_ARG <code>[begin,end)</code> was an invalid range of
+  *                       frames (begin < 0, begin >= end, or end >
+  *                       opus_repacketizer_get_nb_frames()).
+  * @retval #OPUS_BUFFER_TOO_SMALL \a maxlen was insufficient to contain the
+  *                                complete output packet.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_repacketizer_out_range(OpusRepacketizer *rp, int begin, int end, unsigned char *data, opus_int32 maxlen) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4);
+
+/** Return the total number of frames contained in packet data submitted to
+  * the repacketizer state so far via opus_repacketizer_cat() since the last
+  * call to opus_repacketizer_init() or opus_repacketizer_create().
+  * This defines the valid range of packets that can be extracted with
+  * opus_repacketizer_out_range() or opus_repacketizer_out().
+  * @param rp <tt>OpusRepacketizer*</tt>: The repacketizer state containing the
+  *                                       frames.
+  * @returns The total number of frames contained in the packet data submitted
+  *          to the repacketizer state.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_repacketizer_get_nb_frames(OpusRepacketizer *rp) OPUS_ARG_NONNULL(1);
+
+/** Construct a new packet from data previously submitted to the repacketizer
+  * state via opus_repacketizer_cat().
+  * This is a convenience routine that returns all the data submitted so far
+  * in a single packet.
+  * It is equivalent to calling
+  * @code
+  * opus_repacketizer_out_range(rp, 0, opus_repacketizer_get_nb_frames(rp),
+  *                             data, maxlen)
+  * @endcode
+  * @param rp <tt>OpusRepacketizer*</tt>: The repacketizer state from which to
+  *                                       construct the new packet.
+  * @param[out] data <tt>const unsigned char*</tt>: The buffer in which to
+  *                                                 store the output packet.
+  * @param maxlen <tt>opus_int32</tt>: The maximum number of bytes to store in
+  *                                    the output buffer. In order to guarantee
+  *                                    success, this should be at least
+  *                                    <code>1277*opus_repacketizer_get_nb_frames(rp)</code>.
+  *                                    However,
+  *                                    <code>1*opus_repacketizer_get_nb_frames(rp)</code>
+  *                                    plus the size of all packet data
+  *                                    submitted to the repacketizer since the
+  *                                    last call to opus_repacketizer_init() or
+  *                                    opus_repacketizer_create() is also
+  *                                    sufficient, and possibly much smaller.
+  * @returns The total size of the output packet on success, or an error code
+  *          on failure.
+  * @retval #OPUS_BUFFER_TOO_SMALL \a maxlen was insufficient to contain the
+  *                                complete output packet.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_repacketizer_out(OpusRepacketizer *rp, unsigned char *data, opus_int32 maxlen) OPUS_ARG_NONNULL(1);
+
+/** Pads a given Opus packet to a larger size (possibly changing the TOC sequence).
+  * @param[in,out] data <tt>const unsigned char*</tt>: The buffer containing the
+  *                                                   packet to pad.
+  * @param len <tt>opus_int32</tt>: The size of the packet.
+  *                                 This must be at least 1.
+  * @param new_len <tt>opus_int32</tt>: The desired size of the packet after padding.
+  *                                 This must be at least as large as len.
+  * @returns an error code
+  * @retval #OPUS_OK \a on success.
+  * @retval #OPUS_BAD_ARG \a len was less than 1 or new_len was less than len.
+  * @retval #OPUS_INVALID_PACKET \a data did not contain a valid Opus packet.
+  */
+OPUS_EXPORT int opus_packet_pad(unsigned char *data, opus_int32 len, opus_int32 new_len);
+
+/** Remove all padding from a given Opus packet and rewrite the TOC sequence to
+  * minimize space usage.
+  * @param[in,out] data <tt>const unsigned char*</tt>: The buffer containing the
+  *                                                   packet to strip.
+  * @param len <tt>opus_int32</tt>: The size of the packet.
+  *                                 This must be at least 1.
+  * @returns The new size of the output packet on success, or an error code
+  *          on failure.
+  * @retval #OPUS_BAD_ARG \a len was less than 1.
+  * @retval #OPUS_INVALID_PACKET \a data did not contain a valid Opus packet.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_packet_unpad(unsigned char *data, opus_int32 len);
+
+/** Pads a given Opus multi-stream packet to a larger size (possibly changing the TOC sequence).
+  * @param[in,out] data <tt>const unsigned char*</tt>: The buffer containing the
+  *                                                   packet to pad.
+  * @param len <tt>opus_int32</tt>: The size of the packet.
+  *                                 This must be at least 1.
+  * @param new_len <tt>opus_int32</tt>: The desired size of the packet after padding.
+  *                                 This must be at least 1.
+  * @param nb_streams <tt>opus_int32</tt>: The number of streams (not channels) in the packet.
+  *                                 This must be at least as large as len.
+  * @returns an error code
+  * @retval #OPUS_OK \a on success.
+  * @retval #OPUS_BAD_ARG \a len was less than 1.
+  * @retval #OPUS_INVALID_PACKET \a data did not contain a valid Opus packet.
+  */
+OPUS_EXPORT int opus_multistream_packet_pad(unsigned char *data, opus_int32 len, opus_int32 new_len, int nb_streams);
+
+/** Remove all padding from a given Opus multi-stream packet and rewrite the TOC sequence to
+  * minimize space usage.
+  * @param[in,out] data <tt>const unsigned char*</tt>: The buffer containing the
+  *                                                   packet to strip.
+  * @param len <tt>opus_int32</tt>: The size of the packet.
+  *                                 This must be at least 1.
+  * @param nb_streams <tt>opus_int32</tt>: The number of streams (not channels) in the packet.
+  *                                 This must be at least 1.
+  * @returns The new size of the output packet on success, or an error code
+  *          on failure.
+  * @retval #OPUS_BAD_ARG \a len was less than 1 or new_len was less than len.
+  * @retval #OPUS_INVALID_PACKET \a data did not contain a valid Opus packet.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_multistream_packet_unpad(unsigned char *data, opus_int32 len, int nb_streams);
+
+/**@}*/
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* OPUS_H */

+ 801 - 0
Sources/OpusLib/include/opus/opus_defines.h

@@ -0,0 +1,801 @@
+/* Copyright (c) 2010-2011 Xiph.Org Foundation, Skype Limited
+   Written by Jean-Marc Valin and Koen Vos */
+/*
+   Redistribution and use in source and binary forms, with or without
+   modification, are permitted provided that the following conditions
+   are met:
+
+   - Redistributions of source code must retain the above copyright
+   notice, this list of conditions and the following disclaimer.
+
+   - Redistributions in binary form must reproduce the above copyright
+   notice, this list of conditions and the following disclaimer in the
+   documentation and/or other materials provided with the distribution.
+
+   THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+   ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+   LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+   A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
+   OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+   EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+   PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+   PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+   LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+   NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+   SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+/**
+ * @file opus_defines.h
+ * @brief Opus reference implementation constants
+ */
+
+#ifndef OPUS_DEFINES_H
+#define OPUS_DEFINES_H
+
+#include "opus_types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** @defgroup opus_errorcodes Error codes
+ * @{
+ */
+/** No error @hideinitializer*/
+#define OPUS_OK                0
+/** One or more invalid/out of range arguments @hideinitializer*/
+#define OPUS_BAD_ARG          -1
+/** Not enough bytes allocated in the buffer @hideinitializer*/
+#define OPUS_BUFFER_TOO_SMALL -2
+/** An internal error was detected @hideinitializer*/
+#define OPUS_INTERNAL_ERROR   -3
+/** The compressed data passed is corrupted @hideinitializer*/
+#define OPUS_INVALID_PACKET   -4
+/** Invalid/unsupported request number @hideinitializer*/
+#define OPUS_UNIMPLEMENTED    -5
+/** An encoder or decoder structure is invalid or already freed @hideinitializer*/
+#define OPUS_INVALID_STATE    -6
+/** Memory allocation has failed @hideinitializer*/
+#define OPUS_ALLOC_FAIL       -7
+/**@}*/
+
+/** @cond OPUS_INTERNAL_DOC */
+/**Export control for opus functions */
+
+#ifndef OPUS_EXPORT
+# if defined(_WIN32)
+#  if defined(OPUS_BUILD) && defined(DLL_EXPORT)
+#   define OPUS_EXPORT __declspec(dllexport)
+#  else
+#   define OPUS_EXPORT
+#  endif
+# elif defined(__GNUC__) && defined(OPUS_BUILD)
+#  define OPUS_EXPORT __attribute__ ((visibility ("default")))
+# else
+#  define OPUS_EXPORT
+# endif
+#endif
+
+# if !defined(OPUS_GNUC_PREREQ)
+#  if defined(__GNUC__)&&defined(__GNUC_MINOR__)
+#   define OPUS_GNUC_PREREQ(_maj,_min) \
+ ((__GNUC__<<16)+__GNUC_MINOR__>=((_maj)<<16)+(_min))
+#  else
+#   define OPUS_GNUC_PREREQ(_maj,_min) 0
+#  endif
+# endif
+
+#if (!defined(__STDC_VERSION__) || (__STDC_VERSION__ < 199901L) )
+# if OPUS_GNUC_PREREQ(3,0)
+#  define OPUS_RESTRICT __restrict__
+# elif (defined(_MSC_VER) && _MSC_VER >= 1400)
+#  define OPUS_RESTRICT __restrict
+# else
+#  define OPUS_RESTRICT
+# endif
+#else
+# define OPUS_RESTRICT restrict
+#endif
+
+#if (!defined(__STDC_VERSION__) || (__STDC_VERSION__ < 199901L) )
+# if OPUS_GNUC_PREREQ(2,7)
+#  define OPUS_INLINE __inline__
+# elif (defined(_MSC_VER))
+#  define OPUS_INLINE __inline
+# else
+#  define OPUS_INLINE
+# endif
+#else
+# define OPUS_INLINE inline
+#endif
+
+/**Warning attributes for opus functions
+  * NONNULL is not used in OPUS_BUILD to avoid the compiler optimizing out
+  * some paranoid null checks. */
+#if defined(__GNUC__) && OPUS_GNUC_PREREQ(3, 4)
+# define OPUS_WARN_UNUSED_RESULT __attribute__ ((__warn_unused_result__))
+#else
+# define OPUS_WARN_UNUSED_RESULT
+#endif
+#if !defined(OPUS_BUILD) && defined(__GNUC__) && OPUS_GNUC_PREREQ(3, 4)
+# define OPUS_ARG_NONNULL(_x)  __attribute__ ((__nonnull__(_x)))
+#else
+# define OPUS_ARG_NONNULL(_x)
+#endif
+
+/** These are the actual Encoder CTL ID numbers.
+  * They should not be used directly by applications.
+  * In general, SETs should be even and GETs should be odd.*/
+#define OPUS_SET_APPLICATION_REQUEST         4000
+#define OPUS_GET_APPLICATION_REQUEST         4001
+#define OPUS_SET_BITRATE_REQUEST             4002
+#define OPUS_GET_BITRATE_REQUEST             4003
+#define OPUS_SET_MAX_BANDWIDTH_REQUEST       4004
+#define OPUS_GET_MAX_BANDWIDTH_REQUEST       4005
+#define OPUS_SET_VBR_REQUEST                 4006
+#define OPUS_GET_VBR_REQUEST                 4007
+#define OPUS_SET_BANDWIDTH_REQUEST           4008
+#define OPUS_GET_BANDWIDTH_REQUEST           4009
+#define OPUS_SET_COMPLEXITY_REQUEST          4010
+#define OPUS_GET_COMPLEXITY_REQUEST          4011
+#define OPUS_SET_INBAND_FEC_REQUEST          4012
+#define OPUS_GET_INBAND_FEC_REQUEST          4013
+#define OPUS_SET_PACKET_LOSS_PERC_REQUEST    4014
+#define OPUS_GET_PACKET_LOSS_PERC_REQUEST    4015
+#define OPUS_SET_DTX_REQUEST                 4016
+#define OPUS_GET_DTX_REQUEST                 4017
+#define OPUS_SET_VBR_CONSTRAINT_REQUEST      4020
+#define OPUS_GET_VBR_CONSTRAINT_REQUEST      4021
+#define OPUS_SET_FORCE_CHANNELS_REQUEST      4022
+#define OPUS_GET_FORCE_CHANNELS_REQUEST      4023
+#define OPUS_SET_SIGNAL_REQUEST              4024
+#define OPUS_GET_SIGNAL_REQUEST              4025
+#define OPUS_GET_LOOKAHEAD_REQUEST           4027
+/* #define OPUS_RESET_STATE 4028 */
+#define OPUS_GET_SAMPLE_RATE_REQUEST         4029
+#define OPUS_GET_FINAL_RANGE_REQUEST         4031
+#define OPUS_GET_PITCH_REQUEST               4033
+#define OPUS_SET_GAIN_REQUEST                4034
+#define OPUS_GET_GAIN_REQUEST                4045 /* Should have been 4035 */
+#define OPUS_SET_LSB_DEPTH_REQUEST           4036
+#define OPUS_GET_LSB_DEPTH_REQUEST           4037
+#define OPUS_GET_LAST_PACKET_DURATION_REQUEST 4039
+#define OPUS_SET_EXPERT_FRAME_DURATION_REQUEST 4040
+#define OPUS_GET_EXPERT_FRAME_DURATION_REQUEST 4041
+#define OPUS_SET_PREDICTION_DISABLED_REQUEST 4042
+#define OPUS_GET_PREDICTION_DISABLED_REQUEST 4043
+/* Don't use 4045, it's already taken by OPUS_GET_GAIN_REQUEST */
+#define OPUS_SET_PHASE_INVERSION_DISABLED_REQUEST 4046
+#define OPUS_GET_PHASE_INVERSION_DISABLED_REQUEST 4047
+#define OPUS_GET_IN_DTX_REQUEST              4049
+
+/** Defines for the presence of extended APIs. */
+#define OPUS_HAVE_OPUS_PROJECTION_H
+
+/* Macros to trigger compilation errors when the wrong types are provided to a CTL */
+#define __opus_check_int(x) (((void)((x) == (opus_int32)0)), (opus_int32)(x))
+#define __opus_check_int_ptr(ptr) ((ptr) + ((ptr) - (opus_int32*)(ptr)))
+#define __opus_check_uint_ptr(ptr) ((ptr) + ((ptr) - (opus_uint32*)(ptr)))
+#define __opus_check_val16_ptr(ptr) ((ptr) + ((ptr) - (opus_val16*)(ptr)))
+/** @endcond */
+
+/** @defgroup opus_ctlvalues Pre-defined values for CTL interface
+  * @see opus_genericctls, opus_encoderctls
+  * @{
+  */
+/* Values for the various encoder CTLs */
+#define OPUS_AUTO                           -1000 /**<Auto/default setting @hideinitializer*/
+#define OPUS_BITRATE_MAX                       -1 /**<Maximum bitrate @hideinitializer*/
+
+/** Best for most VoIP/videoconference applications where listening quality and intelligibility matter most
+ * @hideinitializer */
+#define OPUS_APPLICATION_VOIP                2048
+/** Best for broadcast/high-fidelity application where the decoded audio should be as close as possible to the input
+ * @hideinitializer */
+#define OPUS_APPLICATION_AUDIO               2049
+/** Only use when lowest-achievable latency is what matters most. Voice-optimized modes cannot be used.
+ * @hideinitializer */
+#define OPUS_APPLICATION_RESTRICTED_LOWDELAY 2051
+
+#define OPUS_SIGNAL_VOICE                    3001 /**< Signal being encoded is voice */
+#define OPUS_SIGNAL_MUSIC                    3002 /**< Signal being encoded is music */
+#define OPUS_BANDWIDTH_NARROWBAND            1101 /**< 4 kHz bandpass @hideinitializer*/
+#define OPUS_BANDWIDTH_MEDIUMBAND            1102 /**< 6 kHz bandpass @hideinitializer*/
+#define OPUS_BANDWIDTH_WIDEBAND              1103 /**< 8 kHz bandpass @hideinitializer*/
+#define OPUS_BANDWIDTH_SUPERWIDEBAND         1104 /**<12 kHz bandpass @hideinitializer*/
+#define OPUS_BANDWIDTH_FULLBAND              1105 /**<20 kHz bandpass @hideinitializer*/
+
+#define OPUS_FRAMESIZE_ARG                   5000 /**< Select frame size from the argument (default) */
+#define OPUS_FRAMESIZE_2_5_MS                5001 /**< Use 2.5 ms frames */
+#define OPUS_FRAMESIZE_5_MS                  5002 /**< Use 5 ms frames */
+#define OPUS_FRAMESIZE_10_MS                 5003 /**< Use 10 ms frames */
+#define OPUS_FRAMESIZE_20_MS                 5004 /**< Use 20 ms frames */
+#define OPUS_FRAMESIZE_40_MS                 5005 /**< Use 40 ms frames */
+#define OPUS_FRAMESIZE_60_MS                 5006 /**< Use 60 ms frames */
+#define OPUS_FRAMESIZE_80_MS                 5007 /**< Use 80 ms frames */
+#define OPUS_FRAMESIZE_100_MS                5008 /**< Use 100 ms frames */
+#define OPUS_FRAMESIZE_120_MS                5009 /**< Use 120 ms frames */
+
+/**@}*/
+
+
+/** @defgroup opus_encoderctls Encoder related CTLs
+  *
+  * These are convenience macros for use with the \c opus_encode_ctl
+  * interface. They are used to generate the appropriate series of
+  * arguments for that call, passing the correct type, size and so
+  * on as expected for each particular request.
+  *
+  * Some usage examples:
+  *
+  * @code
+  * int ret;
+  * ret = opus_encoder_ctl(enc_ctx, OPUS_SET_BANDWIDTH(OPUS_AUTO));
+  * if (ret != OPUS_OK) return ret;
+  *
+  * opus_int32 rate;
+  * opus_encoder_ctl(enc_ctx, OPUS_GET_BANDWIDTH(&rate));
+  *
+  * opus_encoder_ctl(enc_ctx, OPUS_RESET_STATE);
+  * @endcode
+  *
+  * @see opus_genericctls, opus_encoder
+  * @{
+  */
+
+/** Configures the encoder's computational complexity.
+  * The supported range is 0-10 inclusive with 10 representing the highest complexity.
+  * @see OPUS_GET_COMPLEXITY
+  * @param[in] x <tt>opus_int32</tt>: Allowed values: 0-10, inclusive.
+  *
+  * @hideinitializer */
+#define OPUS_SET_COMPLEXITY(x) OPUS_SET_COMPLEXITY_REQUEST, __opus_check_int(x)
+/** Gets the encoder's complexity configuration.
+  * @see OPUS_SET_COMPLEXITY
+  * @param[out] x <tt>opus_int32 *</tt>: Returns a value in the range 0-10,
+  *                                      inclusive.
+  * @hideinitializer */
+#define OPUS_GET_COMPLEXITY(x) OPUS_GET_COMPLEXITY_REQUEST, __opus_check_int_ptr(x)
+
+/** Configures the bitrate in the encoder.
+  * Rates from 500 to 512000 bits per second are meaningful, as well as the
+  * special values #OPUS_AUTO and #OPUS_BITRATE_MAX.
+  * The value #OPUS_BITRATE_MAX can be used to cause the codec to use as much
+  * rate as it can, which is useful for controlling the rate by adjusting the
+  * output buffer size.
+  * @see OPUS_GET_BITRATE
+  * @param[in] x <tt>opus_int32</tt>: Bitrate in bits per second. The default
+  *                                   is determined based on the number of
+  *                                   channels and the input sampling rate.
+  * @hideinitializer */
+#define OPUS_SET_BITRATE(x) OPUS_SET_BITRATE_REQUEST, __opus_check_int(x)
+/** Gets the encoder's bitrate configuration.
+  * @see OPUS_SET_BITRATE
+  * @param[out] x <tt>opus_int32 *</tt>: Returns the bitrate in bits per second.
+  *                                      The default is determined based on the
+  *                                      number of channels and the input
+  *                                      sampling rate.
+  * @hideinitializer */
+#define OPUS_GET_BITRATE(x) OPUS_GET_BITRATE_REQUEST, __opus_check_int_ptr(x)
+
+/** Enables or disables variable bitrate (VBR) in the encoder.
+  * The configured bitrate may not be met exactly because frames must
+  * be an integer number of bytes in length.
+  * @see OPUS_GET_VBR
+  * @see OPUS_SET_VBR_CONSTRAINT
+  * @param[in] x <tt>opus_int32</tt>: Allowed values:
+  * <dl>
+  * <dt>0</dt><dd>Hard CBR. For LPC/hybrid modes at very low bit-rate, this can
+  *               cause noticeable quality degradation.</dd>
+  * <dt>1</dt><dd>VBR (default). The exact type of VBR is controlled by
+  *               #OPUS_SET_VBR_CONSTRAINT.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_SET_VBR(x) OPUS_SET_VBR_REQUEST, __opus_check_int(x)
+/** Determine if variable bitrate (VBR) is enabled in the encoder.
+  * @see OPUS_SET_VBR
+  * @see OPUS_GET_VBR_CONSTRAINT
+  * @param[out] x <tt>opus_int32 *</tt>: Returns one of the following values:
+  * <dl>
+  * <dt>0</dt><dd>Hard CBR.</dd>
+  * <dt>1</dt><dd>VBR (default). The exact type of VBR may be retrieved via
+  *               #OPUS_GET_VBR_CONSTRAINT.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_GET_VBR(x) OPUS_GET_VBR_REQUEST, __opus_check_int_ptr(x)
+
+/** Enables or disables constrained VBR in the encoder.
+  * This setting is ignored when the encoder is in CBR mode.
+  * @warning Only the MDCT mode of Opus currently heeds the constraint.
+  *  Speech mode ignores it completely, hybrid mode may fail to obey it
+  *  if the LPC layer uses more bitrate than the constraint would have
+  *  permitted.
+  * @see OPUS_GET_VBR_CONSTRAINT
+  * @see OPUS_SET_VBR
+  * @param[in] x <tt>opus_int32</tt>: Allowed values:
+  * <dl>
+  * <dt>0</dt><dd>Unconstrained VBR.</dd>
+  * <dt>1</dt><dd>Constrained VBR (default). This creates a maximum of one
+  *               frame of buffering delay assuming a transport with a
+  *               serialization speed of the nominal bitrate.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_SET_VBR_CONSTRAINT(x) OPUS_SET_VBR_CONSTRAINT_REQUEST, __opus_check_int(x)
+/** Determine if constrained VBR is enabled in the encoder.
+  * @see OPUS_SET_VBR_CONSTRAINT
+  * @see OPUS_GET_VBR
+  * @param[out] x <tt>opus_int32 *</tt>: Returns one of the following values:
+  * <dl>
+  * <dt>0</dt><dd>Unconstrained VBR.</dd>
+  * <dt>1</dt><dd>Constrained VBR (default).</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_GET_VBR_CONSTRAINT(x) OPUS_GET_VBR_CONSTRAINT_REQUEST, __opus_check_int_ptr(x)
+
+/** Configures mono/stereo forcing in the encoder.
+  * This can force the encoder to produce packets encoded as either mono or
+  * stereo, regardless of the format of the input audio. This is useful when
+  * the caller knows that the input signal is currently a mono source embedded
+  * in a stereo stream.
+  * @see OPUS_GET_FORCE_CHANNELS
+  * @param[in] x <tt>opus_int32</tt>: Allowed values:
+  * <dl>
+  * <dt>#OPUS_AUTO</dt><dd>Not forced (default)</dd>
+  * <dt>1</dt>         <dd>Forced mono</dd>
+  * <dt>2</dt>         <dd>Forced stereo</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_SET_FORCE_CHANNELS(x) OPUS_SET_FORCE_CHANNELS_REQUEST, __opus_check_int(x)
+/** Gets the encoder's forced channel configuration.
+  * @see OPUS_SET_FORCE_CHANNELS
+  * @param[out] x <tt>opus_int32 *</tt>:
+  * <dl>
+  * <dt>#OPUS_AUTO</dt><dd>Not forced (default)</dd>
+  * <dt>1</dt>         <dd>Forced mono</dd>
+  * <dt>2</dt>         <dd>Forced stereo</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_GET_FORCE_CHANNELS(x) OPUS_GET_FORCE_CHANNELS_REQUEST, __opus_check_int_ptr(x)
+
+/** Configures the maximum bandpass that the encoder will select automatically.
+  * Applications should normally use this instead of #OPUS_SET_BANDWIDTH
+  * (leaving that set to the default, #OPUS_AUTO). This allows the
+  * application to set an upper bound based on the type of input it is
+  * providing, but still gives the encoder the freedom to reduce the bandpass
+  * when the bitrate becomes too low, for better overall quality.
+  * @see OPUS_GET_MAX_BANDWIDTH
+  * @param[in] x <tt>opus_int32</tt>: Allowed values:
+  * <dl>
+  * <dt>OPUS_BANDWIDTH_NARROWBAND</dt>    <dd>4 kHz passband</dd>
+  * <dt>OPUS_BANDWIDTH_MEDIUMBAND</dt>    <dd>6 kHz passband</dd>
+  * <dt>OPUS_BANDWIDTH_WIDEBAND</dt>      <dd>8 kHz passband</dd>
+  * <dt>OPUS_BANDWIDTH_SUPERWIDEBAND</dt><dd>12 kHz passband</dd>
+  * <dt>OPUS_BANDWIDTH_FULLBAND</dt>     <dd>20 kHz passband (default)</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_SET_MAX_BANDWIDTH(x) OPUS_SET_MAX_BANDWIDTH_REQUEST, __opus_check_int(x)
+
+/** Gets the encoder's configured maximum allowed bandpass.
+  * @see OPUS_SET_MAX_BANDWIDTH
+  * @param[out] x <tt>opus_int32 *</tt>: Allowed values:
+  * <dl>
+  * <dt>#OPUS_BANDWIDTH_NARROWBAND</dt>    <dd>4 kHz passband</dd>
+  * <dt>#OPUS_BANDWIDTH_MEDIUMBAND</dt>    <dd>6 kHz passband</dd>
+  * <dt>#OPUS_BANDWIDTH_WIDEBAND</dt>      <dd>8 kHz passband</dd>
+  * <dt>#OPUS_BANDWIDTH_SUPERWIDEBAND</dt><dd>12 kHz passband</dd>
+  * <dt>#OPUS_BANDWIDTH_FULLBAND</dt>     <dd>20 kHz passband (default)</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_GET_MAX_BANDWIDTH(x) OPUS_GET_MAX_BANDWIDTH_REQUEST, __opus_check_int_ptr(x)
+
+/** Sets the encoder's bandpass to a specific value.
+  * This prevents the encoder from automatically selecting the bandpass based
+  * on the available bitrate. If an application knows the bandpass of the input
+  * audio it is providing, it should normally use #OPUS_SET_MAX_BANDWIDTH
+  * instead, which still gives the encoder the freedom to reduce the bandpass
+  * when the bitrate becomes too low, for better overall quality.
+  * @see OPUS_GET_BANDWIDTH
+  * @param[in] x <tt>opus_int32</tt>: Allowed values:
+  * <dl>
+  * <dt>#OPUS_AUTO</dt>                    <dd>(default)</dd>
+  * <dt>#OPUS_BANDWIDTH_NARROWBAND</dt>    <dd>4 kHz passband</dd>
+  * <dt>#OPUS_BANDWIDTH_MEDIUMBAND</dt>    <dd>6 kHz passband</dd>
+  * <dt>#OPUS_BANDWIDTH_WIDEBAND</dt>      <dd>8 kHz passband</dd>
+  * <dt>#OPUS_BANDWIDTH_SUPERWIDEBAND</dt><dd>12 kHz passband</dd>
+  * <dt>#OPUS_BANDWIDTH_FULLBAND</dt>     <dd>20 kHz passband</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_SET_BANDWIDTH(x) OPUS_SET_BANDWIDTH_REQUEST, __opus_check_int(x)
+
+/** Configures the type of signal being encoded.
+  * This is a hint which helps the encoder's mode selection.
+  * @see OPUS_GET_SIGNAL
+  * @param[in] x <tt>opus_int32</tt>: Allowed values:
+  * <dl>
+  * <dt>#OPUS_AUTO</dt>        <dd>(default)</dd>
+  * <dt>#OPUS_SIGNAL_VOICE</dt><dd>Bias thresholds towards choosing LPC or Hybrid modes.</dd>
+  * <dt>#OPUS_SIGNAL_MUSIC</dt><dd>Bias thresholds towards choosing MDCT modes.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_SET_SIGNAL(x) OPUS_SET_SIGNAL_REQUEST, __opus_check_int(x)
+/** Gets the encoder's configured signal type.
+  * @see OPUS_SET_SIGNAL
+  * @param[out] x <tt>opus_int32 *</tt>: Returns one of the following values:
+  * <dl>
+  * <dt>#OPUS_AUTO</dt>        <dd>(default)</dd>
+  * <dt>#OPUS_SIGNAL_VOICE</dt><dd>Bias thresholds towards choosing LPC or Hybrid modes.</dd>
+  * <dt>#OPUS_SIGNAL_MUSIC</dt><dd>Bias thresholds towards choosing MDCT modes.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_GET_SIGNAL(x) OPUS_GET_SIGNAL_REQUEST, __opus_check_int_ptr(x)
+
+
+/** Configures the encoder's intended application.
+  * The initial value is a mandatory argument to the encoder_create function.
+  * @see OPUS_GET_APPLICATION
+  * @param[in] x <tt>opus_int32</tt>: Returns one of the following values:
+  * <dl>
+  * <dt>#OPUS_APPLICATION_VOIP</dt>
+  * <dd>Process signal for improved speech intelligibility.</dd>
+  * <dt>#OPUS_APPLICATION_AUDIO</dt>
+  * <dd>Favor faithfulness to the original input.</dd>
+  * <dt>#OPUS_APPLICATION_RESTRICTED_LOWDELAY</dt>
+  * <dd>Configure the minimum possible coding delay by disabling certain modes
+  * of operation.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_SET_APPLICATION(x) OPUS_SET_APPLICATION_REQUEST, __opus_check_int(x)
+/** Gets the encoder's configured application.
+  * @see OPUS_SET_APPLICATION
+  * @param[out] x <tt>opus_int32 *</tt>: Returns one of the following values:
+  * <dl>
+  * <dt>#OPUS_APPLICATION_VOIP</dt>
+  * <dd>Process signal for improved speech intelligibility.</dd>
+  * <dt>#OPUS_APPLICATION_AUDIO</dt>
+  * <dd>Favor faithfulness to the original input.</dd>
+  * <dt>#OPUS_APPLICATION_RESTRICTED_LOWDELAY</dt>
+  * <dd>Configure the minimum possible coding delay by disabling certain modes
+  * of operation.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_GET_APPLICATION(x) OPUS_GET_APPLICATION_REQUEST, __opus_check_int_ptr(x)
+
+/** Gets the total samples of delay added by the entire codec.
+  * This can be queried by the encoder and then the provided number of samples can be
+  * skipped on from the start of the decoder's output to provide time aligned input
+  * and output. From the perspective of a decoding application the real data begins this many
+  * samples late.
+  *
+  * The decoder contribution to this delay is identical for all decoders, but the
+  * encoder portion of the delay may vary from implementation to implementation,
+  * version to version, or even depend on the encoder's initial configuration.
+  * Applications needing delay compensation should call this CTL rather than
+  * hard-coding a value.
+  * @param[out] x <tt>opus_int32 *</tt>:   Number of lookahead samples
+  * @hideinitializer */
+#define OPUS_GET_LOOKAHEAD(x) OPUS_GET_LOOKAHEAD_REQUEST, __opus_check_int_ptr(x)
+
+/** Configures the encoder's use of inband forward error correction (FEC).
+  * @note This is only applicable to the LPC layer
+  * @see OPUS_GET_INBAND_FEC
+  * @param[in] x <tt>opus_int32</tt>: Allowed values:
+  * <dl>
+  * <dt>0</dt><dd>Disable inband FEC (default).</dd>
+  * <dt>1</dt><dd>Inband FEC enabled. If the packet loss rate is sufficiently high, Opus will automatically switch to SILK even at high rates to enable use of that FEC.</dd>
+  * <dt>2</dt><dd>Inband FEC enabled, but does not necessarily switch to SILK if we have music.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_SET_INBAND_FEC(x) OPUS_SET_INBAND_FEC_REQUEST, __opus_check_int(x)
+/** Gets encoder's configured use of inband forward error correction.
+  * @see OPUS_SET_INBAND_FEC
+  * @param[out] x <tt>opus_int32 *</tt>: Returns one of the following values:
+  * <dl>
+  * <dt>0</dt><dd>Inband FEC disabled (default).</dd>
+  * <dt>1</dt><dd>Inband FEC enabled. If the packet loss rate is sufficiently high, Opus will automatically switch to SILK even at high rates to enable use of that FEC.</dd>
+  * <dt>2</dt><dd>Inband FEC enabled, but does not necessarily switch to SILK if we have music.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_GET_INBAND_FEC(x) OPUS_GET_INBAND_FEC_REQUEST, __opus_check_int_ptr(x)
+
+/** Configures the encoder's expected packet loss percentage.
+  * Higher values trigger progressively more loss resistant behavior in the encoder
+  * at the expense of quality at a given bitrate in the absence of packet loss, but
+  * greater quality under loss.
+  * @see OPUS_GET_PACKET_LOSS_PERC
+  * @param[in] x <tt>opus_int32</tt>:   Loss percentage in the range 0-100, inclusive (default: 0).
+  * @hideinitializer */
+#define OPUS_SET_PACKET_LOSS_PERC(x) OPUS_SET_PACKET_LOSS_PERC_REQUEST, __opus_check_int(x)
+/** Gets the encoder's configured packet loss percentage.
+  * @see OPUS_SET_PACKET_LOSS_PERC
+  * @param[out] x <tt>opus_int32 *</tt>: Returns the configured loss percentage
+  *                                      in the range 0-100, inclusive (default: 0).
+  * @hideinitializer */
+#define OPUS_GET_PACKET_LOSS_PERC(x) OPUS_GET_PACKET_LOSS_PERC_REQUEST, __opus_check_int_ptr(x)
+
+/** Configures the encoder's use of discontinuous transmission (DTX).
+  * @note This is only applicable to the LPC layer
+  * @see OPUS_GET_DTX
+  * @param[in] x <tt>opus_int32</tt>: Allowed values:
+  * <dl>
+  * <dt>0</dt><dd>Disable DTX (default).</dd>
+  * <dt>1</dt><dd>Enabled DTX.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_SET_DTX(x) OPUS_SET_DTX_REQUEST, __opus_check_int(x)
+/** Gets encoder's configured use of discontinuous transmission.
+  * @see OPUS_SET_DTX
+  * @param[out] x <tt>opus_int32 *</tt>: Returns one of the following values:
+  * <dl>
+  * <dt>0</dt><dd>DTX disabled (default).</dd>
+  * <dt>1</dt><dd>DTX enabled.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_GET_DTX(x) OPUS_GET_DTX_REQUEST, __opus_check_int_ptr(x)
+/** Configures the depth of signal being encoded.
+  *
+  * This is a hint which helps the encoder identify silence and near-silence.
+  * It represents the number of significant bits of linear intensity below
+  * which the signal contains ignorable quantization or other noise.
+  *
+  * For example, OPUS_SET_LSB_DEPTH(14) would be an appropriate setting
+  * for G.711 u-law input. OPUS_SET_LSB_DEPTH(16) would be appropriate
+  * for 16-bit linear pcm input with opus_encode_float().
+  *
+  * When using opus_encode() instead of opus_encode_float(), or when libopus
+  * is compiled for fixed-point, the encoder uses the minimum of the value
+  * set here and the value 16.
+  *
+  * @see OPUS_GET_LSB_DEPTH
+  * @param[in] x <tt>opus_int32</tt>: Input precision in bits, between 8 and 24
+  *                                   (default: 24).
+  * @hideinitializer */
+#define OPUS_SET_LSB_DEPTH(x) OPUS_SET_LSB_DEPTH_REQUEST, __opus_check_int(x)
+/** Gets the encoder's configured signal depth.
+  * @see OPUS_SET_LSB_DEPTH
+  * @param[out] x <tt>opus_int32 *</tt>: Input precision in bits, between 8 and
+  *                                      24 (default: 24).
+  * @hideinitializer */
+#define OPUS_GET_LSB_DEPTH(x) OPUS_GET_LSB_DEPTH_REQUEST, __opus_check_int_ptr(x)
+
+/** Configures the encoder's use of variable duration frames.
+  * When variable duration is enabled, the encoder is free to use a shorter frame
+  * size than the one requested in the opus_encode*() call.
+  * It is then the user's responsibility
+  * to verify how much audio was encoded by checking the ToC byte of the encoded
+  * packet. The part of the audio that was not encoded needs to be resent to the
+  * encoder for the next call. Do not use this option unless you <b>really</b>
+  * know what you are doing.
+  * @see OPUS_GET_EXPERT_FRAME_DURATION
+  * @param[in] x <tt>opus_int32</tt>: Allowed values:
+  * <dl>
+  * <dt>OPUS_FRAMESIZE_ARG</dt><dd>Select frame size from the argument (default).</dd>
+  * <dt>OPUS_FRAMESIZE_2_5_MS</dt><dd>Use 2.5 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_5_MS</dt><dd>Use 5 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_10_MS</dt><dd>Use 10 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_20_MS</dt><dd>Use 20 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_40_MS</dt><dd>Use 40 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_60_MS</dt><dd>Use 60 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_80_MS</dt><dd>Use 80 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_100_MS</dt><dd>Use 100 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_120_MS</dt><dd>Use 120 ms frames.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_SET_EXPERT_FRAME_DURATION(x) OPUS_SET_EXPERT_FRAME_DURATION_REQUEST, __opus_check_int(x)
+/** Gets the encoder's configured use of variable duration frames.
+  * @see OPUS_SET_EXPERT_FRAME_DURATION
+  * @param[out] x <tt>opus_int32 *</tt>: Returns one of the following values:
+  * <dl>
+  * <dt>OPUS_FRAMESIZE_ARG</dt><dd>Select frame size from the argument (default).</dd>
+  * <dt>OPUS_FRAMESIZE_2_5_MS</dt><dd>Use 2.5 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_5_MS</dt><dd>Use 5 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_10_MS</dt><dd>Use 10 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_20_MS</dt><dd>Use 20 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_40_MS</dt><dd>Use 40 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_60_MS</dt><dd>Use 60 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_80_MS</dt><dd>Use 80 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_100_MS</dt><dd>Use 100 ms frames.</dd>
+  * <dt>OPUS_FRAMESIZE_120_MS</dt><dd>Use 120 ms frames.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_GET_EXPERT_FRAME_DURATION(x) OPUS_GET_EXPERT_FRAME_DURATION_REQUEST, __opus_check_int_ptr(x)
+
+/** If set to 1, disables almost all use of prediction, making frames almost
+  * completely independent. This reduces quality.
+  * @see OPUS_GET_PREDICTION_DISABLED
+  * @param[in] x <tt>opus_int32</tt>: Allowed values:
+  * <dl>
+  * <dt>0</dt><dd>Enable prediction (default).</dd>
+  * <dt>1</dt><dd>Disable prediction.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_SET_PREDICTION_DISABLED(x) OPUS_SET_PREDICTION_DISABLED_REQUEST, __opus_check_int(x)
+/** Gets the encoder's configured prediction status.
+  * @see OPUS_SET_PREDICTION_DISABLED
+  * @param[out] x <tt>opus_int32 *</tt>: Returns one of the following values:
+  * <dl>
+  * <dt>0</dt><dd>Prediction enabled (default).</dd>
+  * <dt>1</dt><dd>Prediction disabled.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_GET_PREDICTION_DISABLED(x) OPUS_GET_PREDICTION_DISABLED_REQUEST, __opus_check_int_ptr(x)
+
+/**@}*/
+
+/** @defgroup opus_genericctls Generic CTLs
+  *
+  * These macros are used with the \c opus_decoder_ctl and
+  * \c opus_encoder_ctl calls to generate a particular
+  * request.
+  *
+  * When called on an \c OpusDecoder they apply to that
+  * particular decoder instance. When called on an
+  * \c OpusEncoder they apply to the corresponding setting
+  * on that encoder instance, if present.
+  *
+  * Some usage examples:
+  *
+  * @code
+  * int ret;
+  * opus_int32 pitch;
+  * ret = opus_decoder_ctl(dec_ctx, OPUS_GET_PITCH(&pitch));
+  * if (ret == OPUS_OK) return ret;
+  *
+  * opus_encoder_ctl(enc_ctx, OPUS_RESET_STATE);
+  * opus_decoder_ctl(dec_ctx, OPUS_RESET_STATE);
+  *
+  * opus_int32 enc_bw, dec_bw;
+  * opus_encoder_ctl(enc_ctx, OPUS_GET_BANDWIDTH(&enc_bw));
+  * opus_decoder_ctl(dec_ctx, OPUS_GET_BANDWIDTH(&dec_bw));
+  * if (enc_bw != dec_bw) {
+  *   printf("packet bandwidth mismatch!\n");
+  * }
+  * @endcode
+  *
+  * @see opus_encoder, opus_decoder_ctl, opus_encoder_ctl, opus_decoderctls, opus_encoderctls
+  * @{
+  */
+
+/** Resets the codec state to be equivalent to a freshly initialized state.
+  * This should be called when switching streams in order to prevent
+  * the back to back decoding from giving different results from
+  * one at a time decoding.
+  * @hideinitializer */
+#define OPUS_RESET_STATE 4028
+
+/** Gets the final state of the codec's entropy coder.
+  * This is used for testing purposes,
+  * The encoder and decoder state should be identical after coding a payload
+  * (assuming no data corruption or software bugs)
+  *
+  * @param[out] x <tt>opus_uint32 *</tt>: Entropy coder state
+  *
+  * @hideinitializer */
+#define OPUS_GET_FINAL_RANGE(x) OPUS_GET_FINAL_RANGE_REQUEST, __opus_check_uint_ptr(x)
+
+/** Gets the encoder's configured bandpass or the decoder's last bandpass.
+  * @see OPUS_SET_BANDWIDTH
+  * @param[out] x <tt>opus_int32 *</tt>: Returns one of the following values:
+  * <dl>
+  * <dt>#OPUS_AUTO</dt>                    <dd>(default)</dd>
+  * <dt>#OPUS_BANDWIDTH_NARROWBAND</dt>    <dd>4 kHz passband</dd>
+  * <dt>#OPUS_BANDWIDTH_MEDIUMBAND</dt>    <dd>6 kHz passband</dd>
+  * <dt>#OPUS_BANDWIDTH_WIDEBAND</dt>      <dd>8 kHz passband</dd>
+  * <dt>#OPUS_BANDWIDTH_SUPERWIDEBAND</dt><dd>12 kHz passband</dd>
+  * <dt>#OPUS_BANDWIDTH_FULLBAND</dt>     <dd>20 kHz passband</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_GET_BANDWIDTH(x) OPUS_GET_BANDWIDTH_REQUEST, __opus_check_int_ptr(x)
+
+/** Gets the sampling rate the encoder or decoder was initialized with.
+  * This simply returns the <code>Fs</code> value passed to opus_encoder_init()
+  * or opus_decoder_init().
+  * @param[out] x <tt>opus_int32 *</tt>: Sampling rate of encoder or decoder.
+  * @hideinitializer
+  */
+#define OPUS_GET_SAMPLE_RATE(x) OPUS_GET_SAMPLE_RATE_REQUEST, __opus_check_int_ptr(x)
+
+/** If set to 1, disables the use of phase inversion for intensity stereo,
+  * improving the quality of mono downmixes, but slightly reducing normal
+  * stereo quality. Disabling phase inversion in the decoder does not comply
+  * with RFC 6716, although it does not cause any interoperability issue and
+  * is expected to become part of the Opus standard once RFC 6716 is updated
+  * by draft-ietf-codec-opus-update.
+  * @see OPUS_GET_PHASE_INVERSION_DISABLED
+  * @param[in] x <tt>opus_int32</tt>: Allowed values:
+  * <dl>
+  * <dt>0</dt><dd>Enable phase inversion (default).</dd>
+  * <dt>1</dt><dd>Disable phase inversion.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_SET_PHASE_INVERSION_DISABLED(x) OPUS_SET_PHASE_INVERSION_DISABLED_REQUEST, __opus_check_int(x)
+/** Gets the encoder's configured phase inversion status.
+  * @see OPUS_SET_PHASE_INVERSION_DISABLED
+  * @param[out] x <tt>opus_int32 *</tt>: Returns one of the following values:
+  * <dl>
+  * <dt>0</dt><dd>Stereo phase inversion enabled (default).</dd>
+  * <dt>1</dt><dd>Stereo phase inversion disabled.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_GET_PHASE_INVERSION_DISABLED(x) OPUS_GET_PHASE_INVERSION_DISABLED_REQUEST, __opus_check_int_ptr(x)
+/** Gets the DTX state of the encoder.
+  * Returns whether the last encoded frame was either a comfort noise update
+  * during DTX or not encoded because of DTX.
+  * @param[out] x <tt>opus_int32 *</tt>: Returns one of the following values:
+  * <dl>
+  * <dt>0</dt><dd>The encoder is not in DTX.</dd>
+  * <dt>1</dt><dd>The encoder is in DTX.</dd>
+  * </dl>
+  * @hideinitializer */
+#define OPUS_GET_IN_DTX(x) OPUS_GET_IN_DTX_REQUEST, __opus_check_int_ptr(x)
+
+/**@}*/
+
+/** @defgroup opus_decoderctls Decoder related CTLs
+  * @see opus_genericctls, opus_encoderctls, opus_decoder
+  * @{
+  */
+
+/** Configures decoder gain adjustment.
+  * Scales the decoded output by a factor specified in Q8 dB units.
+  * This has a maximum range of -32768 to 32767 inclusive, and returns
+  * OPUS_BAD_ARG otherwise. The default is zero indicating no adjustment.
+  * This setting survives decoder reset.
+  *
+  * gain = pow(10, x/(20.0*256))
+  *
+  * @param[in] x <tt>opus_int32</tt>:   Amount to scale PCM signal by in Q8 dB units.
+  * @hideinitializer */
+#define OPUS_SET_GAIN(x) OPUS_SET_GAIN_REQUEST, __opus_check_int(x)
+/** Gets the decoder's configured gain adjustment. @see OPUS_SET_GAIN
+  *
+  * @param[out] x <tt>opus_int32 *</tt>: Amount to scale PCM signal by in Q8 dB units.
+  * @hideinitializer */
+#define OPUS_GET_GAIN(x) OPUS_GET_GAIN_REQUEST, __opus_check_int_ptr(x)
+
+/** Gets the duration (in samples) of the last packet successfully decoded or concealed.
+  * @param[out] x <tt>opus_int32 *</tt>: Number of samples (at current sampling rate).
+  * @hideinitializer */
+#define OPUS_GET_LAST_PACKET_DURATION(x) OPUS_GET_LAST_PACKET_DURATION_REQUEST, __opus_check_int_ptr(x)
+
+/** Gets the pitch of the last decoded frame, if available.
+  * This can be used for any post-processing algorithm requiring the use of pitch,
+  * e.g. time stretching/shortening. If the last frame was not voiced, or if the
+  * pitch was not coded in the frame, then zero is returned.
+  *
+  * This CTL is only implemented for decoder instances.
+  *
+  * @param[out] x <tt>opus_int32 *</tt>: pitch period at 48 kHz (or 0 if not available)
+  *
+  * @hideinitializer */
+#define OPUS_GET_PITCH(x) OPUS_GET_PITCH_REQUEST, __opus_check_int_ptr(x)
+
+/**@}*/
+
+/** @defgroup opus_libinfo Opus library information functions
+  * @{
+  */
+
+/** Converts an opus error code into a human readable string.
+  *
+  * @param[in] error <tt>int</tt>: Error number
+  * @returns Error string
+  */
+OPUS_EXPORT const char *opus_strerror(int error);
+
+/** Gets the libopus version string.
+  *
+  * Applications may look for the substring "-fixed" in the version string to
+  * determine whether they have a fixed-point or floating-point build at
+  * runtime.
+  *
+  * @returns Version string
+  */
+OPUS_EXPORT const char *opus_get_version_string(void);
+/**@}*/
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* OPUS_DEFINES_H */

+ 660 - 0
Sources/OpusLib/include/opus/opus_multistream.h

@@ -0,0 +1,660 @@
+/* Copyright (c) 2011 Xiph.Org Foundation
+   Written by Jean-Marc Valin */
+/*
+   Redistribution and use in source and binary forms, with or without
+   modification, are permitted provided that the following conditions
+   are met:
+
+   - Redistributions of source code must retain the above copyright
+   notice, this list of conditions and the following disclaimer.
+
+   - Redistributions in binary form must reproduce the above copyright
+   notice, this list of conditions and the following disclaimer in the
+   documentation and/or other materials provided with the distribution.
+
+   THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+   ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+   LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+   A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
+   OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+   EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+   PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+   PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+   LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+   NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+   SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+/**
+ * @file opus_multistream.h
+ * @brief Opus reference implementation multistream API
+ */
+
+#ifndef OPUS_MULTISTREAM_H
+#define OPUS_MULTISTREAM_H
+
+#include "opus.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** @cond OPUS_INTERNAL_DOC */
+
+/** Macros to trigger compilation errors when the wrong types are provided to a
+  * CTL. */
+/**@{*/
+#define __opus_check_encstate_ptr(ptr) ((ptr) + ((ptr) - (OpusEncoder**)(ptr)))
+#define __opus_check_decstate_ptr(ptr) ((ptr) + ((ptr) - (OpusDecoder**)(ptr)))
+/**@}*/
+
+/** These are the actual encoder and decoder CTL ID numbers.
+  * They should not be used directly by applications.
+  * In general, SETs should be even and GETs should be odd.*/
+/**@{*/
+#define OPUS_MULTISTREAM_GET_ENCODER_STATE_REQUEST 5120
+#define OPUS_MULTISTREAM_GET_DECODER_STATE_REQUEST 5122
+/**@}*/
+
+/** @endcond */
+
+/** @defgroup opus_multistream_ctls Multistream specific encoder and decoder CTLs
+  *
+  * These are convenience macros that are specific to the
+  * opus_multistream_encoder_ctl() and opus_multistream_decoder_ctl()
+  * interface.
+  * The CTLs from @ref opus_genericctls, @ref opus_encoderctls, and
+  * @ref opus_decoderctls may be applied to a multistream encoder or decoder as
+  * well.
+  * In addition, you may retrieve the encoder or decoder state for an specific
+  * stream via #OPUS_MULTISTREAM_GET_ENCODER_STATE or
+  * #OPUS_MULTISTREAM_GET_DECODER_STATE and apply CTLs to it individually.
+  */
+/**@{*/
+
+/** Gets the encoder state for an individual stream of a multistream encoder.
+  * @param[in] x <tt>opus_int32</tt>: The index of the stream whose encoder you
+  *                                   wish to retrieve.
+  *                                   This must be non-negative and less than
+  *                                   the <code>streams</code> parameter used
+  *                                   to initialize the encoder.
+  * @param[out] y <tt>OpusEncoder**</tt>: Returns a pointer to the given
+  *                                       encoder state.
+  * @retval OPUS_BAD_ARG The index of the requested stream was out of range.
+  * @hideinitializer
+  */
+#define OPUS_MULTISTREAM_GET_ENCODER_STATE(x,y) OPUS_MULTISTREAM_GET_ENCODER_STATE_REQUEST, __opus_check_int(x), __opus_check_encstate_ptr(y)
+
+/** Gets the decoder state for an individual stream of a multistream decoder.
+  * @param[in] x <tt>opus_int32</tt>: The index of the stream whose decoder you
+  *                                   wish to retrieve.
+  *                                   This must be non-negative and less than
+  *                                   the <code>streams</code> parameter used
+  *                                   to initialize the decoder.
+  * @param[out] y <tt>OpusDecoder**</tt>: Returns a pointer to the given
+  *                                       decoder state.
+  * @retval OPUS_BAD_ARG The index of the requested stream was out of range.
+  * @hideinitializer
+  */
+#define OPUS_MULTISTREAM_GET_DECODER_STATE(x,y) OPUS_MULTISTREAM_GET_DECODER_STATE_REQUEST, __opus_check_int(x), __opus_check_decstate_ptr(y)
+
+/**@}*/
+
+/** @defgroup opus_multistream Opus Multistream API
+  * @{
+  *
+  * The multistream API allows individual Opus streams to be combined into a
+  * single packet, enabling support for up to 255 channels. Unlike an
+  * elementary Opus stream, the encoder and decoder must negotiate the channel
+  * configuration before the decoder can successfully interpret the data in the
+  * packets produced by the encoder. Some basic information, such as packet
+  * duration, can be computed without any special negotiation.
+  *
+  * The format for multistream Opus packets is defined in
+  * <a href="https://tools.ietf.org/html/rfc7845">RFC 7845</a>
+  * and is based on the self-delimited Opus framing described in Appendix B of
+  * <a href="https://tools.ietf.org/html/rfc6716">RFC 6716</a>.
+  * Normal Opus packets are just a degenerate case of multistream Opus packets,
+  * and can be encoded or decoded with the multistream API by setting
+  * <code>streams</code> to <code>1</code> when initializing the encoder or
+  * decoder.
+  *
+  * Multistream Opus streams can contain up to 255 elementary Opus streams.
+  * These may be either "uncoupled" or "coupled", indicating that the decoder
+  * is configured to decode them to either 1 or 2 channels, respectively.
+  * The streams are ordered so that all coupled streams appear at the
+  * beginning.
+  *
+  * A <code>mapping</code> table defines which decoded channel <code>i</code>
+  * should be used for each input/output (I/O) channel <code>j</code>. This table is
+  * typically provided as an unsigned char array.
+  * Let <code>i = mapping[j]</code> be the index for I/O channel <code>j</code>.
+  * If <code>i < 2*coupled_streams</code>, then I/O channel <code>j</code> is
+  * encoded as the left channel of stream <code>(i/2)</code> if <code>i</code>
+  * is even, or  as the right channel of stream <code>(i/2)</code> if
+  * <code>i</code> is odd. Otherwise, I/O channel <code>j</code> is encoded as
+  * mono in stream <code>(i - coupled_streams)</code>, unless it has the special
+  * value 255, in which case it is omitted from the encoding entirely (the
+  * decoder will reproduce it as silence). Each value <code>i</code> must either
+  * be the special value 255 or be less than <code>streams + coupled_streams</code>.
+  *
+  * The output channels specified by the encoder
+  * should use the
+  * <a href="https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-810004.3.9">Vorbis
+  * channel ordering</a>. A decoder may wish to apply an additional permutation
+  * to the mapping the encoder used to achieve a different output channel
+  * order (e.g. for outputing in WAV order).
+  *
+  * Each multistream packet contains an Opus packet for each stream, and all of
+  * the Opus packets in a single multistream packet must have the same
+  * duration. Therefore the duration of a multistream packet can be extracted
+  * from the TOC sequence of the first stream, which is located at the
+  * beginning of the packet, just like an elementary Opus stream:
+  *
+  * @code
+  * int nb_samples;
+  * int nb_frames;
+  * nb_frames = opus_packet_get_nb_frames(data, len);
+  * if (nb_frames < 1)
+  *   return nb_frames;
+  * nb_samples = opus_packet_get_samples_per_frame(data, 48000) * nb_frames;
+  * @endcode
+  *
+  * The general encoding and decoding process proceeds exactly the same as in
+  * the normal @ref opus_encoder and @ref opus_decoder APIs.
+  * See their documentation for an overview of how to use the corresponding
+  * multistream functions.
+  */
+
+/** Opus multistream encoder state.
+  * This contains the complete state of a multistream Opus encoder.
+  * It is position independent and can be freely copied.
+  * @see opus_multistream_encoder_create
+  * @see opus_multistream_encoder_init
+  */
+typedef struct OpusMSEncoder OpusMSEncoder;
+
+/** Opus multistream decoder state.
+  * This contains the complete state of a multistream Opus decoder.
+  * It is position independent and can be freely copied.
+  * @see opus_multistream_decoder_create
+  * @see opus_multistream_decoder_init
+  */
+typedef struct OpusMSDecoder OpusMSDecoder;
+
+/**\name Multistream encoder functions */
+/**@{*/
+
+/** Gets the size of an OpusMSEncoder structure.
+  * @param streams <tt>int</tt>: The total number of streams to encode from the
+  *                              input.
+  *                              This must be no more than 255.
+  * @param coupled_streams <tt>int</tt>: Number of coupled (2 channel) streams
+  *                                      to encode.
+  *                                      This must be no larger than the total
+  *                                      number of streams.
+  *                                      Additionally, The total number of
+  *                                      encoded channels (<code>streams +
+  *                                      coupled_streams</code>) must be no
+  *                                      more than 255.
+  * @returns The size in bytes on success, or a negative error code
+  *          (see @ref opus_errorcodes) on error.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_multistream_encoder_get_size(
+      int streams,
+      int coupled_streams
+);
+
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_multistream_surround_encoder_get_size(
+      int channels,
+      int mapping_family
+);
+
+
+/** Allocates and initializes a multistream encoder state.
+  * Call opus_multistream_encoder_destroy() to release
+  * this object when finished.
+  * @param Fs <tt>opus_int32</tt>: Sampling rate of the input signal (in Hz).
+  *                                This must be one of 8000, 12000, 16000,
+  *                                24000, or 48000.
+  * @param channels <tt>int</tt>: Number of channels in the input signal.
+  *                               This must be at most 255.
+  *                               It may be greater than the number of
+  *                               coded channels (<code>streams +
+  *                               coupled_streams</code>).
+  * @param streams <tt>int</tt>: The total number of streams to encode from the
+  *                              input.
+  *                              This must be no more than the number of channels.
+  * @param coupled_streams <tt>int</tt>: Number of coupled (2 channel) streams
+  *                                      to encode.
+  *                                      This must be no larger than the total
+  *                                      number of streams.
+  *                                      Additionally, The total number of
+  *                                      encoded channels (<code>streams +
+  *                                      coupled_streams</code>) must be no
+  *                                      more than the number of input channels.
+  * @param[in] mapping <code>const unsigned char[channels]</code>: Mapping from
+  *                    encoded channels to input channels, as described in
+  *                    @ref opus_multistream. As an extra constraint, the
+  *                    multistream encoder does not allow encoding coupled
+  *                    streams for which one channel is unused since this
+  *                    is never a good idea.
+  * @param application <tt>int</tt>: The target encoder application.
+  *                                  This must be one of the following:
+  * <dl>
+  * <dt>#OPUS_APPLICATION_VOIP</dt>
+  * <dd>Process signal for improved speech intelligibility.</dd>
+  * <dt>#OPUS_APPLICATION_AUDIO</dt>
+  * <dd>Favor faithfulness to the original input.</dd>
+  * <dt>#OPUS_APPLICATION_RESTRICTED_LOWDELAY</dt>
+  * <dd>Configure the minimum possible coding delay by disabling certain modes
+  * of operation.</dd>
+  * </dl>
+  * @param[out] error <tt>int *</tt>: Returns #OPUS_OK on success, or an error
+  *                                   code (see @ref opus_errorcodes) on
+  *                                   failure.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusMSEncoder *opus_multistream_encoder_create(
+      opus_int32 Fs,
+      int channels,
+      int streams,
+      int coupled_streams,
+      const unsigned char *mapping,
+      int application,
+      int *error
+) OPUS_ARG_NONNULL(5);
+
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusMSEncoder *opus_multistream_surround_encoder_create(
+      opus_int32 Fs,
+      int channels,
+      int mapping_family,
+      int *streams,
+      int *coupled_streams,
+      unsigned char *mapping,
+      int application,
+      int *error
+) OPUS_ARG_NONNULL(4) OPUS_ARG_NONNULL(5) OPUS_ARG_NONNULL(6);
+
+/** Initialize a previously allocated multistream encoder state.
+  * The memory pointed to by \a st must be at least the size returned by
+  * opus_multistream_encoder_get_size().
+  * This is intended for applications which use their own allocator instead of
+  * malloc.
+  * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL.
+  * @see opus_multistream_encoder_create
+  * @see opus_multistream_encoder_get_size
+  * @param st <tt>OpusMSEncoder*</tt>: Multistream encoder state to initialize.
+  * @param Fs <tt>opus_int32</tt>: Sampling rate of the input signal (in Hz).
+  *                                This must be one of 8000, 12000, 16000,
+  *                                24000, or 48000.
+  * @param channels <tt>int</tt>: Number of channels in the input signal.
+  *                               This must be at most 255.
+  *                               It may be greater than the number of
+  *                               coded channels (<code>streams +
+  *                               coupled_streams</code>).
+  * @param streams <tt>int</tt>: The total number of streams to encode from the
+  *                              input.
+  *                              This must be no more than the number of channels.
+  * @param coupled_streams <tt>int</tt>: Number of coupled (2 channel) streams
+  *                                      to encode.
+  *                                      This must be no larger than the total
+  *                                      number of streams.
+  *                                      Additionally, The total number of
+  *                                      encoded channels (<code>streams +
+  *                                      coupled_streams</code>) must be no
+  *                                      more than the number of input channels.
+  * @param[in] mapping <code>const unsigned char[channels]</code>: Mapping from
+  *                    encoded channels to input channels, as described in
+  *                    @ref opus_multistream. As an extra constraint, the
+  *                    multistream encoder does not allow encoding coupled
+  *                    streams for which one channel is unused since this
+  *                    is never a good idea.
+  * @param application <tt>int</tt>: The target encoder application.
+  *                                  This must be one of the following:
+  * <dl>
+  * <dt>#OPUS_APPLICATION_VOIP</dt>
+  * <dd>Process signal for improved speech intelligibility.</dd>
+  * <dt>#OPUS_APPLICATION_AUDIO</dt>
+  * <dd>Favor faithfulness to the original input.</dd>
+  * <dt>#OPUS_APPLICATION_RESTRICTED_LOWDELAY</dt>
+  * <dd>Configure the minimum possible coding delay by disabling certain modes
+  * of operation.</dd>
+  * </dl>
+  * @returns #OPUS_OK on success, or an error code (see @ref opus_errorcodes)
+  *          on failure.
+  */
+OPUS_EXPORT int opus_multistream_encoder_init(
+      OpusMSEncoder *st,
+      opus_int32 Fs,
+      int channels,
+      int streams,
+      int coupled_streams,
+      const unsigned char *mapping,
+      int application
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(6);
+
+OPUS_EXPORT int opus_multistream_surround_encoder_init(
+      OpusMSEncoder *st,
+      opus_int32 Fs,
+      int channels,
+      int mapping_family,
+      int *streams,
+      int *coupled_streams,
+      unsigned char *mapping,
+      int application
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(5) OPUS_ARG_NONNULL(6) OPUS_ARG_NONNULL(7);
+
+/** Encodes a multistream Opus frame.
+  * @param st <tt>OpusMSEncoder*</tt>: Multistream encoder state.
+  * @param[in] pcm <tt>const opus_int16*</tt>: The input signal as interleaved
+  *                                            samples.
+  *                                            This must contain
+  *                                            <code>frame_size*channels</code>
+  *                                            samples.
+  * @param frame_size <tt>int</tt>: Number of samples per channel in the input
+  *                                 signal.
+  *                                 This must be an Opus frame size for the
+  *                                 encoder's sampling rate.
+  *                                 For example, at 48 kHz the permitted values
+  *                                 are 120, 240, 480, 960, 1920, and 2880.
+  *                                 Passing in a duration of less than 10 ms
+  *                                 (480 samples at 48 kHz) will prevent the
+  *                                 encoder from using the LPC or hybrid modes.
+  * @param[out] data <tt>unsigned char*</tt>: Output payload.
+  *                                           This must contain storage for at
+  *                                           least \a max_data_bytes.
+  * @param [in] max_data_bytes <tt>opus_int32</tt>: Size of the allocated
+  *                                                 memory for the output
+  *                                                 payload. This may be
+  *                                                 used to impose an upper limit on
+  *                                                 the instant bitrate, but should
+  *                                                 not be used as the only bitrate
+  *                                                 control. Use #OPUS_SET_BITRATE to
+  *                                                 control the bitrate.
+  * @returns The length of the encoded packet (in bytes) on success or a
+  *          negative error code (see @ref opus_errorcodes) on failure.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_encode(
+    OpusMSEncoder *st,
+    const opus_int16 *pcm,
+    int frame_size,
+    unsigned char *data,
+    opus_int32 max_data_bytes
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4);
+
+/** Encodes a multistream Opus frame from floating point input.
+  * @param st <tt>OpusMSEncoder*</tt>: Multistream encoder state.
+  * @param[in] pcm <tt>const float*</tt>: The input signal as interleaved
+  *                                       samples with a normal range of
+  *                                       +/-1.0.
+  *                                       Samples with a range beyond +/-1.0
+  *                                       are supported but will be clipped by
+  *                                       decoders using the integer API and
+  *                                       should only be used if it is known
+  *                                       that the far end supports extended
+  *                                       dynamic range.
+  *                                       This must contain
+  *                                       <code>frame_size*channels</code>
+  *                                       samples.
+  * @param frame_size <tt>int</tt>: Number of samples per channel in the input
+  *                                 signal.
+  *                                 This must be an Opus frame size for the
+  *                                 encoder's sampling rate.
+  *                                 For example, at 48 kHz the permitted values
+  *                                 are 120, 240, 480, 960, 1920, and 2880.
+  *                                 Passing in a duration of less than 10 ms
+  *                                 (480 samples at 48 kHz) will prevent the
+  *                                 encoder from using the LPC or hybrid modes.
+  * @param[out] data <tt>unsigned char*</tt>: Output payload.
+  *                                           This must contain storage for at
+  *                                           least \a max_data_bytes.
+  * @param [in] max_data_bytes <tt>opus_int32</tt>: Size of the allocated
+  *                                                 memory for the output
+  *                                                 payload. This may be
+  *                                                 used to impose an upper limit on
+  *                                                 the instant bitrate, but should
+  *                                                 not be used as the only bitrate
+  *                                                 control. Use #OPUS_SET_BITRATE to
+  *                                                 control the bitrate.
+  * @returns The length of the encoded packet (in bytes) on success or a
+  *          negative error code (see @ref opus_errorcodes) on failure.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_encode_float(
+      OpusMSEncoder *st,
+      const float *pcm,
+      int frame_size,
+      unsigned char *data,
+      opus_int32 max_data_bytes
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4);
+
+/** Frees an <code>OpusMSEncoder</code> allocated by
+  * opus_multistream_encoder_create().
+  * @param st <tt>OpusMSEncoder*</tt>: Multistream encoder state to be freed.
+  */
+OPUS_EXPORT void opus_multistream_encoder_destroy(OpusMSEncoder *st);
+
+/** Perform a CTL function on a multistream Opus encoder.
+  *
+  * Generally the request and subsequent arguments are generated by a
+  * convenience macro.
+  * @param st <tt>OpusMSEncoder*</tt>: Multistream encoder state.
+  * @param request This and all remaining parameters should be replaced by one
+  *                of the convenience macros in @ref opus_genericctls,
+  *                @ref opus_encoderctls, or @ref opus_multistream_ctls.
+  * @see opus_genericctls
+  * @see opus_encoderctls
+  * @see opus_multistream_ctls
+  */
+OPUS_EXPORT int opus_multistream_encoder_ctl(OpusMSEncoder *st, int request, ...) OPUS_ARG_NONNULL(1);
+
+/**@}*/
+
+/**\name Multistream decoder functions */
+/**@{*/
+
+/** Gets the size of an <code>OpusMSDecoder</code> structure.
+  * @param streams <tt>int</tt>: The total number of streams coded in the
+  *                              input.
+  *                              This must be no more than 255.
+  * @param coupled_streams <tt>int</tt>: Number streams to decode as coupled
+  *                                      (2 channel) streams.
+  *                                      This must be no larger than the total
+  *                                      number of streams.
+  *                                      Additionally, The total number of
+  *                                      coded channels (<code>streams +
+  *                                      coupled_streams</code>) must be no
+  *                                      more than 255.
+  * @returns The size in bytes on success, or a negative error code
+  *          (see @ref opus_errorcodes) on error.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_multistream_decoder_get_size(
+      int streams,
+      int coupled_streams
+);
+
+/** Allocates and initializes a multistream decoder state.
+  * Call opus_multistream_decoder_destroy() to release
+  * this object when finished.
+  * @param Fs <tt>opus_int32</tt>: Sampling rate to decode at (in Hz).
+  *                                This must be one of 8000, 12000, 16000,
+  *                                24000, or 48000.
+  * @param channels <tt>int</tt>: Number of channels to output.
+  *                               This must be at most 255.
+  *                               It may be different from the number of coded
+  *                               channels (<code>streams +
+  *                               coupled_streams</code>).
+  * @param streams <tt>int</tt>: The total number of streams coded in the
+  *                              input.
+  *                              This must be no more than 255.
+  * @param coupled_streams <tt>int</tt>: Number of streams to decode as coupled
+  *                                      (2 channel) streams.
+  *                                      This must be no larger than the total
+  *                                      number of streams.
+  *                                      Additionally, The total number of
+  *                                      coded channels (<code>streams +
+  *                                      coupled_streams</code>) must be no
+  *                                      more than 255.
+  * @param[in] mapping <code>const unsigned char[channels]</code>: Mapping from
+  *                    coded channels to output channels, as described in
+  *                    @ref opus_multistream.
+  * @param[out] error <tt>int *</tt>: Returns #OPUS_OK on success, or an error
+  *                                   code (see @ref opus_errorcodes) on
+  *                                   failure.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusMSDecoder *opus_multistream_decoder_create(
+      opus_int32 Fs,
+      int channels,
+      int streams,
+      int coupled_streams,
+      const unsigned char *mapping,
+      int *error
+) OPUS_ARG_NONNULL(5);
+
+/** Intialize a previously allocated decoder state object.
+  * The memory pointed to by \a st must be at least the size returned by
+  * opus_multistream_encoder_get_size().
+  * This is intended for applications which use their own allocator instead of
+  * malloc.
+  * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL.
+  * @see opus_multistream_decoder_create
+  * @see opus_multistream_deocder_get_size
+  * @param st <tt>OpusMSEncoder*</tt>: Multistream encoder state to initialize.
+  * @param Fs <tt>opus_int32</tt>: Sampling rate to decode at (in Hz).
+  *                                This must be one of 8000, 12000, 16000,
+  *                                24000, or 48000.
+  * @param channels <tt>int</tt>: Number of channels to output.
+  *                               This must be at most 255.
+  *                               It may be different from the number of coded
+  *                               channels (<code>streams +
+  *                               coupled_streams</code>).
+  * @param streams <tt>int</tt>: The total number of streams coded in the
+  *                              input.
+  *                              This must be no more than 255.
+  * @param coupled_streams <tt>int</tt>: Number of streams to decode as coupled
+  *                                      (2 channel) streams.
+  *                                      This must be no larger than the total
+  *                                      number of streams.
+  *                                      Additionally, The total number of
+  *                                      coded channels (<code>streams +
+  *                                      coupled_streams</code>) must be no
+  *                                      more than 255.
+  * @param[in] mapping <code>const unsigned char[channels]</code>: Mapping from
+  *                    coded channels to output channels, as described in
+  *                    @ref opus_multistream.
+  * @returns #OPUS_OK on success, or an error code (see @ref opus_errorcodes)
+  *          on failure.
+  */
+OPUS_EXPORT int opus_multistream_decoder_init(
+      OpusMSDecoder *st,
+      opus_int32 Fs,
+      int channels,
+      int streams,
+      int coupled_streams,
+      const unsigned char *mapping
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(6);
+
+/** Decode a multistream Opus packet.
+  * @param st <tt>OpusMSDecoder*</tt>: Multistream decoder state.
+  * @param[in] data <tt>const unsigned char*</tt>: Input payload.
+  *                                                Use a <code>NULL</code>
+  *                                                pointer to indicate packet
+  *                                                loss.
+  * @param len <tt>opus_int32</tt>: Number of bytes in payload.
+  * @param[out] pcm <tt>opus_int16*</tt>: Output signal, with interleaved
+  *                                       samples.
+  *                                       This must contain room for
+  *                                       <code>frame_size*channels</code>
+  *                                       samples.
+  * @param frame_size <tt>int</tt>: The number of samples per channel of
+  *                                 available space in \a pcm.
+  *                                 If this is less than the maximum packet duration
+  *                                 (120 ms; 5760 for 48kHz), this function will not be capable
+  *                                 of decoding some packets. In the case of PLC (data==NULL)
+  *                                 or FEC (decode_fec=1), then frame_size needs to be exactly
+  *                                 the duration of audio that is missing, otherwise the
+  *                                 decoder will not be in the optimal state to decode the
+  *                                 next incoming packet. For the PLC and FEC cases, frame_size
+  *                                 <b>must</b> be a multiple of 2.5 ms.
+  * @param decode_fec <tt>int</tt>: Flag (0 or 1) to request that any in-band
+  *                                 forward error correction data be decoded.
+  *                                 If no such data is available, the frame is
+  *                                 decoded as if it were lost.
+  * @returns Number of samples decoded on success or a negative error code
+  *          (see @ref opus_errorcodes) on failure.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_decode(
+    OpusMSDecoder *st,
+    const unsigned char *data,
+    opus_int32 len,
+    opus_int16 *pcm,
+    int frame_size,
+    int decode_fec
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4);
+
+/** Decode a multistream Opus packet with floating point output.
+  * @param st <tt>OpusMSDecoder*</tt>: Multistream decoder state.
+  * @param[in] data <tt>const unsigned char*</tt>: Input payload.
+  *                                                Use a <code>NULL</code>
+  *                                                pointer to indicate packet
+  *                                                loss.
+  * @param len <tt>opus_int32</tt>: Number of bytes in payload.
+  * @param[out] pcm <tt>opus_int16*</tt>: Output signal, with interleaved
+  *                                       samples.
+  *                                       This must contain room for
+  *                                       <code>frame_size*channels</code>
+  *                                       samples.
+  * @param frame_size <tt>int</tt>: The number of samples per channel of
+  *                                 available space in \a pcm.
+  *                                 If this is less than the maximum packet duration
+  *                                 (120 ms; 5760 for 48kHz), this function will not be capable
+  *                                 of decoding some packets. In the case of PLC (data==NULL)
+  *                                 or FEC (decode_fec=1), then frame_size needs to be exactly
+  *                                 the duration of audio that is missing, otherwise the
+  *                                 decoder will not be in the optimal state to decode the
+  *                                 next incoming packet. For the PLC and FEC cases, frame_size
+  *                                 <b>must</b> be a multiple of 2.5 ms.
+  * @param decode_fec <tt>int</tt>: Flag (0 or 1) to request that any in-band
+  *                                 forward error correction data be decoded.
+  *                                 If no such data is available, the frame is
+  *                                 decoded as if it were lost.
+  * @returns Number of samples decoded on success or a negative error code
+  *          (see @ref opus_errorcodes) on failure.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_multistream_decode_float(
+    OpusMSDecoder *st,
+    const unsigned char *data,
+    opus_int32 len,
+    float *pcm,
+    int frame_size,
+    int decode_fec
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4);
+
+/** Perform a CTL function on a multistream Opus decoder.
+  *
+  * Generally the request and subsequent arguments are generated by a
+  * convenience macro.
+  * @param st <tt>OpusMSDecoder*</tt>: Multistream decoder state.
+  * @param request This and all remaining parameters should be replaced by one
+  *                of the convenience macros in @ref opus_genericctls,
+  *                @ref opus_decoderctls, or @ref opus_multistream_ctls.
+  * @see opus_genericctls
+  * @see opus_decoderctls
+  * @see opus_multistream_ctls
+  */
+OPUS_EXPORT int opus_multistream_decoder_ctl(OpusMSDecoder *st, int request, ...) OPUS_ARG_NONNULL(1);
+
+/** Frees an <code>OpusMSDecoder</code> allocated by
+  * opus_multistream_decoder_create().
+  * @param st <tt>OpusMSDecoder</tt>: Multistream decoder state to be freed.
+  */
+OPUS_EXPORT void opus_multistream_decoder_destroy(OpusMSDecoder *st);
+
+/**@}*/
+
+/**@}*/
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* OPUS_MULTISTREAM_H */

+ 568 - 0
Sources/OpusLib/include/opus/opus_projection.h

@@ -0,0 +1,568 @@
+/* Copyright (c) 2017 Google Inc.
+   Written by Andrew Allen */
+/*
+   Redistribution and use in source and binary forms, with or without
+   modification, are permitted provided that the following conditions
+   are met:
+
+   - Redistributions of source code must retain the above copyright
+   notice, this list of conditions and the following disclaimer.
+
+   - Redistributions in binary form must reproduce the above copyright
+   notice, this list of conditions and the following disclaimer in the
+   documentation and/or other materials provided with the distribution.
+
+   THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+   ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+   LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+   A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
+   OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+   EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+   PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+   PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+   LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+   NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+   SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+
+/**
+ * @file opus_projection.h
+ * @brief Opus projection reference API
+ */
+
+#ifndef OPUS_PROJECTION_H
+#define OPUS_PROJECTION_H
+
+#include "opus_multistream.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** @cond OPUS_INTERNAL_DOC */
+
+/** These are the actual encoder and decoder CTL ID numbers.
+  * They should not be used directly by applications.c
+  * In general, SETs should be even and GETs should be odd.*/
+/**@{*/
+#define OPUS_PROJECTION_GET_DEMIXING_MATRIX_GAIN_REQUEST    6001
+#define OPUS_PROJECTION_GET_DEMIXING_MATRIX_SIZE_REQUEST    6003
+#define OPUS_PROJECTION_GET_DEMIXING_MATRIX_REQUEST         6005
+/**@}*/
+
+
+/** @endcond */
+
+/** @defgroup opus_projection_ctls Projection specific encoder and decoder CTLs
+  *
+  * These are convenience macros that are specific to the
+  * opus_projection_encoder_ctl() and opus_projection_decoder_ctl()
+  * interface.
+  * The CTLs from @ref opus_genericctls, @ref opus_encoderctls,
+  * @ref opus_decoderctls, and @ref opus_multistream_ctls may be applied to a
+  * projection encoder or decoder as well.
+  */
+/**@{*/
+
+/** Gets the gain (in dB. S7.8-format) of the demixing matrix from the encoder.
+  * @param[out] x <tt>opus_int32 *</tt>: Returns the gain (in dB. S7.8-format)
+  *                                      of the demixing matrix.
+  * @hideinitializer
+  */
+#define OPUS_PROJECTION_GET_DEMIXING_MATRIX_GAIN(x) OPUS_PROJECTION_GET_DEMIXING_MATRIX_GAIN_REQUEST, __opus_check_int_ptr(x)
+
+
+/** Gets the size in bytes of the demixing matrix from the encoder.
+  * @param[out] x <tt>opus_int32 *</tt>: Returns the size in bytes of the
+  *                                      demixing matrix.
+  * @hideinitializer
+  */
+#define OPUS_PROJECTION_GET_DEMIXING_MATRIX_SIZE(x) OPUS_PROJECTION_GET_DEMIXING_MATRIX_SIZE_REQUEST, __opus_check_int_ptr(x)
+
+
+/** Copies the demixing matrix to the supplied pointer location.
+  * @param[out] x <tt>unsigned char *</tt>: Returns the demixing matrix to the
+  *                                         supplied pointer location.
+  * @param y <tt>opus_int32</tt>: The size in bytes of the reserved memory at the
+  *                              pointer location.
+  * @hideinitializer
+  */
+#define OPUS_PROJECTION_GET_DEMIXING_MATRIX(x,y) OPUS_PROJECTION_GET_DEMIXING_MATRIX_REQUEST, x, __opus_check_int(y)
+
+
+/**@}*/
+
+/** Opus projection encoder state.
+ * This contains the complete state of a projection Opus encoder.
+ * It is position independent and can be freely copied.
+ * @see opus_projection_ambisonics_encoder_create
+ */
+typedef struct OpusProjectionEncoder OpusProjectionEncoder;
+
+
+/** Opus projection decoder state.
+  * This contains the complete state of a projection Opus decoder.
+  * It is position independent and can be freely copied.
+  * @see opus_projection_decoder_create
+  * @see opus_projection_decoder_init
+  */
+typedef struct OpusProjectionDecoder OpusProjectionDecoder;
+
+
+/**\name Projection encoder functions */
+/**@{*/
+
+/** Gets the size of an OpusProjectionEncoder structure.
+  * @param channels <tt>int</tt>: The total number of input channels to encode.
+  *                               This must be no more than 255.
+  * @param mapping_family <tt>int</tt>: The mapping family to use for selecting
+  *                                     the appropriate projection.
+  * @returns The size in bytes on success, or a negative error code
+  *          (see @ref opus_errorcodes) on error.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_projection_ambisonics_encoder_get_size(
+    int channels,
+    int mapping_family
+);
+
+
+/** Allocates and initializes a projection encoder state.
+  * Call opus_projection_encoder_destroy() to release
+  * this object when finished.
+  * @param Fs <tt>opus_int32</tt>: Sampling rate of the input signal (in Hz).
+  *                                This must be one of 8000, 12000, 16000,
+  *                                24000, or 48000.
+  * @param channels <tt>int</tt>: Number of channels in the input signal.
+  *                               This must be at most 255.
+  *                               It may be greater than the number of
+  *                               coded channels (<code>streams +
+  *                               coupled_streams</code>).
+  * @param mapping_family <tt>int</tt>: The mapping family to use for selecting
+  *                                     the appropriate projection.
+  * @param[out] streams <tt>int *</tt>: The total number of streams that will
+  *                                     be encoded from the input.
+  * @param[out] coupled_streams <tt>int *</tt>: Number of coupled (2 channel)
+  *                                 streams that will be encoded from the input.
+  * @param application <tt>int</tt>: The target encoder application.
+  *                                  This must be one of the following:
+  * <dl>
+  * <dt>#OPUS_APPLICATION_VOIP</dt>
+  * <dd>Process signal for improved speech intelligibility.</dd>
+  * <dt>#OPUS_APPLICATION_AUDIO</dt>
+  * <dd>Favor faithfulness to the original input.</dd>
+  * <dt>#OPUS_APPLICATION_RESTRICTED_LOWDELAY</dt>
+  * <dd>Configure the minimum possible coding delay by disabling certain modes
+  * of operation.</dd>
+  * </dl>
+  * @param[out] error <tt>int *</tt>: Returns #OPUS_OK on success, or an error
+  *                                   code (see @ref opus_errorcodes) on
+  *                                   failure.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusProjectionEncoder *opus_projection_ambisonics_encoder_create(
+    opus_int32 Fs,
+    int channels,
+    int mapping_family,
+    int *streams,
+    int *coupled_streams,
+    int application,
+    int *error
+) OPUS_ARG_NONNULL(4) OPUS_ARG_NONNULL(5);
+
+
+/** Initialize a previously allocated projection encoder state.
+  * The memory pointed to by \a st must be at least the size returned by
+  * opus_projection_ambisonics_encoder_get_size().
+  * This is intended for applications which use their own allocator instead of
+  * malloc.
+  * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL.
+  * @see opus_projection_ambisonics_encoder_create
+  * @see opus_projection_ambisonics_encoder_get_size
+  * @param st <tt>OpusProjectionEncoder*</tt>: Projection encoder state to initialize.
+  * @param Fs <tt>opus_int32</tt>: Sampling rate of the input signal (in Hz).
+  *                                This must be one of 8000, 12000, 16000,
+  *                                24000, or 48000.
+  * @param channels <tt>int</tt>: Number of channels in the input signal.
+  *                               This must be at most 255.
+  *                               It may be greater than the number of
+  *                               coded channels (<code>streams +
+  *                               coupled_streams</code>).
+  * @param streams <tt>int</tt>: The total number of streams to encode from the
+  *                              input.
+  *                              This must be no more than the number of channels.
+  * @param coupled_streams <tt>int</tt>: Number of coupled (2 channel) streams
+  *                                      to encode.
+  *                                      This must be no larger than the total
+  *                                      number of streams.
+  *                                      Additionally, The total number of
+  *                                      encoded channels (<code>streams +
+  *                                      coupled_streams</code>) must be no
+  *                                      more than the number of input channels.
+  * @param application <tt>int</tt>: The target encoder application.
+  *                                  This must be one of the following:
+  * <dl>
+  * <dt>#OPUS_APPLICATION_VOIP</dt>
+  * <dd>Process signal for improved speech intelligibility.</dd>
+  * <dt>#OPUS_APPLICATION_AUDIO</dt>
+  * <dd>Favor faithfulness to the original input.</dd>
+  * <dt>#OPUS_APPLICATION_RESTRICTED_LOWDELAY</dt>
+  * <dd>Configure the minimum possible coding delay by disabling certain modes
+  * of operation.</dd>
+  * </dl>
+  * @returns #OPUS_OK on success, or an error code (see @ref opus_errorcodes)
+  *          on failure.
+  */
+OPUS_EXPORT int opus_projection_ambisonics_encoder_init(
+    OpusProjectionEncoder *st,
+    opus_int32 Fs,
+    int channels,
+    int mapping_family,
+    int *streams,
+    int *coupled_streams,
+    int application
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(5) OPUS_ARG_NONNULL(6);
+
+
+/** Encodes a projection Opus frame.
+  * @param st <tt>OpusProjectionEncoder*</tt>: Projection encoder state.
+  * @param[in] pcm <tt>const opus_int16*</tt>: The input signal as interleaved
+  *                                            samples.
+  *                                            This must contain
+  *                                            <code>frame_size*channels</code>
+  *                                            samples.
+  * @param frame_size <tt>int</tt>: Number of samples per channel in the input
+  *                                 signal.
+  *                                 This must be an Opus frame size for the
+  *                                 encoder's sampling rate.
+  *                                 For example, at 48 kHz the permitted values
+  *                                 are 120, 240, 480, 960, 1920, and 2880.
+  *                                 Passing in a duration of less than 10 ms
+  *                                 (480 samples at 48 kHz) will prevent the
+  *                                 encoder from using the LPC or hybrid modes.
+  * @param[out] data <tt>unsigned char*</tt>: Output payload.
+  *                                           This must contain storage for at
+  *                                           least \a max_data_bytes.
+  * @param [in] max_data_bytes <tt>opus_int32</tt>: Size of the allocated
+  *                                                 memory for the output
+  *                                                 payload. This may be
+  *                                                 used to impose an upper limit on
+  *                                                 the instant bitrate, but should
+  *                                                 not be used as the only bitrate
+  *                                                 control. Use #OPUS_SET_BITRATE to
+  *                                                 control the bitrate.
+  * @returns The length of the encoded packet (in bytes) on success or a
+  *          negative error code (see @ref opus_errorcodes) on failure.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_encode(
+    OpusProjectionEncoder *st,
+    const opus_int16 *pcm,
+    int frame_size,
+    unsigned char *data,
+    opus_int32 max_data_bytes
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4);
+
+
+/** Encodes a projection Opus frame from floating point input.
+  * @param st <tt>OpusProjectionEncoder*</tt>: Projection encoder state.
+  * @param[in] pcm <tt>const float*</tt>: The input signal as interleaved
+  *                                       samples with a normal range of
+  *                                       +/-1.0.
+  *                                       Samples with a range beyond +/-1.0
+  *                                       are supported but will be clipped by
+  *                                       decoders using the integer API and
+  *                                       should only be used if it is known
+  *                                       that the far end supports extended
+  *                                       dynamic range.
+  *                                       This must contain
+  *                                       <code>frame_size*channels</code>
+  *                                       samples.
+  * @param frame_size <tt>int</tt>: Number of samples per channel in the input
+  *                                 signal.
+  *                                 This must be an Opus frame size for the
+  *                                 encoder's sampling rate.
+  *                                 For example, at 48 kHz the permitted values
+  *                                 are 120, 240, 480, 960, 1920, and 2880.
+  *                                 Passing in a duration of less than 10 ms
+  *                                 (480 samples at 48 kHz) will prevent the
+  *                                 encoder from using the LPC or hybrid modes.
+  * @param[out] data <tt>unsigned char*</tt>: Output payload.
+  *                                           This must contain storage for at
+  *                                           least \a max_data_bytes.
+  * @param [in] max_data_bytes <tt>opus_int32</tt>: Size of the allocated
+  *                                                 memory for the output
+  *                                                 payload. This may be
+  *                                                 used to impose an upper limit on
+  *                                                 the instant bitrate, but should
+  *                                                 not be used as the only bitrate
+  *                                                 control. Use #OPUS_SET_BITRATE to
+  *                                                 control the bitrate.
+  * @returns The length of the encoded packet (in bytes) on success or a
+  *          negative error code (see @ref opus_errorcodes) on failure.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_encode_float(
+    OpusProjectionEncoder *st,
+    const float *pcm,
+    int frame_size,
+    unsigned char *data,
+    opus_int32 max_data_bytes
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(2) OPUS_ARG_NONNULL(4);
+
+
+/** Frees an <code>OpusProjectionEncoder</code> allocated by
+  * opus_projection_ambisonics_encoder_create().
+  * @param st <tt>OpusProjectionEncoder*</tt>: Projection encoder state to be freed.
+  */
+OPUS_EXPORT void opus_projection_encoder_destroy(OpusProjectionEncoder *st);
+
+
+/** Perform a CTL function on a projection Opus encoder.
+  *
+  * Generally the request and subsequent arguments are generated by a
+  * convenience macro.
+  * @param st <tt>OpusProjectionEncoder*</tt>: Projection encoder state.
+  * @param request This and all remaining parameters should be replaced by one
+  *                of the convenience macros in @ref opus_genericctls,
+  *                @ref opus_encoderctls, @ref opus_multistream_ctls, or
+  *                @ref opus_projection_ctls
+  * @see opus_genericctls
+  * @see opus_encoderctls
+  * @see opus_multistream_ctls
+  * @see opus_projection_ctls
+  */
+OPUS_EXPORT int opus_projection_encoder_ctl(OpusProjectionEncoder *st, int request, ...) OPUS_ARG_NONNULL(1);
+
+
+/**@}*/
+
+/**\name Projection decoder functions */
+/**@{*/
+
+/** Gets the size of an <code>OpusProjectionDecoder</code> structure.
+  * @param channels <tt>int</tt>: The total number of output channels.
+  *                               This must be no more than 255.
+  * @param streams <tt>int</tt>: The total number of streams coded in the
+  *                              input.
+  *                              This must be no more than 255.
+  * @param coupled_streams <tt>int</tt>: Number streams to decode as coupled
+  *                                      (2 channel) streams.
+  *                                      This must be no larger than the total
+  *                                      number of streams.
+  *                                      Additionally, The total number of
+  *                                      coded channels (<code>streams +
+  *                                      coupled_streams</code>) must be no
+  *                                      more than 255.
+  * @returns The size in bytes on success, or a negative error code
+  *          (see @ref opus_errorcodes) on error.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT opus_int32 opus_projection_decoder_get_size(
+    int channels,
+    int streams,
+    int coupled_streams
+);
+
+
+/** Allocates and initializes a projection decoder state.
+  * Call opus_projection_decoder_destroy() to release
+  * this object when finished.
+  * @param Fs <tt>opus_int32</tt>: Sampling rate to decode at (in Hz).
+  *                                This must be one of 8000, 12000, 16000,
+  *                                24000, or 48000.
+  * @param channels <tt>int</tt>: Number of channels to output.
+  *                               This must be at most 255.
+  *                               It may be different from the number of coded
+  *                               channels (<code>streams +
+  *                               coupled_streams</code>).
+  * @param streams <tt>int</tt>: The total number of streams coded in the
+  *                              input.
+  *                              This must be no more than 255.
+  * @param coupled_streams <tt>int</tt>: Number of streams to decode as coupled
+  *                                      (2 channel) streams.
+  *                                      This must be no larger than the total
+  *                                      number of streams.
+  *                                      Additionally, The total number of
+  *                                      coded channels (<code>streams +
+  *                                      coupled_streams</code>) must be no
+  *                                      more than 255.
+  * @param[in] demixing_matrix <tt>const unsigned char[demixing_matrix_size]</tt>: Demixing matrix
+  *                         that mapping from coded channels to output channels,
+  *                         as described in @ref opus_projection and
+  *                         @ref opus_projection_ctls.
+  * @param demixing_matrix_size <tt>opus_int32</tt>: The size in bytes of the
+  *                                                  demixing matrix, as
+  *                                                  described in @ref
+  *                                                  opus_projection_ctls.
+  * @param[out] error <tt>int *</tt>: Returns #OPUS_OK on success, or an error
+  *                                   code (see @ref opus_errorcodes) on
+  *                                   failure.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT OpusProjectionDecoder *opus_projection_decoder_create(
+    opus_int32 Fs,
+    int channels,
+    int streams,
+    int coupled_streams,
+    unsigned char *demixing_matrix,
+    opus_int32 demixing_matrix_size,
+    int *error
+) OPUS_ARG_NONNULL(5);
+
+
+/** Intialize a previously allocated projection decoder state object.
+  * The memory pointed to by \a st must be at least the size returned by
+  * opus_projection_decoder_get_size().
+  * This is intended for applications which use their own allocator instead of
+  * malloc.
+  * To reset a previously initialized state, use the #OPUS_RESET_STATE CTL.
+  * @see opus_projection_decoder_create
+  * @see opus_projection_deocder_get_size
+  * @param st <tt>OpusProjectionDecoder*</tt>: Projection encoder state to initialize.
+  * @param Fs <tt>opus_int32</tt>: Sampling rate to decode at (in Hz).
+  *                                This must be one of 8000, 12000, 16000,
+  *                                24000, or 48000.
+  * @param channels <tt>int</tt>: Number of channels to output.
+  *                               This must be at most 255.
+  *                               It may be different from the number of coded
+  *                               channels (<code>streams +
+  *                               coupled_streams</code>).
+  * @param streams <tt>int</tt>: The total number of streams coded in the
+  *                              input.
+  *                              This must be no more than 255.
+  * @param coupled_streams <tt>int</tt>: Number of streams to decode as coupled
+  *                                      (2 channel) streams.
+  *                                      This must be no larger than the total
+  *                                      number of streams.
+  *                                      Additionally, The total number of
+  *                                      coded channels (<code>streams +
+  *                                      coupled_streams</code>) must be no
+  *                                      more than 255.
+  * @param[in] demixing_matrix <tt>const unsigned char[demixing_matrix_size]</tt>: Demixing matrix
+  *                         that mapping from coded channels to output channels,
+  *                         as described in @ref opus_projection and
+  *                         @ref opus_projection_ctls.
+  * @param demixing_matrix_size <tt>opus_int32</tt>: The size in bytes of the
+  *                                                  demixing matrix, as
+  *                                                  described in @ref
+  *                                                  opus_projection_ctls.
+  * @returns #OPUS_OK on success, or an error code (see @ref opus_errorcodes)
+  *          on failure.
+  */
+OPUS_EXPORT int opus_projection_decoder_init(
+    OpusProjectionDecoder *st,
+    opus_int32 Fs,
+    int channels,
+    int streams,
+    int coupled_streams,
+    unsigned char *demixing_matrix,
+    opus_int32 demixing_matrix_size
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(6);
+
+
+/** Decode a projection Opus packet.
+  * @param st <tt>OpusProjectionDecoder*</tt>: Projection decoder state.
+  * @param[in] data <tt>const unsigned char*</tt>: Input payload.
+  *                                                Use a <code>NULL</code>
+  *                                                pointer to indicate packet
+  *                                                loss.
+  * @param len <tt>opus_int32</tt>: Number of bytes in payload.
+  * @param[out] pcm <tt>opus_int16*</tt>: Output signal, with interleaved
+  *                                       samples.
+  *                                       This must contain room for
+  *                                       <code>frame_size*channels</code>
+  *                                       samples.
+  * @param frame_size <tt>int</tt>: The number of samples per channel of
+  *                                 available space in \a pcm.
+  *                                 If this is less than the maximum packet duration
+  *                                 (120 ms; 5760 for 48kHz), this function will not be capable
+  *                                 of decoding some packets. In the case of PLC (data==NULL)
+  *                                 or FEC (decode_fec=1), then frame_size needs to be exactly
+  *                                 the duration of audio that is missing, otherwise the
+  *                                 decoder will not be in the optimal state to decode the
+  *                                 next incoming packet. For the PLC and FEC cases, frame_size
+  *                                 <b>must</b> be a multiple of 2.5 ms.
+  * @param decode_fec <tt>int</tt>: Flag (0 or 1) to request that any in-band
+  *                                 forward error correction data be decoded.
+  *                                 If no such data is available, the frame is
+  *                                 decoded as if it were lost.
+  * @returns Number of samples decoded on success or a negative error code
+  *          (see @ref opus_errorcodes) on failure.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_decode(
+    OpusProjectionDecoder *st,
+    const unsigned char *data,
+    opus_int32 len,
+    opus_int16 *pcm,
+    int frame_size,
+    int decode_fec
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4);
+
+
+/** Decode a projection Opus packet with floating point output.
+  * @param st <tt>OpusProjectionDecoder*</tt>: Projection decoder state.
+  * @param[in] data <tt>const unsigned char*</tt>: Input payload.
+  *                                                Use a <code>NULL</code>
+  *                                                pointer to indicate packet
+  *                                                loss.
+  * @param len <tt>opus_int32</tt>: Number of bytes in payload.
+  * @param[out] pcm <tt>opus_int16*</tt>: Output signal, with interleaved
+  *                                       samples.
+  *                                       This must contain room for
+  *                                       <code>frame_size*channels</code>
+  *                                       samples.
+  * @param frame_size <tt>int</tt>: The number of samples per channel of
+  *                                 available space in \a pcm.
+  *                                 If this is less than the maximum packet duration
+  *                                 (120 ms; 5760 for 48kHz), this function will not be capable
+  *                                 of decoding some packets. In the case of PLC (data==NULL)
+  *                                 or FEC (decode_fec=1), then frame_size needs to be exactly
+  *                                 the duration of audio that is missing, otherwise the
+  *                                 decoder will not be in the optimal state to decode the
+  *                                 next incoming packet. For the PLC and FEC cases, frame_size
+  *                                 <b>must</b> be a multiple of 2.5 ms.
+  * @param decode_fec <tt>int</tt>: Flag (0 or 1) to request that any in-band
+  *                                 forward error correction data be decoded.
+  *                                 If no such data is available, the frame is
+  *                                 decoded as if it were lost.
+  * @returns Number of samples decoded on success or a negative error code
+  *          (see @ref opus_errorcodes) on failure.
+  */
+OPUS_EXPORT OPUS_WARN_UNUSED_RESULT int opus_projection_decode_float(
+    OpusProjectionDecoder *st,
+    const unsigned char *data,
+    opus_int32 len,
+    float *pcm,
+    int frame_size,
+    int decode_fec
+) OPUS_ARG_NONNULL(1) OPUS_ARG_NONNULL(4);
+
+
+/** Perform a CTL function on a projection Opus decoder.
+  *
+  * Generally the request and subsequent arguments are generated by a
+  * convenience macro.
+  * @param st <tt>OpusProjectionDecoder*</tt>: Projection decoder state.
+  * @param request This and all remaining parameters should be replaced by one
+  *                of the convenience macros in @ref opus_genericctls,
+  *                @ref opus_decoderctls, @ref opus_multistream_ctls, or
+  *                @ref opus_projection_ctls.
+  * @see opus_genericctls
+  * @see opus_decoderctls
+  * @see opus_multistream_ctls
+  * @see opus_projection_ctls
+  */
+OPUS_EXPORT int opus_projection_decoder_ctl(OpusProjectionDecoder *st, int request, ...) OPUS_ARG_NONNULL(1);
+
+
+/** Frees an <code>OpusProjectionDecoder</code> allocated by
+  * opus_projection_decoder_create().
+  * @param st <tt>OpusProjectionDecoder</tt>: Projection decoder state to be freed.
+  */
+OPUS_EXPORT void opus_projection_decoder_destroy(OpusProjectionDecoder *st);
+
+
+/**@}*/
+
+/**@}*/
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* OPUS_PROJECTION_H */

+ 166 - 0
Sources/OpusLib/include/opus/opus_types.h

@@ -0,0 +1,166 @@
+/* (C) COPYRIGHT 1994-2002 Xiph.Org Foundation */
+/* Modified by Jean-Marc Valin */
+/*
+   Redistribution and use in source and binary forms, with or without
+   modification, are permitted provided that the following conditions
+   are met:
+
+   - Redistributions of source code must retain the above copyright
+   notice, this list of conditions and the following disclaimer.
+
+   - Redistributions in binary form must reproduce the above copyright
+   notice, this list of conditions and the following disclaimer in the
+   documentation and/or other materials provided with the distribution.
+
+   THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+   ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+   LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+   A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER
+   OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+   EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+   PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+   PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+   LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+   NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+   SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+*/
+/* opus_types.h based on ogg_types.h from libogg */
+
+/**
+   @file opus_types.h
+   @brief Opus reference implementation types
+*/
+#ifndef OPUS_TYPES_H
+#define OPUS_TYPES_H
+
+#define opus_int         int                     /* used for counters etc; at least 16 bits */
+#define opus_int64       long long
+#define opus_int8        signed char
+
+#define opus_uint        unsigned int            /* used for counters etc; at least 16 bits */
+#define opus_uint64      unsigned long long
+#define opus_uint8       unsigned char
+
+/* Use the real stdint.h if it's there (taken from Paul Hsieh's pstdint.h) */
+#if (defined(__STDC__) && __STDC__ && defined(__STDC_VERSION__) && __STDC_VERSION__ >= 199901L) || (defined(__GNUC__) && (defined(_STDINT_H) || defined(_STDINT_H_)) || defined (HAVE_STDINT_H))
+#include <stdint.h>
+#  undef opus_int64
+#  undef opus_int8
+#  undef opus_uint64
+#  undef opus_uint8
+   typedef int8_t opus_int8;
+   typedef uint8_t opus_uint8;
+   typedef int16_t opus_int16;
+   typedef uint16_t opus_uint16;
+   typedef int32_t opus_int32;
+   typedef uint32_t opus_uint32;
+   typedef int64_t opus_int64;
+   typedef uint64_t opus_uint64;
+#elif defined(_WIN32)
+
+#  if defined(__CYGWIN__)
+#    include <_G_config.h>
+     typedef _G_int32_t opus_int32;
+     typedef _G_uint32_t opus_uint32;
+     typedef _G_int16 opus_int16;
+     typedef _G_uint16 opus_uint16;
+#  elif defined(__MINGW32__)
+     typedef short opus_int16;
+     typedef unsigned short opus_uint16;
+     typedef int opus_int32;
+     typedef unsigned int opus_uint32;
+#  elif defined(__MWERKS__)
+     typedef int opus_int32;
+     typedef unsigned int opus_uint32;
+     typedef short opus_int16;
+     typedef unsigned short opus_uint16;
+#  else
+     /* MSVC/Borland */
+     typedef __int32 opus_int32;
+     typedef unsigned __int32 opus_uint32;
+     typedef __int16 opus_int16;
+     typedef unsigned __int16 opus_uint16;
+#  endif
+
+#elif defined(__MACOS__)
+
+#  include <sys/types.h>
+   typedef SInt16 opus_int16;
+   typedef UInt16 opus_uint16;
+   typedef SInt32 opus_int32;
+   typedef UInt32 opus_uint32;
+
+#elif (defined(__APPLE__) && defined(__MACH__)) /* MacOS X Framework build */
+
+#  include <sys/types.h>
+   typedef int16_t opus_int16;
+   typedef u_int16_t opus_uint16;
+   typedef int32_t opus_int32;
+   typedef u_int32_t opus_uint32;
+
+#elif defined(__BEOS__)
+
+   /* Be */
+#  include <inttypes.h>
+   typedef int16 opus_int16;
+   typedef u_int16 opus_uint16;
+   typedef int32_t opus_int32;
+   typedef u_int32_t opus_uint32;
+
+#elif defined (__EMX__)
+
+   /* OS/2 GCC */
+   typedef short opus_int16;
+   typedef unsigned short opus_uint16;
+   typedef int opus_int32;
+   typedef unsigned int opus_uint32;
+
+#elif defined (DJGPP)
+
+   /* DJGPP */
+   typedef short opus_int16;
+   typedef unsigned short opus_uint16;
+   typedef int opus_int32;
+   typedef unsigned int opus_uint32;
+
+#elif defined(R5900)
+
+   /* PS2 EE */
+   typedef int opus_int32;
+   typedef unsigned opus_uint32;
+   typedef short opus_int16;
+   typedef unsigned short opus_uint16;
+
+#elif defined(__SYMBIAN32__)
+
+   /* Symbian GCC */
+   typedef signed short opus_int16;
+   typedef unsigned short opus_uint16;
+   typedef signed int opus_int32;
+   typedef unsigned int opus_uint32;
+
+#elif defined(CONFIG_TI_C54X) || defined (CONFIG_TI_C55X)
+
+   typedef short opus_int16;
+   typedef unsigned short opus_uint16;
+   typedef long opus_int32;
+   typedef unsigned long opus_uint32;
+
+#elif defined(CONFIG_TI_C6X)
+
+   typedef short opus_int16;
+   typedef unsigned short opus_uint16;
+   typedef int opus_int32;
+   typedef unsigned int opus_uint32;
+
+#else
+
+   /* Give up, take a reasonable guess */
+   typedef short opus_int16;
+   typedef unsigned short opus_uint16;
+   typedef int opus_int32;
+   typedef unsigned int opus_uint32;
+
+#endif
+
+#endif  /* OPUS_TYPES_H */

+ 2164 - 0
Sources/OpusLib/include/opus/opusfile.h

@@ -0,0 +1,2164 @@
+/********************************************************************
+ *                                                                  *
+ * THIS FILE IS PART OF THE libopusfile SOFTWARE CODEC SOURCE CODE. *
+ * USE, DISTRIBUTION AND REPRODUCTION OF THIS LIBRARY SOURCE IS     *
+ * GOVERNED BY A BSD-STYLE SOURCE LICENSE INCLUDED WITH THIS SOURCE *
+ * IN 'COPYING'. PLEASE READ THESE TERMS BEFORE DISTRIBUTING.       *
+ *                                                                  *
+ * THE libopusfile SOURCE CODE IS (C) COPYRIGHT 1994-2012           *
+ * by the Xiph.Org Foundation and contributors https://xiph.org/    *
+ *                                                                  *
+ ********************************************************************
+
+ function: stdio-based convenience library for opening/seeking/decoding
+ last mod: $Id: vorbisfile.h 17182 2010-04-29 03:48:32Z xiphmont $
+
+ ********************************************************************/
+#if !defined(_opusfile_h)
+# define _opusfile_h (1)
+
+/**\mainpage
+   \section Introduction
+
+   This is the documentation for the <tt>libopusfile</tt> C API.
+
+   The <tt>libopusfile</tt> package provides a convenient high-level API for
+    decoding and basic manipulation of all Ogg Opus audio streams.
+   <tt>libopusfile</tt> is implemented as a layer on top of Xiph.Org's
+    reference
+    <tt><a href="https://www.xiph.org/ogg/doc/libogg/reference.html">libogg</a></tt>
+    and
+    <tt><a href="https://opus-codec.org/docs/opus_api-1.3.1/">libopus</a></tt>
+    libraries.
+
+   <tt>libopusfile</tt> provides several sets of built-in routines for
+    file/stream access, and may also use custom stream I/O routines provided by
+    the embedded environment.
+   There are built-in I/O routines provided for ANSI-compliant
+    <code>stdio</code> (<code>FILE *</code>), memory buffers, and URLs
+    (including <file:> URLs, plus optionally <http:> and <https:> URLs).
+
+   \section Organization
+
+   The main API is divided into several sections:
+   - \ref stream_open_close
+   - \ref stream_info
+   - \ref stream_decoding
+   - \ref stream_seeking
+
+   Several additional sections are not tied to the main API.
+   - \ref stream_callbacks
+   - \ref header_info
+   - \ref error_codes
+
+   \section Overview
+
+   The <tt>libopusfile</tt> API always decodes files to 48&nbsp;kHz.
+   The original sample rate is not preserved by the lossy compression, though
+    it is stored in the header to allow you to resample to it after decoding
+    (the <tt>libopusfile</tt> API does not currently provide a resampler,
+    but the
+    <a href="https://www.speex.org/docs/manual/speex-manual/node7.html#SECTION00760000000000000000">the
+    Speex resampler</a> is a good choice if you need one).
+   In general, if you are playing back the audio, you should leave it at
+    48&nbsp;kHz, provided your audio hardware supports it.
+   When decoding to a file, it may be worth resampling back to the original
+    sample rate, so as not to surprise users who might not expect the sample
+    rate to change after encoding to Opus and decoding.
+
+   Opus files can contain anywhere from 1 to 255 channels of audio.
+   The channel mappings for up to 8 channels are the same as the
+    <a href="https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-810004.3.9">Vorbis
+    mappings</a>.
+   A special stereo API can convert everything to 2 channels, making it simple
+    to support multichannel files in an application which only has stereo
+    output.
+   Although the <tt>libopusfile</tt> ABI provides support for the theoretical
+    maximum number of channels, the current implementation does not support
+    files with more than 8 channels, as they do not have well-defined channel
+    mappings.
+
+   Like all Ogg files, Opus files may be "chained".
+   That is, multiple Opus files may be combined into a single, longer file just
+    by concatenating the original files.
+   This is commonly done in internet radio streaming, as it allows the title
+    and artist to be updated each time the song changes, since each link in the
+    chain includes its own set of metadata.
+
+   <tt>libopusfile</tt> fully supports chained files.
+   It will decode the first Opus stream found in each link of a chained file
+    (ignoring any other streams that might be concurrently multiplexed with it,
+    such as a video stream).
+
+   The channel count can also change between links.
+   If your application is not prepared to deal with this, it can use the stereo
+    API to ensure the audio from all links will always get decoded into a
+    common format.
+   Since <tt>libopusfile</tt> always decodes to 48&nbsp;kHz, you do not have to
+    worry about the sample rate changing between links (as was possible with
+    Vorbis).
+   This makes application support for chained files with <tt>libopusfile</tt>
+    very easy.*/
+
+# if defined(__cplusplus)
+extern "C" {
+# endif
+
+# include <stdarg.h>
+# include <stdio.h>
+# include <ogg/ogg.h>
+# include <opus_multistream.h>
+
+/**@cond PRIVATE*/
+
+/*Enable special features for gcc and gcc-compatible compilers.*/
+# if !defined(OP_GNUC_PREREQ)
+#  if defined(__GNUC__)&&defined(__GNUC_MINOR__)
+#   define OP_GNUC_PREREQ(_maj,_min) \
+ ((__GNUC__<<16)+__GNUC_MINOR__>=((_maj)<<16)+(_min))
+#  else
+#   define OP_GNUC_PREREQ(_maj,_min) 0
+#  endif
+# endif
+
+# if OP_GNUC_PREREQ(4,0)
+#  pragma GCC visibility push(default)
+# endif
+
+typedef struct OpusHead          OpusHead;
+typedef struct OpusTags          OpusTags;
+typedef struct OpusPictureTag    OpusPictureTag;
+typedef struct OpusServerInfo    OpusServerInfo;
+typedef struct OpusFileCallbacks OpusFileCallbacks;
+typedef struct OggOpusFile       OggOpusFile;
+
+/*Warning attributes for libopusfile functions.*/
+# if OP_GNUC_PREREQ(3,4)
+#  define OP_WARN_UNUSED_RESULT __attribute__((__warn_unused_result__))
+# else
+#  define OP_WARN_UNUSED_RESULT
+# endif
+# if OP_GNUC_PREREQ(3,4)
+#  define OP_ARG_NONNULL(_x) __attribute__((__nonnull__(_x)))
+# else
+#  define OP_ARG_NONNULL(_x)
+# endif
+
+/**@endcond*/
+
+/**\defgroup error_codes Error Codes*/
+/*@{*/
+/**\name List of possible error codes
+   Many of the functions in this library return a negative error code when a
+    function fails.
+   This list provides a brief explanation of the common errors.
+   See each individual function for more details on what a specific error code
+    means in that context.*/
+/*@{*/
+
+/**A request did not succeed.*/
+#define OP_FALSE         (-1)
+/*Currently not used externally.*/
+#define OP_EOF           (-2)
+/**There was a hole in the page sequence numbers (e.g., a page was corrupt or
+    missing).*/
+#define OP_HOLE          (-3)
+/**An underlying read, seek, or tell operation failed when it should have
+    succeeded.*/
+#define OP_EREAD         (-128)
+/**A <code>NULL</code> pointer was passed where one was unexpected, or an
+    internal memory allocation failed, or an internal library error was
+    encountered.*/
+#define OP_EFAULT        (-129)
+/**The stream used a feature that is not implemented, such as an unsupported
+    channel family.*/
+#define OP_EIMPL         (-130)
+/**One or more parameters to a function were invalid.*/
+#define OP_EINVAL        (-131)
+/**A purported Ogg Opus stream did not begin with an Ogg page, a purported
+    header packet did not start with one of the required strings, "OpusHead" or
+    "OpusTags", or a link in a chained file was encountered that did not
+    contain any logical Opus streams.*/
+#define OP_ENOTFORMAT    (-132)
+/**A required header packet was not properly formatted, contained illegal
+    values, or was missing altogether.*/
+#define OP_EBADHEADER    (-133)
+/**The ID header contained an unrecognized version number.*/
+#define OP_EVERSION      (-134)
+/*Currently not used at all.*/
+#define OP_ENOTAUDIO     (-135)
+/**An audio packet failed to decode properly.
+   This is usually caused by a multistream Ogg packet where the durations of
+    the individual Opus packets contained in it are not all the same.*/
+#define OP_EBADPACKET    (-136)
+/**We failed to find data we had seen before, or the bitstream structure was
+    sufficiently malformed that seeking to the target destination was
+    impossible.*/
+#define OP_EBADLINK      (-137)
+/**An operation that requires seeking was requested on an unseekable stream.*/
+#define OP_ENOSEEK       (-138)
+/**The first or last granule position of a link failed basic validity checks.*/
+#define OP_EBADTIMESTAMP (-139)
+
+/*@}*/
+/*@}*/
+
+/**\defgroup header_info Header Information*/
+/*@{*/
+
+/**The maximum number of channels in an Ogg Opus stream.*/
+#define OPUS_CHANNEL_COUNT_MAX (255)
+
+/**Ogg Opus bitstream information.
+   This contains the basic playback parameters for a stream, and corresponds to
+    the initial ID header packet of an Ogg Opus stream.*/
+struct OpusHead{
+  /**The Ogg Opus format version, in the range 0...255.
+     The top 4 bits represent a "major" version, and the bottom four bits
+      represent backwards-compatible "minor" revisions.
+     The current specification describes version 1.
+     This library will recognize versions up through 15 as backwards compatible
+      with the current specification.
+     An earlier draft of the specification described a version 0, but the only
+      difference between version 1 and version 0 is that version 0 did
+      not specify the semantics for handling the version field.*/
+  int           version;
+  /**The number of channels, in the range 1...255.*/
+  int           channel_count;
+  /**The number of samples that should be discarded from the beginning of the
+      stream.*/
+  unsigned      pre_skip;
+  /**The sampling rate of the original input.
+     All Opus audio is coded at 48 kHz, and should also be decoded at 48 kHz
+      for playback (unless the target hardware does not support this sampling
+      rate).
+     However, this field may be used to resample the audio back to the original
+      sampling rate, for example, when saving the output to a file.*/
+  opus_uint32   input_sample_rate;
+  /**The gain to apply to the decoded output, in dB, as a Q8 value in the range
+      -32768...32767.
+     The <tt>libopusfile</tt> API will automatically apply this gain to the
+      decoded output before returning it, scaling it by
+      <code>pow(10,output_gain/(20.0*256))</code>.
+     You can adjust this behavior with op_set_gain_offset().*/
+  int           output_gain;
+  /**The channel mapping family, in the range 0...255.
+     Channel mapping family 0 covers mono or stereo in a single stream.
+     Channel mapping family 1 covers 1 to 8 channels in one or more streams,
+      using the Vorbis speaker assignments.
+     Channel mapping family 255 covers 1 to 255 channels in one or more
+      streams, but without any defined speaker assignment.*/
+  int           mapping_family;
+  /**The number of Opus streams in each Ogg packet, in the range 1...255.*/
+  int           stream_count;
+  /**The number of coupled Opus streams in each Ogg packet, in the range
+      0...127.
+     This must satisfy <code>0 <= coupled_count <= stream_count</code> and
+      <code>coupled_count + stream_count <= 255</code>.
+     The coupled streams appear first, before all uncoupled streams, in an Ogg
+      Opus packet.*/
+  int           coupled_count;
+  /**The mapping from coded stream channels to output channels.
+     Let <code>index=mapping[k]</code> be the value for channel <code>k</code>.
+     If <code>index<2*coupled_count</code>, then it refers to the left channel
+      from stream <code>(index/2)</code> if even, and the right channel from
+      stream <code>(index/2)</code> if odd.
+     Otherwise, it refers to the output of the uncoupled stream
+      <code>(index-coupled_count)</code>.*/
+  unsigned char mapping[OPUS_CHANNEL_COUNT_MAX];
+};
+
+/**The metadata from an Ogg Opus stream.
+
+   This structure holds the in-stream metadata corresponding to the 'comment'
+    header packet of an Ogg Opus stream.
+   The comment header is meant to be used much like someone jotting a quick
+    note on the label of a CD.
+   It should be a short, to the point text note that can be more than a couple
+    words, but not more than a short paragraph.
+
+   The metadata is stored as a series of (tag, value) pairs, in length-encoded
+    string vectors, using the same format as Vorbis (without the final "framing
+    bit"), Theora, and Speex, except for the packet header.
+   The first occurrence of the '=' character delimits the tag and value.
+   A particular tag may occur more than once, and order is significant.
+   The character set encoding for the strings is always UTF-8, but the tag
+    names are limited to ASCII, and treated as case-insensitive.
+   See <a href="https://www.xiph.org/vorbis/doc/v-comment.html">the Vorbis
+    comment header specification</a> for details.
+
+   In filling in this structure, <tt>libopusfile</tt> will null-terminate the
+    #user_comments strings for safety.
+   However, the bitstream format itself treats them as 8-bit clean vectors,
+    possibly containing NUL characters, so the #comment_lengths array should be
+    treated as their authoritative length.
+
+   This structure is binary and source-compatible with a
+    <code>vorbis_comment</code>, and pointers to it may be freely cast to
+    <code>vorbis_comment</code> pointers, and vice versa.
+   It is provided as a separate type to avoid introducing a compile-time
+    dependency on the libvorbis headers.*/
+struct OpusTags{
+  /**The array of comment string vectors.*/
+  char **user_comments;
+  /**An array of the corresponding length of each vector, in bytes.*/
+  int   *comment_lengths;
+  /**The total number of comment streams.*/
+  int    comments;
+  /**The null-terminated vendor string.
+     This identifies the software used to encode the stream.*/
+  char  *vendor;
+};
+
+/**\name Picture tag image formats*/
+/*@{*/
+
+/**The MIME type was not recognized, or the image data did not match the
+    declared MIME type.*/
+#define OP_PIC_FORMAT_UNKNOWN (-1)
+/**The MIME type indicates the image data is really a URL.*/
+#define OP_PIC_FORMAT_URL     (0)
+/**The image is a JPEG.*/
+#define OP_PIC_FORMAT_JPEG    (1)
+/**The image is a PNG.*/
+#define OP_PIC_FORMAT_PNG     (2)
+/**The image is a GIF.*/
+#define OP_PIC_FORMAT_GIF     (3)
+
+/*@}*/
+
+/**The contents of a METADATA_BLOCK_PICTURE tag.*/
+struct OpusPictureTag{
+  /**The picture type according to the ID3v2 APIC frame:
+     <ol start="0">
+     <li>Other</li>
+     <li>32x32 pixels 'file icon' (PNG only)</li>
+     <li>Other file icon</li>
+     <li>Cover (front)</li>
+     <li>Cover (back)</li>
+     <li>Leaflet page</li>
+     <li>Media (e.g. label side of CD)</li>
+     <li>Lead artist/lead performer/soloist</li>
+     <li>Artist/performer</li>
+     <li>Conductor</li>
+     <li>Band/Orchestra</li>
+     <li>Composer</li>
+     <li>Lyricist/text writer</li>
+     <li>Recording Location</li>
+     <li>During recording</li>
+     <li>During performance</li>
+     <li>Movie/video screen capture</li>
+     <li>A bright colored fish</li>
+     <li>Illustration</li>
+     <li>Band/artist logotype</li>
+     <li>Publisher/Studio logotype</li>
+     </ol>
+     Others are reserved and should not be used.
+     There may only be one each of picture type 1 and 2 in a file.*/
+  opus_int32     type;
+  /**The MIME type of the picture, in printable ASCII characters 0x20-0x7E.
+     The MIME type may also be <code>"-->"</code> to signify that the data part
+      is a URL pointing to the picture instead of the picture data itself.
+     In this case, a terminating NUL is appended to the URL string in #data,
+      but #data_length is set to the length of the string excluding that
+      terminating NUL.*/
+  char          *mime_type;
+  /**The description of the picture, in UTF-8.*/
+  char          *description;
+  /**The width of the picture in pixels.*/
+  opus_uint32    width;
+  /**The height of the picture in pixels.*/
+  opus_uint32    height;
+  /**The color depth of the picture in bits-per-pixel (<em>not</em>
+      bits-per-channel).*/
+  opus_uint32    depth;
+  /**For indexed-color pictures (e.g., GIF), the number of colors used, or 0
+      for non-indexed pictures.*/
+  opus_uint32    colors;
+  /**The length of the picture data in bytes.*/
+  opus_uint32    data_length;
+  /**The binary picture data.*/
+  unsigned char *data;
+  /**The format of the picture data, if known.
+     One of
+     <ul>
+     <li>#OP_PIC_FORMAT_UNKNOWN,</li>
+     <li>#OP_PIC_FORMAT_URL,</li>
+     <li>#OP_PIC_FORMAT_JPEG,</li>
+     <li>#OP_PIC_FORMAT_PNG, or</li>
+     <li>#OP_PIC_FORMAT_GIF.</li>
+     </ul>*/
+  int            format;
+};
+
+/**\name Functions for manipulating header data
+
+   These functions manipulate the #OpusHead and #OpusTags structures,
+    which describe the audio parameters and tag-value metadata, respectively.
+   These can be used to query the headers returned by <tt>libopusfile</tt>, or
+    to parse Opus headers from sources other than an Ogg Opus stream, provided
+    they use the same format.*/
+/*@{*/
+
+/**Parses the contents of the ID header packet of an Ogg Opus stream.
+   \param[out] _head Returns the contents of the parsed packet.
+                     The contents of this structure are untouched on error.
+                     This may be <code>NULL</code> to merely test the header
+                      for validity.
+   \param[in]  _data The contents of the ID header packet.
+   \param      _len  The number of bytes of data in the ID header packet.
+   \return 0 on success or a negative value on error.
+   \retval #OP_ENOTFORMAT If the data does not start with the "OpusHead"
+                           string.
+   \retval #OP_EVERSION   If the version field signaled a version this library
+                           does not know how to parse.
+   \retval #OP_EIMPL      If the channel mapping family was 255, which general
+                           purpose players should not attempt to play.
+   \retval #OP_EBADHEADER If the contents of the packet otherwise violate the
+                           Ogg Opus specification:
+                          <ul>
+                           <li>Insufficient data,</li>
+                           <li>Too much data for the known minor versions,</li>
+                           <li>An unrecognized channel mapping family,</li>
+                           <li>Zero channels or too many channels,</li>
+                           <li>Zero coded streams,</li>
+                           <li>Too many coupled streams, or</li>
+                           <li>An invalid channel mapping index.</li>
+                          </ul>*/
+OP_WARN_UNUSED_RESULT int opus_head_parse(OpusHead *_head,
+ const unsigned char *_data,size_t _len) OP_ARG_NONNULL(2);
+
+/**Converts a granule position to a sample offset for a given Ogg Opus stream.
+   The sample offset is simply <code>_gp-_head->pre_skip</code>.
+   Granule position values smaller than OpusHead#pre_skip correspond to audio
+    that should never be played, and thus have no associated sample offset.
+   This function returns -1 for such values.
+   This function also correctly handles extremely large granule positions,
+    which may have wrapped around to a negative number when stored in a signed
+    ogg_int64_t value.
+   \param _head The #OpusHead information from the ID header of the stream.
+   \param _gp   The granule position to convert.
+   \return The sample offset associated with the given granule position
+            (counting at a 48 kHz sampling rate), or the special value -1 on
+            error (i.e., the granule position was smaller than the pre-skip
+            amount).*/
+ogg_int64_t opus_granule_sample(const OpusHead *_head,ogg_int64_t _gp)
+ OP_ARG_NONNULL(1);
+
+/**Parses the contents of the 'comment' header packet of an Ogg Opus stream.
+   \param[out] _tags An uninitialized #OpusTags structure.
+                     This returns the contents of the parsed packet.
+                     The contents of this structure are untouched on error.
+                     This may be <code>NULL</code> to merely test the header
+                      for validity.
+   \param[in]  _data The contents of the 'comment' header packet.
+   \param      _len  The number of bytes of data in the 'info' header packet.
+   \retval 0              Success.
+   \retval #OP_ENOTFORMAT If the data does not start with the "OpusTags"
+                           string.
+   \retval #OP_EBADHEADER If the contents of the packet otherwise violate the
+                           Ogg Opus specification.
+   \retval #OP_EFAULT     If there wasn't enough memory to store the tags.*/
+OP_WARN_UNUSED_RESULT int opus_tags_parse(OpusTags *_tags,
+ const unsigned char *_data,size_t _len) OP_ARG_NONNULL(2);
+
+/**Performs a deep copy of an #OpusTags structure.
+   \param _dst The #OpusTags structure to copy into.
+               If this function fails, the contents of this structure remain
+                untouched.
+   \param _src The #OpusTags structure to copy from.
+   \retval 0          Success.
+   \retval #OP_EFAULT If there wasn't enough memory to copy the tags.*/
+int opus_tags_copy(OpusTags *_dst,const OpusTags *_src) OP_ARG_NONNULL(1);
+
+/**Initializes an #OpusTags structure.
+   This should be called on a freshly allocated #OpusTags structure before
+    attempting to use it.
+   \param _tags The #OpusTags structure to initialize.*/
+void opus_tags_init(OpusTags *_tags) OP_ARG_NONNULL(1);
+
+/**Add a (tag, value) pair to an initialized #OpusTags structure.
+   \note Neither opus_tags_add() nor opus_tags_add_comment() support values
+    containing embedded NULs, although the bitstream format does support them.
+   To add such tags, you will need to manipulate the #OpusTags structure
+    directly.
+   \param _tags  The #OpusTags structure to add the (tag, value) pair to.
+   \param _tag   A NUL-terminated, case-insensitive, ASCII string containing
+                  the tag to add (without an '=' character).
+   \param _value A NUL-terminated UTF-8 containing the corresponding value.
+   \return 0 on success, or a negative value on failure.
+   \retval #OP_EFAULT An internal memory allocation failed.*/
+int opus_tags_add(OpusTags *_tags,const char *_tag,const char *_value)
+ OP_ARG_NONNULL(1) OP_ARG_NONNULL(2) OP_ARG_NONNULL(3);
+
+/**Add a comment to an initialized #OpusTags structure.
+   \note Neither opus_tags_add_comment() nor opus_tags_add() support comments
+    containing embedded NULs, although the bitstream format does support them.
+   To add such tags, you will need to manipulate the #OpusTags structure
+    directly.
+   \param _tags    The #OpusTags structure to add the comment to.
+   \param _comment A NUL-terminated UTF-8 string containing the comment in
+                    "TAG=value" form.
+   \return 0 on success, or a negative value on failure.
+   \retval #OP_EFAULT An internal memory allocation failed.*/
+int opus_tags_add_comment(OpusTags *_tags,const char *_comment)
+ OP_ARG_NONNULL(1) OP_ARG_NONNULL(2);
+
+/**Replace the binary suffix data at the end of the packet (if any).
+   \param _tags An initialized #OpusTags structure.
+   \param _data A buffer of binary data to append after the encoded user
+                 comments.
+                The least significant bit of the first byte of this data must
+                 be set (to ensure the data is preserved by other editors).
+   \param _len  The number of bytes of binary data to append.
+                This may be zero to remove any existing binary suffix data.
+   \return 0 on success, or a negative value on error.
+   \retval #OP_EINVAL \a _len was negative, or \a _len was positive but
+                       \a _data was <code>NULL</code> or the least significant
+                       bit of the first byte was not set.
+   \retval #OP_EFAULT An internal memory allocation failed.*/
+int opus_tags_set_binary_suffix(OpusTags *_tags,
+ const unsigned char *_data,int _len) OP_ARG_NONNULL(1);
+
+/**Look up a comment value by its tag.
+   \param _tags  An initialized #OpusTags structure.
+   \param _tag   The tag to look up.
+   \param _count The instance of the tag.
+                 The same tag can appear multiple times, each with a distinct
+                  value, so an index is required to retrieve them all.
+                 The order in which these values appear is significant and
+                  should be preserved.
+                 Use opus_tags_query_count() to get the legal range for the
+                  \a _count parameter.
+   \return A pointer to the queried tag's value.
+           This points directly to data in the #OpusTags structure.
+           It should not be modified or freed by the application, and
+            modifications to the structure may invalidate the pointer.
+   \retval NULL If no matching tag is found.*/
+const char *opus_tags_query(const OpusTags *_tags,const char *_tag,int _count)
+ OP_ARG_NONNULL(1) OP_ARG_NONNULL(2);
+
+/**Look up the number of instances of a tag.
+   Call this first when querying for a specific tag and then iterate over the
+    number of instances with separate calls to opus_tags_query() to retrieve
+    all the values for that tag in order.
+   \param _tags An initialized #OpusTags structure.
+   \param _tag  The tag to look up.
+   \return The number of instances of this particular tag.*/
+int opus_tags_query_count(const OpusTags *_tags,const char *_tag)
+ OP_ARG_NONNULL(1) OP_ARG_NONNULL(2);
+
+/**Retrieve the binary suffix data at the end of the packet (if any).
+   \param      _tags An initialized #OpusTags structure.
+   \param[out] _len  Returns the number of bytes of binary suffix data returned.
+   \return A pointer to the binary suffix data, or <code>NULL</code> if none
+            was present.*/
+const unsigned char *opus_tags_get_binary_suffix(const OpusTags *_tags,
+ int *_len) OP_ARG_NONNULL(1) OP_ARG_NONNULL(2);
+
+/**Get the album gain from an R128_ALBUM_GAIN tag, if one was specified.
+   This searches for the first R128_ALBUM_GAIN tag with a valid signed,
+    16-bit decimal integer value and returns the value.
+   This routine is exposed merely for convenience for applications which wish
+    to do something special with the album gain (i.e., display it).
+   If you simply wish to apply the album gain instead of the header gain, you
+    can use op_set_gain_offset() with an #OP_ALBUM_GAIN type and no offset.
+   \param      _tags    An initialized #OpusTags structure.
+   \param[out] _gain_q8 The album gain, in 1/256ths of a dB.
+                        This will lie in the range [-32768,32767], and should
+                         be applied in <em>addition</em> to the header gain.
+                        On error, no value is returned, and the previous
+                         contents remain unchanged.
+   \return 0 on success, or a negative value on error.
+   \retval #OP_FALSE There was no album gain available in the given tags.*/
+int opus_tags_get_album_gain(const OpusTags *_tags,int *_gain_q8)
+ OP_ARG_NONNULL(1) OP_ARG_NONNULL(2);
+
+/**Get the track gain from an R128_TRACK_GAIN tag, if one was specified.
+   This searches for the first R128_TRACK_GAIN tag with a valid signed,
+    16-bit decimal integer value and returns the value.
+   This routine is exposed merely for convenience for applications which wish
+    to do something special with the track gain (i.e., display it).
+   If you simply wish to apply the track gain instead of the header gain, you
+    can use op_set_gain_offset() with an #OP_TRACK_GAIN type and no offset.
+   \param      _tags    An initialized #OpusTags structure.
+   \param[out] _gain_q8 The track gain, in 1/256ths of a dB.
+                        This will lie in the range [-32768,32767], and should
+                         be applied in <em>addition</em> to the header gain.
+                        On error, no value is returned, and the previous
+                         contents remain unchanged.
+   \return 0 on success, or a negative value on error.
+   \retval #OP_FALSE There was no track gain available in the given tags.*/
+int opus_tags_get_track_gain(const OpusTags *_tags,int *_gain_q8)
+ OP_ARG_NONNULL(1) OP_ARG_NONNULL(2);
+
+/**Clears the #OpusTags structure.
+   This should be called on an #OpusTags structure after it is no longer
+    needed.
+   It will free all memory used by the structure members.
+   \param _tags The #OpusTags structure to clear.*/
+void opus_tags_clear(OpusTags *_tags) OP_ARG_NONNULL(1);
+
+/**Check if \a _comment is an instance of a \a _tag_name tag.
+   \see opus_tagncompare
+   \param _tag_name A NUL-terminated, case-insensitive, ASCII string containing
+                     the name of the tag to check for (without the terminating
+                     '=' character).
+   \param _comment  The comment string to check.
+   \return An integer less than, equal to, or greater than zero if \a _comment
+            is found respectively, to be less than, to match, or be greater
+            than a "tag=value" string whose tag matches \a _tag_name.*/
+int opus_tagcompare(const char *_tag_name,const char *_comment);
+
+/**Check if \a _comment is an instance of a \a _tag_name tag.
+   This version is slightly more efficient than opus_tagcompare() if the length
+    of the tag name is already known (e.g., because it is a constant).
+   \see opus_tagcompare
+   \param _tag_name A case-insensitive ASCII string containing the name of the
+                     tag to check for (without the terminating '=' character).
+   \param _tag_len  The number of characters in the tag name.
+                    This must be non-negative.
+   \param _comment  The comment string to check.
+   \return An integer less than, equal to, or greater than zero if \a _comment
+            is found respectively, to be less than, to match, or be greater
+            than a "tag=value" string whose tag matches the first \a _tag_len
+            characters of \a _tag_name.*/
+int opus_tagncompare(const char *_tag_name,int _tag_len,const char *_comment);
+
+/**Parse a single METADATA_BLOCK_PICTURE tag.
+   This decodes the BASE64-encoded content of the tag and returns a structure
+    with the MIME type, description, image parameters (if known), and the
+    compressed image data.
+   If the MIME type indicates the presence of an image format we recognize
+    (JPEG, PNG, or GIF) and the actual image data contains the magic signature
+    associated with that format, then the OpusPictureTag::format field will be
+    set to the corresponding format.
+   This is provided as a convenience to avoid requiring applications to parse
+    the MIME type and/or do their own format detection for the commonly used
+    formats.
+   In this case, we also attempt to extract the image parameters directly from
+    the image data (overriding any that were present in the tag, which the
+    specification says applications are not meant to rely on).
+   The application must still provide its own support for actually decoding the
+    image data and, if applicable, retrieving that data from URLs.
+   \param[out] _pic Returns the parsed picture data.
+                    No sanitation is done on the type, MIME type, or
+                     description fields, so these might return invalid values.
+                    The contents of this structure are left unmodified on
+                     failure.
+   \param      _tag The METADATA_BLOCK_PICTURE tag contents.
+                    The leading "METADATA_BLOCK_PICTURE=" portion is optional,
+                     to allow the function to be used on either directly on the
+                     values in OpusTags::user_comments or on the return value
+                     of opus_tags_query().
+   \return 0 on success or a negative value on error.
+   \retval #OP_ENOTFORMAT The METADATA_BLOCK_PICTURE contents were not valid.
+   \retval #OP_EFAULT     There was not enough memory to store the picture tag
+                           contents.*/
+OP_WARN_UNUSED_RESULT int opus_picture_tag_parse(OpusPictureTag *_pic,
+ const char *_tag) OP_ARG_NONNULL(1) OP_ARG_NONNULL(2);
+
+/**Initializes an #OpusPictureTag structure.
+   This should be called on a freshly allocated #OpusPictureTag structure
+    before attempting to use it.
+   \param _pic The #OpusPictureTag structure to initialize.*/
+void opus_picture_tag_init(OpusPictureTag *_pic) OP_ARG_NONNULL(1);
+
+/**Clears the #OpusPictureTag structure.
+   This should be called on an #OpusPictureTag structure after it is no longer
+    needed.
+   It will free all memory used by the structure members.
+   \param _pic The #OpusPictureTag structure to clear.*/
+void opus_picture_tag_clear(OpusPictureTag *_pic) OP_ARG_NONNULL(1);
+
+/*@}*/
+
+/*@}*/
+
+/**\defgroup url_options URL Reading Options*/
+/*@{*/
+/**\name URL reading options
+   Options for op_url_stream_create() and associated functions.
+   These allow you to provide proxy configuration parameters, skip SSL
+    certificate checks, etc.
+   Options are processed in order, and if the same option is passed multiple
+    times, only the value specified by the last occurrence has an effect
+    (unless otherwise specified).
+   They may be expanded in the future.*/
+/*@{*/
+
+/**@cond PRIVATE*/
+
+/*These are the raw numbers used to define the request codes.
+  They should not be used directly.*/
+#define OP_SSL_SKIP_CERTIFICATE_CHECK_REQUEST (6464)
+#define OP_HTTP_PROXY_HOST_REQUEST            (6528)
+#define OP_HTTP_PROXY_PORT_REQUEST            (6592)
+#define OP_HTTP_PROXY_USER_REQUEST            (6656)
+#define OP_HTTP_PROXY_PASS_REQUEST            (6720)
+#define OP_GET_SERVER_INFO_REQUEST            (6784)
+
+#define OP_URL_OPT(_request) ((char *)(_request))
+
+/*These macros trigger compilation errors or warnings if the wrong types are
+   provided to one of the URL options.*/
+#define OP_CHECK_INT(_x) ((void)((_x)==(opus_int32)0),(opus_int32)(_x))
+#define OP_CHECK_CONST_CHAR_PTR(_x) ((_x)+((_x)-(const char *)(_x)))
+#define OP_CHECK_SERVER_INFO_PTR(_x) ((_x)+((_x)-(OpusServerInfo *)(_x)))
+
+/**@endcond*/
+
+/**HTTP/Shoutcast/Icecast server information associated with a URL.*/
+struct OpusServerInfo{
+  /**The name of the server (icy-name/ice-name).
+     This is <code>NULL</code> if there was no <code>icy-name</code> or
+      <code>ice-name</code> header.*/
+  char        *name;
+  /**A short description of the server (icy-description/ice-description).
+     This is <code>NULL</code> if there was no <code>icy-description</code> or
+      <code>ice-description</code> header.*/
+  char        *description;
+  /**The genre the server falls under (icy-genre/ice-genre).
+     This is <code>NULL</code> if there was no <code>icy-genre</code> or
+      <code>ice-genre</code> header.*/
+  char        *genre;
+  /**The homepage for the server (icy-url/ice-url).
+     This is <code>NULL</code> if there was no <code>icy-url</code> or
+      <code>ice-url</code> header.*/
+  char        *url;
+  /**The software used by the origin server (Server).
+     This is <code>NULL</code> if there was no <code>Server</code> header.*/
+  char        *server;
+  /**The media type of the entity sent to the recepient (Content-Type).
+     This is <code>NULL</code> if there was no <code>Content-Type</code>
+      header.*/
+  char        *content_type;
+  /**The nominal stream bitrate in kbps (icy-br/ice-bitrate).
+     This is <code>-1</code> if there was no <code>icy-br</code> or
+      <code>ice-bitrate</code> header.*/
+  opus_int32   bitrate_kbps;
+  /**Flag indicating whether the server is public (<code>1</code>) or not
+      (<code>0</code>) (icy-pub/ice-public).
+     This is <code>-1</code> if there was no <code>icy-pub</code> or
+      <code>ice-public</code> header.*/
+  int          is_public;
+  /**Flag indicating whether the server is using HTTPS instead of HTTP.
+     This is <code>0</code> unless HTTPS is being used.
+     This may not match the protocol used in the original URL if there were
+      redirections.*/
+  int          is_ssl;
+};
+
+/**Initializes an #OpusServerInfo structure.
+   All fields are set as if the corresponding header was not available.
+   \param _info The #OpusServerInfo structure to initialize.
+   \note If you use this function, you must link against <tt>libopusurl</tt>.*/
+void opus_server_info_init(OpusServerInfo *_info) OP_ARG_NONNULL(1);
+
+/**Clears the #OpusServerInfo structure.
+   This should be called on an #OpusServerInfo structure after it is no longer
+    needed.
+   It will free all memory used by the structure members.
+   \param _info The #OpusServerInfo structure to clear.
+   \note If you use this function, you must link against <tt>libopusurl</tt>.*/
+void opus_server_info_clear(OpusServerInfo *_info) OP_ARG_NONNULL(1);
+
+/**Skip the certificate check when connecting via TLS/SSL (https).
+   \param _b <code>opus_int32</code>: Whether or not to skip the certificate
+              check.
+             The check will be skipped if \a _b is non-zero, and will not be
+              skipped if \a _b is zero.
+   \hideinitializer*/
+#define OP_SSL_SKIP_CERTIFICATE_CHECK(_b) \
+ OP_URL_OPT(OP_SSL_SKIP_CERTIFICATE_CHECK_REQUEST),OP_CHECK_INT(_b)
+
+/**Proxy connections through the given host.
+   If no port is specified via #OP_HTTP_PROXY_PORT, the port number defaults
+    to 8080 (http-alt).
+   All proxy parameters are ignored for non-http and non-https URLs.
+   \param _host <code>const char *</code>: The proxy server hostname.
+                This may be <code>NULL</code> to disable the use of a proxy
+                 server.
+   \hideinitializer*/
+#define OP_HTTP_PROXY_HOST(_host) \
+ OP_URL_OPT(OP_HTTP_PROXY_HOST_REQUEST),OP_CHECK_CONST_CHAR_PTR(_host)
+
+/**Use the given port when proxying connections.
+   This option only has an effect if #OP_HTTP_PROXY_HOST is specified with a
+    non-<code>NULL</code> \a _host.
+   If this option is not provided, the proxy port number defaults to 8080
+    (http-alt).
+   All proxy parameters are ignored for non-http and non-https URLs.
+   \param _port <code>opus_int32</code>: The proxy server port.
+                This must be in the range 0...65535 (inclusive), or the
+                 URL function this is passed to will fail.
+   \hideinitializer*/
+#define OP_HTTP_PROXY_PORT(_port) \
+ OP_URL_OPT(OP_HTTP_PROXY_PORT_REQUEST),OP_CHECK_INT(_port)
+
+/**Use the given user name for authentication when proxying connections.
+   All proxy parameters are ignored for non-http and non-https URLs.
+   \param _user const char *: The proxy server user name.
+                              This may be <code>NULL</code> to disable proxy
+                               authentication.
+                              A non-<code>NULL</code> value only has an effect
+                               if #OP_HTTP_PROXY_HOST and #OP_HTTP_PROXY_PASS
+                               are also specified with non-<code>NULL</code>
+                               arguments.
+   \hideinitializer*/
+#define OP_HTTP_PROXY_USER(_user) \
+ OP_URL_OPT(OP_HTTP_PROXY_USER_REQUEST),OP_CHECK_CONST_CHAR_PTR(_user)
+
+/**Use the given password for authentication when proxying connections.
+   All proxy parameters are ignored for non-http and non-https URLs.
+   \param _pass const char *: The proxy server password.
+                              This may be <code>NULL</code> to disable proxy
+                               authentication.
+                              A non-<code>NULL</code> value only has an effect
+                               if #OP_HTTP_PROXY_HOST and #OP_HTTP_PROXY_USER
+                               are also specified with non-<code>NULL</code>
+                               arguments.
+   \hideinitializer*/
+#define OP_HTTP_PROXY_PASS(_pass) \
+ OP_URL_OPT(OP_HTTP_PROXY_PASS_REQUEST),OP_CHECK_CONST_CHAR_PTR(_pass)
+
+/**Parse information about the streaming server (if any) and return it.
+   Very little validation is done.
+   In particular, OpusServerInfo::url may not be a valid URL,
+    OpusServerInfo::bitrate_kbps may not really be in kbps, and
+    OpusServerInfo::content_type may not be a valid MIME type.
+   The character set of the string fields is not specified anywhere, and should
+    not be assumed to be valid UTF-8.
+   \param _info OpusServerInfo *: Returns information about the server.
+                                  If there is any error opening the stream, the
+                                   contents of this structure remain
+                                   unmodified.
+                                  On success, fills in the structure with the
+                                   server information that was available, if
+                                   any.
+                                  After a successful return, the contents of
+                                   this structure should be freed by calling
+                                   opus_server_info_clear().
+   \hideinitializer*/
+#define OP_GET_SERVER_INFO(_info) \
+ OP_URL_OPT(OP_GET_SERVER_INFO_REQUEST),OP_CHECK_SERVER_INFO_PTR(_info)
+
+/*@}*/
+/*@}*/
+
+/**\defgroup stream_callbacks Abstract Stream Reading Interface*/
+/*@{*/
+/**\name Functions for reading from streams
+   These functions define the interface used to read from and seek in a stream
+    of data.
+   A stream does not need to implement seeking, but the decoder will not be
+    able to seek if it does not do so.
+   These functions also include some convenience routines for working with
+    standard <code>FILE</code> pointers, complete streams stored in a single
+    block of memory, or URLs.*/
+/*@{*/
+
+/**Reads up to \a _nbytes bytes of data from \a _stream.
+   \param      _stream The stream to read from.
+   \param[out] _ptr    The buffer to store the data in.
+   \param      _nbytes The maximum number of bytes to read.
+                       This function may return fewer, though it will not
+                        return zero unless it reaches end-of-file.
+   \return The number of bytes successfully read, or a negative value on
+            error.*/
+typedef int (*op_read_func)(void *_stream,unsigned char *_ptr,int _nbytes);
+
+/**Sets the position indicator for \a _stream.
+   The new position, measured in bytes, is obtained by adding \a _offset
+    bytes to the position specified by \a _whence.
+   If \a _whence is set to <code>SEEK_SET</code>, <code>SEEK_CUR</code>, or
+    <code>SEEK_END</code>, the offset is relative to the start of the stream,
+    the current position indicator, or end-of-file, respectively.
+   \retval 0  Success.
+   \retval -1 Seeking is not supported or an error occurred.
+              <code>errno</code> need not be set.*/
+typedef int (*op_seek_func)(void *_stream,opus_int64 _offset,int _whence);
+
+/**Obtains the current value of the position indicator for \a _stream.
+   \return The current position indicator.*/
+typedef opus_int64 (*op_tell_func)(void *_stream);
+
+/**Closes the underlying stream.
+   \retval 0   Success.
+   \retval EOF An error occurred.
+               <code>errno</code> need not be set.*/
+typedef int (*op_close_func)(void *_stream);
+
+/**The callbacks used to access non-<code>FILE</code> stream resources.
+   The function prototypes are basically the same as for the stdio functions
+    <code>fread()</code>, <code>fseek()</code>, <code>ftell()</code>, and
+    <code>fclose()</code>.
+   The differences are that the <code>FILE *</code> arguments have been
+    replaced with a <code>void *</code>, which is to be used as a pointer to
+    whatever internal data these functions might need, that #seek and #tell
+    take and return 64-bit offsets, and that #seek <em>must</em> return -1 if
+    the stream is unseekable.*/
+struct OpusFileCallbacks{
+  /**Used to read data from the stream.
+     This must not be <code>NULL</code>.*/
+  op_read_func  read;
+  /**Used to seek in the stream.
+     This may be <code>NULL</code> if seeking is not implemented.*/
+  op_seek_func  seek;
+  /**Used to return the current read position in the stream.
+     This may be <code>NULL</code> if seeking is not implemented.*/
+  op_tell_func  tell;
+  /**Used to close the stream when the decoder is freed.
+     This may be <code>NULL</code> to leave the stream open.*/
+  op_close_func close;
+};
+
+/**Opens a stream with <code>fopen()</code> and fills in a set of callbacks
+    that can be used to access it.
+   This is useful to avoid writing your own portable 64-bit seeking wrappers,
+    and also avoids cross-module linking issues on Windows, where a
+    <code>FILE *</code> must be accessed by routines defined in the same module
+    that opened it.
+   \param[out] _cb   The callbacks to use for this file.
+                     If there is an error opening the file, nothing will be
+                      filled in here.
+   \param      _path The path to the file to open.
+                     On Windows, this string must be UTF-8 (to allow access to
+                      files whose names cannot be represented in the current
+                      MBCS code page).
+                     All other systems use the native character encoding.
+   \param      _mode The mode to open the file in.
+   \return A stream handle to use with the callbacks, or <code>NULL</code> on
+            error.*/
+OP_WARN_UNUSED_RESULT void *op_fopen(OpusFileCallbacks *_cb,
+ const char *_path,const char *_mode) OP_ARG_NONNULL(1) OP_ARG_NONNULL(2)
+ OP_ARG_NONNULL(3);
+
+/**Opens a stream with <code>fdopen()</code> and fills in a set of callbacks
+    that can be used to access it.
+   This is useful to avoid writing your own portable 64-bit seeking wrappers,
+    and also avoids cross-module linking issues on Windows, where a
+    <code>FILE *</code> must be accessed by routines defined in the same module
+    that opened it.
+   \param[out] _cb   The callbacks to use for this file.
+                     If there is an error opening the file, nothing will be
+                      filled in here.
+   \param      _fd   The file descriptor to open.
+   \param      _mode The mode to open the file in.
+   \return A stream handle to use with the callbacks, or <code>NULL</code> on
+            error.*/
+OP_WARN_UNUSED_RESULT void *op_fdopen(OpusFileCallbacks *_cb,
+ int _fd,const char *_mode) OP_ARG_NONNULL(1) OP_ARG_NONNULL(3);
+
+/**Opens a stream with <code>freopen()</code> and fills in a set of callbacks
+    that can be used to access it.
+   This is useful to avoid writing your own portable 64-bit seeking wrappers,
+    and also avoids cross-module linking issues on Windows, where a
+    <code>FILE *</code> must be accessed by routines defined in the same module
+    that opened it.
+   \param[out] _cb     The callbacks to use for this file.
+                       If there is an error opening the file, nothing will be
+                        filled in here.
+   \param      _path   The path to the file to open.
+                       On Windows, this string must be UTF-8 (to allow access
+                        to files whose names cannot be represented in the
+                        current MBCS code page).
+                       All other systems use the native character encoding.
+   \param      _mode   The mode to open the file in.
+   \param      _stream A stream previously returned by op_fopen(), op_fdopen(),
+                        or op_freopen().
+   \return A stream handle to use with the callbacks, or <code>NULL</code> on
+            error.*/
+OP_WARN_UNUSED_RESULT void *op_freopen(OpusFileCallbacks *_cb,
+ const char *_path,const char *_mode,void *_stream) OP_ARG_NONNULL(1)
+ OP_ARG_NONNULL(2) OP_ARG_NONNULL(3) OP_ARG_NONNULL(4);
+
+/**Creates a stream that reads from the given block of memory.
+   This block of memory must contain the complete stream to decode.
+   This is useful for caching small streams (e.g., sound effects) in RAM.
+   \param[out] _cb   The callbacks to use for this stream.
+                     If there is an error creating the stream, nothing will be
+                      filled in here.
+   \param      _data The block of memory to read from.
+   \param      _size The size of the block of memory.
+   \return A stream handle to use with the callbacks, or <code>NULL</code> on
+            error.*/
+OP_WARN_UNUSED_RESULT void *op_mem_stream_create(OpusFileCallbacks *_cb,
+ const unsigned char *_data,size_t _size) OP_ARG_NONNULL(1);
+
+/**Creates a stream that reads from the given URL.
+   This function behaves identically to op_url_stream_create(), except that it
+    takes a va_list instead of a variable number of arguments.
+   It does not call the <code>va_end</code> macro, and because it invokes the
+    <code>va_arg</code> macro, the value of \a _ap is undefined after the call.
+   \note If you use this function, you must link against <tt>libopusurl</tt>.
+   \param[out]    _cb  The callbacks to use for this stream.
+                       If there is an error creating the stream, nothing will
+                        be filled in here.
+   \param         _url The URL to read from.
+                       Currently only the <file:>, <http:>, and <https:>
+                        schemes are supported.
+                       Both <http:> and <https:> may be disabled at compile
+                        time, in which case opening such URLs will always fail.
+                       Currently this only supports URIs.
+                       IRIs should be converted to UTF-8 and URL-escaped, with
+                        internationalized domain names encoded in punycode,
+                        before passing them to this function.
+   \param[in,out] _ap  A list of the \ref url_options "optional flags" to use.
+                       This is a variable-length list of options terminated
+                        with <code>NULL</code>.
+   \return A stream handle to use with the callbacks, or <code>NULL</code> on
+            error.*/
+OP_WARN_UNUSED_RESULT void *op_url_stream_vcreate(OpusFileCallbacks *_cb,
+ const char *_url,va_list _ap) OP_ARG_NONNULL(1) OP_ARG_NONNULL(2);
+
+/**Creates a stream that reads from the given URL.
+   \note If you use this function, you must link against <tt>libopusurl</tt>.
+   \param[out] _cb  The callbacks to use for this stream.
+                    If there is an error creating the stream, nothing will be
+                     filled in here.
+   \param      _url The URL to read from.
+                    Currently only the <file:>, <http:>, and <https:> schemes
+                     are supported.
+                    Both <http:> and <https:> may be disabled at compile time,
+                     in which case opening such URLs will always fail.
+                    Currently this only supports URIs.
+                    IRIs should be converted to UTF-8 and URL-escaped, with
+                     internationalized domain names encoded in punycode, before
+                     passing them to this function.
+   \param      ...  The \ref url_options "optional flags" to use.
+                    This is a variable-length list of options terminated with
+                     <code>NULL</code>.
+   \return A stream handle to use with the callbacks, or <code>NULL</code> on
+            error.*/
+OP_WARN_UNUSED_RESULT void *op_url_stream_create(OpusFileCallbacks *_cb,
+ const char *_url,...) OP_ARG_NONNULL(1) OP_ARG_NONNULL(2);
+
+/*@}*/
+/*@}*/
+
+/**\defgroup stream_open_close Opening and Closing*/
+/*@{*/
+/**\name Functions for opening and closing streams
+
+   These functions allow you to test a stream to see if it is Opus, open it,
+    and close it.
+   Several flavors are provided for each of the built-in stream types, plus a
+    more general version which takes a set of application-provided callbacks.*/
+/*@{*/
+
+/**Test to see if this is an Opus stream.
+   For good results, you will need at least 57 bytes (for a pure Opus-only
+    stream).
+   Something like 512 bytes will give more reliable results for multiplexed
+    streams.
+   This function is meant to be a quick-rejection filter.
+   Its purpose is not to guarantee that a stream is a valid Opus stream, but to
+    ensure that it looks enough like Opus that it isn't going to be recognized
+    as some other format (except possibly an Opus stream that is also
+    multiplexed with other codecs, such as video).
+   \param[out] _head     The parsed ID header contents.
+                         You may pass <code>NULL</code> if you do not need
+                          this information.
+                         If the function fails, the contents of this structure
+                          remain untouched.
+   \param _initial_data  An initial buffer of data from the start of the
+                          stream.
+   \param _initial_bytes The number of bytes in \a _initial_data.
+   \return 0 if the data appears to be Opus, or a negative value on error.
+   \retval #OP_FALSE      There was not enough data to tell if this was an Opus
+                           stream or not.
+   \retval #OP_EFAULT     An internal memory allocation failed.
+   \retval #OP_EIMPL      The stream used a feature that is not implemented,
+                           such as an unsupported channel family.
+   \retval #OP_ENOTFORMAT If the data did not contain a recognizable ID
+                           header for an Opus stream.
+   \retval #OP_EVERSION   If the version field signaled a version this library
+                           does not know how to parse.
+   \retval #OP_EBADHEADER The ID header was not properly formatted or contained
+                           illegal values.*/
+int op_test(OpusHead *_head,
+ const unsigned char *_initial_data,size_t _initial_bytes);
+
+/**Open a stream from the given file path.
+   \param      _path  The path to the file to open.
+   \param[out] _error Returns 0 on success, or a failure code on error.
+                      You may pass in <code>NULL</code> if you don't want the
+                       failure code.
+                      The failure code will be #OP_EFAULT if the file could not
+                       be opened, or one of the other failure codes from
+                       op_open_callbacks() otherwise.
+   \return A freshly opened \c OggOpusFile, or <code>NULL</code> on error.*/
+OP_WARN_UNUSED_RESULT OggOpusFile *op_open_file(const char *_path,int *_error)
+ OP_ARG_NONNULL(1);
+
+/**Open a stream from a memory buffer.
+   \param      _data  The memory buffer to open.
+   \param      _size  The number of bytes in the buffer.
+   \param[out] _error Returns 0 on success, or a failure code on error.
+                      You may pass in <code>NULL</code> if you don't want the
+                       failure code.
+                      See op_open_callbacks() for a full list of failure codes.
+   \return A freshly opened \c OggOpusFile, or <code>NULL</code> on error.*/
+OP_WARN_UNUSED_RESULT OggOpusFile *op_open_memory(const unsigned char *_data,
+ size_t _size,int *_error);
+
+/**Open a stream from a URL.
+   This function behaves identically to op_open_url(), except that it
+    takes a va_list instead of a variable number of arguments.
+   It does not call the <code>va_end</code> macro, and because it invokes the
+    <code>va_arg</code> macro, the value of \a _ap is undefined after the call.
+   \note If you use this function, you must link against <tt>libopusurl</tt>.
+   \param         _url   The URL to open.
+                         Currently only the <file:>, <http:>, and <https:>
+                          schemes are supported.
+                         Both <http:> and <https:> may be disabled at compile
+                          time, in which case opening such URLs will always
+                          fail.
+                         Currently this only supports URIs.
+                         IRIs should be converted to UTF-8 and URL-escaped,
+                          with internationalized domain names encoded in
+                          punycode, before passing them to this function.
+   \param[out]    _error Returns 0 on success, or a failure code on error.
+                         You may pass in <code>NULL</code> if you don't want
+                          the failure code.
+                         See op_open_callbacks() for a full list of failure
+                          codes.
+   \param[in,out] _ap    A list of the \ref url_options "optional flags" to
+                          use.
+                         This is a variable-length list of options terminated
+                          with <code>NULL</code>.
+   \return A freshly opened \c OggOpusFile, or <code>NULL</code> on error.*/
+OP_WARN_UNUSED_RESULT OggOpusFile *op_vopen_url(const char *_url,
+ int *_error,va_list _ap) OP_ARG_NONNULL(1);
+
+/**Open a stream from a URL.
+   \note If you use this function, you must link against <tt>libopusurl</tt>.
+   \param      _url   The URL to open.
+                      Currently only the <file:>, <http:>, and <https:> schemes
+                       are supported.
+                      Both <http:> and <https:> may be disabled at compile
+                       time, in which case opening such URLs will always fail.
+                      Currently this only supports URIs.
+                      IRIs should be converted to UTF-8 and URL-escaped, with
+                       internationalized domain names encoded in punycode,
+                       before passing them to this function.
+   \param[out] _error Returns 0 on success, or a failure code on error.
+                      You may pass in <code>NULL</code> if you don't want the
+                       failure code.
+                      See op_open_callbacks() for a full list of failure codes.
+   \param      ...    The \ref url_options "optional flags" to use.
+                      This is a variable-length list of options terminated with
+                       <code>NULL</code>.
+   \return A freshly opened \c OggOpusFile, or <code>NULL</code> on error.*/
+OP_WARN_UNUSED_RESULT OggOpusFile *op_open_url(const char *_url,
+ int *_error,...) OP_ARG_NONNULL(1);
+
+/**Open a stream using the given set of callbacks to access it.
+   \param _stream        The stream to read from (e.g., a <code>FILE *</code>).
+                         This value will be passed verbatim as the first
+                          argument to all of the callbacks.
+   \param _cb            The callbacks with which to access the stream.
+                         <code><a href="#op_read_func">read()</a></code> must
+                          be implemented.
+                         <code><a href="#op_seek_func">seek()</a></code> and
+                          <code><a href="#op_tell_func">tell()</a></code> may
+                          be <code>NULL</code>, or may always return -1 to
+                          indicate a stream is unseekable, but if
+                          <code><a href="#op_seek_func">seek()</a></code> is
+                          implemented and succeeds on a particular stream, then
+                          <code><a href="#op_tell_func">tell()</a></code> must
+                          also.
+                         <code><a href="#op_close_func">close()</a></code> may
+                          be <code>NULL</code>, but if it is not, it will be
+                          called when the \c OggOpusFile is destroyed by
+                          op_free().
+                         It will not be called if op_open_callbacks() fails
+                          with an error.
+   \param _initial_data  An initial buffer of data from the start of the
+                          stream.
+                         Applications can read some number of bytes from the
+                          start of the stream to help identify this as an Opus
+                          stream, and then provide them here to allow the
+                          stream to be opened, even if it is unseekable.
+   \param _initial_bytes The number of bytes in \a _initial_data.
+                         If the stream is seekable, its current position (as
+                          reported by
+                          <code><a href="#opus_tell_func">tell()</a></code>
+                          at the start of this function) must be equal to
+                          \a _initial_bytes.
+                         Otherwise, seeking to absolute positions will
+                          generate inconsistent results.
+   \param[out] _error    Returns 0 on success, or a failure code on error.
+                         You may pass in <code>NULL</code> if you don't want
+                          the failure code.
+                         The failure code will be one of
+                         <dl>
+                           <dt>#OP_EREAD</dt>
+                           <dd>An underlying read, seek, or tell operation
+                            failed when it should have succeeded, or we failed
+                            to find data in the stream we had seen before.</dd>
+                           <dt>#OP_EFAULT</dt>
+                           <dd>There was a memory allocation failure, or an
+                            internal library error.</dd>
+                           <dt>#OP_EIMPL</dt>
+                           <dd>The stream used a feature that is not
+                            implemented, such as an unsupported channel
+                            family.</dd>
+                           <dt>#OP_EINVAL</dt>
+                           <dd><code><a href="#op_seek_func">seek()</a></code>
+                            was implemented and succeeded on this source, but
+                            <code><a href="#op_tell_func">tell()</a></code>
+                            did not, or the starting position indicator was
+                            not equal to \a _initial_bytes.</dd>
+                           <dt>#OP_ENOTFORMAT</dt>
+                           <dd>The stream contained a link that did not have
+                            any logical Opus streams in it.</dd>
+                           <dt>#OP_EBADHEADER</dt>
+                           <dd>A required header packet was not properly
+                            formatted, contained illegal values, or was missing
+                            altogether.</dd>
+                           <dt>#OP_EVERSION</dt>
+                           <dd>An ID header contained an unrecognized version
+                            number.</dd>
+                           <dt>#OP_EBADLINK</dt>
+                           <dd>We failed to find data we had seen before after
+                            seeking.</dd>
+                           <dt>#OP_EBADTIMESTAMP</dt>
+                           <dd>The first or last timestamp in a link failed
+                            basic validity checks.</dd>
+                         </dl>
+   \return A freshly opened \c OggOpusFile, or <code>NULL</code> on error.
+           <tt>libopusfile</tt> does <em>not</em> take ownership of the stream
+            if the call fails.
+           The calling application is responsible for closing the stream if
+            this call returns an error.*/
+OP_WARN_UNUSED_RESULT OggOpusFile *op_open_callbacks(void *_stream,
+ const OpusFileCallbacks *_cb,const unsigned char *_initial_data,
+ size_t _initial_bytes,int *_error) OP_ARG_NONNULL(2);
+
+/**Partially open a stream from the given file path.
+   \see op_test_callbacks
+   \param      _path  The path to the file to open.
+   \param[out] _error Returns 0 on success, or a failure code on error.
+                      You may pass in <code>NULL</code> if you don't want the
+                       failure code.
+                      The failure code will be #OP_EFAULT if the file could not
+                       be opened, or one of the other failure codes from
+                       op_open_callbacks() otherwise.
+   \return A partially opened \c OggOpusFile, or <code>NULL</code> on error.*/
+OP_WARN_UNUSED_RESULT OggOpusFile *op_test_file(const char *_path,int *_error)
+ OP_ARG_NONNULL(1);
+
+/**Partially open a stream from a memory buffer.
+   \see op_test_callbacks
+   \param      _data  The memory buffer to open.
+   \param      _size  The number of bytes in the buffer.
+   \param[out] _error Returns 0 on success, or a failure code on error.
+                      You may pass in <code>NULL</code> if you don't want the
+                       failure code.
+                      See op_open_callbacks() for a full list of failure codes.
+   \return A partially opened \c OggOpusFile, or <code>NULL</code> on error.*/
+OP_WARN_UNUSED_RESULT OggOpusFile *op_test_memory(const unsigned char *_data,
+ size_t _size,int *_error);
+
+/**Partially open a stream from a URL.
+   This function behaves identically to op_test_url(), except that it
+    takes a va_list instead of a variable number of arguments.
+   It does not call the <code>va_end</code> macro, and because it invokes the
+    <code>va_arg</code> macro, the value of \a _ap is undefined after the call.
+   \note If you use this function, you must link against <tt>libopusurl</tt>.
+   \see op_test_url
+   \see op_test_callbacks
+   \param         _url    The URL to open.
+                          Currently only the <file:>, <http:>, and <https:>
+                           schemes are supported.
+                          Both <http:> and <https:> may be disabled at compile
+                           time, in which case opening such URLs will always
+                           fail.
+                          Currently this only supports URIs.
+                          IRIs should be converted to UTF-8 and URL-escaped,
+                           with internationalized domain names encoded in
+                           punycode, before passing them to this function.
+   \param[out]    _error  Returns 0 on success, or a failure code on error.
+                          You may pass in <code>NULL</code> if you don't want
+                           the failure code.
+                          See op_open_callbacks() for a full list of failure
+                           codes.
+   \param[in,out] _ap     A list of the \ref url_options "optional flags" to
+                           use.
+                          This is a variable-length list of options terminated
+                           with <code>NULL</code>.
+   \return A partially opened \c OggOpusFile, or <code>NULL</code> on error.*/
+OP_WARN_UNUSED_RESULT OggOpusFile *op_vtest_url(const char *_url,
+ int *_error,va_list _ap) OP_ARG_NONNULL(1);
+
+/**Partially open a stream from a URL.
+   \note If you use this function, you must link against <tt>libopusurl</tt>.
+   \see op_test_callbacks
+   \param      _url    The URL to open.
+                       Currently only the <file:>, <http:>, and <https:>
+                        schemes are supported.
+                       Both <http:> and <https:> may be disabled at compile
+                        time, in which case opening such URLs will always fail.
+                       Currently this only supports URIs.
+                       IRIs should be converted to UTF-8 and URL-escaped, with
+                        internationalized domain names encoded in punycode,
+                        before passing them to this function.
+   \param[out] _error  Returns 0 on success, or a failure code on error.
+                       You may pass in <code>NULL</code> if you don't want the
+                        failure code.
+                       See op_open_callbacks() for a full list of failure
+                        codes.
+   \param      ...     The \ref url_options "optional flags" to use.
+                       This is a variable-length list of options terminated
+                        with <code>NULL</code>.
+   \return A partially opened \c OggOpusFile, or <code>NULL</code> on error.*/
+OP_WARN_UNUSED_RESULT OggOpusFile *op_test_url(const char *_url,
+ int *_error,...) OP_ARG_NONNULL(1);
+
+/**Partially open a stream using the given set of callbacks to access it.
+   This tests for Opusness and loads the headers for the first link.
+   It does not seek (although it tests for seekability).
+   You can query a partially open stream for the few pieces of basic
+    information returned by op_serialno(), op_channel_count(), op_head(), and
+    op_tags() (but only for the first link).
+   You may also determine if it is seekable via a call to op_seekable().
+   You cannot read audio from the stream, seek, get the size or duration,
+    get information from links other than the first one, or even get the total
+    number of links until you finish opening the stream with op_test_open().
+   If you do not need to do any of these things, you can dispose of it with
+    op_free() instead.
+
+   This function is provided mostly to simplify porting existing code that used
+    <tt>libvorbisfile</tt>.
+   For new code, you are likely better off using op_test() instead, which
+    is less resource-intensive, requires less data to succeed, and imposes a
+    hard limit on the amount of data it examines (important for unseekable
+    streams, where all such data must be buffered until you are sure of the
+    stream type).
+   \param _stream        The stream to read from (e.g., a <code>FILE *</code>).
+                         This value will be passed verbatim as the first
+                          argument to all of the callbacks.
+   \param _cb            The callbacks with which to access the stream.
+                         <code><a href="#op_read_func">read()</a></code> must
+                          be implemented.
+                         <code><a href="#op_seek_func">seek()</a></code> and
+                          <code><a href="#op_tell_func">tell()</a></code> may
+                          be <code>NULL</code>, or may always return -1 to
+                          indicate a stream is unseekable, but if
+                          <code><a href="#op_seek_func">seek()</a></code> is
+                          implemented and succeeds on a particular stream, then
+                          <code><a href="#op_tell_func">tell()</a></code> must
+                          also.
+                         <code><a href="#op_close_func">close()</a></code> may
+                          be <code>NULL</code>, but if it is not, it will be
+                          called when the \c OggOpusFile is destroyed by
+                          op_free().
+                         It will not be called if op_open_callbacks() fails
+                          with an error.
+   \param _initial_data  An initial buffer of data from the start of the
+                          stream.
+                         Applications can read some number of bytes from the
+                          start of the stream to help identify this as an Opus
+                          stream, and then provide them here to allow the
+                          stream to be tested more thoroughly, even if it is
+                          unseekable.
+   \param _initial_bytes The number of bytes in \a _initial_data.
+                         If the stream is seekable, its current position (as
+                          reported by
+                          <code><a href="#opus_tell_func">tell()</a></code>
+                          at the start of this function) must be equal to
+                          \a _initial_bytes.
+                         Otherwise, seeking to absolute positions will
+                          generate inconsistent results.
+   \param[out] _error    Returns 0 on success, or a failure code on error.
+                         You may pass in <code>NULL</code> if you don't want
+                          the failure code.
+                         See op_open_callbacks() for a full list of failure
+                          codes.
+   \return A partially opened \c OggOpusFile, or <code>NULL</code> on error.
+           <tt>libopusfile</tt> does <em>not</em> take ownership of the stream
+            if the call fails.
+           The calling application is responsible for closing the stream if
+            this call returns an error.*/
+OP_WARN_UNUSED_RESULT OggOpusFile *op_test_callbacks(void *_stream,
+ const OpusFileCallbacks *_cb,const unsigned char *_initial_data,
+ size_t _initial_bytes,int *_error) OP_ARG_NONNULL(2);
+
+/**Finish opening a stream partially opened with op_test_callbacks() or one of
+    the associated convenience functions.
+   If this function fails, you are still responsible for freeing the
+    \c OggOpusFile with op_free().
+   \param _of The \c OggOpusFile to finish opening.
+   \return 0 on success, or a negative value on error.
+   \retval #OP_EREAD         An underlying read, seek, or tell operation failed
+                              when it should have succeeded.
+   \retval #OP_EFAULT        There was a memory allocation failure, or an
+                              internal library error.
+   \retval #OP_EIMPL         The stream used a feature that is not implemented,
+                              such as an unsupported channel family.
+   \retval #OP_EINVAL        The stream was not partially opened with
+                              op_test_callbacks() or one of the associated
+                              convenience functions.
+   \retval #OP_ENOTFORMAT    The stream contained a link that did not have any
+                              logical Opus streams in it.
+   \retval #OP_EBADHEADER    A required header packet was not properly
+                              formatted, contained illegal values, or was
+                              missing altogether.
+   \retval #OP_EVERSION      An ID header contained an unrecognized version
+                              number.
+   \retval #OP_EBADLINK      We failed to find data we had seen before after
+                              seeking.
+   \retval #OP_EBADTIMESTAMP The first or last timestamp in a link failed basic
+                              validity checks.*/
+int op_test_open(OggOpusFile *_of) OP_ARG_NONNULL(1);
+
+/**Release all memory used by an \c OggOpusFile.
+   \param _of The \c OggOpusFile to free.*/
+void op_free(OggOpusFile *_of);
+
+/*@}*/
+/*@}*/
+
+/**\defgroup stream_info Stream Information*/
+/*@{*/
+/**\name Functions for obtaining information about streams
+
+   These functions allow you to get basic information about a stream, including
+    seekability, the number of links (for chained streams), plus the size,
+    duration, bitrate, header parameters, and meta information for each link
+    (or, where available, the stream as a whole).
+   Some of these (size, duration) are only available for seekable streams.
+   You can also query the current stream position, link, and playback time,
+    and instantaneous bitrate during playback.
+
+   Some of these functions may be used successfully on the partially open
+    streams returned by op_test_callbacks() or one of the associated
+    convenience functions.
+   Their documention will indicate so explicitly.*/
+/*@{*/
+
+/**Returns whether or not the stream being read is seekable.
+   This is true if
+   <ol>
+   <li>The <code><a href="#op_seek_func">seek()</a></code> and
+    <code><a href="#op_tell_func">tell()</a></code> callbacks are both
+    non-<code>NULL</code>,</li>
+   <li>The <code><a href="#op_seek_func">seek()</a></code> callback was
+    successfully executed at least once, and</li>
+   <li>The <code><a href="#op_tell_func">tell()</a></code> callback was
+    successfully able to report the position indicator afterwards.</li>
+   </ol>
+   This function may be called on partially-opened streams.
+   \param _of The \c OggOpusFile whose seekable status is to be returned.
+   \return A non-zero value if seekable, and 0 if unseekable.*/
+int op_seekable(const OggOpusFile *_of) OP_ARG_NONNULL(1);
+
+/**Returns the number of links in this chained stream.
+   This function may be called on partially-opened streams, but it will always
+    return 1.
+   The actual number of links is not known until the stream is fully opened.
+   \param _of The \c OggOpusFile from which to retrieve the link count.
+   \return For fully-open seekable streams, this returns the total number of
+            links in the whole stream, which will be at least 1.
+           For partially-open or unseekable streams, this always returns 1.*/
+int op_link_count(const OggOpusFile *_of) OP_ARG_NONNULL(1);
+
+/**Get the serial number of the given link in a (possibly-chained) Ogg Opus
+    stream.
+   This function may be called on partially-opened streams, but it will always
+    return the serial number of the Opus stream in the first link.
+   \param _of The \c OggOpusFile from which to retrieve the serial number.
+   \param _li The index of the link whose serial number should be retrieved.
+              Use a negative number to get the serial number of the current
+               link.
+   \return The serial number of the given link.
+           If \a _li is greater than the total number of links, this returns
+            the serial number of the last link.
+           If the stream is not seekable, this always returns the serial number
+            of the current link.*/
+opus_uint32 op_serialno(const OggOpusFile *_of,int _li) OP_ARG_NONNULL(1);
+
+/**Get the channel count of the given link in a (possibly-chained) Ogg Opus
+    stream.
+   This is equivalent to <code>op_head(_of,_li)->channel_count</code>, but
+    is provided for convenience.
+   This function may be called on partially-opened streams, but it will always
+    return the channel count of the Opus stream in the first link.
+   \param _of The \c OggOpusFile from which to retrieve the channel count.
+   \param _li The index of the link whose channel count should be retrieved.
+              Use a negative number to get the channel count of the current
+               link.
+   \return The channel count of the given link.
+           If \a _li is greater than the total number of links, this returns
+            the channel count of the last link.
+           If the stream is not seekable, this always returns the channel count
+            of the current link.*/
+int op_channel_count(const OggOpusFile *_of,int _li) OP_ARG_NONNULL(1);
+
+/**Get the total (compressed) size of the stream, or of an individual link in
+    a (possibly-chained) Ogg Opus stream, including all headers and Ogg muxing
+    overhead.
+   \warning If the Opus stream (or link) is concurrently multiplexed with other
+    logical streams (e.g., video), this returns the size of the entire stream
+    (or link), not just the number of bytes in the first logical Opus stream.
+   Returning the latter would require scanning the entire file.
+   \param _of The \c OggOpusFile from which to retrieve the compressed size.
+   \param _li The index of the link whose compressed size should be computed.
+              Use a negative number to get the compressed size of the entire
+               stream.
+   \return The compressed size of the entire stream if \a _li is negative, the
+            compressed size of link \a _li if it is non-negative, or a negative
+            value on error.
+           The compressed size of the entire stream may be smaller than that
+            of the underlying stream if trailing garbage was detected in the
+            file.
+   \retval #OP_EINVAL The stream is not seekable (so we can't know the length),
+                       \a _li wasn't less than the total number of links in
+                       the stream, or the stream was only partially open.*/
+opus_int64 op_raw_total(const OggOpusFile *_of,int _li) OP_ARG_NONNULL(1);
+
+/**Get the total PCM length (number of samples at 48 kHz) of the stream, or of
+    an individual link in a (possibly-chained) Ogg Opus stream.
+   Users looking for <code>op_time_total()</code> should use op_pcm_total()
+    instead.
+   Because timestamps in Opus are fixed at 48 kHz, there is no need for a
+    separate function to convert this to seconds (and leaving it out avoids
+    introducing floating point to the API, for those that wish to avoid it).
+   \param _of The \c OggOpusFile from which to retrieve the PCM offset.
+   \param _li The index of the link whose PCM length should be computed.
+              Use a negative number to get the PCM length of the entire stream.
+   \return The PCM length of the entire stream if \a _li is negative, the PCM
+            length of link \a _li if it is non-negative, or a negative value on
+            error.
+   \retval #OP_EINVAL The stream is not seekable (so we can't know the length),
+                       \a _li wasn't less than the total number of links in
+                       the stream, or the stream was only partially open.*/
+ogg_int64_t op_pcm_total(const OggOpusFile *_of,int _li) OP_ARG_NONNULL(1);
+
+/**Get the ID header information for the given link in a (possibly chained) Ogg
+    Opus stream.
+   This function may be called on partially-opened streams, but it will always
+    return the ID header information of the Opus stream in the first link.
+   \param _of The \c OggOpusFile from which to retrieve the ID header
+               information.
+   \param _li The index of the link whose ID header information should be
+               retrieved.
+              Use a negative number to get the ID header information of the
+               current link.
+              For an unseekable stream, \a _li is ignored, and the ID header
+               information for the current link is always returned, if
+               available.
+   \return The contents of the ID header for the given link.*/
+const OpusHead *op_head(const OggOpusFile *_of,int _li) OP_ARG_NONNULL(1);
+
+/**Get the comment header information for the given link in a (possibly
+    chained) Ogg Opus stream.
+   This function may be called on partially-opened streams, but it will always
+    return the tags from the Opus stream in the first link.
+   \param _of The \c OggOpusFile from which to retrieve the comment header
+               information.
+   \param _li The index of the link whose comment header information should be
+               retrieved.
+              Use a negative number to get the comment header information of
+               the current link.
+              For an unseekable stream, \a _li is ignored, and the comment
+               header information for the current link is always returned, if
+               available.
+   \return The contents of the comment header for the given link, or
+            <code>NULL</code> if this is an unseekable stream that encountered
+            an invalid link.*/
+const OpusTags *op_tags(const OggOpusFile *_of,int _li) OP_ARG_NONNULL(1);
+
+/**Retrieve the index of the current link.
+   This is the link that produced the data most recently read by
+    op_read_float() or its associated functions, or, after a seek, the link
+    that the seek target landed in.
+   Reading more data may advance the link index (even on the first read after a
+    seek).
+   \param _of The \c OggOpusFile from which to retrieve the current link index.
+   \return The index of the current link on success, or a negative value on
+            failure.
+           For seekable streams, this is a number between 0 (inclusive) and the
+            value returned by op_link_count() (exclusive).
+           For unseekable streams, this value starts at 0 and increments by one
+            each time a new link is encountered (even though op_link_count()
+            always returns 1).
+   \retval #OP_EINVAL The stream was only partially open.*/
+int op_current_link(const OggOpusFile *_of) OP_ARG_NONNULL(1);
+
+/**Computes the bitrate of the stream, or of an individual link in a
+    (possibly-chained) Ogg Opus stream.
+   The stream must be seekable to compute the bitrate.
+   For unseekable streams, use op_bitrate_instant() to get periodic estimates.
+   \warning If the Opus stream (or link) is concurrently multiplexed with other
+    logical streams (e.g., video), this uses the size of the entire stream (or
+    link) to compute the bitrate, not just the number of bytes in the first
+    logical Opus stream.
+   Returning the latter requires scanning the entire file, but this may be done
+    by decoding the whole file and calling op_bitrate_instant() once at the
+    end.
+   Install a trivial decoding callback with op_set_decode_callback() if you
+    wish to skip actual decoding during this process.
+   \param _of The \c OggOpusFile from which to retrieve the bitrate.
+   \param _li The index of the link whose bitrate should be computed.
+              Use a negative number to get the bitrate of the whole stream.
+   \return The bitrate on success, or a negative value on error.
+   \retval #OP_EINVAL The stream was only partially open, the stream was not
+                       seekable, or \a _li was larger than the number of
+                       links.*/
+opus_int32 op_bitrate(const OggOpusFile *_of,int _li) OP_ARG_NONNULL(1);
+
+/**Compute the instantaneous bitrate, measured as the ratio of bits to playable
+    samples decoded since a) the last call to op_bitrate_instant(), b) the last
+    seek, or c) the start of playback, whichever was most recent.
+   This will spike somewhat after a seek or at the start/end of a chain
+    boundary, as pre-skip, pre-roll, and end-trimming causes samples to be
+    decoded but not played.
+   \param _of The \c OggOpusFile from which to retrieve the bitrate.
+   \return The bitrate, in bits per second, or a negative value on error.
+   \retval #OP_FALSE  No data has been decoded since any of the events
+                       described above.
+   \retval #OP_EINVAL The stream was only partially open.*/
+opus_int32 op_bitrate_instant(OggOpusFile *_of) OP_ARG_NONNULL(1);
+
+/**Obtain the current value of the position indicator for \a _of.
+   \param _of The \c OggOpusFile from which to retrieve the position indicator.
+   \return The byte position that is currently being read from.
+   \retval #OP_EINVAL The stream was only partially open.*/
+opus_int64 op_raw_tell(const OggOpusFile *_of) OP_ARG_NONNULL(1);
+
+/**Obtain the PCM offset of the next sample to be read.
+   If the stream is not properly timestamped, this might not increment by the
+    proper amount between reads, or even return monotonically increasing
+    values.
+   \param _of The \c OggOpusFile from which to retrieve the PCM offset.
+   \return The PCM offset of the next sample to be read.
+   \retval #OP_EINVAL The stream was only partially open.*/
+ogg_int64_t op_pcm_tell(const OggOpusFile *_of) OP_ARG_NONNULL(1);
+
+/*@}*/
+/*@}*/
+
+/**\defgroup stream_seeking Seeking*/
+/*@{*/
+/**\name Functions for seeking in Opus streams
+
+   These functions let you seek in Opus streams, if the underlying stream
+    support it.
+   Seeking is implemented for all built-in stream I/O routines, though some
+    individual streams may not be seekable (pipes, live HTTP streams, or HTTP
+    streams from a server that does not support <code>Range</code> requests).
+
+   op_raw_seek() is the fastest: it is guaranteed to perform at most one
+    physical seek, but, since the target is a byte position, makes no guarantee
+    how close to a given time it will come.
+   op_pcm_seek() provides sample-accurate seeking.
+   The number of physical seeks it requires is still quite small (often 1 or
+    2, even in highly variable bitrate streams).
+
+   Seeking in Opus requires decoding some pre-roll amount before playback to
+    allow the internal state to converge (as if recovering from packet loss).
+   This is handled internally by <tt>libopusfile</tt>, but means there is
+    little extra overhead for decoding up to the exact position requested
+    (since it must decode some amount of audio anyway).
+   It also means that decoding after seeking may not return exactly the same
+    values as would be obtained by decoding the stream straight through.
+   However, such differences are expected to be smaller than the loss
+    introduced by Opus's lossy compression.*/
+/*@{*/
+
+/**Seek to a byte offset relative to the <b>compressed</b> data.
+   This also scans packets to update the PCM cursor.
+   It will cross a logical bitstream boundary, but only if it can't get any
+    packets out of the tail of the link to which it seeks.
+   \param _of          The \c OggOpusFile in which to seek.
+   \param _byte_offset The byte position to seek to.
+                       This must be between 0 and #op_raw_total(\a _of,\c -1)
+                        (inclusive).
+   \return 0 on success, or a negative error code on failure.
+   \retval #OP_EREAD    The underlying seek operation failed.
+   \retval #OP_EINVAL   The stream was only partially open, or the target was
+                         outside the valid range for the stream.
+   \retval #OP_ENOSEEK  This stream is not seekable.
+   \retval #OP_EBADLINK Failed to initialize a decoder for a stream for an
+                         unknown reason.*/
+int op_raw_seek(OggOpusFile *_of,opus_int64 _byte_offset) OP_ARG_NONNULL(1);
+
+/**Seek to the specified PCM offset, such that decoding will begin at exactly
+    the requested position.
+   \param _of         The \c OggOpusFile in which to seek.
+   \param _pcm_offset The PCM offset to seek to.
+                      This is in samples at 48 kHz relative to the start of the
+                       stream.
+   \return 0 on success, or a negative value on error.
+   \retval #OP_EREAD    An underlying read or seek operation failed.
+   \retval #OP_EINVAL   The stream was only partially open, or the target was
+                         outside the valid range for the stream.
+   \retval #OP_ENOSEEK  This stream is not seekable.
+   \retval #OP_EBADLINK We failed to find data we had seen before, or the
+                         bitstream structure was sufficiently malformed that
+                         seeking to the target destination was impossible.*/
+int op_pcm_seek(OggOpusFile *_of,ogg_int64_t _pcm_offset) OP_ARG_NONNULL(1);
+
+/*@}*/
+/*@}*/
+
+/**\defgroup stream_decoding Decoding*/
+/*@{*/
+/**\name Functions for decoding audio data
+
+   These functions retrieve actual decoded audio data from the stream.
+   The general functions, op_read() and op_read_float() return 16-bit or
+    floating-point output, both using native endian ordering.
+   The number of channels returned can change from link to link in a chained
+    stream.
+   There are special functions, op_read_stereo() and op_read_float_stereo(),
+    which always output two channels, to simplify applications which do not
+    wish to handle multichannel audio.
+   These downmix multichannel files to two channels, so they can always return
+    samples in the same format for every link in a chained file.
+
+   If the rest of your audio processing chain can handle floating point, the
+    floating-point routines should be preferred, as they prevent clipping and
+    other issues which might be avoided entirely if, e.g., you scale down the
+    volume at some other stage.
+   However, if you intend to consume 16-bit samples directly, the conversion in
+    <tt>libopusfile</tt> provides noise-shaping dithering and, if compiled
+    against <tt>libopus</tt>&nbsp;1.1 or later, soft-clipping prevention.
+
+   <tt>libopusfile</tt> can also be configured at compile time to use the
+    fixed-point <tt>libopus</tt> API.
+   If so, <tt>libopusfile</tt>'s floating-point API may also be disabled.
+   In that configuration, nothing in <tt>libopusfile</tt> will use any
+    floating-point operations, to simplify support on devices without an
+    adequate FPU.
+
+   \warning HTTPS streams may be be vulnerable to truncation attacks if you do
+    not check the error return code from op_read_float() or its associated
+    functions.
+   If the remote peer does not close the connection gracefully (with a TLS
+    "close notify" message), these functions will return #OP_EREAD instead of 0
+    when they reach the end of the file.
+   If you are reading from an <https:> URL (particularly if seeking is not
+    supported), you should make sure to check for this error and warn the user
+    appropriately.*/
+/*@{*/
+
+/**Indicates that the decoding callback should produce signed 16-bit
+    native-endian output samples.*/
+#define OP_DEC_FORMAT_SHORT (7008)
+/**Indicates that the decoding callback should produce 32-bit native-endian
+    float samples.*/
+#define OP_DEC_FORMAT_FLOAT (7040)
+
+/**Indicates that the decoding callback did not decode anything, and that
+    <tt>libopusfile</tt> should decode normally instead.*/
+#define OP_DEC_USE_DEFAULT  (6720)
+
+/**Called to decode an Opus packet.
+   This should invoke the functional equivalent of opus_multistream_decode() or
+    opus_multistream_decode_float(), except that it returns 0 on success
+    instead of the number of decoded samples (which is known a priori).
+   \param _ctx       The application-provided callback context.
+   \param _decoder   The decoder to use to decode the packet.
+   \param[out] _pcm  The buffer to decode into.
+                     This will always have enough room for \a _nchannels of
+                      \a _nsamples samples, which should be placed into this
+                      buffer interleaved.
+   \param _op        The packet to decode.
+                     This will always have its granule position set to a valid
+                      value.
+   \param _nsamples  The number of samples expected from the packet.
+   \param _nchannels The number of channels expected from the packet.
+   \param _format    The desired sample output format.
+                     This is either #OP_DEC_FORMAT_SHORT or
+                      #OP_DEC_FORMAT_FLOAT.
+   \param _li        The index of the link from which this packet was decoded.
+   \return A non-negative value on success, or a negative value on error.
+           Any error codes should be the same as those returned by
+            opus_multistream_decode() or opus_multistream_decode_float().
+           Success codes are as follows:
+   \retval 0                   Decoding was successful.
+                               The application has filled the buffer with
+                                exactly <code>\a _nsamples*\a
+                                _nchannels</code> samples in the requested
+                                format.
+   \retval #OP_DEC_USE_DEFAULT No decoding was done.
+                               <tt>libopusfile</tt> should do the decoding
+                                by itself instead.*/
+typedef int (*op_decode_cb_func)(void *_ctx,OpusMSDecoder *_decoder,void *_pcm,
+ const ogg_packet *_op,int _nsamples,int _nchannels,int _format,int _li);
+
+/**Sets the packet decode callback function.
+   If set, this is called once for each packet that needs to be decoded.
+   This can be used by advanced applications to do additional processing on the
+    compressed or uncompressed data.
+   For example, an application might save the final entropy coder state for
+    debugging and testing purposes, or it might apply additional filters
+    before the downmixing, dithering, or soft-clipping performed by
+    <tt>libopusfile</tt>, so long as these filters do not introduce any
+    latency.
+
+   A call to this function is no guarantee that the audio will eventually be
+    delivered to the application.
+   <tt>libopusfile</tt> may discard some or all of the decoded audio data
+    (i.e., at the beginning or end of a link, or after a seek), however the
+    callback is still required to provide all of it.
+   \param _of        The \c OggOpusFile on which to set the decode callback.
+   \param _decode_cb The callback function to call.
+                     This may be <code>NULL</code> to disable calling the
+                      callback.
+   \param _ctx       The application-provided context pointer to pass to the
+                      callback on each call.*/
+void op_set_decode_callback(OggOpusFile *_of,
+ op_decode_cb_func _decode_cb,void *_ctx) OP_ARG_NONNULL(1);
+
+/**Gain offset type that indicates that the provided offset is relative to the
+    header gain.
+   This is the default.*/
+#define OP_HEADER_GAIN   (0)
+
+/**Gain offset type that indicates that the provided offset is relative to the
+    R128_ALBUM_GAIN value (if any), in addition to the header gain.*/
+#define OP_ALBUM_GAIN    (3007)
+
+/**Gain offset type that indicates that the provided offset is relative to the
+    R128_TRACK_GAIN value (if any), in addition to the header gain.*/
+#define OP_TRACK_GAIN    (3008)
+
+/**Gain offset type that indicates that the provided offset should be used as
+    the gain directly, without applying any the header or track gains.*/
+#define OP_ABSOLUTE_GAIN (3009)
+
+/**Sets the gain to be used for decoded output.
+   By default, the gain in the header is applied with no additional offset.
+   The total gain (including header gain and/or track gain, if applicable, and
+    this offset), will be clamped to [-32768,32767]/256 dB.
+   This is more than enough to saturate or underflow 16-bit PCM.
+   \note The new gain will not be applied to any already buffered, decoded
+    output.
+   This means you cannot change it sample-by-sample, as at best it will be
+    updated packet-by-packet.
+   It is meant for setting a target volume level, rather than applying smooth
+    fades, etc.
+   \param _of             The \c OggOpusFile on which to set the gain offset.
+   \param _gain_type      One of #OP_HEADER_GAIN, #OP_ALBUM_GAIN,
+                           #OP_TRACK_GAIN, or #OP_ABSOLUTE_GAIN.
+   \param _gain_offset_q8 The gain offset to apply, in 1/256ths of a dB.
+   \return 0 on success or a negative value on error.
+   \retval #OP_EINVAL The \a _gain_type was unrecognized.*/
+int op_set_gain_offset(OggOpusFile *_of,
+ int _gain_type,opus_int32 _gain_offset_q8) OP_ARG_NONNULL(1);
+
+/**Sets whether or not dithering is enabled for 16-bit decoding.
+   By default, when <tt>libopusfile</tt> is compiled to use floating-point
+    internally, calling op_read() or op_read_stereo() will first decode to
+    float, and then convert to fixed-point using noise-shaping dithering.
+   This flag can be used to disable that dithering.
+   When the application uses op_read_float() or op_read_float_stereo(), or when
+    the library has been compiled to decode directly to fixed point, this flag
+    has no effect.
+   \param _of      The \c OggOpusFile on which to enable or disable dithering.
+   \param _enabled A non-zero value to enable dithering, or 0 to disable it.*/
+void op_set_dither_enabled(OggOpusFile *_of,int _enabled) OP_ARG_NONNULL(1);
+
+/**Reads more samples from the stream.
+   \note Although \a _buf_size must indicate the total number of values that
+    can be stored in \a _pcm, the return value is the number of samples
+    <em>per channel</em>.
+   This is done because
+   <ol>
+   <li>The channel count cannot be known a priori (reading more samples might
+        advance us into the next link, with a different channel count), so
+        \a _buf_size cannot also be in units of samples per channel,</li>
+   <li>Returning the samples per channel matches the <code>libopus</code> API
+        as closely as we're able,</li>
+   <li>Returning the total number of values instead of samples per channel
+        would mean the caller would need a division to compute the samples per
+        channel, and might worry about the possibility of getting back samples
+        for some channels and not others, and</li>
+   <li>This approach is relatively fool-proof: if an application passes too
+        small a value to \a _buf_size, they will simply get fewer samples back,
+        and if they assume the return value is the total number of values, then
+        they will simply read too few (rather than reading too many and going
+        off the end of the buffer).</li>
+   </ol>
+   \param      _of       The \c OggOpusFile from which to read.
+   \param[out] _pcm      A buffer in which to store the output PCM samples, as
+                          signed native-endian 16-bit values at 48&nbsp;kHz
+                          with a nominal range of <code>[-32768,32767)</code>.
+                         Multiple channels are interleaved using the
+                          <a href="https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-810004.3.9">Vorbis
+                          channel ordering</a>.
+                         This must have room for at least \a _buf_size values.
+   \param      _buf_size The number of values that can be stored in \a _pcm.
+                         It is recommended that this be large enough for at
+                          least 120 ms of data at 48 kHz per channel (5760
+                          values per channel).
+                         Smaller buffers will simply return less data, possibly
+                          consuming more memory to buffer the data internally.
+                         <tt>libopusfile</tt> may return less data than
+                          requested.
+                         If so, there is no guarantee that the remaining data
+                          in \a _pcm will be unmodified.
+   \param[out] _li       The index of the link this data was decoded from.
+                         You may pass <code>NULL</code> if you do not need this
+                          information.
+                         If this function fails (returning a negative value),
+                          this parameter is left unset.
+   \return The number of samples read per channel on success, or a negative
+            value on failure.
+           The channel count can be retrieved on success by calling
+            <code>op_head(_of,*_li)</code>.
+           The number of samples returned may be 0 if the buffer was too small
+            to store even a single sample for all channels, or if end-of-file
+            was reached.
+           The list of possible failure codes follows.
+           Most of them can only be returned by unseekable, chained streams
+            that encounter a new link.
+   \retval #OP_HOLE          There was a hole in the data, and some samples
+                              may have been skipped.
+                             Call this function again to continue decoding
+                              past the hole.
+   \retval #OP_EREAD         An underlying read operation failed.
+                             This may signal a truncation attack from an
+                              <https:> source.
+   \retval #OP_EFAULT        An internal memory allocation failed.
+   \retval #OP_EIMPL         An unseekable stream encountered a new link that
+                              used a feature that is not implemented, such as
+                              an unsupported channel family.
+   \retval #OP_EINVAL        The stream was only partially open.
+   \retval #OP_ENOTFORMAT    An unseekable stream encountered a new link that
+                              did not have any logical Opus streams in it.
+   \retval #OP_EBADHEADER    An unseekable stream encountered a new link with a
+                              required header packet that was not properly
+                              formatted, contained illegal values, or was
+                              missing altogether.
+   \retval #OP_EVERSION      An unseekable stream encountered a new link with
+                              an ID header that contained an unrecognized
+                              version number.
+   \retval #OP_EBADPACKET    Failed to properly decode the next packet.
+   \retval #OP_EBADLINK      We failed to find data we had seen before.
+   \retval #OP_EBADTIMESTAMP An unseekable stream encountered a new link with
+                              a starting timestamp that failed basic validity
+                              checks.*/
+OP_WARN_UNUSED_RESULT int op_read(OggOpusFile *_of,
+ opus_int16 *_pcm,int _buf_size,int *_li) OP_ARG_NONNULL(1);
+
+/**Reads more samples from the stream.
+   \note Although \a _buf_size must indicate the total number of values that
+    can be stored in \a _pcm, the return value is the number of samples
+    <em>per channel</em>.
+   <ol>
+   <li>The channel count cannot be known a priori (reading more samples might
+        advance us into the next link, with a different channel count), so
+        \a _buf_size cannot also be in units of samples per channel,</li>
+   <li>Returning the samples per channel matches the <code>libopus</code> API
+        as closely as we're able,</li>
+   <li>Returning the total number of values instead of samples per channel
+        would mean the caller would need a division to compute the samples per
+        channel, and might worry about the possibility of getting back samples
+        for some channels and not others, and</li>
+   <li>This approach is relatively fool-proof: if an application passes too
+        small a value to \a _buf_size, they will simply get fewer samples back,
+        and if they assume the return value is the total number of values, then
+        they will simply read too few (rather than reading too many and going
+        off the end of the buffer).</li>
+   </ol>
+   \param      _of       The \c OggOpusFile from which to read.
+   \param[out] _pcm      A buffer in which to store the output PCM samples as
+                          signed floats at 48&nbsp;kHz with a nominal range of
+                          <code>[-1.0,1.0]</code>.
+                         Multiple channels are interleaved using the
+                          <a href="https://www.xiph.org/vorbis/doc/Vorbis_I_spec.html#x1-810004.3.9">Vorbis
+                          channel ordering</a>.
+                         This must have room for at least \a _buf_size floats.
+   \param      _buf_size The number of floats that can be stored in \a _pcm.
+                         It is recommended that this be large enough for at
+                          least 120 ms of data at 48 kHz per channel (5760
+                          samples per channel).
+                         Smaller buffers will simply return less data, possibly
+                          consuming more memory to buffer the data internally.
+                         If less than \a _buf_size values are returned,
+                          <tt>libopusfile</tt> makes no guarantee that the
+                          remaining data in \a _pcm will be unmodified.
+   \param[out] _li       The index of the link this data was decoded from.
+                         You may pass <code>NULL</code> if you do not need this
+                          information.
+                         If this function fails (returning a negative value),
+                          this parameter is left unset.
+   \return The number of samples read per channel on success, or a negative
+            value on failure.
+           The channel count can be retrieved on success by calling
+            <code>op_head(_of,*_li)</code>.
+           The number of samples returned may be 0 if the buffer was too small
+            to store even a single sample for all channels, or if end-of-file
+            was reached.
+           The list of possible failure codes follows.
+           Most of them can only be returned by unseekable, chained streams
+            that encounter a new link.
+   \retval #OP_HOLE          There was a hole in the data, and some samples
+                              may have been skipped.
+                             Call this function again to continue decoding
+                              past the hole.
+   \retval #OP_EREAD         An underlying read operation failed.
+                             This may signal a truncation attack from an
+                              <https:> source.
+   \retval #OP_EFAULT        An internal memory allocation failed.
+   \retval #OP_EIMPL         An unseekable stream encountered a new link that
+                              used a feature that is not implemented, such as
+                              an unsupported channel family.
+   \retval #OP_EINVAL        The stream was only partially open.
+   \retval #OP_ENOTFORMAT    An unseekable stream encountered a new link that
+                              did not have any logical Opus streams in it.
+   \retval #OP_EBADHEADER    An unseekable stream encountered a new link with a
+                              required header packet that was not properly
+                              formatted, contained illegal values, or was
+                              missing altogether.
+   \retval #OP_EVERSION      An unseekable stream encountered a new link with
+                              an ID header that contained an unrecognized
+                              version number.
+   \retval #OP_EBADPACKET    Failed to properly decode the next packet.
+   \retval #OP_EBADLINK      We failed to find data we had seen before.
+   \retval #OP_EBADTIMESTAMP An unseekable stream encountered a new link with
+                              a starting timestamp that failed basic validity
+                              checks.*/
+OP_WARN_UNUSED_RESULT int op_read_float(OggOpusFile *_of,
+ float *_pcm,int _buf_size,int *_li) OP_ARG_NONNULL(1);
+
+/**Reads more samples from the stream and downmixes to stereo, if necessary.
+   This function is intended for simple players that want a uniform output
+    format, even if the channel count changes between links in a chained
+    stream.
+   \note \a _buf_size indicates the total number of values that can be stored
+    in \a _pcm, while the return value is the number of samples <em>per
+    channel</em>, even though the channel count is known, for consistency with
+    op_read().
+   \param      _of       The \c OggOpusFile from which to read.
+   \param[out] _pcm      A buffer in which to store the output PCM samples, as
+                          signed native-endian 16-bit values at 48&nbsp;kHz
+                          with a nominal range of <code>[-32768,32767)</code>.
+                         The left and right channels are interleaved in the
+                          buffer.
+                         This must have room for at least \a _buf_size values.
+   \param      _buf_size The number of values that can be stored in \a _pcm.
+                         It is recommended that this be large enough for at
+                          least 120 ms of data at 48 kHz per channel (11520
+                          values total).
+                         Smaller buffers will simply return less data, possibly
+                          consuming more memory to buffer the data internally.
+                         If less than \a _buf_size values are returned,
+                          <tt>libopusfile</tt> makes no guarantee that the
+                          remaining data in \a _pcm will be unmodified.
+   \return The number of samples read per channel on success, or a negative
+            value on failure.
+           The number of samples returned may be 0 if the buffer was too small
+            to store even a single sample for both channels, or if end-of-file
+            was reached.
+           The list of possible failure codes follows.
+           Most of them can only be returned by unseekable, chained streams
+            that encounter a new link.
+   \retval #OP_HOLE          There was a hole in the data, and some samples
+                              may have been skipped.
+                             Call this function again to continue decoding
+                              past the hole.
+   \retval #OP_EREAD         An underlying read operation failed.
+                             This may signal a truncation attack from an
+                              <https:> source.
+   \retval #OP_EFAULT        An internal memory allocation failed.
+   \retval #OP_EIMPL         An unseekable stream encountered a new link that
+                              used a feature that is not implemented, such as
+                              an unsupported channel family.
+   \retval #OP_EINVAL        The stream was only partially open.
+   \retval #OP_ENOTFORMAT    An unseekable stream encountered a new link that
+                              did not have any logical Opus streams in it.
+   \retval #OP_EBADHEADER    An unseekable stream encountered a new link with a
+                              required header packet that was not properly
+                              formatted, contained illegal values, or was
+                              missing altogether.
+   \retval #OP_EVERSION      An unseekable stream encountered a new link with
+                              an ID header that contained an unrecognized
+                              version number.
+   \retval #OP_EBADPACKET    Failed to properly decode the next packet.
+   \retval #OP_EBADLINK      We failed to find data we had seen before.
+   \retval #OP_EBADTIMESTAMP An unseekable stream encountered a new link with
+                              a starting timestamp that failed basic validity
+                              checks.*/
+OP_WARN_UNUSED_RESULT int op_read_stereo(OggOpusFile *_of,
+ opus_int16 *_pcm,int _buf_size) OP_ARG_NONNULL(1);
+
+/**Reads more samples from the stream and downmixes to stereo, if necessary.
+   This function is intended for simple players that want a uniform output
+    format, even if the channel count changes between links in a chained
+    stream.
+   \note \a _buf_size indicates the total number of values that can be stored
+    in \a _pcm, while the return value is the number of samples <em>per
+    channel</em>, even though the channel count is known, for consistency with
+    op_read_float().
+   \param      _of       The \c OggOpusFile from which to read.
+   \param[out] _pcm      A buffer in which to store the output PCM samples, as
+                          signed floats at 48&nbsp;kHz with a nominal range of
+                          <code>[-1.0,1.0]</code>.
+                         The left and right channels are interleaved in the
+                          buffer.
+                         This must have room for at least \a _buf_size values.
+   \param      _buf_size The number of values that can be stored in \a _pcm.
+                         It is recommended that this be large enough for at
+                          least 120 ms of data at 48 kHz per channel (11520
+                          values total).
+                         Smaller buffers will simply return less data, possibly
+                          consuming more memory to buffer the data internally.
+                         If less than \a _buf_size values are returned,
+                          <tt>libopusfile</tt> makes no guarantee that the
+                          remaining data in \a _pcm will be unmodified.
+   \return The number of samples read per channel on success, or a negative
+            value on failure.
+           The number of samples returned may be 0 if the buffer was too small
+            to store even a single sample for both channels, or if end-of-file
+            was reached.
+           The list of possible failure codes follows.
+           Most of them can only be returned by unseekable, chained streams
+            that encounter a new link.
+   \retval #OP_HOLE          There was a hole in the data, and some samples
+                              may have been skipped.
+                             Call this function again to continue decoding
+                              past the hole.
+   \retval #OP_EREAD         An underlying read operation failed.
+                             This may signal a truncation attack from an
+                              <https:> source.
+   \retval #OP_EFAULT        An internal memory allocation failed.
+   \retval #OP_EIMPL         An unseekable stream encountered a new link that
+                              used a feature that is not implemented, such as
+                              an unsupported channel family.
+   \retval #OP_EINVAL        The stream was only partially open.
+   \retval #OP_ENOTFORMAT    An unseekable stream encountered a new link that
+                              that did not have any logical Opus streams in it.
+   \retval #OP_EBADHEADER    An unseekable stream encountered a new link with a
+                              required header packet that was not properly
+                              formatted, contained illegal values, or was
+                              missing altogether.
+   \retval #OP_EVERSION      An unseekable stream encountered a new link with
+                              an ID header that contained an unrecognized
+                              version number.
+   \retval #OP_EBADPACKET    Failed to properly decode the next packet.
+   \retval #OP_EBADLINK      We failed to find data we had seen before.
+   \retval #OP_EBADTIMESTAMP An unseekable stream encountered a new link with
+                              a starting timestamp that failed basic validity
+                              checks.*/
+OP_WARN_UNUSED_RESULT int op_read_float_stereo(OggOpusFile *_of,
+ float *_pcm,int _buf_size) OP_ARG_NONNULL(1);
+
+/*@}*/
+/*@}*/
+
+# if OP_GNUC_PREREQ(4,0)
+#  pragma GCC visibility pop
+# endif
+
+# if defined(__cplusplus)
+}
+# endif
+
+#endif

BIN
Sources/OpusLib/lib/libogg.a


BIN
Sources/OpusLib/lib/libopus.a


BIN
Sources/OpusLib/lib/libopusfile.a


+ 15 - 0
Sources/Resources/Assets.xcassets/AccentColor.colorset/Contents.json

@@ -0,0 +1,15 @@
+{
+  "colors" : [
+    {
+      "color" : {
+        "color-space" : "srgb",
+        "components" : { "red" : "0.000", "green" : "1.000", "blue" : "0.000", "alpha" : "1.000" }
+      },
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

+ 9 - 0
Sources/Resources/Assets.xcassets/AppIcon-Blue.appiconset/Contents.json

@@ -0,0 +1,9 @@
+{
+  "images" : [
+    { "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Sources/Resources/Assets.xcassets/AppIcon-Blue.appiconset/icon_1024.png


+ 9 - 0
Sources/Resources/Assets.xcassets/AppIcon-Cyan.appiconset/Contents.json

@@ -0,0 +1,9 @@
+{
+  "images" : [
+    { "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Sources/Resources/Assets.xcassets/AppIcon-Cyan.appiconset/icon_1024.png


+ 9 - 0
Sources/Resources/Assets.xcassets/AppIcon-Gold.appiconset/Contents.json

@@ -0,0 +1,9 @@
+{
+  "images" : [
+    { "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Sources/Resources/Assets.xcassets/AppIcon-Gold.appiconset/icon_1024.png


+ 9 - 0
Sources/Resources/Assets.xcassets/AppIcon-Green.appiconset/Contents.json

@@ -0,0 +1,9 @@
+{
+  "images" : [
+    { "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Sources/Resources/Assets.xcassets/AppIcon-Green.appiconset/icon_1024.png


+ 9 - 0
Sources/Resources/Assets.xcassets/AppIcon-Lime.appiconset/Contents.json

@@ -0,0 +1,9 @@
+{
+  "images" : [
+    { "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Sources/Resources/Assets.xcassets/AppIcon-Lime.appiconset/icon_1024.png


+ 9 - 0
Sources/Resources/Assets.xcassets/AppIcon-Orange.appiconset/Contents.json

@@ -0,0 +1,9 @@
+{
+  "images" : [
+    { "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Sources/Resources/Assets.xcassets/AppIcon-Orange.appiconset/icon_1024.png


+ 9 - 0
Sources/Resources/Assets.xcassets/AppIcon-Pink.appiconset/Contents.json

@@ -0,0 +1,9 @@
+{
+  "images" : [
+    { "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Sources/Resources/Assets.xcassets/AppIcon-Pink.appiconset/icon_1024.png


+ 9 - 0
Sources/Resources/Assets.xcassets/AppIcon-Purple.appiconset/Contents.json

@@ -0,0 +1,9 @@
+{
+  "images" : [
+    { "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Sources/Resources/Assets.xcassets/AppIcon-Purple.appiconset/icon_1024.png


+ 9 - 0
Sources/Resources/Assets.xcassets/AppIcon-Red.appiconset/Contents.json

@@ -0,0 +1,9 @@
+{
+  "images" : [
+    { "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Sources/Resources/Assets.xcassets/AppIcon-Red.appiconset/icon_1024.png


+ 9 - 0
Sources/Resources/Assets.xcassets/AppIcon-White.appiconset/Contents.json

@@ -0,0 +1,9 @@
+{
+  "images" : [
+    { "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

BIN
Sources/Resources/Assets.xcassets/AppIcon-White.appiconset/icon_1024.png


+ 9 - 0
Sources/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json

@@ -0,0 +1,9 @@
+{
+  "images" : [
+    { "filename" : "icon_1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}

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


+ 6 - 0
Sources/Resources/Assets.xcassets/Contents.json

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

+ 110 - 0
Sources/Services/ArtworkService.swift

@@ -0,0 +1,110 @@
+import AVFoundation
+import Foundation
+import UIKit
+
+/// Loads album artwork from folder images or embedded metadata.
+actor ArtworkService {
+
+    static let shared = ArtworkService()
+
+    /// Cache keyed by track file path (not folder) so each track gets its own artwork.
+    private var cache: [String: UIImage?] = [:]
+
+    private static let coverFileNames: [String] = [
+        "cover", "folder", "front", "album", "albumart",
+        "albumartsmall", "thumb", "artwork", "art",
+        "Cover", "Folder", "Front", "Album"
+    ]
+
+    private static let imageExtensions: Set<String> = [
+        "jpg", "jpeg", "png", "bmp", "gif", "tiff", "webp"
+    ]
+
+    // MARK: - Public API
+
+    /// Get artwork for a track. Pass the URL directly to avoid SwiftData model access issues.
+    func artwork(for trackURL: URL) async -> UIImage? {
+        let trackPath = trackURL.path
+
+        // Check cache (nil value means we already tried and found nothing)
+        if let cached = cache[trackPath] {
+            return cached
+        }
+
+        // Only access files within the app sandbox
+        let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
+        guard trackPath.hasPrefix(docsDir.path) else {
+            cache[trackPath] = Optional<UIImage>.none
+            return nil
+        }
+
+        // 1. Try embedded metadata FIRST (most accurate per-track)
+        if FileManager.default.fileExists(atPath: trackPath),
+           let embedded = await extractEmbeddedArtwork(from: trackURL) {
+            cache[trackPath] = embedded
+            return embedded
+        }
+
+        // 2. Try folder images (cover.jpg etc.)
+        let folderURL = trackURL.deletingLastPathComponent()
+        if let folderArt = findFolderArtwork(in: folderURL) {
+            cache[trackPath] = folderArt
+            return folderArt
+        }
+
+        // Cache nil so we don't retry
+        cache[trackPath] = Optional<UIImage>.none
+        return nil
+    }
+
+    func clearCache() {
+        cache.removeAll()
+    }
+
+    // MARK: - Folder Artwork
+
+    private func findFolderArtwork(in folderURL: URL) -> UIImage? {
+        let fm = FileManager.default
+
+        for name in Self.coverFileNames {
+            for ext in Self.imageExtensions {
+                let candidate = folderURL.appendingPathComponent("\(name).\(ext)")
+                if fm.fileExists(atPath: candidate.path),
+                   let data = try? Data(contentsOf: candidate),
+                   let image = UIImage(data: data) {
+                    return image
+                }
+            }
+        }
+
+        // Fallback: any image file in the folder
+        if let contents = try? fm.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
+            for fileURL in contents {
+                if Self.imageExtensions.contains(fileURL.pathExtension.lowercased()),
+                   let data = try? Data(contentsOf: fileURL),
+                   let image = UIImage(data: data) {
+                    return image
+                }
+            }
+        }
+
+        return nil
+    }
+
+    // MARK: - Embedded Artwork
+
+    private func extractEmbeddedArtwork(from url: URL) async -> UIImage? {
+        let asset = AVURLAsset(url: url)
+        guard let metadata = try? await asset.load(.metadata) else { return nil }
+
+        for item in metadata {
+            guard item.commonKey == .commonKeyArtwork else { continue }
+            if let data = try? await item.load(.dataValue),
+               let image = UIImage(data: data) {
+                return image
+            }
+        }
+
+        return nil
+    }
+}

+ 382 - 0
Sources/Services/AudioEngine.swift

@@ -0,0 +1,382 @@
+import AVFoundation
+import Foundation
+import Observation
+import os
+
+private let logger = Logger(subsystem: "com.mixboard.MixBoardiOS", category: "AudioEngine")
+
+/// Core audio playback engine using AVAudioEngine for high-quality output.
+/// Adapted for iOS with AVAudioSession configuration.
+@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: - 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
+   @ObservationIgnored private var playbackGeneration: Int = 0
+
+   /// Called on main actor when playback finishes naturally.
+   @ObservationIgnored var onPlaybackFinished: (() -> Void)?
+
+   // MARK: - Init
+
+   init() {
+       configureAudioSession()
+       setupAudioChain()
+       observeInterruptions()
+       observeRouteChanges()
+   }
+
+   deinit {
+       NotificationCenter.default.removeObserver(self)
+       engine.stop()
+   }
+
+   // MARK: - iOS Audio Session
+
+   private func configureAudioSession() {
+       let session = AVAudioSession.sharedInstance()
+       do {
+           try session.setCategory(.playback, mode: .default, options: [])
+           try session.setActive(true)
+       } catch {
+           print("AudioEngine: Failed to configure audio session: \(error)")
+       }
+   }
+
+   // MARK: - Audio Chain Setup
+
+   private func setupAudioChain() {
+       engine.attach(playerNode)
+       engine.attach(eqNode)
+
+       configureBandEQ()
+
+       // Connect with nil format — AVAudioEngine will negotiate the format
+       // when we schedule a buffer or segment
+       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
+       playerNode.stop()
+       isPlaying = false
+
+       let url = track.fileURL
+       guard FileManager.default.fileExists(atPath: url.path) else {
+           throw AudioEngineError.fileNotFound(url.lastPathComponent)
+       }
+
+       // Reset OGG state
+       oggBuffer = nil
+       isOGG = false
+       audioFile = nil
+
+       var didDecodeBuffer = false
+       if OGGDecoder.isOGGFile(url) {
+           // Decode OGG Vorbis to PCM buffer
+           logger.notice("Loading OGG file: \(url.lastPathComponent)")
+           let (buffer, format) = try OGGDecoder.decode(url: url)
+           oggBuffer = buffer
+           isOGG = true
+           audioLengthFrames = AVAudioFramePosition(buffer.frameLength)
+           audioSampleRate = format.sampleRate
+           didDecodeBuffer = true
+       }
+       #if !DISABLE_OPUS
+       if !didDecodeBuffer && OpusDecoder.isOpusFile(url) {
+           // Decode Opus to PCM buffer
+           logger.notice("Loading Opus file: \(url.lastPathComponent)")
+           let (buffer, format) = try OpusDecoder.decode(url: url)
+           oggBuffer = buffer
+           isOGG = true  // reuse the same buffer playback path
+           audioLengthFrames = AVAudioFramePosition(buffer.frameLength)
+           audioSampleRate = format.sampleRate
+           didDecodeBuffer = true
+       }
+       #endif
+       if !didDecodeBuffer {
+           logger.notice("Loading audio file: \(url.lastPathComponent)")
+           audioFile = try AVAudioFile(forReading: url)
+           guard let file = audioFile else { return }
+           audioLengthFrames = file.length
+           audioSampleRate = file.processingFormat.sampleRate
+       }
+
+       duration = Double(audioLengthFrames) / audioSampleRate
+       logger.notice("Loaded: duration=\(self.duration)s, sampleRate=\(self.audioSampleRate), frames=\(self.audioLengthFrames)")
+       seekFrame = 0
+       currentTime = 0
+       currentTrack = track
+   }
+
+   func play() {
+       guard audioFile != nil || oggBuffer != nil else { return }
+
+       // Ensure audio session is active
+       try? AVAudioSession.sharedInstance().setActive(true)
+
+       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 }
+
+       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
+       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
+       playerNode.stop()
+
+       seekFrame = AVAudioFramePosition(time * audioSampleRate)
+       seekFrame = max(0, min(seekFrame, audioLengthFrames))
+       currentTime = Double(seekFrame) / audioSampleRate
+
+       if wasPlaying {
+           play()
+       }
+       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 >= 0, band < eqNode.bands.count else { return }
+       eqNode.bands[band].gain = gain
+   }
+
+   // MARK: - Time Tracking
+
+   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() {
+       isPlaying = false
+       seekFrame = 0
+       currentTime = duration
+
+       if let track = currentTrack {
+           track.playCount += 1
+           track.lastPlayed = Date()
+       }
+
+       onPlaybackFinished?()
+   }
+
+   // MARK: - Interruption Handling (phone calls, Siri, etc.)
+
+   private func observeInterruptions() {
+       NotificationCenter.default.addObserver(
+           forName: AVAudioSession.interruptionNotification,
+           object: AVAudioSession.sharedInstance(),
+           queue: nil
+       ) { [weak self] notification in
+           Task { @MainActor in
+               self?.handleInterruption(notification)
+           }
+       }
+   }
+
+   private func handleInterruption(_ notification: Notification) {
+       guard let info = notification.userInfo,
+             let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
+             let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
+
+       switch type {
+       case .began:
+           // Pause playback when interrupted (phone call, etc.)
+           if isPlaying {
+               pause()
+           }
+       case .ended:
+           guard let optionsValue = info[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
+           let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
+           if options.contains(.shouldResume) {
+               play()
+           }
+       @unknown default:
+           break
+       }
+   }
+
+   // MARK: - Route Change Handling (headphones unplug, etc.)
+
+   private func observeRouteChanges() {
+       NotificationCenter.default.addObserver(
+           forName: AVAudioSession.routeChangeNotification,
+           object: AVAudioSession.sharedInstance(),
+           queue: nil
+       ) { [weak self] notification in
+           Task { @MainActor in
+               self?.handleRouteChange(notification)
+           }
+       }
+   }
+
+   private func handleRouteChange(_ notification: Notification) {
+       guard let info = notification.userInfo,
+             let reasonValue = info[AVAudioSessionRouteChangeReasonKey] as? UInt,
+             let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return }
+
+       // Pause when headphones are unplugged (standard iOS behavior)
+       if reason == .oldDeviceUnavailable {
+           if isPlaying {
+               pause()
+           }
+       }
+   }
+}
+
+// MARK: - Errors
+
+enum AudioEngineError: LocalizedError {
+   case fileNotFound(String)
+
+   var errorDescription: String? {
+       switch self {
+       case .fileNotFound(let name):
+           return "Audio file not found: \(name)"
+       }
+   }
+}

+ 129 - 0
Sources/Services/BPMDetector.swift

@@ -0,0 +1,129 @@
+import Accelerate
+import AVFoundation
+import Foundation
+
+/// BPM detection using spectral flux onset detection and autocorrelation.
+struct BPMDetector {
+
+    /// Detect BPM for a track. Analyzes the first 60 seconds.
+    static func detectBPM(for track: Track) async throws -> Double {
+        let url = track.fileURL
+        let file = try AVAudioFile(forReading: url)
+
+        let format = file.processingFormat
+        let sampleRate = format.sampleRate
+        let maxFrames = AVAudioFrameCount(min(Double(file.length), sampleRate * 60))
+
+        guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: maxFrames) else {
+            return 0
+        }
+        try file.read(into: buffer, frameCount: maxFrames)
+
+        guard let floatData = buffer.floatChannelData else { return 0 }
+        let frameCount = Int(buffer.frameLength)
+
+        // Mix to mono
+        var mono = [Float](repeating: 0, count: frameCount)
+        let channels = Int(format.channelCount)
+        for ch in 0..<channels {
+            let ptr = floatData[ch]
+            for i in 0..<frameCount {
+                mono[i] += ptr[i]
+            }
+        }
+        if channels > 1 {
+            var div = Float(channels)
+            vDSP_vsdiv(mono, 1, &div, &mono, 1, vDSP_Length(frameCount))
+        }
+
+        // FFT-based spectral flux
+        let fftSize = 2048
+        let hopSize = 512
+        let log2n = vDSP_Length(log2(Double(fftSize)))
+        guard let fftSetup = vDSP_create_fftsetup(log2n, FFTRadix(kFFTRadix2)) else { return 0 }
+        defer { vDSP_destroy_fftsetup(fftSetup) }
+
+        let halfN = fftSize / 2
+        var window = [Float](repeating: 0, count: fftSize)
+        vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
+
+        var prevMagnitudes = [Float](repeating: 0, count: halfN)
+        var onsetSignal: [Float] = []
+
+        var position = 0
+        while position + fftSize <= frameCount {
+            var frame = Array(mono[position..<position + fftSize])
+            vDSP_vmul(frame, 1, window, 1, &frame, 1, vDSP_Length(fftSize))
+
+            var real = [Float](repeating: 0, count: halfN)
+            var imag = [Float](repeating: 0, count: halfN)
+            frame.withUnsafeBufferPointer { framePtr in
+                real.withUnsafeMutableBufferPointer { realPtr in
+                    imag.withUnsafeMutableBufferPointer { imagPtr in
+                        var splitComplex = DSPSplitComplex(realp: realPtr.baseAddress!, imagp: imagPtr.baseAddress!)
+                        framePtr.baseAddress!.withMemoryRebound(to: DSPComplex.self, capacity: halfN) { complexPtr in
+                            vDSP_ctoz(complexPtr, 2, &splitComplex, 1, vDSP_Length(halfN))
+                        }
+                        vDSP_fft_zrip(fftSetup, &splitComplex, 1, log2n, FFTDirection(kFFTDirection_Forward))
+                    }
+                }
+            }
+
+            // Magnitudes
+            var magnitudes = [Float](repeating: 0, count: halfN)
+            real.withUnsafeBufferPointer { rPtr in
+                imag.withUnsafeBufferPointer { iPtr in
+                    var split = DSPSplitComplex(realp: UnsafeMutablePointer(mutating: rPtr.baseAddress!),
+                                                imagp: UnsafeMutablePointer(mutating: iPtr.baseAddress!))
+                    vDSP_zvabs(&split, 1, &magnitudes, 1, vDSP_Length(halfN))
+                }
+            }
+
+            // Spectral flux (positive differences only)
+            var flux: Float = 0
+            for i in 0..<halfN {
+                let diff = magnitudes[i] - prevMagnitudes[i]
+                if diff > 0 { flux += diff }
+            }
+            onsetSignal.append(flux)
+            prevMagnitudes = magnitudes
+
+            position += hopSize
+        }
+
+        guard onsetSignal.count > 2 else { return 0 }
+
+        // Autocorrelation for BPM
+        let onsetRate = sampleRate / Double(hopSize)
+        let minLag = Int(onsetRate * 60.0 / 200.0)  // 200 BPM
+        let maxLag = Int(onsetRate * 60.0 / 60.0)    // 60 BPM
+
+        guard maxLag < onsetSignal.count else { return 0 }
+
+        var bestLag = minLag
+        var bestCorrelation: Float = -Float.greatestFiniteMagnitude
+
+        for lag in minLag...maxLag {
+            var correlation: Float = 0
+            let count = vDSP_Length(onsetSignal.count - lag)
+            onsetSignal.withUnsafeBufferPointer { ptr in
+                let a = ptr.baseAddress!
+                let b = ptr.baseAddress! + lag
+                vDSP_dotpr(a, 1, b, 1, &correlation, count)
+            }
+            if correlation > bestCorrelation {
+                bestCorrelation = correlation
+                bestLag = lag
+            }
+        }
+
+        let bpm = (onsetRate * 60.0) / Double(bestLag)
+
+        // Normalize to reasonable range
+        var normalizedBPM = bpm
+        while normalizedBPM > 200 { normalizedBPM /= 2 }
+        while normalizedBPM < 60 { normalizedBPM *= 2 }
+
+        return (normalizedBPM * 10).rounded() / 10
+    }
+}

+ 154 - 0
Sources/Services/ChadMusicAPIClient.swift

@@ -0,0 +1,154 @@
+import Foundation
+
+/// HTTP client for the Chad Music REST API.
+@MainActor
+@Observable
+final class ChadMusicAPIClient {
+    static let shared = ChadMusicAPIClient()
+
+    // MARK: - Configuration
+
+    var serverURL: String {
+        get { UserDefaults.standard.string(forKey: "chadMusic.serverURL") ?? "" }
+        set { UserDefaults.standard.set(newValue, forKey: "chadMusic.serverURL") }
+    }
+
+    var isConfigured: Bool {
+        !serverURL.isEmpty && KeychainService.loadAPIKey() != nil
+    }
+
+    // MARK: - Private
+
+    @ObservationIgnored private let session: URLSession
+    @ObservationIgnored 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
+
+    func fetchStats() async throws -> ChadStats {
+        try await get("api/stats")
+    }
+
+    func fetchCategory(_ category: ChadCategoryType) async throws -> [ChadCategory] {
+        try await get("api/cat/\(category.rawValue)")
+    }
+
+    func fetchAlbums() async throws -> [ChadAlbum] {
+        try await get("api/cat/album")
+    }
+
+    func fetchAlbumTracks(albumId: String) async throws -> [ChadTrack] {
+        try await get("api/album/\(albumId)/tracks")
+    }
+
+    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))
+        }
+    }
+
+    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
+        let path = trackPath.hasPrefix("/") ? trackPath : "/" + trackPath
+        return URL(string: base + path)
+    }
+
+    var authHeaders: [String: String] {
+        guard let key = KeychainService.loadAPIKey() 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 }
+        let normalized = trimmed.hasSuffix("/") ? trimmed : trimmed + "/"
+        return URL(string: normalized)
+    }
+
+    private func get<T: Decodable>(_ path: String) async throws -> T {
+        guard let base = baseURL else {
+            throw ChadMusicError.notConfigured
+        }
+
+        let url = base.appending(path: path)
+        var request = URLRequest(url: url)
+        request.httpMethod = "GET"
+
+        if let apiKey = KeychainService.loadAPIKey() {
+            request.setValue("Bearer \(apiKey)", 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)"
+        }
+    }
+}

+ 161 - 0
Sources/Services/KeyDetector.swift

@@ -0,0 +1,161 @@
+import Accelerate
+import AVFoundation
+import Foundation
+
+/// Musical key detection using chromagram analysis and key profile matching.
+struct KeyDetector {
+
+    struct KeyResult {
+        let key: String         // e.g. "C Major", "A Minor"
+        let shortKey: String    // e.g. "C", "Am"
+        let camelotCode: String // e.g. "8B", "1A"
+        let confidence: Double  // 0.0 - 1.0
+    }
+
+    private static let noteNames = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
+
+    // Krumhansl-Kessler major/minor profiles
+    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]
+    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]
+
+    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"]
+
+    /// Detect the musical key of a track.
+    static func detectKey(for track: Track) async throws -> KeyResult {
+        let url = track.fileURL
+        let file = try AVAudioFile(forReading: url)
+
+        let format = file.processingFormat
+        let sampleRate = format.sampleRate
+        let maxFrames = AVAudioFrameCount(min(Double(file.length), sampleRate * 30))
+
+        guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: maxFrames) else {
+            return KeyResult(key: "Unknown", shortKey: "?", camelotCode: "?", confidence: 0)
+        }
+        try file.read(into: buffer, frameCount: maxFrames)
+
+        guard let floatData = buffer.floatChannelData else {
+            return KeyResult(key: "Unknown", shortKey: "?", camelotCode: "?", confidence: 0)
+        }
+
+        let frameCount = Int(buffer.frameLength)
+        var mono = [Float](repeating: 0, count: frameCount)
+        let channels = Int(format.channelCount)
+        for ch in 0..<channels {
+            let ptr = floatData[ch]
+            for i in 0..<frameCount { mono[i] += ptr[i] }
+        }
+        if channels > 1 {
+            var div = Float(channels)
+            vDSP_vsdiv(mono, 1, &div, &mono, 1, vDSP_Length(frameCount))
+        }
+
+        // Compute chromagram
+        let fftSize = 8192
+        let hopSize = 4096
+        let log2n = vDSP_Length(log2(Double(fftSize)))
+        guard let fftSetup = vDSP_create_fftsetup(log2n, FFTRadix(kFFTRadix2)) else {
+            return KeyResult(key: "Unknown", shortKey: "?", camelotCode: "?", confidence: 0)
+        }
+        defer { vDSP_destroy_fftsetup(fftSetup) }
+
+        let halfN = fftSize / 2
+        var chromagram = [Double](repeating: 0, count: 12)
+        var frameCount2 = 0
+
+        var position = 0
+        while position + fftSize <= frameCount {
+            var frame = Array(mono[position..<position + fftSize])
+            var window = [Float](repeating: 0, count: fftSize)
+            vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
+            vDSP_vmul(frame, 1, window, 1, &frame, 1, vDSP_Length(fftSize))
+
+            var real = [Float](repeating: 0, count: halfN)
+            var imag = [Float](repeating: 0, count: halfN)
+            frame.withUnsafeBufferPointer { fPtr in
+                real.withUnsafeMutableBufferPointer { rPtr in
+                    imag.withUnsafeMutableBufferPointer { iPtr in
+                        var split = DSPSplitComplex(realp: rPtr.baseAddress!, imagp: iPtr.baseAddress!)
+                        fPtr.baseAddress!.withMemoryRebound(to: DSPComplex.self, capacity: halfN) { cPtr in
+                            vDSP_ctoz(cPtr, 2, &split, 1, vDSP_Length(halfN))
+                        }
+                        vDSP_fft_zrip(fftSetup, &split, 1, log2n, FFTDirection(kFFTDirection_Forward))
+                    }
+                }
+            }
+
+            var magnitudes = [Float](repeating: 0, count: halfN)
+            real.withUnsafeBufferPointer { rPtr in
+                imag.withUnsafeBufferPointer { iPtr in
+                    var split = DSPSplitComplex(
+                        realp: UnsafeMutablePointer(mutating: rPtr.baseAddress!),
+                        imagp: UnsafeMutablePointer(mutating: iPtr.baseAddress!))
+                    vDSP_zvabs(&split, 1, &magnitudes, 1, vDSP_Length(halfN))
+                }
+            }
+
+            // Map frequency bins to pitch classes
+            for bin in 1..<halfN {
+                let freq = Double(bin) * sampleRate / Double(fftSize)
+                guard freq > 60 && freq < 5000 else { continue }
+                let midiNote = 69.0 + 12.0 * log2(freq / 440.0)
+                let pitchClass = Int(round(midiNote)) % 12
+                let normalizedClass = (pitchClass + 12) % 12
+                chromagram[normalizedClass] += Double(magnitudes[bin])
+            }
+            frameCount2 += 1
+            position += hopSize
+        }
+
+        guard frameCount2 > 0 else {
+            return KeyResult(key: "Unknown", shortKey: "?", camelotCode: "?", confidence: 0)
+        }
+
+        for i in 0..<12 { chromagram[i] /= Double(frameCount2) }
+
+        // Match against key profiles using Pearson correlation
+        var bestCorrelation = -Double.greatestFiniteMagnitude
+        var bestKey = 0
+        var bestIsMajor = true
+
+        for root in 0..<12 {
+            let rotated = (0..<12).map { chromagram[($0 + root) % 12] }
+            let majorCorr = pearsonCorrelation(rotated, majorProfile)
+            let minorCorr = pearsonCorrelation(rotated, minorProfile)
+
+            if majorCorr > bestCorrelation {
+                bestCorrelation = majorCorr
+                bestKey = root
+                bestIsMajor = true
+            }
+            if minorCorr > bestCorrelation {
+                bestCorrelation = minorCorr
+                bestKey = root
+                bestIsMajor = false
+            }
+        }
+
+        let confidence = max(0, min(1, (bestCorrelation + 1) / 2))
+        let noteName = noteNames[bestKey]
+        let keyName = bestIsMajor ? "\(noteName) Major" : "\(noteName) Minor"
+        let shortKey = bestIsMajor ? noteName : "\(noteName)m"
+        let camelot = bestIsMajor ? camelotMajor[bestKey] : camelotMinor[bestKey]
+
+        return KeyResult(key: keyName, shortKey: shortKey, camelotCode: camelot, confidence: confidence)
+    }
+
+    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
+    }
+}

+ 65 - 0
Sources/Services/KeychainService.swift

@@ -0,0 +1,65 @@
+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"
+
+    static func saveAPIKey(_ key: String) throws {
+        guard let data = key.data(using: .utf8) else { return }
+
+        let deleteQuery: [String: Any] = [
+            kSecClass as String: kSecClassGenericPassword,
+            kSecAttrService as String: service,
+            kSecAttrAccount as String: apiKeyAccount,
+        ]
+        SecItemDelete(deleteQuery as CFDictionary)
+
+        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)
+    }
+
+    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)"
+        }
+    }
+}

+ 376 - 0
Sources/Services/LibraryManager.swift

@@ -0,0 +1,376 @@
+import Foundation
+import os
+import SwiftData
+
+private let logger = Logger(subsystem: "com.mixboard.MixBoardiOS", category: "LibraryManager")
+
+/// Manages the music library — importing tracks from the document picker and managing files.
+@MainActor
+final class LibraryManager: ObservableObject {
+    @Published var isScanning = false
+    @Published var scanProgress: Double = 0
+    @Published var scanStatus: String = ""
+    @Published var lastError: String?
+
+    private var modelContext: ModelContext?
+
+    /// App documents directory where imported music files are stored.
+    static var musicDirectory: URL {
+        let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
+        let musicDir = docs.appendingPathComponent("Music", isDirectory: true)
+        try? FileManager.default.createDirectory(at: musicDir, withIntermediateDirectories: true)
+        return musicDir
+    }
+
+    func setModelContext(_ context: ModelContext) {
+        self.modelContext = context
+    }
+
+    // MARK: - Import from URLs (files and/or folders)
+
+    /// Import audio files or folders selected from a document picker.
+    /// Files get copied into the app's Music directory.
+    func importFiles(_ urls: [URL]) async {
+        guard let context = modelContext else { return }
+        isScanning = true
+        scanProgress = 0
+        scanStatus = "Collecting files..."
+        lastError = nil
+
+        // Resolve folders into individual audio file URLs
+        var audioURLs: [URL] = []
+        for url in urls {
+            let accessing = url.startAccessingSecurityScopedResource()
+            defer { if accessing { url.stopAccessingSecurityScopedResource() } }
+
+            var isDir: ObjCBool = false
+            if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue {
+                // It's a folder — enumerate audio files recursively
+                let folderFiles = collectAudioFiles(in: url)
+                audioURLs.append(contentsOf: folderFiles)
+            } else if MetadataService.isSupportedAudioFile(url) {
+                audioURLs.append(url)
+            }
+        }
+
+        guard !audioURLs.isEmpty else {
+            isScanning = false
+            scanStatus = ""
+            lastError = "No supported audio files found"
+            return
+        }
+
+        scanStatus = "Importing \(audioURLs.count) files..."
+
+        for (index, url) in audioURLs.enumerated() {
+            scanProgress = Double(index) / Double(audioURLs.count)
+            scanStatus = "Importing \(index + 1)/\(audioURLs.count): \(url.lastPathComponent)"
+
+            let accessing = url.startAccessingSecurityScopedResource()
+            defer { if accessing { url.stopAccessingSecurityScopedResource() } }
+
+            do {
+                try await importFile(url, context: context)
+            } catch {
+                print("LibraryManager: Failed to import \(url.lastPathComponent): \(error)")
+            }
+        }
+
+        try? context.save()
+        isScanning = false
+        scanProgress = 1.0
+        scanStatus = ""
+    }
+
+    /// Recursively collect audio files from a directory.
+    private func collectAudioFiles(in folderURL: URL) -> [URL] {
+        var result: [URL] = []
+        let fm = FileManager.default
+
+        guard let enumerator = fm.enumerator(
+            at: folderURL,
+            includingPropertiesForKeys: [.isRegularFileKey],
+            options: [.skipsHiddenFiles]
+        ) else { return result }
+
+        for case let fileURL as URL in enumerator {
+            if MetadataService.isSupportedAudioFile(fileURL) {
+                result.append(fileURL)
+            }
+        }
+
+        return result
+    }
+
+    // MARK: - Auto-scan Documents folder
+
+    /// Scan the app's entire Documents directory (recursively) for any audio files
+    /// that aren't yet in the library. This picks up files placed via the Files app.
+    func scanMusicDirectory() async {
+        guard let context = modelContext else {
+            logger.error("scanMusicDirectory: No model context")
+            return
+        }
+
+        let fm = FileManager.default
+        let docsDir = fm.urls(for: .documentDirectory, in: .userDomainMask).first!.standardizedFileURL
+        logger.notice("scanMusicDirectory: Scanning \(docsDir.path)")
+
+        // Collect audio files from the entire Documents directory recursively
+        var audioFiles: [URL] = []
+        if let enumerator = fm.enumerator(
+            at: docsDir,
+            includingPropertiesForKeys: [.isRegularFileKey],
+            options: [.skipsHiddenFiles]
+        ) {
+            for case let fileURL as URL in enumerator {
+                if MetadataService.isSupportedAudioFile(fileURL) {
+                    audioFiles.append(fileURL)
+                }
+            }
+        }
+
+        logger.notice("scanMusicDirectory: Found \(audioFiles.count) audio files on disk")
+        guard !audioFiles.isEmpty else { return }
+
+        var newCount = 0
+        isScanning = true
+        scanStatus = "Scanning for music files..."
+
+        for (index, fileURL) in audioFiles.enumerated() {
+            scanProgress = Double(index) / Double(audioFiles.count)
+
+            let fileName = fileURL.lastPathComponent
+            // Check by relative path (not just filename) so same-named files in different folders are all imported
+            let standardizedFile = fileURL.standardizedFileURL.path
+            let standardizedDocs = docsDir.path
+            let checkPath = standardizedFile.hasPrefix(standardizedDocs + "/")
+                ? String(standardizedFile.dropFirst(standardizedDocs.count + 1))
+                : fileName
+            let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.filePath == checkPath })
+            let existing = (try? context.fetch(descriptor)) ?? []
+
+            if existing.isEmpty {
+                do {
+                    let metadata = try await MetadataService.readMetadata(from: fileURL)
+                    // Store the path relative to Documents (standardize to handle /private/var vs /var)
+                    let standardizedFile = fileURL.standardizedFileURL.path
+                    let standardizedDocs = docsDir.path
+                    let relativePath = standardizedFile.hasPrefix(standardizedDocs + "/")
+                        ? String(standardizedFile.dropFirst(standardizedDocs.count + 1))
+                        : fileName
+                    logger.notice("scanMusicDirectory: Importing \(fileName) → relativePath: \(relativePath)")
+                    let track = Track(
+                        title: metadata.title,
+                        artist: metadata.artist,
+                        album: metadata.album,
+                        genre: metadata.genre,
+                        filePath: relativePath,
+                        fileName: fileName,
+                        duration: metadata.duration,
+                        sampleRate: metadata.sampleRate,
+                        bitDepth: metadata.bitDepth,
+                        channels: metadata.channels,
+                        fileFormat: metadata.fileFormat,
+                        fileSizeBytes: metadata.fileSizeBytes
+                    )
+                    track.year = metadata.year
+                    context.insert(track)
+                    newCount += 1
+                    scanStatus = "Found: \(fileName)"
+                } catch {
+                    logger.error("scanMusicDirectory: Failed to scan \(fileName): \(error)")
+                }
+            }
+        }
+
+        if newCount > 0 {
+            try? context.save()
+        }
+
+        isScanning = false
+        scanProgress = 1.0
+        scanStatus = newCount > 0 ? "Found \(newCount) new tracks" : ""
+    }
+
+    /// Fix tracks with bad paths. Call once at startup, not during scans.
+    func fixBadPathsIfNeeded() {
+        guard let context = modelContext else { return }
+        let hasRun = UserDefaults.standard.bool(forKey: "pathFixV1")
+        guard !hasRun else { return }
+        fixBadPaths(context: context)
+        UserDefaults.standard.set(true, forKey: "pathFixV1")
+    }
+
+    /// Fix tracks that have absolute or /private-prefixed paths from a previous scan bug.
+    private func fixBadPaths(context: ModelContext) {
+        let descriptor = FetchDescriptor<Track>()
+        guard let allTracks = try? context.fetch(descriptor) else { return }
+        let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.standardizedFileURL
+
+        var fixed = 0
+        for track in allTracks {
+            var path = track.filePath
+            // Remove leading /private prefix
+            if path.hasPrefix("/private") {
+                path = String(path.dropFirst("/private".count))
+            }
+            // Remove absolute Documents path prefix if present
+            if path.hasPrefix(docsDir.path + "/") {
+                path = String(path.dropFirst(docsDir.path.count + 1))
+            }
+            // Remove leading / if still absolute
+            if path.hasPrefix("/") && !path.hasPrefix(docsDir.path) {
+                // Check if the file actually exists relative to Documents
+                let candidateURL = docsDir.appendingPathComponent(String(path.dropFirst()))
+                if FileManager.default.fileExists(atPath: candidateURL.path) {
+                    path = String(path.dropFirst())
+                }
+            }
+            if path != track.filePath {
+                logger.notice("fixBadPaths: \(track.filePath) → \(path)")
+                track.filePath = path
+                fixed += 1
+            }
+        }
+        if fixed > 0 {
+            try? context.save()
+            logger.notice("fixBadPaths: Fixed \(fixed) tracks")
+        }
+    }
+
+    // MARK: - Import single file
+
+    /// Import a single audio file. Copies it to the app's Music directory.
+    func importFile(_ url: URL, context: ModelContext? = nil) async throws {
+        let ctx = context ?? modelContext
+        guard let ctx else { return }
+
+        let fileName = url.lastPathComponent
+
+        // Check if already imported by filename
+        let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.fileName == fileName })
+        let existing = try ctx.fetch(descriptor)
+        guard existing.isEmpty else { return }
+
+        // Copy file to app's Music directory (unless it's already there)
+        let destURL = Self.musicDirectory.appendingPathComponent(fileName)
+        if !FileManager.default.fileExists(atPath: destURL.path) {
+            try FileManager.default.copyItem(at: url, to: destURL)
+        }
+
+        // Read metadata
+        let metadata = try await MetadataService.readMetadata(from: destURL)
+
+        let relativePath = "Music/\(fileName)"
+        let track = Track(
+            title: metadata.title,
+            artist: metadata.artist,
+            album: metadata.album,
+            genre: metadata.genre,
+            filePath: relativePath,
+            fileName: fileName,
+            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)
+    }
+
+    // MARK: - Analysis
+
+    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)")
+        }
+    }
+
+    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: - Deletion
+
+    func removeTrack(_ track: Track, deleteFile: Bool = true) {
+        if deleteFile {
+            try? FileManager.default.removeItem(at: track.fileURL)
+        }
+        modelContext?.delete(track)
+    }
+
+    // MARK: - Rescan Metadata
+
+    /// Re-read metadata from disk for all tracks in the library.
+    /// Updates title, artist, album, genre, year, duration, and audio properties.
+    /// Useful after fixing metadata readers (e.g. OGG/Opus tag reading).
+    func rescanMetadata() async {
+        guard let context = modelContext else { return }
+
+        let descriptor = FetchDescriptor<Track>()
+        guard let allTracks = try? context.fetch(descriptor) else { return }
+        guard !allTracks.isEmpty else { return }
+
+        isScanning = true
+        scanStatus = "Rescanning metadata..."
+        scanProgress = 0
+
+        var updated = 0
+
+        for (index, track) in allTracks.enumerated() {
+            scanProgress = Double(index) / Double(allTracks.count)
+            scanStatus = "Rescanning \(index + 1)/\(allTracks.count): \(track.fileName)"
+
+            let fileURL = track.fileURL
+            guard FileManager.default.fileExists(atPath: fileURL.path) else { continue }
+
+            do {
+                let metadata = try await MetadataService.readMetadata(from: fileURL)
+
+                // Always update metadata fields
+                track.title = metadata.title
+                track.artist = metadata.artist
+                track.album = metadata.album
+                track.genre = metadata.genre
+                track.year = metadata.year
+                track.duration = metadata.duration
+                track.sampleRate = metadata.sampleRate
+                track.bitDepth = metadata.bitDepth
+                track.channels = metadata.channels
+                track.fileFormat = metadata.fileFormat
+                track.fileSizeBytes = metadata.fileSizeBytes
+                updated += 1
+            } catch {
+                logger.error("rescanMetadata: Failed for \(track.fileName): \(error)")
+            }
+        }
+
+        if updated > 0 {
+            try? context.save()
+        }
+
+        isScanning = false
+        scanProgress = 1.0
+        scanStatus = updated > 0 ? "Updated \(updated) tracks" : "No updates needed"
+        logger.notice("rescanMetadata: Updated \(updated) of \(allTracks.count) tracks")
+    }
+}

+ 98 - 0
Sources/Services/LyricsParser.swift

@@ -0,0 +1,98 @@
+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)
+
+            // 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
+    }
+}

+ 89 - 0
Sources/Services/MediaKeyHandler.swift

@@ -0,0 +1,89 @@
+import Foundation
+import MediaPlayer
+
+/// Handles iOS Now Playing info center and remote command center for lock screen / Control Center controls.
+@MainActor
+final class MediaKeyHandler {
+
+    static let shared = MediaKeyHandler()
+
+    private weak var playerVM: PlayerViewModel?
+
+    func register(playerVM: PlayerViewModel) {
+        self.playerVM = playerVM
+        setupRemoteCommands()
+    }
+
+    private func setupRemoteCommands() {
+        let center = MPRemoteCommandCenter.shared()
+
+        center.playCommand.isEnabled = true
+        center.playCommand.addTarget { [weak self] _ in
+            self?.playerVM?.togglePlayPause()
+            return .success
+        }
+
+        center.pauseCommand.isEnabled = true
+        center.pauseCommand.addTarget { [weak self] _ in
+            self?.playerVM?.togglePlayPause()
+            return .success
+        }
+
+        center.togglePlayPauseCommand.isEnabled = true
+        center.togglePlayPauseCommand.addTarget { [weak self] _ in
+            self?.playerVM?.togglePlayPause()
+            return .success
+        }
+
+        center.nextTrackCommand.isEnabled = true
+        center.nextTrackCommand.addTarget { [weak self] _ in
+            self?.playerVM?.playNext()
+            return .success
+        }
+
+        center.previousTrackCommand.isEnabled = true
+        center.previousTrackCommand.addTarget { [weak self] _ in
+            self?.playerVM?.playPrevious()
+            return .success
+        }
+
+        center.changePlaybackPositionCommand.isEnabled = true
+        center.changePlaybackPositionCommand.addTarget { [weak self] event in
+            guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
+            self?.playerVM?.seek(to: event.positionTime)
+            return .success
+        }
+
+        center.skipForwardCommand.isEnabled = true
+        center.skipForwardCommand.preferredIntervals = [10]
+        center.skipForwardCommand.addTarget { [weak self] event in
+            guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed }
+            self?.playerVM?.skipForward(event.interval)
+            return .success
+        }
+
+        center.skipBackwardCommand.isEnabled = true
+        center.skipBackwardCommand.preferredIntervals = [10]
+        center.skipBackwardCommand.addTarget { [weak self] event in
+            guard let event = event as? MPSkipIntervalCommandEvent else { return .commandFailed }
+            self?.playerVM?.skipBackward(event.interval)
+            return .success
+        }
+    }
+
+    func updateNowPlaying(track: Track?, isPlaying: Bool, currentTime: TimeInterval, duration: TimeInterval) {
+        var info = [String: Any]()
+        info[MPMediaItemPropertyTitle] = track?.title ?? "MixBoard"
+        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)
+        }
+
+        MPNowPlayingInfoCenter.default().nowPlayingInfo = info
+    }
+}

+ 354 - 0
Sources/Services/MetadataService.swift

@@ -0,0 +1,354 @@
+import AVFoundation
+import Foundation
+
+/// Reads audio file metadata (ID3 tags, etc.) using AVFoundation.
+struct MetadataService {
+
+    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
+
+    static func readMetadata(from url: URL) async throws -> AudioMetadata {
+        // OGG/Opus files need special handling since AVFoundation may not support them
+        let ext = url.pathExtension.lowercased()
+        if ext == "ogg" {
+            return readOGGMetadata(from: url)
+        }
+        #if !DISABLE_OPUS
+        if ext == "opus" {
+            return readOpusMetadata(from: url)
+        }
+        #endif
+
+        let asset = AVURLAsset(url: url)
+
+        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) {
+                    year = Int(val.prefix(4))
+                }
+            default:
+                break
+            }
+        }
+
+        // Also check format-specific date tags if year not found via common keys.
+        // AVFoundation identifiers vary by format:
+        //   ID3v2: "id3/%00TDRC", "id3/%00TYER", "id3/%00TDAT", "id3/%00DATE"
+        //   iTunes/M4A: "itsk/©day"
+        //   Vorbis/FLAC: "org.xiph.vorbiscomment/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 {
+                    // Check by identifier
+                    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
+                        }
+                    }
+                    // Also check by key space — some items don't have identifiers
+                    // but have keys in their key space that indicate a date
+                    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 anything that looks like a year
+        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 {
+                    // Only accept if the value looks like a pure date (not a random string containing digits)
+                    let trimmed = val.trimmingCharacters(in: .whitespaces)
+                    if trimmed.range(of: #"^\d{4}(-\d{2}(-\d{2})?)?$"#, options: .regularExpression) != nil {
+                        year = y
+                        break
+                    }
+                }
+            }
+        }
+
+        let audioFile = try AVAudioFile(forReading: url)
+        let format = audioFile.processingFormat
+        let sampleRate = format.sampleRate
+        let channels = Int(format.channelCount)
+        let bitDepth: Int
+        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
+
+    /// Dump all raw metadata from a file for diagnostic purposes.
+    /// Returns a string listing every metadata item AVFoundation can see.
+    static func dumpAllMetadata(from url: URL) async -> String {
+        let ext = url.pathExtension.lowercased()
+        
+        if ext == "ogg" {
+            return dumpOGGTags(from: url)
+        }
+        #if !DISABLE_OPUS
+        if ext == "opus" {
+            return dumpOpusTags(from: url)
+        }
+        #endif
+
+        var lines: [String] = ["=== Metadata for \(url.lastPathComponent) ==="]
+        do {
+            let asset = AVURLAsset(url: url)
+            let metadata = try await asset.load(.metadata)
+            lines.append("Total items: \(metadata.count)")
+            for (i, item) in metadata.enumerated() {
+                let commonKey = item.commonKey?.rawValue ?? "(none)"
+                let identifier = item.identifier?.rawValue ?? "(none)"
+                let keySpace = item.keySpace?.rawValue ?? "(none)"
+                let key = (item.key as? String) ?? String(describing: item.key)
+                let value = (try? await item.load(.stringValue)) ?? "(nil)"
+                lines.append("[\(i)] commonKey=\(commonKey) id=\(identifier) keySpace=\(keySpace) key=\(key) value=\(value)")
+            }
+        } catch {
+            lines.append("Error: \(error)")
+        }
+        return lines.joined(separator: "\n")
+    }
+
+    private static func dumpOGGTags(from url: URL) -> String {
+        var lines: [String] = ["=== OGG Tags for \(url.lastPathComponent) ==="]
+        var error: Int32 = 0
+        guard let vorbis = stb_vorbis_open_filename(url.path, &error, nil) else {
+            lines.append("Failed to open OGG file (error \(error))")
+            return lines.joined(separator: "\n")
+        }
+        defer { stb_vorbis_close(vorbis) }
+        let comment = stb_vorbis_get_comment(vorbis)
+        lines.append("Vendor: \(comment.vendor.map { String(cString: $0) } ?? "(nil)")")
+        lines.append("Comment count: \(comment.comment_list_length)")
+        if let commentList = comment.comment_list {
+            for i in 0..<Int(comment.comment_list_length) {
+                if let cStr = commentList[i] {
+                    lines.append("  [\(i)] \(String(cString: cStr))")
+                }
+            }
+        }
+        return lines.joined(separator: "\n")
+    }
+
+    #if !DISABLE_OPUS
+    private static func dumpOpusTags(from url: URL) -> String {
+        var lines: [String] = ["=== Opus Tags for \(url.lastPathComponent) ==="]
+        var error: Int32 = 0
+        guard let opusFile = op_open_file(url.path, &error) else {
+            lines.append("Failed to open Opus file (error \(error))")
+            return lines.joined(separator: "\n")
+        }
+        defer { op_free(opusFile) }
+        if let tags = op_tags(opusFile, -1) {
+            let vendor = tags.pointee.vendor.map { String(cString: $0) } ?? "(nil)"
+            lines.append("Vendor: \(vendor)")
+            let count = tags.pointee.comments
+            lines.append("Comment count: \(count)")
+            for i in 0..<Int(count) {
+                if let lengths = tags.pointee.comment_lengths,
+                   let comments = tags.pointee.user_comments,
+                   let cStr = comments[Int(i)] {
+                    let len = Int(lengths[Int(i)])
+                    let str = String(cString: cStr)
+                    lines.append("  [\(i)] (len=\(len)) \(str)")
+                }
+            }
+        } else {
+            lines.append("No tags found")
+        }
+        return lines.joined(separator: "\n")
+    }
+    #endif
+
+    static let supportedExtensions: Set<String> = [
+        "mp3", "wav", "aif", "aiff", "flac", "m4a", "aac", "caf", "alac", "ogg", "opus"
+    ]
+
+    static func isSupportedAudioFile(_ url: URL) -> Bool {
+        supportedExtensions.contains(url.pathExtension.lowercased())
+    }
+
+    // MARK: - OGG Metadata
+
+    private static func readOGGMetadata(from url: URL) -> AudioMetadata {
+        let fallbackTitle = url.deletingPathExtension().lastPathComponent
+        let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
+
+        var error: Int32 = 0
+        guard let vorbis = stb_vorbis_open_filename(url.path, &error, nil) else {
+            return AudioMetadata(
+                title: fallbackTitle, artist: "", album: "", genre: "", year: nil,
+                duration: 0, sampleRate: 44100, bitDepth: 16,
+                channels: 2, fileFormat: "OGG", fileSizeBytes: fileSize
+            )
+        }
+        defer { stb_vorbis_close(vorbis) }
+
+        let info = stb_vorbis_get_info(vorbis)
+        let totalSamples = stb_vorbis_stream_length_in_samples(vorbis)
+        let sampleRate = Double(info.sample_rate)
+        let channels = Int(info.channels)
+        let duration = sampleRate > 0 ? Double(totalSamples) / sampleRate : 0
+
+        // Read Vorbis Comment tags
+        var title = fallbackTitle
+        var artist = ""
+        var album = ""
+        var genre = ""
+        var year: Int?
+
+        let comment = stb_vorbis_get_comment(vorbis)
+        if comment.comment_list_length > 0, let commentList = comment.comment_list {
+            for i in 0..<Int(comment.comment_list_length) {
+                guard let cStr = commentList[i] else { continue }
+                let entry = String(cString: cStr)
+                // Vorbis comments are "TAG=VALUE" format (case-insensitive tag names)
+                guard let eqIndex = entry.firstIndex(of: "=") else { continue }
+                let tag = entry[..<eqIndex].uppercased()
+                let value = String(entry[entry.index(after: eqIndex)...])
+                guard !value.isEmpty else { continue }
+
+                switch tag {
+                case "TITLE": title = value
+                case "ARTIST": artist = value
+                case "ALBUM": album = value
+                case "GENRE": genre = value
+                case "DATE", "YEAR", "ORIGINALDATE", "ORIGINALYEAR":
+                    if year == nil { year = Int(value.prefix(4)) }
+                default: break
+                }
+            }
+        }
+
+        return AudioMetadata(
+            title: title, artist: artist, album: album, genre: genre, year: year,
+            duration: duration, sampleRate: sampleRate, bitDepth: 16,
+            channels: channels, fileFormat: "OGG", fileSizeBytes: fileSize
+        )
+    }
+
+    #if !DISABLE_OPUS
+    private static func readOpusMetadata(from url: URL) -> AudioMetadata {
+        let fallbackTitle = url.deletingPathExtension().lastPathComponent
+        let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
+
+        var error: Int32 = 0
+        guard let opusFile = op_open_file(url.path, &error) else {
+            return AudioMetadata(
+                title: fallbackTitle, artist: "", album: "", genre: "", year: nil,
+                duration: 0, sampleRate: 48000, bitDepth: 16,
+                channels: 2, fileFormat: "OPUS", fileSizeBytes: fileSize
+            )
+        }
+        defer { op_free(opusFile) }
+
+        let channels = Int(op_channel_count(opusFile, -1))
+        let totalSamples = op_pcm_total(opusFile, -1)
+        let duration = Double(totalSamples) / 48000.0
+
+        // Read Vorbis Comment tags
+        var title = fallbackTitle
+        var artist = ""
+        var album = ""
+        var genre = ""
+        var year: Int?
+
+        if let tags = op_tags(opusFile, -1) {
+            title = opusTagValue(tags, "TITLE") ?? fallbackTitle
+            artist = opusTagValue(tags, "ARTIST") ?? ""
+            album = opusTagValue(tags, "ALBUM") ?? ""
+            genre = opusTagValue(tags, "GENRE") ?? ""
+            if let dateStr = opusTagValue(tags, "DATE") ?? opusTagValue(tags, "YEAR")
+                ?? opusTagValue(tags, "ORIGINALDATE") ?? opusTagValue(tags, "ORIGINALYEAR") {
+                year = Int(dateStr.prefix(4))
+            }
+        }
+
+        return AudioMetadata(
+            title: title, artist: artist, album: album, genre: genre, year: year,
+            duration: duration, sampleRate: 48000, bitDepth: 16,
+            channels: channels, fileFormat: "OPUS", fileSizeBytes: fileSize
+        )
+    }
+
+    /// Read a single Vorbis Comment tag from an OpusTags struct.
+    private static func opusTagValue(_ tags: UnsafePointer<OpusTags>, _ tag: String) -> String? {
+        let count = opus_tags_query_count(tags, tag)
+        guard count > 0 else { return nil }
+        guard let value = opus_tags_query(tags, tag, 0) else { return nil }
+        let str = String(cString: value)
+        return str.isEmpty ? nil : str
+    }
+    #endif
+}

+ 130 - 0
Sources/Services/OGGDecoder.swift

@@ -0,0 +1,130 @@
+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"
+    }
+
+    /// Convert an OGG file to a CAF file (for compatibility with AVAudioFile).
+    /// Returns the URL of the converted file.
+    static func convertToCAF(url: URL) throws -> URL {
+        let (buffer, format) = try decode(url: url)
+
+        let cafURL = url.deletingPathExtension().appendingPathExtension("caf")
+
+        // Write to CAF format
+        let outputFile = try AVAudioFile(
+            forWriting: cafURL,
+            settings: format.settings,
+            commonFormat: .pcmFormatFloat32,
+            interleaved: false
+        )
+        try outputFile.write(from: buffer)
+
+        return cafURL
+    }
+}

+ 107 - 0
Sources/Services/OpusDecoder.swift

@@ -0,0 +1,107 @@
+#if !DISABLE_OPUS
+import AVFoundation
+import Foundation
+
+/// Decodes OGG Opus files using libopusfile (compiled for iOS ARM64).
+struct OpusDecoder {
+
+    enum OpusError: LocalizedError {
+        case failedToOpen(String, Int32)
+        case failedToCreateBuffer
+        case decodingFailed
+
+        var errorDescription: String? {
+            switch self {
+            case .failedToOpen(let name, let code): return "Failed to open Opus file '\(name)' (error \(code))"
+            case .failedToCreateBuffer: return "Failed to create audio buffer"
+            case .decodingFailed: return "Opus decoding failed"
+            }
+        }
+    }
+
+    static func isOpusFile(_ url: URL) -> Bool {
+        url.pathExtension.lowercased() == "opus"
+    }
+
+    /// Decode an OGG Opus file to an AVAudioPCMBuffer.
+    static func decode(url: URL) throws -> (buffer: AVAudioPCMBuffer, format: AVAudioFormat) {
+        var error: Int32 = 0
+        guard let opusFile = op_open_file(url.path, &error) else {
+            throw OpusError.failedToOpen(url.lastPathComponent, error)
+        }
+        defer { op_free(opusFile) }
+
+        let channels = Int(op_channel_count(opusFile, -1))
+        let totalSamples = op_pcm_total(opusFile, -1)
+        let sampleRate: Double = 48000 // Opus always decodes at 48kHz
+
+        guard let format = AVAudioFormat(
+            commonFormat: .pcmFormatFloat32,
+            sampleRate: sampleRate,
+            channels: AVAudioChannelCount(channels),
+            interleaved: false
+        ) else {
+            throw OpusError.failedToCreateBuffer
+        }
+
+        let frameCapacity = totalSamples > 0 ? AVAudioFrameCount(totalSamples) : AVAudioFrameCount(sampleRate * 600)
+        guard let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: frameCapacity) else {
+            throw OpusError.failedToCreateBuffer
+        }
+
+        // Decode in chunks — op_read_float gives interleaved float samples
+        let chunkSize = 5760 * channels // 120ms at 48kHz
+        var tempInterleaved = [Float](repeating: 0, count: chunkSize)
+        var totalDecoded: Int64 = 0
+
+        while true {
+            let samplesRead = op_read_float(opusFile, &tempInterleaved, Int32(chunkSize / channels), nil)
+            if samplesRead <= 0 { break }
+
+            let frameCount = Int(samplesRead)
+
+            // Deinterleave into the PCM buffer's channel pointers
+            for frame in 0..<frameCount {
+                for ch in 0..<channels {
+                    let bufferOffset = Int(totalDecoded) + frame
+                    if bufferOffset < Int(frameCapacity) {
+                        buffer.floatChannelData![ch][bufferOffset] = tempInterleaved[frame * channels + ch]
+                    }
+                }
+            }
+
+            totalDecoded += Int64(frameCount)
+        }
+
+        guard totalDecoded > 0 else {
+            throw OpusError.decodingFailed
+        }
+
+        buffer.frameLength = AVAudioFrameCount(min(totalDecoded, Int64(frameCapacity)))
+        return (buffer, format)
+    }
+
+    /// Get duration of an Opus file in seconds.
+    static func duration(url: URL) -> TimeInterval {
+        var error: Int32 = 0
+        guard let opusFile = op_open_file(url.path, &error) else { return 0 }
+        defer { op_free(opusFile) }
+
+        let totalSamples = op_pcm_total(opusFile, -1)
+        return Double(totalSamples) / 48000.0
+    }
+
+    /// Get basic info about an Opus file.
+    static func fileInfo(url: URL) -> (sampleRate: Double, channels: Int, duration: TimeInterval)? {
+        var error: Int32 = 0
+        guard let opusFile = op_open_file(url.path, &error) else { return nil }
+        defer { op_free(opusFile) }
+
+        let channels = Int(op_channel_count(opusFile, -1))
+        let totalSamples = op_pcm_total(opusFile, -1)
+        let duration = Double(totalSamples) / 48000.0
+
+        return (sampleRate: 48000, channels: channels, duration: duration)
+    }
+}
+#endif // !DISABLE_OPUS

+ 180 - 0
Sources/Services/StreamingPlayer.swift

@@ -0,0 +1,180 @@
+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).
+@MainActor
+@Observable
+final class StreamingPlayer {
+    // MARK: - State
+
+    var isPlaying = false
+    var currentTime: TimeInterval = 0
+    var duration: TimeInterval = 0
+    var isBuffering = false
+
+    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: - Audio Session (iOS)
+    // Configure audio session for playback. We set .playback category but do NOT
+    // deactivate on stop — the session stays active when switching between
+    // StreamingPlayer and AudioEngine to avoid session thrashing.
+
+    private func configureAudioSession() {
+        do {
+            let session = AVAudioSession.sharedInstance()
+            if session.category != .playback {
+                try session.setCategory(.playback, mode: .default, options: [])
+            }
+            if !session.isOtherAudioPlaying {
+                try session.setActive(true)
+            }
+        } catch {
+            print("StreamingPlayer: Failed to configure audio session: \(error)")
+        }
+    }
+
+    // MARK: - Load & Play
+
+    func loadAndPlay(track: ChadTrack, streamURL: URL, authHeaders: [String: String]) {
+        cleanup()
+        configureAudioSession()
+
+        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
+
+        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
+                    }
+                    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
+                }
+            }
+        }
+
+        bufferObservation = playerItem.observe(\.isPlaybackBufferEmpty, options: [.new]) { [weak self] _, change in
+            Task { @MainActor in
+                self?.isBuffering = change.newValue ?? false
+            }
+        }
+
+        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)
+            }
+        }
+
+        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
+    }
+}

+ 185 - 0
Sources/Services/SyncManager.swift

@@ -0,0 +1,185 @@
+import Foundation
+import SwiftData
+import UIKit
+
+/// Syncs playlist metadata between iOS and macOS via a shared JSON file in the app's documents.
+/// The JSON file can be shared through iCloud Drive, AirDrop, or any file transfer method.
+///
+/// Sync format: each device writes its own playlists to a JSON file.
+/// The Mac app watches for this file and imports playlist data.
+/// Matching is done by filename (not file path, since paths differ between devices).
+@MainActor
+final class SyncManager: ObservableObject {
+    @Published var lastSyncDate: Date?
+    @Published var isSyncing = false
+    @Published var syncError: String?
+
+    /// The shared sync directory — lives in the app's documents for easy access.
+    /// Users can share this via iCloud Drive or Files app.
+    static var syncDirectory: URL {
+        let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
+        let syncDir = docs.appendingPathComponent("Sync", isDirectory: true)
+        try? FileManager.default.createDirectory(at: syncDir, withIntermediateDirectories: true)
+        return syncDir
+    }
+
+    /// The sync file that contains exported playlist data.
+    static var syncFileURL: URL {
+        syncDirectory.appendingPathComponent("mixboard-playlists.json")
+    }
+
+    // MARK: - Export Playlists (iOS → JSON → Mac)
+
+    /// Export all playlists to the sync JSON file.
+    func exportPlaylists(_ playlists: [Playlist]) {
+        isSyncing = true
+        syncError = nil
+
+        do {
+            let payload = SyncPayload(
+                version: 1,
+                exportedAt: Date(),
+                exportedFrom: deviceName,
+                playlists: playlists.map { SyncPlaylist(from: $0) }
+            )
+
+            let encoder = JSONEncoder()
+            encoder.dateEncodingStrategy = .iso8601
+            encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+            let data = try encoder.encode(payload)
+            try data.write(to: Self.syncFileURL, options: .atomic)
+
+            lastSyncDate = Date()
+            isSyncing = false
+        } catch {
+            syncError = error.localizedDescription
+            isSyncing = false
+        }
+    }
+
+    /// Import playlists from a sync JSON file (e.g. from Mac).
+    func importPlaylists(from url: URL, context: ModelContext) throws -> [SyncPlaylist] {
+        let data = try Data(contentsOf: url)
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .iso8601
+        let payload = try decoder.decode(SyncPayload.self, from: data)
+        return payload.playlists
+    }
+
+    /// Create actual Playlist models from synced data, matching tracks by filename.
+    func mergeImportedPlaylists(_ syncPlaylists: [SyncPlaylist], existingTracks: [Track], context: ModelContext) -> (created: Int, matched: Int, unmatched: Int) {
+        var totalCreated = 0
+        var totalMatched = 0
+        var totalUnmatched = 0
+
+        for sp in syncPlaylists {
+            let playlist = Playlist(name: sp.name, notes: sp.notes, color: sp.color)
+            playlist.targetBPM = sp.targetBPM
+            context.insert(playlist)
+            totalCreated += 1
+
+            for (position, se) in sp.entries.enumerated() {
+                // Try to find matching track by filename
+                let matchedTrack = existingTracks.first { $0.fileName == se.filename }
+
+                if let track = matchedTrack {
+                    let entry = PlaylistEntry(
+                        position: position,
+                        track: track,
+                        crossfadeDuration: se.crossfadeDuration,
+                        startOffset: se.startOffset,
+                        endOffset: se.endOffset,
+                        gainAdjustment: se.gainAdjustment,
+                        notes: se.notes
+                    )
+                    playlist.entries.append(entry)
+                    totalMatched += 1
+                } else {
+                    // Create a placeholder entry with no track (unmatched)
+                    let entry = PlaylistEntry(
+                        position: position,
+                        track: nil,
+                        crossfadeDuration: se.crossfadeDuration,
+                        notes: "⚠ File not found: \(se.filename)\n\(se.notes)"
+                    )
+                    playlist.entries.append(entry)
+                    totalUnmatched += 1
+                }
+            }
+        }
+
+        try? context.save()
+        return (totalCreated, totalMatched, totalUnmatched)
+    }
+
+    // MARK: - Helpers
+
+    private var deviceName: String {
+        UIDevice.current.name
+    }
+}
+
+// MARK: - Sync Data Models (Codable)
+
+struct SyncPayload: Codable {
+    let version: Int
+    let exportedAt: Date
+    let exportedFrom: String
+    let playlists: [SyncPlaylist]
+}
+
+struct SyncPlaylist: Codable, Identifiable {
+    let id: UUID
+    let name: String
+    let notes: String
+    let color: String
+    let dateCreated: Date
+    let dateModified: Date
+    let targetBPM: Double?
+    let entries: [SyncEntry]
+
+    init(from playlist: Playlist) {
+        self.id = playlist.id
+        self.name = playlist.name
+        self.notes = playlist.notes
+        self.color = playlist.color
+        self.dateCreated = playlist.dateCreated
+        self.dateModified = playlist.dateModified
+        self.targetBPM = playlist.targetBPM
+        self.entries = playlist.sortedEntries.map { SyncEntry(from: $0) }
+    }
+}
+
+struct SyncEntry: Codable {
+    let id: UUID
+    let position: Int
+    let filename: String           // matching key between devices
+    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
+
+    init(from entry: PlaylistEntry) {
+        self.id = entry.id
+        self.position = entry.position
+        self.filename = entry.track?.fileName ?? "unknown"
+        self.title = entry.track?.title ?? "Unknown"
+        self.artist = entry.track?.artist ?? ""
+        self.album = entry.track?.album ?? ""
+        self.duration = entry.track?.duration ?? 0
+        self.bpm = entry.track?.bpm
+        self.musicalKey = entry.track?.musicalKey
+        self.crossfadeDuration = entry.crossfadeDuration
+        self.startOffset = entry.startOffset
+        self.endOffset = entry.endOffset
+        self.gainAdjustment = entry.gainAdjustment
+        self.notes = entry.notes
+    }
+}

+ 73 - 0
Sources/Services/WaveformGenerator.swift

@@ -0,0 +1,73 @@
+import Accelerate
+import AVFoundation
+import Foundation
+
+/// Generates waveform data from audio files for visualization.
+struct WaveformGenerator {
+
+    struct WaveformSample: Codable {
+        let min: Float
+        let max: Float
+    }
+
+    /// Generate downsampled waveform from an audio file.
+    static func generateWaveform(for track: Track, targetSampleCount: Int = 300) async throws -> [WaveformSample] {
+        let url = track.fileURL
+        let file = try AVAudioFile(forReading: url)
+
+        guard let buffer = AVAudioPCMBuffer(pcmFormat: file.processingFormat, frameCapacity: AVAudioFrameCount(file.length)) else {
+            return []
+        }
+        try file.read(into: buffer)
+
+        guard let floatData = buffer.floatChannelData else { return [] }
+        let frameCount = Int(buffer.frameLength)
+        let channelCount = Int(buffer.format.channelCount)
+
+        // Mix to mono
+        var monoSamples = [Float](repeating: 0, count: frameCount)
+        for ch in 0..<channelCount {
+            let channelPtr = floatData[ch]
+            for i in 0..<frameCount {
+                monoSamples[i] += channelPtr[i]
+            }
+        }
+        if channelCount > 1 {
+            var divisor = Float(channelCount)
+            vDSP_vsdiv(monoSamples, 1, &divisor, &monoSamples, 1, vDSP_Length(frameCount))
+        }
+
+        // Downsample to target count
+        let samplesPerBucket = max(1, frameCount / targetSampleCount)
+        var waveform: [WaveformSample] = []
+        waveform.reserveCapacity(targetSampleCount)
+
+        for i in 0..<targetSampleCount {
+            let start = i * samplesPerBucket
+            let end = min(start + samplesPerBucket, frameCount)
+            let count = vDSP_Length(end - start)
+            guard count > 0 else { continue }
+
+            var minVal: Float = 0
+            var maxVal: Float = 0
+            monoSamples.withUnsafeBufferPointer { ptr in
+                let base = ptr.baseAddress! + start
+                vDSP_minv(base, 1, &minVal, count)
+                vDSP_maxv(base, 1, &maxVal, count)
+            }
+            waveform.append(WaveformSample(min: minVal, max: maxVal))
+        }
+
+        // Cache on the track
+        if let encoded = try? JSONEncoder().encode(waveform) {
+            track.waveformData = encoded
+        }
+
+        return waveform
+    }
+
+    /// Decode cached waveform data.
+    static func decodeCachedWaveform(from data: Data) -> [WaveformSample]? {
+        try? JSONDecoder().decode([WaveformSample].self, from: data)
+    }
+}

+ 657 - 0
Sources/ViewModels/PlayerViewModel.swift

@@ -0,0 +1,657 @@
+import Foundation
+import os
+import SwiftData
+import SwiftUI
+
+private let logger = Logger(subsystem: "com.mixboard.MixBoardiOS", category: "PlayerVM")
+
+/// ViewModel wrapping the AudioEngine with additional UI state.
+@MainActor
+@Observable
+final class PlayerViewModel {
+    let audioEngine = AudioEngine()
+    let streamingPlayer = StreamingPlayer()
+
+    // MARK: - UI State
+
+    var showingWaveform = true
+    var waveformSamples: [WaveformGenerator.WaveformSample] = []
+    var isLoadingWaveform = false
+    var showNowPlaying = false
+    var showQueue = false
+
+    /// ID of the currently playing playlist entry.
+    var currentPlayingEntryID: UUID?
+
+    /// The playlist currently being played through.
+    var currentPlaylist: Playlist?
+
+    /// Shuffle mode.
+    var shuffleEnabled: Bool = false {
+        didSet {
+            if shuffleEnabled {
+                upNext.shuffle()
+            } else {
+                rebuildUpNextFromSource()
+            }
+        }
+    }
+
+    /// 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?
+
+    var isBuffering: Bool { streamingPlayer.isBuffering }
+
+    /// Repeat mode.
+    enum RepeatMode: String, CaseIterable {
+        case off = "Off"
+        case all = "Repeat All"
+        case one = "Repeat One"
+
+        var icon: String {
+            switch self {
+            case .off: return "repeat"
+            case .all: return "repeat"
+            case .one: return "repeat.1"
+            }
+        }
+    }
+    var repeatMode: RepeatMode = .off
+
+    // MARK: - Queue
+
+    var nowPlayingEntry: QueueEntry?
+    var userQueue: [QueueEntry] = []
+    var upNext: [QueueEntry] = []
+    var history: [QueueEntry] = []
+
+    /// Undo state for queue replacement
+    var showUndoToast = false
+    var undoMessage = ""
+    @ObservationIgnored private var previousQueueSnapshot: QueueSnapshot?
+    @ObservationIgnored private var undoTimer: Timer?
+
+    private struct QueueSnapshot {
+        let nowPlaying: QueueEntry?
+        let userQueue: [QueueEntry]
+        let upNext: [QueueEntry]
+    }
+
+    /// ModelContext for resolving SwiftData track IDs — set from the view layer
+    @ObservationIgnored var modelContext: ModelContext?
+
+    // MARK: - Synced State
+
+    var isPlaying: Bool = false
+    var currentTime: TimeInterval = 0
+    var duration: TimeInterval = 0
+    var currentTrack: Track?
+
+    var volume: Float {
+        get { audioEngine.volume }
+        set { audioEngine.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() {
+        restoreQueue()
+        startSyncTimer()
+        audioEngine.onPlaybackFinished = { [weak self] in
+            self?.playNext()
+        }
+        streamingPlayer.onPlaybackFinished = { [weak self] in
+            self?.playNext()
+        }
+    }
+
+    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 {
+            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 {
+            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 }
+        }
+
+        stateSaveCounter += 1
+        if stateSaveCounter >= 60 {
+            stateSaveCounter = 0
+            savePlaybackState()
+        }
+
+        nowPlayingCounter += 1
+        if nowPlayingCounter >= 30 {
+            nowPlayingCounter = 0
+            updateNowPlaying()
+        }
+    }
+
+    private func savePlaybackState() {
+        AppState.savePlaybackState(
+            playlistID: currentPlaylist?.id,
+            entryID: currentPlayingEntryID,
+            trackFilePath: currentTrack?.filePath,
+            playbackTime: currentTime
+        )
+    }
+
+    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 {
+                logger.error("loadAndPlay: Failed to build stream URL for cloud track")
+                return
+            }
+            // Stop local playback
+            audioEngine.stop()
+            waveformSamples = []
+
+            isCloudPlayback = true
+            currentCloudTrack = nil
+            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 {
+            logger.notice("loadAndPlay: \(track.title) path=\(track.filePath)")
+            logger.notice("loadAndPlay: fileURL=\(track.fileURL.path)")
+            logger.notice("loadAndPlay: exists=\(FileManager.default.fileExists(atPath: track.fileURL.path))")
+            try audioEngine.loadTrack(track)
+            audioEngine.play()
+            currentPlayingEntryID = entryID
+            if let playlist { currentPlaylist = playlist }
+            syncFromEngine()
+            savePlaybackState()
+            loadWaveform(for: track)
+            resetSkipCounter()
+            logger.notice("loadAndPlay: success, playing")
+        } catch {
+            logger.error("loadAndPlay: FAILED: \(error)")
+            print("PlayerViewModel: Failed to load track: \(error)")
+        }
+    }
+
+    /// Play a cloud track directly via StreamingPlayer (from CloudBrowserView).
+    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)
+        resetSkipCounter()
+        syncFromEngine()
+    }
+
+    func togglePlayPause() {
+        if isCloudPlayback {
+            streamingPlayer.togglePlayPause()
+        } else {
+            audioEngine.togglePlayPause()
+        }
+        syncFromEngine()
+    }
+
+    func stop() {
+        if isCloudPlayback {
+            streamingPlayer.stop()
+            isCloudPlayback = false
+            currentCloudTrack = nil
+        }
+        audioEngine.stop()
+        waveformSamples = []
+        currentPlayingEntryID = nil
+        syncFromEngine()
+    }
+
+    // MARK: - Playlist Navigation (Queue-based)
+
+    func playNext() {
+        // Repeat One: replay current
+        if repeatMode == .one, let current = nowPlayingEntry {
+            playQueueEntry(current)
+            return
+        }
+
+        // Push current to history
+        if let current = nowPlayingEntry {
+            history.insert(current, at: 0)
+            if history.count > 50 { history = Array(history.prefix(50)) }
+        }
+
+        // Pop from userQueue first, then upNext
+        if !userQueue.isEmpty {
+            let next = userQueue.removeFirst()
+            nowPlayingEntry = next
+            playQueueEntry(next)
+            persistQueue()
+            return
+        }
+
+        if !upNext.isEmpty {
+            let next = upNext.removeFirst()
+            nowPlayingEntry = next
+            playQueueEntry(next)
+            persistQueue()
+            return
+        }
+
+        // Both empty — check repeat all
+        if repeatMode == .all, let playlist = currentPlaylist {
+            rebuildUpNextFromPlaylist(playlist, afterEntryID: nil)
+            if !upNext.isEmpty {
+                let next = upNext.removeFirst()
+                nowPlayingEntry = next
+                playQueueEntry(next)
+                persistQueue()
+                return
+            }
+        }
+
+        // Nothing left
+        nowPlayingEntry = nil
+        stop()
+        persistQueue()
+    }
+
+    func playPrevious() {
+        if currentTime > 3 {
+            seek(to: 0)
+            return
+        }
+
+        guard !history.isEmpty else { return }
+        let prev = history.removeFirst()
+
+        // Push current nowPlaying to front of upNext
+        if let current = nowPlayingEntry {
+            upNext.insert(current, at: 0)
+        }
+
+        nowPlayingEntry = prev
+        playQueueEntry(prev)
+        persistQueue()
+    }
+
+    // MARK: - Queue Actions
+
+    /// Add entry to end of user queue.
+    func addToQueue(_ entry: QueueEntry) {
+        userQueue.append(entry)
+        persistQueue()
+        // Auto-play if nothing is currently playing
+        if !isPlaying {
+            playNext()
+        }
+    }
+
+    /// Insert entry at front of user queue ("Play Next").
+    func playNextInQueue(_ entry: QueueEntry) {
+        userQueue.insert(entry, at: 0)
+        persistQueue()
+    }
+
+    /// Play a track from playlist context — sets as nowPlaying, fills upNext with remainder.
+    func playFromPlaylist(track: Track, entryID: UUID, playlist: Playlist) {
+        let hadQueue = nowPlayingEntry != nil || !userQueue.isEmpty || !upNext.isEmpty
+        if hadQueue {
+            previousQueueSnapshot = QueueSnapshot(
+                nowPlaying: nowPlayingEntry,
+                userQueue: userQueue,
+                upNext: upNext
+            )
+        }
+
+        let entry = QueueEntry.from(track: track)
+        nowPlayingEntry = entry
+        userQueue = []
+        history = []
+
+        rebuildUpNextFromPlaylist(playlist, afterEntryID: entryID)
+
+        currentPlaylist = playlist
+        currentPlayingEntryID = entryID
+        playQueueEntry(entry)
+        persistQueue()
+
+        if hadQueue {
+            showUndoToastBriefly("Queue replaced")
+        }
+    }
+
+    /// Play a cloud track directly — sets as nowPlaying, clears upNext.
+    func playCloudTrackDirectly(_ track: ChadTrack, streamURL: URL, authHeaders: [String: String]) {
+        let hadQueue = nowPlayingEntry != nil || !userQueue.isEmpty || !upNext.isEmpty
+        if hadQueue {
+            previousQueueSnapshot = QueueSnapshot(
+                nowPlaying: nowPlayingEntry,
+                userQueue: userQueue,
+                upNext: upNext
+            )
+        }
+
+        let entry = QueueEntry.from(cloudTrack: track)
+        nowPlayingEntry = entry
+        userQueue = []
+        upNext = []
+        history = []
+        currentPlaylist = nil
+        currentPlayingEntryID = nil
+
+        loadAndPlayCloud(track, streamURL: streamURL, authHeaders: authHeaders)
+        persistQueue()
+
+        if hadQueue {
+            showUndoToastBriefly("Queue replaced")
+        }
+    }
+
+    func removeFromQueue(entry: QueueEntry) {
+        userQueue.removeAll { $0.id == entry.id }
+        upNext.removeAll { $0.id == entry.id }
+        persistQueue()
+    }
+
+    func clearQueue() {
+        userQueue.removeAll()
+        upNext.removeAll()
+        persistQueue()
+    }
+
+    func moveUserQueueEntry(from source: IndexSet, to destination: Int) {
+        userQueue.move(fromOffsets: source, toOffset: destination)
+        persistQueue()
+    }
+
+    func moveUpNextEntry(from source: IndexSet, to destination: Int) {
+        upNext.move(fromOffsets: source, toOffset: destination)
+        persistQueue()
+    }
+
+    func undoQueueReplacement() {
+        guard let snapshot = previousQueueSnapshot else { return }
+        nowPlayingEntry = snapshot.nowPlaying
+        userQueue = snapshot.userQueue
+        upNext = snapshot.upNext
+        previousQueueSnapshot = nil
+        showUndoToast = false
+        undoTimer?.invalidate()
+
+        if let entry = nowPlayingEntry {
+            playQueueEntry(entry)
+        }
+        persistQueue()
+    }
+
+    // MARK: - Queue Entry Playback
+
+    private func playQueueEntry(_ entry: QueueEntry) {
+        switch entry.source {
+        case .swiftDataTrack(let trackPersistentID, let isCloud, let cloudStreamPath):
+            guard let ctx = modelContext else {
+                logger.error("playQueueEntry: no modelContext set")
+                return
+            }
+            // Fetch track by UUID string
+            guard let trackID = UUID(uuidString: trackPersistentID) else {
+                logger.error("playQueueEntry: invalid UUID string: \(trackPersistentID)")
+                skipBrokenEntry()
+                return
+            }
+            let descriptor = FetchDescriptor<Track>(
+                predicate: #Predicate<Track> { $0.id == trackID }
+            )
+            guard let track = (try? ctx.fetch(descriptor))?.first else {
+                logger.error("playQueueEntry: track not found for ID \(trackPersistentID)")
+                skipBrokenEntry()
+                return
+            }
+            loadAndPlay(track, entryID: currentPlayingEntryID, playlist: currentPlaylist)
+
+        case .cloudDirect(_, let streamPath):
+            let client = ChadMusicAPIClient.shared
+            guard let url = client.streamURL(for: streamPath) else {
+                logger.error("playQueueEntry: failed to build stream URL for \(streamPath)")
+                skipBrokenEntry()
+                return
+            }
+            let chadTrack = ChadTrack(
+                id: entry.id.uuidString,
+                title: entry.title,
+                artist: entry.artist,
+                albumArtist: nil,
+                album: nil,
+                duration: entry.duration,
+                no: nil,
+                url: streamPath,
+                bitRate: nil,
+                year: nil,
+                cover: nil
+            )
+            loadAndPlayCloud(chadTrack, streamURL: url, authHeaders: client.authHeaders)
+        }
+    }
+
+    @ObservationIgnored private var skipCount = 0
+    private static let maxSkips = 20
+
+    private func skipBrokenEntry() {
+        skipCount += 1
+        guard skipCount <= Self.maxSkips else {
+            logger.error("skipBrokenEntry: exceeded \(Self.maxSkips) skips, stopping to prevent infinite loop")
+            skipCount = 0
+            userQueue.removeAll()
+            upNext.removeAll()
+            stop()
+            return
+        }
+        playNext()
+    }
+
+    /// Reset skip counter on successful playback start
+    private func resetSkipCounter() {
+        skipCount = 0
+    }
+
+    // MARK: - Queue Helpers
+
+    private func rebuildUpNextFromPlaylist(_ playlist: Playlist, afterEntryID: UUID?) {
+        let entries = playlist.sortedEntries
+        var startIndex = 0
+        if let afterID = afterEntryID,
+           let idx = entries.firstIndex(where: { $0.id == afterID }) {
+            startIndex = idx + 1
+        }
+
+        upNext = entries[startIndex...].compactMap { entry -> QueueEntry? in
+            guard let track = entry.track else { return nil }
+            return QueueEntry.from(track: track)
+        }
+
+        if shuffleEnabled {
+            upNext.shuffle()
+        }
+    }
+
+    private func rebuildUpNextFromSource() {
+        guard let playlist = currentPlaylist else { return }
+        rebuildUpNextFromPlaylist(playlist, afterEntryID: currentPlayingEntryID)
+    }
+
+    private func showUndoToastBriefly(_ message: String) {
+        undoMessage = message
+        showUndoToast = true
+        undoTimer?.invalidate()
+        undoTimer = Timer.scheduledTimer(withTimeInterval: 4.0, repeats: false) { [weak self] _ in
+            Task { @MainActor in
+                self?.showUndoToast = false
+                self?.previousQueueSnapshot = nil
+            }
+        }
+    }
+
+    // MARK: - Queue Persistence
+
+    private static let queueKey = "mixboard.queueState"
+
+    private struct PersistedQueue: Codable {
+        let nowPlaying: QueueEntry?
+        let userQueue: [QueueEntry]
+        let upNext: [QueueEntry]
+    }
+
+    private func persistQueue() {
+        let state = PersistedQueue(nowPlaying: nowPlayingEntry, userQueue: userQueue, upNext: upNext)
+        if let data = try? JSONEncoder().encode(state) {
+            UserDefaults.standard.set(data, forKey: Self.queueKey)
+        }
+    }
+
+    private func restoreQueue() {
+        guard let data = UserDefaults.standard.data(forKey: Self.queueKey),
+              let state = try? JSONDecoder().decode(PersistedQueue.self, from: data) else { return }
+        nowPlayingEntry = state.nowPlaying
+        userQueue = state.userQueue
+        upNext = state.upNext
+    }
+
+    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: - Waveform
+
+    func loadWaveform(for track: Track) {
+        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)
+    }
+}

+ 243 - 0
Sources/ViewModels/PlaylistViewModel.swift

@@ -0,0 +1,243 @@
+import Foundation
+import SwiftData
+import SwiftUI
+
+/// ViewModel for managing playlists and building mixes.
+@MainActor
+@Observable
+final class PlaylistViewModel {
+    var selectedPlaylist: Playlist?
+    var showExportSheet = false
+    var exportError: String?
+
+    /// Temporary status message shown as a toast.
+    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)"
+    }
+
+    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
+        }
+    }
+
+    var defaultCrossfadeDuration: TimeInterval = 2.0
+
+    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, context: context) {
+            showStatus("Already in \(target.name)")
+            return false
+        }
+
+        addTrack(track, to: target, context: context)
+        showStatus("→ \(target.name)")
+        return true
+    }
+
+    func quickAddToTarget(track: Track, context: ModelContext) -> Bool {
+        return quickAddToMix(slot: 0, track: track, context: context)
+    }
+
+    /// Safe duplicate check using a fetch query instead of traversing relationships.
+    func isDuplicate(track: Track, in playlist: Playlist, context: ModelContext? = nil) -> Bool {
+        // If we have a context, use a safe fetch-based check
+        if let context {
+            let trackID = track.id
+            let playlistID = playlist.id
+            let descriptor = FetchDescriptor<PlaylistEntry>(
+                predicate: #Predicate<PlaylistEntry> { entry in
+                    entry.playlist?.id == playlistID && entry.track?.id == trackID
+                }
+            )
+            let count = (try? context.fetchCount(descriptor)) ?? 0
+            return count > 0
+        }
+        // Fallback: try relationship traversal but catch crashes
+        let trackID = track.id
+        return playlist.entries.contains { entry in
+            guard let t = entry.track else { return false }
+            return t.id == trackID
+        }
+    }
+
+    // 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
+
+    func addTrack(_ track: Track, to playlist: Playlist, context: ModelContext, warnDuplicate: Bool = true) {
+        if warnDuplicate && isDuplicate(track: track, in: playlist, context: context) {
+            showStatus("⚠ Already in \(playlist.name)")
+            return
+        }
+        playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration)
+        try? context.save()
+    }
+
+    func addTracks(_ tracks: [Track], to playlist: Playlist, context: ModelContext) {
+        // Sort by full file path to preserve folder structure ordering
+        // Use numeric-aware sorting so "12.1.1" comes before "12.1.2"
+        let sorted = tracks.sorted { 
+            $0.filePath.compare($1.filePath, options: [.numeric, .caseInsensitive]) == .orderedAscending
+        }
+        // Log first 10 paths to verify ordering
+        for (i, t) in sorted.prefix(10).enumerated() {
+            print("addTracks sorted[\(i)]: \(t.filePath)")
+        }
+        var added = 0
+        var skipped = 0
+        for track in sorted {
+            if isDuplicate(track: track, in: playlist, context: context) {
+                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()
+    }
+
+    // MARK: - Cue Points
+
+    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: - Import Files to Playlist
+
+    func importFilesToPlaylist(
+        urls: [URL],
+        playlist: Playlist,
+        libraryManager: LibraryManager,
+        context: ModelContext
+    ) async {
+        for url in urls where MetadataService.isSupportedAudioFile(url) {
+            let accessing = url.startAccessingSecurityScopedResource()
+            defer { if accessing { url.stopAccessingSecurityScopedResource() } }
+
+            do {
+                try await libraryManager.importFile(url, context: context)
+            } catch {
+                print("PlaylistViewModel: import failed for \(url.lastPathComponent): \(error)")
+            }
+
+            let fileName = url.lastPathComponent
+            let descriptor = FetchDescriptor<Track>(predicate: #Predicate { $0.fileName == fileName })
+            if let track = try? context.fetch(descriptor).first {
+                playlist.addTrack(track, crossfadeDuration: defaultCrossfadeDuration)
+            }
+        }
+        try? context.save()
+    }
+
+    /// 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)
+    }
+}

+ 75 - 0
Sources/Views/AddGroupToPlaylistSheet.swift

@@ -0,0 +1,75 @@
+import SwiftData
+import SwiftUI
+
+/// Sheet for adding multiple tracks (e.g. an album, artist, folder) to a playlist.
+struct AddGroupToPlaylistSheet: View {
+    let tracks: [Track]
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(\.modelContext) private var modelContext
+    @Environment(\.dismiss) private var dismiss
+
+    @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist]
+    @State private var showNewPlaylist = false
+    @State private var newPlaylistName = ""
+
+    var body: some View {
+        NavigationStack {
+            List {
+                Section {
+                    Text("Adding \(tracks.count) tracks")
+                        .foregroundStyle(theme.secondaryText)
+                }
+
+                Section {
+                    Button {
+                        showNewPlaylist = true
+                    } label: {
+                        Label("New Playlist", systemImage: "plus.circle")
+                            .foregroundStyle(theme.accent)
+                    }
+                }
+
+                Section("Existing Playlists") {
+                    ForEach(playlists) { playlist in
+                        Button {
+                            playlistVM.addTracks(tracks, to: playlist, context: modelContext)
+                            playlistVM.showStatus("Added \(tracks.count) tracks to \(playlist.name)")
+                            dismiss()
+                        } label: {
+                            HStack {
+                                Circle()
+                                    .fill(Color(hex: playlist.color) ?? theme.accent)
+                                    .frame(width: 10, height: 10)
+                                Text(playlist.name)
+                                    .foregroundStyle(theme.primaryText)
+                                Spacer()
+                                Text("\(playlist.trackCount)")
+                                    .font(.caption)
+                                    .foregroundStyle(theme.tertiaryText)
+                            }
+                        }
+                    }
+                }
+            }
+            .navigationTitle("Add to Playlist")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") { dismiss() }
+                }
+            }
+            .alert("New Playlist", isPresented: $showNewPlaylist) {
+                TextField("Playlist name", text: $newPlaylistName)
+                Button("Cancel", role: .cancel) { newPlaylistName = "" }
+                Button("Create & Add") {
+                    guard !newPlaylistName.isEmpty else { return }
+                    let pl = playlistVM.createPlaylist(name: newPlaylistName, context: modelContext)
+                    playlistVM.addTracks(tracks, to: pl, context: modelContext)
+                    playlistVM.showStatus("Created \(newPlaylistName) with \(tracks.count) tracks")
+                    dismiss()
+                }
+            }
+        }
+    }
+}

+ 77 - 0
Sources/Views/AddToPlaylistSheet.swift

@@ -0,0 +1,77 @@
+import SwiftData
+import SwiftUI
+
+/// "Add to Playlist" sheet — pick which playlist to add a track to.
+struct AddToPlaylistSheet: View {
+    let track: Track
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(\.modelContext) private var modelContext
+    @Environment(\.dismiss) private var dismiss
+
+    @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist]
+    @State private var showNewPlaylist = false
+    @State private var newPlaylistName = ""
+
+    var body: some View {
+        NavigationStack {
+            List {
+                Section {
+                    Button {
+                        showNewPlaylist = true
+                    } label: {
+                        Label("New Playlist", systemImage: "plus.circle")
+                            .foregroundStyle(theme.accent)
+                    }
+                }
+
+                Section("Existing Playlists") {
+                    ForEach(playlists) { playlist in
+                        let isDuplicate = playlistVM.isDuplicate(track: track, in: playlist, context: modelContext)
+
+                        Button {
+                            playlistVM.addTrack(track, to: playlist, context: modelContext)
+                            dismiss()
+                        } label: {
+                            HStack {
+                                Circle()
+                                    .fill(Color(hex: playlist.color) ?? theme.accent)
+                                    .frame(width: 10, height: 10)
+                                Text(playlist.name)
+                                    .foregroundStyle(theme.primaryText)
+                                Spacer()
+                                if isDuplicate {
+                                    Text("Already added")
+                                        .font(.caption)
+                                        .foregroundStyle(theme.tertiaryText)
+                                }
+                                Text("\(playlist.trackCount)")
+                                    .font(.caption)
+                                    .foregroundStyle(theme.tertiaryText)
+                            }
+                        }
+                        .disabled(isDuplicate)
+                        .opacity(isDuplicate ? 0.5 : 1)
+                    }
+                }
+            }
+            .navigationTitle("Add to Playlist")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") { dismiss() }
+                }
+            }
+            .alert("New Playlist", isPresented: $showNewPlaylist) {
+                TextField("Playlist name", text: $newPlaylistName)
+                Button("Cancel", role: .cancel) { newPlaylistName = "" }
+                Button("Create & Add") {
+                    guard !newPlaylistName.isEmpty else { return }
+                    let pl = playlistVM.createPlaylist(name: newPlaylistName, context: modelContext)
+                    playlistVM.addTrack(track, to: pl, context: modelContext)
+                    dismiss()
+                }
+            }
+        }
+    }
+}

+ 596 - 0
Sources/Views/CloudBrowserView.swift

@@ -0,0 +1,596 @@
+import os
+import SwiftData
+import SwiftUI
+
+private let cloudLogger = Logger(subsystem: "com.mixboard", category: "CloudAdd")
+
+/// Browse cloud music from the Chad Music server.
+/// Presented as a sheet — browse categories → albums → tracks, play or add to playlists.
+struct CloudBrowserView: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(\.modelContext) private var modelContext
+    @Environment(\.dismiss) private var dismiss
+
+    @State private var client = ChadMusicAPIClient.shared
+    @State private var stats: ChadStats?
+    @State private var isLoadingStats = false
+    @State private var errorMessage: String?
+
+    var body: some View {
+        NavigationStack {
+            Group {
+                if !client.isConfigured {
+                    notConfiguredView
+                } else {
+                    categoryListView
+                }
+            }
+            .navigationTitle("Cloud Music")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button("Done") { dismiss() }
+                }
+            }
+        }
+    }
+
+    // MARK: - Not Configured
+
+    private var notConfiguredView: some View {
+        VStack(spacing: 20) {
+            Spacer()
+            Image(systemName: "cloud.slash")
+                .font(.system(size: 50))
+                .foregroundStyle(theme.tertiaryText)
+            Text("Not Connected")
+                .font(.title2)
+                .foregroundStyle(theme.secondaryText)
+            Text("Set up your Chad Music server in Settings to browse cloud music.")
+                .font(.subheadline)
+                .foregroundStyle(theme.tertiaryText)
+                .multilineTextAlignment(.center)
+                .padding(.horizontal, 40)
+            Spacer()
+        }
+    }
+
+    // MARK: - Category List
+
+    private var categoryListView: some View {
+        List {
+            if let stats {
+                Section {
+                    HStack(spacing: 16) {
+                        statBadge(value: stats.tracks, label: "Tracks")
+                        statBadge(value: stats.albums, label: "Albums")
+                        statBadge(value: stats.artists, label: "Artists")
+                    }
+                    .frame(maxWidth: .infinity)
+                    .listRowBackground(Color.clear)
+                }
+            }
+
+            Section("Browse") {
+                ForEach(ChadCategoryType.allCases) { category in
+                    NavigationLink {
+                        if category == .album {
+                            AlbumListView()
+                        } else {
+                            CategoryDetailView(category: category)
+                        }
+                    } label: {
+                        Label(category.displayName, systemImage: category.icon)
+                    }
+                }
+            }
+        }
+        .listStyle(.insetGrouped)
+        .task {
+            await loadStats()
+        }
+        .refreshable {
+            await loadStats()
+        }
+    }
+
+    private func statBadge(value: Int?, label: String) -> some View {
+        VStack(spacing: 2) {
+            Text("\(value ?? 0)")
+                .font(.system(size: 20, weight: .bold, design: .rounded))
+                .foregroundStyle(theme.accent)
+            Text(label)
+                .font(.caption2)
+                .foregroundStyle(theme.secondaryText)
+        }
+        .frame(maxWidth: .infinity)
+    }
+
+    private func loadStats() async {
+        isLoadingStats = true
+        errorMessage = nil
+        let result = await client.testConnection()
+        switch result {
+        case .success(let s):
+            stats = s
+        case .failure(let error):
+            errorMessage = error.localizedDescription
+        }
+        isLoadingStats = false
+    }
+}
+
+// MARK: - Album List View
+
+struct AlbumListView: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(\.modelContext) private var modelContext
+
+    @State private var albums: [ChadAlbum] = []
+    @State private var isLoading = false
+    @State private var errorMessage: String?
+    @State private var searchText = ""
+
+    private var filteredAlbums: [ChadAlbum] {
+        if searchText.isEmpty { return albums }
+        let query = searchText.lowercased()
+        return albums.filter {
+            ($0.title.lowercased().contains(query)) ||
+            ($0.artist?.lowercased().contains(query) ?? false)
+        }
+    }
+
+    var body: some View {
+        Group {
+            if isLoading && albums.isEmpty {
+                ProgressView("Loading albums…")
+            } else if let error = errorMessage, albums.isEmpty {
+                Text(error).foregroundStyle(.red)
+            } else {
+                List(filteredAlbums) { album in
+                    NavigationLink {
+                        AlbumDetailView(album: album)
+                    } label: {
+                        AlbumRow(album: album)
+                    }
+                }
+                .listStyle(.plain)
+                .searchable(text: $searchText, prompt: "Search albums")
+            }
+        }
+        .navigationTitle("Albums")
+        .navigationBarTitleDisplayMode(.inline)
+        .task {
+            await loadAlbums()
+        }
+    }
+
+    private func loadAlbums() async {
+        isLoading = true
+        do {
+            albums = try await ChadMusicAPIClient.shared.fetchAlbums()
+        } catch {
+            errorMessage = error.localizedDescription
+        }
+        isLoading = false
+    }
+}
+
+// MARK: - Album Row
+
+struct AlbumRow: View {
+    let album: ChadAlbum
+    @EnvironmentObject private var theme: AppTheme
+
+    var body: some View {
+        HStack(spacing: 12) {
+            Image(systemName: "opticaldisc")
+                .font(.title2)
+                .foregroundStyle(theme.accent)
+                .frame(width: 44, height: 44)
+                .background(theme.accent.opacity(0.1))
+                .clipShape(RoundedRectangle(cornerRadius: 8))
+
+            VStack(alignment: .leading, spacing: 2) {
+                Text(album.title)
+                    .font(.subheadline.weight(.medium))
+                    .foregroundStyle(theme.primaryText)
+                    .lineLimit(1)
+                HStack(spacing: 4) {
+                    if let artist = album.artist {
+                        Text(artist)
+                            .font(.caption)
+                            .foregroundStyle(theme.secondaryText)
+                            .lineLimit(1)
+                    }
+                    if let year = album.year {
+                        Text("·")
+                            .foregroundStyle(theme.tertiaryText)
+                        Text("\(year)")
+                            .font(.caption)
+                            .foregroundStyle(theme.tertiaryText)
+                    }
+                }
+            }
+
+            Spacer()
+
+            if let count = album.trackCount {
+                Text("\(count)")
+                    .font(.caption)
+                    .foregroundStyle(theme.tertiaryText)
+            }
+        }
+    }
+}
+
+// MARK: - Category Detail View
+
+struct CategoryDetailView: View {
+    let category: ChadCategoryType
+    @EnvironmentObject private var theme: AppTheme
+
+    @State private var items: [ChadCategory] = []
+    @State private var isLoading = false
+    @State private var errorMessage: String?
+    @State private var searchText = ""
+
+    private var filteredItems: [ChadCategory] {
+        if searchText.isEmpty { return items }
+        let query = searchText.lowercased()
+        return items.filter { $0.name.lowercased().contains(query) }
+    }
+
+    var body: some View {
+        Group {
+            if isLoading && items.isEmpty {
+                ProgressView("Loading…")
+            } else if let error = errorMessage, items.isEmpty {
+                Text(error).foregroundStyle(.red)
+            } else {
+                List(filteredItems) { item in
+                    HStack {
+                        Text(item.name)
+                            .foregroundStyle(theme.primaryText)
+                        Spacer()
+                        if let count = item.count {
+                            Text("\(count)")
+                                .font(.caption)
+                                .foregroundStyle(theme.tertiaryText)
+                        }
+                    }
+                }
+                .listStyle(.plain)
+                .searchable(text: $searchText, prompt: "Search \(category.displayName.lowercased())")
+            }
+        }
+        .navigationTitle(category.displayName)
+        .navigationBarTitleDisplayMode(.inline)
+        .task {
+            await load()
+        }
+    }
+
+    private func load() async {
+        isLoading = true
+        do {
+            items = try await ChadMusicAPIClient.shared.fetchCategory(category)
+        } catch {
+            errorMessage = error.localizedDescription
+        }
+        isLoading = false
+    }
+}
+
+// MARK: - Album Detail View
+
+struct AlbumDetailView: View {
+    let album: ChadAlbum
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(\.modelContext) private var modelContext
+
+    @AppStorage("trackTapAction") private var trackTapAction = "playNow"
+
+    @State private var tracks: [ChadTrack] = []
+    @State private var isLoading = false
+    @State private var errorMessage: String?
+    @State private var showAddToPlaylist = false
+    @State private var tracksToAdd: [ChadTrack] = []
+
+    @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist]
+
+    var body: some View {
+        Group {
+            if isLoading && tracks.isEmpty {
+                ProgressView("Loading tracks…")
+            } else if let error = errorMessage, tracks.isEmpty {
+                Text(error).foregroundStyle(.red)
+            } else {
+                List {
+                    // Album header
+                    Section {
+                        VStack(spacing: 8) {
+                            Image(systemName: "opticaldisc.fill")
+                                .font(.system(size: 50))
+                                .foregroundStyle(theme.accent)
+                            Text(album.title)
+                                .font(.title3.bold())
+                                .foregroundStyle(theme.primaryText)
+                                .multilineTextAlignment(.center)
+                            if let artist = album.artist {
+                                Text(artist)
+                                    .font(.subheadline)
+                                    .foregroundStyle(theme.secondaryText)
+                            }
+                            HStack(spacing: 12) {
+                                if let year = album.year {
+                                    Text("\(year)")
+                                }
+                                if let genre = album.genre {
+                                    Text(genre)
+                                }
+                                Text("\(tracks.count) tracks")
+                            }
+                            .font(.caption)
+                            .foregroundStyle(theme.tertiaryText)
+                        }
+                        .frame(maxWidth: .infinity)
+                        .listRowBackground(Color.clear)
+                    }
+
+                    // Play all / Add all
+                    Section {
+                        Button {
+                            playAll()
+                        } label: {
+                            Label("Play All", systemImage: "play.fill")
+                        }
+
+                        Button {
+                            tracksToAdd = tracks
+                            showAddToPlaylist = true
+                        } label: {
+                            Label("Add All to Playlist", systemImage: "plus.circle")
+                        }
+                    }
+
+                    // Track list
+                    Section {
+                        ForEach(Array(tracks.enumerated()), id: \.element.id) { index, track in
+                            CloudTrackRow(track: track, index: index + 1) {
+                                if trackTapAction == "addToQueue" {
+                                    playerVM.addToQueue(QueueEntry.from(cloudTrack: track))
+                                } else {
+                                    playTrack(track)
+                                }
+                            }
+                            .contextMenu {
+                                Button {
+                                    playTrack(track)
+                                } label: {
+                                    Label("Play Now", systemImage: "play.fill")
+                                }
+                                Button {
+                                    playerVM.playNextInQueue(QueueEntry.from(cloudTrack: track))
+                                } label: {
+                                    Label("Play Next", systemImage: "text.insert")
+                                }
+                                Button {
+                                    playerVM.addToQueue(QueueEntry.from(cloudTrack: track))
+                                } label: {
+                                    Label("Add to Queue", systemImage: "text.append")
+                                }
+                                Divider()
+                                Button {
+                                    tracksToAdd = [track]
+                                    showAddToPlaylist = true
+                                } label: {
+                                    Label("Add to Playlist", systemImage: "plus.circle")
+                                }
+                            }
+                        }
+                    }
+                }
+                .listStyle(.insetGrouped)
+            }
+        }
+        .navigationTitle(album.title)
+        .navigationBarTitleDisplayMode(.inline)
+        .task {
+            await loadTracks()
+        }
+        .sheet(isPresented: $showAddToPlaylist) {
+            CloudAddToPlaylistSheet(
+                tracksToAdd: $tracksToAdd,
+                playlists: playlists
+            )
+        }
+    }
+
+    private func loadTracks() async {
+        isLoading = true
+        do {
+            tracks = try await ChadMusicAPIClient.shared.fetchAlbumTracks(albumId: album.id)
+        } catch {
+            errorMessage = error.localizedDescription
+        }
+        isLoading = false
+    }
+
+    private func playTrack(_ track: ChadTrack) {
+        let client = ChadMusicAPIClient.shared
+        guard let url = client.streamURL(for: track.url) else { return }
+        playerVM.loadAndPlayCloud(track, streamURL: url, authHeaders: client.authHeaders)
+    }
+
+    private func playAll() {
+        guard let firstTrack = tracks.first else { return }
+        playTrack(firstTrack)
+    }
+}
+
+// MARK: - Cloud Track Row
+
+struct CloudTrackRow: View {
+    let track: ChadTrack
+    let index: Int
+    let onTap: () -> Void
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+
+    private var isCurrentlyPlaying: Bool {
+        playerVM.isCloudPlayback && playerVM.currentCloudTrack?.id == track.id
+    }
+
+    var body: some View {
+        Button(action: onTap) {
+            HStack(spacing: 12) {
+                // Track number or playing indicator
+                ZStack {
+                    if isCurrentlyPlaying {
+                        Image(systemName: playerVM.isPlaying ? "speaker.wave.2.fill" : "speaker.fill")
+                            .font(.caption)
+                            .foregroundStyle(theme.accent)
+                    } else {
+                        Text("\(track.trackNumber ?? index)")
+                            .font(.caption.monospacedDigit())
+                            .foregroundStyle(theme.tertiaryText)
+                    }
+                }
+                .frame(width: 28)
+
+                // Track info
+                VStack(alignment: .leading, spacing: 1) {
+                    Text(track.title)
+                        .font(.subheadline)
+                        .foregroundStyle(isCurrentlyPlaying ? theme.accent : theme.primaryText)
+                        .lineLimit(1)
+                    if let artist = track.artist, !artist.isEmpty {
+                        Text(artist)
+                            .font(.caption)
+                            .foregroundStyle(theme.secondaryText)
+                            .lineLimit(1)
+                    }
+                }
+
+                Spacer()
+
+                // Duration
+                Text(track.formattedDuration)
+                    .font(.caption.monospacedDigit())
+                    .foregroundStyle(theme.tertiaryText)
+            }
+        }
+        .buttonStyle(.plain)
+    }
+}
+
+// MARK: - Cloud Add To Playlist Sheet
+
+struct CloudAddToPlaylistSheet: View {
+    @Binding var tracksToAdd: [ChadTrack]
+    let playlists: [Playlist]
+    @Environment(\.modelContext) private var modelContext
+    @Environment(\.dismiss) private var dismiss
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var theme: AppTheme
+
+    @State private var showNewPlaylistAlert = false
+    @State private var newPlaylistName = ""
+
+    var body: some View {
+        NavigationStack {
+            List {
+                // New Playlist button
+                Button {
+                    newPlaylistName = ""
+                    showNewPlaylistAlert = true
+                } label: {
+                    HStack {
+                        Image(systemName: "plus.circle.fill")
+                            .foregroundStyle(theme.accent)
+                        Text("New Playlist")
+                            .foregroundStyle(theme.accent)
+                    }
+                }
+
+                // Existing playlists
+                ForEach(playlists) { playlist in
+                    Button {
+                        addTracks(to: playlist)
+                        dismiss()
+                    } label: {
+                        HStack {
+                            Image(systemName: "music.note.list")
+                                .foregroundStyle(theme.accent)
+                            Text(playlist.name)
+                                .foregroundStyle(theme.primaryText)
+                            Spacer()
+                            Text("\(playlist.entries.count) tracks")
+                                .font(.caption)
+                                .foregroundStyle(theme.tertiaryText)
+                        }
+                    }
+                }
+            }
+            .navigationTitle("Add to Playlist")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button("Cancel") { dismiss() }
+                }
+            }
+            .alert("New Playlist", isPresented: $showNewPlaylistAlert) {
+                TextField("Playlist name", text: $newPlaylistName)
+                Button("Create") {
+                    guard !newPlaylistName.trimmingCharacters(in: .whitespaces).isEmpty else { return }
+                    let playlist = Playlist(name: newPlaylistName.trimmingCharacters(in: .whitespaces))
+                    modelContext.insert(playlist)
+                    addTracks(to: playlist)
+                    dismiss()
+                }
+                Button("Cancel", role: .cancel) {}
+            } message: {
+                Text("Enter a name for the new playlist.")
+            }
+        }
+    }
+
+    private func addTracks(to playlist: Playlist) {
+        cloudLogger.notice("START — adding \(self.tracksToAdd.count) tracks to '\(playlist.name)' (current entries: \(playlist.entries.count))")
+
+        var newTracks: [Track] = []
+        for chadTrack in tracksToAdd {
+            let track = Track.fromCloud(chadTrack)
+            modelContext.insert(track)
+            newTracks.append(track)
+            cloudLogger.notice("inserted track '\(track.title)' isCloud=\(track.isCloud) cloudId=\(track.cloudTrackId ?? "nil")")
+        }
+
+        do {
+            try modelContext.save()
+            cloudLogger.notice("saved \(newTracks.count) tracks OK")
+        } catch {
+            cloudLogger.error("SAVE TRACKS FAILED: \(error)")
+        }
+
+        for track in newTracks {
+            playlist.addTrack(track)
+            cloudLogger.notice("added '\(track.title)' to playlist, entries now: \(playlist.entries.count)")
+        }
+
+        do {
+            try modelContext.save()
+            cloudLogger.notice("final save OK — playlist '\(playlist.name)' entries: \(playlist.entries.count)")
+        } catch {
+            cloudLogger.error("FINAL SAVE FAILED: \(error)")
+        }
+
+        for entry in playlist.entries {
+            cloudLogger.notice("VERIFY entry pos=\(entry.position) track=\(entry.track?.title ?? "NIL")")
+        }
+    }
+}

+ 116 - 0
Sources/Views/ContentView.swift

@@ -0,0 +1,116 @@
+import SwiftData
+import SwiftUI
+
+/// Root view — Playlists as the main screen, Library and Settings accessible from toolbar.
+struct ContentView: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var libraryManager: LibraryManager
+    @EnvironmentObject private var theme: AppTheme
+    @EnvironmentObject private var syncManager: SyncManager
+    @Environment(\.modelContext) private var modelContext
+
+    @State private var showLibrary = false
+    @State private var showSettings = false
+    @State private var showCloudBrowser = false
+
+    @Query(sort: \Playlist.dateModified, order: .reverse)
+    private var playlists: [Playlist]
+
+    var body: some View {
+        VStack(spacing: 0) {
+            // Main content: Playlists
+            PlaylistListView()
+
+            // Mini player at bottom
+            if playerVM.currentTrack != nil || playerVM.isCloudPlayback {
+                MiniPlayerView()
+            }
+        }
+        .accessibilityIdentifier("ContentView")
+        .overlay(alignment: .bottom) {
+            // Undo queue replacement toast
+            if playerVM.showUndoToast {
+                HStack(spacing: 8) {
+                    Text(playerVM.undoMessage)
+                        .font(.subheadline)
+                        .foregroundStyle(theme.primaryText)
+                    Button("Undo") {
+                        playerVM.undoQueueReplacement()
+                    }
+                    .font(.subheadline.bold())
+                    .foregroundStyle(theme.accent)
+                }
+                .padding(.horizontal, 16)
+                .padding(.vertical, 10)
+                .background(theme.cardBackground.opacity(0.95))
+                .clipShape(Capsule())
+                .shadow(radius: 8)
+                .padding(.bottom, (playerVM.currentTrack != nil || playerVM.isCloudPlayback) ? 90 : 60)
+                .transition(.move(edge: .bottom).combined(with: .opacity))
+                .animation(.easeInOut(duration: 0.3), value: playerVM.showUndoToast)
+            }
+        }
+        .overlay(alignment: .bottom) {
+            // Status toast
+            if let status = playlistVM.statusMessage {
+                HStack(spacing: 8) {
+                    Image(systemName: "checkmark.circle.fill")
+                        .foregroundStyle(theme.accent)
+                    Text(status)
+                        .font(.subheadline)
+                        .foregroundStyle(theme.primaryText)
+                }
+                .padding(.horizontal, 16)
+                .padding(.vertical, 10)
+                .background(theme.cardBackground.opacity(0.95))
+                .clipShape(Capsule())
+                .shadow(radius: 8)
+                .padding(.bottom, (playerVM.currentTrack != nil || playerVM.isCloudPlayback) ? 90 : 60)
+                .transition(.move(edge: .bottom).combined(with: .opacity))
+                .animation(.easeInOut(duration: 0.3), value: playlistVM.statusMessage)
+            }
+        }
+        .onAppear {
+            libraryManager.setModelContext(modelContext)
+            libraryManager.fixBadPathsIfNeeded()
+            playlistVM.restoreTargetPlaylist(from: playlists)
+            playerVM.modelContext = modelContext
+        }
+        .onChange(of: playlists.count) { _, newCount in
+            guard newCount > 0 else { return }
+            Task {
+                try? await Task.sleep(for: .seconds(2))
+                syncManager.exportPlaylists(playlists)
+            }
+        }
+        .fullScreenCover(isPresented: Binding(
+            get: { playerVM.showNowPlaying },
+            set: { playerVM.showNowPlaying = $0 }
+        )) {
+            NowPlayingView()
+                .environmentObject(theme)
+                .environmentObject(libraryManager)
+        }
+        .sheet(isPresented: $showLibrary) {
+            NavigationStack {
+                LibraryView()
+            }
+            .environmentObject(theme)
+        }
+        .sheet(isPresented: $showSettings) {
+            SettingsView()
+                .environmentObject(theme)
+                .environmentObject(syncManager)
+        }
+        .sheet(isPresented: $showCloudBrowser) {
+            CloudBrowserView()
+                .environmentObject(theme)
+        }
+        .sheet(isPresented: Bindable(playerVM).showQueue) {
+            QueueView()
+                .environment(playerVM)
+                .environmentObject(theme)
+        }
+    }
+}

+ 352 - 0
Sources/Views/FolderBrowserView.swift

@@ -0,0 +1,352 @@
+import SwiftData
+import SwiftUI
+
+/// Folder browser — drill down into the Documents directory tree.
+/// Supports recursive add-to-playlist at any folder level.
+struct FolderBrowserView: View {
+    let folderURL: URL
+    let title: String
+
+    @Environment(PlayerViewModel.self) private var playerVM
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var theme: AppTheme
+    @EnvironmentObject private var libraryManager: LibraryManager
+    @Environment(\.modelContext) private var modelContext
+
+    @Query private var allTracks: [Track]
+
+    @State private var subfolders: [URL] = []
+    @State private var audioFiles: [URL] = []
+    @State private var showAddGroupToPlaylist: [Track]?
+    @State private var cachedAllTracks: [Track]?
+
+    private static let mixColors: [Color] = [
+        Color(red: 0.95, green: 0.3, blue: 0.3),
+        Color(red: 0.3, green: 0.75, blue: 0.95),
+        Color(red: 0.95, green: 0.75, blue: 0.2),
+    ]
+
+    var body: some View {
+        List {
+            // Action row — show if there are tracks in this folder tree
+            if let allTrx = cachedAllTracks, !allTrx.isEmpty {
+                folderActionRow(allTrx)
+            }
+
+            // Subfolders
+            if !subfolders.isEmpty {
+                Section("Folders") {
+                    ForEach(subfolders, id: \.path) { folder in
+                        NavigationLink {
+                            FolderBrowserView(folderURL: folder, title: folder.lastPathComponent)
+                        } label: {
+                            HStack(spacing: 12) {
+                                folderArtwork(folder)
+                                    .frame(width: 40, height: 40)
+
+                                Text(folder.lastPathComponent)
+                                    .foregroundStyle(theme.primaryText)
+                                    .lineLimit(2)
+                            }
+                        }
+                        .contextMenu {
+                            let tracks = allTracksRecursive(in: folder)
+                            if !tracks.isEmpty {
+                                Button {
+                                    if let first = tracks.first {
+                                        playerVM.loadAndPlay(first)
+                                    }
+                                } label: {
+                                    Label("Play All (\(tracks.count))", systemImage: "play.fill")
+                                }
+
+                                Divider()
+
+                                ForEach(0..<3, id: \.self) { slot in
+                                    if playlistVM.mixTargets[slot] != nil {
+                                        Button {
+                                            for track in tracks {
+                                                _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
+                                            }
+                                            playlistVM.showStatus("Added \(tracks.count) to \(playlistVM.mixTargetName(slot))")
+                                        } label: {
+                                            Label("Add all to \(playlistVM.mixTargetName(slot))", systemImage: "\(slot + 1).circle.fill")
+                                        }
+                                    }
+                                }
+
+                                Divider()
+
+                                Button {
+                                    showAddGroupToPlaylist = tracks
+                                } label: {
+                                    Label("Add all to Playlist...", systemImage: "plus.circle")
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+
+            // Audio files in this folder
+            if !audioFiles.isEmpty {
+                Section("Audio files") {
+                    ForEach(audioFiles, id: \.path) { fileURL in
+                        if let track = trackForFile(fileURL) {
+                            Button {
+                                playerVM.loadAndPlay(track)
+                                playerVM.showNowPlaying = true
+                            } label: {
+                                TrackRow(track: track)
+                                    .contentShape(Rectangle())
+                            }
+                            .buttonStyle(.plain)
+                            .contextMenu {
+                                Button {
+                                    playerVM.loadAndPlay(track)
+                                    playerVM.showNowPlaying = true
+                                } label: {
+                                    Label("Play", systemImage: "play.fill")
+                                }
+
+                                Divider()
+
+                                ForEach(0..<3, id: \.self) { slot in
+                                    if playlistVM.mixTargets[slot] != nil {
+                                        Button {
+                                            _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
+                                        } label: {
+                                            Label("Add to \(playlistVM.mixTargetName(slot))", systemImage: "\(slot + 1).circle.fill")
+                                        }
+                                    }
+                                }
+
+                                Divider()
+
+                                Button {
+                                    showAddGroupToPlaylist = [track]
+                                } label: {
+                                    Label("Add to Playlist...", systemImage: "plus.circle")
+                                }
+                            }
+                        } else {
+                            // File not yet imported
+                            HStack {
+                                Image(systemName: "music.note")
+                                    .foregroundStyle(theme.tertiaryText)
+                                    .frame(width: 40, height: 40)
+                                VStack(alignment: .leading) {
+                                    Text(fileURL.lastPathComponent)
+                                        .foregroundStyle(theme.primaryText)
+                                        .lineLimit(1)
+                                    Text("Not imported")
+                                        .font(.caption)
+                                        .foregroundStyle(theme.tertiaryText)
+                                }
+                                Spacer()
+                                let size = (try? FileManager.default.attributesOfItem(atPath: fileURL.path)[.size] as? Int64) ?? 0
+                                Text(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))
+                                    .font(.caption)
+                                    .foregroundStyle(theme.tertiaryText)
+                            }
+                        }
+                    }
+                }
+            }
+
+            if subfolders.isEmpty && audioFiles.isEmpty {
+                Text("Empty folder")
+                    .foregroundStyle(theme.tertiaryText)
+                    .frame(maxWidth: .infinity, alignment: .center)
+            }
+        }
+        .listStyle(.plain)
+        .navigationTitle(title)
+        .navigationBarTitleDisplayMode(.inline)
+        .onAppear {
+            scanFolder()
+            // Compute recursive tracks once, not on every render
+            cachedAllTracks = allTracksRecursive(in: folderURL)
+        }
+        .sheet(isPresented: Binding(
+            get: { showAddGroupToPlaylist != nil },
+            set: { if !$0 { showAddGroupToPlaylist = nil } }
+        )) {
+            if let tracks = showAddGroupToPlaylist {
+                AddGroupToPlaylistSheet(tracks: tracks)
+                    .environmentObject(theme)
+            }
+        }
+    }
+
+    // MARK: - Folder scanning
+
+    private func scanFolder() {
+        let fm = FileManager.default
+        guard let contents = try? fm.contentsOfDirectory(
+            at: folderURL,
+            includingPropertiesForKeys: [.isDirectoryKey],
+            options: [.skipsHiddenFiles]
+        ) else { return }
+
+        var folders: [URL] = []
+        var files: [URL] = []
+
+        for url in contents.sorted(by: { $0.lastPathComponent.compare($1.lastPathComponent, options: [.numeric, .caseInsensitive]) == .orderedAscending }) {
+            var isDir: ObjCBool = false
+            if fm.fileExists(atPath: url.path, isDirectory: &isDir) {
+                if isDir.boolValue {
+                    folders.append(url)
+                } else if MetadataService.isSupportedAudioFile(url) {
+                    files.append(url)
+                }
+            }
+        }
+
+        subfolders = folders
+        audioFiles = files
+    }
+
+    // MARK: - Track matching
+
+    /// Find the Track model for a file URL by matching the relative path.
+    private func trackForFile(_ url: URL) -> Track? {
+        let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
+        let standardized = url.standardizedFileURL.path
+        let docsPath = docsDir.standardizedFileURL.path
+        let relativePath = standardized.hasPrefix(docsPath + "/")
+            ? String(standardized.dropFirst(docsPath.count + 1))
+            : url.lastPathComponent
+        // Try matching by full path first, fallback to fileName
+        return allTracks.first { $0.filePath == relativePath }
+            ?? allTracks.first { $0.fileName == url.lastPathComponent }
+    }
+
+    /// All tracks in this folder (not recursive).
+    private var tracksInThisFolder: [Track] {
+        audioFiles.compactMap { trackForFile($0) }
+    }
+
+    /// All tracks recursively in a folder and its subfolders.
+    private func allTracksRecursive(in folder: URL) -> [Track] {
+        let fm = FileManager.default
+        var result: [Track] = []
+        var notFound: [String] = []
+
+        guard let enumerator = fm.enumerator(
+            at: folder,
+            includingPropertiesForKeys: [.isRegularFileKey],
+            options: [.skipsHiddenFiles]
+        ) else { return result }
+
+        for case let fileURL as URL in enumerator {
+            if MetadataService.isSupportedAudioFile(fileURL) {
+                if let track = trackForFile(fileURL) {
+                    result.append(track)
+                } else {
+                    notFound.append(fileURL.lastPathComponent)
+                }
+            }
+        }
+
+        if !notFound.isEmpty {
+            print("allTracksRecursive: \(notFound.count) files not in DB")
+        }
+
+        // Sort by full path with numeric sorting so "12.1.1" < "12.1.2" < "12.1.10"
+        result.sort { $0.filePath.compare($1.filePath, options: [.numeric, .caseInsensitive]) == .orderedAscending }
+
+        return result
+    }
+
+    // MARK: - Folder artwork (first image in folder or first track's art)
+
+    private func folderArtwork(_ folder: URL) -> some View {
+        let fm = FileManager.default
+        let imageExts: Set<String> = ["jpg", "jpeg", "png"]
+
+        // Look for cover image in the folder
+        var imageData: Data?
+        if let contents = try? fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
+            for file in contents {
+                if imageExts.contains(file.pathExtension.lowercased()) {
+                    imageData = try? Data(contentsOf: file)
+                    break
+                }
+            }
+        }
+
+        return Group {
+            if let data = imageData, let img = UIImage(data: data) {
+                Image(uiImage: img)
+                    .resizable()
+                    .aspectRatio(contentMode: .fill)
+                    .clipShape(RoundedRectangle(cornerRadius: 6))
+            } else {
+                ZStack {
+                    RoundedRectangle(cornerRadius: 6)
+                        .fill(theme.cardBackground)
+                    Image(systemName: "folder.fill")
+                        .foregroundStyle(theme.tertiaryText)
+                }
+            }
+        }
+    }
+
+    // MARK: - Folder action row
+
+    private func folderActionRow(_ tracks: [Track]) -> some View {
+        HStack(spacing: 12) {
+            Button {
+                if let first = tracks.first {
+                    playerVM.loadAndPlay(first)
+                }
+            } label: {
+                Label("Play", systemImage: "play.fill")
+                    .font(.caption)
+                    .foregroundStyle(theme.accent)
+            }
+            .buttonStyle(.plain)
+
+            Divider().frame(height: 16)
+
+            ForEach(0..<3, id: \.self) { slot in
+                if playlistVM.mixTargets[slot] != nil {
+                    Button {
+                        for track in tracks {
+                            _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
+                        }
+                        playlistVM.showStatus("Added \(tracks.count) to \(playlistVM.mixTargetName(slot))")
+                    } label: {
+                        Text("\(slot + 1)")
+                            .font(.system(size: 11, weight: .bold, design: .rounded))
+                            .frame(width: 24, height: 24)
+                            .foregroundStyle(Self.mixColors[slot])
+                            .background(Self.mixColors[slot].opacity(0.15))
+                            .clipShape(RoundedRectangle(cornerRadius: 5))
+                    }
+                    .buttonStyle(.plain)
+                }
+            }
+
+            Divider().frame(height: 16)
+
+            Button {
+                showAddGroupToPlaylist = tracks
+            } label: {
+                Image(systemName: "plus.circle")
+                    .font(.system(size: 16))
+                    .foregroundStyle(theme.secondaryText)
+            }
+            .buttonStyle(.plain)
+
+            Spacer()
+
+            Text("\(tracks.count) tracks")
+                .font(.caption)
+                .foregroundStyle(theme.tertiaryText)
+        }
+        .padding(.vertical, 4)
+        .listRowBackground(theme.cardBackground.opacity(0.3))
+    }
+}

+ 115 - 0
Sources/Views/GroupTemplateEditorSheet.swift

@@ -0,0 +1,115 @@
+import SwiftUI
+
+/// Editor for 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 {
+        NavigationStack {
+            List {
+                Section {
+                    TextField("Template (empty = no grouping)", text: $template)
+                        .font(.body.monospaced())
+                        .autocorrectionDisabled()
+                        .textInputAutocapitalization(.never)
+                } header: {
+                    Text("Group Template")
+                } footer: {
+                    Text("Use placeholders like {Album}, {Artist}, {Date}, etc. You can also use {Year} as an alias for {Date}. Tracks with the same resolved value will be grouped together.")
+                }
+
+                Section("Presets") {
+                    ForEach(GroupTemplateResolver.presets, id: \.template) { preset in
+                        Button {
+                            template = preset.template
+                        } label: {
+                            HStack {
+                                VStack(alignment: .leading, spacing: 2) {
+                                    Text(preset.name)
+                                        .foregroundStyle(theme.primaryText)
+                                    if !preset.template.isEmpty {
+                                        Text(preset.template)
+                                            .font(.caption.monospaced())
+                                            .foregroundStyle(theme.tertiaryText)
+                                    }
+                                }
+                                Spacer()
+                                if template == preset.template {
+                                    Image(systemName: "checkmark")
+                                        .foregroundStyle(theme.accent)
+                                }
+                            }
+                        }
+                    }
+                }
+
+                Section("Available Placeholders") {
+                    ForEach(GroupTemplateResolver.placeholders, id: \.token) { placeholder in
+                        Button {
+                            template += placeholder.token
+                        } label: {
+                            HStack {
+                                Text(placeholder.token)
+                                    .font(.body.monospaced())
+                                    .foregroundStyle(theme.accent)
+                                Spacer()
+                                Text(placeholder.description)
+                                    .font(.caption)
+                                    .foregroundStyle(theme.tertiaryText)
+                            }
+                        }
+                    }
+                }
+
+                if !template.isEmpty {
+                    Section("Preview") {
+                        Text("Groups will look like:")
+                            .font(.caption)
+                            .foregroundStyle(theme.tertiaryText)
+                        Text(previewText)
+                            .font(.subheadline.weight(.semibold))
+                            .foregroundStyle(theme.groupHeaderText)
+                    }
+                }
+            }
+            .navigationTitle("Grouping")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") { dismiss() }
+                }
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Save") {
+                        playlist.groupTemplate = template
+                        try? modelContext.save()
+                        dismiss()
+                    }
+                }
+            }
+            .onAppear {
+                template = playlist.groupTemplate
+            }
+        }
+    }
+
+    private var previewText: String {
+        // Show a sample with dummy data
+        let sample = template
+            .replacingOccurrences(of: "{Artist}", with: "Raekwon")
+            .replacingOccurrences(of: "{Album}", with: "Only Built 4 Cuban Linx")
+            .replacingOccurrences(of: "{Genre}", with: "Hip Hop")
+            .replacingOccurrences(of: "{Date}", with: "1995")
+            .replacingOccurrences(of: "{Year}", with: "1995")
+            .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
+    }
+}

+ 526 - 0
Sources/Views/LibraryView.swift

@@ -0,0 +1,526 @@
+import SwiftData
+import SwiftUI
+import UniformTypeIdentifiers
+
+/// Library tab — browse and manage imported audio files.
+/// Five browse modes: Songs (flat), Artists, Albums, Genres, Folders.
+struct LibraryView: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var libraryManager: LibraryManager
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(\.modelContext) private var modelContext
+
+    @Query(sort: \Track.dateAdded, order: .reverse) private var tracks: [Track]
+
+    @State private var showImporter = false
+    @State private var showFolderImporter = false
+    @State private var searchText = ""
+    @State private var showAddToPlaylist: Track?
+    @State private var browseMode: BrowseMode = .folders
+    @State private var sortOrder: SortOrder = .dateAdded
+    @State private var hasScanned = false
+
+    enum BrowseMode: String, CaseIterable {
+        case folders = "Folders"
+        case songs = "Songs"
+        case artists = "Artists"
+        case albums = "Albums"
+        case genres = "Genres"
+    }
+
+    enum SortOrder: String, CaseIterable {
+        case dateAdded = "Date Added"
+        case title = "Title"
+        case artist = "Artist"
+        case bpm = "BPM"
+        case duration = "Duration"
+    }
+
+    // MARK: - Filtered & Sorted Tracks
+
+    private var filteredTracks: [Track] {
+        let base: [Track]
+        switch sortOrder {
+        case .dateAdded: base = tracks
+        case .title: base = tracks.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
+        case .artist: base = tracks.sorted { $0.artist.localizedCaseInsensitiveCompare($1.artist) == .orderedAscending }
+        case .bpm: base = tracks.sorted { ($0.bpm ?? 0) < ($1.bpm ?? 0) }
+        case .duration: base = tracks.sorted { $0.duration < $1.duration }
+        }
+
+        if searchText.isEmpty { return base }
+        let query = searchText.lowercased()
+        return base.filter {
+            $0.title.lowercased().contains(query) ||
+            $0.artist.lowercased().contains(query) ||
+            $0.album.lowercased().contains(query)
+        }
+    }
+
+    // MARK: - Grouped Data
+
+    private var artistGroups: [(String, [Track])] {
+        let grouped = Dictionary(grouping: filteredTracks) { $0.artist.isEmpty ? "Unknown Artist" : $0.artist }
+        return grouped.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }
+    }
+
+    private var albumGroups: [(String, [Track])] {
+        let grouped = Dictionary(grouping: filteredTracks) {
+            let artist = $0.artist.isEmpty ? "Unknown" : $0.artist
+            let album = $0.album.isEmpty ? "Unknown Album" : $0.album
+            return "\(artist) — \(album)"
+        }
+        return grouped.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }
+    }
+
+    private var genreGroups: [(String, [Track])] {
+        let grouped = Dictionary(grouping: filteredTracks) { $0.genre.isEmpty ? "Unknown Genre" : $0.genre }
+        return grouped.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }
+    }
+
+    private var folderGroups: [(String, [Track])] {
+        let grouped = Dictionary(grouping: filteredTracks) { track -> String in
+            let components = track.filePath.split(separator: "/").dropLast()
+            return components.isEmpty ? "/" : components.joined(separator: "/")
+        }
+        return grouped.sorted { $0.key.localizedCaseInsensitiveCompare($1.key) == .orderedAscending }
+    }
+
+    // MARK: - Body
+
+    var body: some View {
+        NavigationStack {
+            VStack(spacing: 0) {
+                if !tracks.isEmpty {
+                    // Browse mode picker
+                    ScrollView(.horizontal, showsIndicators: false) {
+                        HStack(spacing: 8) {
+                            ForEach(BrowseMode.allCases, id: \.self) { mode in
+                                Button {
+                                    withAnimation(.easeInOut(duration: 0.2)) {
+                                        browseMode = mode
+                                    }
+                                } label: {
+                                    Text(mode.rawValue)
+                                        .font(.subheadline.weight(browseMode == mode ? .semibold : .regular))
+                                        .padding(.horizontal, 14)
+                                        .padding(.vertical, 6)
+                                        .background(browseMode == mode ? theme.accent.opacity(0.2) : Color.clear)
+                                        .foregroundStyle(browseMode == mode ? theme.accent : theme.secondaryText)
+                                        .clipShape(Capsule())
+                                        .overlay(
+                                            Capsule()
+                                                .stroke(browseMode == mode ? theme.accent.opacity(0.5) : theme.separatorColor, lineWidth: 1)
+                                        )
+                                }
+                                .buttonStyle(.plain)
+                            }
+                        }
+                        .padding(.horizontal, 16)
+                        .padding(.vertical, 8)
+                    }
+
+                    Divider()
+                }
+
+                Group {
+                    if tracks.isEmpty {
+                        emptyState
+                    } else {
+                        mainList
+                    }
+                }
+            }
+            .navigationTitle("Library")
+            .accessibilityIdentifier("libraryView")
+            .searchable(text: $searchText, prompt: "Search tracks")
+            .toolbar {
+                ToolbarItem(placement: .topBarLeading) {
+                    Menu {
+                        ForEach(SortOrder.allCases, id: \.self) { order in
+                            Button {
+                                sortOrder = order
+                            } label: {
+                                HStack {
+                                    Text(order.rawValue)
+                                    if sortOrder == order {
+                                        Image(systemName: "checkmark")
+                                    }
+                                }
+                            }
+                        }
+                    } label: {
+                        Image(systemName: "arrow.up.arrow.down")
+                    }
+                }
+
+                ToolbarItem(placement: .topBarTrailing) {
+                    Menu {
+                        Button {
+                            showImporter = true
+                        } label: {
+                            Label("Import Files", systemImage: "doc.badge.plus")
+                        }
+
+                        Button {
+                            showFolderImporter = true
+                        } label: {
+                            Label("Import Folder", systemImage: "folder.badge.plus")
+                        }
+
+                        Button {
+                            Task { await libraryManager.scanMusicDirectory() }
+                        } label: {
+                            Label("Scan Music Folder", systemImage: "arrow.clockwise")
+                        }
+                    } label: {
+                        Image(systemName: "plus")
+                    }
+                }
+
+                if !tracks.filter({ !$0.isAnalyzed }).isEmpty {
+                    ToolbarItem(placement: .topBarTrailing) {
+                        Button {
+                            Task { await libraryManager.analyzeAllTracks(tracks: tracks) }
+                        } label: {
+                            Image(systemName: "waveform.badge.magnifyingglass")
+                        }
+                    }
+                }
+            }
+            .fileImporter(
+                isPresented: $showImporter,
+                allowedContentTypes: [
+                    .mp3, .wav, .aiff,
+                    UTType(filenameExtension: "flac") ?? .audio,
+                    UTType(filenameExtension: "m4a") ?? .audio,
+                    UTType(filenameExtension: "ogg") ?? .audio,
+                    .audio
+                ],
+                allowsMultipleSelection: true
+            ) { result in
+                if case .success(let urls) = result {
+                    Task { await libraryManager.importFiles(urls) }
+                }
+            }
+            .fileImporter(
+                isPresented: $showFolderImporter,
+                allowedContentTypes: [.folder],
+                allowsMultipleSelection: true
+            ) { result in
+                if case .success(let urls) = result {
+                    Task { await libraryManager.importFiles(urls) }
+                }
+            }
+            .sheet(item: $showAddToPlaylist) { track in
+                AddToPlaylistSheet(track: track)
+                    .environmentObject(theme)
+            }
+            .sheet(isPresented: Binding(
+                get: { showAddGroupToPlaylist != nil },
+                set: { if !$0 { showAddGroupToPlaylist = nil } }
+            )) {
+                if let tracks = showAddGroupToPlaylist {
+                    AddGroupToPlaylistSheet(tracks: tracks)
+                        .environmentObject(theme)
+                }
+            }
+            .task(id: "initialScan") {
+                // Only scan once when the view first appears, not on every tab switch
+                guard !hasScanned else { return }
+                hasScanned = true
+                await libraryManager.scanMusicDirectory()
+            }
+        }
+    }
+
+    // MARK: - Main List
+
+    @ViewBuilder
+    private var mainList: some View {
+        if browseMode == .folders {
+            // Folders mode: show drill-down browser directly
+            FolderBrowserView(
+                folderURL: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!,
+                title: "Music"
+            )
+        } else {
+            List {
+                if libraryManager.isScanning {
+                    scanningRow
+                }
+
+                switch browseMode {
+                case .songs:
+                    songsList
+                case .artists:
+                    groupedList(groups: artistGroups, icon: "person.fill")
+                case .albums:
+                    groupedList(groups: albumGroups, icon: "square.stack.fill")
+                case .genres:
+                    groupedList(groups: genreGroups, icon: "guitars.fill")
+                case .folders:
+                    EmptyView() // handled above
+                }
+
+                footer
+            }
+            .listStyle(.plain)
+        }
+    }
+
+    // MARK: - Songs (flat list)
+
+    private var songsList: some View {
+        ForEach(filteredTracks) { track in
+            trackRow(track)
+        }
+    }
+
+    // MARK: - Grouped list
+
+    @State private var showAddGroupToPlaylist: [Track]?
+
+    private func groupedList(groups: [(String, [Track])], icon: String) -> some View {
+        ForEach(groups, id: \.0) { groupName, groupTracks in
+            Section {
+                // Action row for the whole group
+                groupActionRow(groupTracks)
+
+                ForEach(groupTracks) { track in
+                    trackRow(track)
+                }
+            } header: {
+                HStack(spacing: 8) {
+                    Image(systemName: icon)
+                        .font(.caption)
+                        .foregroundStyle(theme.accent)
+                    Text(groupName)
+                        .font(.subheadline.weight(.semibold))
+                        .foregroundStyle(theme.groupHeaderText)
+
+                    Spacer()
+
+                    Text("\(groupTracks.count)")
+                        .font(.caption)
+                        .foregroundStyle(theme.tertiaryText)
+
+                    let totalDuration = groupTracks.reduce(0) { $0 + $1.duration }
+                    Text(formatDuration(totalDuration))
+                        .font(.caption.monospacedDigit())
+                        .foregroundStyle(theme.tertiaryText)
+                }
+            }
+        }
+    }
+
+    // MARK: - Group Action Row (play all, add all to mix/playlist)
+
+    private func groupActionRow(_ tracks: [Track]) -> some View {
+        HStack(spacing: 12) {
+            // Play all
+            Button {
+                if let first = tracks.first {
+                    playerVM.loadAndPlay(first)
+                }
+            } label: {
+                Label("Play", systemImage: "play.fill")
+                    .font(.caption)
+                    .foregroundStyle(theme.accent)
+            }
+            .buttonStyle(.plain)
+
+            Divider().frame(height: 16)
+
+            // Mix buttons
+            ForEach(0..<3, id: \.self) { slot in
+                if playlistVM.mixTargets[slot] != nil {
+                    Button {
+                        for track in tracks {
+                            _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
+                        }
+                        playlistVM.showStatus("Added \(tracks.count) to \(playlistVM.mixTargetName(slot))")
+                    } label: {
+                        Text("\(slot + 1)")
+                            .font(.system(size: 11, weight: .bold, design: .rounded))
+                            .frame(width: 24, height: 24)
+                            .foregroundStyle(Self.mixColors[slot])
+                            .background(Self.mixColors[slot].opacity(0.15))
+                            .clipShape(RoundedRectangle(cornerRadius: 5))
+                    }
+                    .buttonStyle(.plain)
+                }
+            }
+
+            Divider().frame(height: 16)
+
+            // Add to playlist
+            Button {
+                showAddGroupToPlaylist = tracks
+            } label: {
+                Image(systemName: "plus.circle")
+                    .font(.system(size: 16))
+                    .foregroundStyle(theme.secondaryText)
+            }
+            .buttonStyle(.plain)
+
+            Spacer()
+        }
+        .padding(.vertical, 4)
+        .listRowBackground(theme.cardBackground.opacity(0.3))
+    }
+
+    private static let mixColors: [Color] = [
+        Color(red: 0.95, green: 0.3, blue: 0.3),
+        Color(red: 0.3, green: 0.75, blue: 0.95),
+        Color(red: 0.95, green: 0.75, blue: 0.2),
+    ]
+
+    // MARK: - Track Row with actions
+
+    private func trackRow(_ track: Track) -> some View {
+        Button {
+            playerVM.loadAndPlay(track)
+            playerVM.showNowPlaying = true
+        } label: {
+            TrackRow(track: track)
+                .contentShape(Rectangle())
+        }
+        .buttonStyle(.plain)
+            .swipeActions(edge: .trailing) {
+                Button(role: .destructive) {
+                    libraryManager.removeTrack(track)
+                    try? modelContext.save()
+                } label: {
+                    Label("Delete", systemImage: "trash")
+                }
+
+                Button {
+                    showAddToPlaylist = track
+                } label: {
+                    Label("Add to...", systemImage: "plus.circle")
+                }
+                .tint(theme.accent)
+            }
+            .swipeActions(edge: .leading) {
+                Button {
+                    _ = playlistVM.quickAddToTarget(track: track, context: modelContext)
+                } label: {
+                    Label("Quick Add", systemImage: "star.fill")
+                }
+                .tint(.orange)
+            }
+            .contextMenu {
+                Button {
+                    _ = playlistVM.quickAddToTarget(track: track, context: modelContext)
+                } label: {
+                    Label("Add to Target Playlist", systemImage: "star.fill")
+                }
+
+                Button {
+                    showAddToPlaylist = track
+                } label: {
+                    Label("Add to Playlist...", systemImage: "plus.circle")
+                }
+
+                if !track.isAnalyzed {
+                    Button {
+                        Task { await libraryManager.analyzeTrack(track) }
+                    } label: {
+                        Label("Analyze BPM & Key", systemImage: "waveform.badge.magnifyingglass")
+                    }
+                }
+
+                Divider()
+
+                Button(role: .destructive) {
+                    libraryManager.removeTrack(track)
+                    try? modelContext.save()
+                } label: {
+                    Label("Delete", systemImage: "trash")
+                }
+            }
+    }
+
+    // MARK: - Scanning Row
+
+    private var scanningRow: some View {
+        HStack(spacing: 12) {
+            ProgressView()
+            VStack(alignment: .leading, spacing: 2) {
+                Text("Importing...")
+                    .foregroundStyle(theme.secondaryText)
+                if !libraryManager.scanStatus.isEmpty {
+                    Text(libraryManager.scanStatus)
+                        .font(.caption)
+                        .foregroundStyle(theme.tertiaryText)
+                        .lineLimit(1)
+                }
+            }
+        }
+    }
+
+    // MARK: - Footer
+
+    @ViewBuilder
+    private var footer: some View {
+        if !filteredTracks.isEmpty {
+            VStack(spacing: 2) {
+                Text("\(filteredTracks.count) tracks")
+                    .font(.caption)
+                    .foregroundStyle(theme.tertiaryText)
+
+                let totalDuration = filteredTracks.reduce(0) { $0 + $1.duration }
+                Text(formatDuration(totalDuration))
+                    .font(.caption.monospacedDigit())
+                    .foregroundStyle(theme.tertiaryText)
+            }
+            .frame(maxWidth: .infinity, alignment: .center)
+            .listRowBackground(Color.clear)
+        }
+    }
+
+    // MARK: - Empty State
+
+    private var emptyState: some View {
+        VStack(spacing: 20) {
+            Spacer()
+            Image(systemName: "music.note.house")
+                .font(.system(size: 60))
+                .foregroundStyle(theme.tertiaryText)
+            Text("No tracks yet")
+                .font(.title2)
+                .foregroundStyle(theme.secondaryText)
+            Text("Import your MP3, FLAC, OGG, and other audio files")
+                .font(.subheadline)
+                .foregroundStyle(theme.tertiaryText)
+                .multilineTextAlignment(.center)
+            Button {
+                showImporter = true
+            } label: {
+                Label("Import Files", systemImage: "plus.circle.fill")
+                    .font(.headline)
+                    .padding(.horizontal, 24)
+                    .padding(.vertical, 12)
+            }
+            .buttonStyle(.borderedProminent)
+            .tint(theme.accent)
+            Spacer()
+        }
+        .padding()
+    }
+
+    // MARK: - Helpers
+
+    private func formatDuration(_ total: TimeInterval) -> String {
+        let t = Int(total)
+        let hours = t / 3600
+        let minutes = (t % 3600) / 60
+        let seconds = t % 60
+        if hours > 0 {
+            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
+        }
+        return String(format: "%d:%02d", minutes, seconds)
+    }
+}

+ 121 - 0
Sources/Views/MiniPlayerView.swift

@@ -0,0 +1,121 @@
+import SwiftUI
+
+/// Compact bottom bar showing current track with play/pause and next.
+/// Tapping opens the full Now Playing view.
+struct MiniPlayerView: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+
+    var body: some View {
+        VStack(spacing: 0) {
+            // Progress bar
+            GeometryReader { geo in
+                ZStack(alignment: .leading) {
+                    Rectangle()
+                        .fill(theme.seekbarBackground)
+                    Rectangle()
+                        .fill(theme.seekbarForeground)
+                        .frame(width: max(0, playerVM.progress * geo.size.width))
+                }
+            }
+            .frame(height: 3)
+
+            HStack(spacing: 12) {
+                // Artwork or icon
+                if let track = playerVM.currentTrack {
+                    ArtworkThumbnail(track: track)
+                        .frame(width: 40, height: 40)
+                        .overlay(alignment: .bottomTrailing) {
+                            if track.isCloud {
+                                Image(systemName: "cloud.fill")
+                                    .font(.system(size: 10))
+                                    .foregroundStyle(.white)
+                                    .padding(2)
+                                    .background(theme.accent.opacity(0.8))
+                                    .clipShape(Circle())
+                                    .offset(x: 2, y: 2)
+                            }
+                        }
+                } else if playerVM.isCloudPlayback {
+                    // Cloud track without SwiftData Track (direct play from browser)
+                    Image(systemName: "cloud.fill")
+                        .font(.title2)
+                        .foregroundStyle(theme.accent)
+                        .frame(width: 40, height: 40)
+                        .background(theme.accent.opacity(0.1))
+                        .clipShape(RoundedRectangle(cornerRadius: 8))
+                }
+
+                // Track info
+                VStack(alignment: .leading, spacing: 1) {
+                    Text(playerVM.currentTrack?.title ?? playerVM.currentCloudTrack?.title ?? "Not Playing")
+                        .font(.system(size: 14, weight: .medium))
+                        .foregroundStyle(theme.primaryText)
+                        .lineLimit(1)
+
+                    if let artist = playerVM.currentTrack?.artist ?? playerVM.currentCloudTrack?.artist, !artist.isEmpty {
+                        Text(artist)
+                            .font(.system(size: 12))
+                            .foregroundStyle(theme.secondaryText)
+                            .lineLimit(1)
+                    }
+                }
+
+                Spacer()
+
+                // Buffering indicator
+                if playerVM.isBuffering {
+                    ProgressView()
+                        .scaleEffect(0.7)
+                }
+
+                // Transport
+                HStack(spacing: 16) {
+                    Button {
+                        playerVM.togglePlayPause()
+                    } label: {
+                        Image(systemName: playerVM.isPlaying ? "pause.fill" : "play.fill")
+                            .font(.system(size: 22))
+                            .foregroundStyle(theme.accent)
+                            .frame(width: 44, height: 44)
+                            .contentShape(Rectangle())
+                    }
+                    .buttonStyle(.plain)
+                    .accessibilityIdentifier("miniPlayerPlayPause")
+
+                    Button {
+                        playerVM.playNext()
+                    } label: {
+                        Image(systemName: "forward.fill")
+                            .font(.system(size: 18))
+                            .foregroundStyle(theme.secondaryText)
+                            .frame(width: 44, height: 44)
+                            .contentShape(Rectangle())
+                    }
+                    .buttonStyle(.plain)
+                    .accessibilityIdentifier("miniPlayerNext")
+
+                    Button {
+                        playerVM.showQueue = true
+                    } label: {
+                        Image(systemName: "list.bullet")
+                            .font(.system(size: 16))
+                            .foregroundStyle(theme.secondaryText)
+                            .frame(width: 36, height: 44)
+                            .contentShape(Rectangle())
+                    }
+                    .buttonStyle(.plain)
+                    .accessibilityIdentifier("miniPlayerQueue")
+                }
+            }
+            .padding(.horizontal, 16)
+            .padding(.vertical, 8)
+            .background(theme.playerBarBackground)
+        }
+        .background(theme.playerBarBackground)
+        .accessibilityIdentifier("miniPlayer")
+        .onTapGesture {
+            playerVM.showNowPlaying = true
+        }
+    }
+}

+ 704 - 0
Sources/Views/NowPlayingView.swift

@@ -0,0 +1,704 @@
+import SwiftUI
+
+/// Full-screen Now Playing view with large artwork, waveform, transport controls, and quick-add.
+struct NowPlayingView: View {
+    @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 isDragging = false
+    @State private var dragProgress: Double = 0
+
+    // Lyrics state
+    @State private var showLyrics = false
+    @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
+
+    enum LyricsState {
+        case idle
+        case loading
+        case loaded
+        case notFound
+        case error(String)
+        case instrumental
+    }
+
+    var body: some View {
+        NavigationStack {
+            ZStack {
+                theme.background.ignoresSafeArea()
+
+                VStack(spacing: 0) {
+                    if showLyrics {
+                        // Lyrics mode: scrollable lyrics panel
+                        lyricsPanel
+                            .transition(.opacity)
+                    } else {
+                        // Normal mode: artwork
+                        artworkSection
+                            .padding(.top, 20)
+
+                        Spacer(minLength: 16)
+                    }
+
+                    // Track info
+                    trackInfoSection
+                        .padding(.horizontal, 24)
+
+                    Spacer(minLength: showLyrics ? 8 : 16)
+
+                    // Waveform (hide in lyrics mode)
+                    if !showLyrics && playerVM.showingWaveform && !playerVM.waveformSamples.isEmpty {
+                        WaveformView()
+                            .frame(height: 60)
+                            .padding(.horizontal, 24)
+                    }
+
+                    // Seekbar + time
+                    seekbarSection
+                        .padding(.horizontal, 24)
+                        .padding(.top, 12)
+
+                    // Transport controls
+                    transportSection
+                        .padding(.top, 16)
+
+                    // Extra controls
+                    extraControlsSection
+                        .padding(.top, 8)
+                        .padding(.bottom, 20)
+                }
+            }
+            .toolbar {
+                ToolbarItem(placement: .topBarLeading) {
+                    Button {
+                        dismiss()
+                    } label: {
+                        Image(systemName: "chevron.down")
+                            .font(.title3)
+                            .foregroundStyle(theme.secondaryText)
+                    }
+                    .accessibilityIdentifier("nowPlayingDismiss")
+                }
+
+                ToolbarItem(placement: .topBarTrailing) {
+                    HStack(spacing: 12) {
+                        // Queue button
+                        Button {
+                            playerVM.showQueue = true
+                        } label: {
+                            Image(systemName: "list.bullet")
+                                .foregroundStyle(theme.secondaryText)
+                        }
+                        .accessibilityIdentifier("queueButton")
+
+                        // Lyrics toggle button
+                        Button {
+                            withAnimation(.easeInOut(duration: 0.3)) {
+                                showLyrics.toggle()
+                            }
+                            if showLyrics {
+                                if case .idle = lyricsState {
+                                    loadLyrics()
+                                }
+                            }
+                        } label: {
+                            Image(systemName: showLyrics ? "text.quote.fill" : "text.quote")
+                                .foregroundStyle(showLyrics ? theme.accent : theme.secondaryText)
+                        }
+                        .accessibilityIdentifier("lyricsButton")
+
+                        Menu {
+                            if let track = playerVM.currentTrack {
+                                Button {
+                                    _ = playlistVM.quickAddToTarget(track: track, context: modelContext)
+                                } label: {
+                                    Label("Add to Target Playlist", systemImage: "star.fill")
+                                }
+
+                                Button {
+                                    playlistVM.addCuePoint(
+                                        to: track,
+                                        at: playerVM.currentTime,
+                                        name: "Marker at \(playerVM.currentTimeFormatted)",
+                                        context: modelContext
+                                    )
+                                    playlistVM.showStatus("Cue point added")
+                                } label: {
+                                    Label("Add Cue Point Here", systemImage: "bookmark.fill")
+                                }
+                            }
+                        } label: {
+                            Image(systemName: "ellipsis.circle")
+                                .foregroundStyle(theme.secondaryText)
+                        }
+                    }
+                }
+            }
+            .toolbarBackground(theme.background, for: .navigationBar)
+            .onChange(of: playerVM.currentTrack?.id) { _, newID in
+                if newID != lastLoadedTrackID {
+                    loadLyrics()
+                }
+            }
+
+        }
+    }
+
+    // MARK: - Artwork
+
+    private var artworkSection: some View {
+        Group {
+            if let track = playerVM.currentTrack {
+                LargeArtwork(track: track)
+                    .frame(width: 280, height: 280)
+                    .shadow(radius: 20)
+                    .overlay(alignment: .bottomTrailing) {
+                        if track.isCloud {
+                            Image(systemName: "cloud.fill")
+                                .font(.caption)
+                                .foregroundStyle(.white)
+                                .padding(4)
+                                .background(theme.accent.opacity(0.8))
+                                .clipShape(Circle())
+                                .offset(x: -8, y: -8)
+                        }
+                    }
+            } else if playerVM.isCloudPlayback {
+                RoundedRectangle(cornerRadius: 16)
+                    .fill(theme.cardBackground)
+                    .frame(width: 280, height: 280)
+                    .overlay {
+                        VStack(spacing: 8) {
+                            Image(systemName: "cloud.fill")
+                                .font(.system(size: 60))
+                                .foregroundStyle(theme.accent)
+                            if playerVM.isBuffering {
+                                ProgressView()
+                            }
+                        }
+                    }
+            } else {
+                RoundedRectangle(cornerRadius: 16)
+                    .fill(theme.cardBackground)
+                    .frame(width: 280, height: 280)
+                    .overlay {
+                        Image(systemName: "music.note")
+                            .font(.system(size: 60))
+                            .foregroundStyle(theme.tertiaryText)
+                    }
+            }
+        }
+    }
+
+    // MARK: - Track Info
+
+    private var trackInfoSection: some View {
+        VStack(spacing: 4) {
+            Text(playerVM.currentTrack?.title ?? playerVM.currentCloudTrack?.title ?? "Not Playing")
+                .font(.title2.bold())
+                .foregroundStyle(theme.primaryText)
+                .lineLimit(1)
+                .accessibilityIdentifier("nowPlayingTitle")
+
+            Text(playerVM.currentTrack?.artist ?? playerVM.currentCloudTrack?.artist ?? "")
+                .font(.body)
+                .foregroundStyle(theme.secondaryText)
+                .lineLimit(1)
+                .accessibilityIdentifier("nowPlayingArtist")
+
+            HStack(spacing: 12) {
+                if let bpm = playerVM.currentTrack?.bpm {
+                    Label("\(String(format: "%.0f", bpm)) BPM", systemImage: "metronome")
+                        .font(.caption)
+                        .foregroundStyle(theme.tertiaryText)
+                }
+                if let key = playerVM.currentTrack?.musicalKey {
+                    Label(key, systemImage: "music.quarternote.3")
+                        .font(.caption)
+                        .foregroundStyle(theme.tertiaryText)
+                }
+                if let format = playerVM.currentTrack?.fileFormat {
+                    Text(format)
+                        .font(.caption.weight(.medium))
+                        .foregroundStyle(theme.tertiaryText)
+                        .padding(.horizontal, 5)
+                        .padding(.vertical, 1)
+                        .background(theme.tertiaryText.opacity(0.15))
+                        .clipShape(RoundedRectangle(cornerRadius: 3))
+                }
+            }
+        }
+    }
+
+    // MARK: - Seekbar
+
+    private var seekbarSection: some View {
+        VStack(spacing: 4) {
+            GeometryReader { geo in
+                ZStack(alignment: .leading) {
+                    Capsule()
+                        .fill(theme.seekbarBackground)
+                    Capsule()
+                        .fill(theme.seekbarForeground)
+                        .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: 6)
+
+            HStack {
+                Text(playerVM.currentTimeFormatted)
+                    .font(.caption.monospacedDigit())
+                    .foregroundStyle(theme.secondaryText)
+                Spacer()
+                Text(playerVM.remainingTimeFormatted)
+                    .font(.caption.monospacedDigit())
+                    .foregroundStyle(theme.secondaryText)
+            }
+        }
+    }
+
+    // MARK: - Transport
+
+    private var transportSection: some View {
+        HStack(spacing: 36) {
+            Button { playerVM.shuffleEnabled.toggle() } label: {
+                Image(systemName: "shuffle")
+                    .font(.system(size: 18))
+                    .foregroundStyle(playerVM.shuffleEnabled ? theme.accent : theme.tertiaryText)
+                    .frame(width: 44, height: 44)
+            }
+            .buttonStyle(.plain)
+            .accessibilityIdentifier("shuffleButton")
+
+            Button { playerVM.playPrevious() } label: {
+                Image(systemName: "backward.fill")
+                    .font(.system(size: 28))
+                    .foregroundStyle(theme.primaryText)
+                    .frame(width: 44, height: 44)
+            }
+            .buttonStyle(.plain)
+            .accessibilityIdentifier("previousButton")
+
+            Button { playerVM.togglePlayPause() } label: {
+                Image(systemName: playerVM.isPlaying ? "pause.circle.fill" : "play.circle.fill")
+                    .font(.system(size: 64))
+                    .foregroundStyle(theme.accent)
+            }
+            .buttonStyle(.plain)
+            .accessibilityIdentifier("playPauseButton")
+
+            Button { playerVM.playNext() } label: {
+                Image(systemName: "forward.fill")
+                    .font(.system(size: 28))
+                    .foregroundStyle(theme.primaryText)
+                    .frame(width: 44, height: 44)
+            }
+            .buttonStyle(.plain)
+            .accessibilityIdentifier("nextButton")
+
+            Button {
+                switch playerVM.repeatMode {
+                case .off: playerVM.repeatMode = .all
+                case .all: playerVM.repeatMode = .one
+                case .one: playerVM.repeatMode = .off
+                }
+            } label: {
+                Image(systemName: playerVM.repeatMode.icon)
+                    .font(.system(size: 18))
+                    .foregroundStyle(playerVM.repeatMode != .off ? theme.accent : theme.tertiaryText)
+                    .frame(width: 44, height: 44)
+            }
+            .buttonStyle(.plain)
+        }
+    }
+
+    // MARK: - Extra Controls
+
+    private static let mixColors: [Color] = [
+        Color(red: 0.95, green: 0.3, blue: 0.3),
+        Color(red: 0.3, green: 0.75, blue: 0.95),
+        Color(red: 0.95, green: 0.75, blue: 0.2),
+    ]
+
+    private var extraControlsSection: some View {
+        HStack(spacing: 20) {
+            // 3 Mix buttons
+            ForEach(0..<3, id: \.self) { slot in
+                Button {
+                    guard let track = playerVM.currentTrack else { return }
+                    _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
+                } label: {
+                    VStack(spacing: 4) {
+                        Text("\(slot + 1)")
+                            .font(.system(size: 16, weight: .bold, design: .rounded))
+                            .frame(width: 36, height: 36)
+                            .foregroundStyle(
+                                playlistVM.mixTargets[slot] != nil
+                                    ? Self.mixColors[slot]
+                                    : theme.tertiaryText
+                            )
+                            .background(
+                                playlistVM.mixTargets[slot] != nil
+                                    ? Self.mixColors[slot].opacity(0.15)
+                                    : theme.tertiaryText.opacity(0.08)
+                            )
+                            .clipShape(RoundedRectangle(cornerRadius: 8))
+                        Text(playlistVM.mixTargetName(slot))
+                            .font(.system(size: 9))
+                            .foregroundStyle(theme.tertiaryText)
+                            .lineLimit(1)
+                    }
+                    .frame(minWidth: 50, minHeight: 44)
+                }
+                .buttonStyle(.plain)
+            }
+
+            Spacer()
+
+            // Skip backward
+            Button { playerVM.skipBackward() } label: {
+                VStack(spacing: 4) {
+                    Image(systemName: "gobackward.10")
+                        .font(.system(size: 20))
+                    Text("-10s")
+                        .font(.caption2)
+                }
+                .foregroundStyle(theme.secondaryText)
+                .frame(minWidth: 44, minHeight: 44)
+            }
+            .buttonStyle(.plain)
+
+            // Skip forward
+            Button { playerVM.skipForward() } label: {
+                VStack(spacing: 4) {
+                    Image(systemName: "goforward.10")
+                        .font(.system(size: 20))
+                    Text("+10s")
+                        .font(.caption2)
+                }
+                .foregroundStyle(theme.secondaryText)
+                .frame(minWidth: 44, minHeight: 44)
+            }
+            .buttonStyle(.plain)
+        }
+        .padding(.horizontal, 24)
+    }
+
+    // MARK: - Lyrics Panel
+
+    private var lyricsPanel: some View {
+        VStack(spacing: 0) {
+            // Lyrics header with synced/plain toggle
+            HStack {
+                Text("Lyrics")
+                    .font(.headline)
+                    .foregroundStyle(theme.secondaryText)
+                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 ? theme.tertiaryText.opacity(0.15) : theme.accent.opacity(0.2))
+                        .foregroundStyle(showAsPlainText ? theme.secondaryText : theme.accent)
+                        .clipShape(Capsule())
+                    }
+                    .buttonStyle(.plain)
+                }
+            }
+            .padding(.horizontal, 24)
+            .padding(.top, 12)
+            .padding(.bottom, 4)
+
+            // Lyrics content
+            switch lyricsState {
+            case .idle, .loading:
+                Spacer()
+                ProgressView()
+                    .scaleEffect(0.8)
+                Text("Searching for lyrics…")
+                    .font(.callout)
+                    .foregroundStyle(theme.tertiaryText)
+                    .padding(.top, 8)
+                Spacer()
+
+            case .notFound:
+                Spacer()
+                VStack(spacing: 12) {
+                    Image(systemName: "text.page.slash")
+                        .font(.system(size: 36))
+                        .foregroundStyle(theme.tertiaryText)
+                    Text("No lyrics found")
+                        .font(.title3)
+                        .foregroundStyle(theme.secondaryText)
+                    if let track = playerVM.currentTrack {
+                        Text("\(track.artist) — \(track.title)")
+                            .font(.caption)
+                            .foregroundStyle(theme.tertiaryText)
+                    }
+                }
+                Spacer()
+
+            case .instrumental:
+                Spacer()
+                VStack(spacing: 12) {
+                    Image(systemName: "pianokeys")
+                        .font(.system(size: 36))
+                        .foregroundStyle(theme.tertiaryText)
+                    Text("Instrumental")
+                        .font(.title3)
+                        .foregroundStyle(theme.secondaryText)
+                }
+                Spacer()
+
+            case .error(let message):
+                Spacer()
+                VStack(spacing: 12) {
+                    Image(systemName: "exclamationmark.triangle")
+                        .font(.system(size: 36))
+                        .foregroundStyle(theme.tertiaryText)
+                    Text(message)
+                        .font(.callout)
+                        .foregroundStyle(theme.secondaryText)
+                }
+                Spacer()
+
+            case .loaded:
+                if isSynced && !showAsPlainText {
+                    SyncedLyricsView(
+                        lines: lyrics,
+                        currentTime: playerVM.currentTime,
+                        accent: theme.accent,
+                        secondaryText: theme.secondaryText,
+                        primaryText: theme.primaryText,
+                        onSeek: { time in playerVM.seek(to: time) }
+                    )
+                } else {
+                    PlainLyricsView(lines: lyrics, primaryText: theme.primaryText)
+                }
+            }
+        }
+        .accessibilityIdentifier("lyricsPanel")
+    }
+
+    // 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: - Large Artwork
+
+struct LargeArtwork: View {
+    let track: Track
+    @EnvironmentObject private var theme: AppTheme
+    @State private var artwork: UIImage?
+
+    var body: some View {
+        Group {
+            if let image = artwork {
+                Image(uiImage: image)
+                    .resizable()
+                    .aspectRatio(contentMode: .fill)
+            } else {
+                ZStack {
+                    RoundedRectangle(cornerRadius: 16)
+                        .fill(theme.cardBackground)
+                    Image(systemName: "music.note")
+                        .font(.system(size: 60))
+                        .foregroundStyle(theme.tertiaryText)
+                }
+            }
+        }
+        .clipShape(RoundedRectangle(cornerRadius: 16))
+        .task {
+            let url = track.fileURL
+            artwork = await ArtworkService.shared.artwork(for: url)
+        }
+    }
+}
+
+// MARK: - Volume Slider
+
+struct VolumeSlider: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+
+    var body: some View {
+        VStack(spacing: 4) {
+            Image(systemName: volumeIcon)
+                .font(.system(size: 20))
+                .foregroundStyle(theme.secondaryText)
+            Text("\(Int(playerVM.volume * 100))%")
+                .font(.caption2)
+                .foregroundStyle(theme.tertiaryText)
+        }
+        .onTapGesture {
+            // Toggle between mute and previous volume
+            if playerVM.volume > 0 {
+                playerVM.volume = 0
+            } else {
+                playerVM.volume = 0.8
+            }
+        }
+    }
+
+    private var volumeIcon: String {
+        if playerVM.volume == 0 { return "speaker.slash" }
+        if playerVM.volume < 0.3 { return "speaker.wave.1" }
+        if playerVM.volume < 0.7 { return "speaker.wave.2" }
+        return "speaker.wave.3"
+    }
+}
+
+// MARK: - Synced Lyrics View (auto-scrolling, highlighted)
+
+struct SyncedLyricsView: View {
+    let lines: [LyricsParser.LyricLine]
+    let currentTime: TimeInterval
+    let accent: Color
+    let secondaryText: Color
+    let primaryText: Color
+    let onSeek: (TimeInterval) -> Void
+
+    @State private var currentIndex: Int?
+
+    var body: some View {
+        ScrollViewReader { proxy in
+            ScrollView {
+                LazyVStack(alignment: .leading, spacing: 8) {
+                    Spacer()
+                        .frame(height: 20)
+
+                    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) ? secondaryText : primaryText))
+                            .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)
+                    }
+
+                    Spacer()
+                        .frame(height: 60)
+                }
+            }
+            .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]
+    let primaryText: Color
+
+    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 : primaryText.opacity(0.8))
+                        .padding(.horizontal, 24)
+                }
+
+                Spacer()
+                    .frame(height: 40)
+            }
+        }
+    }
+}

+ 480 - 0
Sources/Views/PlaylistDetailView.swift

@@ -0,0 +1,480 @@
+import SwiftData
+import SwiftUI
+import UniformTypeIdentifiers
+
+/// Detail view for a single playlist — shows tracks with reorder, play, remove.
+struct PlaylistDetailView: View {
+    let playlist: Playlist
+    @Environment(PlayerViewModel.self) private var playerVM
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var libraryManager: LibraryManager
+    @EnvironmentObject private var theme: AppTheme
+    @EnvironmentObject private var syncManager: SyncManager
+    @Environment(\.modelContext) private var modelContext
+
+    @AppStorage("trackTapAction") private var trackTapAction = "playNow"
+
+    @State private var showAddTracks = false
+    @State private var showEntryNotes: PlaylistEntry?
+    @State private var showGroupEditor = false
+    @State private var isEditing = false
+
+    var body: some View {
+        List {
+            // Header stats
+            playlistHeader
+
+            // Track entries — grouped if template is set
+            if playlist.groupTemplate.isEmpty {
+                // No grouping — flat list
+                flatEntryList
+            } else {
+                // Grouped by template
+                groupedEntryList
+            }
+        }
+        .listStyle(.plain)
+        .navigationTitle(playlist.name)
+        .accessibilityIdentifier("playlistDetailView")
+        .toolbar {
+            ToolbarItem(placement: .topBarTrailing) {
+                EditButton()
+            }
+
+            ToolbarItem(placement: .topBarTrailing) {
+                Menu {
+                    Button {
+                        showAddTracks = true
+                    } label: {
+                        Label("Add Tracks", systemImage: "plus")
+                    }
+
+                    Button {
+                        playlistVM.targetPlaylist = playlist
+                        playlistVM.showStatus("Target: \(playlist.name)")
+                    } label: {
+                        Label("Set as Target", systemImage: "star.fill")
+                    }
+
+                    Divider()
+
+                    Button {
+                        showGroupEditor = true
+                    } label: {
+                        Label(
+                            playlist.groupTemplate.isEmpty ? "Grouping..." : "Grouping: \(playlist.groupTemplate)",
+                            systemImage: "rectangle.3.group"
+                        )
+                    }
+
+                    Divider()
+
+                    Button {
+                        syncManager.exportPlaylists([playlist])
+                        playlistVM.showStatus("Playlist exported to Sync folder")
+                    } label: {
+                        Label("Export for Mac", systemImage: "square.and.arrow.up")
+                    }
+
+                    // Play all
+                    if let firstEntry = playlist.sortedEntries.first,
+                       let track = firstEntry.track {
+                        Button {
+                            playerVM.playFromPlaylist(track: track, entryID: firstEntry.id, playlist: playlist)
+                        } label: {
+                            Label("Play All", systemImage: "play.fill")
+                        }
+                    }
+                } label: {
+                    Image(systemName: "ellipsis.circle")
+                }
+            }
+        }
+        .sheet(isPresented: $showAddTracks) {
+            AddTracksToPlaylistSheet(playlist: playlist)
+                .environmentObject(theme)
+        }
+        .sheet(item: $showEntryNotes) { entry in
+            EntryNotesSheet(entry: entry)
+                .environmentObject(theme)
+        }
+        .sheet(isPresented: $showGroupEditor) {
+            GroupTemplateEditorSheet(playlist: playlist)
+                .environmentObject(theme)
+        }
+    }
+
+    // MARK: - Header
+
+    private var playlistHeader: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            HStack {
+                Circle()
+                    .fill(Color(hex: playlist.color) ?? theme.accent)
+                    .frame(width: 16, height: 16)
+
+                Text("\(playlist.trackCount) tracks")
+                    .font(.subheadline)
+                    .foregroundStyle(theme.secondaryText)
+
+                Text("•")
+                    .foregroundStyle(theme.tertiaryText)
+
+                Text(playlist.formattedTotalDuration)
+                    .font(.subheadline.monospacedDigit())
+                    .foregroundStyle(theme.secondaryText)
+
+                if let bpm = playlist.targetBPM {
+                    Text("•")
+                        .foregroundStyle(theme.tertiaryText)
+                    Text("\(String(format: "%.0f", bpm)) BPM")
+                        .font(.subheadline.monospacedDigit())
+                        .foregroundStyle(theme.tertiaryText)
+                }
+            }
+
+            if !playlist.notes.isEmpty {
+                Text(playlist.notes)
+                    .font(.caption)
+                    .foregroundStyle(theme.tertiaryText)
+            }
+        }
+        .padding(.vertical, 4)
+        .listRowBackground(Color.clear)
+    }
+
+    // MARK: - Flat Entry List (no grouping)
+
+    private var flatEntryList: some View {
+        ForEach(playlist.sortedEntries) { entry in
+            entryRow(entry)
+        }
+        .onMove { source, destination in
+            if let first = source.first {
+                playlistVM.moveEntry(in: playlist, from: first, to: destination, context: modelContext)
+            }
+        }
+        .onDelete { offsets in
+            let entries = playlist.sortedEntries
+            for index in offsets {
+                playlistVM.removeEntry(entries[index], from: playlist, context: modelContext)
+            }
+        }
+    }
+
+    // MARK: - Grouped Entry List (by template)
+
+    private var groupedEntryList: some View {
+        let sorted = playlist.sortedEntries
+        let groups = groupEntries(sorted, template: playlist.groupTemplate)
+
+        return ForEach(groups, id: \.header) { group in
+            Section {
+                ForEach(group.entries) { entry in
+                    entryRow(entry)
+                }
+            } header: {
+                Text(group.header)
+                    .font(.subheadline.weight(.semibold))
+                    .foregroundStyle(theme.groupHeaderText)
+            }
+        }
+    }
+
+    private struct EntryGroup {
+        let header: String
+        let entries: [PlaylistEntry]
+    }
+
+    private func groupEntries(_ entries: [PlaylistEntry], template: String) -> [EntryGroup] {
+        var groups: [(String, [PlaylistEntry])] = []
+        var currentHeader = ""
+        var currentEntries: [PlaylistEntry] = []
+
+        for entry in entries {
+            let header: String
+            if let track = entry.track {
+                header = GroupTemplateResolver.resolve(template: template, for: track)
+            } else {
+                header = "Unknown"
+            }
+
+            if header != currentHeader {
+                if !currentEntries.isEmpty {
+                    groups.append((currentHeader, currentEntries))
+                }
+                currentHeader = header
+                currentEntries = [entry]
+            } else {
+                currentEntries.append(entry)
+            }
+        }
+        if !currentEntries.isEmpty {
+            groups.append((currentHeader, currentEntries))
+        }
+
+        return groups.map { EntryGroup(header: $0.0, entries: $0.1) }
+    }
+
+    // MARK: - Entry Row
+
+    @ViewBuilder
+    private func entryRow(_ entry: PlaylistEntry) -> some View {
+        if let track = entry.track {
+            Button {
+                if trackTapAction == "addToQueue" {
+                    playerVM.addToQueue(QueueEntry.from(track: track))
+                } else {
+                    playerVM.playFromPlaylist(track: track, entryID: entry.id, playlist: playlist)
+                }
+            } label: {
+                PlaylistEntryRow(entry: entry, track: track)
+                    .contentShape(Rectangle())
+            }
+            .buttonStyle(.plain)
+                .contextMenu {
+                    Button {
+                        playerVM.playFromPlaylist(track: track, entryID: entry.id, playlist: playlist)
+                    } label: {
+                        Label("Play Now", systemImage: "play.fill")
+                    }
+                    Button {
+                        playerVM.playNextInQueue(QueueEntry.from(track: track))
+                    } label: {
+                        Label("Play Next", systemImage: "text.insert")
+                    }
+                    Button {
+                        playerVM.addToQueue(QueueEntry.from(track: track))
+                    } label: {
+                        Label("Add to Queue", systemImage: "text.append")
+                    }
+                }
+                .swipeActions(edge: .trailing) {
+                    Button(role: .destructive) {
+                        playlistVM.removeEntry(entry, from: playlist, context: modelContext)
+                    } label: {
+                        Label("Remove", systemImage: "minus.circle")
+                    }
+                }
+                .swipeActions(edge: .leading) {
+                    Button {
+                        showEntryNotes = entry
+                    } label: {
+                        Label("Notes", systemImage: "note.text")
+                    }
+                    .tint(theme.accent)
+                }
+        } else {
+            HStack {
+                Image(systemName: "exclamationmark.triangle")
+                    .foregroundStyle(.orange)
+                Text(entry.notes.isEmpty ? "Track not found" : entry.notes)
+                    .font(.subheadline)
+                    .foregroundStyle(theme.secondaryText)
+            }
+        }
+    }
+}
+
+// MARK: - Playlist Entry Row
+
+struct PlaylistEntryRow: View {
+    let entry: PlaylistEntry
+    let track: Track
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+
+    private var isPlaying: Bool {
+        playerVM.currentPlayingEntryID == entry.id
+    }
+
+    var body: some View {
+        HStack(spacing: 12) {
+            // Position number
+            Text("\(entry.position + 1)")
+                .font(.system(size: 14, design: .monospaced))
+                .foregroundStyle(isPlaying ? theme.playingHighlight : theme.tertiaryText)
+                .frame(width: 24)
+
+            // Track info
+            VStack(alignment: .leading, spacing: 2) {
+                Text(track.title)
+                    .font(.system(size: theme.dataFontSize, weight: isPlaying ? .semibold : .regular))
+                    .foregroundStyle(isPlaying ? theme.playingHighlight : theme.primaryText)
+                    .lineLimit(1)
+
+                HStack(spacing: 6) {
+                    if !track.artist.isEmpty {
+                        Text(track.artist)
+                            .font(.system(size: theme.smallFontSize))
+                            .foregroundStyle(theme.secondaryText)
+                            .lineLimit(1)
+                    }
+
+                    if entry.crossfadeDuration > 0 {
+                        Text("⤬ \(String(format: "%.1fs", entry.crossfadeDuration))")
+                            .font(.system(size: 10, design: .monospaced))
+                            .foregroundStyle(theme.tertiaryText)
+                    }
+                }
+            }
+
+            Spacer()
+
+            VStack(alignment: .trailing, spacing: 2) {
+                Text(track.formattedDuration)
+                    .font(.system(size: theme.smallFontSize, design: .monospaced))
+                    .foregroundStyle(theme.secondaryText)
+
+                if let bpm = track.bpm {
+                    Text("\(String(format: "%.0f", bpm))")
+                        .font(.system(size: 10, design: .monospaced))
+                        .foregroundStyle(theme.tertiaryText)
+                }
+            }
+
+            if isPlaying && playerVM.isPlaying {
+                Image(systemName: "speaker.wave.2.fill")
+                    .font(.caption)
+                    .foregroundStyle(theme.playingHighlight)
+            }
+        }
+        .padding(.vertical, 4)
+    }
+}
+
+// MARK: - Add Tracks to Playlist Sheet
+
+struct AddTracksToPlaylistSheet: View {
+    let playlist: Playlist
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(\.modelContext) private var modelContext
+    @Environment(\.dismiss) private var dismiss
+
+    @Query(sort: \Track.title) private var allTracks: [Track]
+    @State private var searchText = ""
+    @State private var selectedTracks: Set<UUID> = []
+    @State private var existingTrackIDs: Set<UUID> = []
+
+    private var filteredTracks: [Track] {
+        if searchText.isEmpty { return allTracks }
+        let q = searchText.lowercased()
+        return allTracks.filter {
+            $0.title.lowercased().contains(q) ||
+            $0.artist.lowercased().contains(q)
+        }
+    }
+
+    var body: some View {
+        NavigationStack {
+            List(filteredTracks) { track in
+                let isInPlaylist = existingTrackIDs.contains(track.id)
+                let isSelected = selectedTracks.contains(track.id)
+
+                HStack {
+                    TrackRow(track: track)
+
+                    Spacer()
+
+                    if isInPlaylist {
+                        Image(systemName: "checkmark.circle.fill")
+                            .foregroundStyle(theme.tertiaryText)
+                    } else if isSelected {
+                        Image(systemName: "checkmark.circle.fill")
+                            .foregroundStyle(theme.accent)
+                    } else {
+                        Image(systemName: "circle")
+                            .foregroundStyle(theme.tertiaryText)
+                    }
+                }
+                .contentShape(Rectangle())
+                .onTapGesture {
+                    guard !isInPlaylist else { return }
+                    if isSelected {
+                        selectedTracks.remove(track.id)
+                    } else {
+                        selectedTracks.insert(track.id)
+                    }
+                }
+                .opacity(isInPlaylist ? 0.5 : 1)
+            }
+            .searchable(text: $searchText, prompt: "Search library")
+            .navigationTitle("Add Tracks")
+            .navigationBarTitleDisplayMode(.inline)
+            .onAppear {
+                // Pre-compute existing track IDs once — avoids per-row database queries
+                let playlistID = playlist.id
+                let descriptor = FetchDescriptor<PlaylistEntry>(
+                    predicate: #Predicate<PlaylistEntry> { $0.playlist?.id == playlistID }
+                )
+                if let entries = try? modelContext.fetch(descriptor) {
+                    existingTrackIDs = Set(entries.compactMap { $0.track?.id })
+                }
+            }
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") { dismiss() }
+                }
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Add \(selectedTracks.count)") {
+                        let tracksToAdd = allTracks.filter { selectedTracks.contains($0.id) }
+                        playlistVM.addTracks(tracksToAdd, to: playlist, context: modelContext)
+                        dismiss()
+                    }
+                    .disabled(selectedTracks.isEmpty)
+                }
+            }
+        }
+    }
+}
+
+// MARK: - Entry Notes Sheet
+
+struct EntryNotesSheet: View {
+    let entry: PlaylistEntry
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(\.modelContext) private var modelContext
+    @Environment(\.dismiss) private var dismiss
+    @State private var notes: String = ""
+
+    var body: some View {
+        NavigationStack {
+            VStack(alignment: .leading, spacing: 12) {
+                if let track = entry.track {
+                    Text("\(track.artist) — \(track.title)")
+                        .font(.headline)
+                        .foregroundStyle(theme.primaryText)
+                }
+
+                TextEditor(text: $notes)
+                    .font(.body)
+                    .frame(minHeight: 200)
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 8)
+                            .stroke(theme.separatorColor, lineWidth: 1)
+                    )
+
+                Spacer()
+            }
+            .padding()
+            .navigationTitle("Notes")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .cancellationAction) {
+                    Button("Cancel") { dismiss() }
+                }
+                ToolbarItem(placement: .confirmationAction) {
+                    Button("Save") {
+                        entry.notes = notes
+                        try? modelContext.save()
+                        dismiss()
+                    }
+                }
+            }
+            .onAppear {
+                notes = entry.notes
+            }
+        }
+    }
+}

+ 218 - 0
Sources/Views/PlaylistListView.swift

@@ -0,0 +1,218 @@
+import SwiftData
+import SwiftUI
+
+/// Main screen — list of playlists with Library and Settings in toolbar.
+struct PlaylistListView: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var libraryManager: LibraryManager
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(\.modelContext) private var modelContext
+
+    @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist]
+
+    @State private var showNewPlaylist = false
+    @State private var newPlaylistName = ""
+    @State private var showLibrary = false
+    @State private var showSettings = false
+    @State private var showCloudBrowser = false
+
+    var body: some View {
+        NavigationStack {
+            Group {
+                if playlists.isEmpty {
+                    emptyState
+                } else {
+                    playlistList
+                }
+            }
+            .navigationTitle("MixBoard")
+            .toolbar {
+                ToolbarItem(placement: .topBarLeading) {
+                    HStack(spacing: 12) {
+                        Button {
+                            showLibrary = true
+                        } label: {
+                            Image(systemName: "music.note.list")
+                        }
+                        .accessibilityIdentifier("libraryButton")
+
+                        Button {
+                            showCloudBrowser = true
+                        } label: {
+                            Image(systemName: "cloud.fill")
+                        }
+                        .accessibilityIdentifier("cloudBrowserButton")
+
+                        Button {
+                            showSettings = true
+                        } label: {
+                            Image(systemName: "gearshape")
+                        }
+                        .accessibilityIdentifier("settingsButton")
+                    }
+                }
+
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button {
+                        showNewPlaylist = true
+                    } label: {
+                        Image(systemName: "plus")
+                    }
+                    .accessibilityIdentifier("newPlaylistButton")
+                    .accessibilityIdentifier("newPlaylistButton")
+                }
+            }
+            .alert("New Playlist", isPresented: $showNewPlaylist) {
+                TextField("Playlist name", text: $newPlaylistName)
+                Button("Cancel", role: .cancel) { newPlaylistName = "" }
+                Button("Create") {
+                    guard !newPlaylistName.isEmpty else { return }
+                    let pl = playlistVM.createPlaylist(name: newPlaylistName, context: modelContext)
+                    playlistVM.selectedPlaylist = pl
+                    newPlaylistName = ""
+                }
+            } message: {
+                Text("Enter a name for your new playlist")
+            }
+            .sheet(isPresented: $showLibrary) {
+                LibraryView()
+                    .environmentObject(theme)
+            }
+            .sheet(isPresented: $showSettings) {
+                SettingsView()
+                    .environmentObject(theme)
+            }
+            .sheet(isPresented: $showCloudBrowser) {
+                CloudBrowserView()
+                    .environmentObject(theme)
+            }
+        }
+    }
+
+    // MARK: - Playlist List
+
+    private var playlistList: some View {
+        List {
+            ForEach(playlists) { playlist in
+                NavigationLink {
+                    PlaylistDetailView(playlist: playlist)
+                } label: {
+                    PlaylistRowView(playlist: playlist)
+                }
+                .swipeActions(edge: .trailing) {
+                    Button(role: .destructive) {
+                        playlistVM.deletePlaylist(playlist, context: modelContext)
+                    } label: {
+                        Label("Delete", systemImage: "trash")
+                    }
+                }
+                .swipeActions(edge: .leading) {
+                    Button {
+                        playlistVM.targetPlaylist = playlist
+                        playlistVM.showStatus("Target: \(playlist.name)")
+                    } label: {
+                        Label("Target", systemImage: "star.fill")
+                    }
+                    .tint(.orange)
+                }
+                .contextMenu {
+                    Button {
+                        playlistVM.targetPlaylist = playlist
+                        playlistVM.showStatus("Target: \(playlist.name)")
+                    } label: {
+                        Label(
+                            playlistVM.targetPlaylist?.id == playlist.id ? "Current Target" : "Set as Target",
+                            systemImage: "star.fill"
+                        )
+                    }
+
+                    Button(role: .destructive) {
+                        playlistVM.deletePlaylist(playlist, context: modelContext)
+                    } label: {
+                        Label("Delete", systemImage: "trash")
+                    }
+                }
+            }
+        }
+        .listStyle(.insetGrouped)
+        .accessibilityIdentifier("playlistList")
+    }
+
+    // MARK: - Empty State
+
+    private var emptyState: some View {
+        VStack(spacing: 20) {
+            Spacer()
+            Image(systemName: "music.note.list")
+                .font(.system(size: 60))
+                .foregroundStyle(theme.tertiaryText)
+            Text("No playlists yet")
+                .font(.title2)
+                .foregroundStyle(theme.secondaryText)
+                .accessibilityIdentifier("emptyStateTitle")
+            Text("Create a playlist to start building your mix")
+                .font(.subheadline)
+                .foregroundStyle(theme.tertiaryText)
+            Button {
+                showNewPlaylist = true
+            } label: {
+                Label("New Playlist", systemImage: "plus.circle.fill")
+                    .font(.headline)
+                    .padding(.horizontal, 24)
+                    .padding(.vertical, 12)
+            }
+            .buttonStyle(.borderedProminent)
+            .tint(theme.accent)
+            .accessibilityIdentifier("emptyStateNewPlaylistButton")
+            Spacer()
+        }
+        .accessibilityIdentifier("emptyState")
+    }
+}
+
+// MARK: - Playlist Row
+
+struct PlaylistRowView: View {
+    let playlist: Playlist
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @EnvironmentObject private var theme: AppTheme
+
+    private var isTarget: Bool {
+        guard let target = playlistVM.mixTargets.first(where: { $0?.id == playlist.id }) else { return false }
+        return target != nil
+    }
+
+    var body: some View {
+        HStack(spacing: 12) {
+            // Color indicator
+            Circle()
+                .fill(Color(hex: playlist.color) ?? theme.accent)
+                .frame(width: 12, height: 12)
+
+            VStack(alignment: .leading, spacing: 2) {
+                HStack(spacing: 6) {
+                    Text(playlist.name)
+                        .font(.headline)
+                        .foregroundStyle(theme.primaryText)
+
+                    if isTarget {
+                        Image(systemName: "star.fill")
+                            .font(.caption2)
+                            .foregroundStyle(.orange)
+                    }
+                }
+
+                HStack(spacing: 8) {
+                    Text("\(playlist.trackCount) tracks")
+                        .font(.caption)
+                        .foregroundStyle(theme.secondaryText)
+                }
+            }
+
+            Spacer()
+        }
+        .padding(.vertical, 4)
+        .accessibilityIdentifier("playlistRow_\(playlist.name)")
+    }
+}

+ 144 - 0
Sources/Views/QueueView.swift

@@ -0,0 +1,144 @@
+import SwiftUI
+
+/// Sheet showing the current playback queue: Now Playing, User Queue, Up Next.
+struct QueueView: View {
+    @Environment(PlayerViewModel.self) private var playerVM
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(\.dismiss) private var dismiss
+
+    var body: some View {
+        NavigationStack {
+            List {
+                // Now Playing
+                if let nowPlaying = playerVM.nowPlayingEntry {
+                    Section("Now Playing") {
+                        queueRow(nowPlaying, isNowPlaying: true)
+                    }
+                }
+
+                // User Queue (manually added)
+                if !playerVM.userQueue.isEmpty {
+                    Section("Next in Queue") {
+                        ForEach(playerVM.userQueue) { entry in
+                            queueRow(entry)
+                        }
+                        .onMove { source, destination in
+                            playerVM.moveUserQueueEntry(from: source, to: destination)
+                        }
+                        .onDelete { offsets in
+                            for index in offsets.sorted().reversed() {
+                                let entry = playerVM.userQueue[index]
+                                playerVM.removeFromQueue(entry: entry)
+                            }
+                        }
+                    }
+                }
+
+                // Up Next (auto from playlist)
+                if !playerVM.upNext.isEmpty {
+                    Section("Up Next") {
+                        ForEach(playerVM.upNext) { entry in
+                            queueRow(entry)
+                        }
+                        .onMove { source, destination in
+                            playerVM.moveUpNextEntry(from: source, to: destination)
+                        }
+                        .onDelete { offsets in
+                            for index in offsets.sorted().reversed() {
+                                let entry = playerVM.upNext[index]
+                                playerVM.removeFromQueue(entry: entry)
+                            }
+                        }
+                    }
+                }
+
+                if playerVM.nowPlayingEntry == nil && playerVM.userQueue.isEmpty && playerVM.upNext.isEmpty {
+                    Section {
+                        VStack(spacing: 12) {
+                            Image(systemName: "list.bullet")
+                                .font(.system(size: 36))
+                                .foregroundStyle(theme.tertiaryText)
+                            Text("Queue is empty")
+                                .font(.title3)
+                                .foregroundStyle(theme.secondaryText)
+                            Text("Add tracks using \"Play Next\" or \"Add to Queue\" from any track's context menu.")
+                                .font(.caption)
+                                .foregroundStyle(theme.tertiaryText)
+                                .multilineTextAlignment(.center)
+                        }
+                        .frame(maxWidth: .infinity)
+                        .padding(.vertical, 40)
+                    }
+                    .listRowBackground(Color.clear)
+                }
+            }
+            .listStyle(.insetGrouped)
+            .environment(\.editMode, .constant(.active))
+            .navigationTitle("Queue")
+            .navigationBarTitleDisplayMode(.inline)
+            .toolbar {
+                ToolbarItem(placement: .topBarLeading) {
+                    if !playerVM.userQueue.isEmpty || !playerVM.upNext.isEmpty {
+                        Button("Clear") {
+                            playerVM.clearQueue()
+                        }
+                        .foregroundStyle(.red)
+                    }
+                }
+                ToolbarItem(placement: .topBarTrailing) {
+                    Button("Done") { dismiss() }
+                }
+            }
+        }
+    }
+
+    @ViewBuilder
+    private func queueRow(_ entry: QueueEntry, isNowPlaying: Bool = false) -> some View {
+        HStack(spacing: 12) {
+            // Cloud indicator or music note
+            Group {
+                switch entry.source {
+                case .cloudDirect:
+                    Image(systemName: "cloud.fill")
+                        .foregroundStyle(theme.accent)
+                case .swiftDataTrack(_, let isCloud, _):
+                    if isCloud {
+                        Image(systemName: "cloud.fill")
+                            .foregroundStyle(theme.accent)
+                    } else {
+                        Image(systemName: "music.note")
+                            .foregroundStyle(theme.tertiaryText)
+                    }
+                }
+            }
+            .font(.caption)
+            .frame(width: 24)
+
+            VStack(alignment: .leading, spacing: 2) {
+                Text(entry.title)
+                    .font(.subheadline.weight(isNowPlaying ? .semibold : .regular))
+                    .foregroundStyle(isNowPlaying ? theme.accent : theme.primaryText)
+                    .lineLimit(1)
+                if !entry.artist.isEmpty {
+                    Text(entry.artist)
+                        .font(.caption)
+                        .foregroundStyle(theme.secondaryText)
+                        .lineLimit(1)
+                }
+            }
+
+            Spacer()
+
+            Text(entry.formattedDuration)
+                .font(.caption.monospacedDigit())
+                .foregroundStyle(theme.tertiaryText)
+
+            if isNowPlaying && playerVM.isPlaying {
+                Image(systemName: "speaker.wave.2.fill")
+                    .font(.caption)
+                    .foregroundStyle(theme.accent)
+            }
+        }
+        .padding(.vertical, 2)
+    }
+}

+ 428 - 0
Sources/Views/SettingsView.swift

@@ -0,0 +1,428 @@
+import SwiftData
+import SwiftUI
+
+/// Settings tab — skin selection, sync, about.
+struct SettingsView: View {
+    @EnvironmentObject private var theme: AppTheme
+    @EnvironmentObject private var syncManager: SyncManager
+    @EnvironmentObject private var libraryManager: LibraryManager
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @Environment(\.modelContext) private var modelContext
+
+    @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist]
+    @Query private var tracks: [Track]
+
+    @State private var showSyncImporter = false
+    @State private var showSyncExportConfirm = false
+    @State private var syncResult: String?
+    @State private var showResetConfirm = false
+
+    // Playback settings
+    @AppStorage("trackTapAction") private var trackTapAction = "playNow"
+
+    // Chad Music settings
+    @State private var chadServerURL: String = UserDefaults.standard.string(forKey: "chadMusic.serverURL") ?? ""
+    @State private var chadAPIKey: String = KeychainService.loadAPIKey() ?? ""
+    @State private var chadTestResult: ChadTestState = .idle
+
+    private enum ChadTestState {
+        case idle
+        case testing
+        case success(ChadStats)
+        case failed(String)
+    }
+
+    private static let mixColors: [Color] = [
+        Color(red: 0.95, green: 0.3, blue: 0.3),
+        Color(red: 0.3, green: 0.75, blue: 0.95),
+        Color(red: 0.95, green: 0.75, blue: 0.2),
+    ]
+
+    var body: some View {
+        NavigationStack {
+            List {
+                // MARK: - Mix Targets
+                Section {
+                    ForEach(0..<3, id: \.self) { slot in
+                        Menu {
+                            ForEach(playlists) { playlist in
+                                Button(playlist.name) {
+                                    playlistVM.setMixTarget(slot, playlist: playlist)
+                                }
+                            }
+                            if playlistVM.mixTargets[slot] != nil {
+                                Divider()
+                                Button("Clear", role: .destructive) {
+                                    playlistVM.setMixTarget(slot, playlist: nil)
+                                }
+                            }
+                        } label: {
+                            HStack(spacing: 12) {
+                                Text("\(slot + 1)")
+                                    .font(.system(size: 14, weight: .bold, design: .rounded))
+                                    .frame(width: 28, height: 28)
+                                    .foregroundStyle(Self.mixColors[slot])
+                                    .background(Self.mixColors[slot].opacity(0.15))
+                                    .clipShape(RoundedRectangle(cornerRadius: 6))
+
+                                if let target = playlistVM.mixTargets[slot] {
+                                    Text(target.name)
+                                        .foregroundStyle(theme.primaryText)
+                                } else {
+                                    Text("Not set")
+                                        .foregroundStyle(theme.tertiaryText)
+                                }
+
+                                Spacer()
+
+                                Image(systemName: "chevron.up.chevron.down")
+                                    .font(.caption)
+                                    .foregroundStyle(theme.tertiaryText)
+                            }
+                            .contentShape(Rectangle())
+                        }
+                    }
+                } header: {
+                    Text("Mix Targets")
+                } footer: {
+                    Text("Assign playlists to the 3 mix buttons shown on each track. Tap a number on a track to quick-add it.")
+                }
+
+                // MARK: - App Icon Color
+                Section("App Icon") {
+                    let iconOptions: [(name: String, color: Color, iconName: String?)] = [
+                        ("Default", .green, nil),
+                        ("Green", Color(red: 0.35, green: 0.85, blue: 0.25), "AppIcon-Green"),
+                        ("Lime", Color(red: 0.55, green: 0.95, blue: 0.15), "AppIcon-Lime"),
+                        ("Cyan", Color(red: 0.15, green: 0.85, blue: 0.85), "AppIcon-Cyan"),
+                        ("Blue", Color(red: 0.25, green: 0.45, blue: 0.95), "AppIcon-Blue"),
+                        ("Purple", Color(red: 0.6, green: 0.3, blue: 0.9), "AppIcon-Purple"),
+                        ("Pink", Color(red: 0.95, green: 0.3, blue: 0.6), "AppIcon-Pink"),
+                        ("Red", Color(red: 0.95, green: 0.25, blue: 0.25), "AppIcon-Red"),
+                        ("Orange", Color(red: 0.95, green: 0.55, blue: 0.15), "AppIcon-Orange"),
+                        ("Gold", Color(red: 0.95, green: 0.8, blue: 0.15), "AppIcon-Gold"),
+                        ("White", Color(red: 0.9, green: 0.9, blue: 0.92), "AppIcon-White"),
+                    ]
+
+                    ScrollView(.horizontal, showsIndicators: false) {
+                        HStack(spacing: 12) {
+                            ForEach(iconOptions, id: \.name) { option in
+                                Button {
+                                    UIApplication.shared.setAlternateIconName(option.iconName) { error in
+                                        if let error { print("Icon change failed: \(error)") }
+                                    }
+                                } label: {
+                                    VStack(spacing: 6) {
+                                        RoundedRectangle(cornerRadius: 12)
+                                            .fill(option.color)
+                                            .frame(width: 50, height: 50)
+                                            .overlay(
+                                                RoundedRectangle(cornerRadius: 12)
+                                                    .stroke(Color.white.opacity(0.2), lineWidth: 1)
+                                            )
+                                        Text(option.name)
+                                            .font(.caption2)
+                                            .foregroundStyle(theme.secondaryText)
+                                    }
+                                }
+                                .buttonStyle(.plain)
+                            }
+                        }
+                        .padding(.vertical, 8)
+                    }
+                    .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
+                }
+
+                // MARK: - Playback
+                Section("Playback") {
+                    Picker("Track Tap Action", selection: $trackTapAction) {
+                        Text("Play Now").tag("playNow")
+                        Text("Add to Queue").tag("addToQueue")
+                    }
+                }
+
+                // MARK: - Skin Selection
+                Section("Skin") {
+                    ForEach(AppTheme.Skin.allCases) { skin in
+                        Button {
+                            withAnimation(.easeInOut(duration: 0.3)) {
+                                theme.currentSkin = skin
+                            }
+                        } label: {
+                            HStack(spacing: 12) {
+                                Image(systemName: skin.icon)
+                                    .font(.title3)
+                                    .foregroundStyle(theme.currentSkin == skin ? theme.accent : theme.secondaryText)
+                                    .frame(width: 32)
+
+                                VStack(alignment: .leading, spacing: 2) {
+                                    Text(skin.rawValue)
+                                        .font(.headline)
+                                        .foregroundStyle(theme.primaryText)
+                                    Text(skin.description)
+                                        .font(.caption)
+                                        .foregroundStyle(theme.secondaryText)
+                                }
+
+                                Spacer()
+
+                                if theme.currentSkin == skin {
+                                    Image(systemName: "checkmark.circle.fill")
+                                        .foregroundStyle(theme.accent)
+                                }
+                            }
+                        }
+                    }
+                }
+
+                // MARK: - Chad Music
+                Section {
+                    HStack {
+                        Text("Server URL")
+                            .foregroundStyle(theme.secondaryText)
+                        TextField("https://music.example.com", text: $chadServerURL)
+                            .textContentType(.URL)
+                            .keyboardType(.URL)
+                            .autocapitalization(.none)
+                            .disableAutocorrection(true)
+                            .multilineTextAlignment(.trailing)
+                            .onChange(of: chadServerURL) { _, newValue in
+                                ChadMusicAPIClient.shared.serverURL = newValue
+                            }
+                    }
+
+                    HStack {
+                        Text("API Key")
+                            .foregroundStyle(theme.secondaryText)
+                        SecureField("Enter API key", text: $chadAPIKey)
+                            .multilineTextAlignment(.trailing)
+                            .onChange(of: chadAPIKey) { _, newValue in
+                                if newValue.isEmpty {
+                                    KeychainService.deleteAPIKey()
+                                } else {
+                                    try? KeychainService.saveAPIKey(newValue)
+                                }
+                            }
+                    }
+
+                    Button {
+                        testChadConnection()
+                    } label: {
+                        HStack {
+                            Label("Test Connection", systemImage: "antenna.radiowaves.left.and.right")
+                            Spacer()
+                            switch chadTestResult {
+                            case .idle:
+                                EmptyView()
+                            case .testing:
+                                ProgressView()
+                            case .success:
+                                Image(systemName: "checkmark.circle.fill")
+                                    .foregroundStyle(.green)
+                            case .failed:
+                                Image(systemName: "xmark.circle.fill")
+                                    .foregroundStyle(.red)
+                            }
+                        }
+                    }
+                    .disabled(chadServerURL.isEmpty || chadAPIKey.isEmpty)
+
+                    switch chadTestResult {
+                    case .success(let stats):
+                        HStack(spacing: 16) {
+                            if let tracks = stats.tracks {
+                                VStack {
+                                    Text("\(tracks)")
+                                        .font(.headline)
+                                    Text("Tracks")
+                                        .font(.caption2)
+                                        .foregroundStyle(theme.secondaryText)
+                                }
+                            }
+                            if let albums = stats.albums {
+                                VStack {
+                                    Text("\(albums)")
+                                        .font(.headline)
+                                    Text("Albums")
+                                        .font(.caption2)
+                                        .foregroundStyle(theme.secondaryText)
+                                }
+                            }
+                            if let artists = stats.artists {
+                                VStack {
+                                    Text("\(artists)")
+                                        .font(.headline)
+                                    Text("Artists")
+                                        .font(.caption2)
+                                        .foregroundStyle(theme.secondaryText)
+                                }
+                            }
+                        }
+                        .frame(maxWidth: .infinity)
+                    case .failed(let msg):
+                        Text(msg)
+                            .font(.caption)
+                            .foregroundStyle(.red)
+                    default:
+                        EmptyView()
+                    }
+                } header: {
+                    Text("Chad Music")
+                } footer: {
+                    Text("Connect to your Chad Music server to stream cloud music. The server URL and API key are stored securely in the Keychain.")
+                }
+
+                // MARK: - Sync
+                Section {
+                    Button {
+                        syncManager.exportPlaylists(playlists)
+                        showSyncExportConfirm = true
+                    } label: {
+                        Label("Export Playlists for Mac", systemImage: "square.and.arrow.up")
+                    }
+                    .disabled(playlists.isEmpty)
+
+                    Button {
+                        showSyncImporter = true
+                    } label: {
+                        Label("Import Playlists from Mac", systemImage: "square.and.arrow.down")
+                    }
+
+                    if let date = syncManager.lastSyncDate {
+                        HStack {
+                            Text("Last export")
+                                .foregroundStyle(theme.secondaryText)
+                            Spacer()
+                            Text(date.formatted(date: .abbreviated, time: .shortened))
+                                .font(.caption)
+                                .foregroundStyle(theme.tertiaryText)
+                        }
+                    }
+
+                    if let result = syncResult {
+                        Text(result)
+                            .font(.caption)
+                            .foregroundStyle(theme.accent)
+                    }
+                } header: {
+                    Text("Sync")
+                } footer: {
+                    Text("Export creates a JSON file in Documents/Sync/ that you can share with the Mac app via AirDrop, iCloud Drive, or USB.")
+                }
+
+                // MARK: - Library Stats
+                Section("Library") {
+                    HStack {
+                        Text("Tracks")
+                        Spacer()
+                        Text("\(tracks.count)")
+                            .foregroundStyle(theme.secondaryText)
+                    }
+
+                    HStack {
+                        Text("Playlists")
+                        Spacer()
+                        Text("\(playlists.count)")
+                            .foregroundStyle(theme.secondaryText)
+                    }
+
+                    HStack {
+                        Text("Analyzed")
+                        Spacer()
+                        Text("\(tracks.filter(\.isAnalyzed).count) / \(tracks.count)")
+                            .foregroundStyle(theme.secondaryText)
+                    }
+
+                    Button {
+                        Task { await libraryManager.rescanMetadata() }
+                    } label: {
+                        Label("Rescan Metadata", systemImage: "arrow.clockwise")
+                    }
+                    .disabled(libraryManager.isScanning)
+
+                    Button(role: .destructive) {
+                        showResetConfirm = true
+                    } label: {
+                        Label("Reset Library & Rescan", systemImage: "arrow.counterclockwise")
+                            .foregroundStyle(.red)
+                    }
+                }
+
+                // MARK: - About
+                Section("About") {
+                    HStack {
+                        Text("MixBoard iOS")
+                        Spacer()
+                        Text("1.0.0")
+                            .foregroundStyle(theme.tertiaryText)
+                    }
+
+                    HStack {
+                        Text("Supported Formats")
+                        Spacer()
+                        Text("MP3, FLAC, WAV, AIFF, M4A, AAC, OGG, Opus")
+                            .font(.caption)
+                            .foregroundStyle(theme.tertiaryText)
+                    }
+                }
+            }
+            .navigationTitle("Settings")
+            .accessibilityIdentifier("settingsView")
+            .fileImporter(
+                isPresented: $showSyncImporter,
+                allowedContentTypes: [.json],
+                allowsMultipleSelection: false
+            ) { result in
+                switch result {
+                case .success(let urls):
+                    guard let url = urls.first else { return }
+                    let accessing = url.startAccessingSecurityScopedResource()
+                    defer { if accessing { url.stopAccessingSecurityScopedResource() } }
+                    do {
+                        let imported = try syncManager.importPlaylists(from: url, context: modelContext)
+                        let result = syncManager.mergeImportedPlaylists(imported, existingTracks: tracks, context: modelContext)
+                        syncResult = "Imported \(result.created) playlists, \(result.matched) tracks matched, \(result.unmatched) not found"
+                    } catch {
+                        syncResult = "Import failed: \(error.localizedDescription)"
+                    }
+                case .failure(let error):
+                    syncResult = "Error: \(error.localizedDescription)"
+                }
+            }
+            .alert("Exported", isPresented: $showSyncExportConfirm) {
+                Button("OK") {}
+            } message: {
+                Text("Playlists exported to Documents/Sync/mixboard-playlists.json\n\nShare this file with your Mac via AirDrop, iCloud Drive, or Files app.")
+            }
+            .alert("Reset Library?", isPresented: $showResetConfirm) {
+                Button("Cancel", role: .cancel) {}
+                Button("Reset & Rescan", role: .destructive) {
+                    // Delete all tracks from database
+                    for track in tracks {
+                        modelContext.delete(track)
+                    }
+                    try? modelContext.save()
+                    // Rescan
+                    Task {
+                        await libraryManager.scanMusicDirectory()
+                    }
+                }
+            } message: {
+                Text("This will remove all tracks from the database and re-scan your music folder. Your music files are NOT deleted.")
+            }
+        }
+    }
+
+    private func testChadConnection() {
+        chadTestResult = .testing
+        Task {
+            let result = await ChadMusicAPIClient.shared.testConnection()
+            switch result {
+            case .success(let stats):
+                chadTestResult = .success(stats)
+            case .failure(let error):
+                chadTestResult = .failed(error.localizedDescription)
+            }
+        }
+    }
+}

+ 158 - 0
Sources/Views/TrackRow.swift

@@ -0,0 +1,158 @@
+import SwiftUI
+
+/// Compact track row for lists — adapts to current skin.
+/// Shows 3 quick-add mix buttons on the right side.
+struct TrackRow: View {
+    let track: Track
+    @EnvironmentObject private var theme: AppTheme
+    @Environment(PlayerViewModel.self) private var playerVM
+    @Environment(PlaylistViewModel.self) private var playlistVM
+    @Environment(\.modelContext) private var modelContext
+
+    private var isCurrentlyPlaying: Bool {
+        playerVM.currentTrack?.id == track.id
+    }
+
+    private static let mixColors: [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
+    ]
+
+    var body: some View {
+        HStack(spacing: 10) {
+            // Album art
+            ArtworkThumbnail(track: track)
+                .frame(width: 44, height: 44)
+
+            // Track info
+            VStack(alignment: .leading, spacing: 2) {
+                Text(track.title)
+                    .font(.system(size: theme.dataFontSize, weight: isCurrentlyPlaying ? .semibold : .regular))
+                    .foregroundStyle(isCurrentlyPlaying ? theme.playingHighlight : theme.primaryText)
+                    .lineLimit(1)
+
+                HStack(spacing: 6) {
+                    if !track.artist.isEmpty {
+                        Text(track.artist)
+                            .font(.system(size: theme.smallFontSize))
+                            .foregroundStyle(isCurrentlyPlaying ? theme.playingHighlight.opacity(0.7) : theme.secondaryText)
+                            .lineLimit(1)
+                    }
+
+                    if let bpm = track.bpm {
+                        Text("•")
+                            .foregroundStyle(theme.tertiaryText)
+                        Text("\(String(format: "%.0f", bpm)) BPM")
+                            .font(.system(size: theme.smallFontSize, design: .monospaced))
+                            .foregroundStyle(theme.tertiaryText)
+                    }
+
+                    if let key = track.musicalKey {
+                        Text(key)
+                            .font(.system(size: theme.smallFontSize, design: .monospaced))
+                            .foregroundStyle(theme.tertiaryText)
+                    }
+                }
+            }
+
+            Spacer(minLength: 4)
+
+            // 3 Mix buttons
+            HStack(spacing: 4) {
+                ForEach(0..<3, id: \.self) { slot in
+                    Button {
+                        _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
+                    } label: {
+                        Text("\(slot + 1)")
+                            .font(.system(size: 11, weight: .bold, design: .rounded))
+                            .frame(width: 26, height: 26)
+                            .foregroundStyle(
+                                playlistVM.mixTargets[slot] != nil
+                                    ? Self.mixColors[slot]
+                                    : theme.tertiaryText
+                            )
+                            .background(
+                                playlistVM.mixTargets[slot] != nil
+                                    ? Self.mixColors[slot].opacity(0.15)
+                                    : theme.tertiaryText.opacity(0.08)
+                            )
+                            .clipShape(RoundedRectangle(cornerRadius: 6))
+                    }
+                    .buttonStyle(.plain)
+                }
+            }
+
+            // Duration + format
+            VStack(alignment: .trailing, spacing: 2) {
+                Text(track.formattedDuration)
+                    .font(.system(size: theme.smallFontSize, design: .monospaced))
+                    .foregroundStyle(theme.secondaryText)
+
+                Text(track.fileFormat)
+                    .font(.system(size: 9, weight: .medium, design: .monospaced))
+                    .foregroundStyle(theme.tertiaryText)
+                    .padding(.horizontal, 4)
+                    .padding(.vertical, 1)
+                    .background(theme.tertiaryText.opacity(0.15))
+                    .clipShape(RoundedRectangle(cornerRadius: 3))
+            }
+
+            if isCurrentlyPlaying && playerVM.isPlaying {
+                Image(systemName: "speaker.wave.2.fill")
+                    .font(.caption)
+                    .foregroundStyle(theme.playingHighlight)
+            }
+        }
+        .padding(.vertical, 4)
+        .contextMenu {
+            Button {
+                playerVM.loadAndPlay(track)
+                playerVM.showNowPlaying = true
+            } label: {
+                Label("Play Now", systemImage: "play.fill")
+            }
+            Button {
+                playerVM.playNextInQueue(QueueEntry.from(track: track))
+            } label: {
+                Label("Play Next", systemImage: "text.insert")
+            }
+            Button {
+                playerVM.addToQueue(QueueEntry.from(track: track))
+            } label: {
+                Label("Add to Queue", systemImage: "text.append")
+            }
+        }
+    }
+}
+
+// MARK: - Artwork Thumbnail
+
+struct ArtworkThumbnail: View {
+    let track: Track
+    @EnvironmentObject private var theme: AppTheme
+    @State private var artwork: UIImage?
+
+    var body: some View {
+        Group {
+            if let image = artwork {
+                Image(uiImage: image)
+                    .resizable()
+                    .aspectRatio(contentMode: .fill)
+            } else {
+                ZStack {
+                    RoundedRectangle(cornerRadius: theme.cornerRadius / 2)
+                        .fill(theme.cardBackground)
+                    Image(systemName: "music.note")
+                        .font(.system(size: 16))
+                        .foregroundStyle(theme.tertiaryText)
+                }
+            }
+        }
+        .clipShape(RoundedRectangle(cornerRadius: theme.cornerRadius / 2))
+        .task {
+            let url = track.fileURL
+            artwork = await ArtworkService.shared.artwork(for: url)
+        }
+    }
+}

+ 60 - 0
Sources/Views/WaveformView.swift

@@ -0,0 +1,60 @@
+import SwiftUI
+
+/// Canvas-based waveform visualization with playback progress.
+struct WaveformView: 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
+            Canvas { context, size in
+                let samples = playerVM.waveformSamples
+                guard !samples.isEmpty else { return }
+
+                let barWidth = size.width / CGFloat(samples.count)
+                let midY = size.height / 2
+                let progress = isDragging ? dragProgress : playerVM.progress
+
+                for (index, sample) in samples.enumerated() {
+                    let x = CGFloat(index) * barWidth
+                    let isPlayed = Double(index) / Double(samples.count) < progress
+
+                    let topHeight = CGFloat(sample.max) * midY
+                    let bottomHeight = CGFloat(-sample.min) * midY
+
+                    let rect = CGRect(
+                        x: x,
+                        y: midY - topHeight,
+                        width: max(barWidth - 0.5, 0.5),
+                        height: topHeight + bottomHeight
+                    )
+
+                    let color = isPlayed ? theme.seekbarForeground : theme.seekbarBackground
+                    context.fill(Path(rect), with: .color(color))
+                }
+
+                // Playhead line
+                let playheadX = progress * Double(size.width)
+                let playheadRect = CGRect(x: playheadX - 0.5, y: 0, width: 1, height: size.height)
+                context.fill(Path(playheadRect), with: .color(theme.accent))
+            }
+            .gesture(
+                DragGesture(minimumDistance: 0)
+                    .onChanged { value in
+                        isDragging = true
+                        dragProgress = max(0, min(1, Double(value.location.x / geo.size.width)))
+                    }
+                    .onEnded { value in
+                        let prog = max(0, min(1, Double(value.location.x / geo.size.width)))
+                        playerVM.seekToProgress(prog)
+                        isDragging = false
+                    }
+            )
+        }
+        .clipShape(RoundedRectangle(cornerRadius: 6))
+        .contentShape(Rectangle())
+    }
+}

+ 167 - 0
Tests/AudioEngineTests.swift

@@ -0,0 +1,167 @@
+import XCTest
+@testable import MixBoard
+
+@MainActor
+final class AudioEngineTests: XCTestCase {
+
+    // MARK: - Error Descriptions
+
+    func testFileNotFoundErrorDescription() {
+        let error = AudioEngineError.fileNotFound("missing.mp3")
+        XCTAssertEqual(error.errorDescription, "Audio file not found: missing.mp3")
+    }
+
+    func testFileNotFoundErrorWithEmptyName() {
+        let error = AudioEngineError.fileNotFound("")
+        XCTAssertNotNil(error.errorDescription)
+    }
+
+    // MARK: - Initial State
+
+    func testInitialIsPlayingFalse() {
+        let engine = AudioEngine()
+        XCTAssertFalse(engine.isPlaying)
+    }
+
+    func testInitialCurrentTimeZero() {
+        let engine = AudioEngine()
+        XCTAssertEqual(engine.currentTime, 0)
+    }
+
+    func testInitialDurationZero() {
+        let engine = AudioEngine()
+        XCTAssertEqual(engine.duration, 0)
+    }
+
+    func testDefaultVolume() {
+        let engine = AudioEngine()
+        XCTAssertEqual(engine.volume, 0.8, accuracy: 0.001)
+    }
+
+    func testCurrentTrackNil() {
+        let engine = AudioEngine()
+        XCTAssertNil(engine.currentTrack)
+    }
+
+    // MARK: - Volume
+
+    func testSetVolume() {
+        let engine = AudioEngine()
+        engine.volume = 0.5
+        XCTAssertEqual(engine.volume, 0.5, accuracy: 0.001)
+    }
+
+    func testSetVolumeZero() {
+        let engine = AudioEngine()
+        engine.volume = 0
+        XCTAssertEqual(engine.volume, 0, accuracy: 0.001)
+    }
+
+    func testSetVolumeMax() {
+        let engine = AudioEngine()
+        engine.volume = 1.0
+        XCTAssertEqual(engine.volume, 1.0, accuracy: 0.001)
+    }
+
+    // MARK: - Load Track Error Paths
+
+    func testLoadTrackFileNotFound() {
+        let engine = AudioEngine()
+        let track = Track(title: "Missing", filePath: "nonexistent/path/missing.mp3")
+
+        XCTAssertThrowsError(try engine.loadTrack(track)) { error in
+            guard case AudioEngineError.fileNotFound = error else {
+                XCTFail("Expected fileNotFound error, got \(error)")
+                return
+            }
+        }
+    }
+
+    func testLoadTrackResetsPlayingState() {
+        let engine = AudioEngine()
+        // Even a failed load should leave isPlaying = false
+        let track = Track(title: "Missing", filePath: "does/not/exist.mp3")
+        try? engine.loadTrack(track)
+        XCTAssertFalse(engine.isPlaying)
+    }
+
+    // MARK: - Stop
+
+    func testStopResetsState() {
+        let engine = AudioEngine()
+        engine.stop()
+        XCTAssertFalse(engine.isPlaying)
+        XCTAssertEqual(engine.currentTime, 0)
+    }
+
+    // MARK: - Pause Without File
+
+    func testPauseWithoutFile() {
+        let engine = AudioEngine()
+        engine.pause()
+        XCTAssertFalse(engine.isPlaying)
+    }
+
+    // MARK: - Toggle Play/Pause Without File
+
+    func testTogglePlayPauseWithNoFile() {
+        let engine = AudioEngine()
+        // Should not crash
+        engine.togglePlayPause()
+        XCTAssertFalse(engine.isPlaying)
+    }
+
+    // MARK: - EQ
+
+    func testSetEQValidBand() {
+        let engine = AudioEngine()
+        // Should not crash
+        engine.setEQ(band: 0, gain: 5.0)
+        engine.setEQ(band: 1, gain: -3.0)
+        engine.setEQ(band: 2, gain: 0.0)
+    }
+
+    func testSetEQOutOfRangeBand() {
+        let engine = AudioEngine()
+        // Should not crash — guard protects against out-of-range indices
+        engine.setEQ(band: 99, gain: 10.0)
+        engine.setEQ(band: -1, gain: 10.0)
+        engine.setEQ(band: 3, gain: 10.0)
+        engine.setEQ(band: 1000, gain: 10.0)
+    }
+
+    // MARK: - Seek Without File
+
+    func testSeekToWithoutFile() {
+        let engine = AudioEngine()
+        engine.seek(to: 30.0)
+        // Should not crash; currentTime should be clamped
+        XCTAssertEqual(engine.currentTime, 0, accuracy: 0.01)
+    }
+
+    func testSeekByWithoutFile() {
+        let engine = AudioEngine()
+        engine.seek(by: 10.0)
+        XCTAssertEqual(engine.currentTime, 0, accuracy: 0.01)
+    }
+
+    // MARK: - Update Current Time
+
+    func testUpdateCurrentTimeNotPlaying() {
+        let engine = AudioEngine()
+        engine.updateCurrentTime()
+        // Should not crash or change time
+        XCTAssertEqual(engine.currentTime, 0)
+    }
+
+    // MARK: - Playback Callback
+
+    func testOnPlaybackFinishedCallback() {
+        let engine = AudioEngine()
+        var callbackCalled = false
+        engine.onPlaybackFinished = { callbackCalled = true }
+        XCTAssertFalse(callbackCalled)
+        // The callback should be set but not called until playback actually finishes
+        XCTAssertNotNil(engine.onPlaybackFinished)
+    }
+}

+ 350 - 0
Tests/CloudStreamingTests.swift

@@ -0,0 +1,350 @@
+import Foundation
+import Testing
+@testable import MixBoardiOS
+
+// MARK: - ChadMusic Model Tests
+
+@Suite("ChadMusic Models")
+struct ChadMusicModelTests {
+    @Test("ChadCategory decodes correctly")
+    func decodeChadCategory() throws {
+        let json = """
+        {"item": "Rock", "count": 42}
+        """.data(using: .utf8)!
+
+        let category = try JSONDecoder().decode(ChadCategory.self, from: json)
+        #expect(category.item == "Rock")
+        #expect(category.count == 42)
+        #expect(category.id == "Rock")
+        #expect(category.name == "Rock")
+    }
+
+    @Test("ChadCategory decodes with null count")
+    func decodeChadCategoryNullCount() throws {
+        let json = """
+        {"item": "Jazz", "count": null}
+        """.data(using: .utf8)!
+
+        let category = try JSONDecoder().decode(ChadCategory.self, from: json)
+        #expect(category.item == "Jazz")
+        #expect(category.count == nil)
+    }
+
+    @Test("ChadAlbum decodes with snake_case keys")
+    func decodeChadAlbum() throws {
+        let json = """
+        {
+            "id": "abc123",
+            "album": "Dark Side of the Moon",
+            "artist": "Pink Floyd",
+            "year": 1973,
+            "genre": "Progressive Rock",
+            "track_count": 10,
+            "cover": "/covers/abc123.jpg",
+            "publisher": "Harvest",
+            "country": "UK",
+            "type": "Album",
+            "status": "Official",
+            "total_duration": 2580.0,
+            "original_date": "1973-03-01",
+            "mb_id": "some-uuid"
+        }
+        """.data(using: .utf8)!
+
+        let album = try JSONDecoder().decode(ChadAlbum.self, from: json)
+        #expect(album.id == "abc123")
+        #expect(album.title == "Dark Side of the Moon")
+        #expect(album.artist == "Pink Floyd")
+        #expect(album.year == 1973)
+        #expect(album.genre == "Progressive Rock")
+        #expect(album.trackCount == 10)
+        #expect(album.totalDuration == 2580.0)
+    }
+
+    @Test("ChadAlbum title defaults to Untitled when album is null")
+    func chadAlbumUntitled() throws {
+        let json = """
+        {"id": "x", "album": null, "artist": null, "year": null, "genre": null, "track_count": null, "cover": null, "publisher": null, "country": null, "type": null, "status": null, "total_duration": null, "original_date": null, "mb_id": null}
+        """.data(using: .utf8)!
+
+        let album = try JSONDecoder().decode(ChadAlbum.self, from: json)
+        #expect(album.title == "Untitled")
+    }
+
+    @Test("ChadTrack decodes with all fields")
+    func decodeChadTrack() throws {
+        let json = """
+        {
+            "id": "track1",
+            "title": "Breathe",
+            "artist": "Pink Floyd",
+            "album_artist": "Pink Floyd",
+            "album": "Dark Side of the Moon",
+            "duration": 169.0,
+            "no": 2,
+            "url": "/music/Pink Floyd/DSOTM/02-Breathe.flac",
+            "bit_rate": 1411,
+            "year": 1973,
+            "cover": "/covers/abc.jpg"
+        }
+        """.data(using: .utf8)!
+
+        let track = try JSONDecoder().decode(ChadTrack.self, from: json)
+        #expect(track.id == "track1")
+        #expect(track.title == "Breathe")
+        #expect(track.artist == "Pink Floyd")
+        #expect(track.albumArtist == "Pink Floyd")
+        #expect(track.album == "Dark Side of the Moon")
+        #expect(track.duration == 169.0)
+        #expect(track.trackNumber == 2)
+        #expect(track.url == "/music/Pink Floyd/DSOTM/02-Breathe.flac")
+        #expect(track.bitRate == 1411)
+        #expect(track.year == 1973)
+    }
+
+    @Test("ChadTrack formatted duration")
+    func trackFormattedDuration() throws {
+        let json = """
+        {"id": "t", "title": "T", "artist": null, "album_artist": null, "album": null, "duration": 185.0, "no": null, "url": "/x", "bit_rate": null, "year": null, "cover": null}
+        """.data(using: .utf8)!
+
+        let track = try JSONDecoder().decode(ChadTrack.self, from: json)
+        #expect(track.formattedDuration == "3:05")
+    }
+
+    @Test("ChadTrack formatted duration with nil shows dash")
+    func trackFormattedDurationNil() throws {
+        let json = """
+        {"id": "t", "title": "T", "artist": null, "album_artist": null, "album": null, "duration": null, "no": null, "url": "/x", "bit_rate": null, "year": null, "cover": null}
+        """.data(using: .utf8)!
+
+        let track = try JSONDecoder().decode(ChadTrack.self, from: json)
+        #expect(track.formattedDuration == "—")
+    }
+
+    @Test("ChadStats decodes")
+    func decodeChadStats() throws {
+        let json = """
+        {"tracks": 5000, "albums": 400, "artists": 200, "duration": "3d 14h 22m"}
+        """.data(using: .utf8)!
+
+        let stats = try JSONDecoder().decode(ChadStats.self, from: json)
+        #expect(stats.tracks == 5000)
+        #expect(stats.albums == 400)
+        #expect(stats.artists == 200)
+        #expect(stats.duration == "3d 14h 22m")
+    }
+
+    @Test("ChadCategoryType has correct display names")
+    func categoryDisplayNames() {
+        #expect(ChadCategoryType.album.displayName == "Albums")
+        #expect(ChadCategoryType.artist.displayName == "Artists")
+        #expect(ChadCategoryType.genre.displayName == "Genres")
+        #expect(ChadCategoryType.year.displayName == "Years")
+    }
+
+    @Test("ChadCategoryType has icons")
+    func categoryIcons() {
+        for category in ChadCategoryType.allCases {
+            #expect(!category.icon.isEmpty)
+        }
+    }
+}
+
+// MARK: - Track.fromCloud Tests
+
+@Suite("Track Cloud Factory")
+struct TrackFromCloudTests {
+    @Test("fromCloud creates track with correct fields")
+    func fromCloudBasic() {
+        let chadTrack = ChadTrack(
+            id: "abc123",
+            title: "Test Track",
+            artist: "Test Artist",
+            albumArtist: nil,
+            album: "Test Album",
+            duration: 240.0,
+            no: 3,
+            url: "/music/Test/Album/03-Track.flac",
+            bitRate: 1411,
+            year: 2024,
+            cover: nil
+        )
+
+        let track = Track.fromCloud(chadTrack)
+        #expect(track.title == "Test Track")
+        #expect(track.artist == "Test Artist")
+        #expect(track.album == "Test Album")
+        #expect(track.duration == 240.0)
+        #expect(track.isCloud == true)
+        #expect(track.cloudStreamPath == "/music/Test/Album/03-Track.flac")
+        #expect(track.cloudTrackId == "abc123")
+        #expect(track.year == 2024)
+        #expect(track.filePath == "")
+    }
+
+    @Test("fromCloud handles nil optional fields")
+    func fromCloudNilFields() {
+        let chadTrack = ChadTrack(
+            id: "x",
+            title: "Minimal",
+            artist: nil,
+            albumArtist: nil,
+            album: nil,
+            duration: nil,
+            no: nil,
+            url: "/music/file.mp3",
+            bitRate: nil,
+            year: nil,
+            cover: nil
+        )
+
+        let track = Track.fromCloud(chadTrack)
+        #expect(track.title == "Minimal")
+        #expect(track.artist == "")
+        #expect(track.album == "")
+        #expect(track.duration == 0)
+        #expect(track.isCloud == true)
+        #expect(track.year == nil)
+    }
+
+    @Test("hasLocalFile is false for cloud tracks")
+    func cloudTrackHasNoLocalFile() {
+        let chadTrack = ChadTrack(
+            id: "x",
+            title: "Cloud",
+            artist: nil,
+            albumArtist: nil,
+            album: nil,
+            duration: 100,
+            no: nil,
+            url: "/music/file.mp3",
+            bitRate: nil,
+            year: nil,
+            cover: nil
+        )
+        let track = Track.fromCloud(chadTrack)
+        #expect(track.hasLocalFile == false)
+    }
+}
+
+// MARK: - ChadMusicAPIClient Tests
+
+@Suite("ChadMusicAPIClient")
+struct ChadMusicAPIClientTests {
+    @Test("streamURL builds correct URL")
+    @MainActor
+    func streamURLBuilding() {
+        let client = ChadMusicAPIClient()
+        client.serverURL = "https://music.example.com"
+
+        let url = client.streamURL(for: "/music/Artist/Album/track.flac")
+        #expect(url?.absoluteString == "https://music.example.com/music/Artist/Album/track.flac")
+    }
+
+    @Test("streamURL handles trailing slash on server")
+    @MainActor
+    func streamURLTrailingSlash() {
+        let client = ChadMusicAPIClient()
+        client.serverURL = "https://music.example.com/"
+
+        let url = client.streamURL(for: "/music/file.mp3")
+        #expect(url?.absoluteString == "https://music.example.com/music/file.mp3")
+    }
+
+    @Test("streamURL handles path without leading slash")
+    @MainActor
+    func streamURLNoLeadingSlash() {
+        let client = ChadMusicAPIClient()
+        client.serverURL = "https://music.example.com"
+
+        let url = client.streamURL(for: "music/file.mp3")
+        #expect(url?.absoluteString == "https://music.example.com/music/file.mp3")
+    }
+
+    @Test("streamURL returns nil for empty server")
+    @MainActor
+    func streamURLEmptyServer() {
+        let client = ChadMusicAPIClient()
+        client.serverURL = ""
+
+        let url = client.streamURL(for: "/music/file.mp3")
+        #expect(url == nil)
+    }
+
+    @Test("isConfigured false when no URL or key")
+    @MainActor
+    func notConfigured() {
+        let client = ChadMusicAPIClient()
+        client.serverURL = ""
+        #expect(client.isConfigured == false)
+    }
+}
+
+// MARK: - KeychainService Tests
+
+@Suite("KeychainService")
+struct KeychainServiceTests {
+    @Test("Save and load API key roundtrip")
+    func saveAndLoad() throws {
+        // Clean up first
+        KeychainService.deleteAPIKey()
+
+        try KeychainService.saveAPIKey("test-key-12345")
+        let loaded = KeychainService.loadAPIKey()
+        #expect(loaded == "test-key-12345")
+
+        // Clean up
+        KeychainService.deleteAPIKey()
+    }
+
+    @Test("Load returns nil when no key stored")
+    func loadNil() {
+        KeychainService.deleteAPIKey()
+        let loaded = KeychainService.loadAPIKey()
+        #expect(loaded == nil)
+    }
+
+    @Test("Delete removes the key")
+    func deleteKey() throws {
+        try KeychainService.saveAPIKey("to-delete")
+        KeychainService.deleteAPIKey()
+        let loaded = KeychainService.loadAPIKey()
+        #expect(loaded == nil)
+    }
+
+    @Test("Saving overwrites existing key")
+    func overwrite() throws {
+        KeychainService.deleteAPIKey()
+        try KeychainService.saveAPIKey("old-key")
+        try KeychainService.saveAPIKey("new-key")
+        let loaded = KeychainService.loadAPIKey()
+        #expect(loaded == "new-key")
+
+        KeychainService.deleteAPIKey()
+    }
+}
+
+// MARK: - ChadMusicError Tests
+
+@Suite("ChadMusicError")
+struct ChadMusicErrorTests {
+    @Test("Error descriptions are non-empty")
+    func errorDescriptions() {
+        let errors: [ChadMusicError] = [
+            .notConfigured,
+            .unauthorized,
+            .forbidden,
+            .notFound("test"),
+            .httpError(500),
+            .invalidResponse,
+            .decodingFailed(NSError(domain: "test", code: 0)),
+            .networkError(NSError(domain: "test", code: 0)),
+        ]
+
+        for error in errors {
+            #expect(error.errorDescription != nil)
+            #expect(!error.errorDescription!.isEmpty)
+        }
+    }
+}

+ 141 - 0
Tests/CodecTests.swift

@@ -0,0 +1,141 @@
+import XCTest
+@testable import MixBoard
+
+final class CodecTests: XCTestCase {
+
+    // MARK: - OGGDecoder Error Descriptions
+
+    func testOGGFailedToOpenErrorDescription() {
+        let error = OGGDecoder.OGGError.failedToOpen("test.ogg")
+        XCTAssertEqual(error.errorDescription, "Failed to open OGG file: test.ogg")
+    }
+
+    func testOGGFailedToCreateBufferErrorDescription() {
+        let error = OGGDecoder.OGGError.failedToCreateBuffer
+        XCTAssertEqual(error.errorDescription, "Failed to create audio buffer")
+    }
+
+    func testOGGDecodingFailedErrorDescription() {
+        let error = OGGDecoder.OGGError.decodingFailed
+        XCTAssertEqual(error.errorDescription, "OGG decoding failed")
+    }
+
+    // MARK: - OpusDecoder Error Descriptions
+
+    #if !DISABLE_OPUS
+    func testOpusFailedToOpenErrorDescription() {
+        let error = OpusDecoder.OpusError.failedToOpen("test.opus", -1)
+        XCTAssertTrue(error.errorDescription!.contains("test.opus"))
+        XCTAssertTrue(error.errorDescription!.contains("-1"))
+    }
+
+    func testOpusFailedToCreateBufferErrorDescription() {
+        let error = OpusDecoder.OpusError.failedToCreateBuffer
+        XCTAssertEqual(error.errorDescription, "Failed to create audio buffer")
+    }
+
+    func testOpusDecodingFailedErrorDescription() {
+        let error = OpusDecoder.OpusError.decodingFailed
+        XCTAssertEqual(error.errorDescription, "Opus decoding failed")
+    }
+    #endif
+
+    // MARK: - isOGGFile
+
+    func testIsOGGFileTrue() {
+        XCTAssertTrue(OGGDecoder.isOGGFile(URL(fileURLWithPath: "/path/to/file.ogg")))
+    }
+
+    func testIsOGGFileFalse() {
+        XCTAssertFalse(OGGDecoder.isOGGFile(URL(fileURLWithPath: "/path/to/file.mp3")))
+        XCTAssertFalse(OGGDecoder.isOGGFile(URL(fileURLWithPath: "/path/to/file.flac")))
+        XCTAssertFalse(OGGDecoder.isOGGFile(URL(fileURLWithPath: "/path/to/file.opus")))
+    }
+
+    func testIsOGGFileCaseInsensitive() {
+        XCTAssertTrue(OGGDecoder.isOGGFile(URL(fileURLWithPath: "/path/to/file.OGG")))
+    }
+
+    // MARK: - isOpusFile
+
+    #if !DISABLE_OPUS
+    func testIsOpusFileTrue() {
+        XCTAssertTrue(OpusDecoder.isOpusFile(URL(fileURLWithPath: "/path/to/file.opus")))
+    }
+
+    func testIsOpusFileFalse() {
+        XCTAssertFalse(OpusDecoder.isOpusFile(URL(fileURLWithPath: "/path/to/file.ogg")))
+        XCTAssertFalse(OpusDecoder.isOpusFile(URL(fileURLWithPath: "/path/to/file.mp3")))
+        XCTAssertFalse(OpusDecoder.isOpusFile(URL(fileURLWithPath: "/path/to/file.wav")))
+    }
+
+    func testIsOpusFileCaseInsensitive() {
+        XCTAssertTrue(OpusDecoder.isOpusFile(URL(fileURLWithPath: "/path/to/file.OPUS")))
+    }
+    #endif
+
+    // MARK: - OGGDecoder with non-existent files
+
+    func testOGGDecodeNonExistentFile() {
+        let url = URL(fileURLWithPath: "/nonexistent/path/test.ogg")
+        XCTAssertThrowsError(try OGGDecoder.decode(url: url)) { error in
+            guard case OGGDecoder.OGGError.failedToOpen = error else {
+                XCTFail("Expected failedToOpen error, got \(error)")
+                return
+            }
+        }
+    }
+
+    func testOGGDurationNonExistentFile() {
+        let url = URL(fileURLWithPath: "/nonexistent/path/test.ogg")
+        let duration = OGGDecoder.duration(url: url)
+        XCTAssertEqual(duration, 0)
+    }
+
+    func testOGGFileInfoNonExistentFile() {
+        let url = URL(fileURLWithPath: "/nonexistent/path/test.ogg")
+        let info = OGGDecoder.fileInfo(url: url)
+        XCTAssertNil(info)
+    }
+
+    // MARK: - OpusDecoder with non-existent files
+
+    func testOpusDecodeNonExistentFile() {
+        let url = URL(fileURLWithPath: "/nonexistent/path/test.opus")
+        XCTAssertThrowsError(try OpusDecoder.decode(url: url)) { error in
+            guard case OpusDecoder.OpusError.failedToOpen = error else {
+                XCTFail("Expected failedToOpen error, got \(error)")
+                return
+            }
+        }
+    }
+
+    func testOpusDurationNonExistentFile() {
+        let url = URL(fileURLWithPath: "/nonexistent/path/test.opus")
+        let duration = OpusDecoder.duration(url: url)
+        XCTAssertEqual(duration, 0)
+    }
+
+    func testOpusFileInfoNonExistentFile() {
+        let url = URL(fileURLWithPath: "/nonexistent/path/test.opus")
+        let info = OpusDecoder.fileInfo(url: url)
+        XCTAssertNil(info)
+    }
+
+    // MARK: - OGG convertToCAF error path
+
+    func testOGGConvertToCAFNonExistentFile() {
+        let url = URL(fileURLWithPath: "/nonexistent/path/test.ogg")
+        XCTAssertThrowsError(try OGGDecoder.convertToCAF(url: url))
+    }
+
+    // MARK: - Metadata Service format detection with codecs
+
+    func testOpusIsSupportedAudioFile() {
+        XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.opus")))
+    }
+
+    func testOGGIsSupportedAudioFile() {
+        XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.ogg")))
+    }
+}

+ 199 - 0
Tests/GroupTemplateResolverTests.swift

@@ -0,0 +1,199 @@
+import XCTest
+@testable import MixBoard
+
+final class GroupTemplateResolverTests: XCTestCase {
+
+    private func makeTrack(
+        artist: String = "Test Artist",
+        album: String = "Test Album",
+        genre: String = "Electronic",
+        filePath: String = "Music/subfolder/song.mp3",
+        fileFormat: String = "MP3",
+        bpm: Double? = 128.0,
+        musicalKey: String? = "Am",
+        year: Int? = 2023
+    ) -> Track {
+        let track = Track(title: "Test Song", artist: artist, album: album, genre: genre, filePath: filePath, fileFormat: fileFormat)
+        track.bpm = bpm
+        track.musicalKey = musicalKey
+        track.year = year
+        return track
+    }
+
+    // MARK: - Basic Placeholder Resolution
+
+    func testResolveArtistTemplate() {
+        let track = makeTrack(artist: "Raekwon")
+        let result = GroupTemplateResolver.resolve(template: "{Artist}", for: track)
+        XCTAssertEqual(result, "Raekwon")
+    }
+
+    func testResolveAlbumTemplate() {
+        let track = makeTrack(album: "Only Built 4 Cuban Linx")
+        let result = GroupTemplateResolver.resolve(template: "{Album}", for: track)
+        XCTAssertEqual(result, "Only Built 4 Cuban Linx")
+    }
+
+    func testResolveGenreTemplate() {
+        let track = makeTrack(genre: "Hip-Hop")
+        let result = GroupTemplateResolver.resolve(template: "{Genre}", for: track)
+        XCTAssertEqual(result, "Hip-Hop")
+    }
+
+    func testResolveDateTemplate() {
+        let track = makeTrack(year: 1995)
+        let result = GroupTemplateResolver.resolve(template: "{Date}", for: track)
+        XCTAssertEqual(result, "1995")
+    }
+
+    func testResolveFormatTemplate() {
+        let track = makeTrack(fileFormat: "FLAC")
+        let result = GroupTemplateResolver.resolve(template: "{Format}", for: track)
+        XCTAssertEqual(result, "FLAC")
+    }
+
+    func testResolveKeyTemplate() {
+        let track = makeTrack(musicalKey: "Cm")
+        let result = GroupTemplateResolver.resolve(template: "{Key}", for: track)
+        XCTAssertEqual(result, "Cm")
+    }
+
+    // MARK: - Folder Resolution
+
+    func testResolveFolderTemplate() {
+        let track = makeTrack(filePath: "Music/Hip-Hop/Raekwon/track.mp3")
+        let result = GroupTemplateResolver.resolve(template: "{Folder}", for: track)
+        XCTAssertEqual(result, "Raekwon")
+    }
+
+    func testResolveFolderRootPath() {
+        let track = makeTrack(filePath: "song.mp3")
+        let result = GroupTemplateResolver.resolve(template: "{Folder}", for: track)
+        XCTAssertEqual(result, "Root")
+    }
+
+    // MARK: - BPM Range
+
+    func testResolveBPMRange() {
+        let track = makeTrack(bpm: 125.0)
+        let result = GroupTemplateResolver.resolve(template: "{BPM}", for: track)
+        XCTAssertEqual(result, "120-130 BPM")
+    }
+
+    func testResolveBPMRangeExact() {
+        let track = makeTrack(bpm: 130.0)
+        let result = GroupTemplateResolver.resolve(template: "{BPM}", for: track)
+        XCTAssertEqual(result, "130-140 BPM")
+    }
+
+    func testResolveBPMNil() {
+        let track = makeTrack(bpm: nil)
+        let result = GroupTemplateResolver.resolve(template: "{BPM}", for: track)
+        XCTAssertEqual(result, "No BPM")
+    }
+
+    // MARK: - Compound Templates
+
+    func testResolveAlbumDateTemplate() {
+        let track = makeTrack(album: "Madvillainy", year: 2004)
+        let result = GroupTemplateResolver.resolve(template: "{Album} ({Date})", for: track)
+        XCTAssertEqual(result, "Madvillainy (2004)")
+    }
+
+    func testResolveArtistAlbumTemplate() {
+        let track = makeTrack(artist: "MF DOOM", album: "Operation: Doomsday")
+        let result = GroupTemplateResolver.resolve(template: "{Artist} — {Album}", for: track)
+        XCTAssertEqual(result, "MF DOOM — Operation: Doomsday")
+    }
+
+    // MARK: - Missing/Empty Data Handling
+
+    func testResolveEmptyTemplate() {
+        let track = makeTrack()
+        let result = GroupTemplateResolver.resolve(template: "", for: track)
+        XCTAssertEqual(result, "")
+    }
+
+    func testResolveEmptyArtist() {
+        let track = makeTrack(artist: "")
+        let result = GroupTemplateResolver.resolve(template: "{Artist}", for: track)
+        XCTAssertEqual(result, "Unknown Artist")
+    }
+
+    func testResolveEmptyAlbum() {
+        let track = makeTrack(album: "")
+        let result = GroupTemplateResolver.resolve(template: "{Album}", for: track)
+        XCTAssertEqual(result, "Unknown Album")
+    }
+
+    func testResolveEmptyGenre() {
+        let track = makeTrack(genre: "")
+        let result = GroupTemplateResolver.resolve(template: "{Genre}", for: track)
+        XCTAssertEqual(result, "Unknown Genre")
+    }
+
+    func testResolveEmptyFormat() {
+        let track = makeTrack(fileFormat: "")
+        let result = GroupTemplateResolver.resolve(template: "{Format}", for: track)
+        XCTAssertEqual(result, "Unknown")
+    }
+
+    func testResolveNilKey() {
+        let track = makeTrack(musicalKey: nil)
+        let result = GroupTemplateResolver.resolve(template: "{Key}", for: track)
+        XCTAssertEqual(result, "Unknown Key")
+    }
+
+    func testResolveNilDateCleansUpBrackets() {
+        let track = makeTrack(album: "Some Album", year: nil)
+        let result = GroupTemplateResolver.resolve(template: "{Album} ({Date})", for: track)
+        // Empty parens should be cleaned up
+        XCTAssertEqual(result, "Some Album")
+    }
+
+    func testResolveUnknownArtistCleanup() {
+        let track = makeTrack(artist: "")
+        let result = GroupTemplateResolver.resolve(template: "{Artist} — {Album}", for: track)
+        // " — Unknown Artist" should be stripped — but since Artist is first, it stays
+        // The cleanup rule is " — Unknown Artist" specifically
+        XCTAssertFalse(result.isEmpty)
+    }
+
+    func testResolveAllEmptyFieldsReturnsUngrouped() {
+        let track = Track(title: "Bare", filePath: "song.mp3")
+        // With all empty, the resolved string may end up blank after cleanup
+        let result = GroupTemplateResolver.resolve(template: "{Date}", for: track)
+        // Year is nil → Date resolves to "" → template becomes "" → should return "Ungrouped"
+        XCTAssertEqual(result, "Ungrouped")
+    }
+
+    // MARK: - Presets & Placeholders
+
+    func testPresetsExist() {
+        XCTAssertFalse(GroupTemplateResolver.presets.isEmpty)
+        XCTAssertGreaterThanOrEqual(GroupTemplateResolver.presets.count, 8)
+    }
+
+    func testPresetsContainNoGrouping() {
+        let noGrouping = GroupTemplateResolver.presets.first { $0.template.isEmpty }
+        XCTAssertNotNil(noGrouping)
+        XCTAssertEqual(noGrouping?.name, "No Grouping")
+    }
+
+    func testPlaceholdersExist() {
+        XCTAssertEqual(GroupTemplateResolver.placeholders.count, 8)
+    }
+
+    func testAllPlaceholderTokensHaveBraces() {
+        for placeholder in GroupTemplateResolver.placeholders {
+            XCTAssertTrue(placeholder.token.hasPrefix("{"), "\(placeholder.token) should start with {")
+            XCTAssertTrue(placeholder.token.hasSuffix("}"), "\(placeholder.token) should end with }")
+        }
+    }
+
+    func testAllPlaceholderDescriptionsNonEmpty() {
+        for placeholder in GroupTemplateResolver.placeholders {
+            XCTAssertFalse(placeholder.description.isEmpty, "\(placeholder.token) should have a description")
+        }
+    }
+}

+ 397 - 0
Tests/LyricsTests.swift

@@ -0,0 +1,397 @@
+import XCTest
+@testable import MixBoard
+
+// MARK: - LyricsParser Tests
+
+final class LyricsParserTests: XCTestCase {
+
+    // MARK: - parseSynced: Basic LRC Format
+
+    func testParseSyncedBasic() {
+        let lrc = """
+        [00:12.34] First line
+        [00:24.56] Second line
+        [00:36.78] Third line
+        """
+        let lines = LyricsParser.parseSynced(lrc)
+        XCTAssertEqual(lines.count, 3)
+        XCTAssertEqual(lines[0].text, "First line")
+        XCTAssertEqual(lines[0].timestamp, 12.34, accuracy: 0.01)
+        XCTAssertEqual(lines[1].text, "Second line")
+        XCTAssertEqual(lines[1].timestamp, 24.56, accuracy: 0.01)
+        XCTAssertEqual(lines[2].text, "Third line")
+        XCTAssertEqual(lines[2].timestamp, 36.78, accuracy: 0.01)
+    }
+
+    func testParseSyncedSortedByTimestamp() {
+        let lrc = """
+        [01:00.00] Late line
+        [00:10.00] Early line
+        [00:30.00] Middle line
+        """
+        let lines = LyricsParser.parseSynced(lrc)
+        XCTAssertEqual(lines.count, 3)
+        XCTAssertEqual(lines[0].text, "Early line")
+        XCTAssertEqual(lines[1].text, "Middle line")
+        XCTAssertEqual(lines[2].text, "Late line")
+    }
+
+    func testParseSyncedWithMilliseconds() {
+        let lrc = "[02:05.456] Line with ms"
+        let lines = LyricsParser.parseSynced(lrc)
+        XCTAssertEqual(lines.count, 1)
+        XCTAssertEqual(lines[0].timestamp, 125.456, accuracy: 0.001)
+        XCTAssertEqual(lines[0].text, "Line with ms")
+    }
+
+    func testParseSyncedWithCentiseconds() {
+        let lrc = "[01:30.50] Half second"
+        let lines = LyricsParser.parseSynced(lrc)
+        XCTAssertEqual(lines.count, 1)
+        XCTAssertEqual(lines[0].timestamp, 90.50, accuracy: 0.01)
+    }
+
+    func testParseSyncedWithColonSeparator() {
+        // Some LRC files use : instead of . for fractional seconds
+        let lrc = "[01:30:50] Colon style"
+        let lines = LyricsParser.parseSynced(lrc)
+        XCTAssertEqual(lines.count, 1)
+        XCTAssertEqual(lines[0].timestamp, 90.50, accuracy: 0.01)
+    }
+
+    func testParseSyncedWithCommaSeparator() {
+        let lrc = "[01:30,50] Comma style"
+        let lines = LyricsParser.parseSynced(lrc)
+        XCTAssertEqual(lines.count, 1)
+        XCTAssertEqual(lines[0].timestamp, 90.50, accuracy: 0.01)
+    }
+
+    func testParseSyncedMultipleTimestampsPerLine() {
+        let lrc = "[00:12.00][01:24.00] Repeated chorus line"
+        let lines = LyricsParser.parseSynced(lrc)
+        XCTAssertEqual(lines.count, 2)
+        XCTAssertEqual(lines[0].timestamp, 12.0, accuracy: 0.01)
+        XCTAssertEqual(lines[0].text, "Repeated chorus line")
+        XCTAssertEqual(lines[1].timestamp, 84.0, accuracy: 0.01)
+        XCTAssertEqual(lines[1].text, "Repeated chorus line")
+    }
+
+    func testParseSyncedEmptyTextLine() {
+        let lrc = """
+        [00:10.00] Has text
+        [00:20.00]
+        [00:30.00] After break
+        """
+        let lines = LyricsParser.parseSynced(lrc)
+        XCTAssertEqual(lines.count, 3)
+        XCTAssertEqual(lines[1].text, "")
+    }
+
+    func testParseSyncedEmptyInput() {
+        let lines = LyricsParser.parseSynced("")
+        XCTAssertTrue(lines.isEmpty)
+    }
+
+    func testParseSyncedNoTimestamps() {
+        let lrc = """
+        Just plain text
+        No timestamps here
+        """
+        let lines = LyricsParser.parseSynced(lrc)
+        XCTAssertTrue(lines.isEmpty)
+    }
+
+    func testParseSyncedThreeDigitMinutes() {
+        let lrc = "[100:00.00] Very long track"
+        let lines = LyricsParser.parseSynced(lrc)
+        XCTAssertEqual(lines.count, 1)
+        XCTAssertEqual(lines[0].timestamp, 6000.0, accuracy: 0.01)
+    }
+
+    func testParseSyncedSkipsBlankLines() {
+        let lrc = """
+        [00:10.00] Line one
+
+        [00:20.00] Line two
+        """
+        let lines = LyricsParser.parseSynced(lrc)
+        XCTAssertEqual(lines.count, 2)
+    }
+
+    func testParseSyncedZeroTimestamp() {
+        let lrc = "[00:00.00] Very start"
+        let lines = LyricsParser.parseSynced(lrc)
+        XCTAssertEqual(lines.count, 1)
+        XCTAssertEqual(lines[0].timestamp, 0.0, accuracy: 0.001)
+    }
+
+    // MARK: - parsePlain
+
+    func testParsePlainBasic() {
+        let text = """
+        First line
+        Second line
+        Third line
+        """
+        let lines = LyricsParser.parsePlain(text)
+        XCTAssertEqual(lines.count, 3)
+        XCTAssertEqual(lines[0].text, "First line")
+        XCTAssertEqual(lines[1].text, "Second line")
+        XCTAssertEqual(lines[2].text, "Third line")
+    }
+
+    func testParsePlainTimestampsAreLineIndices() {
+        let text = "Line A\nLine B\nLine C"
+        let lines = LyricsParser.parsePlain(text)
+        XCTAssertEqual(lines[0].timestamp, 0)
+        XCTAssertEqual(lines[1].timestamp, 1)
+        XCTAssertEqual(lines[2].timestamp, 2)
+    }
+
+    func testParsePlainEmptyInput() {
+        let lines = LyricsParser.parsePlain("")
+        XCTAssertEqual(lines.count, 1)
+        XCTAssertEqual(lines[0].text, "")
+    }
+
+    func testParsePlainWithBlankLines() {
+        let text = "Verse 1\n\nVerse 2"
+        let lines = LyricsParser.parsePlain(text)
+        XCTAssertEqual(lines.count, 3)
+        XCTAssertEqual(lines[1].text, "")
+    }
+
+    // MARK: - currentLineIndex
+
+    func testCurrentLineIndexBasic() {
+        let lines = [
+            LyricsParser.LyricLine(timestamp: 10, text: "A"),
+            LyricsParser.LyricLine(timestamp: 20, text: "B"),
+            LyricsParser.LyricLine(timestamp: 30, text: "C"),
+        ]
+        XCTAssertEqual(LyricsParser.currentLineIndex(in: lines, at: 15), 0)
+        XCTAssertEqual(LyricsParser.currentLineIndex(in: lines, at: 25), 1)
+        XCTAssertEqual(LyricsParser.currentLineIndex(in: lines, at: 35), 2)
+    }
+
+    func testCurrentLineIndexExactMatch() {
+        let lines = [
+            LyricsParser.LyricLine(timestamp: 10, text: "A"),
+            LyricsParser.LyricLine(timestamp: 20, text: "B"),
+        ]
+        XCTAssertEqual(LyricsParser.currentLineIndex(in: lines, at: 10), 0)
+        XCTAssertEqual(LyricsParser.currentLineIndex(in: lines, at: 20), 1)
+    }
+
+    func testCurrentLineIndexBeforeFirstLine() {
+        let lines = [
+            LyricsParser.LyricLine(timestamp: 10, text: "A"),
+        ]
+        XCTAssertNil(LyricsParser.currentLineIndex(in: lines, at: 5))
+    }
+
+    func testCurrentLineIndexEmpty() {
+        XCTAssertNil(LyricsParser.currentLineIndex(in: [], at: 10))
+    }
+
+    func testCurrentLineIndexZeroTime() {
+        let lines = [
+            LyricsParser.LyricLine(timestamp: 0, text: "Start"),
+            LyricsParser.LyricLine(timestamp: 10, text: "Next"),
+        ]
+        XCTAssertEqual(LyricsParser.currentLineIndex(in: lines, at: 0), 0)
+    }
+
+    func testCurrentLineIndexAfterLastLine() {
+        let lines = [
+            LyricsParser.LyricLine(timestamp: 10, text: "A"),
+            LyricsParser.LyricLine(timestamp: 20, text: "B"),
+        ]
+        XCTAssertEqual(LyricsParser.currentLineIndex(in: lines, at: 9999), 1)
+    }
+
+    // MARK: - LyricLine.formattedTime
+
+    func testFormattedTimeZero() {
+        let line = LyricsParser.LyricLine(timestamp: 0, text: "")
+        XCTAssertEqual(line.formattedTime, "0:00")
+    }
+
+    func testFormattedTimeMinutesAndSeconds() {
+        let line = LyricsParser.LyricLine(timestamp: 125.456, text: "")
+        XCTAssertEqual(line.formattedTime, "2:05")
+    }
+
+    func testFormattedTimeLargeValue() {
+        let line = LyricsParser.LyricLine(timestamp: 3661, text: "")
+        XCTAssertEqual(line.formattedTime, "61:01")
+    }
+
+    // MARK: - LyricLine.Equatable
+
+    func testLyricLineEquatable() {
+        let a = LyricsParser.LyricLine(timestamp: 10, text: "Hello")
+        let b = LyricsParser.LyricLine(timestamp: 10, text: "Hello")
+        // Different UUIDs, so they're not equal
+        XCTAssertNotEqual(a, b)
+        // Same instance should be equal
+        XCTAssertEqual(a, a)
+    }
+}
+
+// MARK: - LyricsResult Tests
+
+final class LyricsResultTests: XCTestCase {
+
+    func testHasSyncedLyricsTrue() {
+        let result = LyricsResult(syncedLyrics: "[00:10.00] Hello")
+        XCTAssertTrue(result.hasSyncedLyrics)
+    }
+
+    func testHasSyncedLyricsFalseNil() {
+        let result = LyricsResult(syncedLyrics: nil)
+        XCTAssertFalse(result.hasSyncedLyrics)
+    }
+
+    func testHasSyncedLyricsFalseEmpty() {
+        let result = LyricsResult(syncedLyrics: "")
+        XCTAssertFalse(result.hasSyncedLyrics)
+    }
+
+    func testHasLyricsWithSynced() {
+        let result = LyricsResult(syncedLyrics: "[00:10.00] Hello")
+        XCTAssertTrue(result.hasLyrics)
+    }
+
+    func testHasLyricsWithPlain() {
+        let result = LyricsResult(plainLyrics: "Hello world")
+        XCTAssertTrue(result.hasLyrics)
+    }
+
+    func testHasLyricsFalse() {
+        let result = LyricsResult()
+        XCTAssertFalse(result.hasLyrics)
+    }
+
+    func testIsInstrumental() {
+        let result = LyricsResult(isInstrumental: true)
+        XCTAssertTrue(result.isInstrumental)
+    }
+
+    func testDefaultValues() {
+        let result = LyricsResult()
+        XCTAssertEqual(result.trackName, "")
+        XCTAssertEqual(result.artistName, "")
+        XCTAssertEqual(result.albumName, "")
+        XCTAssertFalse(result.isInstrumental)
+        XCTAssertNil(result.plainLyrics)
+        XCTAssertNil(result.syncedLyrics)
+    }
+}
+
+// MARK: - LyricsError Tests
+
+final class LyricsErrorTests: XCTestCase {
+
+    func testInvalidURLDescription() {
+        let error = LyricsError.invalidURL
+        XCTAssertEqual(error.errorDescription, "Invalid lyrics search URL")
+    }
+
+    func testNotFoundDescription() {
+        let error = LyricsError.notFound
+        XCTAssertEqual(error.errorDescription, "No lyrics found")
+    }
+
+    func testNetworkErrorDescription() {
+        let error = LyricsError.networkError
+        XCTAssertEqual(error.errorDescription, "Network error fetching lyrics")
+    }
+
+    func testHTTPErrorDescription() {
+        let error = LyricsError.httpError(404)
+        XCTAssertEqual(error.errorDescription, "HTTP error 404")
+    }
+
+    func testHTTPErrorDescription500() {
+        let error = LyricsError.httpError(500)
+        XCTAssertEqual(error.errorDescription, "HTTP error 500")
+    }
+}
+
+// MARK: - LRCLIBService Tests
+
+final class LRCLIBServiceTests: XCTestCase {
+
+    func testCacheIsInitiallyEmpty() async {
+        let service = LRCLIBService()
+        await service.clearCache()
+        // No crash
+    }
+
+    func testFetchLyricsEmptyArtist() async {
+        let service = LRCLIBService()
+        do {
+            _ = try await service.fetchLyrics(artist: "", title: "")
+            // May succeed or throw — depends on network
+        } catch {
+            // Expected — either notFound or networkError
+            XCTAssertTrue(error is LyricsError)
+        }
+    }
+
+    func testFetchLyricsWellKnownSong() async throws {
+        // Use a well-known song that's very likely in LRCLIB
+        let service = LRCLIBService()
+        do {
+            let result = try await service.fetchLyrics(
+                artist: "Queen",
+                title: "Bohemian Rhapsody"
+            )
+            XCTAssertTrue(result.hasLyrics)
+        } catch {
+            // Network may be unavailable in CI — don't fail
+            print("Network test skipped: \(error)")
+        }
+    }
+
+    func testFetchLyricsNonExistentSong() async {
+        let service = LRCLIBService()
+        do {
+            _ = try await service.fetchLyrics(
+                artist: "zzz_nonexistent_artist_12345",
+                title: "zzz_nonexistent_title_12345"
+            )
+            XCTFail("Expected LyricsError.notFound")
+        } catch LyricsError.notFound {
+            // Expected
+        } catch {
+            // Network error is also acceptable
+        }
+    }
+
+    func testCachingWorks() async throws {
+        let service = LRCLIBService()
+        do {
+            let result1 = try await service.fetchLyrics(
+                artist: "Queen",
+                title: "Bohemian Rhapsody"
+            )
+            // Second call should hit cache (much faster)
+            let result2 = try await service.fetchLyrics(
+                artist: "Queen",
+                title: "Bohemian Rhapsody"
+            )
+            XCTAssertEqual(result1.trackName, result2.trackName)
+        } catch {
+            // Network may be unavailable — don't fail
+            print("Network test skipped: \(error)")
+        }
+    }
+
+    func testClearCache() async {
+        let service = LRCLIBService()
+        await service.clearCache()
+        // Should not crash and cache should be empty
+    }
+}

+ 531 - 0
Tests/ModelTests.swift

@@ -0,0 +1,531 @@
+import XCTest
+import SwiftUI
+@testable import MixBoard
+
+// MARK: - Model Tests
+
+final class ModelTests: XCTestCase {
+
+    func testTrackInit() {
+        let track = Track(
+            title: "Test Song",
+            artist: "Test Artist",
+            filePath: "Music/test.mp3",
+            fileName: "test.mp3",
+            duration: 180
+        )
+        XCTAssertEqual(track.title, "Test Song")
+        XCTAssertEqual(track.artist, "Test Artist")
+        XCTAssertEqual(track.fileName, "test.mp3")
+        XCTAssertEqual(track.formattedDuration, "3:00")
+        XCTAssertFalse(track.isAnalyzed)
+        XCTAssertNil(track.bpm)
+    }
+
+    func testTrackDefaults() {
+        let track = Track(title: "Minimal", filePath: "Music/minimal.wav")
+        XCTAssertEqual(track.artist, "")
+        XCTAssertEqual(track.album, "")
+        XCTAssertEqual(track.genre, "")
+        XCTAssertEqual(track.playCount, 0)
+        XCTAssertEqual(track.rating, 0)
+        XCTAssertNil(track.lastPlayed)
+        XCTAssertNil(track.color)
+        XCTAssertNil(track.waveformData)
+        XCTAssertEqual(track.notes, "")
+        XCTAssertTrue(track.cuePoints.isEmpty)
+    }
+
+    func testTrackFormattedBPM() {
+        let track = Track(title: "Song", filePath: "Music/song.mp3")
+        XCTAssertEqual(track.formattedBPM, "—")
+        track.bpm = 128.5
+        XCTAssertEqual(track.formattedBPM, "128.5")
+    }
+
+    func testTrackFormattedDurationEdgeCases() {
+        let track0 = Track(title: "Zero", filePath: "Music/z.mp3", duration: 0)
+        XCTAssertEqual(track0.formattedDuration, "0:00")
+
+        let trackLong = Track(title: "Long", filePath: "Music/l.mp3", duration: 3661)
+        XCTAssertEqual(trackLong.formattedDuration, "61:01")
+    }
+
+    func testFileNameExtraction() {
+        let track = Track(title: "Song", filePath: "Music/subfolder/Artist - Song.flac")
+        XCTAssertEqual(track.fileName, "Artist - Song.flac")
+    }
+
+    func testFileNameExplicit() {
+        let track = Track(title: "Song", filePath: "Music/song.mp3", fileName: "custom.mp3")
+        XCTAssertEqual(track.fileName, "custom.mp3")
+    }
+
+    func testTrackFileURL() {
+        let track = Track(title: "Song", filePath: "Music/test.mp3")
+        let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
+        XCTAssertEqual(track.fileURL, docs.appendingPathComponent("Music/test.mp3"))
+    }
+}
+
+// MARK: - Playlist Tests
+
+final class PlaylistTests: XCTestCase {
+
+    func testPlaylistInit() {
+        let playlist = Playlist(name: "Friday Mix")
+        XCTAssertEqual(playlist.name, "Friday Mix")
+        XCTAssertEqual(playlist.color, "#2196F3")
+        XCTAssertEqual(playlist.trackCount, 0)
+        XCTAssertNil(playlist.targetBPM)
+        XCTAssertTrue(playlist.entries.isEmpty)
+        XCTAssertNil(playlist.folder)
+    }
+
+    func testPlaylistAddTrack() {
+        let playlist = Playlist(name: "My Mix")
+        let track = Track(title: "Song", filePath: "Music/song.mp3")
+
+        XCTAssertEqual(playlist.trackCount, 0)
+        playlist.addTrack(track, crossfadeDuration: 2.0)
+        XCTAssertEqual(playlist.trackCount, 1)
+
+        let entry = playlist.sortedEntries.first
+        XCTAssertEqual(entry?.position, 0)
+        XCTAssertEqual(entry?.crossfadeDuration, 2.0)
+    }
+
+    func testPlaylistAddMultipleTracks() {
+        let playlist = Playlist(name: "Mix")
+        let t1 = Track(title: "First", filePath: "Music/first.mp3", duration: 200)
+        let t2 = Track(title: "Second", filePath: "Music/second.mp3", duration: 180)
+        let t3 = Track(title: "Third", filePath: "Music/third.mp3", duration: 240)
+
+        playlist.addTrack(t1)
+        playlist.addTrack(t2)
+        playlist.addTrack(t3)
+
+        XCTAssertEqual(playlist.trackCount, 3)
+        let sorted = playlist.sortedEntries
+        XCTAssertEqual(sorted[0].track?.title, "First")
+        XCTAssertEqual(sorted[1].track?.title, "Second")
+        XCTAssertEqual(sorted[2].track?.title, "Third")
+        XCTAssertEqual(sorted[0].position, 0)
+        XCTAssertEqual(sorted[1].position, 1)
+        XCTAssertEqual(sorted[2].position, 2)
+    }
+
+    func testPlaylistTotalDuration() {
+        let playlist = Playlist(name: "Mix")
+        let t1 = Track(title: "A", filePath: "Music/a.mp3", duration: 200)
+        let t2 = Track(title: "B", filePath: "Music/b.mp3", duration: 180)
+        playlist.addTrack(t1)
+        playlist.addTrack(t2)
+        XCTAssertEqual(playlist.totalDuration, 380, accuracy: 0.01)
+    }
+
+    func testPlaylistFormattedTotalDuration() {
+        let playlist = Playlist(name: "Mix")
+        playlist.addTrack(Track(title: "A", filePath: "Music/a.mp3", duration: 3700))
+        XCTAssertTrue(playlist.formattedTotalDuration.contains(":"))
+    }
+
+    func testPlaylistRemoveEntry() {
+        let playlist = Playlist(name: "Mix")
+        let t1 = Track(title: "A", filePath: "Music/a.mp3")
+        let t2 = Track(title: "B", filePath: "Music/b.mp3")
+        let t3 = Track(title: "C", filePath: "Music/c.mp3")
+        playlist.addTrack(t1)
+        playlist.addTrack(t2)
+        playlist.addTrack(t3)
+
+        playlist.removeEntry(at: 1)  // Remove "B"
+        XCTAssertEqual(playlist.trackCount, 2)
+
+        let sorted = playlist.sortedEntries
+        XCTAssertEqual(sorted[0].track?.title, "A")
+        XCTAssertEqual(sorted[1].track?.title, "C")
+        // Positions should be re-indexed
+        XCTAssertEqual(sorted[0].position, 0)
+        XCTAssertEqual(sorted[1].position, 1)
+    }
+
+    func testPlaylistMoveEntry() {
+        let playlist = Playlist(name: "Mix")
+        let t1 = Track(title: "A", filePath: "Music/a.mp3")
+        let t2 = Track(title: "B", filePath: "Music/b.mp3")
+        let t3 = Track(title: "C", filePath: "Music/c.mp3")
+        playlist.addTrack(t1)
+        playlist.addTrack(t2)
+        playlist.addTrack(t3)
+
+        // Move C from position 2 to position 0
+        playlist.moveEntry(from: 2, to: 0)
+        let sorted = playlist.sortedEntries
+        XCTAssertEqual(sorted[0].track?.title, "C")
+        XCTAssertEqual(sorted[1].track?.title, "A")
+        XCTAssertEqual(sorted[2].track?.title, "B")
+    }
+
+    func testPlaylistDateModifiedUpdates() {
+        let playlist = Playlist(name: "Mix")
+        let originalDate = playlist.dateModified
+
+        // Small delay to ensure date changes
+        let track = Track(title: "Song", filePath: "Music/song.mp3")
+        playlist.addTrack(track)
+        XCTAssertGreaterThanOrEqual(playlist.dateModified, originalDate)
+    }
+}
+
+// MARK: - PlaylistEntry Tests
+
+final class PlaylistEntryTests: XCTestCase {
+
+    func testEntryEffectiveDuration() {
+        let track = Track(title: "Song", filePath: "Music/song.mp3", duration: 300)
+        let entry = PlaylistEntry(position: 0, track: track)
+        XCTAssertEqual(entry.effectiveDuration, 300, accuracy: 0.01)
+    }
+
+    func testEntryEffectiveDurationWithOffsets() {
+        let track = Track(title: "Song", filePath: "Music/song.mp3", duration: 300)
+        let entry = PlaylistEntry(position: 0, track: track, startOffset: 10, endOffset: 280)
+        XCTAssertEqual(entry.effectiveDuration, 270, accuracy: 0.01)
+    }
+
+    func testEntryEffectiveDurationNoTrack() {
+        let entry = PlaylistEntry(position: 0, track: nil)
+        XCTAssertEqual(entry.effectiveDuration, 0)
+    }
+
+    func testEntryDefaults() {
+        let entry = PlaylistEntry(position: 5, track: nil)
+        XCTAssertEqual(entry.position, 5)
+        XCTAssertEqual(entry.crossfadeDuration, 0)
+        XCTAssertEqual(entry.startOffset, 0)
+        XCTAssertEqual(entry.endOffset, 0)
+        XCTAssertEqual(entry.gainAdjustment, 0)
+        XCTAssertEqual(entry.notes, "")
+    }
+}
+
+// MARK: - CuePoint Tests
+
+final class CuePointTests: XCTestCase {
+
+    func testCuePointFormatTime() {
+        XCTAssertEqual(CuePoint.formatTime(0), "00:00.000")
+        XCTAssertEqual(CuePoint.formatTime(125.456), "02:05.456")
+        XCTAssertEqual(CuePoint.formatTime(59.999), "00:59.999")
+    }
+
+    func testCuePointInit() {
+        let cue = CuePoint(name: "Drop", timestamp: 45.5, type: .drop)
+        XCTAssertEqual(cue.name, "Drop")
+        XCTAssertEqual(cue.timestamp, 45.5)
+        XCTAssertEqual(cue.type, .drop)
+        XCTAssertFalse(cue.isRegion)
+    }
+
+    func testCuePointRegion() {
+        let cue = CuePoint(name: "Verse", timestamp: 30, endTimestamp: 90, type: .verse)
+        XCTAssertTrue(cue.isRegion)
+        XCTAssertEqual(cue.endTimestamp, 90)
+    }
+
+    func testCuePointComparable() {
+        let a = CuePoint(timestamp: 10)
+        let b = CuePoint(timestamp: 20)
+        let c = CuePoint(timestamp: 5)
+        XCTAssertTrue(c < a)
+        XCTAssertTrue(a < b)
+        let sorted = [b, a, c].sorted()
+        XCTAssertEqual(sorted.map(\.timestamp), [5, 10, 20])
+    }
+
+    func testCuePointTypes() {
+        XCTAssertEqual(CuePointType.allCases.count, 11)
+        XCTAssertEqual(CuePointType.marker.rawValue, "Marker")
+        XCTAssertEqual(CuePointType.fadeOut.rawValue, "Fade Out")
+    }
+}
+
+// MARK: - PlaylistFolder Tests
+
+final class PlaylistFolderTests: XCTestCase {
+
+    func testFolderInit() {
+        let folder = PlaylistFolder(name: "My Mixes")
+        XCTAssertEqual(folder.name, "My Mixes")
+        XCTAssertTrue(folder.isExpanded)
+        XCTAssertTrue(folder.playlists.isEmpty)
+        XCTAssertEqual(folder.totalTrackCount, 0)
+    }
+}
+
+// MARK: - AppState Tests
+
+final class AppStateTests: XCTestCase {
+
+    override func tearDown() {
+        // Clean up UserDefaults
+        UserDefaults.standard.removeObject(forKey: "appState.lastPlaylistID")
+        UserDefaults.standard.removeObject(forKey: "appState.lastEntryID")
+        UserDefaults.standard.removeObject(forKey: "appState.lastTrackFilePath")
+        UserDefaults.standard.removeObject(forKey: "appState.lastPlaybackTime")
+    }
+
+    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 testSaveAndLoadTrackFilePath() {
+        AppState.saveLastTrack(filePath: "Music/test.mp3")
+        XCTAssertEqual(AppState.lastTrackFilePath, "Music/test.mp3")
+    }
+
+    func testSaveAndLoadPlaybackTime() {
+        AppState.savePlaybackTime(42.5)
+        XCTAssertEqual(AppState.lastPlaybackTime, 42.5, accuracy: 0.01)
+    }
+
+    func testSavePlaybackStateAll() {
+        let playlistID = UUID()
+        let entryID = UUID()
+        AppState.savePlaybackState(
+            playlistID: playlistID,
+            entryID: entryID,
+            trackFilePath: "Music/combined.flac",
+            playbackTime: 99.9
+        )
+        XCTAssertEqual(AppState.lastPlaylistID, playlistID)
+        XCTAssertEqual(AppState.lastEntryID, entryID)
+        XCTAssertEqual(AppState.lastTrackFilePath, "Music/combined.flac")
+        XCTAssertEqual(AppState.lastPlaybackTime, 99.9, accuracy: 0.01)
+    }
+
+    func testDefaultsAreNil() {
+        XCTAssertNil(AppState.lastPlaylistID)
+        XCTAssertNil(AppState.lastEntryID)
+    }
+}
+
+// MARK: - Sync Encoding/Decoding Tests
+
+final class SyncTests: XCTestCase {
+
+    func testSyncPayloadRoundTrip() throws {
+        let playlist = Playlist(name: "Test Mix", notes: "Great tracks", color: "#FF5722")
+        playlist.targetBPM = 128.0
+        let t1 = Track(title: "Song A", artist: "Artist A", filePath: "Music/a.mp3", fileName: "a.mp3", duration: 200)
+        t1.bpm = 126
+        t1.musicalKey = "Am"
+        let t2 = Track(title: "Song B", artist: "Artist B", filePath: "Music/b.flac", fileName: "b.flac", duration: 180)
+        playlist.addTrack(t1, crossfadeDuration: 3.0)
+        playlist.addTrack(t2, crossfadeDuration: 2.0)
+
+        let syncPlaylist = SyncPlaylist(from: playlist)
+        let payload = SyncPayload(version: 1, exportedAt: Date(), exportedFrom: "Test", playlists: [syncPlaylist])
+
+        let encoder = JSONEncoder()
+        encoder.dateEncodingStrategy = .iso8601
+        encoder.outputFormatting = .prettyPrinted
+        let data = try encoder.encode(payload)
+
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .iso8601
+        let decoded = try decoder.decode(SyncPayload.self, from: data)
+
+        XCTAssertEqual(decoded.version, 1)
+        XCTAssertEqual(decoded.playlists.count, 1)
+
+        let dp = decoded.playlists[0]
+        XCTAssertEqual(dp.name, "Test Mix")
+        XCTAssertEqual(dp.notes, "Great tracks")
+        XCTAssertEqual(dp.color, "#FF5722")
+        XCTAssertEqual(dp.targetBPM, 128.0)
+        XCTAssertEqual(dp.entries.count, 2)
+
+        XCTAssertEqual(dp.entries[0].filename, "a.mp3")
+        XCTAssertEqual(dp.entries[0].title, "Song A")
+        XCTAssertEqual(dp.entries[0].artist, "Artist A")
+        XCTAssertEqual(dp.entries[0].bpm, 126)
+        XCTAssertEqual(dp.entries[0].musicalKey, "Am")
+        XCTAssertEqual(dp.entries[0].crossfadeDuration, 3.0)
+
+        XCTAssertEqual(dp.entries[1].filename, "b.flac")
+        XCTAssertEqual(dp.entries[1].title, "Song B")
+    }
+
+    func testSyncEmptyPlaylist() throws {
+        let playlist = Playlist(name: "Empty")
+        let sp = SyncPlaylist(from: playlist)
+        XCTAssertTrue(sp.entries.isEmpty)
+
+        let payload = SyncPayload(version: 1, exportedAt: Date(), exportedFrom: "iPhone", playlists: [sp])
+        let encoder = JSONEncoder()
+        encoder.dateEncodingStrategy = .iso8601
+        let data = try encoder.encode(payload)
+        let decoded = try JSONDecoder().apply { $0.dateDecodingStrategy = .iso8601 }.decode(SyncPayload.self, from: data)
+        XCTAssertEqual(decoded.playlists[0].entries.count, 0)
+    }
+
+    func testSyncEntryWithNoTrack() {
+        let entry = PlaylistEntry(position: 0, track: nil, notes: "Missing track")
+        let syncEntry = SyncEntry(from: entry)
+        XCTAssertEqual(syncEntry.filename, "unknown")
+        XCTAssertEqual(syncEntry.title, "Unknown")
+        XCTAssertEqual(syncEntry.notes, "Missing track")
+    }
+
+    func testSyncMultiplePlaylists() throws {
+        let p1 = Playlist(name: "Mix 1")
+        let p2 = Playlist(name: "Mix 2")
+        p1.addTrack(Track(title: "Song", filePath: "Music/s.mp3"))
+
+        let payload = SyncPayload(
+            version: 1,
+            exportedAt: Date(),
+            exportedFrom: "iPad",
+            playlists: [SyncPlaylist(from: p1), SyncPlaylist(from: p2)]
+        )
+
+        let encoder = JSONEncoder()
+        encoder.dateEncodingStrategy = .iso8601
+        let data = try encoder.encode(payload)
+
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .iso8601
+        let decoded = try decoder.decode(SyncPayload.self, from: data)
+        XCTAssertEqual(decoded.playlists.count, 2)
+        XCTAssertEqual(decoded.exportedFrom, "iPad")
+    }
+}
+
+// MARK: - MetadataService Tests
+
+final class MetadataServiceTests: XCTestCase {
+
+    func testSupportedFormats() {
+        let supported = ["mp3", "wav", "aif", "aiff", "flac", "m4a", "aac", "caf", "alac", "ogg"]
+        for ext in supported {
+            XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.\(ext)")),
+                          "\(ext) should be supported")
+        }
+    }
+
+    func testUnsupportedFormats() {
+        let unsupported = ["txt", "jpg", "pdf", "mp4", "wma", "doc", "zip"]
+        for ext in unsupported {
+            XCTAssertFalse(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.\(ext)")),
+                           "\(ext) should not be supported")
+        }
+    }
+
+    func testCaseInsensitive() {
+        XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.MP3")))
+        XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.Flac")))
+        XCTAssertTrue(MetadataService.isSupportedAudioFile(URL(fileURLWithPath: "/test.WAV")))
+    }
+}
+
+// MARK: - AppTheme Tests
+
+final class AppThemeTests: XCTestCase {
+
+    func testDefaultSkin() {
+        let theme = AppTheme()
+        XCTAssertNotNil(theme.currentSkin)
+    }
+
+    func testWinampSkin() {
+        let theme = AppTheme()
+        theme.currentSkin = .winamp
+        XCTAssertTrue(theme.useDarkMode)
+        XCTAssertEqual(theme.preferredColorScheme, .dark)
+        XCTAssertEqual(theme.cornerRadius, 4)
+    }
+
+    func testFoobarSkin() {
+        let theme = AppTheme()
+        theme.currentSkin = .foobarLight
+        XCTAssertFalse(theme.useDarkMode)
+        XCTAssertEqual(theme.preferredColorScheme, .light)
+        XCTAssertEqual(theme.cornerRadius, 6)
+    }
+
+    func testFoobarDarkSkin() {
+        let theme = AppTheme()
+        theme.currentSkin = .foobarDark
+        XCTAssertTrue(theme.useDarkMode)
+        XCTAssertEqual(theme.preferredColorScheme, .dark)
+    }
+
+    func testVinylSkin() {
+        let theme = AppTheme()
+        theme.currentSkin = .vinyl
+        XCTAssertTrue(theme.useDarkMode)
+        XCTAssertEqual(theme.preferredColorScheme, .dark)
+    }
+
+    func testObsidianSkin() {
+        let theme = AppTheme()
+        theme.currentSkin = .obsidian
+        XCTAssertTrue(theme.useDarkMode)
+        XCTAssertEqual(theme.cornerRadius, 12)
+    }
+
+    func testWmpSkin() {
+        let theme = AppTheme()
+        theme.currentSkin = .wmp
+        XCTAssertTrue(theme.useDarkMode)
+        XCTAssertEqual(theme.cornerRadius, 8)
+    }
+
+    func testSkinCount() {
+        XCTAssertEqual(AppTheme.Skin.allCases.count, 7)
+    }
+
+    func testSkinPersistence() {
+        let theme = AppTheme()
+        theme.currentSkin = .foobarLight
+        XCTAssertEqual(UserDefaults.standard.string(forKey: "appThemeSkin"), "foobar Light")
+        theme.currentSkin = .winamp
+        XCTAssertEqual(UserDefaults.standard.string(forKey: "appThemeSkin"), "Winamp")
+    }
+}
+
+// MARK: - Color Extension Tests
+
+final class ColorTests: XCTestCase {
+
+    func testValidHexColors() {
+        XCTAssertNotNil(Color(hex: "#FF0000"))
+        XCTAssertNotNil(Color(hex: "00FF00"))
+        XCTAssertNotNil(Color(hex: "#2196F3"))
+    }
+
+    func testInvalidHexColors() {
+        XCTAssertNil(Color(hex: ""))
+        XCTAssertNil(Color(hex: "XYZ"))
+        XCTAssertNil(Color(hex: "#12345"))  // 5 chars
+    }
+}
+
+// MARK: - Helper
+
+extension JSONDecoder {
+    func apply(_ configure: (JSONDecoder) -> Void) -> JSONDecoder {
+        configure(self)
+        return self
+    }
+}

Неке датотеке нису приказане због велике количине промена