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

@@ -17,7 +17,7 @@ import asyncio
import contextlib
import logging
import sys
from collections.abc import Coroutine
from collections.abc import Awaitable, Callable, Coroutine
from pathlib import Path
from typing import Any
@@ -31,6 +31,7 @@ from musicmouse.devices.null_transport import NullTransport
from musicmouse.devices.player import Player, VlcPlayer
from musicmouse.devices.serial_link import SerialLink
from musicmouse.library import MusicLibrary
from musicmouse.library.analysis import build_analyzer
from musicmouse.reactions import register_all
from musicmouse.services.base import Service
from musicmouse.services.mqtt import MqttService, build_entities
@@ -43,9 +44,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="musicmouse", description="Host backend for the MusicMouse RFID music player."
)
parser.add_argument(
"-c", "--config", type=Path, required=True, help="path to config.yml"
)
parser.add_argument("-c", "--config", type=Path, required=True, help="path to config.yml")
parser.add_argument(
"-s",
"--simulate",
@@ -127,9 +126,7 @@ async def run_real(config: Config, config_path: Path, *, hardware: bool = True)
clock=clock,
)
mouse = MusicMouseDevice(bus, link, config.tag_map, port=general.serial_port)
link.attach(
mouse.feed, on_connect=mouse.on_connected, on_disconnect=mouse.on_disconnected
)
link.attach(mouse.feed, on_connect=mouse.on_connected, on_disconnect=mouse.on_disconnected)
else:
mouse = MusicMouseDevice(bus, NullTransport(), config.tag_map, port="none")
@@ -137,9 +134,7 @@ async def run_real(config: Config, config_path: Path, *, hardware: bool = True)
library = await build_library(config)
app = _build_app(config, bus, mouse, player, library, clock=clock)
services = _build_services(
app, mouse, player, clock=clock, config_path=config_path
)
services = _build_services(app, mouse, player, clock=clock, config_path=config_path)
_log.info(
"MusicMouse %s starting: %d figures, %d albums, serial %s, audio %s, mqtt %s",
@@ -157,6 +152,15 @@ async def run_real(config: Config, config_path: Path, *, hardware: bool = True)
[
*([link.run()] if link else []),
player.run(),
# `is_busy` reads `player.is_playing` directly rather than the library
# knowing about playback at all: analysis must never compete with audio
# decoding for CPU, and this device is otherwise idle most of the day, so
# the pass simply resumes once it is. `on_batch` is unset unless the web
# front-end is on - nothing else has a use for the notification.
library.run_analysis(
is_busy=lambda: player.is_playing,
on_batch=_analysis_batch_hook(services),
),
*(service.run() for service in services),
]
)
@@ -184,6 +188,15 @@ async def run_simulated(config: Config, config_path: Path, script: Path | None)
sim.app, sim.app.mouse, sim.player, clock=RealClock(), config_path=config_path
)
tasks = [asyncio.create_task(service.run(), name=service.name) for service in services]
tasks.append(
asyncio.create_task(
sim.app.library.run_analysis(
is_busy=lambda: sim.player.is_playing,
on_batch=_analysis_batch_hook(services),
),
name="library-analysis",
)
)
try:
if script is not None:
await run_script_file(sim, script)
@@ -250,6 +263,7 @@ async def build_library(config: Config) -> MusicLibrary:
library_config.root,
library_config.cache,
frozenset(config.general.audio_extensions),
analyzer=build_analyzer(),
figure_kinds=config.figure_kinds,
)
@@ -279,6 +293,16 @@ def _build_app(
return app
def _analysis_batch_hook(services: list[Service]) -> Callable[[], Awaitable[None]] | None:
"""The web front-end's own hub, if it is running - so open tabs refetch the library
as background analysis lands, instead of only after a manual reload. `None` when
there is no web service, which `MusicLibrary.analyze_pending` treats as "nobody to
tell".
"""
web_service = next((s for s in services if isinstance(s, WebService)), None)
return web_service.hub.broadcast_library if web_service else None
def _build_services(
app: App,
mouse: MusicMouseDevice,

View File

@@ -8,12 +8,22 @@ plain immutable data.
from __future__ import annotations
import asyncio
import contextlib
import logging
from collections.abc import Mapping
import os
from collections.abc import Awaitable, Callable, Collection, Mapping
from dataclasses import replace
from pathlib import Path
from typing import Final
from musicmouse.library.analysis import ANALYZER_VERSION, Analyzer, BeatGrid, NullAnalyzer
from musicmouse.library.analysis import (
ANALYZER_VERSION,
Analyzer,
BeatGrid,
NullAnalyzer,
TrackAnalysis,
TrackCurves,
)
from musicmouse.library.cache import Fingerprint, LibraryCache
from musicmouse.library.models import Album, LibraryTrack, album_id, track_key
from musicmouse.library.scanner import scan_library
@@ -32,10 +42,41 @@ __all__ = [
"LibraryTrack",
"MusicLibrary",
"NullAnalyzer",
"TrackCurves",
"album_id",
"track_key",
]
#: The only kind worth spending DSP on: an audiobook chapter or a podcast episode is
#: tens of minutes of narration with no musical mood to extract, and there are far more
#: of them in a typical library than there are songs.
_ANALYZED_KINDS: Final[tuple[AlbumKind, ...]] = ("music",)
#: How often the background worker checks whether it may resume after `is_busy()` said
#: no. A track's own analysis is a couple of seconds of CPU, so overshooting by this
#: much when playback stops is not worth polling harder for.
_BUSY_POLL_SECONDS: Final = 2.0
#: Analysis is folded back into the live index and persisted this often during a long
#: run, so a first-time pass over an unanalyzed library shows up gradually in open
#: browser tabs rather than only after the whole thing finishes.
_PUBLISH_BATCH_SIZE: Final = 25
def _analyze_one(
analyzer: Analyzer, path: Path
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
"""Runs in a worker thread. Lowers this thread's own scheduling priority first.
On Linux, ``os.nice`` affects only the calling thread, not the whole process - so
this makes idle-time analysis yield CPU to anything else without touching threads
used for other work. Niceness only ever increases and clamps at the OS maximum
(19), so calling this repeatedly on a reused pool thread is harmless.
"""
with contextlib.suppress(OSError):
os.nice(1)
return analyzer.analyze(path)
class MusicLibrary:
"""An immutable index of albums, rebuilt wholesale rather than mutated in place."""
@@ -56,6 +97,10 @@ class MusicLibrary:
#: What each figure holds. The only thing a folder name cannot say.
self.figure_kinds: Mapping[str, AlbumKind] = figure_kinds or {}
self._entries: dict[str, tuple[Album, Fingerprint]] = {}
#: Set by `request_analysis`, consumed by `run_analysis`. An `Event` rather than
#: a queue: a request raised while one is already pending or running just
#: coalesces into it, which is exactly what "look again" should mean here.
self._analysis_requested = asyncio.Event()
# -------------------------------------------------------------------- reading
@@ -85,10 +130,21 @@ class MusicLibrary:
return None
return self.cache.load_beats(track_key(album.tracks[index].path))
def curve(self, identifier: str, index: int) -> TrackCurves | None:
album = self.get(identifier)
if album is None or not 0 <= index < len(album.tracks):
return None
return self.cache.load_curve(track_key(album.tracks[index].path))
# -------------------------------------------------------------------- writing
async def refresh(self) -> None:
"""Rescan from disk. Blocking work happens off the loop; the swap is atomic."""
"""Rescan from disk. Blocking work happens off the loop; the swap is atomic.
Ends by waking the background analyzer: a rescan is exactly when new tracks -
the only ones analysis can be pending for - enter the index, whether that is
the startup scan or a parent tapping "Bibliothek neu einlesen".
"""
known = dict(self._entries)
entries = await asyncio.to_thread(
scan_library,
@@ -100,19 +156,28 @@ class MusicLibrary:
)
self._entries = await asyncio.to_thread(self._with_analysis, entries)
await asyncio.to_thread(self.cache.store_index, self._entries)
self.request_analysis()
def _with_analysis(
self, entries: dict[str, tuple[Album, Fingerprint]]
self,
entries: dict[str, tuple[Album, Fingerprint]],
*,
kinds: Collection[AlbumKind] = _ANALYZED_KINDS,
) -> dict[str, tuple[Album, Fingerprint]]:
"""Fold cached analysis results into the freshly scanned index.
Skipped entirely while ``analysis/`` is empty, which is the normal case until an
analyzer has actually been run - no point stat-ing 900 files that cannot exist.
Restricted to `kinds` (music by default): stat-ing hundreds of book and podcast
tracks whose analysis can never exist would be pure waste. Skipped entirely
while ``analysis/`` is empty, which is the normal case until an analyzer has
actually been run.
"""
if not any(self.cache.analysis.glob("*.json")):
return entries
out: dict[str, tuple[Album, Fingerprint]] = {}
for identifier, (album, fingerprint) in entries.items():
if album.kind not in kinds:
out[identifier] = (album, fingerprint)
continue
tracks = tuple(
replace(track, analysis=self.cache.load_analysis(track_key(track.path)))
for track in album.tracks
@@ -136,12 +201,52 @@ class MusicLibrary:
await library.refresh()
return library
async def analyze_pending(self) -> int:
"""Run the analyzer over tracks that have no current result.
# ------------------------------------------------------------------- analysis
Never called during startup: the index is what playback needs, and analysis is
minutes of DSP per track. Results are keyed by file content, so they can equally
well be produced on a faster machine and the ``analysis/`` folder copied over.
def request_analysis(self) -> None:
"""Wake the background worker to look for tracks with no current analysis.
Idempotent, and safe to call before `run_analysis` has even started a first
time - the request just waits on the event.
"""
self._analysis_requested.set()
async def run_analysis(
self,
*,
is_busy: Callable[[], bool] = lambda: False,
on_batch: Callable[[], Awaitable[None]] | None = None,
) -> None:
"""Analyze pending tracks whenever `request_analysis` wakes this up.
A long-running task, cancelled at shutdown alongside every other one. Loops
forever so a request raised *during* a pass (a rescan mid-analysis) starts
another pass right after, rather than being lost.
"""
while True:
await self._analysis_requested.wait()
self._analysis_requested.clear()
await self.analyze_pending(is_busy=is_busy, on_batch=on_batch)
async def analyze_pending(
self,
*,
kinds: Collection[AlbumKind] = _ANALYZED_KINDS,
is_busy: Callable[[], bool] = lambda: False,
on_batch: Callable[[], Awaitable[None]] | None = None,
batch_size: int = _PUBLISH_BATCH_SIZE,
) -> int:
"""Run the analyzer over tracks of `kinds` that have no current result.
Restricted to music by default - see `_ANALYZED_KINDS`. Checked before every
track, `is_busy()` pauses the whole pass rather than one file: analysis must
never compete with audio decoding for CPU, and a children's player is idle most
of the day, so the pass simply resumes next time it is. A track the analyzer
fails on (corrupt file, DRM, zero length) is still recorded as attempted - with
every scalar left `None` - so it is never retried forever and the frontend falls
back to the un-analyzed baseline for it. Results are folded into the live index
and persisted every `batch_size` tracks, so a long first run is visible in open
browser tabs as it goes rather than only once it finishes.
"""
analyzer = self.analyzer
if analyzer.version < ANALYZER_VERSION:
@@ -149,16 +254,44 @@ class MusicLibrary:
done = 0
for album in self.albums:
if album.kind not in kinds:
continue
for track in album.tracks:
while is_busy():
await asyncio.sleep(_BUSY_POLL_SECONDS)
key = track_key(track.path)
cached = self.cache.load_analysis(key)
if cached is not None and cached.version >= analyzer.version:
continue
analysis, grid = await asyncio.to_thread(analyzer.analyze, track.path)
try:
analysis, grid, curve = await asyncio.to_thread(
_analyze_one, analyzer, track.path
)
except Exception:
_log.warning(
"Analyzer raised on %s; marking it attempted so it is not retried forever",
track.path,
exc_info=True,
)
analysis, grid, curve = TrackAnalysis(version=analyzer.version), None, None
if grid is not None:
self.cache.store_beats(key, grid)
if curve is not None:
self.cache.store_curve(key, curve)
self.cache.store_analysis(key, analysis)
done += 1
if done % batch_size == 0:
await self._publish_analysis(kinds, on_batch)
if done % batch_size:
await self._publish_analysis(kinds, on_batch)
if done:
_log.info("Analyzed %d tracks", done)
return done
async def _publish_analysis(
self, kinds: Collection[AlbumKind], on_batch: Callable[[], Awaitable[None]] | None
) -> None:
self._entries = await asyncio.to_thread(self._with_analysis, self._entries, kinds=kinds)
await asyncio.to_thread(self.cache.store_index, self._entries)
if on_batch is not None:
await on_batch()

View File

@@ -1,16 +1,20 @@
"""Offline audio analysis: the seam, not the implementation.
"""Offline audio analysis, and the seam that lets it be optional.
Nothing here computes anything yet. What it does is fix the shape of the results so
that the analyzer can arrive later without touching the scanner, the cache format, the
API contract or the frontend's data flow.
:class:`LibrosaAnalyzer` (``musicmouse.library.librosa_analyzer``) does the real work,
kept in its own module behind :func:`build_analyzer` so that importing *this* module -
which the scanner, the cache and the web API all do - never pulls in librosa or numpy.
:class:`MusicLibrary` runs whichever analyzer it is given in the background, between
tracks, so the reactive background is a thing a library grows into rather than a
migration.
Two rules hold the design together:
Two rules hold the result shape together:
* **Scalars travel with the index, time series do not.** A 25-minute podcast at 120 BPM
has ~3000 beats; 900 tracks of that inside ``GET /api/library`` would be tens of
megabytes. :class:`TrackAnalysis` is a handful of floats and rides along; the beat
grid lives in its own file and is fetched for the one track that is playing.
* **Every field is optional with a default.** Adding ``danceability`` later needs no
grid and the per-second :class:`TrackCurves` each live in their own file and are
fetched only for the one track that is playing.
* **Every field is optional with a default.** Adding a new scalar later needs no
migration and no cache wipe: unknown keys on disk are dropped on load, missing ones
fall back to the default. Only :data:`ANALYZER_VERSION` moving past what a file
records marks that file stale.
@@ -18,21 +22,28 @@ Two rules hold the design together:
from __future__ import annotations
import logging
from dataclasses import asdict, dataclass, fields
from pathlib import Path
from typing import Any, Final, Protocol
_log = logging.getLogger(__name__)
__all__ = [
"ANALYZER_VERSION",
"Analyzer",
"BeatGrid",
"NullAnalyzer",
"TrackAnalysis",
"TrackCurves",
"build_analyzer",
]
#: Bumped when an analyzer's output changes meaning. Cached results recorded under a
#: lower version are recomputed; results at or above it are left alone.
ANALYZER_VERSION: Final = 1
#: lower version are recomputed; results at or above it are left alone. 2: energy/
#: valence scalars became mean-of-curve instead of whole-track-percentile/heuristic,
#: and every track needs a new TrackCurves artifact producing.
ANALYZER_VERSION: Final = 2
@dataclass(frozen=True, slots=True)
@@ -48,6 +59,10 @@ class TrackAnalysis:
valence: float | None = None
#: 0..1 spectral centroid. Drives the background hue.
brightness: float | None = None
#: 0..1 confidence that :attr:`tempo` is an audible, steady beat rather than an
#: artifact of free-tempo or spoken-word material. Below-threshold tracks should
#: not be pulsed on the beat even though a grid exists for them.
pulse: float | None = None
#: Whether a beat grid for this track exists on disk.
beats: bool = False
@@ -85,22 +100,80 @@ class BeatGrid:
return cls(tuple(flat[0::2]), tuple(flat[1::2]))
@dataclass(frozen=True, slots=True)
class TrackCurves:
"""Per-second samples of the things that vary *within* a track. Regularly
sampled, so just a hop and equal-length arrays, no per-sample timestamps -
contrast :class:`BeatGrid`'s event-based irregular times.
``energy``/``valence`` drive colour; ``drive`` (rhythmic intensity) modulates the
water current's magnitude. Tempo is deliberately absent: measured against a real
library it is flat to within a few percent inside a track, so a per-second tempo
curve would carry estimator noise (including occasional octave errors) and
nothing else. Whole-track tempo keeps setting the current's base magnitude;
``drive`` is the signal that actually varies.
"""
hop_seconds: float
energy: tuple[float, ...]
valence: tuple[float, ...]
drive: tuple[float, ...]
def to_json(self) -> dict[str, Any]:
return {
"hop_seconds": self.hop_seconds,
"energy": list(self.energy),
"valence": list(self.valence),
"drive": list(self.drive),
}
@classmethod
def from_json(cls, data: dict[str, Any]) -> TrackCurves:
return cls(
hop_seconds=float(data["hop_seconds"]),
energy=tuple(data["energy"]),
valence=tuple(data["valence"]),
drive=tuple(data["drive"]),
)
class Analyzer(Protocol):
"""Turns one audio file into cacheable analysis results.
Implementations are CPU-bound and run off the event loop. The real one will live
behind an optional dependency group so the device never installs numpy to play music.
Implementations are CPU-bound and run off the event loop.
"""
version: int
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None]: ...
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]: ...
class NullAnalyzer:
"""Analyzes nothing. Keeps the wiring exercised until a real analyzer lands."""
"""Analyzes nothing. What :func:`build_analyzer` falls back to without librosa."""
version = 0
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None]: # noqa: ARG002
return TrackAnalysis(), None
def analyze(
self,
path: Path, # noqa: ARG002
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
return TrackAnalysis(), None, None
def build_analyzer() -> Analyzer:
"""The real analyzer if its optional dependency group is installed, else a no-op.
``NullAnalyzer.version`` is ``0``, which is below :data:`ANALYZER_VERSION`, so
:meth:`~musicmouse.library.MusicLibrary.analyze_pending` returns immediately and the
background stays at its static baseline - not a crash, not a degraded mode, just the
feature switched off until ``pip install -e '.[analysis]'`` turns it on.
"""
try:
from musicmouse.library.librosa_analyzer import LibrosaAnalyzer
except ImportError:
_log.info(
"librosa is not installed: the reactive background is off. "
"Install the 'analysis' extra to enable it."
)
return NullAnalyzer()
return LibrosaAnalyzer()

View File

@@ -8,6 +8,7 @@ different amounts to produce::
├── covers/<album_id>.jpg medium: art pulled out of an ID3 APIC frame
└── analysis/<track_key>.json expensive: minutes of DSP per track
analysis/<track_key>.beats.json
analysis/<track_key>.curve.json
That split is the whole point. A rescan must be free to rebuild ``index.json`` without
destroying analysis, so everything expensive is keyed by a *content* key (see
@@ -24,7 +25,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
from musicmouse.library.analysis import BeatGrid, TrackAnalysis
from musicmouse.library.analysis import BeatGrid, TrackAnalysis, TrackCurves
from musicmouse.library.models import Album, LibraryTrack
from musicmouse.library.sections import SECTIONS, AlbumKind
@@ -110,6 +111,16 @@ class LibraryCache:
def store_beats(self, key: str, grid: BeatGrid) -> None:
_write_atomic(self.analysis / f"{key}.beats.json", json.dumps(grid.to_json()))
def load_curve(self, key: str) -> TrackCurves | None:
path = self.analysis / f"{key}.curve.json"
try:
return TrackCurves.from_json(json.loads(path.read_text(encoding="utf-8")))
except (OSError, ValueError, KeyError):
return None
def store_curve(self, key: str, curve: TrackCurves) -> None:
_write_atomic(self.analysis / f"{key}.curve.json", json.dumps(curve.to_json()))
# --------------------------------------------------------------------- index
def load_index(self) -> dict[str, tuple[Album, Fingerprint]]:

View File

@@ -0,0 +1,318 @@
"""A concrete :class:`~musicmouse.library.analysis.Analyzer` built on librosa.
Only reached through :func:`musicmouse.library.analysis.build_analyzer`, so nothing
else in the app ever imports librosa or numpy: a device without the ``analysis`` extra
installed never executes this module at all.
Every scalar here is a signal-processing proxy, not a measurement of how a track
actually feels - ``valence`` most of all, see its section below. Good enough to drive
an ambient background; not a music information retrieval research result.
"""
from __future__ import annotations
import logging
import math
import warnings
from collections.abc import Callable
from pathlib import Path
import librosa
import numpy as np
from musicmouse.library.analysis import ANALYZER_VERSION, BeatGrid, TrackAnalysis, TrackCurves
_log = logging.getLogger(__name__)
__all__ = ["LibrosaAnalyzer"]
#: librosa's own default. Ample for everything below - the highest band that matters,
#: the brightness ceiling, sits well under this rate's 11025 Hz Nyquist frequency.
_SAMPLE_RATE = 22050
_HOP_LENGTH = 512
#: `energy`: the 80th-percentile RMS frame, in dB, mapped floor..ceil to 0..1. -32 dB is
#: a quiet passage, -8 dB is a hot, compressed master. A percentile rather than the mean
#: or max so a quiet intro or a gap between phrases doesn't drag a loud track down, and
#: one clipped peak doesn't blow it out.
_ENERGY_DB_FLOOR = -32.0
_ENERGY_DB_CEIL = -8.0
#: `brightness`: the median spectral centroid (robust to a single loud transient), in
#: Hz, log-mapped floor..ceil to 0..1. 300 Hz is a dark, bass/vocal-heavy mix; 4000 Hz is
#: bright, sparkly production.
_BRIGHTNESS_HZ_FLOOR = 300.0
_BRIGHTNESS_HZ_CEIL = 4000.0
#: `valence`'s tempo term: 60 BPM reads as a lullaby, 150 BPM as a romp.
_TEMPO_BPM_FLOOR = 60.0
_TEMPO_BPM_CEIL = 150.0
#: Key is a global property of a track; the middle minute is its most representative
#: one and this keeps the most expensive feature (chroma) off the tail of a long track.
_CHROMA_EXCERPT_SECONDS = 120.0
#: Seconds per curve sample. Fixed at analysis time, not user-facing - changing this
#: needs a re-analysis and an ANALYZER_VERSION bump, unlike the frontend's own
#: unrelated "curve sample interval" debug slider, which just smooths already-fetched
#: samples for live preview.
_ANALYSIS_HOP_SECONDS = 1.0
#: `drive`'s blend of onset activity (steadier, measured 33-55% relative spread across
#: a track) and local pulse strength (more dynamic but spikier, 59-189%) - weighted
#: toward activity so a busy chorus reads clearly without the current twitching on
#: every transient.
_DRIVE_ACTIVITY_WEIGHT = 0.6
_DRIVE_PLP_WEIGHT = 0.4
#: Krumhansl-Kessler key profiles (Krumhansl & Kessler 1982), starting from C. Every
#: other key is scored by rotating these twelve weights, not by transposing the audio.
_MAJOR_PROFILE = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
_MINOR_PROFILE = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
def _clip01(value: float) -> float:
return max(0.0, min(1.0, value))
def _normalize(value: float, floor: float, ceil: float) -> float:
"""Linear map ``floor..ceil`` to ``0..1``, clipped at both ends."""
return _clip01((value - floor) / (ceil - floor))
def _center_excerpt(y: np.ndarray, sr: float, seconds: float) -> np.ndarray:
max_samples = int(seconds * sr)
if y.size <= max_samples:
return y
start = (y.size - max_samples) // 2
return y[start : start + max_samples]
def _frames_per_window(sr: float, hop_length: int, window_seconds: float) -> int:
return max(1, round(window_seconds * sr / hop_length))
def _windowed(
values: np.ndarray, frames_per_window: int, reduce: Callable[[np.ndarray], float]
) -> np.ndarray:
"""Buckets an already-computed per-frame array (RMS, spectral centroid, onset
envelope, PLP - anything on the STFT hop grid) into `frames_per_window`-wide
windows, applying `reduce` to each. The last window is short rather than dropped,
so a track's tail is never silently excluded from its own curve."""
n = max(1, math.ceil(values.size / frames_per_window))
out = np.empty(n)
for i in range(n):
w = values[i * frames_per_window : (i + 1) * frames_per_window]
out[i] = reduce(w if w.size else values[-1:])
return out
def _energy_window(w: np.ndarray) -> float:
"""The same 80th-percentile+dB statistic as the whole-track `energy` scalar,
applied to one window."""
db = librosa.amplitude_to_db(np.array([np.percentile(w, 80)]), ref=1.0)[0]
return _normalize(float(db), _ENERGY_DB_FLOOR, _ENERGY_DB_CEIL)
def _brightness_window(w: np.ndarray) -> float:
"""The same median+log statistic as the whole-track `brightness` scalar, applied
to one window."""
hz = max(float(np.median(w)), 1.0) # guard log2(0)
floor, ceil = math.log2(_BRIGHTNESS_HZ_FLOOR), math.log2(_BRIGHTNESS_HZ_CEIL)
return _normalize(math.log2(hz), floor, ceil)
def _norm95(values: np.ndarray) -> np.ndarray:
"""Scale so the array's 95th percentile maps to 1.0 - a robust max that one loud
transient can't blow out, the same reasoning as the beat grid's strengths."""
ceiling = float(np.percentile(values, 95))
return values / ceiling if ceiling > 0 else np.zeros_like(values)
def _majorness(chroma_mean: np.ndarray) -> float:
"""Best major key-profile correlation minus best minor one, over all 12 rotations.
Positive means the track's pitch-class distribution fits a major key better than
any minor one; negative the other way round. `np.corrcoef` is undefined for a
perfectly flat chroma vector (silence, pure noise) - `nan_to_num` turns that into
"no signal either way" rather than raising.
"""
def best_fit(profile: np.ndarray) -> float:
return max(
float(np.nan_to_num(np.corrcoef(chroma_mean, np.roll(profile, i))[0, 1]))
for i in range(12)
)
return best_fit(_MAJOR_PROFILE) - best_fit(_MINOR_PROFILE)
class LibrosaAnalyzer:
"""Turns one music file into :class:`TrackAnalysis` plus an optional beat grid
and :class:`TrackCurves`."""
version = ANALYZER_VERSION
def analyze(self, path: Path) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
"""Never raises: a file this can't make sense of is analyzed as "nothing".
A corrupt file, a DRM'd one, or a zero-length one must not abort a batch of
hundreds - the fallback records `version` so :meth:`MusicLibrary.analyze_pending`
does not retry it forever, while every scalar stays `None` so the frontend falls
back to the un-analyzed baseline look rather than something half-computed.
"""
try:
y, sr = librosa.load(path, sr=_SAMPLE_RATE, mono=True)
except Exception:
_log.warning("Could not decode %s; leaving it unanalyzed", path, exc_info=True)
return TrackAnalysis(version=self.version), None, None
if y.size == 0:
_log.warning("%s decoded to no audio; leaving it unanalyzed", path)
return TrackAnalysis(version=self.version), None, None
try:
return self._analyze(y, sr)
except Exception:
_log.warning("Analysis failed for %s; leaving it unanalyzed", path, exc_info=True)
return TrackAnalysis(version=self.version), None, None
def _analyze(
self, y: np.ndarray, sr: float
) -> tuple[TrackAnalysis, BeatGrid | None, TrackCurves | None]:
# One onset envelope feeds tempo, the beat grid's strengths, `pulse` and
# `drive` - the single most expensive shared computation, so it is done once.
onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=_HOP_LENGTH)
tempo_raw, beat_frames = librosa.beat.beat_track(
onset_envelope=onset_env, sr=sr, hop_length=_HOP_LENGTH
)
tempo_bpm = float(np.atleast_1d(tempo_raw)[0])
grid = self._beat_grid(onset_env, beat_frames, sr)
frames_per_window = _frames_per_window(sr, _HOP_LENGTH, _ANALYSIS_HOP_SECONDS)
rms = librosa.feature.rms(y=y, hop_length=_HOP_LENGTH)[0]
energy_curve = _windowed(rms, frames_per_window, _energy_window)
energy = float(np.mean(energy_curve))
centroid = librosa.feature.spectral_centroid(y=y, sr=sr, hop_length=_HOP_LENGTH)[0]
# Scalar `brightness` stays the whole-track median exactly as before this
# refactor - it no longer drives anything on the frontend (only its curve
# feeds `valence` below), so its own meaning is deliberately left unchanged.
brightness = _brightness_window(centroid)
brightness_curve = _windowed(centroid, frames_per_window, _brightness_window)
majorness_norm, tempo_norm = self._valence_terms(y, sr, tempo_bpm)
valence_curve = np.clip(
0.5 * majorness_norm + 0.3 * brightness_curve + 0.2 * tempo_norm, 0.0, 1.0
)
valence = float(np.mean(valence_curve))
pulse = self._pulse(onset_env, sr, tempo_bpm)
drive_curve = self._drive_curve(onset_env, sr, frames_per_window)
curves = TrackCurves(
hop_seconds=_ANALYSIS_HOP_SECONDS,
energy=tuple(float(v) for v in energy_curve),
valence=tuple(float(v) for v in valence_curve),
drive=tuple(float(v) for v in drive_curve),
)
analysis = TrackAnalysis(
version=self.version,
tempo=tempo_bpm,
energy=energy,
valence=valence,
brightness=brightness,
pulse=pulse,
beats=grid is not None,
)
return analysis, grid, curves
def _beat_grid(
self, onset_env: np.ndarray, beat_frames: np.ndarray, sr: float
) -> BeatGrid | None:
if beat_frames.size == 0:
return None
times = librosa.frames_to_time(beat_frames, sr=sr, hop_length=_HOP_LENGTH)
raw_strengths = onset_env[np.clip(beat_frames, 0, onset_env.size - 1)]
# The 95th percentile rather than the max, so one loud crash does not flatten
# every other beat's strength toward zero.
scale = float(np.percentile(raw_strengths, 95))
strengths = raw_strengths / scale if scale > 0 else np.zeros_like(raw_strengths)
return BeatGrid(
tuple(float(t) for t in times),
tuple(_clip01(float(s)) for s in strengths),
)
def _valence_terms(self, y: np.ndarray, sr: float, tempo_bpm: float) -> tuple[float, float]:
"""majorness_norm, tempo_norm - the two whole-track-constant terms of the
valence formula. See the module docstring for why valence is a heuristic, not
a measurement.
Weighted for a *children's* library specifically: mode (major/minor) is the
strongest and most legible cue in this repertoire - a minor-key children's song
is almost always deliberately sad or spooky, unlike in pop where mode is a much
weaker signal. Tempo adds romp-vs-lullaby. Both stay whole-track constants:
key is a global property of a track, and (unlike brightness) chroma-based key
detection is too expensive and too noisy over a short window to be worth
computing per second for what is now only a secondary, "nudge" contribution to
color - see `TrackCurves.valence`.
"""
excerpt = _center_excerpt(y, sr, _CHROMA_EXCERPT_SECONDS)
chroma = librosa.feature.chroma_cqt(y=excerpt, sr=sr, hop_length=_HOP_LENGTH)
majorness = _majorness(chroma.mean(axis=1))
majorness_norm = _clip01((majorness + 1.0) / 2.0)
tempo_norm = _normalize(tempo_bpm, _TEMPO_BPM_FLOOR, _TEMPO_BPM_CEIL)
return majorness_norm, tempo_norm
def _drive_curve(self, onset_env: np.ndarray, sr: float, frames_per_window: int) -> np.ndarray:
"""Rhythmic intensity per window, 0..1, normalised *within the track*.
This - not tempo - is what the frontend's water current breathes with while a
track plays. Measured against a real library, local tempo is flat to within a
few percent inside a track (recorded children's music is played to a click);
the residual "variation" a per-second tempo curve would show is mostly
estimator noise, including occasional octave errors. Onset activity and local
pulse strength (PLP) both genuinely vary within a track (33-55% and 59-189%
relative spread respectively) and don't carry that failure mode.
Per-track normalisation (each component scaled by its own 95th percentile) is
deliberate: absolute "how energetic is this song" is already carried by
whole-track `tempo` (the current's base magnitude) and by `energy` (colour).
This curve is for relative shape within the track - the intro is calmer than
the chorus - so every track uses its own full 0..1 range rather than a
uniformly quiet song sitting flat near zero throughout.
"""
activity = _windowed(onset_env, frames_per_window, lambda w: float(np.mean(w)))
plp = librosa.beat.plp(onset_envelope=onset_env, sr=sr, hop_length=_HOP_LENGTH)
pulse_curve = _windowed(plp, frames_per_window, lambda w: float(np.mean(w)))
activity_term = _DRIVE_ACTIVITY_WEIGHT * _norm95(activity)
pulse_term = _DRIVE_PLP_WEIGHT * _norm95(pulse_curve)
return np.clip(activity_term + pulse_term, 0.0, 1.0)
def _pulse(self, onset_env: np.ndarray, sr: float, tempo_bpm: float) -> float:
"""0..1 confidence that `tempo` is an audible, steady beat.
The onset envelope's autocorrelation at the beat period, relative to its value
at lag zero: a track that truly pulses at `tempo` has a strong echo of itself
one beat later; free-tempo or spoken-word material does not, even though
`beat_track` always returns *some* grid for it.
"""
if tempo_bpm <= 0 or onset_env.size < 2:
return 0.0
period_frames = round((60.0 / tempo_bpm) * sr / _HOP_LENGTH)
if not 0 < period_frames < onset_env.size:
return 0.0
with warnings.catch_warnings():
# A known-spurious warning from numba's complex-magnitude dufunc under
# this numba/numpy/librosa combination (confirmed: the input here has no
# NaN/Inf, and the result is a normal finite float) - narrowly silenced
# rather than left to spam the log once per track analyzed.
warnings.filterwarnings(
"ignore", message="invalid value encountered in cast", category=RuntimeWarning
)
ac = librosa.autocorrelate(onset_env)
if ac[0] <= 0:
return 0.0
return _clip01(float(ac[period_frames] / ac[0]))

View File

@@ -34,7 +34,6 @@ from musicmouse.events import (
from musicmouse.services.web.hub import StateHub
from musicmouse.services.web.schemas import (
AlbumOut,
BeatsOut,
HaConfigOut,
HaDeviceOut,
LibraryOut,
@@ -43,6 +42,8 @@ from musicmouse.services.web.schemas import (
SeekIn,
SettingsIn,
SettingsOut,
TrackCurvesOut,
TrackDetailOut,
VolumeIn,
)
from musicmouse.services.web.settings import (
@@ -86,11 +87,16 @@ def build_router(
)
@router.get("/tracks/{album_id}/{index}/analysis")
def get_track_analysis(album_id: str, index: int) -> BeatsOut:
def get_track_analysis(album_id: str, index: int) -> TrackDetailOut:
grid = app.library.beats(album_id, index)
if grid is None:
curve = app.library.curve(album_id, index)
if grid is None and curve is None:
raise HTTPException(status_code=404, detail="not analyzed")
return BeatsOut(times=list(grid.times), strengths=list(grid.strengths))
return TrackDetailOut(
times=list(grid.times) if grid else [],
strengths=list(grid.strengths) if grid else [],
curve=TrackCurvesOut(**curve.to_json()) if curve else None,
)
@router.post("/library/refresh", status_code=202)
async def refresh_library() -> Response:

View File

@@ -15,7 +15,6 @@ from musicmouse.library.analysis import TrackAnalysis
__all__ = [
"AlbumOut",
"BeatsOut",
"HaConfigOut",
"HaDeviceOut",
"LibraryOut",
@@ -24,6 +23,8 @@ __all__ = [
"SeekIn",
"SettingsIn",
"SettingsOut",
"TrackCurvesOut",
"TrackDetailOut",
"TrackOut",
"VolumeIn",
]
@@ -34,6 +35,8 @@ class AnalysisOut(BaseModel):
energy: float | None = None
valence: float | None = None
brightness: float | None = None
#: 0..1 confidence that `tempo` is an audible, steady beat - see `TrackAnalysis`.
pulse: float | None = None
beats: bool = False
@classmethod
@@ -45,6 +48,7 @@ class AnalysisOut(BaseModel):
energy=analysis.energy,
valence=analysis.valence,
brightness=analysis.brightness,
pulse=analysis.pulse,
beats=analysis.beats,
)
@@ -99,9 +103,23 @@ class LibraryOut(BaseModel):
albums: list[AlbumOut]
class BeatsOut(BaseModel):
class TrackCurvesOut(BaseModel):
hop_seconds: float
energy: list[float]
valence: list[float]
drive: list[float]
class TrackDetailOut(BaseModel):
"""Beats + mood/drive curves for the one track currently playing - fetched
together since both are per-track detail the library payload never carries.
``curve`` is ``None`` only when the whole track failed analysis; ``times``/
``strengths`` are empty (not ``None``) for a track with no reliable beat, since a
free-tempo or spoken-word track still has real energy/valence/drive curves."""
times: list[float]
strengths: list[float]
curve: TrackCurvesOut | None = None
class ConnectionOut(BaseModel):

View File

@@ -24,6 +24,10 @@ dependencies = [
[project.optional-dependencies]
dev = ["mypy>=1.10", "pytest-asyncio>=0.23", "pytest>=8.0", "ruff>=0.5"]
# Off by default: the reactive background is nice, not required, and librosa/numpy are
# a real install cost on a Pi. Missing this group means `build_analyzer()` falls back
# to `NullAnalyzer` and the background stays at its static baseline - never a crash.
analysis = ["librosa>=1.0", "numpy>=2.0"]
[project.scripts]
musicmouse = "musicmouse.__main__:main"
@@ -62,5 +66,7 @@ files = ["musicmouse"]
warn_unreachable = true
[[tool.mypy.overrides]]
module = ["vlc", "serial_asyncio", "ruamel.*"]
# numpy ships its own types, but only when the `analysis` extra is installed - a plain
# checkout must still type-check clean, so it needs the same treatment as librosa.
module = ["vlc", "serial_asyncio", "ruamel.*", "librosa.*", "numpy"]
ignore_missing_imports = true

View File

@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""One-off: backfill cover art for the podcast shows already in the library.
The scanner now reads a podcast episode's own embedded art first, and a show folder's
``cover.jpg`` second (see ``musicmouse.library.scanner._cover_for_episode``) - but
episodes downloaded before today rarely carry per-episode art, and none of the shows
have a folder-level cover on disk yet. This script fills that gap once, by hand, so it
is not something the running app does on its own.
For every ``Kinderpodcasts/<show>`` folder that has no ``cover.jpg``/``.jpeg``/``.png``
already, it looks the show up on the public iTunes Search API and saves the top match's
artwork as ``cover.jpg`` in that folder. A show it cannot match confidently is left
alone and logged, rather than guessed at - check those by hand afterwards.
This makes real network requests to a third-party service and writes into the real
music library, so it is meant to be run and reviewed by a person, not called from the
app:
python scripts/fetch_podcast_covers.py --config /path/to/config.yml
python scripts/fetch_podcast_covers.py --config /path/to/config.yml --dry-run
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from musicmouse.config import load_config
_log = logging.getLogger("fetch_podcast_covers")
#: Mirrors scanner.py's `_COVER_NAMES` - what counts as "already has a cover".
_COVER_NAMES = ("cover.jpg", "cover.jpeg", "cover.png", "folder.jpg")
_ITUNES_SEARCH = "https://itunes.apple.com/search"
_TIMEOUT = 10.0
def _has_cover(folder: Path) -> bool:
return any((folder / name).is_file() for name in _COVER_NAMES)
def _find_artwork_url(show_name: str) -> str | None:
query = urllib.parse.urlencode({"media": "podcast", "term": show_name, "limit": 1})
try:
with urllib.request.urlopen(f"{_ITUNES_SEARCH}?{query}", timeout=_TIMEOUT) as response:
body = json.loads(response.read())
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
_log.warning("Lookup for %r failed: %s", show_name, error)
return None
results = body.get("results") or []
if not results:
return None
# iTunes serves a 100x100 thumbnail by default; ask for something worth showing.
artwork = results[0].get("artworkUrl100")
return artwork.replace("100x100", "600x600") if artwork else None
def backfill(root: Path, *, dry_run: bool) -> None:
podcasts_root = root / "Kinderpodcasts"
if not podcasts_root.is_dir():
_log.warning("No Kinderpodcasts folder at %s", podcasts_root)
return
for folder in sorted(podcasts_root.iterdir()):
if not folder.is_dir() or folder.name.startswith("."):
continue
if _has_cover(folder):
_log.info("%-40s already has a cover, skipping", folder.name)
continue
url = _find_artwork_url(folder.name)
if url is None:
_log.warning("%-40s no confident match - fetch this one by hand", folder.name)
continue
_log.info("%-40s -> %s", folder.name, url)
if dry_run:
continue
try:
with urllib.request.urlopen(url, timeout=_TIMEOUT) as response:
data = response.read()
except (urllib.error.URLError, TimeoutError) as error:
_log.warning("%-40s download failed: %s", folder.name, error)
continue
(folder / "cover.jpg").write_bytes(data)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("-c", "--config", type=Path, required=True, help="path to config.yml")
parser.add_argument(
"--dry-run",
action="store_true",
help="print what would be fetched without downloading or writing anything",
)
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(message)s")
config = load_config(args.config)
backfill(config.general.library.root, dry_run=args.dry_run)
return 0
if __name__ == "__main__":
sys.exit(main())

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