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
co-authored by Claude Opus 5
parent 91e74ff167
commit 905c217193
7 changed files with 785 additions and 0 deletions
+131
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' },
];
+183
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.
*