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:
@@ -2,32 +2,56 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from musicmouse.config import DEFAULT_AUDIO_EXTENSIONS, load_config
|
||||
from musicmouse.library import Album, MusicLibrary
|
||||
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis
|
||||
from musicmouse.library import Album, Analyzer, MusicLibrary
|
||||
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
|
||||
from musicmouse.library.cache import LibraryCache
|
||||
from musicmouse.library.colors import colors_from_id
|
||||
from musicmouse.library.models import album_id
|
||||
from musicmouse.library.models import album_id, track_key
|
||||
from tests.conftest import VALID_CONFIG, write_config, write_track
|
||||
|
||||
EXTENSIONS = frozenset(DEFAULT_AUDIO_EXTENSIONS)
|
||||
|
||||
|
||||
async def build(config_dir: Path) -> MusicLibrary:
|
||||
async def build(config_dir: Path, *, analyzer: Analyzer | None = None) -> MusicLibrary:
|
||||
config = load_config(write_config(config_dir, VALID_CONFIG))
|
||||
return await MusicLibrary.build(
|
||||
config.general.library.root,
|
||||
config.general.library.cache,
|
||||
EXTENSIONS,
|
||||
analyzer=analyzer,
|
||||
figure_kinds=config.figure_kinds,
|
||||
)
|
||||
|
||||
|
||||
class FakeAnalyzer:
|
||||
"""An `Analyzer` that does no DSP, so the worker's own behaviour - which tracks it
|
||||
touches, how it reacts to `is_busy`, what a raising analyzer does to the batch - can
|
||||
be tested without librosa."""
|
||||
|
||||
def __init__(self, *, version: int = ANALYZER_VERSION, fails: set[str] | None = None) -> None:
|
||||
self.version = version
|
||||
self._fails = fails or set()
|
||||
self.calls: list[Path] = []
|
||||
|
||||
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
||||
self.calls.append(path)
|
||||
if path.name in self._fails:
|
||||
raise RuntimeError(f"boom: {path.name}")
|
||||
analysis = TrackAnalysis(version=self.version, tempo=100.0, energy=0.5)
|
||||
curves = TrackCurves(hop_seconds=1.0, energy=(0.5,), valence=(0.5,), drive=(0.5,))
|
||||
return analysis, BeatGrid((0.1,), (1.0,)), curves
|
||||
|
||||
|
||||
def album_named(library: MusicLibrary, title: str) -> Album:
|
||||
return next(album for album in library.albums if album.title == title)
|
||||
|
||||
@@ -233,25 +257,56 @@ async def test_a_new_episode_only_costs_scanning_that_one_file(config_dir: Path)
|
||||
|
||||
|
||||
async def test_a_rescan_leaves_analysis_alone(config_dir: Path) -> None:
|
||||
"""The whole reason the cache is a directory rather than one file."""
|
||||
"""The whole reason the cache is a directory rather than one file.
|
||||
|
||||
"Kinderparty Lieder" rather than "Eule": folding analysis back into the index is
|
||||
restricted to music (see `_ANALYZED_KINDS`), since only music is ever analyzed - a
|
||||
book's `analysis/` file, if one somehow existed, is not something a rescan need
|
||||
resurface.
|
||||
"""
|
||||
library = await build(config_dir)
|
||||
album = album_named(library, "Kinderparty Lieder")
|
||||
from musicmouse.library.models import track_key
|
||||
|
||||
key = track_key(album.tracks[0].path)
|
||||
library.cache.store_analysis(key, TrackAnalysis(version=ANALYZER_VERSION, tempo=128.0))
|
||||
library.cache.store_beats(key, BeatGrid((0.5, 1.0), (1.0, 0.5)))
|
||||
curve = TrackCurves(hop_seconds=1.0, energy=(0.4, 0.6), valence=(0.5, 0.5), drive=(0.3, 0.7))
|
||||
library.cache.store_curve(key, curve)
|
||||
|
||||
# Force a full rescan by dropping the cheap part of the cache.
|
||||
(config_dir / ".cache" / "index.json").unlink()
|
||||
library = await build(config_dir)
|
||||
|
||||
album = album_named(library, "Kinderparty Lieder")
|
||||
assert album.tracks[0].analysis is not None
|
||||
assert album.tracks[0].analysis.tempo == 128.0
|
||||
grid = library.beats(album.id, 0)
|
||||
assert grid is not None
|
||||
assert grid.times == (0.5, 1.0)
|
||||
curve = library.curve(album.id, 0)
|
||||
assert curve is not None
|
||||
assert curve.energy == (0.4, 0.6)
|
||||
|
||||
|
||||
async def test_a_rescan_does_not_fold_analysis_into_a_non_music_album(
|
||||
config_dir: Path,
|
||||
) -> None:
|
||||
"""The other half of the restriction above: a book track's `analysis/` file (however
|
||||
it got there) is not folded into the index, so a rescan never stats hundreds of
|
||||
book/podcast files that can never have one under normal operation."""
|
||||
library = await build(config_dir)
|
||||
album = album_named(library, "Eule")
|
||||
from musicmouse.library.models import track_key
|
||||
|
||||
key = track_key(album.tracks[0].path)
|
||||
library.cache.store_analysis(key, TrackAnalysis(version=ANALYZER_VERSION, tempo=128.0))
|
||||
library.cache.store_beats(key, BeatGrid((0.5, 1.0), (1.0, 0.5)))
|
||||
|
||||
# Force a full rescan by dropping the cheap part of the cache.
|
||||
(config_dir / ".cache" / "index.json").unlink()
|
||||
library = await build(config_dir)
|
||||
|
||||
album = album_named(library, "Eule")
|
||||
assert album.tracks[0].analysis is not None
|
||||
assert album.tracks[0].analysis.tempo == 128.0
|
||||
grid = library.beats(album.id, 0)
|
||||
assert grid is not None
|
||||
assert grid.times == (0.5, 1.0)
|
||||
assert album.tracks[0].analysis is None
|
||||
|
||||
|
||||
async def test_analysis_survives_an_analyzer_that_grew_a_field(tmp_path: Path) -> None:
|
||||
@@ -340,3 +395,189 @@ async def test_a_figures_kind_survives_the_cache(config_dir: Path) -> None:
|
||||
assert eule.series is not None
|
||||
assert album_named(second, "Fuchs").kind == "music"
|
||||
assert album_named(second, "Fuchs").series is None
|
||||
|
||||
|
||||
# -------------------------------------------------------------------- analysis worker
|
||||
|
||||
|
||||
async def _wait_for(predicate: Callable[[], bool], *, timeout: float = 2.0) -> None:
|
||||
"""Poll until `predicate()` is true, rather than guessing a sleep duration."""
|
||||
|
||||
async def poll() -> None:
|
||||
while not predicate():
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
await asyncio.wait_for(poll(), timeout=timeout)
|
||||
|
||||
|
||||
async def _cancel(task: asyncio.Task[object]) -> None:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
async def test_analyze_pending_only_touches_music_albums(config_dir: Path) -> None:
|
||||
"""Books and podcasts are the majority of a real library and none of them get a
|
||||
background - see `_ANALYZED_KINDS`."""
|
||||
analyzer = FakeAnalyzer()
|
||||
library = await build(config_dir, analyzer=analyzer)
|
||||
|
||||
music_track_count = sum(len(a.tracks) for a in library.albums if a.kind == "music")
|
||||
assert music_track_count == 5 # fuchs (3) + Kinderparty Lieder (2)
|
||||
|
||||
done = await library.analyze_pending()
|
||||
|
||||
assert done == 5
|
||||
assert len(analyzer.calls) == 5
|
||||
touched = {
|
||||
next(a for a in library.albums if p in {t.path for t in a.tracks}).kind
|
||||
for p in analyzer.calls
|
||||
}
|
||||
assert touched == {"music"}
|
||||
|
||||
|
||||
async def test_analyze_pending_persists_curves_alongside_beats(config_dir: Path) -> None:
|
||||
"""`MusicLibrary.curve()` mirrors `.beats()` - both are per-track detail fetched
|
||||
for the one track currently playing, written together every pass."""
|
||||
analyzer = FakeAnalyzer()
|
||||
library = await build(config_dir, analyzer=analyzer)
|
||||
album = next(a for a in library.albums if a.kind == "music")
|
||||
|
||||
await library.analyze_pending()
|
||||
|
||||
curve = library.curve(album.id, 0)
|
||||
assert curve is not None
|
||||
assert curve.hop_seconds == 1.0
|
||||
assert curve.energy == (0.5,)
|
||||
assert library.beats(album.id, 0) is not None
|
||||
|
||||
|
||||
async def test_analyze_pending_does_not_repeat_work_already_cached(config_dir: Path) -> None:
|
||||
analyzer = FakeAnalyzer()
|
||||
library = await build(config_dir, analyzer=analyzer)
|
||||
|
||||
await library.analyze_pending()
|
||||
again = await library.analyze_pending()
|
||||
|
||||
assert again == 0
|
||||
assert len(analyzer.calls) == 5
|
||||
|
||||
|
||||
async def test_a_failing_track_is_marked_attempted_and_not_retried(config_dir: Path) -> None:
|
||||
"""A corrupt file, a DRM'd one, or one the analyzer just chokes on must not abort
|
||||
the batch, and must not be retried on every single future pass either."""
|
||||
analyzer = FakeAnalyzer(fails={"00 - lied.mp3"})
|
||||
library = await build(config_dir, analyzer=analyzer)
|
||||
|
||||
done = await library.analyze_pending()
|
||||
assert done == 5 # the failure still counts as "handled"
|
||||
|
||||
kinderparty = album_named(library, "Kinderparty Lieder")
|
||||
failed = next(t for t in kinderparty.tracks if t.path.name == "00 - lied.mp3")
|
||||
cached = library.cache.load_analysis(track_key(failed.path))
|
||||
assert cached is not None
|
||||
assert cached.version == ANALYZER_VERSION
|
||||
assert cached.tempo is None # attempted, not computed
|
||||
|
||||
again = await library.analyze_pending()
|
||||
assert again == 0
|
||||
assert len(analyzer.calls) == 5
|
||||
|
||||
|
||||
async def test_is_busy_pauses_the_pass_until_it_clears(
|
||||
config_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import musicmouse.library as library_module
|
||||
|
||||
monkeypatch.setattr(library_module, "_BUSY_POLL_SECONDS", 0.01)
|
||||
analyzer = FakeAnalyzer()
|
||||
library = await build(config_dir, analyzer=analyzer)
|
||||
|
||||
busy = True
|
||||
task = asyncio.create_task(library.analyze_pending(is_busy=lambda: busy))
|
||||
try:
|
||||
await asyncio.sleep(0.05)
|
||||
assert analyzer.calls == [] # never got past the busy check
|
||||
|
||||
busy = False
|
||||
await asyncio.wait_for(task, timeout=2)
|
||||
finally:
|
||||
if not task.done():
|
||||
await _cancel(task)
|
||||
|
||||
assert len(analyzer.calls) == 5
|
||||
|
||||
|
||||
async def test_run_analysis_does_the_work_refresh_left_pending(config_dir: Path) -> None:
|
||||
"""The trigger this feature actually depends on: `refresh()` - at startup, or from
|
||||
a parent's "Bibliothek neu einlesen" - ends by requesting analysis, and
|
||||
`run_analysis` is what turns that request into finished work, without a second
|
||||
`refresh()` needed to see it."""
|
||||
analyzer = FakeAnalyzer()
|
||||
library = await build(config_dir, analyzer=analyzer) # refresh() already requested
|
||||
|
||||
batches: list[int] = []
|
||||
|
||||
async def on_batch() -> None:
|
||||
batches.append(len(analyzer.calls))
|
||||
|
||||
worker = asyncio.create_task(library.run_analysis(on_batch=on_batch))
|
||||
try:
|
||||
await _wait_for(lambda: bool(batches))
|
||||
finally:
|
||||
await _cancel(worker)
|
||||
|
||||
assert batches[-1] == 5
|
||||
kinderparty = album_named(library, "Kinderparty Lieder")
|
||||
assert kinderparty.tracks[0].analysis is not None
|
||||
assert kinderparty.tracks[0].analysis.tempo == 100.0
|
||||
|
||||
|
||||
async def test_requests_raised_during_a_pass_coalesce_into_one_more_pass(
|
||||
config_dir: Path,
|
||||
) -> None:
|
||||
"""Calling `request_analysis` three times while a pass is already running must not
|
||||
queue three more passes - just one, right after the current one finishes."""
|
||||
release = threading.Event()
|
||||
|
||||
class BlockingOnceAnalyzer(FakeAnalyzer):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._blocked = False
|
||||
|
||||
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
|
||||
if not self._blocked:
|
||||
self._blocked = True
|
||||
release.wait(timeout=2)
|
||||
return super().analyze(path)
|
||||
|
||||
analyzer = BlockingOnceAnalyzer()
|
||||
library = await build(config_dir, analyzer=analyzer)
|
||||
|
||||
pass_count = 0
|
||||
original = library.analyze_pending
|
||||
|
||||
async def counting(**kwargs: object) -> int:
|
||||
nonlocal pass_count
|
||||
pass_count += 1
|
||||
return await original(**kwargs) # type: ignore[arg-type]
|
||||
|
||||
library.analyze_pending = counting # type: ignore[method-assign]
|
||||
|
||||
worker = asyncio.create_task(library.run_analysis())
|
||||
try:
|
||||
await _wait_for(lambda: pass_count >= 1) # the first pass has started
|
||||
await asyncio.sleep(0.05) # ... and is now blocked inside the analyzer
|
||||
library.request_analysis()
|
||||
library.request_analysis()
|
||||
library.request_analysis()
|
||||
release.set()
|
||||
await _wait_for(lambda: pass_count >= 2)
|
||||
# Give the coalesced pass a moment to actually run - everything is already
|
||||
# cached, so it finds nothing pending and returns without incrementing further.
|
||||
await asyncio.sleep(0.1)
|
||||
finally:
|
||||
await _cancel(worker)
|
||||
|
||||
assert pass_count == 2
|
||||
assert len(analyzer.calls) == 5
|
||||
|
||||
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
|
||||
@@ -17,6 +17,8 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from musicmouse.config import WebConfig, load_config
|
||||
from musicmouse.library.analysis import BeatGrid, TrackCurves
|
||||
from musicmouse.library.models import track_key
|
||||
from musicmouse.services.web.service import build_app
|
||||
from musicmouse.simulator.harness import Simulation, build_simulation
|
||||
from tests.conftest import VALID_CONFIG, write_config
|
||||
@@ -96,6 +98,50 @@ async def test_unanalyzed_tracks_report_no_analysis(client: Client) -> None:
|
||||
assert (await client.get(f"/api/tracks/{album['id']}/0/analysis")).status_code == 404
|
||||
|
||||
|
||||
async def test_an_analyzed_track_reports_its_beats_and_curves(
|
||||
client: Client, sim: Simulation
|
||||
) -> None:
|
||||
album = await album_by_title(client, "Eule")
|
||||
library_album = sim.app.library.get(album["id"])
|
||||
assert library_album is not None
|
||||
key = track_key(library_album.tracks[0].path)
|
||||
sim.app.library.cache.store_beats(key, BeatGrid((0.5, 1.0), (1.0, 0.5)))
|
||||
sim.app.library.cache.store_curve(
|
||||
key, TrackCurves(hop_seconds=1.0, energy=(0.4, 0.6), valence=(0.5, 0.5), drive=(0.3, 0.7))
|
||||
)
|
||||
|
||||
response = await client.get(f"/api/tracks/{album['id']}/0/analysis")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["times"] == [0.5, 1.0]
|
||||
assert body["curve"]["energy"] == [0.4, 0.6]
|
||||
assert body["curve"]["valence"] == [0.5, 0.5]
|
||||
assert body["curve"]["drive"] == [0.3, 0.7]
|
||||
|
||||
|
||||
async def test_a_track_with_a_curve_but_no_reliable_beat_still_reports_200(
|
||||
client: Client, sim: Simulation
|
||||
) -> None:
|
||||
"""A free-tempo/spoken-word track has no usable beat grid, but energy/valence/
|
||||
drive are computed regardless - the endpoint must not 404 just because `beats`
|
||||
came back empty."""
|
||||
album = await album_by_title(client, "Eule")
|
||||
library_album = sim.app.library.get(album["id"])
|
||||
assert library_album is not None
|
||||
key = track_key(library_album.tracks[0].path)
|
||||
sim.app.library.cache.store_curve(
|
||||
key, TrackCurves(hop_seconds=1.0, energy=(0.5,), valence=(0.5,), drive=(0.5,))
|
||||
)
|
||||
|
||||
response = await client.get(f"/api/tracks/{album['id']}/0/analysis")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["times"] == []
|
||||
assert body["curve"]["energy"] == [0.5]
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- state
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user