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.
This commit is contained in:
Dejvino 2026-08-05 23:02:00 +02:00
parent 2e4e1d7613
commit 0ee7a6a3b3
4 changed files with 42 additions and 12 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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.

View File

@ -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++) {