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
+16
View File
@@ -0,0 +1,16 @@
# Normalise text files to LF in the repo so Windows checkouts stop
# nagging "LF will be replaced by CRLF" on every git status.
* text=auto eol=lf
# Binary assets — never touch.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.svg text
*.woff binary
*.woff2 binary
*.exe binary
*.dll binary
*.zip binary
+20 -4
View File
@@ -71,6 +71,11 @@ class AudioAnalyzer:
self._lifecycle_lock = threading.Lock() self._lifecycle_lock = threading.Lock()
self._data: dict | None = None self._data: dict | None = None
self._current_device_name: str | 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 # Pre-compute logarithmic bin edges
self._bin_edges = self._compute_bin_edges() self._bin_edges = self._compute_bin_edges()
@@ -110,6 +115,10 @@ class AudioAnalyzer:
if not self.available: if not self.available:
return False 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._running = True
self._thread = threading.Thread(target=self._capture_loop, daemon=True) self._thread = threading.Thread(target=self._capture_loop, daemon=True)
self._thread.start() self._thread.start()
@@ -295,10 +304,17 @@ class AudioAnalyzer:
else: else:
level = 0.0 level = 0.0
# Normalize bins to 0-1 for spectrum display # Slow auto-gain: envelope follower with fast attack,
max_val = bins.max() # slow release. Quiet music yields small bars; loud
if max_val > 0: # passages reach the top; the reference adapts over
bins *= (1.0 / max_val) # 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 energy: average of first 4 bins (~20-200Hz)
bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0 bass = float(bins[:4].mean()) if self.num_bins >= 4 else 0.0
+204 -335
View File
@@ -829,97 +829,10 @@ h1 {
filter: blur(50px) saturate(1.8); filter: blur(50px) saturate(1.8);
} }
/* Vinyl Record Mode */ /* Legacy "vinyl mode" toggle (.album-art-container.vinyl + JS-driven
.album-art-container.vinyl #album-art { spinning class) was removed alongside the sleeve+disc redesign — the
border-radius: 50%; disc now spins structurally via .vinyl-stage .vinyl when playstate
width: 210px; is "playing" (see SLEEVE FRAME section below). */
height: 210px;
margin: 45px;
filter: saturate(0.55) sepia(0.12) brightness(0.92) contrast(1.08);
box-shadow:
0 0 0 3px var(--vinyl-groove),
0 0 0 5px var(--vinyl-ring),
0 0 0 6px var(--vinyl-highlight),
0 0 0 12px var(--vinyl-ring),
0 0 0 13px var(--vinyl-highlight-dim),
0 0 0 20px var(--vinyl-ring),
0 0 0 21px var(--vinyl-highlight),
0 0 0 28px var(--vinyl-ring),
0 0 0 29px var(--vinyl-highlight-dim),
0 0 0 36px var(--vinyl-ring),
0 0 0 37px var(--vinyl-highlight),
0 0 0 42px var(--vinyl-ring),
0 0 0 43px var(--vinyl-groove),
0 0 0 45px var(--vinyl-edge),
0 4px 15px 45px var(--shadow-elevation);
}
/* Vinyl label vignette overlay */
.album-art-container.vinyl::before {
content: '';
position: absolute;
width: 210px;
height: 210px;
border-radius: 50%;
background: radial-gradient(
circle,
transparent 50%,
rgba(0,0,0,0.25) 100%
);
z-index: 2;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 0;
transition: opacity 0.6s ease;
}
.album-art-container.vinyl.spinning::before,
.album-art-container.vinyl.paused::before {
opacity: 1;
}
.album-art-container.vinyl .album-art-glow {
border-radius: 50%;
}
/* Center spindle hole */
.album-art-container::after {
content: '';
position: absolute;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--vinyl-spindle);
border: 2px solid var(--border);
box-shadow: inset 0 1px 3px rgba(0,0,0,0.5);
z-index: 3;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
opacity: 0;
transition: opacity 0.4s ease 0.3s;
}
.album-art-container.vinyl::after {
opacity: 1;
}
.album-art-container.vinyl.spinning #album-art {
animation: vinylSpin 12s linear infinite;
}
.album-art-container.vinyl.paused #album-art {
animation: vinylSpin 12s linear infinite;
animation-play-state: paused;
}
@keyframes vinylSpin {
from { transform: rotate(var(--vinyl-offset, 0deg)) scale(var(--vinyl-scale, 1)); }
to { transform: rotate(calc(var(--vinyl-offset, 0deg) + 360deg)) scale(var(--vinyl-scale, 1)); }
}
/* Audio Spectrogram Visualization */ /* Audio Spectrogram Visualization */
.spectrogram-canvas { .spectrogram-canvas {
@@ -946,12 +859,6 @@ h1 {
transition: opacity 0.08s ease-out; transition: opacity 0.08s ease-out;
} }
/* Adapt spectrogram for vinyl mode */
.album-art-container.vinyl .spectrogram-canvas {
bottom: -10px;
border-radius: 0 0 50% 50%;
}
.track-info { .track-info {
text-align: center; text-align: center;
margin-bottom: 2rem; margin-bottom: 2rem;
@@ -2854,27 +2761,6 @@ button.primary svg {
height: 250px; height: 250px;
} }
.album-art-container.vinyl #album-art {
width: 170px;
height: 170px;
margin: 40px;
box-shadow:
0 0 0 3px #2a2a2a,
0 0 0 5px #1a1a1a,
0 0 0 6px rgba(255,255,255,0.05),
0 0 0 12px #1a1a1a,
0 0 0 13px rgba(255,255,255,0.03),
0 0 0 20px #1a1a1a,
0 0 0 21px rgba(255,255,255,0.05),
0 0 0 28px #1a1a1a,
0 0 0 29px rgba(255,255,255,0.03),
0 0 0 36px #1a1a1a,
0 0 0 37px rgba(255,255,255,0.04),
0 0 0 38px #2a2a2a,
0 0 0 40px #111,
0 4px 12px 40px rgba(0,0,0,0.4);
}
#track-title { #track-title {
font-size: 1.5rem; font-size: 1.5rem;
} }
@@ -4656,12 +4542,17 @@ header .brand-sub {
} }
/* ─── Tonearm SVG ──────────────────────────────────────────── */ /* ─── Tonearm SVG ──────────────────────────────────────────── */
/* Geometry chosen so the SVG bounding box stays right of the sleeve
(sleeve right edge ≈ 68%). Pivot floats just past the stage's right
edge; needle lands on the visible disc grooves at playing rotation
(0deg) and on the outer rest position at -22deg. Never overlaps
the sleeve cover. */
.vinyl-stage .tonearm { .vinyl-stage .tonearm {
position: absolute; position: absolute;
top: -6%; top: 26%;
right: -4%; right: -6%;
width: 56%; width: 36%;
height: 56%; height: 36%;
pointer-events: none; pointer-events: none;
transform-origin: 88% 12%; transform-origin: 88% 12%;
transform: rotate(-22deg); transform: rotate(-22deg);
@@ -4694,6 +4585,162 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
opacity: 0.6; opacity: 0.6;
} }
/* ════════════════════════════════════════════════════════════════
SLEEVE FRAME — production layout
The album cover prints on a cardstock sleeve at left; the disc
sits to the right and peeks out, spinning while the tonearm
rests on it. The vinyl label is now a typographic plate; track
metadata lives in the masthead beside the stage.
════════════════════════════════════════════════════════════════ */
/* Glow: soft ambient halo behind the sleeve */
.vinyl-stage > #album-art-glow {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: 0;
object-fit: cover;
filter: blur(34px) saturate(1.6);
opacity: 0.45;
z-index: 0;
pointer-events: none;
transform: scale(1.05);
}
:root[data-theme="light"] .vinyl-stage > #album-art-glow {
opacity: 0.26;
filter: blur(40px) saturate(1.8);
}
/* Stage gets a warm ambient gradient (matches mockup) */
.album-art-container.vinyl-stage {
background:
radial-gradient(ellipse at center, #1a1611 0%, var(--bg-deep) 80%);
}
:root[data-theme="light"] .album-art-container.vinyl-stage {
background:
radial-gradient(ellipse at center, var(--bg-card-2) 0%, var(--bg-deep) 80%);
}
/* Sleeve: cardstock card with album cover printed on its face.
Geometry mirrors the mockup — sleeve+disc occupy a centered 90%
inner square so the arrangement breathes inside the stage. */
.vinyl-stage .sleeve {
position: absolute;
left: 5%;
top: 10.4%;
width: 63%;
aspect-ratio: 1;
z-index: 3;
background: var(--bg-card-2);
box-shadow:
inset 4px 4px 24px rgba(0, 0, 0, 0.35),
-2px 8px 24px rgba(0, 0, 0, 0.5),
-4px 18px 44px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
:root[data-theme="light"] .vinyl-stage .sleeve {
background: var(--bg-card);
box-shadow:
inset 4px 4px 18px rgba(0, 0, 0, 0.10),
-2px 8px 22px rgba(0, 0, 0, 0.20),
-4px 18px 36px rgba(0, 0, 0, 0.12);
}
/* Album art is the printed cover tipped onto the cardstock sleeve.
A 5% inset reveals the cardstock as a visible border around the
print; outline + inset shadow define the printed-cover edge.
Explicit width/height (not `inset` shorthand) — img is a replaced
element and would otherwise fall back to its intrinsic pixel
size, blowing out the grid track. */
.vinyl-stage .sleeve #album-art {
position: absolute;
top: 5%;
left: 5%;
width: 90%;
height: 90%;
object-fit: cover;
border-radius: 0;
z-index: 1;
box-shadow:
inset 0 0 18px rgba(0, 0, 0, 0.35),
0 2px 6px rgba(0, 0, 0, 0.35);
margin: 0;
background: var(--bg-card);
filter: contrast(0.96) saturate(0.92);
transition: filter 0.6s ease;
}
/* Cardstock paper grain over the print — multiplies into the image */
.vinyl-stage .sleeve-grain {
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.2' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.08 0 0 0 0 0.06 0 0 0 0.55 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
mix-blend-mode: multiply;
pointer-events: none;
z-index: 2;
opacity: 0.7;
}
:root[data-theme="light"] .vinyl-stage .sleeve-grain { opacity: 0.32; }
/* Worn corner notch — tiny triangular wear on bottom-right */
.vinyl-stage .sleeve-corner {
position: absolute;
width: 13%;
height: 13%;
bottom: -1px;
right: -1px;
background: var(--bg-deep);
clip-path: polygon(100% 0, 100% 100%, 0 100%);
opacity: 0.6;
z-index: 4;
}
/* Disc wrap — sits behind the sleeve, peeks out the right edge */
.vinyl-stage .vinyl-wrap {
position: absolute;
right: 3.2%;
top: 19.4%;
width: 63%;
aspect-ratio: 1;
z-index: 2;
}
.vinyl-stage .vinyl-wrap .vinyl {
width: 100%;
}
/* Vinyl label = typographic plate (no album art, lives on the sleeve now) */
.vinyl-stage .vinyl-wrap .vinyl-label {
background: linear-gradient(135deg, #2E2820 0%, #1f1a13 100%);
box-shadow:
inset 0 0 18px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 0 0 3px var(--bg-deep),
0 0 0 4px var(--copper-lo);
display: flex;
align-items: center;
justify-content: center;
}
:root[data-theme="light"] .vinyl-stage .vinyl-wrap .vinyl-label {
background: linear-gradient(135deg, #1F4E3D 0%, #143E2F 100%);
box-shadow:
inset 0 0 18px rgba(0, 0, 0, 0.4),
inset 0 1px 0 rgba(255, 255, 255, 0.06),
0 0 0 3px var(--bg-paper),
0 0 0 4px var(--copper-lo);
}
.vinyl-label-text {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.3em;
color: var(--copper);
text-transform: uppercase;
z-index: 2;
font-weight: 500;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.6);
user-select: none;
}
/* ─── Player details (right column / masthead) ──────────────── */ /* ─── Player details (right column / masthead) ──────────────── */
.player-details, .player-details,
.track-masthead { .track-masthead {
@@ -6511,196 +6558,16 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
.now-playing { grid-template-columns: 1fr; gap: 40px; } .now-playing { grid-template-columns: 1fr; gap: 40px; }
} }
/* ─── Vinyl + tonearm ─────────────────────────────────────── */ /* Vinyl/disc/tonearm rules live in the SLEEVE FRAME section under
.now-playing .vinyl-stage { .vinyl-stage selectors above. The earlier .now-playing duplicates
position: relative; were stale clones from the pre-sleeve mockup snap and overrode the
aspect-ratio: 1; new geometry by source order — removed to let .vinyl-stage rules
width: 100%; take effect. */
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 0;
box-shadow: none;
padding: 0;
overflow: visible;
transform: none !important;
}
.now-playing .vinyl {
position: relative;
width: 86%;
aspect-ratio: 1;
border-radius: 50%;
background:
radial-gradient(circle at 50% 50%,
#0a0907 0%,
#0a0907 18%,
#1a1611 18.3%,
#0a0907 18.6%,
#14110c 22%,
#0a0907 22.3%,
#14110c 26%,
#0a0907 26.3%,
#14110c 30%,
#0a0907 30.3%,
#14110c 34%,
#0a0907 34.3%,
#14110c 38%,
#0a0907 38.3%,
#14110c 42%,
#0a0907 42.3%,
#14110c 46%,
#0a0907 46.3%,
#1c1812 47%,
#0a0907 100%);
box-shadow:
inset 0 0 60px rgba(0,0,0,0.7),
0 30px 80px rgba(0,0,0,0.6),
0 6px 20px rgba(0,0,0,0.5);
animation: sr-snap-spin 14s linear infinite;
animation-play-state: paused;
}
:root[data-playstate="playing"] .now-playing .vinyl {
animation-play-state: running;
}
.now-playing .vinyl::before {
content: "";
position: absolute; inset: 12%;
border-radius: 50%;
background:
conic-gradient(from 0deg,
rgba(255,255,255,0.04) 0deg,
transparent 30deg,
rgba(255,255,255,0.06) 90deg,
transparent 150deg,
rgba(255,255,255,0.03) 210deg,
transparent 270deg,
rgba(255,255,255,0.05) 330deg,
transparent 360deg);
mix-blend-mode: screen;
pointer-events: none;
}
/* Vinyl label = circular clip holding the actual album art */
.now-playing .vinyl-label {
position: absolute;
inset: 28%;
border-radius: 50%;
overflow: hidden;
background: var(--bg-card);
box-shadow:
inset 0 0 24px rgba(0,0,0,0.4),
0 0 0 4px var(--bg-deep),
0 0 0 5px var(--copper-lo);
z-index: 1;
}
.now-playing .vinyl-label::before {
/* Spindle hole */
content: "";
position: absolute;
width: 8%; height: 8%;
top: 46%; left: 46%;
border-radius: 50%;
background: var(--bg-deep);
box-shadow: inset 0 1px 2px rgba(255,255,255,0.1);
z-index: 3;
}
.now-playing .vinyl-label #album-art-glow {
position: absolute;
inset: -10%;
width: 120%;
height: 120%;
border-radius: 50%;
filter: blur(22px) saturate(1.4);
opacity: 0.5;
z-index: 0;
object-fit: cover;
}
.now-playing .vinyl-label #album-art {
position: relative;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 50%;
z-index: 2;
/* Heavy vinyl-consistent tint: deeper sepia + lower saturation
so vibrant covers blend with the copper grooves. */
filter:
sepia(0.6)
saturate(0.7)
contrast(1.12)
brightness(0.88)
hue-rotate(-8deg);
transition: filter 480ms var(--ease), -webkit-mask-image 480ms var(--ease);
/* Soft radial fade — the outer ~12% of the art fades to black so
the album image dissolves into the vinyl surface rather than
cutting hard at the circular clip edge. */
-webkit-mask-image: radial-gradient(circle at 50% 50%,
black 0%,
black 78%,
rgba(0,0,0,0.85) 88%,
rgba(0,0,0,0.4) 96%,
transparent 100%);
mask-image: radial-gradient(circle at 50% 50%,
black 0%,
black 78%,
rgba(0,0,0,0.85) 88%,
rgba(0,0,0,0.4) 96%,
transparent 100%);
}
.now-playing:hover .vinyl-label #album-art {
/* On hover, ease back toward natural color and pull the fade
inward so more of the real cover is visible. */
filter:
sepia(0.25)
saturate(0.92)
contrast(1.05)
brightness(0.98)
hue-rotate(-4deg);
-webkit-mask-image: radial-gradient(circle at 50% 50%,
black 0%,
black 88%,
rgba(0,0,0,0.9) 95%,
rgba(0,0,0,0.5) 99%,
transparent 100%);
mask-image: radial-gradient(circle at 50% 50%,
black 0%,
black 88%,
rgba(0,0,0,0.9) 95%,
rgba(0,0,0,0.5) 99%,
transparent 100%);
}
/* Match the glow tint and soft edge to the album art treatment */
.now-playing .vinyl-label #album-art-glow {
filter: blur(22px) saturate(1.1) sepia(0.5) hue-rotate(-8deg);
opacity: 0.4;
}
/* Tonearm */
.now-playing .tonearm {
position: absolute;
top: -8%; right: -4%;
width: 58%; height: 58%;
pointer-events: none;
transform-origin: 88% 12%;
transform: rotate(-22deg);
transition: transform 1s var(--ease);
z-index: 3;
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.5));
}
:root[data-playstate="playing"] .now-playing .tonearm {
transform: rotate(0deg);
}
@keyframes sr-snap-spin { to { transform: rotate(360deg); } }
/* Spectrogram canvas: hidden — the editorial .spectrum row in the /* Spectrogram canvas: hidden — the editorial .spectrum row in the
track-masthead already shows the audio spectrum. The canvas track-masthead is the visible spectrum. The canvas stays in the
element stays in the DOM so the visualizer JS keeps rendering DOM so the visualizer render loop keeps emitting frequencyData
(drives the album-art bass-pulse + dynamic background). */ for the dynamic background to consume. */
.now-playing .spectrogram-canvas { .now-playing .spectrogram-canvas {
display: none !important; display: none !important;
} }
@@ -7998,7 +7865,7 @@ select option {
letter-spacing: -0.005em; letter-spacing: -0.005em;
line-height: 1.2; line-height: 1.2;
display: inline-flex; display: inline-flex;
align-items: baseline; align-items: center;
gap: 10px; gap: 10px;
/* Allow text to wrap so we don't ellipsis-truncate the model name */ /* Allow text to wrap so we don't ellipsis-truncate the model name */
overflow: hidden; overflow: hidden;
@@ -8013,13 +7880,20 @@ select option {
color: var(--ink-mute); color: var(--ink-mute);
} }
.display-container .display-primary-badge { .display-container .display-primary-badge {
font-family: var(--mono); display: inline-flex;
font-size: 8px; align-items: center;
letter-spacing: 0.18em; justify-content: center;
text-transform: uppercase;
color: var(--copper); color: var(--copper);
border: 1px solid var(--copper); background: none;
padding: 2px 6px; border: 0;
padding: 0;
margin: 0;
line-height: 0;
vertical-align: middle;
filter: drop-shadow(0 0 4px var(--copper-glow));
}
.display-container .display-primary-badge svg {
display: block;
} }
/* Power button — visible & state-coloured (on = jade, off = ink-mute) */ /* Power button — visible & state-coloured (on = jade, off = ink-mute) */
@@ -8486,19 +8360,19 @@ select option {
width: 78%; width: 78%;
margin: 0 auto !important; margin: 0 auto !important;
} }
.vinyl-stage .vinyl { width: 92%; } /* Lighter sleeve grain on phones so the printed art reads
.vinyl-stage .vinyl-label { cleanly at small size. */
.vinyl-stage .sleeve-grain { opacity: 0.55; }
.vinyl-stage .vinyl-wrap .vinyl-label {
box-shadow: box-shadow:
inset 0 0 24px rgba(0, 0, 0, 0.4), inset 0 0 16px rgba(0, 0, 0, 0.5),
0 0 0 3px var(--bg-deep), 0 0 0 2px var(--bg-deep),
0 0 0 4px var(--copper-lo); 0 0 0 3px var(--copper-lo);
}
.vinyl-stage .tonearm {
top: -8% !important;
right: -6% !important;
width: 60% !important;
height: 60% !important;
} }
.vinyl-label-text { font-size: 9px; letter-spacing: 0.24em; }
/* Tonearm geometry inherits the desktop .vinyl-stage .tonearm
values — sleeve + disc proportions are identical on mobile,
so the needle still lands on the visible disc grooves. */
/* Track masthead text: centered, condensed cadence */ /* Track masthead text: centered, condensed cadence */
.track-masthead { text-align: center; } .track-masthead { text-align: center; }
@@ -8767,14 +8641,9 @@ select option {
display: none !important; display: none !important;
} }
/* Tonearm + vinyl tighter still */ /* Vinyl stage tighter still on small phones; tonearm inherits
desktop geometry which is proportional to the stage. */
.album-art-container.vinyl-stage { width: 84%; } .album-art-container.vinyl-stage { width: 84%; }
.vinyl-stage .tonearm {
top: -10% !important;
right: -8% !important;
width: 64% !important;
height: 64% !important;
}
.now-playing #track-title, .now-playing #track-title,
.player-layout #track-title { .player-layout #track-title {
+15 -5
View File
@@ -6,6 +6,7 @@
<title>Media Server</title> <title>Media Server</title>
<meta name="description" content="Remote media player control and file browser"> <meta name="description" content="Remote media player control and file browser">
<meta name="theme-color" content="#121212"> <meta name="theme-color" content="#121212">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Media Server"> <meta name="apple-mobile-web-app-title" content="Media Server">
@@ -159,12 +160,21 @@
<div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player"> <div class="player-container" data-tab-content="player" role="tabpanel" id="panel-player">
<section class="now-playing player-layout"> <section class="now-playing player-layout">
<!-- Vinyl stage with album art as label, plus tonearm --> <!-- Vinyl stage: cardstock sleeve + disc peeking out, plus tonearm -->
<div class="vinyl-stage album-art-container"> <div class="vinyl-stage album-art-container">
<div class="vinyl"> <img id="album-art-glow" class="album-art-glow" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E" alt="" aria-hidden="true">
<div class="vinyl-label"> <div class="sleeve">
<img id="album-art-glow" class="album-art-glow" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3C/svg%3E" alt="" aria-hidden="true"> <img id="album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art">
<img id="album-art" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 300 300'%3E%3Crect fill='%23282828' width='300' height='300'/%3E%3Cpath fill='%236a6a6a' d='M150 80c-38.66 0-70 31.34-70 70s31.34 70 70 70 70-31.34 70-70-31.34-70-70-70zm0 20c27.614 0 50 22.386 50 50s-22.386 50-50 50-50-22.386-50-50 22.386-50 50-50zm0 30a20 20 0 100 40 20 20 0 000-40z'/%3E%3C/svg%3E" alt="Album Art"> <div class="sleeve-grain" aria-hidden="true"></div>
<div class="sleeve-corner" aria-hidden="true"></div>
</div>
<div class="vinyl-wrap">
<div class="vinyl">
<div class="vinyl-label">
<!-- Stylised record-label catalogue mark, not user-facing
copy — intentionally not in the i18n bundle. -->
<span class="vinyl-label-text">REF · 24</span>
</div>
</div> </div>
</div> </div>
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true"> <svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
+2 -6
View File
@@ -21,7 +21,6 @@ import {
activeTab, switchTab, updateTabIndicator, setMiniPlayerVisible, activeTab, switchTab, updateTabIndicator, setMiniPlayerVisible,
initTheme, toggleTheme, initAccentColor, applyAccentColor, initTheme, toggleTheme, initAccentColor, applyAccentColor,
renderAccentSwatches, selectAccentColor, toggleAccentPicker, lightenColor, renderAccentSwatches, selectAccentColor, toggleAccentPicker, lightenColor,
toggleVinylMode, applyVinylMode,
visualizerEnabled, visualizerAvailable, setVisualizerEnabled, visualizerEnabled, visualizerAvailable, setVisualizerEnabled,
checkVisualizerAvailability, toggleVisualizer, applyVisualizerMode, checkVisualizerAvailability, toggleVisualizer, applyVisualizerMode,
loadAudioDevices, onAudioDeviceChanged, loadAudioDevices, onAudioDeviceChanged,
@@ -96,8 +95,8 @@ Object.assign(window, {
switchTab, switchTab,
// Theme & accent // Theme & accent
toggleTheme, toggleAccentPicker, selectAccentColor, lightenColor, toggleTheme, toggleAccentPicker, selectAccentColor, lightenColor,
// Vinyl & visualizer // Visualizer (vinyl spin is structural CSS — no toggle)
toggleVinylMode, toggleVisualizer, toggleVisualizer,
// Background // Background
toggleDynamicBackground, toggleDynamicBackground,
// Auth // Auth
@@ -163,9 +162,6 @@ window.addEventListener('DOMContentLoaded', async () => {
navigator.serviceWorker.register('/sw.js').catch(() => {}); navigator.serviceWorker.register('/sw.js').catch(() => {});
} }
// Vinyl is now structural / always-on via CSS — no init call needed.
// applyVinylMode();
// Build the editorial spectrum bars. Fewer, fatter bars read better // Build the editorial spectrum bars. Fewer, fatter bars read better
// than many thin ones at this column width. JS-managed so we can // than many thin ones at this column width. JS-managed so we can
// drive heights from real audio data when available. // drive heights from real audio data when available.
+7 -1
View File
@@ -57,7 +57,13 @@ export async function loadDisplayMonitors() {
const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 '); const details = [monitor.resolution, monitor.manufacturer].filter(Boolean).join(' \u00B7 ');
const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : ''; const detailsHtml = details ? `<span class="display-monitor-details">${details}</span>` : '';
const primaryBadge = monitor.is_primary ? `<span class="display-primary-badge">${t('display.primary')}</span>` : ''; const primaryBadge = monitor.is_primary
? `<span class="display-primary-badge" title="${t('display.primary')}" aria-label="${t('display.primary')}">
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path fill="currentColor" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
</span>`
: '';
card.innerHTML = ` card.innerHTML = `
<div class="display-monitor-header"> <div class="display-monitor-header">
+8 -91
View File
@@ -208,72 +208,6 @@ document.addEventListener('click', (e) => {
} }
}); });
// Vinyl mode
let vinylMode = localStorage.getItem('vinylMode') === 'true';
function getVinylAngle() {
const art = document.getElementById('album-art');
if (!art) return 0;
const st = getComputedStyle(art);
const tr = st.transform;
if (!tr || tr === 'none') return 0;
const m = tr.match(/matrix\((.+)\)/);
if (!m) return 0;
const vals = m[1].split(',').map(Number);
const angle = Math.round(Math.atan2(vals[1], vals[0]) * (180 / Math.PI));
return ((angle % 360) + 360) % 360;
}
function saveVinylAngle() {
if (!vinylMode) return;
localStorage.setItem('vinylAngle', getVinylAngle());
}
function restoreVinylAngle() {
const saved = localStorage.getItem('vinylAngle');
if (saved) {
const art = document.getElementById('album-art');
if (art) art.style.setProperty('--vinyl-offset', `${saved}deg`);
}
}
setInterval(saveVinylAngle, 2000);
window.addEventListener('beforeunload', saveVinylAngle);
export function toggleVinylMode() {
if (vinylMode) saveVinylAngle();
vinylMode = !vinylMode;
localStorage.setItem('vinylMode', vinylMode);
applyVinylMode();
}
export function applyVinylMode() {
const container = document.querySelector('.album-art-container');
const btn = document.getElementById('vinylToggle');
if (!container) return;
if (vinylMode) {
container.classList.add('vinyl');
if (btn) btn.classList.add('active');
restoreVinylAngle();
updateVinylSpin();
} else {
saveVinylAngle();
container.classList.remove('vinyl', 'spinning', 'paused');
if (btn) btn.classList.remove('active');
}
}
function updateVinylSpin() {
const container = document.querySelector('.album-art-container');
if (!container || !vinylMode) return;
container.classList.remove('spinning', 'paused');
if (currentPlayState === 'playing') {
container.classList.add('spinning');
} else if (currentPlayState === 'paused') {
container.classList.add('paused');
}
}
// Audio Visualizer // Audio Visualizer
export let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true'; export let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
export let visualizerAvailable = false; export let visualizerAvailable = false;
@@ -361,13 +295,6 @@ export function stopVisualizerRender() {
if (visualizerCtx && canvas) { if (visualizerCtx && canvas) {
visualizerCtx.clearRect(0, 0, canvas.width, canvas.height); visualizerCtx.clearRect(0, 0, canvas.width, canvas.height);
} }
const art = document.getElementById('album-art');
if (art) {
art.style.transform = '';
art.style.removeProperty('--vinyl-scale');
}
const glow = document.getElementById('album-art-glow');
if (glow) glow.style.opacity = '';
frequencyData = null; frequencyData = null;
smoothedFrequencies = null; smoothedFrequencies = null;
document.body.classList.remove('audio-spectrum-live'); document.body.classList.remove('audio-spectrum-live');
@@ -417,20 +344,9 @@ function renderVisualizerFrame() {
visualizerCtx.fill(); visualizerCtx.fill();
} }
const bass = frequencyData.bass || 0; // Bass-driven album-art scale + glow pulse removed — the
const scale = 1 + bass * 0.04; // "burst" looked unnatural on the sleeve. Spectrum bars +
const art = document.getElementById('album-art'); // VU needle remain the audio-reactive elements.
if (art) {
if (vinylMode) {
art.style.setProperty('--vinyl-scale', scale);
} else {
art.style.transform = `scale(${scale})`;
}
}
const glow = document.getElementById('album-art-glow');
if (glow) {
glow.style.opacity = (0.4 + bass * 0.4).toFixed(2);
}
// Drive the editorial .spectrum bars from the same frequency data. // Drive the editorial .spectrum bars from the same frequency data.
updateEditorialSpectrum(smoothedFrequencies, numBins); updateEditorialSpectrum(smoothedFrequencies, numBins);
@@ -464,10 +380,12 @@ function updateEditorialSpectrum(bins, numBins) {
for (let j = startIdx; j < endIdx && j < numBins; j++) { for (let j = startIdx; j < endIdx && j < numBins; j++) {
if (bins[j] > peak) peak = bins[j]; if (bins[j] > peak) peak = bins[j];
} }
// Per-bar high-end gain: 1.0 at the lowest bar, ~3.0 at the highest. // Per-bar high-end gain: 1.0 at the lowest bar, ~1.8 at the highest.
const gain = 1 + (i / barCount) * 2.0; // Backend now ships AGC-normalized bins (peak ~1, transients up to 1.5)
// so the master multiplier stays modest to avoid perma-clipping.
const gain = 1 + (i / barCount) * 0.8;
// Floor at 12% so silent bars are still visually present. // Floor at 12% so silent bars are still visually present.
const pct = Math.max(12, Math.min(100, peak * 110 * gain)); const pct = Math.max(12, Math.min(100, peak * 65 * gain));
bars[i].style.height = pct + '%'; bars[i].style.height = pct + '%';
} }
} }
@@ -898,7 +816,6 @@ export function updatePlaybackState(state) {
dom.playPauseIcon.innerHTML = SVG_PLAY; dom.playPauseIcon.innerHTML = SVG_PLAY;
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY; dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
} }
updateVinylSpin();
} }
function updateProgress(position, duration) { function updateProgress(position, duration) {
View File
+152
View File
@@ -0,0 +1,152 @@
"""Tests for AudioAnalyzer.
Covers the pure-Python pieces that don't need real audio hardware:
- Logarithmic FFT bin edge layout
- Slow-AGC envelope follower (attack vs release behaviour)
- Lifecycle reset of the AGC reference on start()
Tests are skipped when numpy isn't installed in the host environment
so they don't block CI on a minimal interpreter.
"""
from __future__ import annotations
import pytest
from media_server.services.audio_analyzer import AudioAnalyzer, _load_numpy
np = _load_numpy()
needs_numpy = pytest.mark.skipif(np is None, reason="numpy not available")
@pytest.fixture
def analyzer() -> AudioAnalyzer:
return AudioAnalyzer(num_bins=16, sample_rate=44100, chunk_size=1024)
# ── _compute_bin_edges ────────────────────────────────────────────
@needs_numpy
def test_bin_edges_count_matches_num_bins_plus_one(analyzer: AudioAnalyzer) -> None:
edges = analyzer._compute_bin_edges()
assert len(edges) == analyzer.num_bins + 1
@needs_numpy
def test_bin_edges_are_monotonic_non_decreasing(analyzer: AudioAnalyzer) -> None:
edges = analyzer._compute_bin_edges()
assert all(edges[i] <= edges[i + 1] for i in range(len(edges) - 1))
@needs_numpy
def test_bin_edges_stay_within_fft_size(analyzer: AudioAnalyzer) -> None:
edges = analyzer._compute_bin_edges()
fft_size = analyzer.chunk_size // 2 + 1
assert max(edges) <= fft_size - 1
assert min(edges) >= 0
# ── AGC envelope follower (the new behaviour) ─────────────────────
def _step_envelope(analyzer: AudioAnalyzer, peak: float) -> float:
"""Run one frame of the AGC update with a known peak value.
Mirrors the math inside _capture_loop without spinning up a real
capture thread or requiring numpy: pure Python on a single float.
"""
if peak > analyzer._spectrum_ref:
analyzer._spectrum_ref += (peak - analyzer._spectrum_ref) * 0.05
else:
analyzer._spectrum_ref += (peak - analyzer._spectrum_ref) * 0.005
return analyzer._spectrum_ref
def test_agc_initial_reference_is_quiet(analyzer: AudioAnalyzer) -> None:
assert analyzer._spectrum_ref == pytest.approx(0.01)
def test_agc_attacks_quickly_toward_loud_signal(analyzer: AudioAnalyzer) -> None:
# Drive 30 frames of a loud signal; reference should climb sharply.
for _ in range(30):
_step_envelope(analyzer, peak=1.0)
# 30 frames of attack=0.05 brings (1 - 0.99^30) ≈ 0.78 of the way to 1.0.
assert analyzer._spectrum_ref > 0.5
assert analyzer._spectrum_ref < 1.0
def test_agc_releases_slowly_toward_quiet_signal(analyzer: AudioAnalyzer) -> None:
analyzer._spectrum_ref = 1.0
for _ in range(30):
_step_envelope(analyzer, peak=0.0)
# Release coefficient is 0.005 — after 30 frames we should have shed
# only ~14% of the headroom, not snap back to silent.
assert analyzer._spectrum_ref > 0.7
assert analyzer._spectrum_ref < 1.0
def test_agc_is_asymmetric_attack_faster_than_release(analyzer: AudioAnalyzer) -> None:
a = AudioAnalyzer()
b = AudioAnalyzer()
a._spectrum_ref = 0.5
b._spectrum_ref = 0.5
# One attack frame toward 1.0
_step_envelope(a, peak=1.0)
# One release frame toward 0.0 (same magnitude of error: 0.5)
_step_envelope(b, peak=0.0)
attack_delta = a._spectrum_ref - 0.5
release_delta = 0.5 - b._spectrum_ref
# Attack coefficient (0.05) is 10× the release coefficient (0.005).
assert attack_delta == pytest.approx(release_delta * 10, rel=1e-6)
# ── start() lifecycle reset ──────────────────────────────────────
def test_start_resets_spectrum_ref_when_unavailable(
monkeypatch: pytest.MonkeyPatch, analyzer: AudioAnalyzer
) -> None:
"""Even when start() returns False (no hardware), the AGC state
should remain at the documented quiet baseline."""
# Force unavailable so start() short-circuits without spawning a thread.
monkeypatch.setattr(
AudioAnalyzer, "available", property(lambda self: False)
)
analyzer._spectrum_ref = 0.95 # leftover from prior session
started = analyzer.start()
assert started is False
# start() returned early before the reset — by design (no capture
# means no need to renormalize). Document the contract.
assert analyzer._spectrum_ref == 0.95
def test_start_resets_spectrum_ref_when_available(
monkeypatch: pytest.MonkeyPatch, analyzer: AudioAnalyzer
) -> None:
"""When capture actually starts, leftover AGC state from a prior
session must be cleared so the first transients don't clip."""
monkeypatch.setattr(
AudioAnalyzer, "available", property(lambda self: True)
)
# Stub out the thread so we don't actually spin up a capture loop.
monkeypatch.setattr(
"media_server.services.audio_analyzer.threading.Thread",
lambda *a, **kw: type("T", (), {"start": lambda self: None})(),
)
analyzer._spectrum_ref = 0.95 # leftover from prior session
try:
started = analyzer.start()
assert started is True
assert analyzer._spectrum_ref == pytest.approx(0.01)
finally:
analyzer._running = False
# ── get_frequency_data thread-safe contract ───────────────────────
def test_get_frequency_data_returns_none_before_capture(
analyzer: AudioAnalyzer,
) -> None:
assert analyzer.get_frequency_data() is None