"""`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