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 <noreply@anthropic.com>
This commit is contained in:
parent
905c217193
commit
0c5b0a8d4d
@ -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.
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user