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.
200 lines
7.4 KiB
Python
200 lines
7.4 KiB
Python
"""`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
|