Phase 3: look generation ("A" complete)
A track now yields a complete, coherent look with no input: palette, per-section scene assignments, parameter sets, post and feedback settings. Seeded from a hash of the decoded PCM, so a file always renders identically. - palette.js builds in OKLCH, not HSL. HSL lightness is not perceptual, so evenly-stepped HSL palettes have colours that vanish and colours that dominate — which matters when nobody is supervising the choice. Regenerates until the contrast floor is cleared. - Scenes are assigned per section KIND, not per section: a track's drops share a scene and the video reads as one piece instead of a shuffle. - Family preference per kind keeps breakdowns off strobing glitch scenes. - Section bias (energy/density/motion) carries track character into params without scenes knowing anything about audio. - PaletteSource is the seam for cover art later; no scene would change. Gate 9/9, including the look-space spread measurement (mean pairwise distance 0.168 against a 0.08 floor) — the one check that catches a generator that is deterministic and valid but visually collapsed. Known gap, not a regression: all four battery tracks currently choose the same two scenes. There are no 'minimal' family scenes yet, so intro and outro sections fall through to flow/organic. Differentiation is presently carried by palette alone. Phase 7 grows the library to fix it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c9b80308d2
commit
022c267888
@ -65,6 +65,9 @@ export function synthesizeBeat({
|
|||||||
hats = true,
|
hats = true,
|
||||||
pad = true,
|
pad = true,
|
||||||
kickGain = 1,
|
kickGain = 1,
|
||||||
|
hatGain = 0.25,
|
||||||
|
padRoot = 110,
|
||||||
|
padGain = 0.12,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const length = Math.round(duration * sampleRate);
|
const length = Math.round(duration * sampleRate);
|
||||||
const buffer = new MockAudioBuffer(2, length, sampleRate);
|
const buffer = new MockAudioBuffer(2, length, sampleRate);
|
||||||
@ -76,9 +79,9 @@ export function synthesizeBeat({
|
|||||||
for (let t = 0; t < duration; t += beat, index++) {
|
for (let t = 0; t < duration; t += beat, index++) {
|
||||||
// Accent the downbeat so the bar phase is detectable.
|
// Accent the downbeat so the bar phase is detectable.
|
||||||
addKick(left, sampleRate, t, kickGain * (index % 4 === 0 ? 1.0 : 0.8));
|
addKick(left, sampleRate, t, kickGain * (index % 4 === 0 ? 1.0 : 0.8));
|
||||||
if (hats) addHat(left, sampleRate, t + beat / 2, 0.25, index + 1);
|
if (hats) addHat(left, sampleRate, t + beat / 2, hatGain, index + 1);
|
||||||
}
|
}
|
||||||
if (pad) addPad(left, sampleRate, 0, duration, 0.12);
|
if (pad) addPad(left, sampleRate, 0, duration, padGain, padRoot);
|
||||||
|
|
||||||
for (let i = 0; i < length; i++) right[i] = left[i] * 0.98;
|
for (let i = 0; i < length; i++) right[i] = left[i] * 0.98;
|
||||||
return buffer;
|
return buffer;
|
||||||
|
|||||||
@ -1 +1,223 @@
|
|||||||
// Phase 3 gate — filled in when the phase lands.
|
// Phase 3 gate — look generation. "A" complete: a track in, a coherent video out.
|
||||||
|
//
|
||||||
|
// The load-bearing check here is look-space spread. It is entirely possible to
|
||||||
|
// build a generator that is deterministic, valid and well-typed and that produces
|
||||||
|
// visually identical output for every seed — failing the whole premise of the
|
||||||
|
// project (PLAN.md, "sameness across tracks") while passing every other test.
|
||||||
|
// The contact sheet measures it directly.
|
||||||
|
|
||||||
|
import { check, expect } from './framework.js';
|
||||||
|
import { Engine } from '../engine/Engine.js';
|
||||||
|
import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js';
|
||||||
|
import { synthesizeBeat, synthesizeSectioned } from '../audio/synth.js';
|
||||||
|
import { generateLook, rerollSection, rerollLook, describeLook } from '../look/LookGenerator.js';
|
||||||
|
import { paletteContrast, relativeLuminance } from '../look/palette.js';
|
||||||
|
import { frameDistance, frameLuminance, frameVariance } from '../engine/hash.js';
|
||||||
|
import { testTrack } from './phase1.js';
|
||||||
|
|
||||||
|
/** Four deliberately different tracks: the differentiation gate needs real spread. */
|
||||||
|
let cachedBattery = null;
|
||||||
|
export function battery() {
|
||||||
|
if (cachedBattery) return cachedBattery;
|
||||||
|
const specs = [
|
||||||
|
{ name: 'dark ambient', buffer: synthesizeBeat({ bpm: 92, duration: 60, hats: false, kickGain: 0.4, padRoot: 55, padGain: 0.22 }) },
|
||||||
|
{ name: 'mid house', buffer: synthesizeBeat({ bpm: 124, duration: 60, hatGain: 0.25, padRoot: 165 }) },
|
||||||
|
{ name: 'bright techno', buffer: synthesizeBeat({ bpm: 140, duration: 60, hatGain: 0.5, padRoot: 440, padGain: 0.2 }) },
|
||||||
|
{ name: 'structured', buffer: synthesizeSectioned({ bpm: 128, duration: 120, changeAt: 60 }) },
|
||||||
|
];
|
||||||
|
cachedBattery = specs.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
track: FeatureTrack.fromAudioBuffer(s.buffer, { fps: 60 }),
|
||||||
|
}));
|
||||||
|
return cachedBattery;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderLookFrame(engine, track, look, frame) {
|
||||||
|
const section = look.sections[track.sectionIndexAt(frame)] || look.sections[0];
|
||||||
|
const layer = section.layers[0];
|
||||||
|
engine.setLayerSpecs([{
|
||||||
|
module: layer.module,
|
||||||
|
params: layer.params,
|
||||||
|
seed: layer.seed,
|
||||||
|
opacity: layer.opacity,
|
||||||
|
blend: layer.blend,
|
||||||
|
palette: look.palette,
|
||||||
|
}]);
|
||||||
|
engine.compositor.setPost(look.post).setFeedback(look.feedback);
|
||||||
|
engine.compositor.reset();
|
||||||
|
return Uint8Array.from(engine.readPixels(engine.renderFrame(frame)));
|
||||||
|
}
|
||||||
|
|
||||||
|
check(3, 'the same audio always produces the same look', () => {
|
||||||
|
const track = testTrack();
|
||||||
|
const samples = new Float32Array(2048).map((_, i) => Math.sin(i * 0.01));
|
||||||
|
const a = generateLook(track, { samples });
|
||||||
|
const b = generateLook(track, { samples });
|
||||||
|
|
||||||
|
if (a.seed !== b.seed) return expect(false, `seeds differ: ${a.seed} vs ${b.seed}`);
|
||||||
|
const sameScenes = a.sections.every((s, i) => s.layers[0].module.name === b.sections[i].layers[0].module.name);
|
||||||
|
const sameParams = JSON.stringify(a.sections.map((s) => s.layers[0].params))
|
||||||
|
=== JSON.stringify(b.sections.map((s) => s.layers[0].params));
|
||||||
|
const samePalette = JSON.stringify(a.palette) === JSON.stringify(b.palette);
|
||||||
|
|
||||||
|
return expect(sameScenes && sameParams && samePalette,
|
||||||
|
`seed ${a.seed.toString(16)} · scenes ${sameScenes} · params ${sameParams} · palette ${samePalette}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
check(3, 'different audio content produces a different seed', () => {
|
||||||
|
const track = testTrack();
|
||||||
|
const a = generateLook(track, { samples: new Float32Array(4096).map((_, i) => Math.sin(i * 0.01)) });
|
||||||
|
const b = generateLook(track, { samples: new Float32Array(4096).map((_, i) => Math.sin(i * 0.013)) });
|
||||||
|
return expect(a.seed !== b.seed, `${a.seed.toString(16)} vs ${b.seed.toString(16)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
check(3, 'palettes clear the contrast floor', () => {
|
||||||
|
const problems = [];
|
||||||
|
for (const { name, track } of battery()) {
|
||||||
|
for (let s = 0; s < 12; s++) {
|
||||||
|
const look = generateLook(track, { seed: 1000 + s * 7919 });
|
||||||
|
const { luminanceSpread, chromaSpread } = paletteContrast(look.palette);
|
||||||
|
if (luminanceSpread < 0.18) {
|
||||||
|
problems.push(`${name} seed ${s}: luminance spread ${luminanceSpread.toFixed(3)}`);
|
||||||
|
}
|
||||||
|
if (chromaSpread < 0.20) {
|
||||||
|
problems.push(`${name} seed ${s}: chroma spread ${chromaSpread.toFixed(3)}`);
|
||||||
|
}
|
||||||
|
if (look.palette.some((c) => c.some((v) => !Number.isFinite(v) || v < 0 || v > 1))) {
|
||||||
|
problems.push(`${name} seed ${s}: colour out of gamut`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expect(problems.length === 0,
|
||||||
|
problems.length ? problems.slice(0, 4).join(' · ') : '48 palettes all usable');
|
||||||
|
});
|
||||||
|
|
||||||
|
check(3, 'look space is genuinely wide across seeds', () => {
|
||||||
|
// The seed contact sheet, measured. One frame per seed, mean pairwise
|
||||||
|
// distance. A collapsed generator fails here and nowhere else.
|
||||||
|
const track = testTrack();
|
||||||
|
const engine = new Engine({ width: 160, height: 90 });
|
||||||
|
try {
|
||||||
|
engine.timeline.setDuration(track.duration);
|
||||||
|
engine.setFeatureProvider(featureProviderFor(track));
|
||||||
|
|
||||||
|
const frames = [];
|
||||||
|
for (let s = 0; s < 16; s++) {
|
||||||
|
const look = generateLook(track, { seed: 7000 + s * 104729 });
|
||||||
|
frames.push(renderLookFrame(engine, track, look, 1500));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sum = 0;
|
||||||
|
let pairs = 0;
|
||||||
|
let minDistance = 1;
|
||||||
|
for (let i = 0; i < frames.length; i++) {
|
||||||
|
for (let j = i + 1; j < frames.length; j++) {
|
||||||
|
const d = frameDistance(frames[i], frames[j]);
|
||||||
|
sum += d; pairs++;
|
||||||
|
minDistance = Math.min(minDistance, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const mean = sum / pairs;
|
||||||
|
return expect(mean > 0.08 && minDistance > 0.01,
|
||||||
|
`mean pairwise distance ${mean.toFixed(4)} (floor 0.08), closest pair ${minDistance.toFixed(4)} (floor 0.01)`);
|
||||||
|
} finally {
|
||||||
|
engine.dispose();
|
||||||
|
}
|
||||||
|
}, { slow: true });
|
||||||
|
|
||||||
|
check(3, 'different tracks get different looks at the same seed', () => {
|
||||||
|
const looks = battery().map(({ name, track }) => ({
|
||||||
|
name,
|
||||||
|
look: generateLook(track, { seed: 42 }), // fixed seed: the difference must come from the audio
|
||||||
|
}));
|
||||||
|
|
||||||
|
const palettes = looks.map(({ look }) => look.palette.map(relativeLuminance).join(','));
|
||||||
|
const uniquePalettes = new Set(palettes).size;
|
||||||
|
const sceneSets = looks.map(({ look }) =>
|
||||||
|
[...new Set(look.sections.map((s) => s.layers[0].module.name))].sort().join('+'));
|
||||||
|
|
||||||
|
return expect(uniquePalettes === looks.length,
|
||||||
|
`${uniquePalettes}/${looks.length} distinct palettes at a fixed seed · ` +
|
||||||
|
looks.map((l, i) => `${l.name}→${sceneSets[i]}`).join(' · '));
|
||||||
|
});
|
||||||
|
|
||||||
|
check(3, 'every generated look renders a live frame on every track', () => {
|
||||||
|
// The generator wanders into corners of the parameter space that the Phase 2
|
||||||
|
// sweep only tests one axis at a time; this tests them in combination.
|
||||||
|
const problems = [];
|
||||||
|
let rendered = 0;
|
||||||
|
|
||||||
|
for (const { name, track } of battery()) {
|
||||||
|
const engine = new Engine({ width: 160, height: 90 });
|
||||||
|
try {
|
||||||
|
engine.timeline.setDuration(track.duration);
|
||||||
|
engine.setFeatureProvider(featureProviderFor(track));
|
||||||
|
|
||||||
|
for (let s = 0; s < 6; s++) {
|
||||||
|
const look = generateLook(track, { seed: 300 + s * 15485863 });
|
||||||
|
for (const section of look.sections) {
|
||||||
|
const frame = Math.min(track.frameCount - 1,
|
||||||
|
section.startFrame + Math.floor((section.endFrame - section.startFrame) / 2));
|
||||||
|
const pixels = renderLookFrame(engine, track, look, frame);
|
||||||
|
rendered++;
|
||||||
|
const lum = frameLuminance(pixels);
|
||||||
|
const variance = frameVariance(pixels);
|
||||||
|
if (lum < 0.0008 || lum > 0.99 || variance < 0.0015) {
|
||||||
|
problems.push(`${name} s${s} ${section.kind}/${section.layers[0].module.name}: ` +
|
||||||
|
`lum ${lum.toFixed(4)} var ${variance.toFixed(4)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
engine.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expect(problems.length === 0,
|
||||||
|
problems.length ? problems.slice(0, 5).join(' · ') : `${rendered} generated frames, all live`);
|
||||||
|
}, { slow: true });
|
||||||
|
|
||||||
|
check(3, 'sections of the same kind share a scene', () => {
|
||||||
|
// Coherence: a track's drops should look like each other, or the video reads
|
||||||
|
// as a shuffle rather than as one piece.
|
||||||
|
const { track } = battery()[3];
|
||||||
|
const look = generateLook(track, { seed: 99 });
|
||||||
|
const byKind = new Map();
|
||||||
|
let violations = 0;
|
||||||
|
for (const s of look.sections) {
|
||||||
|
const name = s.layers[0].module.name;
|
||||||
|
if (byKind.has(s.kind) && byKind.get(s.kind) !== name) violations++;
|
||||||
|
byKind.set(s.kind, name);
|
||||||
|
}
|
||||||
|
return expect(violations === 0,
|
||||||
|
violations ? `${violations} kind(s) using multiple scenes` : describeLook(look));
|
||||||
|
});
|
||||||
|
|
||||||
|
check(3, 'reroll changes a section and respects locks', () => {
|
||||||
|
const track = testTrack();
|
||||||
|
const look = generateLook(track, { seed: 555 });
|
||||||
|
const before = JSON.stringify(look.sections[0].layers[0].params);
|
||||||
|
|
||||||
|
rerollSection(look, track, 0, 1);
|
||||||
|
const afterUnlocked = JSON.stringify(look.sections[0].layers[0].params);
|
||||||
|
|
||||||
|
look.sections[0].locked = true;
|
||||||
|
rerollSection(look, track, 0, 2);
|
||||||
|
const afterLocked = JSON.stringify(look.sections[0].layers[0].params);
|
||||||
|
|
||||||
|
return expect(before !== afterUnlocked && afterUnlocked === afterLocked,
|
||||||
|
`changed when unlocked: ${before !== afterUnlocked}, held when locked: ${afterUnlocked === afterLocked}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
check(3, 'a whole-track reroll preserves locked sections', () => {
|
||||||
|
const track = testTrack();
|
||||||
|
const look = generateLook(track, { seed: 777 });
|
||||||
|
look.sections[0].locked = true;
|
||||||
|
const lockedScene = look.sections[0].layers[0].module.name;
|
||||||
|
const lockedParams = JSON.stringify(look.sections[0].layers[0].params);
|
||||||
|
|
||||||
|
const next = rerollLook(look, track, 888);
|
||||||
|
return expect(
|
||||||
|
next.sections[0].layers[0].module.name === lockedScene &&
|
||||||
|
JSON.stringify(next.sections[0].layers[0].params) === lockedParams,
|
||||||
|
`locked section survived a full reroll (${lockedScene})`);
|
||||||
|
});
|
||||||
|
|||||||
236
flow-state/src/look/LookGenerator.js
Normal file
236
flow-state/src/look/LookGenerator.js
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
// Turns a FeatureTrack into a complete LookSpec: palette, per-section scene
|
||||||
|
// assignments, parameter sets, and the post/feedback settings.
|
||||||
|
//
|
||||||
|
// Runs once per track. Deterministic in the seed, and the seed is derived from
|
||||||
|
// the decoded audio, so a given file always renders the same video.
|
||||||
|
|
||||||
|
import { Rng, hashSamples } from '../engine/rng.js';
|
||||||
|
import { AudioPalette, generateUsablePalette } from './palette.js';
|
||||||
|
import { scenes, scenesInFamily, FAMILIES } from '../scenes/registry.js';
|
||||||
|
import { sampleValues, defaultValues } from '../params/schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which families suit which section kind, in preference order.
|
||||||
|
*
|
||||||
|
* This is the coupling that stops a breakdown landing on a strobing glitch scene
|
||||||
|
* and an intro opening at full density. It is also why families exist at all.
|
||||||
|
*/
|
||||||
|
const FAMILY_BY_KIND = {
|
||||||
|
intro: ['minimal', 'flow', 'organic'],
|
||||||
|
build: ['structural', 'geometric', 'flow'],
|
||||||
|
drop: ['geometric', 'glitch', 'structural'],
|
||||||
|
sustain: ['organic', 'flow', 'geometric'],
|
||||||
|
breakdown: ['minimal', 'organic', 'flow'],
|
||||||
|
outro: ['minimal', 'flow', 'organic'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_ENERGY = {
|
||||||
|
intro: 0.25, build: 0.55, drop: 0.95, sustain: 0.6, breakdown: 0.25, outro: 0.2,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameter bias per section: the values scenes declare a `bias` key against.
|
||||||
|
*
|
||||||
|
* This is how a track's measured character reaches a scene's parameters without
|
||||||
|
* the scene knowing anything about audio. A dense, loud drop pushes `density`
|
||||||
|
* and `energy` up; a breakdown pulls them down. Seed variation still dominates,
|
||||||
|
* so two tracks with the same structure do not converge on the same look.
|
||||||
|
*/
|
||||||
|
function biasFor(section, summary) {
|
||||||
|
const kindEnergy = KIND_ENERGY[section.kind] ?? 0.5;
|
||||||
|
const measured = Math.min(1, section.energy / Math.max(1e-6, summary.meanLoudness * 1.6));
|
||||||
|
const energy = kindEnergy * 0.6 + measured * 0.4;
|
||||||
|
|
||||||
|
return {
|
||||||
|
energy,
|
||||||
|
density: Math.min(1, energy * 0.7 + section.flux * 1.2),
|
||||||
|
motion: Math.min(1, 0.25 + energy * 0.5 + (summary.bpm - 90) / 180),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scenes are chosen per section KIND, not per section.
|
||||||
|
*
|
||||||
|
* All of a track's drops therefore share a scene, all its breakdowns share
|
||||||
|
* another, and the video acquires an identity instead of reading as a shuffle.
|
||||||
|
* Variation between two sections of the same kind comes from their parameter
|
||||||
|
* sets and from the arc driver's drift, which is enough to keep them distinct
|
||||||
|
* without losing the through-line.
|
||||||
|
*/
|
||||||
|
function assignScenesByKind(sections, rng) {
|
||||||
|
const byKind = new Map();
|
||||||
|
const used = new Set();
|
||||||
|
|
||||||
|
const kinds = [...new Set(sections.map((s) => s.kind))];
|
||||||
|
// Order matters for variety: assign the high-impact kinds first so they get
|
||||||
|
// first pick of the library rather than whatever is left.
|
||||||
|
const priority = ['drop', 'sustain', 'build', 'breakdown', 'intro', 'outro'];
|
||||||
|
kinds.sort((a, b) => priority.indexOf(a) - priority.indexOf(b));
|
||||||
|
|
||||||
|
for (const kind of kinds) {
|
||||||
|
const families = FAMILY_BY_KIND[kind] || Object.keys(FAMILIES);
|
||||||
|
let candidates = [];
|
||||||
|
for (const family of families) {
|
||||||
|
const inFamily = scenesInFamily(family);
|
||||||
|
// Weight by family preference order, and push down anything already
|
||||||
|
// used so a five-section track doesn't show one scene five times.
|
||||||
|
const weight = families.length - families.indexOf(family);
|
||||||
|
for (const scene of inFamily) {
|
||||||
|
candidates.push({ scene, weight: weight * (used.has(scene.name) ? 0.15 : 1) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!candidates.length) candidates = scenes.map((scene) => ({ scene, weight: 1 }));
|
||||||
|
|
||||||
|
const chosen = rng.pickWeighted(
|
||||||
|
candidates.map((c) => c.scene),
|
||||||
|
candidates.map((c) => c.weight),
|
||||||
|
);
|
||||||
|
byKind.set(kind, chosen);
|
||||||
|
used.add(chosen.name);
|
||||||
|
}
|
||||||
|
return byKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post-processing and feedback derived from track character.
|
||||||
|
* Ambient material gets more feedback and bloom and less grain; dense club
|
||||||
|
* material gets tighter, punchier settings.
|
||||||
|
*/
|
||||||
|
function derivePost(summary, rng) {
|
||||||
|
const bright = summary.meanCentroid;
|
||||||
|
const noisy = Math.min(1, summary.meanFlatness * 3);
|
||||||
|
const dynamic = Math.min(1, summary.dynamicRange);
|
||||||
|
|
||||||
|
return {
|
||||||
|
post: {
|
||||||
|
bloom: 0.25 + (1 - noisy) * 0.35 + rng.range(-0.05, 0.05),
|
||||||
|
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,
|
||||||
|
vignette: 0.25 + (1 - bright) * 0.25,
|
||||||
|
contrast: 1.0 + dynamic * 0.15,
|
||||||
|
saturation: 1.0 + (1 - noisy) * 0.25,
|
||||||
|
lift: 0.0,
|
||||||
|
exposure: 1.0,
|
||||||
|
},
|
||||||
|
feedback: {
|
||||||
|
// Dynamic, spacious material tolerates long trails; dense material
|
||||||
|
// turns to smear, so it gets much less.
|
||||||
|
amount: Math.min(0.75, 0.15 + dynamic * 0.5),
|
||||||
|
decay: 0.86 + dynamic * 0.08,
|
||||||
|
zoom: 1.0 + rng.range(-0.006, 0.006),
|
||||||
|
rotate: rng.range(-0.004, 0.004),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {FeatureTrack} track
|
||||||
|
* @param {object} options
|
||||||
|
* @returns {object} LookSpec
|
||||||
|
*/
|
||||||
|
export function generateLook(track, { seed = null, samples = null, overrides = null } = {}) {
|
||||||
|
const resolvedSeed = seed !== null
|
||||||
|
? seed >>> 0
|
||||||
|
: samples ? hashSamples(samples) : 0x9e3779b9;
|
||||||
|
|
||||||
|
const rng = new Rng(resolvedSeed);
|
||||||
|
const summary = track.summary;
|
||||||
|
|
||||||
|
const paletteSource = new AudioPalette(summary, rng.fork('palette'));
|
||||||
|
const palette = generateUsablePalette(paletteSource, 6);
|
||||||
|
|
||||||
|
const sceneByKind = assignScenesByKind(track.sections, rng.fork('scenes'));
|
||||||
|
const { post, feedback } = derivePost(summary, rng.fork('post'));
|
||||||
|
|
||||||
|
const sections = track.sections.map((section) => {
|
||||||
|
const module = sceneByKind.get(section.kind) || scenes[0];
|
||||||
|
const sectionRng = rng.fork(`section:${section.index}:${module.name}`);
|
||||||
|
const bias = biasFor(section, summary);
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: section.index,
|
||||||
|
kind: section.kind,
|
||||||
|
startFrame: section.startFrame,
|
||||||
|
endFrame: section.endFrame,
|
||||||
|
start: section.start,
|
||||||
|
end: section.end,
|
||||||
|
locked: false,
|
||||||
|
bias,
|
||||||
|
layers: [{
|
||||||
|
module,
|
||||||
|
params: sampleValues(module, sectionRng, bias),
|
||||||
|
seed: sectionRng.int(0, 0x7fffffff),
|
||||||
|
blend: 'normal',
|
||||||
|
opacity: 1,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const look = {
|
||||||
|
seed: resolvedSeed,
|
||||||
|
palette,
|
||||||
|
paletteScheme: paletteSource.lastScheme,
|
||||||
|
post,
|
||||||
|
feedback,
|
||||||
|
sections,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
|
||||||
|
return overrides ? applyOverrides(look, overrides) : look;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-roll one section, leaving everything else — and locked sections — alone. */
|
||||||
|
export function rerollSection(look, track, sectionIndex, salt = 0) {
|
||||||
|
const section = look.sections[sectionIndex];
|
||||||
|
if (!section || section.locked) return look;
|
||||||
|
|
||||||
|
const rng = new Rng((look.seed ^ (sectionIndex * 0x9e3779b9) ^ (salt * 0x85ebca6b)) >>> 0);
|
||||||
|
const families = FAMILY_BY_KIND[section.kind] || Object.keys(FAMILIES);
|
||||||
|
const candidates = families.flatMap((f) => scenesInFamily(f));
|
||||||
|
const module = candidates.length ? rng.pick(candidates) : scenes[0];
|
||||||
|
|
||||||
|
section.layers = [{
|
||||||
|
module,
|
||||||
|
params: sampleValues(module, rng, section.bias),
|
||||||
|
seed: rng.int(0, 0x7fffffff),
|
||||||
|
blend: 'normal',
|
||||||
|
opacity: 1,
|
||||||
|
}];
|
||||||
|
return look;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reroll the whole track with a new seed, preserving locked sections. */
|
||||||
|
export function rerollLook(look, track, newSeed) {
|
||||||
|
const locked = new Map();
|
||||||
|
look.sections.forEach((s) => { if (s.locked) locked.set(s.index, s); });
|
||||||
|
|
||||||
|
const next = generateLook(track, { seed: newSeed >>> 0 });
|
||||||
|
next.sections.forEach((s, i) => {
|
||||||
|
if (locked.has(i)) next.sections[i] = locked.get(i);
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyOverrides(look, overrides) {
|
||||||
|
if (overrides.palette) look.palette = overrides.palette;
|
||||||
|
if (overrides.post) look.post = { ...look.post, ...overrides.post };
|
||||||
|
if (overrides.feedback) look.feedback = { ...look.feedback, ...overrides.feedback };
|
||||||
|
if (overrides.sections) {
|
||||||
|
overrides.sections.forEach((o, i) => {
|
||||||
|
if (!look.sections[i]) return;
|
||||||
|
if (o.locked !== undefined) look.sections[i].locked = o.locked;
|
||||||
|
if (o.params) Object.assign(look.sections[i].layers[0].params, o.params);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return look;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact description, used by the HUD and by check output. */
|
||||||
|
export function describeLook(look) {
|
||||||
|
const kinds = look.sections.map((s) => `${s.kind}:${s.layers[0].module.name}`);
|
||||||
|
return `seed ${look.seed.toString(16)} · ${look.paletteScheme} · ${[...new Set(kinds)].join(', ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { defaultValues };
|
||||||
156
flow-state/src/look/palette.js
Normal file
156
flow-state/src/look/palette.js
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
// Palette generation.
|
||||||
|
//
|
||||||
|
// Colours are built in OKLCH rather than HSL. HSL's lightness is not perceptual —
|
||||||
|
// pure yellow and pure blue at the same "lightness" differ enormously in how
|
||||||
|
// bright they look — so an HSL palette with even lightness steps produces a set
|
||||||
|
// where some colours vanish and others dominate. OKLCH steps look even because
|
||||||
|
// they are even, which matters a lot when the generator is choosing palettes
|
||||||
|
// unsupervised and nobody is there to correct a bad one.
|
||||||
|
//
|
||||||
|
// Cover art is not available (PLAN.md §Decisions), so everything here derives
|
||||||
|
// from the audio. `PaletteSource` is the seam: adding a CoverArtPalette later is
|
||||||
|
// a new implementation of this interface and one line of config, with no change
|
||||||
|
// to any scene.
|
||||||
|
|
||||||
|
/** OKLCH -> sRGB, components 0..1. h in radians. */
|
||||||
|
export function oklchToRgb(L, C, h) {
|
||||||
|
const a = C * Math.cos(h);
|
||||||
|
const b = C * Math.sin(h);
|
||||||
|
|
||||||
|
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||||
|
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||||
|
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
||||||
|
|
||||||
|
const l = l_ * l_ * l_;
|
||||||
|
const m = m_ * m_ * m_;
|
||||||
|
const s = s_ * s_ * s_;
|
||||||
|
|
||||||
|
const lr = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
||||||
|
const lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
||||||
|
const lb = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
|
||||||
|
|
||||||
|
const gamma = (x) => {
|
||||||
|
const v = Math.max(0, Math.min(1, x));
|
||||||
|
return v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
|
||||||
|
};
|
||||||
|
return [gamma(lr), gamma(lg), gamma(lb)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function relativeLuminance([r, g, b]) {
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spread of a palette's luminance and hue. The Phase 3 gate uses this to reject
|
||||||
|
* muddy sets — palettes where everything sits at the same brightness read as a
|
||||||
|
* single colour once they are composited and bloomed.
|
||||||
|
*/
|
||||||
|
export function paletteContrast(colors) {
|
||||||
|
if (!colors || colors.length < 2) return { luminanceSpread: 0, chromaSpread: 0 };
|
||||||
|
const lums = colors.map(relativeLuminance);
|
||||||
|
const luminanceSpread = Math.max(...lums) - Math.min(...lums);
|
||||||
|
|
||||||
|
let chromaSpread = 0;
|
||||||
|
for (let i = 0; i < colors.length; i++) {
|
||||||
|
for (let j = i + 1; j < colors.length; j++) {
|
||||||
|
const d = Math.hypot(
|
||||||
|
colors[i][0] - colors[j][0],
|
||||||
|
colors[i][1] - colors[j][1],
|
||||||
|
colors[i][2] - colors[j][2],
|
||||||
|
);
|
||||||
|
chromaSpread = Math.max(chromaSpread, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { luminanceSpread, chromaSpread };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCHEMES = {
|
||||||
|
analogous: (h, rng) => [h, h + 0.35, h - 0.35, h + 0.7, h - 0.6, h + 1.0],
|
||||||
|
complement: (h) => [h, h + Math.PI, h + 0.4, h + Math.PI - 0.4, h + 0.8, h + Math.PI + 0.3],
|
||||||
|
triad: (h) => [h, h + 2.094, h + 4.189, h + 0.5, h + 2.6, h + 4.7],
|
||||||
|
split: (h) => [h, h + 2.6, h + 3.7, h + 0.35, h + 2.9, h + 3.4],
|
||||||
|
duo: (h) => [h, h + 1.9, h + 0.2, h + 2.1, h - 0.25, h + 1.7],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SCHEME_NAMES = Object.keys(SCHEMES);
|
||||||
|
|
||||||
|
/** The interface a palette source implements. */
|
||||||
|
export class PaletteSource {
|
||||||
|
/** @returns {number[][]} array of [r,g,b] in 0..1 */
|
||||||
|
generate() { throw new Error('PaletteSource.generate not implemented'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a palette from what the track actually sounds like.
|
||||||
|
*
|
||||||
|
* - spectral centroid -> hue family. A bass-heavy track lands in deep blues and
|
||||||
|
* violets; a bright one moves toward cyan, green and amber. This is the single
|
||||||
|
* strongest differentiator between two tracks, because it tracks the thing a
|
||||||
|
* listener would call the track's colour anyway.
|
||||||
|
* - flatness (noisy vs tonal) -> chroma. Noisy material gets desaturated so it
|
||||||
|
* doesn't turn to mud once bloom is applied.
|
||||||
|
* - dynamic range -> lightness spread. A dynamic track earns a wider range
|
||||||
|
* between its darkest and brightest colour.
|
||||||
|
*/
|
||||||
|
export class AudioPalette extends PaletteSource {
|
||||||
|
constructor(summary, rng) {
|
||||||
|
super();
|
||||||
|
this.summary = summary;
|
||||||
|
this.rng = rng;
|
||||||
|
}
|
||||||
|
|
||||||
|
generate(count = 6) {
|
||||||
|
const { meanCentroid = 0.5, meanFlatness = 0.2, dynamicRange = 0.5 } = 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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const colors = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const t = count > 1 ? i / (count - 1) : 0;
|
||||||
|
// Deliberately non-linear: most entries mid-dark, one or two bright.
|
||||||
|
// Scenes use pal(0) as a base and higher indices as accents.
|
||||||
|
const L = Math.max(0.06, Math.min(0.95, anchor + Math.pow(t, 1.7) * spread));
|
||||||
|
const C = chromaBase * (0.55 + Math.sin(t * Math.PI) * 0.75) + rng.range(-0.012, 0.012);
|
||||||
|
const h = hues[i % hues.length] + rng.range(-0.08, 0.08);
|
||||||
|
colors.push(oklchToRgb(L, Math.max(0, C), h));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastScheme = schemeName;
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry until the palette clears the contrast floor. Unsupervised generation
|
||||||
|
* will occasionally land on a muddy set; regenerating is cheap and beats
|
||||||
|
* shipping a video where every colour is the same grey-violet.
|
||||||
|
*/
|
||||||
|
export function generateUsablePalette(source, count = 6, { minLuminanceSpread = 0.22, attempts = 12 } = {}) {
|
||||||
|
let best = null;
|
||||||
|
let bestScore = -1;
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
const colors = source.generate(count);
|
||||||
|
const { luminanceSpread, chromaSpread } = paletteContrast(colors);
|
||||||
|
const score = luminanceSpread + chromaSpread * 0.4;
|
||||||
|
if (score > bestScore) { bestScore = score; best = colors; }
|
||||||
|
if (luminanceSpread >= minLuminanceSpread) return colors;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toHex([r, g, b]) {
|
||||||
|
const c = (v) => Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16).padStart(2, '0');
|
||||||
|
return `#${c(r)}${c(g)}${c(b)}`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user