Add librosa beat/mood analysis and an Ambience background driven by it

The background worker now runs a real librosa analyzer (tempo, beat grid,
per-second energy/valence curves) instead of only the null baseline, kept
behind build_analyzer() so a plain checkout without the analysis extra still
runs fine. The web player reads that per-track analysis and drives a new
animated "Ambience" background (bubbles, colour, current) that reacts to the
beat and the mood curve as the track plays, plus a debug overlay for tuning
it. Also adds a one-off script to backfill podcast cover art from iTunes.
This commit is contained in:
2026-09-10 22:42:51 +02:00
parent 8aed3b022b
commit 2e0e6ad199
24 changed files with 2789 additions and 60 deletions

View File

@@ -0,0 +1,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
View 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],
};
}

View 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,
};