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,472 @@
/** The background: a gradient plus a bubble field, both painted on one canvas.
*
* Replaces the old fixed 21-`<div>` field (`bubbleRise` in app.css) with a real
* particle loop, because "spawn frequency" and "current" need a rate and a
* magnitude, not a fixed set of `animation-duration`s. The gradient moves to canvas
* too, for the same reason: CSS cannot interpolate `linear-gradient` colour stops
* between tracks, a canvas can just ease toward a new target every frame.
*
* `energy`/`valence`/`drive` are now sampled from a per-second curve at the current
* playback position, so - unlike the original version of this component - the
* mapping from analysis to visuals can no longer run once per track. It runs every
* frame instead (`ambienceFromSampled` below), fed by curve samples smoothed with an
* adjustable time constant (`tunables.curveSmoothingTau`).
*
* One `requestAnimationFrame` loop, started once and never restarted - everything it
* reads (`playing`, tunables, manual control, the per-track base, the beat/curve
* detail, the playback clock) lives in refs, so an album or track change, or a debug
* slider moving, never costs a re-render of this component, let alone a dropped
* frame. This mirrors `usePlaybackClock`'s anchor trick (a `{position, at}` pair
* re-seeded from the server, read against `performance.now()`) but reads it from
* inside the loop instead of `useState`, which is what keeps 60fps from touching
* React at all.
*/
import { useEffect, useRef } from "react";
import type { Album, PlayerState, TrackAnalysis } from "../api/types";
import { useTrackDetail } from "../hooks/useTrackDetail";
import {
ambienceBaseFor,
ambienceColorAt,
currentMagnitudeAt,
currentVelocityAt,
effectiveCurve,
oklchString,
sampleCurveAt,
spawnRateAt,
tempoToMagnitude,
tempoToRise,
type Ambience as AmbienceTarget,
type AmbienceBase,
type Oklch,
} from "../lib/ambience";
import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables";
import { groupOf, type Group } from "../lib/search";
/** Everything `?debugDynamicUI=1` wants to see: the raw analysis, what it maps to,
* and where the eased, on-screen look currently sits relative to that target. */
export interface AmbienceDebugSnapshot {
group: Group | "idle";
trackTitle: string | null;
playing: boolean;
position: number;
analysis: TrackAnalysis | null;
target: AmbienceTarget;
current: AmbienceTarget;
sampledEnergy: number;
sampledValence: number;
sampledDrive: number;
/** The fully beat-kicked current magnitude actually driving bubbles this frame -
* distinct from `current.currentMagnitude`, which is pre-kick. */
currentMagnitude: number;
kick: number;
beatsTrusted: boolean;
beatGridLoaded: boolean;
beatCount: number;
beatCursor: number;
bubbleCount: number;
/** Whether a real fetched curve is in use, vs. the flat scalar-derived fallback. */
curveLoaded: boolean;
/** Whether manual control is *actually* driving the scene right now - distinct from
* `manual.enabled`, which can stay checked across a play/pause cycle. */
usingManual: boolean;
}
interface Props {
album: Album | null;
state: PlayerState;
tunables: AmbienceTunables;
manual: ManualControl;
/** Wired up only behind `?debugDynamicUI=1` - see `AmbienceDebugOverlay`. Throttled
* internally, so passing this costs nothing close to every frame's React render. */
onDebugFrame?: (snapshot: AmbienceDebugSnapshot) => void;
}
//: The debug overlay doesn't need 60fps, and re-rendering React that often just to
//: feed it would defeat the whole point of keeping this loop ref-only.
const DEBUG_EMIT_INTERVAL_MS = 200;
//: Below this, `pulse` says the detected tempo is not a trustworthy, audible beat -
//: moved here from the fetch hook, since it now gates *use* (does this beat crossing
//: drive the kick), not *fetch* (the curve is wanted regardless of beat quality).
const PULSE_THRESHOLD = 0.3;
interface Bubble {
/** Horizontal position in CSS pixels - stateful, unlike the old sine-wander's fixed
* anchor: a persistent one-directional current has to be integrated over time. */
x: number;
/** Pixels risen since spawn. */
risen: number;
/** Size at spawn. */
baseSize: number;
/** Current painted size - grows from `baseSize` as the bubble rises. */
size: number;
baseAlpha: number;
}
//: How quickly the painted look eases toward the per-frame target - an exponential
//: time constant, so this is "how many seconds to close most of the gap", not a hard
//: crossfade duration. ~0.5s reaches ~95% of the way there in about 1.5s. Distinct
//: from `tunables.curveSmoothingTau`, which smooths the sampled signal *feeding*
//: the target, not the ease from `current` toward it - the two compound in series.
const CROSSFADE_TAU_SECONDS = 0.5;
//: How quickly a beat's kick fades back out.
const KICK_DECAY_TAU_SECONDS = 0.12;
//: Extra bubbles/second at the moment of a full-strength (1.0) beat - kept exactly as
//: before this refactor; only the current-magnitude boost is new.
const BEAT_BURST_RATE = 6;
//: Today's rise speed, roughly: the old CSS took a bubble ~10s to cross 120vh.
const BASE_RISE_FRACTION_PER_SECOND = 0.12;
const BUBBLE_MIN_SIZE = 8;
const BUBBLE_MAX_SIZE = 24;
//: The canvas frame clamps to this, so a tab returning from the background after
//: minutes away doesn't dump a giant `dt` into the physics in one jump.
const MAX_FRAME_SECONDS = 0.05;
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function lerpColor(a: Oklch, b: Oklch, t: number): Oklch {
return { l: lerp(a.l, b.l, t), c: lerp(a.c, b.c, t), h: lerp(a.h, b.h, t) };
}
function lerpAmbience(a: AmbienceTarget, b: AmbienceTarget, t: number): AmbienceTarget {
return {
gradient: [
lerpColor(a.gradient[0], b.gradient[0], t),
lerpColor(a.gradient[1], b.gradient[1], t),
lerpColor(a.gradient[2], b.gradient[2], t),
],
tint: lerpColor(a.tint, b.tint, t),
spawnRate: lerp(a.spawnRate, b.spawnRate, t),
rise: lerp(a.rise, b.rise, t),
currentMagnitude: lerp(a.currentMagnitude, b.currentMagnitude, t),
};
}
/** First index whose beat time is `>= position` - a binary search, since a track's
* grid can run to several thousand entries and this runs every frame. */
function beatCursorFor(times: number[], position: number): number {
let lo = 0;
let hi = times.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (times[mid]! < position) lo = mid + 1;
else hi = mid;
}
return lo;
}
function spawnBubble(width: number): Bubble {
const baseSize = BUBBLE_MIN_SIZE + Math.random() * (BUBBLE_MAX_SIZE - BUBBLE_MIN_SIZE);
return {
x: Math.random() * width,
risen: 0,
baseSize,
size: baseSize,
baseAlpha: 0.25 + Math.random() * 0.2,
};
}
/** Turns already-smoothed energy/valence/drive into a full `Ambience` - shared
* between the mount-time seed (unsmoothed, snapped straight to the first sample) and
* every subsequent frame (smoothed with `tunables.curveSmoothingTau`), so the two
* paths can never disagree on what a given (energy, valence, drive) triple means. */
function ambienceFromSampled(
base: AmbienceBase,
usingManual: boolean,
manual: ManualControl,
tunables: AmbienceTunables,
energy: number,
valence: number,
drive: number,
): AmbienceTarget {
if (!usingManual && !base.music) {
return { ...base.fixed!, rise: base.rise, currentMagnitude: 0 };
}
const rise = usingManual ? tempoToRise(manual.tempo) : base.rise;
const baseMagnitude = usingManual ? tempoToMagnitude(manual.tempo) : base.baseCurrentMagnitude;
const { gradient, tint } = ambienceColorAt(energy, valence, tunables);
const magnitude = currentMagnitudeAt(baseMagnitude, drive, tunables.driveCurrentGain);
return { gradient, tint, spawnRate: spawnRateAt(energy), rise, currentMagnitude: magnitude };
}
export function Ambience({ album, state, tunables, manual, onDebugFrame }: Props) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const detail = useTrackDetail(album, state);
const playingRef = useRef(state.playing);
playingRef.current = state.playing;
const detailRef = useRef(detail);
detailRef.current = detail;
const tunablesRef = useRef(tunables);
tunablesRef.current = tunables;
const manualRef = useRef(manual);
manualRef.current = manual;
const onDebugFrameRef = useRef(onDebugFrame);
onDebugFrameRef.current = onDebugFrame;
const groupRef = useRef<Group | "idle">(album ? groupOf(album) : "idle");
groupRef.current = album ? groupOf(album) : "idle";
const trackTitleRef = useRef(state.track_title);
trackTitleRef.current = state.track_title;
// The same anchor trick as `usePlaybackClock`, but read from inside the rAF loop
// rather than through `useState` - see the module docstring.
const clockAnchor = useRef({ position: state.position, at: performance.now() });
useEffect(() => {
clockAnchor.current = { position: state.position, at: performance.now() };
}, [state.position]);
const trackAnalysis = album?.tracks[state.track_index]?.analysis ?? null;
const analysisRef = useRef(trackAnalysis);
analysisRef.current = trackAnalysis;
const baseRef = useRef(ambienceBaseFor(album, trackAnalysis));
useEffect(() => {
baseRef.current = ambienceBaseFor(album, trackAnalysis);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [album, trackAnalysis]);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const size = { width: 0, height: 0 };
// The CSS `.ambience` rule pins this canvas to 100% of `.stage`, independent of
// its own width/height attributes - required so this handler's own writes below
// can't feed back into the size it just measured (see the comment there). The
// clamp is a second line of defence against the same failure mode from anywhere
// else: an unclamped size can hit the browser's canvas allocation limit and throw.
const MAX_BACKING_STORE_PX = 4096;
const resize = () => {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
size.width = rect.width;
size.height = rect.height;
canvas.width = Math.min(MAX_BACKING_STORE_PX, Math.max(1, Math.round(rect.width * dpr)));
canvas.height = Math.min(MAX_BACKING_STORE_PX, Math.max(1, Math.round(rect.height * dpr)));
// Scale factors from the (possibly clamped) backing store, not raw `dpr`, so
// drawing in CSS-pixel units still lands correctly even when clamped.
const scaleX = canvas.width / Math.max(1, rect.width);
const scaleY = canvas.height / Math.max(1, rect.height);
ctx.setTransform(scaleX, 0, 0, scaleY, 0, 0);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
const media = matchMedia("(prefers-reduced-motion: reduce)");
let reducedMotion = media.matches;
const onMotionChange = () => {
reducedMotion = media.matches;
};
media.addEventListener("change", onMotionChange);
let bubbles: Bubble[] = [];
let spawnAccumulator = 0;
let beatCursor = 0;
let kick = 0;
let manualBeatTimer = 0;
let lastFrame = performance.now();
let lastDebugEmit = 0;
let raf = 0;
// Seeded from the first real sample, unsmoothed, so the first paint doesn't flash
// from a hardcoded baseline toward wherever the track's mood actually starts.
let smoothedEnergy = 0.5;
let smoothedValence = 0.5;
let smoothedDrive = 1.0;
{
const base0 = baseRef.current;
const manual0 = manualRef.current;
const usingManual0 = manual0.enabled && !playingRef.current;
if (usingManual0) {
smoothedEnergy = manual0.energy;
smoothedValence = manual0.valence;
smoothedDrive = manual0.drive;
} else if (base0.music) {
const curve0 = effectiveCurve(analysisRef.current, detailRef.current);
const sampled0 = sampleCurveAt(curve0, clockAnchor.current.position);
smoothedEnergy = sampled0.energy;
smoothedValence = sampled0.valence;
smoothedDrive = sampled0.drive;
}
}
let current: AmbienceTarget = ambienceFromSampled(
baseRef.current,
manualRef.current.enabled && !playingRef.current,
manualRef.current,
tunablesRef.current,
smoothedEnergy,
smoothedValence,
smoothedDrive,
);
const tick = (now: number) => {
const dt = Math.min(MAX_FRAME_SECONDS, (now - lastFrame) / 1000);
lastFrame = now;
const base = baseRef.current;
const tunables = tunablesRef.current;
const manual = manualRef.current;
const playing = playingRef.current;
// Real playback always wins the instant it starts - manual mode only ever has
// an effect while genuinely nothing is playing, so the two inputs can never
// fight over the same frame.
const usingManual = manual.enabled && !playing;
const effectivePlaying = playing || usingManual; // manual mode still animates
const anchor = clockAnchor.current;
const position = playing ? anchor.position + (now - anchor.at) / 1000 : anchor.position;
const sampled = usingManual
? { energy: manual.energy, valence: manual.valence, drive: manual.drive }
: sampleCurveAt(effectiveCurve(analysisRef.current, detailRef.current), position);
const smoothing =
tunables.curveSmoothingTau <= 0 ? 1 : 1 - Math.exp(-dt / tunables.curveSmoothingTau);
smoothedEnergy = lerp(smoothedEnergy, sampled.energy, smoothing);
smoothedValence = lerp(smoothedValence, sampled.valence, smoothing);
smoothedDrive = lerp(smoothedDrive, sampled.drive, smoothing);
const target = ambienceFromSampled(
base,
usingManual,
manual,
tunables,
smoothedEnergy,
smoothedValence,
smoothedDrive,
);
current = lerpAmbience(current, target, 1 - Math.exp(-dt / CROSSFADE_TAU_SECONDS));
const analysis = analysisRef.current;
const beatsTrusted =
analysis !== null && analysis.beats && (analysis.pulse ?? 0) >= PULSE_THRESHOLD;
const grid = beatsTrusted && !usingManual ? detailRef.current : null;
if (usingManual) {
// A continuous fake metronome at the manual tempo, instead of walking a real
// grid - reuses the same `kick` variable and decay as real beats, so every
// downstream effect (spawn burst, current boost) is identical either way.
manualBeatTimer -= dt;
if (manualBeatTimer <= 0) {
kick = Math.max(kick, manual.beatStrength);
manualBeatTimer = 60 / Math.max(1, manual.tempo);
}
} else if (grid && grid.times.length > 0) {
// Playback normally only moves forward, so the common case is just advancing
// the cursor with the `while` below. A backward seek is the one thing that
// can break that invariant - detected by the previously consumed beat now
// being back in the future - and needs a real re-search.
if (beatCursor > 0 && grid.times[beatCursor - 1]! > position) {
beatCursor = beatCursorFor(grid.times, position);
}
while (beatCursor < grid.times.length && grid.times[beatCursor]! <= position) {
kick = Math.max(kick, grid.strengths[beatCursor] ?? 0);
beatCursor++;
}
} else {
beatCursor = 0;
}
kick *= Math.exp(-dt / KICK_DECAY_TAU_SECONDS);
const kickedMagnitude = current.currentMagnitude * (1 + kick * tunables.beatCurrentFactor);
if (effectivePlaying) {
spawnAccumulator += (current.spawnRate + kick * BEAT_BURST_RATE) * dt;
while (spawnAccumulator >= 1) {
spawnAccumulator -= 1;
bubbles.push(spawnBubble(size.width));
}
}
// Not playing (and not in manual mode): no new spawns, but bubbles already in
// flight keep rising and draining away rather than freezing mid-air.
const riseSpeed = current.rise * size.height * BASE_RISE_FRACTION_PER_SECOND;
bubbles = bubbles.filter((b) => {
const heightFraction = clamp01(b.risen / size.height);
const velocity = currentVelocityAt(heightFraction, kickedMagnitude);
b.x = (((b.x + velocity * dt) % size.width) + size.width) % size.width;
b.risen += riseSpeed * dt;
b.size = b.baseSize * (1 + tunables.bubbleGrowthRate * heightFraction);
return b.risen < size.height + BUBBLE_MAX_SIZE * 2;
});
ctx.clearRect(0, 0, size.width, size.height);
const [top, mid, deep] = current.gradient;
const gradient = ctx.createLinearGradient(0, 0, 0, size.height);
gradient.addColorStop(0, oklchString(top));
gradient.addColorStop(0.45, oklchString(mid));
gradient.addColorStop(1, oklchString(deep));
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, size.width, size.height);
if (!reducedMotion) {
for (const b of bubbles) {
const y = size.height + BUBBLE_MAX_SIZE - b.risen;
const topThird = size.height / 3;
const fade = y < topThird ? Math.max(0, y / topThird) : 1;
ctx.beginPath();
ctx.fillStyle = oklchString(current.tint, b.baseAlpha * fade);
ctx.arc(b.x, y, b.size / 2, 0, Math.PI * 2);
ctx.fill();
}
}
const debugFrame = onDebugFrameRef.current;
if (debugFrame && now - lastDebugEmit >= DEBUG_EMIT_INTERVAL_MS) {
lastDebugEmit = now;
debugFrame({
group: groupRef.current,
trackTitle: trackTitleRef.current,
playing,
position,
analysis,
target,
current,
sampledEnergy: smoothedEnergy,
sampledValence: smoothedValence,
sampledDrive: smoothedDrive,
currentMagnitude: kickedMagnitude,
kick,
beatsTrusted,
beatGridLoaded: grid !== null,
beatCount: grid?.times.length ?? 0,
beatCursor,
bubbleCount: bubbles.length,
curveLoaded: detailRef.current?.curve != null,
usingManual,
});
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
media.removeEventListener("change", onMotionChange);
};
}, []);
return (
<canvas
ref={canvasRef}
className="ambience"
aria-hidden="true"
style={{ position: "absolute", inset: 0, zIndex: 0, pointerEvents: "none" }}
/>
);
}

