From 0ee7a6a3b34be134fcf6c94423819e3243506b4c Mon Sep 17 00:00:00 2001 From: Dejvino Date: Wed, 5 Aug 2026 23:02:00 +0200 Subject: [PATCH] Diversify palette by timbre, tame the grain Palette hue was pinned to the spectral centroid, which is essentially a bass-vs-treble number most mastered pop lands mid-range on, so different songs converged on the same blue/green/purple wedge and warm red/yellow was unreachable. Derive the base hue from a timbre signature instead: the track's spectral mass in the body (sub/low/mid) against the trebles (high/air), mapped onto a cool(blue)->warm(red) ramp, with BPM+tonality +dynamics driving vibrance. Energy and timbre are now two orthogonal axes, and palettes vary meaningfully between tracks. Grain was also overused: every scene adds its own surface grain and the grade adds another on top. Drop the per-scene texture floor, the grade's grain range, and the default, so tonal tracks read clean. --- flow-state/src/engine/Compositor.js | 2 +- flow-state/src/look/LookGenerator.js | 2 +- flow-state/src/look/Personality.js | 6 +++- flow-state/src/look/palette.js | 44 ++++++++++++++++++++++------ 4 files changed, 42 insertions(+), 12 deletions(-) diff --git a/flow-state/src/engine/Compositor.js b/flow-state/src/engine/Compositor.js index ebbd136..58731ce 100644 --- a/flow-state/src/engine/Compositor.js +++ b/flow-state/src/engine/Compositor.js @@ -10,7 +10,7 @@ const DEFAULT_POST = { bloomThreshold: 0.6, bloomKnee: 0.3, chroma: 0.15, - grain: 0.04, + grain: 0.02, vignette: 0.35, contrast: 1.05, saturation: 1.1, diff --git a/flow-state/src/look/LookGenerator.js b/flow-state/src/look/LookGenerator.js index 89e4d81..b2df469 100644 --- a/flow-state/src/look/LookGenerator.js +++ b/flow-state/src/look/LookGenerator.js @@ -160,7 +160,7 @@ function derivePost(summary, rng) { bloomThreshold: 0.45 + bright * 0.25, bloomKnee: 0.25, chroma: 0.05 + noisy * 0.25 + rng.range(0, 0.08), - grain: 0.02 + noisy * 0.05, + grain: 0.012 + noisy * 0.03, vignette: 0.25 + (1 - bright) * 0.25, contrast: 1.0 + dynamic * 0.15, saturation: 1.0 + (1 - noisy) * 0.25, diff --git a/flow-state/src/look/Personality.js b/flow-state/src/look/Personality.js index 7b929bd..30d4e9c 100644 --- a/flow-state/src/look/Personality.js +++ b/flow-state/src/look/Personality.js @@ -96,7 +96,11 @@ export function generatePersonality(summary, rng, countEligible = null) { const style = { lineWeight: 0.4 + bright * 0.4 + rng.range(-0.15, 0.25), softness: 0.25 + (1 - bright) * 0.4 + rng.range(-0.1, 0.2), - texture: noisy * 0.5 + rng.range(0, 0.25), + // Surface grain is a texture trait, not a default. The old floor of + // ~0.12 put visible film grain on even a perfectly tonal track, and + // since every scene adds sigGrain AND the grade adds its own, that read + // as "grainy by default". Only tracks that want grit carry any. + texture: noisy * 0.4 + rng.range(0, 0.1), // Fold counts stay low and are usually off. Symmetry is the fastest way // to make a library look like one series and also the fastest way to // make every track look like a screensaver. diff --git a/flow-state/src/look/palette.js b/flow-state/src/look/palette.js index 64eeadd..4f2607b 100644 --- a/flow-state/src/look/palette.js +++ b/flow-state/src/look/palette.js @@ -100,21 +100,47 @@ export class AudioPalette extends PaletteSource { } generate(count = 6) { - const { meanCentroid = 0.5, meanFlatness = 0.2, dynamicRange = 0.5 } = this.summary; + const { + meanCentroid = 0.5, meanFlatness = 0.2, dynamicRange = 0.5, + bandBalance = {}, bpm = 120, + } = this.summary; const rng = this.rng; - // Centroid 0..1 mapped onto roughly violet -> blue -> cyan -> green -> amber. - // Offset by a seeded jitter so two tracks with similar spectra still differ. - const baseHue = (4.9 - meanCentroid * 3.6) + rng.range(-0.45, 0.45); + // --- Temperature: the track's timbre signature, not its loudness --- + // Warmth places spectral mass from the body (sub/low/mid) against the + // trebles (high/air). It is folded through the whole band profile rather + // than the centroid alone, because the centroid is a one number that + // most mastered pop sits in the middle of — which was why every track + // flared green/purple. This is the feel of the sound: a voice-and-body + // forward track belongs to the warm end of the wheel, a crisp or airy + // track to the cool end. + const body = (bandBalance.sub ?? 0.4) * 0.6 + + (bandBalance.low ?? 0.4) * 0.9 + + (bandBalance.mid ?? 0.3) * 0.4; + const treble = (bandBalance.high ?? 0.3) * 0.7 + + (bandBalance.air ?? 0.3) * 0.5 + + meanCentroid * 0.5; + const warmth = body / (body + treble + 1e-6); // 0 = cold, 1 = warm + + // Hue sweeps cold(blue, 240°) -> cyan -> green -> yellow -> warm(red), + // so warm material finally reaches red/yellow rather than pooling in the + // blue/green gap. A little seeded jitter keeps identical tracks apart. + const baseHue = (1 - warmth) * (Math.PI * 4 / 3) + rng.range(-0.45, 0.45); const schemeName = rng.pick(SCHEME_NAMES); const hues = SCHEMES[schemeName](baseHue, rng); - // Noisy material desaturates; tonal material is allowed to sing. - const chromaBase = 0.10 + (1 - Math.min(1, meanFlatness * 3)) * 0.11; - // A dynamic track gets a wider light-to-dark range. - const spread = 0.30 + Math.min(1, dynamicRange) * 0.34; - const anchor = 0.36 + rng.range(-0.05, 0.10); + // --- Energy: how vivid and punchy the palette is --- + // Upbeat, dynamic material earns saturated colour; chill, flat material + // stays muted. This is the "does it pop" axis, orthogonal to timbre. + const fast = Math.min(1, Math.max(0, (bpm - 80) / 150)); + const energy = Math.min(1, fast * 0.4 + (1 - Math.min(1, meanFlatness)) * 0.4 + dynamicRange * 0.3); + const chromaBase = 0.10 + energy * 0.16; + + // A dynamic mercury gets a wider light-to-dark range; warmth keeps warm + // tones from sinking into brown, since dark + orange is mud. + const spread = 0.30 + Math.min(1, dynamicRange) * 0.30; + const anchor = 0.40 - warmth * 0.06 + rng.range(-0.05, 0.10); const colors = []; for (let i = 0; i < count; i++) {