Add librosa beat/mood analysis and an Ambience background driven by it

The background worker now runs a real librosa analyzer (tempo, beat grid,
per-second energy/valence curves) instead of only the null baseline, kept
behind build_analyzer() so a plain checkout without the analysis extra still
runs fine. The web player reads that per-track analysis and drives a new
animated "Ambience" background (bubbles, colour, current) that reacts to the
beat and the mood curve as the track plays, plus a debug overlay for tuning
it. Also adds a one-off script to backfill podcast cover art from iTunes.
This commit is contained in:
2026-09-10 22:42:51 +02:00
parent 8aed3b022b
commit 2e0e6ad199
24 changed files with 2789 additions and 60 deletions

View File

@@ -0,0 +1,199 @@
"""`LibrosaAnalyzer` against synthesised signals, not shipped audio fixtures.
Skipped wholesale when the ``analysis`` extra is not installed - exactly the state a
checkout of this repo is in until someone runs ``pip install -e '.[analysis]'``, and
what :func:`musicmouse.library.analysis.build_analyzer` falls back to `NullAnalyzer` for.
Warnings become errors project-wide (see ``pytest.ini_options.filterwarnings`` in
``pyproject.toml``), and numba emits a benign one on its first JIT compile per process -
so this module, alone, turns that off rather than loosening the project-wide setting.
"""
from __future__ import annotations
from pathlib import Path
import pytest
librosa = pytest.importorskip("librosa")
np = pytest.importorskip("numpy")
sf = pytest.importorskip("soundfile")
from musicmouse.library.librosa_analyzer import LibrosaAnalyzer # noqa: E402
pytestmark = pytest.mark.filterwarnings("ignore")
_SR = 22050
def _click_train(bpm: float, seconds: float = 20.0, sr: int = _SR) -> np.ndarray:
"""A metronome: short decaying clicks exactly `bpm` apart, easy for a beat tracker
to lock onto - real music is messier, but this makes a known-answer test possible.
"""
y = np.zeros(int(seconds * sr), dtype=np.float32)
period = 60.0 / bpm
click = np.hanning(200).astype(np.float32)
t = 0.0
while t < seconds:
i = int(t * sr)
n = min(len(click), len(y) - i)
if n > 0:
y[i : i + n] += click[:n]
t += period
return y
def _sine(freq: float, seconds: float = 12.0, sr: int = _SR, amp: float = 0.5) -> np.ndarray:
t = np.linspace(0, seconds, int(seconds * sr), endpoint=False)
return (amp * np.sin(2 * np.pi * freq * t)).astype(np.float32)
def _write(tmp_path: Path, name: str, y: np.ndarray, sr: int = _SR) -> Path:
path = tmp_path / name
sf.write(path, y, sr)
return path
def _close_to_bpm(tempo: float, target: float, tolerance: float = 8.0) -> bool:
"""Beat trackers routinely report a tempo at half or double the "true" one - both
are the same beat grid, just every-other-click or twice-per-click. Any of the three
counts as a correct detection."""
candidates = (target, target / 2, target * 2)
return any(abs(tempo - candidate) <= tolerance for candidate in candidates)
def test_tempo_from_a_click_train(tmp_path: Path) -> None:
path = _write(tmp_path, "clicks.wav", _click_train(120.0))
analysis, grid, curves = LibrosaAnalyzer().analyze(path)
assert analysis.tempo is not None
assert _close_to_bpm(analysis.tempo, 120.0)
assert grid is not None
assert len(grid.times) > 10
assert curves is not None
def test_brightness_orders_a_high_tone_above_a_low_one(tmp_path: Path) -> None:
low = _write(tmp_path, "low.wav", _sine(300.0))
high = _write(tmp_path, "high.wav", _sine(3000.0))
analyzer = LibrosaAnalyzer()
low_analysis, _, _ = analyzer.analyze(low)
high_analysis, _, _ = analyzer.analyze(high)
assert low_analysis.brightness is not None
assert high_analysis.brightness is not None
assert high_analysis.brightness > low_analysis.brightness
def test_energy_orders_a_loud_signal_above_a_quiet_one(tmp_path: Path) -> None:
loud = _write(tmp_path, "loud.wav", _sine(440.0, amp=0.9))
quiet = _write(tmp_path, "quiet.wav", _sine(440.0, amp=0.09))
analyzer = LibrosaAnalyzer()
loud_analysis, _, _ = analyzer.analyze(loud)
quiet_analysis, _, _ = analyzer.analyze(quiet)
assert loud_analysis.energy is not None
assert quiet_analysis.energy is not None
assert loud_analysis.energy > quiet_analysis.energy
def test_pulse_is_higher_for_a_steady_beat_than_a_plain_tone(tmp_path: Path) -> None:
"""What `pulse` is for: telling a track that actually pulses apart from one that
merely has *a* tempo number attached to it, like a sustained tone or narration."""
clicks = _write(tmp_path, "clicks.wav", _click_train(120.0))
tone = _write(tmp_path, "tone.wav", _sine(440.0))
analyzer = LibrosaAnalyzer()
click_analysis, _, _ = analyzer.analyze(clicks)
tone_analysis, _, _ = analyzer.analyze(tone)
assert click_analysis.pulse is not None
assert tone_analysis.pulse is not None
assert click_analysis.pulse > tone_analysis.pulse
def test_a_corrupt_file_is_analyzed_as_nothing_rather_than_raising(tmp_path: Path) -> None:
path = tmp_path / "corrupt.mp3"
path.write_bytes(b"not an audio file")
analysis, grid, curves = LibrosaAnalyzer().analyze(path)
assert analysis.version == LibrosaAnalyzer.version
assert analysis.tempo is None
assert analysis.energy is None
assert analysis.valence is None
assert analysis.brightness is None
assert analysis.pulse is None
assert analysis.beats is False
assert grid is None
assert curves is None
def test_a_zero_length_file_is_analyzed_as_nothing_rather_than_raising(tmp_path: Path) -> None:
path = _write(tmp_path, "empty.wav", np.zeros(0, dtype=np.float32))
analysis, grid, curves = LibrosaAnalyzer().analyze(path)
assert analysis.version == LibrosaAnalyzer.version
assert analysis.tempo is None
assert grid is None
assert curves is None
def test_curves_are_produced_alongside_the_scalars(tmp_path: Path) -> None:
path = _write(tmp_path, "clicks.wav", _click_train(120.0))
_, _, curves = LibrosaAnalyzer().analyze(path)
assert curves is not None
assert curves.hop_seconds == 1.0
assert len(curves.energy) == len(curves.valence) == len(curves.drive) >= 1
def test_whole_track_scalars_are_the_mean_of_their_curves(tmp_path: Path) -> None:
path = _write(tmp_path, "clicks.wav", _click_train(120.0))
analysis, _, curves = LibrosaAnalyzer().analyze(path)
assert curves is not None
assert analysis.energy == pytest.approx(sum(curves.energy) / len(curves.energy))
assert analysis.valence == pytest.approx(sum(curves.valence) / len(curves.valence))
def test_energy_curve_tracks_a_loud_then_quiet_signal(tmp_path: Path) -> None:
loud = _sine(440.0, seconds=10.0, amp=0.9)
quiet = _sine(440.0, seconds=10.0, amp=0.09)
path = _write(tmp_path, "loud_then_quiet.wav", np.concatenate([loud, quiet]))
_, _, curves = LibrosaAnalyzer().analyze(path)
assert curves is not None
half = len(curves.energy) // 2
first_half_mean = sum(curves.energy[:half]) / half
second_half_mean = sum(curves.energy[half:]) / (len(curves.energy) - half)
assert first_half_mean > second_half_mean
def test_drive_is_higher_for_a_steady_beat_than_a_plain_tone(tmp_path: Path) -> None:
"""What `drive` is for: a track's rhythmic intensity, the signal that actually
varies within a track (unlike tempo, which is flat to within a few percent inside
a real recording) - see `LibrosaAnalyzer._drive_curve`."""
clicks = _write(tmp_path, "clicks.wav", _click_train(120.0))
tone = _write(tmp_path, "tone.wav", _sine(440.0))
analyzer = LibrosaAnalyzer()
_, _, click_curves = analyzer.analyze(clicks)
_, _, tone_curves = analyzer.analyze(tone)
assert click_curves is not None
assert tone_curves is not None
click_mean = sum(click_curves.drive) / len(click_curves.drive)
tone_mean = sum(tone_curves.drive) / len(tone_curves.drive)
assert click_mean > tone_mean
def test_a_clip_shorter_than_one_hop_still_produces_a_one_sample_curve(tmp_path: Path) -> None:
path = _write(tmp_path, "short.wav", _sine(440.0, seconds=0.3))
_, _, curves = LibrosaAnalyzer().analyze(path)
assert curves is not None
assert len(curves.energy) == 1