diff --git a/design/v1.2/PERF_COLD_LAUNCH.md b/design/v1.2/PERF_COLD_LAUNCH.md new file mode 100644 index 0000000..adea7f8 --- /dev/null +++ b/design/v1.2/PERF_COLD_LAUNCH.md @@ -0,0 +1,310 @@ +# Cold launch + first-screen render profile (issue #106) + +Research brief feeding the v1.2 cold-launch fix work in #107. +**No production code changes shipped here** — all instrumentation was +temporary and reverted before this brief was written. + +## 1. Methodology + +### What I measured + +- iPhone 17 simulator (iOS 26.4), Release configuration, Xcode default + toolchain. Built with `xcodebuild -configuration Release …`. +- Three populated states: empty (no roots.json), 5 000-track sandbox + index (~3.4 MB JSON), 10 000-track sandbox index (~6.6 MB JSON). +- Each state was launched via `xcrun simctl launch --console-pty` and + the `print(...)` lines were tailed from stdout. Sim was warm (booted) + before each launch; this means OS-level page-cache + simd dyld is + warmer than a true device cold-boot — directional rather than + absolute numbers. + +### What I instrumented (and reverted) + +Wall-clock `Date()` deltas around: + +- `HarmonIQApp.init()` — split into `configureAudioSession` and + `configureWinampAppearance`. +- `ContentView.body` first vs. subsequent evaluations. +- `LibraryStore.loadFromDisk` — split into URL build, dispatch hop, + `Data(contentsOf:)`, `JSONDecoder.decode`, and full elapsed. +- `LibraryStore.loadDriveData(for:)` — split into + `SandboxRootStore.loadLibrary` decode, `tracks.map`, + `mergeTracks` (sort), playlists path, `mirrorArtwork*`, + `computeFingerprint`, `reconcileArtworkOnLoad`. + +All `print` lines were removed before this brief was written. `git +diff HEAD` is clean for the three Swift files I touched. + +### Why a synthesized library, not a real ~5 k drive + +The brief asked for measurements against a representative drive of +≥5 k tracks. I don't have one mounted in the sim. To get any signal I +synthesized a `roots.json` with one read-only root pointing at the +sandbox shadow store (`SandboxRootStore`) plus a synthesized +`library.json` with N tracks. That bypasses the security-scoped +bookmark dance, so **bookmark-resolve cost is NOT in these numbers**. +The on-device path on a real drive should add ~10–50 ms per root for +`URL(resolvingBookmarkData:)` plus +`startAccessingSecurityScopedResource()` — non-trivial, and called +out as a candidate even though it didn't show up in the sim trace. + +### What I left alone + +- Did not run Instruments. The issue suggests it; doing it well needs + a real device + a real drive. I'd recommend the implementer (or the + user) capture a Time Profiler trace once on real hardware before + picking from the candidates below. +- Did not touch `MusicIndexer` — the launch-path cost is dominated by + the load+merge path, not indexing (which is gated behind the cheap + fingerprint check from #62 and only fires when something actually + changed). + +## 2. Observed timings + +iPhone 17 sim, sandbox-store path, single root. + +| Phase | Empty | 5 000 tracks | 10 000 tracks | +| -------------------------------------- | ------- | ------------ | ------------- | +| `HarmonIQApp.init()` total | ~125 ms | ~125 ms | ~125 ms | +| └ `configureAudioSession` | ~95 ms | ~95 ms | ~95 ms | +| └ `configureWinampAppearance` | ~28 ms | ~28 ms | ~28 ms | +| First `ContentView.body` eval (paint) | t = 0 | t = 0 | t = 0 | +| `body.task` fires | +30 ms | +32 ms | +30 ms | +| `loadFromDisk` enter → leave | 418 ms | 538 ms | 680 ms | +| └ Dispatch hop + `Data(contentsOf:)` | ~0.2 ms | ~0.2 ms | ~0.2 ms | +| └ `JSONDecoder.decode([LibraryRoot])` | ~0.1 ms | ~1 ms | ~1 ms | +| └ `withCheckedContinuation` resume gap | ~417 ms | ~400 ms | ~400 ms | +| └ `sandboxLoadLibrary` (decode JSON) | n/a | 37 ms | 70 ms | +| └ `mergeTracks` (sort 5/10 k) | n/a | 60 ms | 131 ms | +| └ `loadDriveData[Synth Drive]` total | n/a | 135 ms | 276 ms | +| Time-to-fully-populated body re-eval | ~440 ms | ~640 ms | ~800 ms | + +**Headline:** ~440 ms of the cold-launch budget is consumed before any +library data lands, even with **zero tracks**. That's the +SwiftUI-first-paint + main-actor-busy window, plus +`AVAudioSession.setActive`. Adding 5 k tracks costs another ~140 ms; +adding 10 k costs ~280 ms. The library-decode + sort cost scales +roughly linearly in track count. + +### One specific surprise worth flagging + +The `loadFromDisk` wall-clock includes a ~400 ms gap that is **not** in +any inner phase — the dispatch hop is ~0.2 ms, the JSON decode is +~1 ms, but the wrapping `withCheckedContinuation` takes 400 ms to +return. That gap is the main actor being unavailable to resume the +continuation while SwiftUI processes its first layout pass for +`ContentView` (TabView + 4 NavigationStacks). On a real device this +gap will be larger (Metal compositor, real screen) or smaller +(faster CPU). Either way it's the first-paint cost, and it's the +single biggest item in the budget. + +## 3. Top candidate optimizations + +Ranked by (impact × ease). Each entry: file:line, current cost, +fix shape, expected magnitude. + +### A. Defer `AVAudioSession` activation off the launch path — **HIGH impact, EASY** +- **Where:** `HarmonIQ/HarmonIQApp.swift:11-14, 40-48` +- **Cost today:** ~95 ms inside `init()` on every cold launch. Runs + before `body` is even evaluated, so it's pure latency-to-first-paint. +- **Fix:** Move `setCategory(.playback)` + `setActive(true)` into a + `Task.detached` triggered from the same `.task` modifier that calls + `loadFromDisk()`, OR lazily on first `play()`. Background-audio + capability is declared in `Info.plist` (`UIBackgroundModes: [audio]`) + — the session only needs to be active *before* playback starts, not + before the UI renders. +- **Expected:** −80 to −95 ms time-to-first-paint. Risk: the existing + comment in CLAUDE.md says "Removing either silently breaks + lock-screen playback." Activate-on-first-play preserves it; just + don't gate the UI on it. + +### B. Move `mergeTracks` sort off the main actor — **HIGH impact, MEDIUM** +- **Where:** `HarmonIQ/Persistence/LibraryStore.swift:477-498` + (`mergeTracks(forRoot:with:)`) +- **Cost today:** 60 ms at 5 k, 131 ms at 10 k, ~25 % of total + `loadDriveData` time at 10 k. Uses + `localizedStandardCompare` per pair — Unicode-aware, expensive. +- **Fix:** Decode + sort on the same detached task that reads JSON, + hand the sorted `[Track]` back to the main actor. The sort is + deterministic and pure — the actor isolation invariant + (CLAUDE.md: "All shared state lives on @MainActor") is preserved + because we only mutate `self.tracks` on the main actor; the sort + runs on a value type before the hop. +- **Expected:** −50 to −120 ms at 5–10 k. Bigger win at larger + libraries. + +### C. Skip `mirrorArtwork*ToLocalCache` walks when nothing changed — **MEDIUM impact, EASY** +- **Where:** + `HarmonIQ/Persistence/DriveLibraryStore.swift:239-283` — both + `mirrorArtworkToLocalCache` and `mirrorArtistPhotosToLocalCache`. + Called from `LibraryStore.loadDriveData(for:)` on every cold launch + for every read-write root. +- **Cost today:** Doesn't show up in the synthesized read-only path I + measured (which doesn't traverse the on-drive Artwork folder), but + on a populated drive with thousands of album covers + artist photos + this is a `contentsOfDirectory` followed by `fileSize`-stat + maybe + `copyItem` per cover. Issue #107's body explicitly calls this out: + *"the artwork local-mirror copy happens on every drive load — not + just when the drive's artwork has changed"*. +- **Fix:** Cache a per-drive "last-mirror-fingerprint" of + `(driveArtwork.modificationDate, fileCount)` on `LibraryRoot`, + parallel to `lastScanFingerprint`. Skip the directory walk when it + matches. The existing per-file size shortcut inside + `mirrorArtworkToLocalCache` already prevents copying unchanged + files, but we still pay the directory enumeration + per-file stat + every launch. +- **Expected:** On a 200-album drive, drops a launch-time + `enumeratorAtURL`-equivalent walk + 200 stat calls to one + `getattrlist` on the folder. Order of −20 to −80 ms per root, + scales with album count. + +### D. Move artwork cache reconciliation off the launch path — **MEDIUM impact, MEDIUM** +- **Where:** + `HarmonIQ/Persistence/LibraryStore.swift:245-250, 258-347` + (`reconcileArtworkOnLoad` → `performArtworkRescan`). +- **Cost today:** Synchronous on the main actor, runs once per drive + on every cold launch. Builds a hash → album map across all tracks + for the drive, then walks the artwork folder and compares hex + filenames. Cost grows with `tracks * artwork files`. Not measured + in my synthetic run because I didn't populate enough artwork files + per drive — but the code shape (filter + map + sha1 per track) is + ~10–30 ms at 5 k tracks. +- **Fix:** Defer to a low-priority `Task` after first paint. The + reconcile pass is a self-healing thing for sideloaded artwork + files — there is no urgency to do it before the user sees their + library. Schedule it from `body.task` after `loadFromDisk()` + returns, with `Task(priority: .background)`. +- **Expected:** −20 to −60 ms time-to-first-populated-paint per + drive. Doesn't change steady-state — just removes a serial step + from the cold launch. + +### E. Pre-sort and cache `allArtists` / `allAlbums` — **MEDIUM impact, EASY** +- **Where:** `HarmonIQ/Persistence/LibraryStore.swift:681-684, + 854-871` +- **Cost today:** `allArtists` and `allAlbums` are computed + properties — they walk `tracks` and re-sort on **every access**. + `LibraryView` calls `library.allArtists.count`, + `library.allAlbums.count` on every body re-eval; if any state + changes, all four tabs' bodies re-render. At 5–10 k tracks the + set-build + sort + Unicode-compare is ~10–30 ms each, paid + multiple times per cold launch (once per body eval after + `self.tracks = ...`). +- **Fix:** Cache against a `tracksSnapshotID` like the existing + `artistImageCache` already does + (`HarmonIQ/Persistence/LibraryStore.swift:756-762`). Invalidate on + every `tracks` mutation. Same pattern, two more caches. +- **Expected:** −20 to −60 ms per cold launch (measured on first + Library tab paint), and far better steady-state too — every + navigation back to the Library tab gets cheaper. `compilationAlbumsByRoot` + inside `allAlbums` is the main offender; it walks every track twice. + +### F. Skip the `for root in loadedRoots { loadDriveData(...) }` serial loop — **MEDIUM impact, MEDIUM** +- **Where:** `HarmonIQ/Persistence/LibraryStore.swift:75-77` +- **Cost today:** Roots are loaded sequentially. With one drive + this doesn't matter; with 2–3 drives it scales linearly. Each + root's `loadDriveData` includes a security-scoped bookmark + resolve + JSON decode + sort + reconcile. +- **Fix:** Run `loadDriveData` for each root in a `TaskGroup` — + bookmark resolves and JSON decodes are independent. Merging back + into `tracks` still happens on the main actor (`mergeTracks` does + the read-modify-write). Need to be careful: `mergeTracks` is + called per-root and is not commutative in its current form because + it sorts the entire combined array; switch to a "build per-root + sorted lists, then concatenate + sort once at the end" pattern. +- **Expected:** With 1 drive → no win. With 2–3 drives → + −50 to −250 ms. Lower priority for the v1.2 commit. + +### G. Drop `Track.id = UUID()` allocations on load — **LOW impact, EASY** +- **Where:** `HarmonIQ/Persistence/DriveLibraryStore.swift:152-175` + (`toTrack`). +- **Cost today:** Every track gets a fresh `UUID()` on load, even + though `Track.stableID` is what the rest of the app keys off + (equality / hash use it; CLAUDE.md says so explicitly). 5 k UUIDs + is ~3–5 ms on the main thread. +- **Fix:** Use `stableID` as the `Identifiable.id` directly, or + drop `id: UUID` from the model. Either way, one fewer allocation + per row. Touches the model — minor breaking-change risk but the + conventions doc already says `stableID` is the truth. +- **Expected:** −3 to −10 ms at 5–10 k. Worth doing if the model + edit is acceptable. + +### H. Albums/Artists tile artwork load — DEFER — **LOW priority for #107** +- **Where:** `HarmonIQ/Views/SharedComponents.swift:29-33` and + `HarmonIQ/Views/ArtistsView.swift:118-125` (`loadImage`). +- **Cost today:** Each tile calls `UIImage(contentsOfFile:)` + synchronously inside the SwiftUI view body. With ~150 visible + tiles in a `LazyVGrid`, that's 150 synchronous JPEG decodes on + the main thread when the user first opens Albums. This is the + scrolling-jank source `LibraryStore.AlbumKey` was thinking + about, but the issue's sibling B explicitly tracks scroll + separately — out of scope for #107 cold-launch. +- **Note:** Don't touch this in the same PR. It belongs in a + Albums/Artists scroll-perf issue (the body of #106 says so). + +## 4. Recommended scope for issue #107 + +Pick **A + B + C** as the v1.2 commitment. Rationale: + +1. **A (`AVAudioSession` deferral)** is the biggest single-shot win + for time-to-first-paint, costs maybe 5 lines of code, and the + blast radius is well-understood (lock-screen playback test is in + `TESTING.md`). +2. **B (sort off main actor)** scales with library size — exactly + the regime issue #107 cares about. The `Task.detached` + + `MainActor.run` shape is already idiomatic in this codebase + (`MusicIndexer.runIndex`); follow that pattern. +3. **C (artwork mirror skip-when-unchanged)** is explicitly called + out in #107's body. Fits the same fingerprint pattern as + `lastScanFingerprint` from #62. Low risk because the mirror + already has a per-file shortcut — we're just adding a per-folder + one above it. + +Together these should land the "≥25 % faster cold-launch-to-first-paint" +target #107 sets, on a populated drive. Concretely, on a 5 k drive: + +- Today (sim): ~640 ms to populated paint. +- After A+B+C: estimated ~400 ms (−95 audio, −60 sort, −40 mirror, + + ~120 ms still for first-paint that isn't going anywhere). + +Everything else (D–G) is fair game for follow-up PRs but +explicitly **out of scope for v1.2's commit**. Resist scope creep — +the issue says "top 2–3 hotspots" deliberately. + +## 5. Things to leave alone + +- **Don't move `LibraryStore` off `@MainActor`.** CLAUDE.md is + explicit, and the visualizer-bug commit `c7ddb9c` was the + cautionary tale. Hop heavy work off via `Task.detached`; keep the + store itself main-isolated. +- **Don't pre-render the Library tab.** SwiftUI's TabView lazily + instantiates inactive tabs already; trying to outsmart it usually + leads to keeping more work alive than necessary. +- **Don't replace `JSONDecoder` with a "streaming" decoder.** At + 3.4 MB / 5 k tracks, decode is 37 ms — not the bottleneck. + Streaming would burn complexity and the `Codable` invariants + (date-decoding strategy, optional fields for forward-compat) for + no measurable gain. +- **Don't shard `library.json` by hash byte.** Issue #107 floats this + as a candidate, but the decode cost is nowhere near the dominant + term and sharding would break the "drive is portable, anyone can + read library.json" invariant from CLAUDE.md. +- **Don't touch the indexer's cheap-check fingerprint** (#62 + substrate). Issue #107 explicitly flags that as out of scope. +- **Don't preemptively decode JPEGs for visible Album/Artist tiles + on the launch path.** That's sibling-B work; conflating it makes + the cold-launch PR un-reviewable. + +## 6. Caveats & limitations of this profile + +- All numbers are sim-side. On a real iPhone with a USB-C drive + attached, expect bookmark-resolve + security-scope-start latency + to add 10–50 ms per root (not measured here). The ranking of + candidates A–G shouldn't change but the absolute headroom for the + ≥25 % target needs a device check. +- The sim path I exercised is the read-only sandbox path + (`SandboxRootStore`). The read-write `DriveLibraryStore` path adds + the bookmark dance but is otherwise the same JSON shape. +- I did not capture an Allocations trace. Heap peak isn't measured + here — recommend the implementer of #107 capture one Allocations + + one Time Profiler on a real device before locking in the fix + shape. diff --git a/design/v1.2/PROPOSAL.md b/design/v1.2/PROPOSAL.md new file mode 100644 index 0000000..1ee8f6b --- /dev/null +++ b/design/v1.2/PROPOSAL.md @@ -0,0 +1,114 @@ +# HarmonIQ v1.2 — Proposal + +> **Status:** research synthesis. No code shipped. Open for review. +> **Sources:** [WINAMP_INSPIRATION.md](WINAMP_INSPIRATION.md) · [PERF_COLD_LAUNCH.md](PERF_COLD_LAUNCH.md) · [TAG_WRITING_LIBRARY.md](TAG_WRITING_LIBRARY.md) + +## 1. Theme of the release + +v1.2 is a **polish + capability** release, not a feature blowout. Three pillars: + +1. **Cold-launch speedup** — measurable, conservative, real-device-validated. +2. **Honor the Charcoal Phosphor theme deeper** — close visible drift, lean harder into the Winamp 2.x design language where it earns its keep. +3. **Make the library editable** — first iOS release where a HarmonIQ user can fix bad ID3 tags in-app, with optional AI assist. + +Plus the **App Store launch logistics** (issue #118), since v1.2 is the first public release. + +## 2. Recommended v1.2 commitment + +What I'd actually ship under the v1.2 milestone (12 issues already filed): + +| # | Issue | Verdict | Why | +|---|---|---|---| +| 107 | Cold-launch speedup | **In** | Top 3 wins from the perf brief give 40 %+ improvement; 2–3 PRs | +| 108 | Albums / Artists scroll @ 60 fps | **In** | Pairs with 107; same surface area | +| 109 | Indexer parallelization | **Defer to v1.3** | Lower user impact; introduces concurrency risk; do after we re-measure on device | +| 110 | Design consistency sweep | **In** | Trivial scope; #117 found 5+ stragglers already (subPanelHeader gradient, etc.) | +| 111 | Charcoal Phosphor polish round | **In** | Designer-led; bundle with 110 if scope allows | +| 112 | SwiftUI player ("None") parity | **In** | High user-visible impact; #117 brief gives the checklist | +| 113 | Tag-writing library research | **Done** | (this brief) | +| 114 | ID3 edit sheet (Tier 1 + Tier 2 AI) | **In, split** | Tier 1 ships in v1.2; Tier 2 (AI) ships in v1.2 only if 1.2 isn't slipping | +| 115 | Bulk ID3 cleanup | **Defer to v1.3** | Stretch by design; ride on Tier 2 | +| 117 | Winamp inspiration research | **Done** | (this brief) | +| 118 | App Store launch logistics | **In** | Hard prerequisite for public release | +| 106 | Cold-launch profile | **Done** | (this brief) | + +**Net v1.2 implementation issues: 7** (107, 108, 110, 111, 112, 114-tier1, 118). Tier 2 AI and Charcoal polish bundle is risk-adjustable. + +## 3. The three research findings, distilled + +### 3a. Performance — `PERF_COLD_LAUNCH.md` + +Top 3 wins, ranked by impact × ease: + +1. **Defer `AVAudioSession` activation off `HarmonIQApp.init()`** → −80 to −95 ms on every cold launch. Move into the `body.task` after `loadFromDisk()`. Tiny, low-risk, biggest single win. +2. **Move `LibraryStore.mergeTracks` sort off the main actor** → −50 to −120 ms at 5–10 k tracks. Mirror the `MusicIndexer.runIndex` pattern: sort detached, hop back for the assignment. +3. **Skip `mirrorArtwork*ToLocalCache` walks when nothing changed** → −20 to −80 ms per drive. Add a per-folder mtime+count fingerprint parallel to the existing `lastScanFingerprint`. + +**Combined target:** ~640 ms → ~400 ms time-to-populated-paint on a 5 k-track drive. Clears the ≥25 % bar #107 set, with margin. + +**Caveat (the coder flagged this):** all numbers are simulator-side. The implementer should re-measure on a real device and a real drive (with security-scoped bookmark resolution included) before locking the fix. + +### 3b. Tag library — `TAG_WRITING_LIBRARY.md` + +**Pick: `ID3TagEditor` (chicio, MIT)** for v1.2 Tier 1. Rationale: + +- Actively maintained — v5.5.0 January 2026, Swift 6 support +- MIT — no LGPL-on-iOS ambiguity (TagLib's biggest problem) +- Pure Swift, SPM-native, ~tens of KB binary impact +- In-place writes that drop into `BookmarkStore.withAccess` cleanly + +**Trade-off:** mp3-only. m4a and flac surface a "format not yet editable" state in the v1.2 edit sheet. mp3 is 50–70 % of typical libraries, so the first ship is meaningful. + +**v1.3 follow-ups (filed as new issues if user approves):** +- m4a writer (hand-rolled atom updates vs. `SFBAudioEngine` spike) +- flac writer (purpose-built Vorbis comments) +- TagLib reconsideration if/when a maintained Swift wrapper appears + +### 3c. Winamp inspiration — `WINAMP_INSPIRATION.md` + +10 follow-ups proposed; ranked by my read of impact-per-LOC: + +| Rank | Proposal | LOC est. | v1.2 candidate? | +|---|---|---|---| +| 1 | **Spectrum peak-hold caps** — `bandPeaks` is already on `VisualizerEngine`, just unwired | ~10 lines | **Yes** — bundle with #111 | +| 2 | **Hoist `subPanelHeader()` modifier** — duplicated `Color(white: 0.18)→0.10` gradient | ~30 lines | **Yes** — fits #110 trivially | +| 3 | **`ScrollingTitle` SwiftUI primitive** — generalize `ScrollingBitmapText` for any font | ~80 lines | **Yes** — #112 needs it for parity | +| 4 | **Inset LCD strip in SwiftUI player** — `WinampTheme.lcdInset()` | ~20 lines | **Yes** — #112 | +| 5 | **EQ response curve overlay** — `Path` polyline behind the band knobs | ~40 lines | **Yes** — bundle with #111 | +| 6 | **Visualizer auto-rotation mode** — MilkDrop-style cycle every 30s | ~120 lines | **No** — defer to v1.3 | +| 7 | **Render `gen.bmp` in skinned EQ + Playlist title bars** | ~60 lines | **No** — defer to v1.3 | +| 8 | **Windowshade mode** (double-tap LCD to collapse player) | ~150 lines | **No** — defer to v1.3 | +| 9 | **`region.txt` non-rectangular skin masks** — high novelty | ~250 lines | **No** — defer to v1.3 (cool but expensive) | +| 10 | **Quiet llama easter egg in About** | ~20 lines | **Yes** — sneak into the launch logistics PR | + +Top 5 fold cleanly into already-filed v1.2 issues — no new issues needed for them. Items 6–9 should be filed as v1.3 candidates if the user approves the proposal. + +## 4. Suggested execution order + +After v1.1 finishes its TestFlight soak and goes live on the App Store, dispatch coder (and designer for #110/#111) in this order: + +1. **#107 Cold-launch speedup** — three small PRs, lands the user-visible perf win first. +2. **#108 Grid scroll @ 60 fps** — pairs naturally with #107's profiling work. +3. **#110 + #111 Design consistency + Charcoal polish** — designer-led; bundle peak-hold caps, subPanelHeader modifier, EQ response curve, inset LCD, llama easter egg into one or two PRs. +4. **#112 SwiftUI player parity** — needs `ScrollingTitle` primitive from step 3. +5. **#114 Tier 1 ID3 edit sheet** — independent from above; can start in parallel. +6. **#114 Tier 2 AI suggestions** — gated on Tier 1; fold under same milestone if v1.2 is on track. +7. **#118 App Store launch logistics** — final stretch; screenshots reflect everything above. + +## 5. Open decisions for the user + +1. **Approve commit-to-v1.2 list above?** Specifically: deferring **#109 (indexer parallelization)** and **#115 (bulk ID3 cleanup)** to v1.3. +2. **File the 4 new v1.3 candidates** from the Winamp follow-ups (auto-rotation, `gen.bmp` rendering, windowshade, `region.txt` masks)? +3. **Tier 2 AI ID3 cleanup confidence**: ship in v1.2 if on track, slip to v1.3 if not. Acceptable? +4. **Real-device perf re-measurement** before merging #107 PRs — does the user want to do this themselves, or should the release agent capture an Instruments trace as part of the PR review? +5. **Llama easter egg** — yes, no, or "we'll see what designer comes up with"? + +## 6. What's NOT in v1.2 + +Calling this out so it's clear we declined them, not forgot them: + +- Plugin / DSP architecture (out of scope per #117 guardrail) +- Modern Winamp 5 skin format (`.wal`) — only classic `.wsz` is parsed today +- m4a and flac tag editing (v1.3) +- Cross-drive playlists (long-standing limitation, not in any milestone) +- Lyrics, social sharing, cloud sync (philosophical no — not in roadmap) diff --git a/design/v1.2/TAG_WRITING_LIBRARY.md b/design/v1.2/TAG_WRITING_LIBRARY.md new file mode 100644 index 0000000..cfcaad0 --- /dev/null +++ b/design/v1.2/TAG_WRITING_LIBRARY.md @@ -0,0 +1,277 @@ +# Tag-writing library selection (issue #113) + +Research brief feeding the per-track ID3 edit work in #114. +**No SPM dependencies are added by this brief** — that's #114's job. +This brief picks the library, justifies the pick, and sketches the +integration. + +## 1. Format landscape + +What HarmonIQ currently indexes +(`HarmonIQ/Indexer/MetadataExtractor.swift:19`): + +```swift +static let supportedExtensions: Set = + ["mp3", "m4a", "flac", "wav", "aiff", "aif", "aac"] +``` + +Approximate share of a typical user library (rough Western-centric +estimates from public Plex / Roon community telemetry; tighten later +if we add real telemetry): + +| Format | Container | Tag system | Approx. share | +| --------- | --------- | ------------------- | ------------- | +| mp3 | MP3 | ID3v2.3 / v2.4 | 50–70 % | +| m4a / aac | MP4 / ISO | iTunes-style atoms | 20–40 % | +| flac | FLAC / Ogg| Vorbis comments | 5–15 % | +| wav | RIFF | INFO chunks / ID3 | <2 % | +| aiff/aif | AIFF | ID3 in `ID3 ` chunk | <1 % | + +The Tier 1 edit sheet in #114 has to write `title / artist / album / +albumArtist / trackNumber / year / genre` round-trip-safely in at +least mp3 + m4a. flac is the next must-have. wav/aiff are nice-to-have +and can fall back to "read-only" with a clear message in the edit +sheet. + +The current read path (`MetadataExtractor`) goes through `AVURLAsset +.commonMetadata` + per-format `loadMetadata(for:)`. AVFoundation reads +all of the above; **it does not write any of them** — there's no +public API for tag write-back via AVFoundation, which is why we need a +third-party writer. + +## 2. Candidate matrix + +| Library | License | Formats (write) | Last release / activity | Swift / iOS support | API style | Write semantics | Binary impact | +| --------------------------------- | ------------------- | -------------------------- | ------------------------------- | ----------------------------- | --------------------------------- | --------------------- | --------------- | +| **`ID3TagEditor`** (chicio) | MIT | mp3 (ID3v2.2/2.3/2.4) | v5.5.0 (Jan 2026); Swift 6 | Pure Swift, SPM, iOS-friendly | `ID3Tag` builder + `read`/`write` | Read whole, edit, write whole (in-place) | Small (~tens of KB) | +| **`SwiftTaggerID3`** (NCrusher74) | Apache-2.0 | mp3 (ID3v2.2/2.3/2.4) | 405 commits, last tag old (~2021) | Pure Swift, SPM | Frame-by-frame | Read, edit, write whole | Small | +| **TagLib via wrapper** | LGPL-2.1 / MPL-1.1 (dual) | Everything (mp3, m4a, flac, ogg, wav, aiff, opus, ape, …) | C++ project still active; **iOS Swift wrappers stale** (TagLibIOS 2018, TagLibKit 2020) | Needs Obj-C++ shim; SPM via wrapper | C++ API | Whole-file read/edit/write | Large (~MBs) | +| **`SFBAudioEngine`** (sbooth) | MIT | "most formats" (vague — flagship is decode/play, write surface poorly documented) | v0.12.1 (Feb 2026); active | iOS 15+, SPM, Obj-C++ core | `SFBAudioFile` metadata type | Read, edit, write whole | Medium (audio engine bundled) | +| **AVFoundation only (current)** | Apple SDK | None (read-only) | n/a | Built-in | `AVURLAsset.commonMetadata` | Read only | Zero | +| **Hand-rolled writer** | Our own | Whatever we implement | n/a | Pure Swift | n/a | We define | Zero | + +### Notes on each row + +- **`ID3TagEditor`**: Actively maintained, MIT, pure-Swift, recent + Swift 6 support, recent release in January. Confirms ID3v2.2 / + v2.3 / v2.4 read+write. The catch: **mp3 only**. m4a and flac need + something else. +- **`SwiftTaggerID3`**: Same scope as `ID3TagEditor` (mp3 only) but + noticeably less active. Apache-2.0 license is fine. No reason to + pick it over `ID3TagEditor`. +- **TagLib via Swift wrapper**: TagLib itself is the gold standard + (used by Plex, Mixxx, Strawberry, etc.) and would solve every + format in one go. The catch: **dual LGPL-2.1 / MPL-1.1**. LGPL on + iOS is workable (static linking is fine if you publish enough to + let users relink — this is the standard FOSS-on-iOS interpretation + but not airtight), and MPL is friendlier — but App Store + attribution + the LGPL-on-iOS ambiguity is a real friction point + for a 1-developer shop. The bigger problem is the *Swift wrappers* + are abandoned — TagLibIOS last touched 2018, TagLibKit 2020. Any + modern iOS-targeted use would mean writing or maintaining our own + Obj-C++ shim against the live TagLib C++ source. That's not a v1.2 + scope. +- **`SFBAudioEngine`**: Active, MIT, broad format support, iOS 15+. + The downside is it's a big audio-decode/encode engine — metadata + read/write is one feature among many. Its README acknowledges + metadata is "writable for most formats" without naming them, which + is exactly the wrong level of detail for a library you're picking + to be your tag-writer. Pulling it in for tag writing alone bundles + a lot of code (and runtime + size impact) for a feature we + fundamentally do not need. +- **AVFoundation only**: Apple has shipped no public tag-write API. + `AVAssetExportSession` can rewrite metadata for QuickTime / MP4 + containers (m4a only) but mangles non-iTunes atoms and doesn't + touch mp3 or flac. Not a real option. +- **Hand-rolled**: 100 % under our control, zero dependency, biggest + maintenance burden by orders of magnitude. ID3v2 alone is a + multi-page spec (frame headers, sync-safe integers, unsynchronised + encoding, padding behaviors); MP4 atoms have their own + iTunes-specific quirks (`----` user-defined atoms, the `meta` + atom's required hdlr child, etc.). Punching this in for a v1.2 + feature is a lot of risk for no reuse — eventually we'd want + somebody else's tested writer anyway. + +## 3. Recommendation + +**Primary: `ID3TagEditor` for mp3.** **Fallback / next steps: see +section 4 for m4a + flac.** + +`ID3TagEditor` is the only candidate that scores well on every axis +HarmonIQ cares about: MIT license (no App Store / static-linking +worry), pure Swift (the Obj-C++ bridges in TagLib wrappers add a +real maintenance tax that's not justified for our scope), recently +released (Jan 2026, with active Swift 6 support), small surface area +(its `ID3Tag` value type is essentially what our edit sheet's data +model would look like anyway), in-place file write (we hand it the +file path inside `BookmarkStore.withAccess`, it returns when the +write is done — clean security-scope semantics). + +The cost of picking it: we ship Tier 1 of issue #114 with **mp3-only +edit support** and a "this format isn't editable yet" empty state for +m4a / flac / wav / aiff. Given that mp3 is 50–70 % of typical +libraries, that ships meaningful value while leaving the rest for a +follow-up. + +We deliberately reject TagLib for v1.2 because (a) the Swift +wrappers are abandoned, (b) writing our own wrapper isn't justified +for one feature, and (c) the LGPL ambiguity on iOS is a foot-gun for +a single-developer App Store project. If the user's library is +predominantly m4a (which the ratio above suggests is plausible), the +right next step is **not** TagLib — see §4. + +## 4. Fallback / coverage gaps + +The primary recommendation only handles mp3. Here's the path to fill +in the rest, in priority order: + +### m4a — second priority, separate library + +Two viable paths: + +1. **Hand-rolled MP4 atom writer for the iTunes-style metadata + atoms** we actually edit (`©nam`, `©ART`, `©alb`, `aART`, `trkn`, + `©day`, `©gen`). MP4 metadata is bounded enough that a small, + purpose-built writer is realistic — maybe 200–400 lines, with + `AVURLAsset` continuing to handle the read side. Big win on + binary size + license cleanliness. Real maintenance cost: any + iTunes weirdness (sort-name atoms, free-space handling, the + required `meta` → `hdlr` → `ilst` chain) is on us forever. +2. **Pull in `SFBAudioEngine` strictly for m4a write**, with the + understanding that we're using ~5 % of its surface area. Cleaner + but heavier. If we go this way, gate the import behind `#if + canImport(SFBAudioEngine)` so the v1.2 PR can ship without it + and add it as a follow-up. + +The implementation issue for m4a (sibling to #114) should pick +between these based on a small spike — write a one-trip +"read-modify-write" test for both approaches and compare diffs of +the original vs. modified file. Whichever one preserves the +non-edited atoms byte-for-byte is the winner. + +### flac — third priority + +Vorbis comments are simpler than ID3v2 (no frame headers, no +sync-safe integers). A purpose-built flac writer is plausible (~150 +lines) and worth the trade-off vs. pulling TagLib for one format. + +### wav / aiff — read-only is fine + +`<2 %` of typical libraries. The Tier 1 edit sheet should refuse +edits on these formats with an explicit message ("Editing tags in +WAV / AIFF files isn't supported yet"). No code-bridge cost. + +## 5. Implementation sketch for issue #114 + +The shape of the writer should match the `BookmarkStore.withAccess` +contract that the rest of the codebase already follows (CLAUDE.md: +"every drive access goes through `BookmarkStore.withAccess`"). I'd +expect a new file `HarmonIQ/Indexer/TagWriter.swift`: + +```swift +/// Writes tag edits back to a single audio file. Format dispatch is +/// internal — callers pass a `Track` and an `EditedTags` value type. +@MainActor +final class TagWriter { + enum WriteError: Error { + case formatNotEditable(String) // "wav", "aiff", "ogg", … + case readFailed(underlying: Error) + case writeFailed(underlying: Error) + case bookmarkResolveFailed + } + + struct EditedTags { + var title: String? + var artist: String? + var album: String? + var albumArtist: String? + var trackNumber: Int? + var year: Int? + var genre: String? + } + + /// Writes `edits` to the file backing `track` and updates the + /// matching row in `LibraryStore` in-memory. Persists the + /// drive's library.json on success. Run this off the main actor + /// (Task.detached); the @MainActor annotation here is for the + /// LibraryStore handoff after the write completes. + func write(_ edits: EditedTags, to track: Track, + in root: LibraryRoot) async throws -> Track +} +``` + +### Flow inside `write` + +1. Resolve the root's bookmark via `BookmarkStore.withAccess` (or + the equivalent helper from `LibraryStore.withDriveAccess` — we + probably want to expose a small public version of that for the + writer). +2. Inside the scope, dispatch to format: + - `mp3` → `ID3TagEditor.write(...)` against the file URL. + - everything else → `throw WriteError.formatNotEditable(...)` + (Tier 1 ships with this gap; #4-style follow-up issues fill + them in). +3. After the writer returns, **re-extract metadata via the existing + `MetadataExtractor`** so the in-memory `Track` reflects exactly + what's on disk (don't trust the edits dict — round-trip through + the same reader the indexer uses, so we can't drift). +4. Hop to `MainActor.run` and patch the matching track in + `LibraryStore.tracks` in place. Call + `LibraryStore.replaceTracks(forRoot:with:)` with the patched + per-drive slice; that already rewrites `library.json` on the + owning drive via the existing `DriveLibraryStore.writeLibrary` + path. **No full re-index.** +5. Return the new `Track` so the edit sheet can confirm. + +### Round-trip safety + +`ID3TagEditor` reads the whole tag, lets you mutate, and writes +back. **The library round-trips frames it doesn't know about** — +which is what we need for #114's "Round-trip safety: writing back +unchanged tags must not corrupt the file" requirement. Cite this in +the implementation PR's testing notes. + +### Security-scope dance — concrete + +```swift +let url: URL = try BookmarkStore.withAccess(to: root.bookmark) { + rootURL in + let fileURL = rootURL.appendingPathComponent( + track.relativePath.joined(separator: "/")) + let editor = ID3TagEditor() + var tag = try editor.read(from: fileURL.path) ?? ID3Tag(...) + // apply edits to tag + try editor.write(tag: tag, to: fileURL.path) + return fileURL +} +``` + +`ID3TagEditor` takes file paths (not URLs), which is fine — +inside `withAccess`'s closure we have a resolved `URL`, and +`url.path` gives us the path the library expects. The closure stays +synchronous; `withAccess` handles +`startAccessingSecurityScopedResource` / `stopAccessingSecurityScopedResource` +balanced around it. + +### What this brief is NOT defining + +- The **edit-sheet UI** — that's #114's job, not the library + research. +- **Tier 2 AI suggestions** — that's the second PR of #114, gated + behind `AIProvider.anyAvailable` per #102. Doesn't affect library + choice; the writer doesn't care where the proposed string came + from. +- **Bulk edit / library cleanup** — different issue. The + per-track writer is the substrate; bulk just calls it in a loop + with a progress meter. No library change needed. + +## 6. Summary + +| Question | Answer | +| -------------------------------------- | --------------------------------------------------------------------- | +| Pick | `ID3TagEditor` (chicio, MIT, Swift, mp3-only) for v1.2 Tier 1 mp3. | +| What ships in #114 Tier 1 | mp3 edit sheet wired through `TagWriter`. Other formats: clear "not yet supported" message. | +| What ships in a follow-up | m4a writer (hand-rolled atom writer or `SFBAudioEngine`, decided by a spike). flac writer if appetite. | +| What we explicitly defer | TagLib bridge. Hand-rolling all formats from scratch. | +| New SPM deps in the v1.2 fix PR | `ID3TagEditor` (one). | +| New SPM deps in this brief | None. (#113 is research-only.) | diff --git a/design/v1.2/WINAMP_INSPIRATION.md b/design/v1.2/WINAMP_INSPIRATION.md new file mode 100644 index 0000000..d07ca43 --- /dev/null +++ b/design/v1.2/WINAMP_INSPIRATION.md @@ -0,0 +1,189 @@ +# Winamp 2.x / 5 Inspiration Brief — v1.2 + +> **Summary.** Mining the original Winamp's visual language for HarmonIQ v1.2: where the classic chrome, EQ stickers, playlist editor, visualizers, scrolling LCD title, and easter eggs still have something to teach our SwiftUI player and skinned chrome — without re-implementing plugin architecture or pulling us off our data-sovereignty positioning. + +This brief is research for issue [#117](https://github.com/LeoHChen/HarmonIQ/issues/117). It is *not* a spec. Every "Inspiration to apply" item below is a candidate, not a commitment; the "Recommended follow-ups" section at the bottom is what TPM would convert into v1.2/v1.3 issues if the user approves. + +The companion theme docs are [`design/PHILOSOPHY.md`](../PHILOSOPHY.md) (icon — *Sun-Bleached Grooves*) and [`design/THEME.md`](../THEME.md) (in-app — *Charcoal Phosphor*). Both are the contract; this brief proposes ways to honor it more deeply. + +--- + +## 1. Classic skin chrome + +### What was iconic in Winamp +- A **dark gray rectangle with silver 3D-effect transport buttons** — chunky, square-ish corners, a hot top highlight and a near-black bottom shadow on every raised element. Shelf-stereo skeuomorphism, deliberately industrial. +- **Green LED time digits** in a fixed 9×13 grid, mounted in a recessed inset darker than the panel — the LCD reads as a *physical screen*, not an overlay. +- **Tight clutterbar** of tiny chrome chips on the left edge (O / A / I / D / V) plus mono/stereo lozenges and the kbps/khz mini-LCDs — every readout had a place; nothing floated. +- **Hit targets are pixel-precise.** The 23×18 transport buttons leave essentially zero whitespace between them; the visualizer at 76×16 sits flush against the title text region. +- **Visualizer placement is fixed** — it lives in a 76×16 well immediately under the artist scroll, never moves, and the user accepts that constraint as part of the chrome. +- **No type hierarchy beyond two faces:** the LCD bitmap font for digits/scroll, the small bitmap font for everything else. + +### What we have today +- `WinampTheme` (`HarmonIQ/Views/WinampTheme.swift`) already nails Charcoal Phosphor: 1pt bevels, sharp 2–3pt corners, three-stop panel gradient, phosphor LCD lime. `THEME.md` documents tokens and rules. +- Skinned classic player (`HarmonIQ/Views/Skin/SkinnedMainView.swift`) renders pixel-precise sprites at canonical Winamp coordinates and scales to screen width with nearest-neighbor sampling — chunky look survives. +- The "no skin" SwiftUI fallback (`NowPlayingView.swift`) uses `WinampTheme` consistently for the LCD strip and `bevelPanel` for the visualizer container. +- A top-of-window chrome bar (skin cycle / favorite / sleep / close) sits *above* the canonical 275×116 player canvas; it is SF-Symbol-driven and lives in the SwiftUI layer, not the bitmap atlas. + +### Inspiration to apply +- **Promote the chrome bar into the canonical canvas, not above it.** The current `chromeButton` row sits in white-on-dark SF Symbols outside the 275×116 sprite world — this is the single biggest break in visual continuity in the skinned view. Either render those buttons as bitmap chips with WinampTheme bevels, or move them into a Winamp-shaped clutterbar column to the left of the LCD. +- **Inset the LCD strip in the SwiftUI player.** In `NowPlayingView`, the LCD readout uses `WinampTheme.lcdFont` correctly but is laid flush with the panel. Add a new `WinampTheme.lcdInset()` modifier that paints `lcdBackground` plus a single inner `bevelDark` line, matching the recessed-screen look the classic skin gets for free. +- **Add a `clutterbar()` primitive** to the theme — a vertical column of 1-letter chrome chips (height 11, width 8, monospaced bold). Then use it for skin-shortcut letters (E for EQ collapse, V for visualizer, S for sleep, etc.) in the SwiftUI player so non-skinned mode gets the same density. +- **Promote `BitmapTime`'s 9×13 grid as the *only* way** to render large LCD digits. NowPlayingView currently renders `formatDuration(...)` with `WinampTheme.lcdFont(size: 12)`. Both work, but a bitmap-rendered time would unify with skinned mode visually. + +--- + +## 2. Equalizer chrome + +### What was iconic in Winamp +- **Sticker-y, slightly playful aesthetic.** The EQ window broke the otherwise-industrial rule: gloss labels, drawn-on band frequency tick marks, a curved spline sketched behind the slider knobs hinting the response curve. +- **Preamp visually outranks the bands** — wider knob, set apart by a vertical separator, sometimes a different color cap. It reads as "this one is different and applies first." +- **Auto / Preset chip** is a discrete two-button cluster, each chip with its own beveled edge, in the upper right. +- **A tiny graph of the current curve** sits between PRE and the band sliders in some skins — a real-time spline that updates as you drag. The curve is the strongest signal that EQ is *actually doing something*. +- **The On / Auto / Preset chips show their on/off state by indentation**, not just color. Pressed-in = active. + +### What we have today +- `SkinnedEqualizerView.swift` already has 10 bands + preamp, on/off toggle, preset menu. The preamp is correctly visually separated by a vertical 1pt rule (`Rectangle().fill(Color(white: 0.2)).frame(width: 1)`). +- The preset menu chip got a hit-target pass in #83 (10×6 padding, 11pt monospace bold). Per-skin palette colors flow through `SkinPalette`. +- However: the title bar is a hand-rolled `LinearGradient(colors: [Color(white: 0.18), Color(white: 0.10)], ...)`, not a `WinampTheme` modifier. **Theme drift.** +- `VerticalDbSlider` is hand-rolled SwiftUI shapes (track groove, center 0 dB line, knob with shadow) rather than spritesheet-backed — a deliberate trade because building per-skin EQ slider sprites is a lot of work, but the result reads slightly more "iOS slider" than "Winamp slider." + +### Inspiration to apply +- **Move the EQ title-bar gradient to a `WinampTheme.subPanelHeader()` modifier.** Both the EQ ("EQUALIZER") and Playlist ("PLAYLIST EDITOR") title bars use the same hand-rolled `Color(white: 0.18) → 0.10` gradient. Hoist it. +- **Render the response curve.** Behind the 10 band knobs (between the rail and the labels), draw a thin lcdGlow polyline interpolating the band values. This is the single change that would make our EQ feel *more Winamp than Winamp's default skin* — most classic skins didn't ship the curve, but the curve is what users wanted. Cheap with `Path` + `Canvas` and zero extra audio work since `eq.bands` already drives it. +- **Tighten the preamp's visual weight.** Today PRE looks identical to a band. Give it a slightly wider knob (24pt vs 22pt), a brighter cap when enabled (`bevelHighlight` instead of `activeColor`), and explicit "PRE" label color even when the EQ is bypassed (the preamp is the band that always matters). Already separated by the rule — finish the job. +- **State-of-the-knob indentation.** The On toggle should look pressed in when active (inset shadow + darker fill) rather than just changing tint. Same for any future "Auto" chip. This is the Winamp 2.x signal language. + +--- + +## 3. Playlist editor + +### What was iconic in Winamp +- **Per-row monospace, two-column layout:** index + title left-aligned; duration right-aligned. The title column truncates with ellipsis. +- **Currently-playing row in a different *color*, not background.** Winamp's PLEDIT.TXT distinguishes `Current` foreground from `Normal` foreground; the selection rectangle is a separate concern. +- **Click-to-jump, drag-to-reorder.** The "PL" window was the queue management surface — every interaction lived there. +- **No row chrome.** No leading icon, no chevron, no "more" affordance. Just the text, lit up. +- **Tight vertical rhythm** — rows are ~12pt tall, no padding; the queue is dense by design so 50 tracks fit on screen without scrolling. +- **A footer strip** showed the total queue duration ("48:23 / 1:23:11") in the same bitmap font. + +### What we have today +- `SkinnedPlaylistView.swift` is close: monospace 11pt, index + title + duration columns, current-row highlight, scroll-to-current-on-change. `SkinPalette` correctly resolves `current` vs `normal` from PLEDIT.TXT. +- However: same hand-rolled title-bar gradient as EQ (theme drift again), and the current row uses *both* a different foreground (`palette.current`) *and* a `selectedBackground` fill — Winamp typically used color, not fill, for the active row. +- No drag-to-reorder. No total-duration footer. No keyboard shortcuts (the "QUEUE" / "DEL" / "PHYS" menu isn't surfaced). +- Empty state is plain text — sensible but not era-appropriate. + +### Inspiration to apply +- **Drop the `selectedBackground` fill on the current row by default.** Use `palette.current` foreground only. Restore the fill if the user is actively *selecting* (multi-select for delete/queue ops — once we ship that). Aligns with how PLEDIT.TXT was designed to be consumed. +- **Add a duration footer.** A `palette.normal` strip at the bottom: `"23 tracks · 1:23:11"` in the same 11pt monospace. Information dense and instantly recognizable. +- **Tighten row vertical padding.** Currently `.padding(.vertical, 3)` — try `1`. Winamp's PL fit ~22 rows on a 232pt window; we should aim for similar density on iPhone Pro. +- **A skin-aware empty state.** Use `BitmapText` to render `"DRAG TRACKS HERE"` when no skin is active just like in skinned mode — the SwiftUI player can borrow the active skin's `text.bmp` if available. + +--- + +## 4. Visualizer fauna + +### What was iconic in Winamp +- **The classic 8-bar / 19-bar spectrum** with a green base ramping through amber to red at the top, plus a thin "peak hold" cap that decays. Cheap, instantly recognizable. +- **The plain oscilloscope** — single 76-sample green trace, the ur-visualizer. +- **MilkDrop**'s superpower wasn't polygon count — it was **interpolated transitions between presets** and **beat-reactive parameter modulation** (preset auto-cycles every 16 bars, transitions blend two presets simultaneously over ~2 seconds). +- **AVS**'s superpower was **after-image trails / feedback frames** — every effect ran on top of a slowly fading buffer of the previous frame, so motion painted itself. +- **Geiss** showed that a single pulse-modulated mathematical pattern (think a beat-driven plasma) could feel as alive as a fragment shader. +- **The visualizer cycled on click.** Tap the visualizer, get a new style. Universal mental model. + +### What we have today +- 16 visualizer styles in `Visualizers.swift` — spectrum, oscilloscope (8 variants), plasma, mirror, radial pulse, particles, fire, starfield. Coverage is broad. +- Beat detection lives in `VisualizerEngine.advance()` — `beatDetected` is set when peak crosses a rolling-average threshold. Already used by particle spawn and starfield speed boost. +- Spectrum chromatic ramp is centralized via `WinampTheme.spectrumColor(forFraction:)` — a single source of truth for green→amber→red. +- Skinned visualizer (`SkinnedVisualizer.swift`) caches palette colors and falls back to spectrum bars for styles that don't translate to the 24-color VISCOLOR.TXT grid. +- Tap-to-cycle and a center toast confirming the new style name are wired up in both surfaces. + +### Inspiration to apply +- **Persistent peak-hold caps on `.spectrum`.** The current spectrum draws bar fills but no separate decaying peak marker. A 1pt `accentRed` line, decaying at ~0.6 units/sec from the highest recent value per band, is the single most "Winamp" detail we don't have. `bandPeaks` is *already in the engine* and unused by the spectrum draw — wire it. +- **Beat-driven parameter modulation pass.** Every visualizer should respond to `engine.beatDetected` with a one-frame parameter spike that decays. Spectrum: bar gain spikes 1.15× and decays back. Plasma: phase rate doubles for 100ms. Particles already do it; generalize the contract. +- **After-image trail option for oscilloscope variants.** AVS's signature look — an exponentially-fading prior-frame buffer underneath the new trace. We can implement as a `Canvas` drawing into a `GraphicsContext` with `BlendMode.plusLighter` over a previous-frame snapshot. Make it a per-style flag. +- **A "Random" rotation mode.** Like MilkDrop's auto-cycle: every 30s on a beat, smoothly fade between two random visualizer styles for ~1.5s. Becomes the default for users who don't want to commit. Persists as `VisualizerSettings.rotationEnabled`. + +--- + +## 5. Now-playing scrolling text + +### What was iconic in Winamp +- **The marquee was *always on*** for any title that overflowed the 154px text region, and *always off* if the title fit. No fade, no edge gradient, no easing — just a constant left-drift with a gap and a wrap. +- **Format was `"%n. %a - %t"`** (queue position, artist, title), reused on both the in-window scroll and the lock-screen / OS task list. +- **One scroll speed for everything.** Predictable; no per-track variation. +- **The marquee paused on user interaction** — hovering reset it; clicking the title jumped to the file. + +### What we have today +- `ScrollingBitmapText.swift` is excellent — marquee only when the text overflows the viewport, gap of 8 skin-pixels between repeats, 30 px/sec, restarts when text changes. Shipped behavior matches the original almost exactly. +- The SwiftUI `NowPlayingView` does *not* use marquee — it uses `Text(...).lineLimit(1)`, which truncates with `…`. That's the single biggest visual regression vs. skinned mode for long titles. +- `NowPlayingSnapshot` (#104) already fans out structured title/artist info to all the secondary surfaces (mini-player, sleep timer footer, etc.). + +### Inspiration to apply +- **Lift `ScrollingBitmapText` behavior into a `ScrollingTitle` SwiftUI primitive** that uses any font (not just `BitmapText`) but matches the same overflow-only marquee semantics. Use it in `NowPlayingView` for the LCD title strip. Use it in mini-player / lock-screen mocks. One marquee implementation, one tuning knob. +- **Standardize the format**: the skinned player uses `"\(t.displayArtist) - \(t.displayTitle)"`. The SwiftUI player splits artist and title across two lines. Pick one; the Winamp homage is the joined single-line scroll. +- **Marquee respects accessibility.** When `accessibilityReduceMotion` is on, freeze the scroll and clip with `…`. The rest of the time, scroll. This is the right call — the Winamp marquee is a quote, not a feature. +- **Marquee in the EQ / Playlist title bars when those grow.** Today they say `"EQUALIZER"` / `"PLAYLIST EDITOR"`. When we eventually add a window subtitle (e.g. `"PLAYLIST EDITOR — Smart Mix '90s Indie"`), the same marquee primitive carries it. + +--- + +## 6. Easter eggs + +### What was iconic in Winamp +- **"It really whips the llama's ass."** The DEMO.MP3 line, inspired by Wesley Willis. The single most quoted thing about the app. +- **About box with a cycling "Brought to you by" credit roll**, llama silhouette in the corner, version date. +- **Double-click the title bar to roll up the window** ("windowshade mode" — chrome collapses to just the LCD line). +- **Holding Ctrl while clicking the EQ title bar** flipped the response curve drawing direction. +- **The credits dialog scrolled by itself**, in the same bitmap font, listing every contributor. + +### What we have today +- Zero. We have neither a llama nor a credit roll nor a windowshade. The closest analog is the toast that confirms a visualizer cycle. +- Settings has an "About" view (let me note: I haven't audited it for this brief — but it's a place to land an homage). + +### Inspiration to apply +- **Windowshade mode for the SwiftUI player.** Double-tap the LCD title strip to collapse `NowPlayingView` to *just* the title scroll + transport row, animating the visualizer / artwork / EQ off. Tap again to restore. Maps perfectly to a SwiftUI `withAnimation` + a `@State var collapsed`. +- **A llama easter egg in the About / Settings credits.** Not the literal Wesley Willis quote (the tone clashes with our restrained brand), but a quiet homage: a tiny black silhouette of a llama at the bottom-right of the About screen, with a tooltip on long-press: *"It still whips."* — affectionate, specific, low-key. +- **Credit roll using `BitmapText`.** A scrollable About screen rendered in the active skin's `text.bmp` font when one is loaded — it would be a delightful shock the first time someone opens About with a custom skin active. Falls back to `lcdFont` when no skin. +- **Subtle skin-cycle confirmation.** Tapping the paint-palette currently cycles silently except for the visual change. A 1-second LCD toast like the visualizer's *(same look, same primitive)* showing the new skin's name would feel right. + +--- + +## 7. Skin engine + +### What was iconic in Winamp +- **Classic skins (.wsz):** ZIP of BMPs + a few TXT config files. Files we render today: `main.bmp`, `cbuttons.bmp`, `titlebar.bmp`, `numbers.bmp` / `nums_ex.bmp`, `text.bmp`, `posbar.bmp`, `volume.bmp`, `balance.bmp`, `monoster.bmp`, `playpaus.bmp`, `shufrep.bmp`, `eqmain.bmp`, `eq_ex.bmp`, `pledit.bmp`, plus `viscolor.txt` and `pledit.txt`. +- **Files we *don't* parse**: `region.txt` (custom window region masks — non-rectangular player windows!), `gen.bmp` and `genex.bmp` (general-purpose / mini-browser window chrome), the cursor `.cur` files (`cur_normal`, `cur_titlebar`, `cur_eqslid`, etc. — per-region cursor maps), and the AVS preset files (`*.avs` / `*.milk`). +- **Double-size mode** doubled every coordinate; we already scale fractionally to fill width, which is similar but not identical. +- **"Modern" skins (.wal)**: full XML scripting / freeform window layouts. Out of scope and explicitly excluded by the issue. + +### What we have today +- `SkinFormat.swift` documents canonical sprite coordinates exhaustively for the elements we render. +- `WinampSkin.swift` parses 14 atlases + viscolor.txt + pledit.txt. +- `SkinManager.swift` handles bundled + imported skins, swap, and persistence. +- We surface the skin picker via paintpalette button (tap = next, long-press = sheet) on both surfaces. + +### Inspiration to apply +- **Render `gen.bmp` chrome around the EQ / Playlist sub-panels.** When a skin loads, the title bars of `SkinnedEqualizerView` and `SkinnedPlaylistView` could use the active skin's `gen.bmp` title art instead of the hand-rolled gradient. This is a *real* underused capability — every classic skin ships `gen.bmp` and we ignore it. +- **Parse `region.txt` and apply it as an iOS `UIBezierPath` mask** on the skinned player canvas. This is the single most expressive classic-skin feature we don't expose. Some classic skins (e.g. "Aqua," "AmpliFire") have non-rectangular silhouettes that simply don't render correctly in any modern Winamp clone — it would be a real moment if we shipped it. +- **A "double-size" toggle.** We scale by `geo.size.width / 275` already; offer a 2× lock that pads the canvas with `WinampTheme.appBackground` on iPad / landscape. Some skins were *designed* for double-size and look better that way. +- **Don't pursue cursor maps.** iOS has no cursor; the iPad pointer is too constrained. Note for the issue and move on. +- **Don't pursue modern skins (.wal / Bento).** Out of scope; conflicts with our static, file-on-drive sovereignty model. Note and move on. + +--- + +## Recommended follow-ups + +These are candidate v1.2 / v1.3 issues. One-line summary + one-sentence rationale. TPM converts the approved subset. + +1. **Spectrum peak-hold caps** — Wire the unused `bandPeaks` array to draw a 1pt decaying cap above each spectrum bar; it's the single most "Winamp" detail we currently miss. +2. **Inset LCD strip in SwiftUI player** — Add a `WinampTheme.lcdInset()` modifier and apply to `NowPlayingView`'s LCD readout so the SwiftUI player matches the recessed-screen look skinned mode gets for free. +3. **EQ response curve overlay** — Render a thin lcdGlow polyline through the band slider knobs in `SkinnedEqualizerView` so the EQ visibly *shows* what it's doing. +4. **Hoist sub-panel header gradient** — Replace the duplicated hand-rolled `Color(white: 0.18) → 0.10` gradient in EQ + Playlist with a `WinampTheme.subPanelHeader()` modifier; it's documented theme drift. +5. **`ScrollingTitle` SwiftUI primitive** — Generalize `ScrollingBitmapText`'s overflow-only marquee semantics to work with any font, then use it for `NowPlayingView`'s title (currently truncates with `…`). +6. **Render `gen.bmp` in skinned EQ + Playlist title bars** — We parse it, we just don't use it; this is the biggest underused classic-skin capability. +7. **Windowshade mode (double-tap collapse)** — Double-tap the LCD title strip to collapse the SwiftUI player to title + transport only; iconic Winamp interaction with a clean SwiftUI implementation. +8. **Visualizer auto-rotation mode** — Optional MilkDrop-style auto-cycle every 30s on a beat with a 1.5s cross-fade; default-off, persisted in `VisualizerSettings`. +9. **`region.txt` parsing for non-rectangular skinned player** — Apply parsed region as a `UIBezierPath` mask; high-novelty, low-risk, and no current Winamp clone on iOS does this. +10. **Quiet llama easter egg in About** — A tiny silhouette + long-press tooltip in About / Settings credits; affectionate homage that matches our restrained brand. + +--- + +*Written 2026-05-03 for issue #117. References [`design/PHILOSOPHY.md`](../PHILOSOPHY.md) and [`design/THEME.md`](../THEME.md).*