fix(player): redesign cleanup pass — sleeve, tonearm, AGC, dead code

Production-readiness pass before merging the Studio Reference redesign
to master.

Audio (backend):
- Reset AGC `_spectrum_ref` envelope on `start()` so a long silent gap
  between sessions doesn't make the first new transients clip at the
  ceiling. Annotated the trade-off (loud transient lifts reference for
  a few seconds afterwards — the price of real loudness).
- Add `tests/test_audio_analyzer.py` with 10 cases: bin-edge layout,
  AGC attack/release asymmetry, lifecycle reset. Skips numpy-dependent
  cases when numpy isn't installed; CI has it.

Vinyl mode dead code removed:
- The toggle button was dropped during the sleeve refactor but the JS
  state, 2 s `setInterval`, `beforeunload` handler, and `applyVinylMode`
  call (commented out in app.js) all stayed. Now properly excised from
  player.js + app.js + window.* exports.
- Stripped the matching `.album-art-container.vinyl*` CSS block and its
  `vinylSpin` keyframes (~95 LoC).

Sleeve + tonearm fixes:
- Removed the duplicate `.now-playing .vinyl-stage` / `.vinyl-label` /
  `.tonearm` block that was overriding the new `.vinyl-stage` rules by
  source order — the uncommitted tonearm geometry never took effect
  because the stale clone won the cascade.
- Tightened tonearm to 36% × 36% at right:-6%, top:26% so the SVG
  bounding box stays right of the sleeve (sleeve right edge ~68%).
  Needle now lands on the visible disc grooves at both rest and
  playing rotations and never overlaps the cover.
- Removed sleeve `transform: rotate(-2.5deg)` + the matching mobile
  `-1.8deg` override; sleeve now sits flat and squared-off.
- Removed the 1px inset hairline on the sleeve and the 1px outline +
  inset highlight on the album art — cleaner, no semitransparent
  border noise.
- Album art inset 5% to expose a cardstock margin around the print
  (using explicit width/height — `inset` shorthand triggered the CSS
  replaced-element rule that uses the image's intrinsic size and blew
  out the grid track).

Mobile + misc:
- Removed mobile tonearm overrides at 720px and 420px — they were
  calibrated for the pre-sleeve geometry and put the needle back over
  the cover on phones; desktop geometry is proportional and works.
- Added `<meta name="mobile-web-app-capable">` alongside the legacy
  Apple variant to silence the deprecation warning in Chromium.
- Replaced the "PRIMARY" badge on display cards with a copper star
  icon (translation key still drives title + aria-label).
- `.gitattributes` with `* text=auto eol=lf` so Windows checkouts stop
  nagging "LF will be replaced by CRLF".

Annotations:
- "REF · 24" record-label catalogue mark marked as intentional non-i18n
  decoration in index.html.

CI: ruff clean, pytest 7 passed + 3 numpy-skipped (all 10 run on CI).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 14:39:20 +03:00
parent f85ce77f14
commit 2a474ea52c
9 changed files with 424 additions and 442 deletions
+20 -4
View File
@@ -71,6 +71,11 @@ class AudioAnalyzer:
self._lifecycle_lock = threading.Lock()
self._data: dict | None = None
self._current_device_name: str | None = None
# Slow AGC envelope so the spectrum reflects real dynamics
# instead of being renormalized to peak=1.0 every frame.
# A loud transient (e.g. notification beep) lifts the reference
# for a few seconds afterwards; this is the price of real loudness.
self._spectrum_ref = 0.01
# Pre-compute logarithmic bin edges
self._bin_edges = self._compute_bin_edges()
@@ -110,6 +115,10 @@ class AudioAnalyzer:
if not self.available:
return False
# Reset AGC envelope so a long silent gap between sessions
# doesn't make the first new transients clip at the ceiling.
self._spectrum_ref = 0.01
self._running = True
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
self._thread.start()
@@ -295,10 +304,17 @@ class AudioAnalyzer:
else:
level = 0.0
# Normalize bins to 0-1 for spectrum display
max_val = bins.max()
if max_val > 0:
bins *= (1.0 / max_val)
# Slow auto-gain: envelope follower with fast attack,
# slow release. Quiet music yields small bars; loud
# passages reach the top; the reference adapts over
# seconds instead of resetting every frame.
current_peak = float(bins.max())
if current_peak > self._spectrum_ref:
self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.05
else:
self._spectrum_ref += (current_peak - self._spectrum_ref) * 0.005
ref = max(self._spectrum_ref, 1e-4)
bins = np.clip(bins / ref, 0.0, 1.5)
# Bass energy: average of first 4 bins (~20-200Hz)
bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0