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

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