// Phase 10 gate — variety. // // Phase 8 gave a track more cuts, Phase 9 gave it a coherent identity. Watching // several tracks side by side exposed what neither addressed: the SAME scene // cast in two different videos looked like the same footage twice. Section bias // is nearly identical between two tracks' drops, so both sampled their params // around the same centre, and the library's own averageness did the rest. // // Three answers, and this gate is what holds them honest: // // temperament — a per-track hand on every parameter dial (Personality.js) // overlays — a second full scene composited over the first, sometimes // palette — a wider reachable colour space, so two tracks differ on // colour before they differ on anything else // // Two later additions are held here for the same reason. GRAIN was in every // video — applied unconditionally per scene and again in the grade, with only // its amount varying — so it is now a treatment most tracks go without and no // two grainy tracks wear the same way (look/grain.js). And TEMPO now dominates // how fast a scene animates, because a slow song was getting scenes that // skittered over it. // // The hard part of testing "variety" is that it is a property of a POPULATION, // not of one render. Every check here therefore samples many tracks and asks // about the spread, never about a single value. import { check, expect } from './framework.js'; import { Engine } from '../engine/Engine.js'; import { scenes } from '../scenes/registry.js'; import { generateLook } from '../look/LookGenerator.js'; import { AudioPalette, generateUsablePalette, relativeLuminance } from '../look/palette.js'; import { Rng } from '../engine/rng.js'; import { FeatureTrack, featureProviderFor } from '../audio/FeatureTrack.js'; import { synthesizeSectioned } from '../audio/synth.js'; import { sampleValues } from '../params/schema.js'; import { frameDistance, frameLuminance } from '../engine/hash.js'; import { battery as timbreBattery } from './phase3.js'; import { grainEnvelope } from '../look/grain.js'; import { signatureUniforms } from '../look/Personality.js'; /** * Several tracks that genuinely differ in what they sound like — the population * every check here measures. Synthetic, so the gate does not depend on assets. */ let cachedBattery = null; function battery() { if (!cachedBattery) { cachedBattery = [ { name: 'slow ambient', buffer: synthesizeSectioned({ bpm: 84, duration: 110, changeAt: 55 }) }, { name: 'mid house', buffer: synthesizeSectioned({ bpm: 122, duration: 110, changeAt: 50 }) }, { name: 'fast techno', buffer: synthesizeSectioned({ bpm: 148, duration: 110, changeAt: 45 }) }, { name: 'broken beat', buffer: synthesizeSectioned({ bpm: 104, duration: 110, changeAt: 70 }) }, ].map((t) => ({ ...t, track: FeatureTrack.fromAudioBuffer(t.buffer, { fps: 60 }) })); } return cachedBattery; } function makeEngine(track, width = 160, height = 90) { const engine = new Engine({ width, height }); engine.timeline.setDuration(track.duration); engine.setFeatureProvider(featureProviderFor(track)); return engine; } /** Every layer stack in a look, flattened. */ const stacksOf = (look) => look.sections.flatMap((s) => s.variants || [s.layers]); check(10, 'two tracks do not share a temperament', () => { // The per-track hand on the dials. If these collapsed toward one value the // whole mechanism would be decorative, and the symptom — every video // sampling around the library average — is exactly what it was built for. // Sampled over several seeds per track rather than one. Four samples of a // ±0.8 draw can land close together by luck, and a gate that fails on that // is measuring the seeds, not the mechanism. What the mechanism has to // deliver is both: a wide population AND no two tracks landing on the same // hand — so both are asserted. const t = []; battery().forEach(({ track }, i) => { for (let s = 0; s < 3; s++) t.push(generateLook(track, { seed: 100 + i * 7919 + s * 104729 }).personality.temperament); }); // Measured as a fraction of each dial's own range, because they are not the // same width — extremity spans 0.5 in total and intensity over three times // that, so one absolute floor would be either trivial or unreachable. const dialRange = { intensity: 1.7, pace: 1.6, extremity: 0.5 }; const keys = Object.keys(dialRange); const spread = (key) => Math.max(...t.map((x) => x[key])) - Math.min(...t.map((x) => x[key])); const worst = Math.min(...keys.map((k) => spread(k) / dialRange[k])); let closest = Infinity; for (let a = 0; a < t.length; a++) { for (let b = a + 1; b < t.length; b++) { closest = Math.min(closest, Math.max(...keys.map((k) => Math.abs(t[a][k] - t[b][k])))); } } return expect(worst > 0.5 && closest > 0.02, `${t.length} tracks · worst dial covers ${(worst * 100).toFixed(0)}% of its range · ` + `spread intensity ${spread('intensity').toFixed(2)} · ` + `pace ${spread('pace').toFixed(2)} · detail ${spread('detail').toFixed(2)} · ` + `extremity ${spread('extremity').toFixed(2)} · closest pair ${closest.toFixed(3)}`); }); check(10, 'one scene looks different in two different videos', () => { // The complaint, as a number. Take a scene, render it with the parameters // and personality two different tracks gave it, and require the images to // actually differ — far above the 1/255 the determinism checks treat as // noise, but far below "unrecognisable". The same scene should still be // itself; it just should not be the same footage. const looks = battery().map(({ track }, i) => ({ track, look: generateLook(track, { seed: 500 + i * 6841 }), })); const distances = []; const problems = []; for (const module of scenes.filter((m) => m.kind === 'fragment').slice(0, 12)) { const engine = makeEngine(looks[0].track); try { const frames = looks.map(({ look }) => { // Sample this scene the way each track's own generator would. // If the track did not cast it, sample it anyway with that // track's temperament — the question is what this scene WOULD // look like in that video, and falling back to defaults would // compare two identical parameter sets and prove nothing. const stack = stacksOf(look).find((s) => s[0].module === module); const params = stack ? stack[0].params : sampleValues( module, new Rng(look.seed ^ 0x51ed270b), look.sections[0].bias, look.personality.temperament, ); engine.setLayerSpecs([{ module, params, seed: look.seed & 0x7fffffff, opacity: 1, blend: 'normal', palette: look.palette, personality: look.personality, }]); engine.prime(300); engine.compositor.reset(); return Uint8Array.from(engine.readPixels(engine.renderFrame(300))); }); for (let i = 0; i < frames.length; i++) { for (let j = i + 1; j < frames.length; j++) { // Relative to how much image there is, not absolute. // frameDistance averages over every pixel, and most scenes // here are mostly dark — two genuinely different renders of // a sparse scene (25 bars versus 53) score under 0.02 in // absolute terms simply because the black background agrees // with itself. Dividing by the images' own brightness asks // the question that was meant: how different is this, as a // fraction of what is actually on screen. const brightness = Math.max(1e-3, (frameLuminance(frames[i]) + frameLuminance(frames[j])) * 0.5); const d = frameDistance(frames[i], frames[j]) / brightness; distances.push(d); if (d < 0.12) problems.push(`${module.name}: ${d.toFixed(3)}`); } } } finally { engine.dispose(); } } const mean = distances.reduce((a, b) => a + b, 0) / Math.max(1, distances.length); return expect(problems.length === 0, problems.slice(0, 4).join(' · ') || `${distances.length} cross-track pairs, mean relative distance ${mean.toFixed(2)}, ` + `closest ${Math.min(...distances).toFixed(2)} (floor 0.12)`); }, { slow: true }); check(10, 'overlays happen sometimes and not always', () => { // A second scene over the first is the variation valve. Always-on would read // as permanently cluttered and never-on is the state this fixed, so the // check is on the RATE across many tracks rather than on any one stack. let stacks = 0; let withOverlay = 0; const blends = new Set(); for (const { track } of battery()) { for (let s = 0; s < 6; s++) { const look = generateLook(track, { seed: 900 + s * 5231 }); for (const stack of stacksOf(look)) { stacks++; const overlay = stack[1]; if (overlay) { withOverlay++; blends.add(overlay.blend); if (overlay.blend === 'normal') { // 'normal' would hide the shot entirely rather than // compositing over it — that is what a cut is for. return expect(false, `${overlay.module.name} overlaid with 'normal'`); } } } } } const rate = withOverlay / Math.max(1, stacks); return expect(rate > 0.05 && rate < 0.55, `${withOverlay}/${stacks} stacks carry an overlay (${(rate * 100).toFixed(0)}%), ` + `blends: ${[...blends].join(', ')}`); }); check(10, 'an overlay never hides the shot underneath it', () => { // Opacity is the other half of "composited over" — a 0.9 overlay is a // replacement with extra steps. const problems = []; for (const { track } of battery()) { for (let s = 0; s < 4; s++) { for (const stack of stacksOf(generateLook(track, { seed: 1300 + s * 8641 }))) { for (const layer of stack.slice(1)) { if (layer.opacity > 0.6) { problems.push(`${layer.module.name} at ${layer.opacity.toFixed(2)}`); } } } } } return expect(problems.length === 0, problems.slice(0, 3).join(' · ') || 'all overlays stay under 0.6'); }); check(10, 'the palette reaches the whole colour wheel', () => { // Hue used to be a one-way sweep from red down to blue, which made violet, // magenta and pink unreachable for every track ever generated — a third of // the wheel the tool simply could not produce. Sampled across many tracks, // every sixth of the wheel should now show up. const buckets = new Array(6).fill(0); const samples = 40; for (let i = 0; i < samples; i++) { const rng = new Rng(2000 + i * 7919); const summary = { meanCentroid: 0.2 + (i % 7) * 0.1, meanFlatness: 0.1 + (i % 5) * 0.08, dynamicRange: 0.3 + (i % 4) * 0.15, bpm: 80 + (i % 9) * 12, bandBalance: { sub: 0.2 + (i % 5) * 0.12, low: 0.3 + (i % 3) * 0.2, mid: 0.25 + (i % 4) * 0.1, high: 0.2 + (i % 6) * 0.1, air: 0.15 + (i % 7) * 0.09, }, }; for (const [r, g, b] of generateUsablePalette(new AudioPalette(summary, rng), 6)) { const max = Math.max(r, g, b); const min = Math.min(r, g, b); if (max - min < 0.08) continue; // greys carry no hue let h; if (max === r) h = ((g - b) / (max - min) + 6) % 6; else if (max === g) h = (b - r) / (max - min) + 2; else h = (r - g) / (max - min) + 4; buckets[Math.floor(h) % 6]++; } } const empty = buckets.filter((n) => n === 0).length; const names = ['red', 'yellow', 'green', 'cyan', 'blue', 'magenta']; return expect(empty === 0, buckets.map((n, i) => `${names[i]}:${n}`).join(' ') + (empty ? ` — ${empty} sixth(s) unreachable` : '')); }); check(10, 'two tracks do not get the same palette', () => { // Distinctness, not just coverage: a wheel that is fully reachable but where // every track lands in the same place would pass the check above. // // Measured on the Phase 3 battery rather than this file's, because those // four tracks differ in TIMBRE and this file's differ mostly in tempo. // Palette follows timbre by design, so two tracks that sound alike SHOULD // get similar colour — asking otherwise would be asking the generator to // ignore its own input. const palettes = timbreBattery().map(({ track }) => generateLook(track, { seed: 61 }).palette); // fixed seed: difference must come from the audio let closest = Infinity; for (let i = 0; i < palettes.length; i++) { for (let j = i + 1; j < palettes.length; j++) { let sum = 0; for (let k = 0; k < palettes[i].length; k++) { const a = palettes[i][k], b = palettes[j][k]; sum += Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); } closest = Math.min(closest, sum / palettes[i].length); } } const lums = palettes.map((p) => p.map(relativeLuminance).join(',')); return expect(closest > 0.08 && new Set(lums).size === palettes.length, `closest pair mean channel distance ${closest.toFixed(3)} (floor 0.08), ` + `${new Set(lums).size}/${palettes.length} distinct`); }); // --- grain ----------------------------------------------------------------- // The complaint these answer: grain was in every video. It was applied twice // unconditionally — once per scene, once in the grade — so the only thing that // varied between tracks was how much. See look/grain.js. check(10, 'most tracks carry no grain at all', () => { const modes = []; for (let i = 0; i < 60; i++) { modes.push(generateLook(battery()[i % 4].track, { seed: 900 + i * 5779 }).grain.mode); } const off = modes.filter((m) => m === 'off').length / modes.length; const distinct = new Set(modes).size; // A quarter to two thirds. Never grainy is as much a failure as always // grainy — the treatment has to remain available. return expect(off > 0.25 && off < 0.7 && distinct >= 4, `${(off * 100).toFixed(0)}% of 60 tracks have no grain · ${distinct} modes used: ` + `${[...new Set(modes)].join(', ')}`); }); check(10, 'two grainy tracks are not grainy the same way', () => { // Amount alone was never the difference that mattered. Cell size, refresh // rate, mask and chroma are, so the population has to spread across them. const specs = []; for (let i = 0; i < 80 && specs.length < 30; i++) { const g = generateLook(battery()[i % 4].track, { seed: 4100 + i * 7717 }).grain; if (g.mode !== 'off') specs.push(g); } const uniq = (key) => new Set(specs.map((s) => s[key])).size; const signatures = new Set(specs.map((s) => `${s.mode}/${s.scale}/${s.rate}/${s.mask}`)).size; return expect(uniq('scale') >= 3 && uniq('rate') >= 3 && uniq('mask') >= 3 && signatures >= specs.length * 0.5, `${specs.length} grainy tracks · ${uniq('scale')} cell sizes · ${uniq('rate')} rates · ` + `${uniq('mask')} masks · ${signatures} distinct treatments`); }); check(10, 'grain that is not constant actually comes and goes', () => { // A 'swell' or 'sections' grain that never reaches zero is just constant // grain with extra steps, and one that never reaches full is decoration. const { track } = battery()[1]; const problems = []; let tested = 0; for (let i = 0; i < 120 && tested < 6; i++) { const look = generateLook(track, { seed: 7000 + i * 3571 }); const g = look.grain; if (g.mode === 'off' || g.mode === 'constant') continue; tested++; let lo = Infinity, hi = -Infinity; for (let f = 0; f < track.frameCount; f += 7) { const env = grainEnvelope(g, { time: f / 60, sectionKind: track.sectionAt(f).kind, features: track.at(f), }); lo = Math.min(lo, env); hi = Math.max(hi, env); } if (lo > 0.05 || hi < 0.4) problems.push(`${g.mode}: ${lo.toFixed(2)}..${hi.toFixed(2)}`); } return expect(tested > 0 && problems.length === 0, problems.length ? problems.join(' · ') : `${tested} time-varying grains all reach 0 and full`); }); check(10, 'a scene that refuses grain never gets any', () => { // `texture: 0` on a module has to survive a track that wants maximum grit, // because the point of it is that crisp line work stays crisp. const gritty = { style: { lineWeight: 0.5, softness: 0.5, texture: 1, symmetry: 1 }, shape: { sides: 0, roundness: 0, elongation: 1, tilt: 0 }, camera: { driftAngle: 0, driftRate: 0, sway: 0, swayRate: 0, spin: 0, breathe: 0 }, space: { horizon: 0.5, depth: 0, washAngle: 0, wash: 0 } }; const refusing = scenes.filter((m) => m.texture === 0); const leaked = refusing.filter((m) => signatureUniforms(gritty, m).u_sigTexture > 0); const takesIt = signatureUniforms(gritty, scenes.find((m) => m.texture === undefined)); return expect(refusing.length > 0 && leaked.length === 0 && takesIt.u_sigTexture === 1, `${refusing.length} scenes opt out, ${leaked.length} leaked · ` + `an opted-in scene still gets ${takesIt.u_sigTexture}`); }); // --- tempo ----------------------------------------------------------------- check(10, 'a slow track animates slower than a fast one', () => { // Motion used to be mostly energy with tempo as a small correction, so a // 70bpm ballad's drop asked its scenes for nearly as much speed as a // 150bpm track's — and the scenes obliged, over a song that was not moving. const slow = generateLook(battery()[0].track, { seed: 31 }); // 84bpm const fast = generateLook(battery()[2].track, { seed: 31 }); // 148bpm const rateMean = (look) => { const vals = []; for (const stack of stacksOf(look)) { for (const layer of stack) { for (const [name, def] of Object.entries(layer.module.params || {})) { if (!def.rate || typeof layer.params[name] !== 'number') continue; const [lo, hi] = def.range; vals.push((layer.params[name] - lo) / (hi - lo)); // normalised } } } return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; }; const s = rateMean(slow), f = rateMean(fast); return expect(f > s * 1.25, `mean normalised rate: 84bpm ${s.toFixed(3)} vs 148bpm ${f.toFixed(3)} ` + `(${(f / Math.max(1e-6, s)).toFixed(2)}×)`); });