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
|
||||
|
||||
Reference in New Issue
Block a user