View File

@@ -0,0 +1,329 @@
/** `?debugDynamicUI=1`: every number the reactive background is currently using, laid
* out so a "why does this track look wrong" question can be answered by eye - the raw
* analysis, what it's sampled to right now, what that maps to, and where the eased
* on-screen look currently sits relative to that target (the crossfade, beat kicks
* and so on are otherwise invisible except as motion). Also the live controls: 5
* always-on tunable sliders, and a manual-control mode that takes over the whole
* pipeline whenever nothing is actually playing - a playground for developing a feel
* for the effect without needing music playing. Not part of the app for anyone who
* didn't ask for it: this only ever mounts behind the query param, in `App.tsx`.
*/
import type { AmbienceDebugSnapshot } from "./Ambience";
import { oklchString, type Ambience, type Oklch } from "../lib/ambience";
import type { AmbienceTunables, ManualControl } from "../lib/ambienceTunables";
interface Props {
snapshot: AmbienceDebugSnapshot;
tunables: AmbienceTunables;
onTunablesChange: (tunables: AmbienceTunables) => void;
manual: ManualControl;
onManualChange: (manual: ManualControl) => void;
playing: boolean;
}
const fmt = (value: number | null, digits = 2): string =>
value === null ? "—" : value.toFixed(digits);
function Swatch({ color }: { color: Oklch }) {
return (
<span
style={{
display: "inline-block",
width: 14,
height: 14,
borderRadius: 4,
background: oklchString(color),
border: "1px solid oklch(100% 0 0 / .4)",
verticalAlign: "middle",
}}
/>
);
}
function Field({ label, value }: { label: string; value: string }) {
return (
<span style={{ whiteSpace: "nowrap" }}>
<span style={{ opacity: 0.6 }}>{label}</span> <b>{value}</b>
</span>
);
}
function RowLabel({ children }: { children: string }) {
return (
<span style={{ opacity: 0.6, width: 58, display: "inline-block" }}>{children}</span>
);
}
function AmbienceRow({ label, ambience }: { label: string; ambience: Ambience }) {
const [top] = ambience.gradient;
return (
<div style={{ display: "flex", gap: 14, alignItems: "center", flexWrap: "wrap" }}>
<RowLabel>{label}</RowLabel>
<Swatch color={top} />
<Field label="hue" value={fmt(top.h, 0)} />
<Field label="chroma" value={fmt(top.c, 3)} />
<Field label="lightness" value={fmt(top.l, 0)} />
<span>
<span style={{ opacity: 0.6 }}>tint</span> <Swatch color={ambience.tint} />
</span>
<Field label="spawn/s" value={fmt(ambience.spawnRate)} />
<Field label="current" value={fmt(ambience.currentMagnitude, 1)} />
<Field label="rise" value={fmt(ambience.rise)} />
</div>
);
}
function TunableInput({
label,
value,
min,
max,
step,
onChange,
}: {
label: string;
value: number;
min: number;
max: number;
step: number;
onChange: (value: number) => void;
}) {
return (
<label style={{ display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}>
<span style={{ opacity: 0.6 }}>{label}</span>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
style={{ width: 70 }}
/>
<b style={{ width: 32, display: "inline-block" }}>{value.toFixed(2)}</b>
</label>
);
}
function TunableRow({
tunables,
onChange,
}: {
tunables: AmbienceTunables;
onChange: (tunables: AmbienceTunables) => void;
}) {
const set = <K extends keyof AmbienceTunables>(key: K, value: number) =>
onChange({ ...tunables, [key]: value });
return (
<div
style={{
display: "flex",
gap: 14,
flexWrap: "wrap",
alignItems: "center",
pointerEvents: "auto",
}}
>
<RowLabel>tunables</RowLabel>
<TunableInput
label="energy→color"
value={tunables.energyColorGain}
min={0}
max={2}
step={0.05}
onChange={(v) => set("energyColorGain", v)}
/>
<TunableInput
label="beat→current"
value={tunables.beatCurrentFactor}
min={0}
max={2}
step={0.05}
onChange={(v) => set("beatCurrentFactor", v)}
/>
<TunableInput
label="drive→current"
value={tunables.driveCurrentGain}
min={0}
max={2}
step={0.05}
onChange={(v) => set("driveCurrentGain", v)}
/>
<TunableInput
label="bubble growth"
value={tunables.bubbleGrowthRate}
min={0}
max={1}
step={0.02}
onChange={(v) => set("bubbleGrowthRate", v)}
/>
<TunableInput
label="curve τ (s)"
value={tunables.curveSmoothingTau}
min={0}
max={2}
step={0.05}
onChange={(v) => set("curveSmoothingTau", v)}
/>
</div>
);
}
function ManualControlRow({
manual,
onChange,
playing,
active,
}: {
manual: ManualControl;
onChange: (manual: ManualControl) => void;
playing: boolean;
active: boolean;
}) {
const set = <K extends keyof ManualControl>(key: K, value: ManualControl[K]) =>
onChange({ ...manual, [key]: value });
return (
<div
style={{
display: "flex",
gap: 14,
flexWrap: "wrap",
alignItems: "center",
pointerEvents: "auto",
}}
>
<span style={{ opacity: 0.6, width: 58, display: "inline-block" }}>
manual {active && <span style={{ color: "oklch(80% 0.14 340)" }}></span>}
</span>
<label style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<input
type="checkbox"
checked={manual.enabled}
onChange={(e) => set("enabled", e.target.checked)}
/>
enabled{playing ? " (available while paused)" : ""}
</label>
<TunableInput
label="energy"
value={manual.energy}
min={0}
max={1}
step={0.02}
onChange={(v) => set("energy", v)}
/>
<TunableInput
label="valence"
value={manual.valence}
min={0}
max={1}
step={0.02}
onChange={(v) => set("valence", v)}
/>
<TunableInput
label="tempo"
value={manual.tempo}
min={40}
max={200}
step={1}
onChange={(v) => set("tempo", v)}
/>
<TunableInput
label="drive"
value={manual.drive}
min={0}
max={1}
step={0.02}
onChange={(v) => set("drive", v)}
/>
<TunableInput
label="beat strength"
value={manual.beatStrength}
min={0}
max={1}
step={0.02}
onChange={(v) => set("beatStrength", v)}
/>
</div>
);
}
export function AmbienceDebugOverlay({
snapshot,
tunables,
onTunablesChange,
manual,
onManualChange,
playing,
}: Props) {
const { analysis } = snapshot;
return (
<div
style={{
position: "fixed",
left: 0,
right: 0,
bottom: 0,
zIndex: 1000,
background: "oklch(15% 0.02 210 / .92)",
color: "oklch(95% 0.01 210)",
font: "12px/1.5 ui-monospace, Menlo, monospace",
padding: "8px 14px",
display: "flex",
flexDirection: "column",
gap: 4,
pointerEvents: "none",
textShadow: "0 1px 2px oklch(0% 0 0 / .6)",
}}
>
<div style={{ display: "flex", gap: 14, flexWrap: "wrap", alignItems: "center" }}>
<b style={{ color: "oklch(80% 0.14 340)" }}>debugDynamicUI</b>
<Field label="group" value={snapshot.group} />
<Field label="playing" value={snapshot.playing ? "yes" : "no"} />
<Field label="position" value={`${fmt(snapshot.position, 1)}s`} />
<Field label="track" value={snapshot.trackTitle ?? "—"} />
</div>
<div style={{ display: "flex", gap: 14, flexWrap: "wrap", alignItems: "center" }}>
<RowLabel>analysis</RowLabel>
<Field label="tempo" value={analysis ? `${fmt(analysis.tempo, 0)} bpm` : "—"} />
<Field label="energy" value={fmt(analysis?.energy ?? null)} />
<Field label="valence" value={fmt(analysis?.valence ?? null)} />
<Field label="brightness" value={fmt(analysis?.brightness ?? null)} />
<Field label="pulse" value={fmt(analysis?.pulse ?? null)} />
<Field label="beats" value={analysis ? (analysis.beats ? "yes" : "no") : "—"} />
</div>
<div style={{ display: "flex", gap: 14, flexWrap: "wrap", alignItems: "center" }}>
<RowLabel>sampled</RowLabel>
<Field label="energy" value={fmt(snapshot.sampledEnergy)} />
<Field label="valence" value={fmt(snapshot.sampledValence)} />
<Field label="drive" value={fmt(snapshot.sampledDrive)} />
<Field label="curve" value={snapshot.curveLoaded ? "loaded" : "flat fallback"} />
</div>
<AmbienceRow label="target" ambience={snapshot.target} />
<AmbienceRow label="current" ambience={snapshot.current} />
<div style={{ display: "flex", gap: 14, flexWrap: "wrap", alignItems: "center" }}>
<RowLabel>beats</RowLabel>
<Field label="kick" value={fmt(snapshot.kick)} />
<Field label="trusted" value={snapshot.beatsTrusted ? "yes" : "no"} />
<Field label="loaded" value={snapshot.beatGridLoaded ? "yes" : "no"} />
<Field label="count" value={String(snapshot.beatCount)} />
<Field label="cursor" value={String(snapshot.beatCursor)} />
<Field label="kicked current" value={fmt(snapshot.currentMagnitude, 1)} />
<Field label="bubbles" value={String(snapshot.bubbleCount)} />
</div>
<TunableRow tunables={tunables} onChange={onTunablesChange} />
<ManualControlRow
manual={manual}
onChange={onManualChange}
playing={playing}
active={snapshot.usingManual}
/>
</div>
);
}

View File

@@ -73,6 +73,9 @@ export function PlayView({
pointerEvents: "none",
opacity: 0.92,
animation: "dolphinSwim 62s linear infinite",
// Freezes in its current pose rather than snapping back to frame 0 -
// `animation: "none"` would restart it, `animationPlayState` just pauses.
animationPlayState: state.playing ? "running" : "paused",
filter: "drop-shadow(0 10px 26px oklch(10% 0.05 210 / .45))",
}}
/>