Three glitch scenes: a press, a loop and a tape

Halftone Misprint is a printing fault rather than an electronic one — the image
is never damaged, only separated and reassembled out of register, and nothing
else in the library is made of dots. Droste Feedback scales the previous frame
where Time Smear translates it, so the image never clears. Analog Wow is the
continuous, wet counterpart to Scan Tear and Block Mosh: the error varies
smoothly down the frame because every line was written at a different moment.

Three things that had to be got right. The halftone ruling is a count of dots
across the frame, not a pixel pitch, or a 4K export is the same dot on a bigger
sheet. The Droste loop has to CONTRACT — expanding pushes every copy off the
edge and leaves a plume instead of a corridor. And all three expressed style
only through grain at first, which is the Side Quest 1 complaint: they now put
the track's line weight and edge softness into the dot, the ring and the band
boundary, taking the measured style response from 24/11/18 to 86/255/88.

Neither feedback scene declares a slow axis, with the numbers recorded in the
files: their own history moves the ten-second average further than any parameter
does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-17 06:55:43 +02:00
parent 91e74ff167
commit 905c217193
7 changed files with 785 additions and 0 deletions

View File

@ -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' },
];

View File

@ -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.
*

View File

@ -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 = [];

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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('');
}