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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user