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

@@ -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

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

View File

@@ -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