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