/** The background: a gradient plus a bubble field, both painted on one canvas. * * Replaces the old fixed 21-`
` 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(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(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 (