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._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
+204 -335
View File
@@ -829,97 +829,10 @@ h1 {
filter: blur(50px) saturate(1.8);
}
/* Vinyl Record Mode */
.album-art-container.vinyl #album-art {
border-radius: 50%;
width: 210px;
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)); }
}
/* Legacy "vinyl mode" toggle (.album-art-container.vinyl + JS-driven
spinning class) was removed alongside the sleeve+disc redesign — the
disc now spins structurally via .vinyl-stage .vinyl when playstate
is "playing" (see SLEEVE FRAME section below). */
/* Audio Spectrogram Visualization */
.spectrogram-canvas {
@@ -946,12 +859,6 @@ h1 {
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 {
text-align: center;
margin-bottom: 2rem;
@@ -2854,27 +2761,6 @@ button.primary svg {
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 {
font-size: 1.5rem;
}
@@ -4656,12 +4542,17 @@ header .brand-sub {
}
/* ─── 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 {
position: absolute;
top: -6%;
right: -4%;
width: 56%;
height: 56%;
top: 26%;
right: -6%;
width: 36%;
height: 36%;
pointer-events: none;
transform-origin: 88% 12%;
transform: rotate(-22deg);
@@ -4694,6 +4585,162 @@ body.visualizer-active .vinyl-stage .spectrogram-canvas {
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,
.track-masthead {
@@ -6511,196 +6558,16 @@ footer .separator { color: var(--ink-ghost); margin: 0 8px; }
.now-playing { grid-template-columns: 1fr; gap: 40px; }
}
/* ─── Vinyl + tonearm ─────────────────────────────────────── */
.now-playing .vinyl-stage {
position: relative;
aspect-ratio: 1;
width: 100%;
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); } }
/* Vinyl/disc/tonearm rules live in the SLEEVE FRAME section under
.vinyl-stage selectors above. The earlier .now-playing duplicates
were stale clones from the pre-sleeve mockup snap and overrode the
new geometry by source order — removed to let .vinyl-stage rules
take effect. */
/* Spectrogram canvas: hidden — the editorial .spectrum row in the
track-masthead already shows the audio spectrum. The canvas
element stays in the DOM so the visualizer JS keeps rendering
(drives the album-art bass-pulse + dynamic background). */
track-masthead is the visible spectrum. The canvas stays in the
DOM so the visualizer render loop keeps emitting frequencyData
for the dynamic background to consume. */
.now-playing .spectrogram-canvas {
display: none !important;
}
@@ -7998,7 +7865,7 @@ select option {
letter-spacing: -0.005em;
line-height: 1.2;
display: inline-flex;
align-items: baseline;
align-items: center;
gap: 10px;
/* Allow text to wrap so we don't ellipsis-truncate the model name */
overflow: hidden;
@@ -8013,13 +7880,20 @@ select option {
color: var(--ink-mute);
}
.display-container .display-primary-badge {
font-family: var(--mono);
font-size: 8px;
letter-spacing: 0.18em;
text-transform: uppercase;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--copper);
border: 1px solid var(--copper);
padding: 2px 6px;
background: none;
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) */
@@ -8486,19 +8360,19 @@ select option {
width: 78%;
margin: 0 auto !important;
}
.vinyl-stage .vinyl { width: 92%; }
.vinyl-stage .vinyl-label {
/* Lighter sleeve grain on phones so the printed art reads
cleanly at small size. */
.vinyl-stage .sleeve-grain { opacity: 0.55; }
.vinyl-stage .vinyl-wrap .vinyl-label {
box-shadow:
inset 0 0 24px rgba(0, 0, 0, 0.4),
0 0 0 3px var(--bg-deep),
0 0 0 4px var(--copper-lo);
}
.vinyl-stage .tonearm {
top: -8% !important;
right: -6% !important;
width: 60% !important;
height: 60% !important;
inset 0 0 16px rgba(0, 0, 0, 0.5),
0 0 0 2px var(--bg-deep),
0 0 0 3px var(--copper-lo);
}
.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-align: center; }
@@ -8767,14 +8641,9 @@ select option {
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%; }
.vinyl-stage .tonearm {
top: -10% !important;
right: -8% !important;
width: 64% !important;
height: 64% !important;
}
.now-playing #track-title,
.player-layout #track-title {
+15 -5
View File
@@ -6,6 +6,7 @@
<title>Media Server</title>
<meta name="description" content="Remote media player control and file browser">
<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-status-bar-style" content="black-translucent">
<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">
<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">
<div class="vinyl-label">
<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-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="sleeve">
<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>
<svg class="tonearm" viewBox="0 0 200 200" aria-hidden="true">
+2 -6
View File
@@ -21,7 +21,6 @@ import {
activeTab, switchTab, updateTabIndicator, setMiniPlayerVisible,
initTheme, toggleTheme, initAccentColor, applyAccentColor,
renderAccentSwatches, selectAccentColor, toggleAccentPicker, lightenColor,
toggleVinylMode, applyVinylMode,
visualizerEnabled, visualizerAvailable, setVisualizerEnabled,
checkVisualizerAvailability, toggleVisualizer, applyVisualizerMode,
loadAudioDevices, onAudioDeviceChanged,
@@ -96,8 +95,8 @@ Object.assign(window, {
switchTab,
// Theme & accent
toggleTheme, toggleAccentPicker, selectAccentColor, lightenColor,
// Vinyl & visualizer
toggleVinylMode, toggleVisualizer,
// Visualizer (vinyl spin is structural CSS — no toggle)
toggleVisualizer,
// Background
toggleDynamicBackground,
// Auth
@@ -163,9 +162,6 @@ window.addEventListener('DOMContentLoaded', async () => {
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
// than many thin ones at this column width. JS-managed so we can
// 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 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 = `
<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
export let visualizerEnabled = localStorage.getItem('visualizerEnabled') === 'true';
export let visualizerAvailable = false;
@@ -361,13 +295,6 @@ export function stopVisualizerRender() {
if (visualizerCtx && canvas) {
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;
smoothedFrequencies = null;
document.body.classList.remove('audio-spectrum-live');
@@ -417,20 +344,9 @@ function renderVisualizerFrame() {
visualizerCtx.fill();
}
const bass = frequencyData.bass || 0;
const scale = 1 + bass * 0.04;
const art = document.getElementById('album-art');
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);
}
// Bass-driven album-art scale + glow pulse removed — the
// "burst" looked unnatural on the sleeve. Spectrum bars +
// VU needle remain the audio-reactive elements.
// Drive the editorial .spectrum bars from the same frequency data.
updateEditorialSpectrum(smoothedFrequencies, numBins);
@@ -464,10 +380,12 @@ function updateEditorialSpectrum(bins, numBins) {
for (let j = startIdx; j < endIdx && j < numBins; j++) {
if (bins[j] > peak) peak = bins[j];
}
// Per-bar high-end gain: 1.0 at the lowest bar, ~3.0 at the highest.
const gain = 1 + (i / barCount) * 2.0;
// Per-bar high-end gain: 1.0 at the lowest bar, ~1.8 at the highest.
// 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.
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 + '%';
}
}
@@ -898,7 +816,6 @@ export function updatePlaybackState(state) {
dom.playPauseIcon.innerHTML = SVG_PLAY;
dom.miniPlayPauseIcon.innerHTML = SVG_PLAY;
}
updateVinylSpin();
}
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