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