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

@@ -0,0 +1,44 @@
/** The current track's beat grid and mood/drive curves, fetched lazily - never inside
* the library payload, and never for a track that has no analysis of any kind.
*
* Fetched whenever the current track is music and has *any* analysis - unlike the
* beat grid's own trustworthiness gate (a spoken-word track or a free-tempo lullaby
* always produces *some* grid from the beat tracker, and pulsing the background to a
* beat nobody can actually hear looks broken), which is a decision about whether the
* already-fetched beats are used, not whether they're fetched at all - see the
* `PULSE_THRESHOLD` gate in `Ambience.tsx`. The energy/valence/drive curves this also
* carries have no such caveat: they drive color and current for every analyzed music
* track regardless of beat quality.
*/
import { useEffect, useState } from "react";
import { api } from "../api/client";
import type { Album, PlayerState, TrackDetail } from "../api/types";
import { groupOf } from "../lib/search";
export function useTrackDetail(album: Album | null, state: PlayerState): TrackDetail | null {
const [detail, setDetail] = useState<TrackDetail | null>(null);
const albumId = state.album_id;
const trackIndex = state.track_index;
const analysis = album?.tracks[trackIndex]?.analysis ?? null;
const eligible =
album !== null && albumId !== null && groupOf(album) === "music" && analysis !== null;
useEffect(() => {
// Cleared up front, not just on failure - a track change must not keep the
// previous track's detail visible while the new one is still in flight.
setDetail(null);
if (!eligible || albumId === null) return;
let cancelled = false;
void api.trackDetail(albumId, trackIndex).then((result) => {
if (!cancelled) setDetail(result);
});
return () => {
cancelled = true;
};
}, [eligible, albumId, trackIndex]);
return detail;
}