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:
@@ -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
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user