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

@@ -1,6 +1,6 @@
/** Every call the UI makes. Commands are fire-and-forget: the websocket reports back. */
import type { Album, HaConfig, HaEntityState, PlayerState, Settings } from "./types";
import type { Album, HaConfig, HaEntityState, PlayerState, Settings, TrackDetail } from "./types";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`/api${path}`, {
@@ -47,6 +47,14 @@ async function fetchHaStates(entityIds: string[]): Promise<Record<string, HaEnti
return byId;
}
/** `null` means "not analyzed" (the backend's expected 404 for this), not an error -
* the ambient background just falls back to its un-analyzed baseline for that track. */
async function fetchTrackDetail(albumId: string, trackIndex: number): Promise<TrackDetail | null> {
const response = await fetch(`/api/tracks/${albumId}/${trackIndex}/analysis`);
if (!response.ok) return null;
return (await response.json()) as TrackDetail;
}
export const api = {
library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums),
state: () => request<PlayerState>("/state"),
@@ -70,6 +78,8 @@ export const api = {
haStates: fetchHaStates,
haCallService: (domain: string, service: string, body: Record<string, unknown>) =>
post(`/ha/services/${domain}/${service}`, body),
trackDetail: fetchTrackDetail,
};
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;