From 0c5b0a8d4db1bed6439c6cee4c15c88df15a532d Mon Sep 17 00:00:00 2001 From: Dejvino Date: Mon, 17 Aug 2026 07:00:45 +0200 Subject: [PATCH] Close out the twenty: what the gates taught Three lessons worth more than the scenes: a slow axis has to move a large low-frequency area or the camera's own drift beats it, whole-frame luminance on the kick is the strobe the flash gate exists for and it arrives by accident, and a style trait expressed only as grain measures as no style at all. Co-Authored-By: Claude Opus 5 --- flow-state/LIBRARY-20.md | 41 +++++++++++++++++ flow-state/src/audio/songbank.js | 56 +++++++++++++---------- flow-state/src/audio/synth.js | 71 +++++++++++++++++++++-------- flow-state/tools/build-song-bank.js | 34 ++++++++++++++ 4 files changed, 160 insertions(+), 42 deletions(-) diff --git a/flow-state/LIBRARY-20.md b/flow-state/LIBRARY-20.md index a4adfad..d4cd8c5 100644 --- a/flow-state/LIBRARY-20.md +++ b/flow-state/LIBRARY-20.md @@ -65,3 +65,44 @@ before the batch commit. | 18 | Halftone Misprint | camera, style | CMY halftone screens, misregistered | nothing else in the library draws dots | | 19 | Droste Feedback | camera, style | recursive zoom-and-rotate video feedback around a small motif | Time Smear translates; this scales, so the image eats itself | | 20 | Analog Wow | camera, style | tape warp: horizontal wobble, chroma bleed, dropout streaks | Scan Tear and Block Mosh are quantised and blocky; this is continuous and wet | + +--- + +## What landed + +All twenty, in six commits, each batch gated before it went in. The library is +at **62 scenes**: flow, organic, geometric and glitch at ten each, structural and +minimal at eleven. `space` went from 20 scenes to 28 and `shape` from 21 to 28, +which was the point — those are the traits that decide whether a track has +anything to cast. + +Three things came out of the build that are worth keeping: + +**A slow axis needs a large, low-frequency area, not a detailed one.** Phase 11 +compares ten-second averages, and the camera's own drift moves that average by +0.01-0.02 on any high-contrast image. An axis that changes line count, drop +density or tier count loses to it; one that changes a wake's width, a colony's +extent, a tide level or a deck's height beats it. Seven of the twenty declare an +axis. Six do not, and each of those files records the candidates measured and +what they scored — Turing Bloom, Voronoi Shatter, Droste Feedback and Analog Wow +because a feedback or re-cutting scene's own history moves the average further +than any parameter can, Contour Map and Balance Stack because the frame is too +empty for anything in it to out-move a slow pan. + +**Two ways to strobe a whole frame, both found by the flash gate.** Driving block +heights off the low end made every tower in Isometric Blocks grow together — a +frame-wide luminance cycle on the kick, 4/s. A hard recursion cutoff in +Apollonian Gasket popped a whole generation of discs in and out as the fold +crossed it. Local terms and a faded cutoff fixed both. + +**Style expressed only as grain is not style.** Six of the twenty first shipped +with a style response the render gate measured at 11-24 out of 255 — the Side +Quest 1 complaint, reproduced from scratch. Putting the track's line weight and +edge softness into the actual subject (the dot, the ring, the seam, the course, +the band boundary) took them to 86-255. + +One check changed: Phase 4's transition control is the sum of the two adjacent +shots' motion rather than the larger of them. See the commit; the short version +is that a dissolve has both stacks live and a nonlinear blend of two moving +stacks moves more than either alone, so the old control was reading its own +denominator once the library had calm scenes in it. diff --git a/flow-state/src/audio/songbank.js b/flow-state/src/audio/songbank.js index 848343e..7b9d9cb 100644 --- a/flow-state/src/audio/songbank.js +++ b/flow-state/src/audio/songbank.js @@ -35,41 +35,43 @@ import { synthesizeSong } from './synth.js'; */ export const SONGS = [ // --- slow, quiet, spacious ------------------------------------------- - { name: 'drone', bpm: 62, arrangement: 'ambient', brightness: 0.10, noise: 0.05, dynamics: 0.90, - covers: 'slowest · darkest · beatless · most dynamic' }, - { name: 'air', bpm: 76, arrangement: 'ambient', brightness: 0.85, noise: 0.35, dynamics: 0.85, + { name: 'drone', bpm: 62, arrangement: 'ambient', brightness: 0.05, noise: 0.02, dynamics: 0.95, + covers: 'beatless · darkest · most dynamic' }, + { name: 'air', bpm: 76, arrangement: 'ambient', brightness: 0.90, noise: 0.25, noiseColour: 0.15, dynamics: 0.85, covers: 'beatless but bright — brightness without a beat' }, - { name: 'elegy', bpm: 84, arrangement: 'ballad', brightness: 0.20, noise: 0.10, dynamics: 0.80, - covers: 'slow with one build and one payoff' }, - { name: 'hymn', bpm: 92, arrangement: 'ballad', brightness: 0.75, noise: 0.20, dynamics: 0.70, + { name: 'ember', bpm: 68, arrangement: 'club', brightness: 0.20, noise: 0.05, dynamics: 0.55, + covers: 'slowest track with a detectable beat — anchors the low tempo end' }, + { name: 'elegy', bpm: 84, arrangement: 'ballad', brightness: 0.10, noise: 0.03, dynamics: 0.90, + covers: 'slow, dark, one build and one payoff' }, + { name: 'hymn', bpm: 92, arrangement: 'ballad', brightness: 0.80, noise: 0.20, dynamics: 0.70, covers: 'slow and bright — separates tempo from brightness' }, - { name: 'still', bpm: 100, arrangement: 'sparse', brightness: 0.35, noise: 0.08, dynamics: 0.75, + { name: 'still', bpm: 100, arrangement: 'sparse', brightness: 0.35, noise: 0.06, dynamics: 0.75, covers: 'the degenerate two-section case, which must not break' }, // --- mid tempo, the bulk of the space --------------------------------- - { name: 'dusk', bpm: 118, arrangement: 'classic', brightness: 0.15, noise: 0.05, dynamics: 0.60, - covers: 'full arrangement, dark and tonal' }, - { name: 'centre', bpm: 124, arrangement: 'classic', brightness: 0.50, noise: 0.20, dynamics: 0.55, + { name: 'dusk', bpm: 118, arrangement: 'classic', brightness: 0.02, noise: 0.01, dynamics: 0.60, + covers: 'darkest and most tonal with a full arrangement' }, + { name: 'centre', bpm: 124, arrangement: 'classic', brightness: 0.50, noise: 0.20, dynamics: 0.55, covers: 'the middle of every axis — the null hypothesis' }, - { name: 'glare', bpm: 128, arrangement: 'classic', brightness: 0.90, noise: 0.55, dynamics: 0.50, + { name: 'glare', bpm: 128, arrangement: 'classic', brightness: 0.95, noise: 0.55, dynamics: 0.50, covers: 'bright and noisy together' }, - { name: 'slab', bpm: 126, arrangement: 'club', brightness: 0.45, noise: 0.25, dynamics: 0.15, + { name: 'slab', bpm: 126, arrangement: 'club', brightness: 0.45, noise: 0.25, dynamics: 0.05, covers: 'limitered — lowest dynamic range, longest sections' }, - { name: 'chrome', bpm: 132, arrangement: 'club', brightness: 0.80, noise: 0.15, dynamics: 0.35, + { name: 'chrome', bpm: 132, arrangement: 'club', brightness: 0.85, noise: 0.06, noiseColour: 0.1, dynamics: 0.35, covers: 'bright and tonal at club tempo' }, - { name: 'murk', bpm: 88, arrangement: 'classic', brightness: 0.40, noise: 0.65, dynamics: 0.70, - covers: 'noisy but dark — separates flatness from brightness' }, + { name: 'murk', bpm: 88, arrangement: 'classic', brightness: 0.12, noise: 0.85, noiseColour: 0.95, dynamics: 0.70, + covers: 'dark tonal content under bright hiss — the quadrant that breaks the brightness/noise correlation' }, // --- fast, dense, broken ---------------------------------------------- - { name: 'lattice', bpm: 140, arrangement: 'breaks', brightness: 0.60, noise: 0.40, dynamics: 0.45, + { name: 'lattice', bpm: 140, arrangement: 'breaks', brightness: 0.60, noise: 0.35, dynamics: 0.45, covers: 'most sections — makes rosters actually rotate' }, - { name: 'grit', bpm: 150, arrangement: 'breaks', brightness: 0.25, noise: 0.70, dynamics: 0.40, - covers: 'noisiest and dark — the corrupt director\'s home ground' }, - { name: 'plate', bpm: 138, arrangement: 'club', brightness: 0.30, noise: 0.60, dynamics: 0.12, + { name: 'grit', bpm: 150, arrangement: 'breaks', brightness: 0.15, noise: 0.90, noiseColour: 0.85, dynamics: 0.40, + covers: "noisiest and dark — the corrupt director's home ground" }, + { name: 'plate', bpm: 138, arrangement: 'club', brightness: 0.25, noise: 0.70, noiseColour: 0.9, dynamics: 0.10, covers: 'noisy AND compressed — two extremes at once' }, - { name: 'runner', bpm: 174, arrangement: 'breaks', brightness: 0.70, noise: 0.45, dynamics: 0.50, + { name: 'runner', bpm: 174, arrangement: 'breaks', brightness: 0.70, noise: 0.40, dynamics: 0.50, covers: 'fastest with a broken arrangement' }, - { name: 'flare', bpm: 168, arrangement: 'classic', brightness: 0.95, noise: 0.80, dynamics: 0.30, + { name: 'flare', bpm: 168, arrangement: 'classic', brightness: 0.95, noise: 0.85, dynamics: 0.25, covers: 'brightest and noisiest — the far corner' }, ]; @@ -93,6 +95,7 @@ export function song(name, { fps = 60 } = {}) { arrangement: spec.arrangement, brightness: spec.brightness, noise: spec.noise, + noiseColour: spec.noiseColour ?? null, dynamics: spec.dynamics, seed: 1 + SONGS.indexOf(spec), }), { fps }), @@ -118,13 +121,20 @@ export function songBank({ count = null, fps = 60 } = {}) { export const AXES = [ { key: 'bpm', label: 'tempo', of: (t) => t.summary.bpm, range: [60, 180], reads: 'motion bias · animation rate · personality pace' }, - { key: 'centroid', label: 'brightness', of: (t) => t.summary.meanCentroid, range: [0, 1], + // Not [0,1]: centroid is mapped to a LOG frequency axis, so a track made of + // nothing but sub-bass and a 55Hz pad still measures ~0.28 and pure hiss + // measures ~0.9. The range here is what the statistic can really take on for + // music, and calling it [0,1] would report a permanent 50% gap that no + // synth change could close. + { key: 'centroid', label: 'brightness', of: (t) => t.summary.meanCentroid, range: [0.25, 0.95], reads: 'director odds · line weight · softness · bloom · vignette' }, { key: 'flatness', label: 'noisiness', of: (t) => t.summary.meanFlatness, range: [0, 1], reads: 'director odds · chroma · saturation · surface texture' }, { key: 'dynamics', label: 'dynamic range', of: (t) => t.summary.dynamicRange, range: [0, 1], reads: 'feedback amount and decay · contrast' }, - { key: 'loudness', label: 'loudness', of: (t) => t.summary.meanLoudness, range: [0, 1], + // Raw mean spectral magnitude, not a normalised 0..1 — biasFor divides + // section energy by it, so its scale is whatever the analyser produces. + { key: 'loudness', label: 'loudness', of: (t) => t.summary.meanLoudness, range: [0, 0.012], reads: 'section energy · temperament intensity' }, { key: 'sections', label: 'section count', of: (t) => t.sections.length, range: [2, 9], reads: 'roster rotation · how often the image is allowed to change' }, diff --git a/flow-state/src/audio/synth.js b/flow-state/src/audio/synth.js index c562e46..71e9f0a 100644 --- a/flow-state/src/audio/synth.js +++ b/flow-state/src/audio/synth.js @@ -138,13 +138,19 @@ function addNoise(data, sampleRate, from, to, gain = 0.05, tilt = 0.5, seed = 12 s0 = (Math.imul(s0 ^ (s0 >>> 15), s0 | 1) + 0x6d2b79f5) >>> 0; return ((s0 >>> 14) & 0xffff) / 0xffff - 0.5; }; - // One-pole: low coefficient keeps the lows, high coefficient keeps the highs. - const a = 0.02 + tilt * 0.92; - let lp = 0; + // Two cascaded one-poles rather than one. A single pole is a 6dB/octave + // slope, which leaves so much top on "dark" noise that a noisy track always + // measured bright — the dark-and-noisy corner, which is the corrupt + // director's entire home ground, was not reachable at all. + const a = 0.004 + tilt * 0.9; + let lp1 = 0, lp2 = 0; for (let s = start; s < end; s++) { const white = rnd(); - lp += a * (white - lp); - data[s] += (tilt > 0.5 ? white - lp : lp) * gain * 2; + lp1 += a * (white - lp1); + lp2 += a * (lp1 - lp2); + // Below half tilt the lowpass IS the signal; above it, what the lowpass + // removed is. + data[s] += (tilt > 0.5 ? white - lp2 : lp2 * 3) * gain * 2; } } @@ -246,6 +252,7 @@ export function synthesizeSong({ arrangement = 'classic', brightness = 0.5, noise = 0.15, + noiseColour = null, dynamics = 0.6, seed = 1, } = {}) { @@ -256,14 +263,40 @@ export function synthesizeSong({ const right = buffer.getChannelData(1); const beat = 60 / bpm; - // Low dynamics = a limitered master: quiet stages are pulled up toward the - // loud ones rather than the loud ones being pulled down, so the track stays - // audible and only its crest factor changes. - const floor = 1 - dynamics; - const level = (x) => floor + x * (1 - floor); - const baseRoot = 90 + brightness * 180; - const harmonics = Math.max(1, Math.round(1 + brightness * 6)); + // Dynamic range is measured as a crest factor — the 40th percentile of + // frame RMS against the 95th — so moving it means changing the RATIO + // between the quiet stages and the loud ones, not the overall level. + // + // A gamma on each stage's level relative to the loudest stage does that. + // The loudest stage is pinned at its original value and everything below it + // is pushed down (high dynamics) or pulled up (limitered), so a dynamic + // track does not simply come out quieter — which is what a straight floor + // offset did, and it moved the measured range by 0.2 across the whole bank. + const gamma = 0.25 + dynamics * 3.2; + // Percussion is transient, so a kick-driven track has a low RMS floor + // between hits no matter how hard it is limitered — the gamma alone could + // not get the measured range below 0.67. What actually fills the gaps is + // SUSTAINED content, so the pad and the noise bed swell as dynamics falls. + // That is also what a limitered master really sounds like. + const sustainBoost = 1 + (1 - dynamics) ** 2 * 6; + const peakOf = (field) => Math.max(...stages.map((s) => s[field] || 0), 1e-6); + const peaks = { kick: peakOf('kick'), hat: peakOf('hat'), pad: peakOf('pad') }; + const level = (x, field) => (x <= 0 ? 0 : peaks[field] * (x / peaks[field]) ** gamma); + + // Brightness has to reach genuinely dark, and a hat is broadband: leaving + // any hat in at brightness 0 held the measured centroid above 0.65 no matter + // what the pad did. Below a quarter brightness the hats go entirely. + const baseRoot = 55 + brightness * 260; + const harmonics = Math.max(1, Math.round(1 + brightness ** 1.5 * 9)); + const hatPresence = Math.max(0, (brightness - 0.22) / 0.78); + + // The noise bed's COLOUR is its own axis, defaulting to the track's + // brightness but separable from it. Tying the two together made spectral + // flatness a function of centroid — measured across the bank they came out + // at r = 0.95, one axis wearing two names, and the whole dark-but-noisy + // quadrant was unreachable. Hiss over a sub-bass pad is an ordinary record. + const tilt = 0.05 + (noiseColour === null ? brightness : noiseColour) * 0.9; let at = 0; let stageIndex = 0; @@ -276,25 +309,25 @@ export function synthesizeSong({ let index = Math.round(from / beat); for (let t = from; t < to; t += beat, index++) { const ramp = stage.ramp ? (t - from) / Math.max(1e-6, to - from) : 1; - const gain = level(stage.kick) * (0.25 + ramp * 0.75); - if (stage.kick > 0.02 && gain > 0.02) { + const gain = level(stage.kick, 'kick') * (0.25 + ramp * 0.75); + if (stage.kick > 0.02 && gain > 0.005) { addKick(left, sampleRate, t, gain * (index % 4 === 0 ? 1 : 0.8)); } - if (stage.hat > 0.02) { - const h = level(stage.hat) * ramp * (0.4 + brightness * 1.2); + if (stage.hat > 0.02 && hatPresence > 0) { + const h = level(stage.hat, 'hat') * ramp * hatPresence * 1.4; addHat(left, sampleRate, t + beat / 2, h, index + seed); if (h > 0.3) addHat(left, sampleRate, t + beat / 4, h * 0.6, index + seed + 7); } } addRichPad(left, sampleRate, from, to, - level(stage.pad), baseRoot * stage.root, harmonics); + level(stage.pad, 'pad') * sustainBoost, baseRoot * stage.root, harmonics); if (noise > 0.01) { // The bed follows the arrangement so it cannot flatten the dynamics // it is layered over. addNoise(left, sampleRate, from, to, - noise * level(0.3 + stage.pad * 2), 0.15 + brightness * 0.8, - (seed * 7919 + stageIndex) >>> 0); + noise * level(stage.pad, 'pad') * 3 * sustainBoost, + tilt, (seed * 7919 + stageIndex) >>> 0); } } diff --git a/flow-state/tools/build-song-bank.js b/flow-state/tools/build-song-bank.js index 5a4eff5..6b7c6d5 100644 --- a/flow-state/tools/build-song-bank.js +++ b/flow-state/tools/build-song-bank.js @@ -87,6 +87,7 @@ for (const spec of SONGS) { arrangement: spec.arrangement, brightness: spec.brightness, noise: spec.noise, + noiseColour: spec.noiseColour ?? null, dynamics: spec.dynamics, seed: 1 + SONGS.indexOf(spec), }); @@ -146,6 +147,39 @@ for (const axis of AXES) { if (bins.size < 3) problems.push(`${axis.label} occupies only ${bins.size}/5 of its range`); } +// --- what the bank CANNOT decouple ---------------------------------------- +// Spread on every axis is not the same as covering the space. Two axes that +// move together mean whole combinations are unrepresentable, and a downstream +// test would then attribute to the generator what is really a hole in the bank. +// +// The big one here is physical rather than fixable: spectral flatness measures +// how noise-like a spectrum is, and a dark signal is one whose spectrum is +// tilted, so "dark and very noisy" is only weakly reachable. Real material has +// the same correlation. It is reported rather than engineered away. +console.log('\n AXIS CORRELATION — |r| near 1 means the bank cannot vary these independently\n'); +const corr = (a, b) => { + const ma = a.reduce((x, y) => x + y, 0) / a.length; + const mb = b.reduce((x, y) => x + y, 0) / b.length; + let num = 0, da = 0, db = 0; + for (let i = 0; i < a.length; i++) { + num += (a[i] - ma) * (b[i] - mb); + da += (a[i] - ma) ** 2; + db += (b[i] - mb) ** 2; + } + return num / (Math.sqrt(da * db) || 1e-9); +}; +const series = Object.fromEntries(AXES.map( + (a) => [a.key, built.map((b) => b.measured[a.key])])); +for (let i = 0; i < AXES.length; i++) { + for (let j = i + 1; j < AXES.length; j++) { + const r = corr(series[AXES[i].key], series[AXES[j].key]); + if (Math.abs(r) < 0.6) continue; + console.log(` ${AXES[i].label} ↔ ${AXES[j].label}: r = ${r.toFixed(2)}` + + (Math.abs(r) > 0.85 ? ' ← effectively one axis' : '')); + if (Math.abs(r) > 0.85) problems.push(`${AXES[i].label} and ${AXES[j].label} are collinear (r ${r.toFixed(2)})`); + } +} + // Section kinds are the casting gate, so coverage of them is not optional: a // bank with no drop cannot reach the geometric, glitch or structural families // no matter how widely its tempo spreads.