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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Every call the UI makes. Commands are fire-and-forget: the websocket reports back. */
|
||||
|
||||
import type { Album, HaConfig, HaEntityState, PlayerState, Settings } from "./types";
|
||||
import type { Album, HaConfig, HaEntityState, PlayerState, Settings, TrackDetail } from "./types";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`/api${path}`, {
|
||||
@@ -47,6 +47,14 @@ async function fetchHaStates(entityIds: string[]): Promise<Record<string, HaEnti
|
||||
return byId;
|
||||
}
|
||||
|
||||
/** `null` means "not analyzed" (the backend's expected 404 for this), not an error -
|
||||
* the ambient background just falls back to its un-analyzed baseline for that track. */
|
||||
async function fetchTrackDetail(albumId: string, trackIndex: number): Promise<TrackDetail | null> {
|
||||
const response = await fetch(`/api/tracks/${albumId}/${trackIndex}/analysis`);
|
||||
if (!response.ok) return null;
|
||||
return (await response.json()) as TrackDetail;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
library: () => request<{ albums: Album[] }>("/library").then((body) => body.albums),
|
||||
state: () => request<PlayerState>("/state"),
|
||||
@@ -70,6 +78,8 @@ export const api = {
|
||||
haStates: fetchHaStates,
|
||||
haCallService: (domain: string, service: string, body: Record<string, unknown>) =>
|
||||
post(`/ha/services/${domain}/${service}`, body),
|
||||
|
||||
trackDetail: fetchTrackDetail,
|
||||
};
|
||||
|
||||
export const coverUrl = (albumId: string) => `/api/albums/${albumId}/cover`;
|
||||
|
||||
@@ -7,9 +7,33 @@ export interface TrackAnalysis {
|
||||
energy: number | null;
|
||||
valence: number | null;
|
||||
brightness: number | null;
|
||||
/** 0..1 confidence that `tempo` is an audible, steady beat rather than an artifact
|
||||
* of free-tempo or spoken-word material - below some threshold, don't pulse on it. */
|
||||
pulse: number | null;
|
||||
beats: boolean;
|
||||
}
|
||||
|
||||
/** Per-second energy/valence/drive samples - the parts of the mood/rhythm formulas
|
||||
* that vary *within* a track. Regularly sampled, so just a hop and equal-length
|
||||
* arrays, no per-sample timestamps. Mirrors `TrackCurvesOut`. */
|
||||
export interface TrackCurves {
|
||||
hop_seconds: number;
|
||||
energy: number[];
|
||||
valence: number[];
|
||||
drive: number[];
|
||||
}
|
||||
|
||||
/** A track's beat grid plus its mood/drive curves, fetched together from
|
||||
* `GET /api/tracks/{album_id}/{index}/analysis` - only for the one track that is
|
||||
* playing, never inside the library payload. `curve` is `null` only when the whole
|
||||
* track failed analysis; `times`/`strengths` are empty (not absent) for a track with
|
||||
* no reliable beat, since energy/valence/drive are still real. Mirrors `TrackDetailOut`. */
|
||||
export interface TrackDetail {
|
||||
times: number[];
|
||||
strengths: number[];
|
||||
curve: TrackCurves | null;
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
title: string;
|
||||
/** Seconds, read from the file's tags at scan time. */
|
||||
|
||||
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))",
|
||||
}}
|
||||
/>
|
||||
|
||||
44
web/src/hooks/useTrackDetail.ts
Normal file
44
web/src/hooks/useTrackDetail.ts
Normal 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;
|
||||
}
|
||||
243
web/src/lib/__tests__/ambience.test.ts
Normal file
243
web/src/lib/__tests__/ambience.test.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Album, TrackAnalysis } from "../../api/types";
|
||||
import {
|
||||
ambienceBaseFor,
|
||||
ambienceColorAt,
|
||||
currentMagnitudeAt,
|
||||
currentVelocityAt,
|
||||
effectiveCurve,
|
||||
IDLE_AMBIENCE,
|
||||
sampleCurveAt,
|
||||
spawnRateAt,
|
||||
tempoToMagnitude,
|
||||
tempoToRise,
|
||||
} from "../ambience";
|
||||
import { DEFAULT_TUNABLES } from "../ambienceTunables";
|
||||
|
||||
function album(over: Partial<Album> = {}): Album {
|
||||
return {
|
||||
id: "a",
|
||||
section: "Musik",
|
||||
kind: "music",
|
||||
title: "Album",
|
||||
artist: "Kinderparty",
|
||||
series: null,
|
||||
figure: null,
|
||||
category: "Kinderparty",
|
||||
colors: ["#111111", "#222222", "#333333"],
|
||||
has_cover: false,
|
||||
duration: 120,
|
||||
tracks: [{ title: "Lied", duration: 60, analysis: null }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function analysis(over: Partial<TrackAnalysis> = {}): TrackAnalysis {
|
||||
return {
|
||||
tempo: null,
|
||||
energy: null,
|
||||
valence: null,
|
||||
brightness: null,
|
||||
pulse: null,
|
||||
beats: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ambienceBaseFor", () => {
|
||||
it("is the idle sea with no bubbles when nothing is loaded", () => {
|
||||
const base = ambienceBaseFor(null, null);
|
||||
expect(base.music).toBe(false);
|
||||
expect(base.fixed).toEqual(IDLE_AMBIENCE);
|
||||
});
|
||||
|
||||
it("gives music, audiobooks and podcasts their own, distinct hue families", () => {
|
||||
const musicHue = IDLE_AMBIENCE.gradient[0].h; // music's baseline hue
|
||||
const audiobook = ambienceBaseFor(album({ kind: "book", section: "Hörbücher" }), null);
|
||||
const podcast = ambienceBaseFor(album({ kind: "book", section: "Kinderpodcasts" }), null);
|
||||
|
||||
const hues = [musicHue, audiobook.fixed!.gradient[0].h, podcast.fixed!.gradient[0].h];
|
||||
expect(new Set(hues).size).toBe(3); // all three distinct
|
||||
expect(musicHue).toBe(210);
|
||||
expect(audiobook.fixed!.gradient[0].h).toBe(80);
|
||||
expect(podcast.fixed!.gradient[0].h).toBe(300);
|
||||
});
|
||||
|
||||
it("never applies the mood mapping outside music, even if a book track somehow has one", () => {
|
||||
const bookAlbum = album({ kind: "book", section: "Hörbücher" });
|
||||
const withoutAnalysis = ambienceBaseFor(bookAlbum, null);
|
||||
const withAnalysis = ambienceBaseFor(
|
||||
bookAlbum,
|
||||
analysis({ valence: 1, energy: 1, brightness: 1, tempo: 160 }),
|
||||
);
|
||||
|
||||
expect(withAnalysis).toEqual(withoutAnalysis);
|
||||
expect(withAnalysis.baseCurrentMagnitude).toBe(0); // effects only ever touch music
|
||||
});
|
||||
|
||||
it("gives podcasts a slower spawn rate than music's baseline drift", () => {
|
||||
const musicBaselineSpawnRate = spawnRateAt(0.5);
|
||||
const podcast = ambienceBaseFor(album({ kind: "book", section: "Kinderpodcasts" }), null);
|
||||
|
||||
expect(podcast.fixed!.spawnRate).toBeLessThan(musicBaselineSpawnRate);
|
||||
});
|
||||
|
||||
it("un-analyzed music falls back to the same tempo baseline as tempoToMagnitude/Rise", () => {
|
||||
const musicAlbum = album({ kind: "music", section: "Musik" });
|
||||
const base = ambienceBaseFor(musicAlbum, null);
|
||||
|
||||
expect(base.music).toBe(true);
|
||||
expect(base.fixed).toBeNull();
|
||||
expect(base.baseCurrentMagnitude).toBe(tempoToMagnitude(null));
|
||||
expect(base.rise).toBe(tempoToRise(null));
|
||||
});
|
||||
|
||||
it("treats a Figuren album by its kind, not its section", () => {
|
||||
const figureBook = album({ kind: "book", section: "Figuren", figure: "eule" });
|
||||
const podcast = ambienceBaseFor(album({ kind: "book", section: "Kinderpodcasts" }), null);
|
||||
const figureBase = ambienceBaseFor(figureBook, null);
|
||||
|
||||
expect(figureBase.fixed).not.toEqual(podcast.fixed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tempoToMagnitude / tempoToRise", () => {
|
||||
it("has a stronger base current and rises faster for an up-tempo track than a slow one", () => {
|
||||
expect(tempoToMagnitude(160)).toBeGreaterThan(tempoToMagnitude(60));
|
||||
expect(tempoToRise(160)).toBeGreaterThan(tempoToRise(60));
|
||||
});
|
||||
});
|
||||
|
||||
describe("effectiveCurve", () => {
|
||||
it("falls back to the baseline per scalar, not as an all-or-nothing object", () => {
|
||||
// A track the analyzer failed on: every field `null`, not a missing object.
|
||||
const attempted = effectiveCurve(analysis(), null);
|
||||
expect(attempted.energy).toEqual([0.5]);
|
||||
expect(attempted.valence).toEqual([0.5]);
|
||||
expect(attempted.drive).toEqual([1.0]); // no-op multiplier, not a damping 0.5
|
||||
|
||||
// Only `valence` present: it should carry through independently of energy's baseline.
|
||||
const partial = effectiveCurve(analysis({ valence: 1 }), null);
|
||||
expect(partial.energy).toEqual([0.5]);
|
||||
expect(partial.valence).toEqual([1]);
|
||||
});
|
||||
|
||||
it("prefers a fetched curve over the scalar fallback", () => {
|
||||
const curve = { hop_seconds: 1, energy: [0.9], valence: [0.1], drive: [0.7] };
|
||||
const result = effectiveCurve(analysis({ energy: 0.5 }), { times: [], strengths: [], curve });
|
||||
expect(result).toBe(curve);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ambienceColorAt", () => {
|
||||
it("equals the un-analyzed baseline exactly at energy=valence=0.5", () => {
|
||||
const result = ambienceColorAt(0.5, 0.5, DEFAULT_TUNABLES);
|
||||
expect(result.gradient).toEqual(IDLE_AMBIENCE.gradient);
|
||||
expect(result.tint).toEqual(IDLE_AMBIENCE.tint);
|
||||
});
|
||||
|
||||
it("collapses to the baseline when energyColorGain is 0, regardless of energy/valence", () => {
|
||||
const tunables = { ...DEFAULT_TUNABLES, energyColorGain: 0 };
|
||||
const result = ambienceColorAt(0, 0.5, tunables);
|
||||
expect(result.gradient[0].c).toBe(IDLE_AMBIENCE.gradient[0].c);
|
||||
expect(result.gradient[0].l).toBe(IDLE_AMBIENCE.gradient[0].l);
|
||||
});
|
||||
|
||||
it("energy alone drives chroma/lightness, not hue - the primary, noticeable driver", () => {
|
||||
const low = ambienceColorAt(0, 0.5, DEFAULT_TUNABLES);
|
||||
const high = ambienceColorAt(1, 0.5, DEFAULT_TUNABLES);
|
||||
|
||||
expect(high.gradient[0].h).toBe(low.gradient[0].h);
|
||||
expect(high.gradient[0].c).toBeGreaterThan(low.gradient[0].c);
|
||||
expect(high.gradient[0].l).toBeGreaterThan(low.gradient[0].l);
|
||||
});
|
||||
|
||||
it("valence alone drives hue, not chroma/lightness, with a smaller swing than energy's", () => {
|
||||
const low = ambienceColorAt(0.5, 0, DEFAULT_TUNABLES);
|
||||
const high = ambienceColorAt(0.5, 1, DEFAULT_TUNABLES);
|
||||
|
||||
expect(high.gradient[0].c).toBe(low.gradient[0].c);
|
||||
expect(high.gradient[0].l).toBe(low.gradient[0].l);
|
||||
const hueSwing = Math.abs(high.gradient[0].h - low.gradient[0].h);
|
||||
expect(hueSwing).toBeGreaterThan(0);
|
||||
expect(hueSwing).toBeLessThan(30); // a nudge, not the primary colour driver
|
||||
});
|
||||
|
||||
it("keeps the valence hue nudge inside a water-plausible range at both extremes", () => {
|
||||
const sad = ambienceColorAt(0.5, 0, DEFAULT_TUNABLES);
|
||||
const happy = ambienceColorAt(0.5, 1, DEFAULT_TUNABLES);
|
||||
|
||||
for (const hue of [sad.gradient[0].h, happy.gradient[0].h]) {
|
||||
expect(hue).toBeGreaterThanOrEqual(160); // never drifts as far as orange
|
||||
expect(hue).toBeLessThanOrEqual(260);
|
||||
}
|
||||
// Sad (dark, low valence) reads colder/more violet than happy (bright, high valence).
|
||||
expect(sad.gradient[0].h).toBeGreaterThan(happy.gradient[0].h);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spawnRateAt", () => {
|
||||
it("increases monotonically with energy", () => {
|
||||
expect(spawnRateAt(1)).toBeGreaterThan(spawnRateAt(0.5));
|
||||
expect(spawnRateAt(0.5)).toBeGreaterThan(spawnRateAt(0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentVelocityAt", () => {
|
||||
it("is ~0 in the bottom third, +magnitude in the middle, -magnitude in the top", () => {
|
||||
expect(currentVelocityAt(0.15, 100)).toBeCloseTo(0, 0);
|
||||
expect(currentVelocityAt(0.5, 100)).toBeCloseTo(100, 0);
|
||||
expect(currentVelocityAt(0.85, 100)).toBeCloseTo(-100, 0);
|
||||
});
|
||||
|
||||
it("blends smoothly across both band boundaries - no jump between adjacent samples", () => {
|
||||
const samples = Array.from({ length: 200 }, (_, i) => currentVelocityAt(i / 199, 100));
|
||||
for (let i = 1; i < samples.length; i++) {
|
||||
expect(Math.abs(samples[i]! - samples[i - 1]!)).toBeLessThan(15);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("sampleCurveAt", () => {
|
||||
const curve = { hop_seconds: 1, energy: [0.2, 0.8], valence: [0.4, 0.6], drive: [0, 1] };
|
||||
|
||||
it("interpolates linearly between samples at a known midpoint", () => {
|
||||
const result = sampleCurveAt(curve, 0.5);
|
||||
expect(result.energy).toBeCloseTo(0.5);
|
||||
expect(result.valence).toBeCloseTo(0.5);
|
||||
expect(result.drive).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it("holds flat before the first and past the last sample", () => {
|
||||
expect(sampleCurveAt(curve, -5).energy).toBe(0.2);
|
||||
expect(sampleCurveAt(curve, 500).energy).toBe(0.8);
|
||||
});
|
||||
|
||||
it("a length-1 curve returns that value everywhere", () => {
|
||||
const flat = { hop_seconds: 1, energy: [0.42], valence: [0.42], drive: [0.42] };
|
||||
expect(sampleCurveAt(flat, 0).energy).toBe(0.42);
|
||||
expect(sampleCurveAt(flat, 99).energy).toBe(0.42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentMagnitudeAt", () => {
|
||||
it("returns exactly the base magnitude at drive=1, driveCurrentGain=1", () => {
|
||||
expect(currentMagnitudeAt(100, 1, 1)).toBe(100);
|
||||
});
|
||||
|
||||
it("never drops to 0 at drive=0 - damps toward a floor instead", () => {
|
||||
const result = currentMagnitudeAt(100, 0, 1);
|
||||
expect(result).toBeGreaterThan(0);
|
||||
expect(result).toBeLessThan(100);
|
||||
});
|
||||
|
||||
it("increases monotonically with drive", () => {
|
||||
expect(currentMagnitudeAt(100, 1, 1)).toBeGreaterThan(currentMagnitudeAt(100, 0.5, 1));
|
||||
expect(currentMagnitudeAt(100, 0.5, 1)).toBeGreaterThan(currentMagnitudeAt(100, 0, 1));
|
||||
});
|
||||
|
||||
it("driveCurrentGain=0 collapses to the floor regardless of drive", () => {
|
||||
expect(currentMagnitudeAt(100, 0.2, 0)).toBe(currentMagnitudeAt(100, 0.9, 0));
|
||||
});
|
||||
});
|
||||
296
web/src/lib/ambience.ts
Normal file
296
web/src/lib/ambience.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
/** What the background should look like for whatever is loaded and playing.
|
||||
*
|
||||
* Pure mapping, no DOM and no animation: `Ambience.tsx` reads this every frame and
|
||||
* eases the canvas toward it. Split into a per-track part (`ambienceBaseFor` - group
|
||||
* palette, tempo-derived rise and current-magnitude base, computed once on track
|
||||
* change) and per-frame pure functions (`ambienceColorAt`, `spawnRateAt`,
|
||||
* `currentVelocityAt`, `sampleCurveAt`, `currentMagnitudeAt`) - because `energy` and
|
||||
* `valence` are now sampled from a per-second curve at the current playback position,
|
||||
* so the visual result can no longer be a single value computed once per track.
|
||||
*/
|
||||
|
||||
import type { Album, TrackAnalysis, TrackCurves, TrackDetail } from "../api/types";
|
||||
import type { AmbienceTunables } from "./ambienceTunables";
|
||||
import { groupOf } from "./search";
|
||||
|
||||
export interface Oklch {
|
||||
/** 0..100, percent lightness. */
|
||||
l: number;
|
||||
c: number;
|
||||
/** Degrees. */
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface Ambience {
|
||||
/** Three gradient stops, top to deep - the vertical falloff every hue family shares. */
|
||||
gradient: [Oklch, Oklch, Oklch];
|
||||
/** Bubble fill colour. */
|
||||
tint: Oklch;
|
||||
/** Bubbles spawned per second. */
|
||||
spawnRate: number;
|
||||
/** Rise speed, viewport-heights per second. */
|
||||
rise: number;
|
||||
/** U≈V magnitude (px/s) of the 3-band water current - tempo x drive-derived,
|
||||
* before the beat kick multiplies it further in `Ambience.tsx`. 0 for non-music. */
|
||||
currentMagnitude: number;
|
||||
}
|
||||
|
||||
export function oklchString(color: Oklch, alpha = 1): string {
|
||||
return `oklch(${color.l}% ${color.c} ${color.h} / ${alpha})`;
|
||||
}
|
||||
|
||||
const clamp01 = (value: number): number => Math.max(0, Math.min(1, value));
|
||||
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
||||
|
||||
//: Today's sea, unchanged - the Dolphin Beats identity, and music's baseline hue.
|
||||
const MUSIC_HUE = 210;
|
||||
//: Matches the book-paper tint `cardBackground` already uses for audiobooks
|
||||
//: (`oklch(93% 0.055 88)` in `lib/covers.ts`).
|
||||
const AUDIOBOOK_HUE = 80;
|
||||
//: The one unused family, so all three shelves are instantly distinguishable at a glance.
|
||||
const PODCAST_HUE = 300;
|
||||
|
||||
const BASE_CHROMA = 0.07;
|
||||
const BASE_TOP_LIGHTNESS = 55;
|
||||
const DEEP_LIGHTNESS = 20;
|
||||
const DEEP_CHROMA = 0.045;
|
||||
|
||||
/** The vertical falloff every hue family shares - only the hue and the top stop's
|
||||
* chroma/lightness change; the deep stop always stays dark so text contrast never
|
||||
* degrades, no matter how bright a mood gets. */
|
||||
function gradientForHue(hue: number, chroma: number, topLightness: number): [Oklch, Oklch, Oklch] {
|
||||
return [
|
||||
{ l: topLightness, c: chroma, h: hue },
|
||||
{ l: topLightness - 17, c: Math.max(0, chroma - 0.01), h: hue },
|
||||
{ l: DEEP_LIGHTNESS, c: DEEP_CHROMA, h: hue },
|
||||
];
|
||||
}
|
||||
|
||||
//: Roughly today's density: 21 fixed bubbles over a ~10s mean period.
|
||||
const MUSIC_SPAWN_BASELINE = 2.0;
|
||||
//: Energy's swing on top of the baseline - matches the old energy-to-spawn-rate range.
|
||||
const MUSIC_SPAWN_ENERGY_SWING = 2.6;
|
||||
//: Audiobooks and podcasts: a slow ambient drift, not a field.
|
||||
const AMBIENT_SPAWN_RATE = 0.5;
|
||||
|
||||
const BUBBLE_TINT: Oklch = { l: 90, c: 0.02, h: MUSIC_HUE };
|
||||
//: Tint chroma tracks gradient chroma proportionally, so a result with every scalar
|
||||
//: `null` (the un-analyzed baseline, or a track the analyzer failed on) computes back
|
||||
//: to exactly `BUBBLE_TINT.c` rather than a near-miss from an independent constant.
|
||||
const TINT_CHROMA_RATIO = BUBBLE_TINT.c / BASE_CHROMA;
|
||||
|
||||
const TEMPO_BPM_FLOOR = 60;
|
||||
const TEMPO_BPM_CEIL = 160;
|
||||
const RISE_AT_FLOOR = 0.6;
|
||||
const RISE_AT_CEIL = 1.6;
|
||||
//: U≈V base magnitude range, px/s - tuned so bubbles visibly drift sideways over a
|
||||
//: few seconds without looking like they're flying off screen.
|
||||
const CURRENT_MAGNITUDE_MIN = 15;
|
||||
const CURRENT_MAGNITUDE_MAX = 55;
|
||||
//: Fallback for unanalyzed music (`tempo === null`) - a small non-zero value, so an
|
||||
//: unanalyzed track's water isn't dead-still, mirroring the old baseline turbulence.
|
||||
const CURRENT_MAGNITUDE_BASELINE = 25;
|
||||
|
||||
/** tempo → U≈V base magnitude (px/s), before drive or any beat kick modulate it
|
||||
* further. `null` (unanalyzed music) falls back to `CURRENT_MAGNITUDE_BASELINE`. */
|
||||
export function tempoToMagnitude(tempo: number | null): number {
|
||||
if (tempo === null) return CURRENT_MAGNITUDE_BASELINE;
|
||||
const tempoNorm = clamp01((tempo - TEMPO_BPM_FLOOR) / (TEMPO_BPM_CEIL - TEMPO_BPM_FLOOR));
|
||||
return lerp(CURRENT_MAGNITUDE_MIN, CURRENT_MAGNITUDE_MAX, tempoNorm);
|
||||
}
|
||||
|
||||
/** tempo → rise speed multiplier. `null` (unanalyzed music) falls back to `1`, the
|
||||
* same baseline `IDLE_AMBIENCE` uses. */
|
||||
export function tempoToRise(tempo: number | null): number {
|
||||
if (tempo === null) return 1;
|
||||
const tempoNorm = clamp01((tempo - TEMPO_BPM_FLOOR) / (TEMPO_BPM_CEIL - TEMPO_BPM_FLOOR));
|
||||
return lerp(RISE_AT_FLOOR, RISE_AT_CEIL, tempoNorm);
|
||||
}
|
||||
|
||||
/** Nothing loaded: today's sea, no bubbles. */
|
||||
export const IDLE_AMBIENCE: Ambience = {
|
||||
gradient: gradientForHue(MUSIC_HUE, BASE_CHROMA, BASE_TOP_LIGHTNESS),
|
||||
tint: BUBBLE_TINT,
|
||||
spawnRate: 0,
|
||||
rise: 1,
|
||||
currentMagnitude: 0,
|
||||
};
|
||||
|
||||
function fixedAmbience(hue: number): {
|
||||
gradient: [Oklch, Oklch, Oklch];
|
||||
tint: Oklch;
|
||||
spawnRate: number;
|
||||
} {
|
||||
return {
|
||||
gradient: gradientForHue(hue, BASE_CHROMA, BASE_TOP_LIGHTNESS),
|
||||
tint: { l: 90, c: 0.02, h: hue },
|
||||
spawnRate: AMBIENT_SPAWN_RATE,
|
||||
};
|
||||
}
|
||||
|
||||
const AUDIOBOOK_FIXED = fixedAmbience(AUDIOBOOK_HUE);
|
||||
const PODCAST_FIXED = fixedAmbience(PODCAST_HUE);
|
||||
|
||||
/** The per-track constant part of the picture: group palette (non-music), and
|
||||
* tempo-derived rise/current-magnitude bases (music). Computed once on track change -
|
||||
* everything time-varying (colour, current strength) is sampled per-frame instead,
|
||||
* see `ambienceColorAt`/`currentMagnitudeAt`. */
|
||||
export interface AmbienceBase {
|
||||
music: boolean;
|
||||
/** Complete, computed once, for non-music/idle. Null for music, whose gradient/tint/
|
||||
* spawnRate instead come from `ambienceColorAt`/`spawnRateAt` every frame. */
|
||||
fixed: { gradient: [Oklch, Oklch, Oklch]; tint: Oklch; spawnRate: number } | null;
|
||||
rise: number;
|
||||
/** Tempo-only base, *before* the per-frame `drive` modulation in
|
||||
* `currentMagnitudeAt` - distinct from `Ambience.currentMagnitude`, which is the
|
||||
* fully-modulated value actually used for rendering. Named differently on purpose
|
||||
* so the two are never confused. */
|
||||
baseCurrentMagnitude: number;
|
||||
}
|
||||
|
||||
/** The background's per-track basis for whatever `album` is loaded - not necessarily
|
||||
* playing; the caller decides whether to actually animate from `state.playing`.
|
||||
* `analysis` is ignored for anything but music, even if a book or podcast track
|
||||
* somehow carries one - the mood/current treatment is a music-only effect. */
|
||||
export function ambienceBaseFor(
|
||||
album: Album | null,
|
||||
analysis: TrackAnalysis | null,
|
||||
): AmbienceBase {
|
||||
if (album === null) {
|
||||
return {
|
||||
music: false,
|
||||
fixed: IDLE_AMBIENCE,
|
||||
rise: IDLE_AMBIENCE.rise,
|
||||
baseCurrentMagnitude: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const group = groupOf(album);
|
||||
if (group === "audiobooks" || group === "podcasts") {
|
||||
const fixed = group === "audiobooks" ? AUDIOBOOK_FIXED : PODCAST_FIXED;
|
||||
return { music: false, fixed, rise: 0.6, baseCurrentMagnitude: 0 };
|
||||
}
|
||||
|
||||
const tempo = analysis?.tempo ?? null;
|
||||
return {
|
||||
music: true,
|
||||
fixed: null,
|
||||
rise: tempoToRise(tempo),
|
||||
baseCurrentMagnitude: tempoToMagnitude(tempo),
|
||||
};
|
||||
}
|
||||
|
||||
//: valence 0 -> dark blue-violet, valence 1 -> bright green-teal, centred on
|
||||
//: MUSIC_HUE - a narrower swing than the pre-refactor 85 degrees, since valence is
|
||||
//: now a secondary nudge rather than the primary colour driver. Clamped implicitly by
|
||||
//: its own small amplitude: at any valence 0..1 the result stays well inside a
|
||||
//: water-plausible range, never drifting toward orange.
|
||||
const VALENCE_HUE_NUDGE = 24;
|
||||
//: Energy's swing on chroma/lightness - wide, so the effect reads as "noticeable" per
|
||||
//: the design brief, in contrast to valence's now-narrow hue nudge.
|
||||
const CHROMA_ENERGY_SWING = 0.11;
|
||||
const LIGHTNESS_ENERGY_SWING = 33;
|
||||
|
||||
/** hue from valence only (secondary, smaller nudge); chroma+lightness from energy
|
||||
* only (primary, "noticeable" driver). Both centred on the un-analyzed baseline
|
||||
* (energy=valence=0.5) so that value reproduces today's flat look exactly, at any
|
||||
* `energyColorGain`. */
|
||||
export function ambienceColorAt(
|
||||
energy: number,
|
||||
valence: number,
|
||||
tunables: AmbienceTunables,
|
||||
): { gradient: [Oklch, Oklch, Oklch]; tint: Oklch } {
|
||||
const hue = MUSIC_HUE - VALENCE_HUE_NUDGE * (clamp01(valence) - 0.5);
|
||||
const gain = tunables.energyColorGain;
|
||||
const energyDelta = clamp01(energy) - 0.5;
|
||||
const chroma = Math.max(0, BASE_CHROMA + CHROMA_ENERGY_SWING * gain * energyDelta);
|
||||
const topLightness = BASE_TOP_LIGHTNESS + LIGHTNESS_ENERGY_SWING * gain * energyDelta;
|
||||
|
||||
return {
|
||||
gradient: gradientForHue(hue, chroma, topLightness),
|
||||
tint: { l: 90, c: chroma * TINT_CHROMA_RATIO, h: hue },
|
||||
};
|
||||
}
|
||||
|
||||
/** energy → bubble spawn rate, monotonically increasing. */
|
||||
export function spawnRateAt(energy: number): number {
|
||||
return MUSIC_SPAWN_BASELINE + MUSIC_SPAWN_ENERGY_SWING * (clamp01(energy) - 0.5);
|
||||
}
|
||||
|
||||
//: The 3-band water current's boundaries and the width of the smooth blend across
|
||||
//: each one - plain constants, not exposed in the debug UI.
|
||||
const BAND_LOW = 1 / 3;
|
||||
const BAND_HIGH = 2 / 3;
|
||||
const BAND_BLEND = 0.12;
|
||||
|
||||
function smoothstep(edge0: number, edge1: number, x: number): number {
|
||||
const t = clamp01((x - edge0) / (edge1 - edge0));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/** Bottom third ~0, middle third +magnitude, top third -magnitude, smoothly blended
|
||||
* across each boundary via smoothstep rather than a hard cutoff - a bubble crossing
|
||||
* 1/3 or 2/3 of the way up changes direction continuously, not with a visible kink.
|
||||
* heightFraction: 0 = water floor (just spawned), 1 = surface. */
|
||||
export function currentVelocityAt(heightFraction: number, magnitude: number): number {
|
||||
const risingEdge = smoothstep(BAND_LOW - BAND_BLEND, BAND_LOW + BAND_BLEND, heightFraction);
|
||||
const fallingEdge = smoothstep(BAND_HIGH - BAND_BLEND, BAND_HIGH + BAND_BLEND, heightFraction);
|
||||
return magnitude * (risingEdge - 2 * fallingEdge); // 0 → +magnitude → -magnitude
|
||||
}
|
||||
|
||||
/** Linear interpolation between a `TrackCurves`'s ~1/s samples at a playback
|
||||
* position; holds flat before the first / past the last sample. A length-1 curve
|
||||
* (the flat-fallback case) returns that one value everywhere - no special-casing
|
||||
* needed. */
|
||||
export function sampleCurveAt(
|
||||
curve: TrackCurves,
|
||||
positionSeconds: number,
|
||||
): { energy: number; valence: number; drive: number } {
|
||||
const { hop_seconds, energy, valence, drive } = curve;
|
||||
const n = energy.length;
|
||||
const fractionalIndex = positionSeconds / hop_seconds;
|
||||
const i0 = Math.max(0, Math.min(n - 1, Math.floor(fractionalIndex)));
|
||||
const i1 = Math.min(n - 1, i0 + 1);
|
||||
const t = i1 === i0 ? 0 : clamp01(fractionalIndex - i0);
|
||||
return {
|
||||
energy: lerp(energy[i0]!, energy[i1]!, t),
|
||||
valence: lerp(valence[i0]!, valence[i1]!, t),
|
||||
drive: lerp(drive[i0]!, drive[i1]!, t),
|
||||
};
|
||||
}
|
||||
|
||||
//: The floor `currentMagnitudeAt` damps toward at drive=0 - never fully still, since
|
||||
//: a quiet passage is still music, not silence.
|
||||
const DRIVE_MAGNITUDE_FLOOR = 0.4;
|
||||
|
||||
/** base (tempo-derived, whole-track) magnitude x a drive multiplier, so a quiet verse
|
||||
* damps the current toward `DRIVE_MAGNITUDE_FLOOR` (never fully still) and a driving
|
||||
* chorus can reach - or with `driveCurrentGain` > 1, exceed - the full base magnitude. */
|
||||
export function currentMagnitudeAt(
|
||||
baseMagnitude: number,
|
||||
drive: number,
|
||||
driveCurrentGain: number,
|
||||
): number {
|
||||
const driveMultiplier =
|
||||
DRIVE_MAGNITUDE_FLOOR + (1 - DRIVE_MAGNITUDE_FLOOR) * clamp01(drive * driveCurrentGain);
|
||||
return baseMagnitude * driveMultiplier;
|
||||
}
|
||||
|
||||
/** The curve to sample from: the real one once fetched, or a length-1 flat fallback
|
||||
* built from the whole-track scalars - available synchronously from the library
|
||||
* payload, so there is no "flash of neutral baseline" while the real curve is still
|
||||
* in flight. `drive` defaults to `1.0` (not `0.5`): with no curve loaded yet,
|
||||
* `currentMagnitudeAt` should reproduce today's pre-refactor behaviour - base
|
||||
* magnitude alone, no drive damping - rather than silently halving it. */
|
||||
export function effectiveCurve(
|
||||
analysis: TrackAnalysis | null,
|
||||
detail: TrackDetail | null,
|
||||
): TrackCurves {
|
||||
if (detail?.curve) return detail.curve;
|
||||
return {
|
||||
hop_seconds: 1,
|
||||
energy: [analysis?.energy ?? 0.5],
|
||||
valence: [analysis?.valence ?? 0.5],
|
||||
drive: [1.0],
|
||||
};
|
||||
}
|
||||
68
web/src/lib/ambienceTunables.ts
Normal file
68
web/src/lib/ambienceTunables.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/** The 5 parameters `?debugDynamicUI=1` can adjust live, session-only (plain React
|
||||
* state in App.tsx, no persistence). Every other constant this refactor introduces
|
||||
* lives in `lib/ambience.ts` / `components/Ambience.tsx` as a plain named constant -
|
||||
* that pair of files plus this one's `DEFAULT_TUNABLES` is the complete answer to
|
||||
* "what do I edit to make a debug-slider tweak permanent."
|
||||
*/
|
||||
|
||||
export interface AmbienceTunables {
|
||||
/** Multiplies energy's swing on chroma/lightness. 1.0 = default. */
|
||||
energyColorGain: number;
|
||||
/** Multiplies how much a beat crossing boosts current magnitude, on top of the
|
||||
* always-present tempo x drive-derived base. 0 = no effect. */
|
||||
beatCurrentFactor: number;
|
||||
/** Multiplies the sampled `drive` curve before it modulates current magnitude (see
|
||||
* `currentMagnitudeAt`). 1.0 = default; 0 = current ignores drive entirely and sits
|
||||
* at `DRIVE_MAGNITUDE_FLOOR` x base magnitude regardless of the music. */
|
||||
driveCurrentGain: number;
|
||||
/** How much bigger a bubble gets by the time it's risen a full screen height, e.g.
|
||||
* 0.4 = 40% larger at the top. */
|
||||
bubbleGrowthRate: number;
|
||||
/** Seconds: exponential smoothing time constant for how quickly the curve-sampled
|
||||
* energy/valence/drive feeding colour and current track the underlying (already
|
||||
* linearly-interpolated) curve - a frontend preview knob, distinct from the
|
||||
* backend's fixed analysis-time sampling rate (`_ANALYSIS_HOP_SECONDS` in
|
||||
* `librosa_analyzer.py`, which needs a re-analysis to change). 0 = no extra
|
||||
* smoothing. */
|
||||
curveSmoothingTau: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_TUNABLES: AmbienceTunables = {
|
||||
energyColorGain: 1.0,
|
||||
beatCurrentFactor: 0.6,
|
||||
driveCurrentGain: 1.0,
|
||||
bubbleGrowthRate: 0.4,
|
||||
curveSmoothingTau: 0.4,
|
||||
};
|
||||
|
||||
/** Drives the whole visual pipeline from these values instead of real playback data -
|
||||
* a "playground mode" for developing a feel for the effect without needing music
|
||||
* playing. Only takes effect while nothing is actually playing (`!state.playing`) -
|
||||
* real playback always wins the instant it starts, so there's never a fight between
|
||||
* the two. Session-only, same as `AmbienceTunables`; distinct from it conceptually:
|
||||
* tunables are *parameters* (how strongly the system reacts), this is *input*
|
||||
* (pretending to be the music). */
|
||||
export interface ManualControl {
|
||||
enabled: boolean;
|
||||
energy: number; // 0..1
|
||||
valence: number; // 0..1
|
||||
tempo: number; // bpm - also stands in for the current's base magnitude and rise
|
||||
/** 0..1 - stands in for the sampled `drive` curve, since there's no real track to
|
||||
* derive rhythmic intensity from. 1.0 default = full current magnitude, matching
|
||||
* the flat-curve fallback's own default (see `effectiveCurve`). */
|
||||
drive: number;
|
||||
/** Strength of each synthetic beat pulse, auto-generated at the `tempo` above while
|
||||
* manual mode is active - a continuous fake metronome rather than a one-shot
|
||||
* trigger button, so the beat-driven effects (spawn burst + current kick) can be
|
||||
* watched continuously rather than poked one click at a time. */
|
||||
beatStrength: number; // 0..1
|
||||
}
|
||||
|
||||
export const DEFAULT_MANUAL_CONTROL: ManualControl = {
|
||||
enabled: false,
|
||||
energy: 0.5,
|
||||
valence: 0.5,
|
||||
tempo: 120,
|
||||
drive: 1.0,
|
||||
beatStrength: 0.8,
|
||||
};
|
||||
@@ -87,6 +87,9 @@ button {
|
||||
);
|
||||
}
|
||||
|
||||
/* Still used by the room-control page's own `<Bubbles>` field (`RoomView.tsx`) - the
|
||||
main player's bubbles moved to `Ambience.tsx`'s canvas, which needs real spawn and
|
||||
turbulence control that a fixed set of CSS animations cannot give it. */
|
||||
.bubble {
|
||||
position: absolute;
|
||||
bottom: -40px;
|
||||
@@ -97,6 +100,20 @@ button {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ambience {
|
||||
display: block;
|
||||
/* A canvas is a replaced element: without an explicit size here, its CSS layout box
|
||||
falls back to its own width/height *attributes* - the very thing the resize
|
||||
handler sets from this box's measured size times devicePixelRatio. On any display
|
||||
where dpr != 1, that is a feedback loop: each resize multiplies the attribute
|
||||
(and thus, without this rule, the box) by dpr again, diverging within a few
|
||||
frames until the canvas exceeds the browser's max size and throws. Pinning the
|
||||
box to 100% of `.stage` breaks the loop - layout no longer depends on the
|
||||
attribute at all. */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pill {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
Reference in New Issue
Block a user