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