Files
media-player-server/tests/test_audio_analyzer.py
T
alexei.dolgolyov 2a474ea52c 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>
2026-04-25 14:39:20 +03:00

153 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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