You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: .claude/CLAUDE.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -298,6 +298,8 @@ Presenters subscribe to Interactors via Combine. Interactors access UseCases via
298
298
299
299
**Spectrum 120 Hz integral-decay retune (#306)**: real-hardware verification of #299 found the leaky-integral term still drained faster in wall-clock time at 120 Hz than at 60 Hz, despite the framerate-independence framework. Root cause: the integral's per-frame decay used `reduction / integralMod` where `integralMod = framerateMod^0.1` — an empirically-tuned cava approximation, not a wall-clock-exact derivation. Replaced with `reduction ^ integralExponent` where `integralExponent = 60 / fps`, which is exactly invariant (`(reduction^(60/fps))^fps == reduction^60` for any fps) and reduces to exponent `1` at 60 fps — the exact pre-#299 hardcoded-60fps decay, with no divisor at all. The gravity/fall term (`gravityScale`) was left unchanged; it was already the well-compensated half of the pair. Empirically confirmed post-fix: heights sampled at the same wall-clock instant now agree to ~1e-6 across 60/120/240 Hz (`SpectrumPresenterTests.decayIsWallClockInvariantAcrossFramerates`). Note the 60 Hz decay value itself shifted slightly (`reduction / 1.1^0.1 ≈ 0.7627` → exactly `reduction = 0.77`, ~1% difference) — an intentional correction, not a regression; visually indistinguishable. **PR review follow-up**: a decay-only fix leaves the integral's *input* term unscaled — a sustained (non-falling) `value` still gets added once per frame with no frame-rate compensation, so at higher fps more additions land per wall-clock second and the sustained steady state (`value / (1 - reduction)`, cava's documented ≈4× at 0.77) overshoots further the higher the refresh rate (120 Hz reached ≈8× instead of ≈4× — worse than the pre-#306 approximation's ≈30% overshoot). `tick()` now precomputes `integralInputScale = (1 - integralDecay) / (1 - reduction)` once per frame (also fixing a Copilot-flagged per-bar `pow` call) and `stepped()` applies it as `mem*integralDecay + value*integralInputScale`, which is the scaling that makes the sustained steady state itself, not just the post-release decay, wall-clock invariant — verified in `SpectrumPresenterTests.sustainedInputIsWallClockInvariantAcrossFramerates`.
300
300
301
+
**Spectrum capture retry on tap-creation failure (#312)**: `SpectrumInteractorImpl`'s single for-await loop dedupes the now-playing helper's periodic identical ticks via `previous` (a live tap must not be needlessly rebuilt). The bug: `previous` was settled *before* `spectrum.startCapture(pid:)`'s result was known, so a transient tap-creation failure right after an app switch — the new pid not yet in `kAudioHardwarePropertyProcessObjectList` (empty process list → `ProcessTapEngine.init?` returns nil), or HAL resource contention because the old tap's synchronous `stop()` teardown wasn't fully drained by coreaudiod — became **permanent**: every subsequent identical tick was swallowed by `guard source != previous`, so the spectrum stayed dead until a daemon restart. Fix: `previous` is still set to the attempted source, but after a failed start a bounded retry budget (`failedAttempts`, counted per source) deliberately lets the next identical tick back through the `source != previous` dedup and re-attempts — **up to `maxCaptureAttempts` (3) consecutive attempts per source**, then it gives up until the source changes, so a *permanent* denial (pre-14.4 OS, TCC) can't hit CoreAudio / log every tick forever. Retry rides the helper's tick cadence rather than an in-loop backoff sleep on purpose: it keeps the single-consumer serialization intact (a sleep would delay a genuinely-new event) and lets a real source change preempt the retry (a new `AudioSourceState` resets the counter). Each failure is logged to stderr (`lyra: spectrum: startCapture(pid: N) failed (attempt k/3); …`), matching the #318 stderr convention — the interactor/tap chain was previously fully silent. Verified by `SpectrumInteractorImplTests.failedCaptureRetriesOnNextTick` and `.failedCaptureGivesUpAfterBoundedRetries`.
302
+
301
303
**AI processing indicator (#57)**: While the AI (LLM) extractor resolves title/artist on a cache miss, the header scrambles in a configurable color so the user sees that work is happening. `TrackInteractorImpl.resolveTrack` emits an extra `TrackUpdate(aiResolving: true)` after the debounce only when an `[ai]` endpoint is configured **and**`MetadataUseCase.isAIMetadataCached(track:)` returns `false` (an LLM cache hit means no API round-trip, so no indicator). `HeaderPresenter` maps `aiResolving` to `DecodeEffectState.startLoading` (the indefinite scramble, distinct from `decode`'s settle) and swaps `titleColor` / `artistColor` to `DecodeEffect.processingColor` (default green `#4ADE80FF`, config key `text.decode_effect.processing_color`, solid or gradient). The resolved (non-`aiResolving`) update settles the scramble and restores the normal color. `HeaderView` reads the effective `titleColor` / `artistColor` (`@Published`) rather than the static `titleStyle.color`.
302
304
303
305
**Confidence-based metadata+lyrics resolution (#308)**: `MetadataRepositoryImpl.resolve()` no longer short-circuits when the LLM cache/DataSource succeeds — LLM, MusicBrainz, and Regex are always all queried (cache-or-datasource per source) and merged (`llmCandidates + mbCandidates + regexCandidates + [track]`), so a bad LLM guess can no longer permanently starve the other sources from ever being tried. `LyricsRepositoryImpl.fetchLyrics(candidates:)` tries three tiers across *all* candidates before giving up — Tier A (`LyricsDataSource.get()`, LRCLIB's own exact match, trusted as-is), Tier B (`LyricsDataSource.search()` fuzzy match, now gated by the new `LyricsMatchValidator` — title similarity via normalized Levenshtein distance, plus duration tolerance when both sides have it), and Tier C (`customScriptLyricsDataSource`, a second `LyricsDataSource` DI key backed by `CustomScriptLyricsDataSourceImpl`, which shells out to a user-configured `[lyrics] fallback_command` argv array with a timeout, expanding `$LYRA_CONFIG_DIR`/`$LYRA_CACHE_DIR` placeholders (both `$VAR` and `${VAR}` forms; literal substitution, not shell interpolation) in every argv element *before* the absolute-executable guard so configs stay machine-portable, mirroring `YouTubeWallpaperDataSourceImpl`'s test-injectable `processRunner` pattern). Whichever tier validates first stores the result under *the matched candidate's* title/artist (fixing a latent bug where the cache was always keyed by `candidates.first` regardless of which candidate actually matched); `LyricsResult.trackName`/`artistName`/`withDisplay()` already carried enough shape to make the confirmed candidate's identity part of the cached lyrics entry itself, so no separate cross-repository "joint commit" coordinator was needed. When no tier validates, nothing is cached and `TrackInteractorImpl.resolveTrack(from:)` falls back to the raw (unprocessed) title/artist rather than an unvalidated candidate guess — a targeted 2-line fix, not a new orchestration layer. The same unvalidated-candidate-guess bug was independently found in `TrackHandlerImpl.infoWithLyrics` (the CLI `lyra track -l` path), which fell back to `candidates.first`'s title/artist on a lyrics miss instead of the raw `track.title`/`track.artist`; fixed identically. The cache **read** side of `LyricsRepositoryImpl.fetchLyrics(candidates:)` had the mirror bug — it checked only `candidates.first`'s cache key, so an entry written under a later-matched candidate (per the write-side fix above) was unreachable on a subsequent read; the read path now loops over every candidate's cache key before falling through to Tier A/B/C. **Review round 2 (cache coherence)**: (a) the MusicBrainz cache stores **all** candidate recordings per query, not just the first — `MetadataDataStore<[MusicBrainzMetadata]>`, with the v5 migration rebuilding `musicbrainz_cache` without its UNIQUE(query_title, query_artist) constraint (one row per candidate; write = delete-then-insert) — because a cache hit that truncated the candidate set made lyrics cached under a later candidate unreachable on replays; (b) candidate dedup keys on a `Hashable` (title, artist, duration) struct — duration participates because LRCLIB exact match and `LyricsMatchValidator` discriminate on it, and the struct removes the `\u{1}`-sentinel collision; (c) the lyrics cache **re-validates entries on read** so pre-#308 poisoned rows can't short-circuit the tiers (invalid entries are skipped and later overwritten by a validated match); (d) `displayAdjusted` fills title and artist independently (a Tier C script may omit `artist_name` — the matched candidate's artist is used, not the raw fallback); (e) `timeout_ms` clamps to a finite 1 ms…1 h window before `Int` conversion (`Int(Double)` traps on NaN/±inf/overflow); (f) `[ai]`/`[lyrics]` decode leniently at runtime but `ConfigDataSourceImpl.tryDecode()` (the healthcheck path) probes both sections strictly, so a malformed section fails validation instead of silently disabling the feature. **Live-device verification fix**: Tier C results were never cached — `GRDBLyricsDataStore.write` guarded on `result.id != nil`, and script results carry no LRCLIB id, so every replay of a script-resolved track silently re-paid the full cascade (all LRCLIB lookups + the script run). Id-less lyric-bearing results are now stored under a stable FNV-1a-derived **negative** synthetic id (genuine LRCLIB ids are positive, so the namespaces can never collide; the stable hash makes rewrites converge on one row), while id-less results with no lyrics content remain unstored.
0 commit comments