diff --git a/flow-state/src/audio/songbank.js b/flow-state/src/audio/songbank.js new file mode 100644 index 0000000..848343e --- /dev/null +++ b/flow-state/src/audio/songbank.js @@ -0,0 +1,131 @@ +// The song bank: a set of tracks that between them exercise every input the +// look generator reads. +// +// Why this has to exist. Everything downstream of the audio is a function of +// five summary statistics and a section list, and until now every measurement +// in this project was taken on ONE synthetic track — which turned out to have +// two sections, both quiet, making half the scene library unreachable and the +// resulting numbers wrong. A single song cannot tell you whether the generator +// is varied; it can only tell you what it does with that song. +// +// The bank is built against what the generator actually consumes, not against +// what sounds like a reasonable spread of music: +// +// summary.bpm motion bias, animation rate, personality pace +// summary.meanCentroid director odds, line weight, softness, bloom, vignette +// summary.meanFlatness director odds, chroma, saturation, surface texture +// summary.dynamicRange feedback amount and decay, contrast +// summary.meanLoudness per-section energy, temperament intensity +// section kinds which families each director opens — the casting gate +// +// Coverage is a claim, so `tools/song-coverage.js` measures it rather than +// trusting this table. Each entry names the corner it is here to occupy; if a +// song stops occupying it, that is a bug in the synth, and the coverage tool is +// what catches it. +// +// These are test signals, not music. They exist to be measured. + +import { FeatureTrack } from './FeatureTrack.js'; +import { synthesizeSong } from './synth.js'; + +/** + * @typedef {object} SongSpec + * @property {string} name + * @property {string} covers the corner of the feature space this one holds + */ +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, + 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, + covers: 'slow and bright — separates tempo from brightness' }, + { name: 'still', bpm: 100, arrangement: 'sparse', brightness: 0.35, noise: 0.08, 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, + covers: 'the middle of every axis — the null hypothesis' }, + { name: 'glare', bpm: 128, arrangement: 'classic', brightness: 0.90, 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, + covers: 'limitered — lowest dynamic range, longest sections' }, + { name: 'chrome', bpm: 132, arrangement: 'club', brightness: 0.80, noise: 0.15, 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' }, + + // --- fast, dense, broken ---------------------------------------------- + { name: 'lattice', bpm: 140, arrangement: 'breaks', brightness: 0.60, noise: 0.40, 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, + covers: 'noisy AND compressed — two extremes at once' }, + { name: 'runner', bpm: 174, arrangement: 'breaks', brightness: 0.70, noise: 0.45, dynamics: 0.50, + covers: 'fastest with a broken arrangement' }, + { name: 'flare', bpm: 168, arrangement: 'classic', brightness: 0.95, noise: 0.80, dynamics: 0.30, + covers: 'brightest and noisiest — the far corner' }, +]; + +const DURATION = 120; + +// Analysis is CPU-bound seconds per track and every caller wants the same +// tracks, so the bank is built once and held. +const cache = new Map(); + +/** One song from the bank, analysed. */ +export function song(name, { fps = 60 } = {}) { + if (cache.has(name)) return cache.get(name); + const spec = SONGS.find((s) => s.name === name); + if (!spec) throw new Error(`no song named "${name}" — have: ${SONGS.map((s) => s.name).join(', ')}`); + + const entry = { + ...spec, + track: FeatureTrack.fromAudioBuffer(synthesizeSong({ + bpm: spec.bpm, + duration: DURATION, + arrangement: spec.arrangement, + brightness: spec.brightness, + noise: spec.noise, + dynamics: spec.dynamics, + seed: 1 + SONGS.indexOf(spec), + }), { fps }), + }; + cache.set(name, entry); + return entry; +} + +/** + * The whole bank, analysed. + * + * `count` takes an evenly spaced subset rather than the first n, so a cheap run + * still spans the space instead of testing five slow ambient tracks. + */ +export function songBank({ count = null, fps = 60 } = {}) { + const specs = count && count < SONGS.length + ? Array.from({ length: count }, (_, i) => SONGS[Math.round(i * (SONGS.length - 1) / (count - 1))]) + : SONGS; + return specs.map((s) => song(s.name, { fps })); +} + +/** Feature axes the bank claims to span, and where each is read. */ +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], + 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], + 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 a8c8b60..c562e46 100644 --- a/flow-state/src/audio/synth.js +++ b/flow-state/src/audio/synth.js @@ -119,6 +119,189 @@ export function synthesizeSectioned({ return buffer; } +/** + * A broadband noise bed. This is the only way to move spectral FLATNESS, which + * the generator reads to decide how noisy a track is — it picks the director + * partly on it, and the whole grade follows. Kicks, hats and pads are all + * tonal or transient, so without this the bank could only ever produce tonal + * tracks and half the director table would be unreachable. + * + * `tilt` shapes it with a one-pole filter: 0 is dark rumble, 1 is bright hiss. + * Brightness and noisiness have to be independently controllable or the bank + * cannot tell the two apart when it reports coverage. + */ +function addNoise(data, sampleRate, from, to, gain = 0.05, tilt = 0.5, seed = 12345) { + const start = Math.max(0, Math.round(from * sampleRate)); + const end = Math.min(data.length, Math.round(to * sampleRate)); + let s0 = seed >>> 0; + const rnd = () => { + 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; + for (let s = start; s < end; s++) { + const white = rnd(); + lp += a * (white - lp); + data[s] += (tilt > 0.5 ? white - lp : lp) * gain * 2; + } +} + +/** A pad with controllable harmonic content — the other half of brightness. */ +function addRichPad(data, sampleRate, from, to, gain, root, harmonics = 2) { + const start = Math.max(0, Math.round(from * sampleRate)); + const end = Math.min(data.length, Math.round(to * sampleRate)); + for (let s = start; s < end; s++) { + const t = s / sampleRate; + let v = 0; + for (let h = 1; h <= harmonics; h++) { + v += Math.sin(2 * Math.PI * root * h * t) / h; + } + data[s] += v * gain / Math.log2(harmonics + 1); + } +} + +/** + * Arrangement shapes, as stage lists. + * + * A song's SHAPE is a feature the generator reads as directly as its tempo — + * section kinds decide which families each director opens, so a bank that only + * contains one arrangement cannot exercise the casting logic no matter how + * widely it spreads tempo and brightness. Spans are fractions of the track. + * + * Stage fields: span, kick, hat, noise, pad, root multiplier, ramp. + */ +export const ARRANGEMENTS = { + // Beatless. Segments into quiet kinds only — the case that must stay + // representable, since it is what an actual ambient track looks like. + ambient: [ + { span: 0.30, kick: 0, hat: 0, pad: 0.10, root: 1.0 }, + { span: 0.25, kick: 0, hat: 0, pad: 0.20, root: 1.5, ramp: true }, + { span: 0.25, kick: 0, hat: 0, pad: 0.12, root: 1.0 }, + { span: 0.20, kick: 0, hat: 0, pad: 0.07, root: 0.75 }, + ], + // The standard shape: everything present, once. + classic: [ + { span: 0.12, kick: 0.00, hat: 0.00, pad: 0.09, root: 1.0 }, + { span: 0.16, kick: 0.55, hat: 0.20, pad: 0.14, root: 1.5, ramp: true }, + { span: 0.22, kick: 1.00, hat: 0.50, pad: 0.24, root: 4.0 }, + { span: 0.14, kick: 0.05, hat: 0.02, pad: 0.07, root: 1.0 }, + { span: 0.24, kick: 1.00, hat: 0.55, pad: 0.26, root: 4.0 }, + { span: 0.12, kick: 0.25, hat: 0.08, pad: 0.08, root: 1.0 }, + ], + // Long and level: a club tool that states its groove and stays there. The + // case that produces `sustain`, which the classic shape barely reaches. + club: [ + { span: 0.10, kick: 0.45, hat: 0.15, pad: 0.10, root: 1.0 }, + { span: 0.30, kick: 0.90, hat: 0.45, pad: 0.20, root: 2.0 }, + { span: 0.12, kick: 0.35, hat: 0.15, pad: 0.12, root: 1.0 }, + { span: 0.34, kick: 1.00, hat: 0.55, pad: 0.22, root: 3.0 }, + { span: 0.14, kick: 0.50, hat: 0.20, pad: 0.10, root: 1.0 }, + ], + // Many short sections. Stresses the segmenter and produces the highest + // section counts, which is what makes rosters rotate. + breaks: [ + { span: 0.10, kick: 0.30, hat: 0.10, pad: 0.08, root: 1.0 }, + { span: 0.12, kick: 0.95, hat: 0.55, pad: 0.20, root: 3.0 }, + { span: 0.10, kick: 0.05, hat: 0.02, pad: 0.06, root: 1.0 }, + { span: 0.14, kick: 1.00, hat: 0.60, pad: 0.24, root: 4.0 }, + { span: 0.10, kick: 0.10, hat: 0.03, pad: 0.07, root: 0.75 }, + { span: 0.16, kick: 0.60, hat: 0.30, pad: 0.16, root: 2.0, ramp: true }, + { span: 0.16, kick: 1.00, hat: 0.65, pad: 0.26, root: 4.0 }, + { span: 0.12, kick: 0.20, hat: 0.05, pad: 0.08, root: 1.0 }, + ], + // One long rise into one payoff, then gone. Slow material's shape. + ballad: [ + { span: 0.28, kick: 0.00, hat: 0.00, pad: 0.10, root: 1.0 }, + { span: 0.30, kick: 0.40, hat: 0.10, pad: 0.18, root: 1.5, ramp: true }, + { span: 0.24, kick: 0.85, hat: 0.35, pad: 0.26, root: 3.0 }, + { span: 0.18, kick: 0.15, hat: 0.03, pad: 0.09, root: 1.0 }, + ], + // Two sections, both quiet. Kept because it is the degenerate case the old + // bank produced by accident, and the generator must not break on it. + sparse: [ + { span: 0.5, kick: 0.10, hat: 0.02, pad: 0.08, root: 1.0 }, + { span: 0.5, kick: 0.35, hat: 0.15, pad: 0.16, root: 2.0 }, + ], +}; + +/** + * One song, from a spec. + * + * The four continuous knobs map onto exactly the four summary statistics the + * look generator reads, so a bank built on them can be checked for coverage + * against what the generator actually consumes rather than against what seemed + * like a reasonable spread of audio. + * + * bpm → summary.bpm + * brightness → summary.meanCentroid (pad register, harmonics, noise tilt) + * noise → summary.meanFlatness (broadband bed) + * dynamics → summary.dynamicRange (how far quiet stages fall below loud) + */ +export function synthesizeSong({ + bpm = 128, + duration = 120, + sampleRate = 44100, + arrangement = 'classic', + brightness = 0.5, + noise = 0.15, + dynamics = 0.6, + seed = 1, +} = {}) { + const stages = ARRANGEMENTS[arrangement] || ARRANGEMENTS.classic; + const length = Math.round(duration * sampleRate); + const buffer = new MockAudioBuffer(2, length, sampleRate); + const left = buffer.getChannelData(0); + 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)); + + let at = 0; + let stageIndex = 0; + for (const stage of stages) { + const from = at; + const to = Math.min(duration, at + stage.span * duration); + at = to; + stageIndex++; + + 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) { + 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); + 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); + 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); + } + } + + for (let i = 0; i < length; i++) right[i] = left[i] * 0.98; + return buffer; +} + /** * A track with a full ARRANGEMENT — intro, build, drop, breakdown, drop, outro. * diff --git a/flow-state/src/scenes/registry.js b/flow-state/src/scenes/registry.js index cfa24da..a38c43e 100644 --- a/flow-state/src/scenes/registry.js +++ b/flow-state/src/scenes/registry.js @@ -59,6 +59,9 @@ import { dataAisle } from './shader/data-aisle.js'; import { voronoiShatter } from './shader/voronoi-shatter.js'; import { apollonianGasket } from './shader/apollonian-gasket.js'; import { isometricBlocks } from './shader/isometric-blocks.js'; +import { halftoneMisprint } from './shader/halftone-misprint.js'; +import { drosteFeedback } from './shader/droste-feedback.js'; +import { analogWow } from './shader/analog-wow.js'; /** * The scene library. Families exist so the arc driver can choose by section @@ -143,6 +146,9 @@ const MODULES = [ voronoiShatter, apollonianGasket, isometricBlocks, + halftoneMisprint, + drosteFeedback, + analogWow, ]; const errors = []; diff --git a/flow-state/src/scenes/shader/analog-wow.js b/flow-state/src/scenes/shader/analog-wow.js new file mode 100644 index 0000000..4d87133 --- /dev/null +++ b/flow-state/src/scenes/shader/analog-wow.js @@ -0,0 +1,100 @@ +// Glitch family: tape. The transport is not quite steady, so every line of the +// image is displaced by where the tape was when that line was written — a +// continuous horizontal warp, chroma trailing behind luma, and the occasional +// dropout where the oxide has worn through. +// +// Scan Tear and Block Mosh are digital faults: they are quantised, blocky, and +// they happen to whole regions at once. This is analogue and wet — nothing has +// an edge, the error varies smoothly down the frame, and the colour lags the +// picture instead of being replaced by it. Wow and flutter, not corruption. +// +// No declared slow axis: the dropouts and the head smear put two windows of +// identical parameters 0.041 apart, the second-highest noise floor in the +// library, and chroma bleed against that measured 0.02x. + +export const analogWow = { + name: 'Analog Wow', + family: 'glitch', + kind: 'fragment', + texture: 0.9, + traits: ['camera', 'style'], + + params: { + wow: { type: 'float', range: [0, 0.12], default: 0.035, uniform: 'u_wow' }, + flutter: { type: 'float', range: [0, 0.03], default: 0.008, uniform: 'u_flutter' }, + bleed: { type: 'float', range: [0, 0.05], default: 0.015, uniform: 'u_bleed' }, + dropout: { type: 'float', range: [0, 0.6], default: 0.2, uniform: 'u_dropout' }, + smear: { type: 'float', range: [0.4, 0.92], default: 0.7, uniform: 'u_smear' }, + bars: { type: 'float', range: [1, 9], default: 3.5, uniform: 'u_bars', bias: 'density' }, + speed: { type: 'float', range: [0.05, 0.7], default: 0.2, uniform: 'u_speed', bias: 'motion', rate: true }, + palette: { type: 'palette', count: 5 }, + }, + + reactive: { + dropout: { feature: 'flux', amount: 0.3, response: 'spike' }, + wow: { feature: 'bandLow', amount: 0.25, response: 'smooth' }, + smear: { feature: 'loudness', amount: 0.15, response: 'smooth' }, + }, + + shader: ` +// What was recorded: broad soft bars of colour, the kind of thing that shows +// tape damage clearly because it has no detail of its own to hide it behind. +vec3 programme(vec2 q, float t) { + float band = q.y * u_bars + fbm(q * 1.4 + vec2(t * 0.3, 0.0), 3) * 1.2; + vec3 col = palRamp(0.1 + band * 0.12); + col *= 0.55 + 0.45 * sat(0.5 + 0.5 * sin(band * 3.14159 + t)); + col += pal(4) * smoothstep(0.85, 1.0, sat(0.5 + 0.5 * sin(q.x * 3.0 - t * 1.3))) * 0.25; + // The boundary between bands is drawn in the track's weight, so even a + // damaged signal is damaged in the video's own hand. + col += pal(4) * sigEdge(fract(band) - 0.5) * (0.15 + u_sigLine * 0.9); + return col * 0.7; +} + +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_speed + u_seed; + p = sigCamera(p); + + // Transport error for THIS line: a slow wow, a faster flutter, and a little + // noise on top. It is a function of y, which is what makes the frame skew + // rather than slide — every line was written at a different moment. + float line = p.y * 30.0; + float err = sin(p.y * 2.1 - t * 1.7) * u_wow + + sin(p.y * 11.0 + t * 5.3) * u_flutter + + (vnoise(vec2(line, t * 3.0)) - 0.5) * u_flutter * 2.0; + + vec2 q = p + vec2(err, 0.0); + + // Luma is where it should be; chroma trails it. Sampling the programme + // three times at three displacements is exactly what a chroma delay does. + vec3 col; + col.r = programme(q + vec2(u_bleed, 0.0), t).r; + col.g = programme(q, t).g; + col.b = programme(q - vec2(u_bleed * 0.7, 0.0), t).b; + + // Dropouts: bands where the tape has lost contact. They travel slowly down + // the frame, and they replace the picture with the tape's own noise floor + // rather than with black — a black band is a cut, this is a fault. + float bandId = floor(p.y * 8.0 + t * 2.0); + float hit = step(1.0 - u_dropout * 0.35, hash11(bandId * 1.7 + floor(t * 3.0))); + float band = smoothstep(0.5, 0.15, abs(fract(p.y * 8.0 + t * 2.0) - 0.5)) * hit; + vec3 noise = pal(1) * (0.25 + 0.5 * hash12(vec2(uv.x * 400.0, bandId))); + col = mix(col, noise, band * 0.7); + + // Head smear: the previous frame, dragged sideways by the same transport + // error, is what gives tape its characteristic horizontal ghosting. The + // programme above stands on its own, so a seek recovers immediately. + vec3 past = prev(uv - vec2(err * 0.4 + 0.004, 0.0)); + col = mix(col, max(col, past), u_smear * 0.55); + + // Head-switching noise at the bottom of the frame — the one part of the + // image that is always damaged. + float sw = smoothstep(0.06, 0.0, uv.y) * u_dropout; + col = mix(col, pal(2) * hash12(vec2(uv.x * 300.0, floor(t * 12.0))), sw * 0.6); + + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; + +export default analogWow; diff --git a/flow-state/src/scenes/shader/droste-feedback.js b/flow-state/src/scenes/shader/droste-feedback.js new file mode 100644 index 0000000..729fc70 --- /dev/null +++ b/flow-state/src/scenes/shader/droste-feedback.js @@ -0,0 +1,85 @@ +// Glitch family: video feedback. A camera pointed at its own monitor — the +// frame is redrawn inside itself slightly smaller and slightly turned, so a +// small motif at the centre becomes an endless corridor of copies of itself. +// +// Time Smear is the other feedback glitch and it TRANSLATES the previous frame, +// which leaves a comet trail behind a moving subject. This scales it. The +// difference is the whole scene: a translated feedback drifts off the edge and +// clears, a scaled one never clears, because everything the frame has ever +// contained is still in there, one ring further in. +// +// A live motif is drawn every frame, so the image exists before the loop has +// anything in it and comes back after a seek. +// +// No declared slow axis. What the corridor holds depends on where the loop has +// been, so two ten-second windows of the same parameters are 0.020 apart on +// their own and the persistence axis moved 0.004 — the same measurement Turing +// Bloom and Voronoi Shatter record. A feedback scene's history is its structure, +// and Phase 11 cannot separate a parameter from it. + +export const drosteFeedback = { + name: 'Droste Feedback', + family: 'glitch', + kind: 'fragment', + texture: 0.5, + traits: ['camera', 'style'], + + params: { + zoom: { type: 'float', range: [0.93, 1.02], default: 0.975, uniform: 'u_zoomStep' }, + turn: { type: 'float', range: [-0.12, 0.12], default: 0.04, uniform: 'u_turn' }, + persist: { type: 'float', range: [0.9, 0.995], default: 0.985, uniform: 'u_persist' }, + motif: { type: 'float', range: [0.04, 0.35], default: 0.14, uniform: 'u_motif' }, + bloom: { type: 'float', range: [0, 1.4], default: 0.6, uniform: 'u_bloom', bias: 'energy' }, + tint: { type: 'float', range: [0, 0.5], default: 0.15, uniform: 'u_tint' }, + pace: { type: 'float', range: [0.05, 0.8], default: 0.25, uniform: 'u_pace', bias: 'motion', rate: true }, + palette: { type: 'palette', count: 5 }, + }, + + reactive: { + bloom: { feature: 'bandMid', amount: 0.3, response: 'smooth' }, + motif: { feature: 'beat', amount: 0.2, response: 'spike' }, + }, + + shader: ` +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_pace + u_seed; + // Folded as well as filmed: a feedback loop through a mirror is still a + // feedback loop, and the fold is the track's, not this scene's. + p = sigFolded(sigCamera(p)); + + // The motif. Small, live, and always drawn: this is the thing the loop eats. + vec2 at = vec2(sin(t * 0.6), cos(t * 0.47)) * 0.42; + float d = length(p - at) - u_motif; + float ring = abs(d) - u_motif * 0.35; + + vec3 col = pal(0) * 0.05; + col += palRamp(0.2 + fbm(p * 2.0 + t, 3) * 0.2) * smoothstep(0.02, -0.02, ring) * (0.5 + u_bloom * 0.8); + col += pal(4) * sigEdge(ring) * (0.3 + u_bloom * 0.9); + col += pal(3) * exp(-abs(d) * 9.0) * u_bloom * 0.35; + + // The loop: read the previous frame from a slightly LARGER, slightly turned + // sample of itself, so last frame's image comes back shrunk toward the + // middle. Contracting is what nests — expanding pushes every copy off the + // edge and leaves a plume instead of a corridor. Scaling about the centre in + // screen space is what keeps the copies concentric rather than sliding. + vec2 c = uv - 0.5; + c.x *= u_aspect; + c = rot(u_turn) * c / max(u_zoomStep, 0.5); + c.x /= u_aspect; + vec3 past = prev(c + 0.5); + + // Each generation is tinted a step further round the palette, so depth into + // the corridor is legible as colour rather than only as size. + past = mix(past, palRamp(0.6) * (past.r + past.g + past.b) * 0.5, u_tint); + + col = max(col, past * u_persist); + + // Vignette, which also stops the corner content being recycled forever. + col *= 0.75 + 0.25 * exp(-dot(p, p) * 0.35); + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; + +export default drosteFeedback; diff --git a/flow-state/src/scenes/shader/halftone-misprint.js b/flow-state/src/scenes/shader/halftone-misprint.js new file mode 100644 index 0000000..d945b64 --- /dev/null +++ b/flow-state/src/scenes/shader/halftone-misprint.js @@ -0,0 +1,102 @@ +// Glitch family: a four-colour press run with the plates out of register. Each +// ink is screened at its own angle, and each one lands a little off, so the +// image is fringed and the dots moiré against each other. +// +// Every other glitch scene here corrupts a signal — tearing it, freezing it, +// blocking it up. This one is a printing fault rather than an electronic one: +// the image is never damaged, it is simply separated and reassembled wrong. +// Nothing else in the library is made of dots, and the dots are what carry it. + +export const halftoneMisprint = { + name: 'Halftone Misprint', + family: 'glitch', + kind: 'fragment', + // The paper's tooth is the point; take the track's grain in full. + texture: 1.2, + traits: ['camera', 'style'], + + params: { + screen: { type: 'float', range: [18, 90], default: 42, uniform: 'u_screen', bias: 'density' }, + slip: { type: 'float', range: [0, 0.06], default: 0.02, uniform: 'u_slip' }, + angle: { type: 'float', range: [0, 1.6], default: 0.5, uniform: 'u_angle' }, + ink: { type: 'float', range: [0.3, 1.5], default: 0.9, uniform: 'u_ink', slowAxis: true }, + art: { type: 'float', range: [0.5, 4], default: 1.6, uniform: 'u_art', bias: 'density' }, + press: { type: 'float', range: [0.02, 0.4], default: 0.09, uniform: 'u_press', bias: 'motion', rate: true }, + palette: { type: 'palette', count: 5 }, + }, + + reactive: { + slip: { feature: 'flux', amount: 0.3, response: 'spike' }, + ink: { feature: 'bandLow', amount: 0.25, response: 'smooth' }, + }, + + shader: ` +// The artwork being printed: a soft, slow field, deliberately simple. What the +// scene is about is the separation, not the picture. +float artwork(vec2 q, float t) { + float a = fbm(q * u_art + vec2(t * 0.5, -t * 0.3), 4); + float b = 0.5 + 0.5 * sin(q.x * u_art * 2.0 + a * 4.0 + t); + return sat(a * 0.7 + b * 0.5); +} + +// One screened separation: sample the artwork where THIS plate landed, then +// threshold it against a rotated dot grid. The dot grows with ink coverage, +// which is what a halftone is. +float plate(vec2 q, float ang, vec2 slip, float t, float gain) { + float density = sat(artwork(q + slip, t) * gain); + + vec2 r = rot(ang) * (q + slip); + // Ruling is a count of dots ACROSS THE FRAME, not a size in pixels — which + // is what keeps a 720p preview and a 4K export the same print rather than + // the same dot pitch on a bigger sheet. + vec2 cell = fract(r * u_screen * 0.5) - 0.5; + + // Dot edge hardness is the track's: a sharp track prints a crisp dot on + // coated stock, a soft one lets the ink spread into the fibre. + float soft = 0.04 + u_sigSoft * 0.45; + float radius = sqrt(max(density, 0.0)) * 0.62; + return smoothstep(radius, radius - soft, length(cell)); +} + +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_press + u_seed; + p = sigCamera(p); + + // Registration error. It steps on the bar rather than drifting: a press + // slips, settles, and slips again, and a continuously crawling offset reads + // as a wobble rather than as a fault. + float era = floor(u_barPhase * 4.0) + floor(t * 2.0) * 4.0; + vec2 slipC = (hash22(vec2(era, 1.0)) - 0.5) * u_slip; + vec2 slipM = (hash22(vec2(era, 2.0)) - 0.5) * u_slip; + vec2 slipY = (hash22(vec2(era, 3.0)) - 0.5) * u_slip; + + // Classic screen angles: 15°, 75°, 0°, spread by the angle parameter so a + // track can have them in near-register or wildly rosetted. + float a1 = 0.26 * u_angle, a2 = 1.31 * u_angle, a3 = 0.0; + + float c = plate(p, a1, slipC, t, u_ink); + float m = plate(p, a2, slipM, t, u_ink); + float y = plate(p, a3, slipY, t, u_ink); + + // Subtractive-ish: each ink takes light out of the paper, in its own hue. + vec3 col = pal(1) * 0.85; + col -= pal(2) * c * 0.5; + col -= pal(3) * m * 0.5; + col -= pal(4) * y * 0.45; + + // The black plate, printed last and in register, which is what stops the + // whole thing turning to mud. + float k = plate(p, 0.65 * u_angle, vec2(0.0), t, u_ink * 0.7); + col = mix(col, pal(0) * 0.12, k * 0.55); + + // Ink edges take the track's line weight — a hard press or a soft one. + col += pal(0) * sigEdge(0.5 - c) * (0.05 + u_sigLine * 0.35); + + col = max(col, vec3(0.0)); + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; + +export default halftoneMisprint; diff --git a/flow-state/tools/build-song-bank.js b/flow-state/tools/build-song-bank.js new file mode 100644 index 0000000..5a4eff5 --- /dev/null +++ b/flow-state/tools/build-song-bank.js @@ -0,0 +1,178 @@ +// Build the test song bank: synthesize every song in src/audio/songbank.js, +// write it out as audio, and verify that the bank covers what it claims to. +// +// The bank is generated rather than stored. It is deterministic, it is a few +// hundred megabytes as audio, and it is derived from a table that will keep +// changing — all three say "rebuild it" rather than "commit it". The output +// directory is gitignored; this tool is the thing that is versioned. +// +// node tools/build-song-bank.js → test/songs/, 120s each +// node tools/build-song-bank.js --duration 60 → shorter, for a quick pass +// node tools/build-song-bank.js --check → verify coverage, write nothing +// node tools/build-song-bank.js --out /tmp/bank +// +// Coverage is measured, never assumed. Each song declares the corner of the +// feature space it exists to occupy; this analyses what was actually produced +// and fails loudly if an axis has collapsed — a synth change that quietly stops +// producing noisy tracks would otherwise leave every downstream measurement +// looking fine while testing half the space. + +import { writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { synthesizeSong } from '../src/audio/synth.js'; +import { FeatureTrack } from '../src/audio/FeatureTrack.js'; +import { SONGS, AXES } from '../src/audio/songbank.js'; + +const here = dirname(fileURLToPath(import.meta.url)); + +function arg(name, fallback = null) { + const i = process.argv.indexOf(`--${name}`); + return i >= 0 && process.argv[i + 1] && !process.argv[i + 1].startsWith('--') + ? process.argv[i + 1] : fallback; +} +const has = (name) => process.argv.includes(`--${name}`); + +const DURATION = Number(arg('duration', 120)); +const OUT = resolve(arg('out', resolve(here, '../test/songs'))); +const CHECK_ONLY = has('check'); + +/** + * 16-bit PCM WAV, mono. + * + * Mono halves the size and loses nothing: the analyser sums to mono before it + * does anything, and the synth writes the same signal to both channels anyway. + */ +function wav(samples, sampleRate) { + const bytes = samples.length * 2; + const buffer = Buffer.alloc(44 + bytes); + buffer.write('RIFF', 0); + buffer.writeUInt32LE(36 + bytes, 4); + buffer.write('WAVE', 8); + buffer.write('fmt ', 12); + buffer.writeUInt32LE(16, 16); // PCM chunk size + buffer.writeUInt16LE(1, 20); // PCM + buffer.writeUInt16LE(1, 22); // mono + buffer.writeUInt32LE(sampleRate, 24); + buffer.writeUInt32LE(sampleRate * 2, 28); + buffer.writeUInt16LE(2, 32); // block align + buffer.writeUInt16LE(16, 34); // bits + buffer.write('data', 36); + buffer.writeUInt32LE(bytes, 40); + + // Peak-normalise to -1 dBFS. Without it the quiet ambient entries clip down + // into 16-bit noise and their measured flatness stops being what the spec + // asked for — the bank would then be testing the encoder, not the generator. + let peak = 0; + for (let i = 0; i < samples.length; i++) peak = Math.max(peak, Math.abs(samples[i])); + const gain = peak > 1e-9 ? 0.89 / peak : 1; + + for (let i = 0; i < samples.length; i++) { + const v = Math.max(-1, Math.min(1, samples[i] * gain)); + buffer.writeInt16LE(Math.round(v * 32767), 44 + i * 2); + } + return buffer; +} + +if (!CHECK_ONLY && !existsSync(OUT)) mkdirSync(OUT, { recursive: true }); + +console.log(`\nbuilding ${SONGS.length} songs · ${DURATION}s each` + + (CHECK_ONLY ? ' · check only, writing nothing' : ` · → ${OUT}`) + '\n'); + +const built = []; +for (const spec of SONGS) { + const buffer = synthesizeSong({ + bpm: spec.bpm, + duration: DURATION, + arrangement: spec.arrangement, + brightness: spec.brightness, + noise: spec.noise, + dynamics: spec.dynamics, + seed: 1 + SONGS.indexOf(spec), + }); + const track = FeatureTrack.fromAudioBuffer(buffer, { fps: 60 }); + + if (!CHECK_ONLY) { + writeFileSync(resolve(OUT, `${spec.name}.wav`), + wav(buffer.getChannelData(0), buffer.sampleRate)); + } + + built.push({ + ...spec, + file: `${spec.name}.wav`, + measured: { + bpm: track.summary.bpm, + centroid: track.summary.meanCentroid, + flatness: track.summary.meanFlatness, + dynamics: track.summary.dynamicRange, + loudness: track.summary.meanLoudness, + sections: track.sections.length, + kinds: track.sections.map((s) => s.kind), + }, + }); + process.stdout.write(` ${spec.name.padEnd(10)} ${built.at(-1).measured.kinds.join(' ')}\n`); +} + +// --- what was actually produced ------------------------------------------- +const n2 = (x) => (typeof x === 'number' ? x.toFixed(2) : String(x)).padStart(7); + +console.log('\n MEASURED — what the generator will actually read\n'); +console.log(' song tempo bright noisy dynamic loud sections arrangement'); +console.log(' ' + '-'.repeat(86)); +for (const b of built) { + console.log(` ${b.name.padEnd(11)}${n2(b.measured.bpm)}${n2(b.measured.centroid)}` + + `${n2(b.measured.flatness)}${n2(b.measured.dynamics)}${n2(b.measured.loudness)}` + + `${String(b.measured.sections).padStart(10)} ${b.arrangement}`); +} + +// --- coverage -------------------------------------------------------------- +// An axis is covered when the bank spreads across it AND reaches both ends. A +// bank can have a wide range and still be two clusters with a hole in the +// middle, so occupancy is measured in quintiles rather than as min-to-max. +console.log('\n COVERAGE — measured, not claimed\n'); +const problems = []; +for (const axis of AXES) { + const values = built.map((b) => b.measured[axis.key === 'bpm' ? 'bpm' : axis.key]); + const lo = Math.min(...values), hi = Math.max(...values); + const [rLo, rHi] = axis.range; + const bins = new Set(values.map( + (v) => Math.max(0, Math.min(4, Math.floor((v - rLo) / (rHi - rLo) * 5))))); + const filled = [0, 1, 2, 3, 4].map((i) => (bins.has(i) ? '█' : '·')).join(''); + const span = (hi - lo) / (rHi - rLo); + + console.log(` ${axis.label.padEnd(15)} ${filled} ${lo.toFixed(2)} → ${hi.toFixed(2)}` + + ` spans ${(span * 100).toFixed(0)}% of ${rLo}–${rHi}`); + console.log(` ${''.padEnd(15)} reads: ${axis.reads}`); + if (bins.size < 3) problems.push(`${axis.label} occupies only ${bins.size}/5 of its range`); +} + +// 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. +const kinds = new Map(); +for (const b of built) for (const k of b.measured.kinds) kinds.set(k, (kinds.get(k) || 0) + 1); +const ALL_KINDS = ['intro', 'build', 'drop', 'sustain', 'breakdown', 'outro']; +console.log(`\n section kinds ${ALL_KINDS.map((k) => (kinds.get(k) ? '█' : '·')).join('')} ` + + ALL_KINDS.map((k) => `${k} ${kinds.get(k) || 0}`).join(' · ')); +const missing = ALL_KINDS.filter((k) => !kinds.get(k)); +if (missing.length) problems.push(`no song produces: ${missing.join(', ')}`); + +if (!CHECK_ONLY) { + writeFileSync(resolve(OUT, 'manifest.json'), JSON.stringify({ + built: new Date().toISOString(), + duration: DURATION, + songs: built, + }, null, 2)); +} + +console.log(''); +if (problems.length) { + console.log(' GAPS'); + for (const p of problems) console.log(` ${p}`); + console.log(''); + process.exitCode = 1; +} else { + console.log(` all ${AXES.length} axes and all ${ALL_KINDS.length} section kinds covered`); + if (!CHECK_ONLY) console.log(` wrote ${built.length} wav files + manifest.json to ${OUT}`); + console.log(''); +}