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:
472
web/src/components/Ambience.tsx
Normal file
472
web/src/components/Ambience.tsx
Normal 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" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
329
web/src/components/AmbienceDebugOverlay.tsx
Normal file
329
web/src/components/AmbienceDebugOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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))",
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user