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:
199
python-backend/tests/test_librosa_analyzer.py
Normal file
199
python-backend/tests/test_librosa_analyzer.py
Normal 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
|
||||
Reference in New Issue
Block a user