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

@@ -11,9 +11,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api } from "./api/client";
import type { Album, HaConfig } from "./api/types";
import { AlbumModal } from "./components/AlbumModal";
import { Ambience, type AmbienceDebugSnapshot } from "./components/Ambience";
import { AmbienceDebugOverlay } from "./components/AmbienceDebugOverlay";
import { AppHeader } from "./components/AppHeader";
import { BrowseView } from "./components/BrowseView";
import { Bubbles } from "./components/Bubbles";
import { HelpOverlay } from "./components/HelpOverlay";
import { ParentPanel } from "./components/ParentPanel";
import { PlayerBar } from "./components/PlayerBar";
@@ -22,6 +23,8 @@ import { RoomView } from "./components/RoomView";
import { useGridColumns } from "./hooks/useGridColumns";
import { useLibrary } from "./hooks/useLibrary";
import { usePlayerState } from "./hooks/usePlayerState";
import { DEFAULT_MANUAL_CONTROL, DEFAULT_TUNABLES } from "./lib/ambienceTunables";
import type { AmbienceTunables, ManualControl } from "./lib/ambienceTunables";
import type { Action, UiState } from "./lib/keyboard";
import { handleKey, initialUiState } from "./lib/keyboard";
import { playPop } from "./lib/pop";
@@ -43,6 +46,15 @@ export function App() {
const [parentMode, setParentMode] = useState(
() => new URLSearchParams(location.search).get("parentMode") === "1",
);
const [debugDynamicUI] = useState(
() => new URLSearchParams(location.search).get("debugDynamicUI") === "1",
);
const [debugSnapshot, setDebugSnapshot] = useState<AmbienceDebugSnapshot | null>(null);
// Exists regardless of `debugDynamicUI` (so `Ambience` always has consistent,
// harmless defaults - `manual.enabled` defaults to false) - only the overlay, and
// thus the ability to change either, is gated behind the query param.
const [tunables, setTunables] = useState<AmbienceTunables>(DEFAULT_TUNABLES);
const [manual, setManual] = useState<ManualControl>(DEFAULT_MANUAL_CONTROL);
// undefined: not yet resolved (hide the nav pill to avoid a flash). null: confirmed
// absent - the room page is a separate opt-in feature, off by default.
const [haConfig, setHaConfig] = useState<HaConfig | null | undefined>(undefined);
@@ -228,7 +240,15 @@ export function App() {
return (
<div className="stage">
<Bubbles />
{state && (
<Ambience
album={currentAlbum}
state={state}
tunables={tunables}
manual={manual}
onDebugFrame={debugDynamicUI ? setDebugSnapshot : undefined}
/>
)}
{ui.view === "browse" && state && (
<div
@@ -346,6 +366,17 @@ export function App() {
{parentMode && <ParentPanel onClose={() => setParentMode(false)} />}
{(!state || library.loading) && <Splash error={library.error} />}
{debugDynamicUI && debugSnapshot && (
<AmbienceDebugOverlay
snapshot={debugSnapshot}
tunables={tunables}
onTunablesChange={setTunables}
manual={manual}
onManualChange={setManual}
playing={state?.playing ?? false}
/>
)}
</div>
);
}