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`;

View File

@@ -7,9 +7,33 @@ export interface TrackAnalysis {
energy: number | null;
valence: number | null;
brightness: number | null;
/** 0..1 confidence that `tempo` is an audible, steady beat rather than an artifact
* of free-tempo or spoken-word material - below some threshold, don't pulse on it. */
pulse: number | null;
beats: boolean;
}
/** Per-second energy/valence/drive samples - the parts of the mood/rhythm formulas
* that vary *within* a track. Regularly sampled, so just a hop and equal-length
* arrays, no per-sample timestamps. Mirrors `TrackCurvesOut`. */
export interface TrackCurves {
hop_seconds: number;
energy: number[];
valence: number[];
drive: number[];
}
/** A track's beat grid plus its mood/drive curves, fetched together from
* `GET /api/tracks/{album_id}/{index}/analysis` - only for the one track that is
* playing, never inside the library payload. `curve` is `null` only when the whole
* track failed analysis; `times`/`strengths` are empty (not absent) for a track with
* no reliable beat, since energy/valence/drive are still real. Mirrors `TrackDetailOut`. */
export interface TrackDetail {
times: number[];
strengths: number[];
curve: TrackCurves | null;
}
export interface Track {
title: string;
/** Seconds, read from the file's tags at scan time. */