// Moodwand reference implementation (TypeScript). // Guide: https://itsjamik.com/tools/moodwand/handoff.md // Three files joined: color.ts (OKLCH math), scale.ts (moods, generate, frameAt), // then audio.ts (listen, react: the music reaction). // ---- color.ts ---- // OKLCH color math (Björn Ottosson's OKLab), with sRGB gamut mapping. // Everything the mood scale does happens in OKLCH; sRGB is only the output. export type Oklch = { l: number; c: number; h: number }; type Rgb = [number, number, number]; const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055); function oklabToLinearSrgb(L: number, a: number, b: number): Rgb { const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3; const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3; const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3; return [ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, ]; } function linearSrgbToOklab([r, g, b]: Rgb): [number, number, number] { const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); return [ 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, ]; } const rad = Math.PI / 180; export function toOklab({ l, c, h }: Oklch): [number, number, number] { return [l, c * Math.cos(h * rad), c * Math.sin(h * rad)]; } export function fromOklab(L: number, a: number, b: number): Oklch { const h = (Math.atan2(b, a) / rad + 360) % 360; return { l: L, c: Math.hypot(a, b), h }; } function linearSrgb(color: Oklch): Rgb { return oklabToLinearSrgb(...toOklab(color)); } const EPS = 1e-4; export function inSrgb(color: Oklch): boolean { return linearSrgb(color).every((v) => v >= -EPS && v <= 1 + EPS); } /** The most chroma sRGB can show at this lightness and hue (binary search). */ export function maxChroma(l: number, h: number): number { let lo = 0; let hi = 0.4; for (let i = 0; i < 24; i++) { const mid = (lo + hi) / 2; if (inSrgb({ l, c: mid, h })) lo = mid; else hi = mid; } return lo; } /** sRGB channels in 0..1, clamped. Only call with in-gamut colors to avoid clipping. */ export function toSrgb(color: Oklch): Rgb { return linearSrgb(color).map((v) => Math.min(1, Math.max(0, toGamma(v)))) as Rgb; } export function toHex(color: Oklch): string { return ( "#" + toSrgb(color) .map((v) => Math.round(v * 255).toString(16).padStart(2, "0")) .join("") ); } export function fromHex(hex: string): Oklch { const n = parseInt(hex.replace("#", ""), 16); const rgb = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => toLinear(v / 255)) as Rgb; return fromOklab(...linearSrgbToOklab(rgb)); } export function toCss({ l, c, h }: Oklch): string { return `oklch(${l.toFixed(3)} ${c.toFixed(3)} ${h.toFixed(1)})`; } /** Mix two colors in OKLab: a straight line, so no hue detours or muddy midpoints. */ export function mixOklab(a: Oklch, b: Oklch, t: number): Oklch { const [la, aa, ba] = toOklab(a); const [lb, ab, bb] = toOklab(b); return fromOklab(la + (lb - la) * t, aa + (ab - aa) * t, ba + (bb - ba) * t); } /** WCAG relative luminance, for picking legible text on top of a palette. */ export function luminance(color: Oklch): number { const [r, g, b] = linearSrgb(color).map((v) => Math.min(1, Math.max(0, v))); return 0.2126 * r + 0.7152 * g + 0.0722 * b; } // ---- scale.ts ---- // The mood scale and the gradient generator. // // A mood is a region of OKLCH space: a band of hues, a range of lightness and a // range of *relative* chroma (a share of the most chroma sRGB can show at that // lightness and hue). Because chroma is relative, every generated color is in // gamut and moods feel equally vivid across hues. // // generate(mood, seed) is deterministic: the same mood and seed always give the // same gradient, so an app can store a seed per track (or hash the track id) // and reproduce its colors anywhere. export type Mood = { id: string; name: string; /** Center of the hue band, in degrees. */ hue: number; /** Half-width of the hue band, in degrees. */ spread: number; /** Lightness range, 0..1. */ lightness: [number, number]; /** Relative chroma range, 0..1 of the sRGB maximum. */ chroma: [number, number]; /** Lightness of the soft highlight stop, or null for none. */ highlight: number | null; /** Seconds for one full loop. */ period: number; }; // Hand-tuned anchors: each mood's hue and range, in OKLCH. export const defaultMoods: Mood[] = [ { id: "low", name: "Low", hue: 278, spread: 18, lightness: [0.36, 0.62], chroma: [0.35, 0.6], highlight: 0.8, period: 14 }, { id: "anxious", name: "Anxious", hue: 10, spread: 22, lightness: [0.55, 0.72], chroma: [0.75, 1], highlight: 0.92, period: 6 }, { id: "sad", name: "Sad", hue: 262, spread: 16, lightness: [0.55, 0.72], chroma: [0.7, 1], highlight: 0.93, period: 12 }, { id: "calm", name: "Calm", hue: 162, spread: 22, lightness: [0.7, 0.84], chroma: [0.6, 0.9], highlight: 0.95, period: 12 }, { id: "happy", name: "Happy", hue: 72, spread: 20, lightness: [0.75, 0.9], chroma: [0.8, 1], highlight: 0.96, period: 8 }, { id: "uplifted", name: "Uplifted", hue: 40, spread: 14, lightness: [0.66, 0.78], chroma: [0.85, 1], highlight: 0.94, period: 6 }, ]; export type Stop = { color: Oklch; /** Relative chroma, kept so the loop can recompute chroma as L and h move. */ rel: number; /** Where the stop sits on a canvas, 0..1. */ x: number; y: number; /** Loop phases. */ phaseH: number; phaseL: number; highlight: boolean; }; /** How the mesh is laid out. Mirrors the props of Paper's MeshGradient. */ export type Shape = { /** Shader time in ms; it decides where the color spots start. */ frame: number; rotation: number; scale: number; offsetX: number; offsetY: number; distortion: number; swirl: number; }; /** * How the colors relate inside the mood's range: * - spread: hues fanned across the whole band * - tonal: one hue, lights and darks of it * - duo: two clusters at opposite edges of the band */ export type Variant = "spread" | "tonal" | "duo"; export type Gradient = { mood: Mood; seed: number; variant: Variant; shape: Shape; stops: Stop[] }; /** 32-bit FNV-1a, for turning a track id or title into a seed. */ export function seedFrom(text: string): number { let h = 0x811c9dc5; for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; } /** mulberry32: small, fast, and identical on every platform. */ function random(seed: number) { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** n values spread evenly over 0..1 (one per slice, jittered) in random order. */ function stratified(n: number, rnd: () => number): number[] { const values = Array.from({ length: n }, (_, i) => (i + rnd()) / n); for (let i = n - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [values[i], values[j]] = [values[j], values[i]]; } return values; } const lerp = (a: number, b: number, t: number) => a + (b - a) * t; const wrap = (h: number) => ((h % 360) + 360) % 360; const clamp01 = (v: number) => Math.min(1, Math.max(0, v)); /** Where in the hue band (0..1) each stop sits, for each variant. */ function huePositions(variant: Variant, count: number, rnd: () => number): number[] { if (variant === "tonal") { const center = rnd(); return Array.from({ length: count }, () => clamp01(center + (rnd() - 0.5) * 0.12)); } if (variant === "duo") { const first = rnd() < 0.5 ? 0 : 1; return Array.from({ length: count }, (_, i) => { const edge = (i + first) % 2; return edge ? 1 - rnd() * 0.18 : rnd() * 0.18; }); } return stratified(count, rnd); } export function generate(mood: Mood, seed: number): Gradient { const rnd = random(seedFrom(mood.id) ^ seed); const roll = rnd(); const variant: Variant = roll < 0.5 ? "spread" : roll < 0.75 ? "tonal" : "duo"; const count = 2 + Math.floor(rnd() * 3); // 2 to 4 mood colors const withHighlight = mood.highlight !== null && rnd() < 0.65; const shape: Shape = { frame: Math.round(rnd() * 5_000_000), rotation: Math.round(rnd() * 360), scale: +lerp(0.75, 1.5, rnd()).toFixed(2), offsetX: +lerp(-0.3, 0.3, rnd()).toFixed(2), offsetY: +lerp(-0.3, 0.3, rnd()).toFixed(2), distortion: +lerp(0.5, 1, rnd()).toFixed(2), swirl: +lerp(0, 0.7, rnd()).toFixed(2), }; const hues = huePositions(variant, count, rnd); // Tonal leans on lightness for its contrast, so it always spans the full range. const lights = variant === "tonal" ? Array.from({ length: count }, (_, i) => i / (count - 1)) : stratified(count, rnd); const spots = stratified(count + 1, rnd); const stops: Stop[] = hues.map((u, i) => { const h = wrap(mood.hue + mood.spread * (2 * u - 1)); const l = lerp(mood.lightness[0], mood.lightness[1], lights[i]); const rel = lerp(mood.chroma[0], mood.chroma[1], rnd()); return { color: { l, c: rel * maxChroma(l, h), h }, rel, x: spots[i], y: rnd(), phaseH: rnd(), phaseL: rnd(), highlight: false, }; }); if (withHighlight && mood.highlight !== null) { const h = wrap(mood.hue + mood.spread * (2 * rnd() - 1) * 0.5); const l = mood.highlight; const rel = 0.35; stops.unshift({ color: { l, c: rel * maxChroma(l, h), h }, rel, x: spots[count], y: rnd(), phaseH: rnd(), phaseL: rnd(), highlight: true, }); } return { mood, seed, variant, shape, stops }; } export type LoopOptions = { /** Degrees each stop drifts around its hue, capped so it stays in the band. */ hueDrift: number; /** Lightness each stop drifts, capped to the mood's range. */ lightDrift: number; }; export const defaultLoop: LoopOptions = { hueDrift: 10, lightDrift: 0.04 }; /** * The gradient's colors at loop position t (0..1). Every movement is a sine of * t, so t = 1 lands exactly where t = 0 started: the loop has no seam. */ export function frameAt(gradient: Gradient, t: number, loop: LoopOptions = defaultLoop): Oklch[] { const { mood } = gradient; const [lMin, lMax] = mood.lightness; return gradient.stops.map((stop) => { const { l, h } = stop.color; const offset = ((h - mood.hue + 540) % 360) - 180; const room = Math.max(0, mood.spread - Math.abs(offset)); const hue = wrap(h + Math.min(loop.hueDrift, room) * Math.sin(2 * Math.PI * (t + stop.phaseH))); const low = stop.highlight ? l - loop.lightDrift : lMin; const high = stop.highlight ? Math.min(0.98, l + loop.lightDrift) : lMax; const amp = Math.min(loop.lightDrift, l - low, high - l); const light = l + amp * Math.sin(2 * Math.PI * (t + stop.phaseL)); return { l: light, c: stop.rel * maxChroma(light, hue), h: hue }; }); } /** Lightest-first is easiest to read in exports. */ export function sortedStops(colors: Oklch[]): Oklch[] { return [...colors].sort((a, b) => b.l - a.l); } // ---- audio.ts ---- // Moodwand's music reaction. Each frame takes two loudness readings, the whole // mix and the bass, and turns them into two smooth signals: // energy 0..1, how loud the song is right now compared with its recent peak // kick a spring that the bass hits push, so drums read as a soft bounce // Everything here is plain math with frame-rate independent smoothing, so a // port gets the same motion at 60 or 120 fps. export type Pulse = { loudness: number; // dB, lightly smoothed peak: number; // dB, the recent loudest moment; falls slowly energy: number; bassFast: number; // dB bassSlow: number; // dB kick: number; kickVelocity: number; }; export const pulseOptions = { range: 12, // dB under the peak that still counts as some energy peakFall: 0.4, // dB per second, so the peak remembers the last loud part attack: 0.25, // s, energy rising release: 0.9, // s, energy falling bassRise: 6, // dB jump above the bass average that makes a full kick springHz: 2.4, damping: 0.5, // below 1 overshoots a little, which reads as a bounce }; export const silence = (): Pulse => ({ loudness: -60, peak: -20, energy: 0, bassFast: -60, bassSlow: -60, kick: 0, kickVelocity: 0, }); const db = (rms: number) => 20 * Math.log10(Math.max(rms, 1e-5)); const clamp01 = (v: number) => Math.min(Math.max(v, 0), 1); /** One-pole smoothing toward a target with a time constant in seconds. */ const follow = (value: number, target: number, dt: number, tau: number) => value + (target - value) * (1 - Math.exp(-dt / tau)); /** Advances the pulse by dt seconds, given the RMS of the mix and of the bass. */ export function listen(p: Pulse, levelRms: number, bassRms: number, dt: number, o = pulseOptions): Pulse { dt = Math.min(dt, 0.1); const loudness = follow(p.loudness, db(levelRms), dt, 0.05); const peak = Math.max(loudness, p.peak - o.peakFall * dt); const target = clamp01((loudness - (peak - o.range)) / o.range); const energy = follow(p.energy, target, dt, target > p.energy ? o.attack : o.release); const bass = db(bassRms); const bassFast = follow(p.bassFast, bass, dt, 0.03); const bassSlow = follow(p.bassSlow, bass, dt, 0.4); const hit = clamp01((bassFast - bassSlow) / o.bassRise); // A damped spring pulled toward the bass hit, stepped at 240 Hz for stability. const w = 2 * Math.PI * o.springHz; let { kick, kickVelocity } = p; const steps = Math.ceil(dt * 240); for (let i = 0; i < steps; i++) { const h = dt / steps; kickVelocity += (w * w * (hit - kick) - 2 * o.damping * w * kickVelocity) * h; kick += kickVelocity * h; } return { loudness, peak, energy, bassFast, bassSlow, kick, kickVelocity }; } /** How far each part of the shape moves at full reactivity. */ export const reactOptions = { distortion: 0.3, // added at full energy swirl: 0.25, // added at full energy speed: [0.6, 2], // times the base speed, from silent to full energy scale: 0.06, // zoom per unit of kick rotation: 4, // degrees per unit of kick sway: 0.025, // offset per unit of kick, in a direction that turns slowly }; /** * The mesh shape and speed for this frame. `amount` (0..1) is how strongly * the gradient reacts; `time` in seconds turns the direction of the sway. */ export function react(shape: Shape, p: Pulse, amount: number, time: number, o = reactOptions): Shape & { speed: number } { const e = p.energy * amount; const k = p.kick * amount; const angle = time * 0.8; const [slow, fast] = o.speed; return { ...shape, distortion: Math.min(1, shape.distortion + o.distortion * e), swirl: Math.min(1, shape.swirl + o.swirl * e), scale: shape.scale * (1 + o.scale * k), rotation: shape.rotation + o.rotation * k, offsetX: shape.offsetX + o.sway * k * Math.cos(angle), offsetY: shape.offsetY + o.sway * k * Math.sin(angle), speed: 1 + amount * (slow + (fast - slow) * p.energy - 1), }; }