diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9ee2252 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/media_server/services/audio_analyzer.py b/media_server/services/audio_analyzer.py index 1ce6df7..f96f019 100644 --- a/media_server/services/audio_analyzer.py +++ b/media_server/services/audio_analyzer.py @@ -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 diff --git a/media_server/static/css/styles.css b/media_server/static/css/styles.css index 189484e..f3ac199 100644 --- a/media_server/static/css/styles.css +++ b/media_server/static/css/styles.css @@ -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 { diff --git a/media_server/static/index.html b/media_server/static/index.html index 4453337..a4eb082 100644 --- a/media_server/static/index.html +++ b/media_server/static/index.html @@ -6,6 +6,7 @@ Media Server + @@ -159,12 +160,21 @@
- +
-
-
- - Album Art + +
+ Album Art + + +
+
+
+
+ + REF · 24 +
${details}` : ''; - const primaryBadge = monitor.is_primary ? `${t('display.primary')}` : ''; + const primaryBadge = monitor.is_primary + ? ` + + ` + : ''; card.innerHTML = `
diff --git a/media_server/static/js/player.js b/media_server/static/js/player.js index 5df8de4..b2ff4a7 100644 --- a/media_server/static/js/player.js +++ b/media_server/static/js/player.js @@ -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) { diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_audio_analyzer.py b/tests/test_audio_analyzer.py new file mode 100644 index 0000000..5934511 --- /dev/null +++ b/tests/test_audio_analyzer.py @@ -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