From 2bbf5bcf426cf8022d89acbe12a2f6afa62efd4d Mon Sep 17 00:00:00 2001 From: Dejvino Date: Mon, 17 Aug 2026 20:00:14 +0200 Subject: [PATCH] Run both variety tests against the song bank, and fix the ceiling twice more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tests now run on real bank entries. The seed gates used to run on a two-section synthetic whose only kinds were intro and outro, so half the scene library was unreachable and the number was measuring that rather than the seed. Probes are labelled by section kind and occurrence rather than by index, which is what makes two different songs comparable at all — they have different section counts, so probe 3 of one is not probe 3 of the other, and matching by position would compare a drop against an outro and score the mismatch as variety. For two seeds of one song the labels are identical and this changes nothing, which is the point. The song test measures one thing the seed test does not: coupling, the rank correlation between how different two tracks sound and how different their videos look. Separation alone can be had by a generator that ignores the audio and hashes the file, and that would be a perfect score for a completely wrong video. Separation without coupling is not variety, it is a different seed per file. The ceiling took two more attempts. Recasting every layer at random averages a dozen scenes together and a dozen random scenes converge on the same generic busy image, so two references came out closer to each other than two real videos and blocks scored over 100% of achievable. Forcing one scene per reference collapsed the other way: a video that never changes scene has almost no internal variation, so the ceiling landed BELOW the floor, which is a within-video quantity. A reference has to match the structure of what it bounds. They now keep the real pipeline — rosters, shots, per-section sampling, so a reference rotates between three or four scenes exactly as a real video does — while drawing from disjoint slices of the library. Same complexity, nothing in common. The fingerprint measurement had the same shape of error: pooling every probe mixed in how much each video varies over its own length, which is large for everything, and washed the answer to a flat 100% while the separation score said almost everything was collapsed. One vector per video now. Both tests fail as committed. Seed separation 0.04, song separation 0.03, coupling -0.03 — two different songs differ from each other by about as much as one video differs from itself, and that difference has no relationship to the music. Colour scores 112%. Co-Authored-By: Claude Opus 5 --- flow-state/src/audio/songbank.js | 13 +- flow-state/src/checks/main.js | 22 +- flow-state/src/checks/phase12.js | 16 +- flow-state/src/checks/variety/print.js | 79 ++++++- flow-state/src/checks/variety/report.js | 246 ++++++++++++++++++++- flow-state/src/checks/variety/signature.js | 100 ++++++--- 6 files changed, 418 insertions(+), 58 deletions(-) diff --git a/flow-state/src/audio/songbank.js b/flow-state/src/audio/songbank.js index ff4e9bc..b991920 100644 --- a/flow-state/src/audio/songbank.js +++ b/flow-state/src/audio/songbank.js @@ -82,8 +82,9 @@ const DURATION = 120; const cache = new Map(); /** One song from the bank, analysed. */ -export function song(name, { fps = 60 } = {}) { - if (cache.has(name)) return cache.get(name); +export function song(name, { fps = 60, duration = DURATION } = {}) { + const cacheKey = `${name}:${fps}:${duration}`; + if (cache.has(cacheKey)) return cache.get(cacheKey); const spec = SONGS.find((s) => s.name === name); if (!spec) throw new Error(`no song named "${name}" — have: ${SONGS.map((s) => s.name).join(', ')}`); @@ -91,7 +92,7 @@ export function song(name, { fps = 60 } = {}) { ...spec, track: FeatureTrack.fromAudioBuffer(synthesizeSong({ bpm: spec.bpm, - duration: DURATION, + duration, arrangement: spec.arrangement, brightness: spec.brightness, noise: spec.noise, @@ -101,7 +102,7 @@ export function song(name, { fps = 60 } = {}) { seed: 1 + SONGS.indexOf(spec), }), { fps }), }; - cache.set(name, entry); + cache.set(cacheKey, entry); return entry; } @@ -111,11 +112,11 @@ export function song(name, { fps = 60 } = {}) { * `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 } = {}) { +export function songBank({ count = null, fps = 60, duration = DURATION } = {}) { 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 })); + return specs.map((s) => song(s.name, { fps, duration })); } /** Feature axes the bank claims to span, and where each is read. */ diff --git a/flow-state/src/checks/main.js b/flow-state/src/checks/main.js index 95c0aa1..9a0824b 100644 --- a/flow-state/src/checks/main.js +++ b/flow-state/src/checks/main.js @@ -1,6 +1,6 @@ import { runAll, summarize, allChecks } from './framework.js'; import { runSceneGate } from './scene-gate.js'; -import { varietyReportLines } from './variety/print.js'; +import { varietyReportLines, songVarietyReportLines } from './variety/print.js'; // Registering a phase's checks is a side effect of importing it. import './phase0.js'; @@ -78,6 +78,26 @@ async function main() { return; } + // Song variety mode: the same instrument, with the SONG as the variable. + // + // checks.html?songs=1 6 songs from the bank + // checks.html?songs=1&count=10 more of the bank, slower + if (params.get('songs')) { + summaryEl.textContent = 'song variety: synthesising the bank and rendering each song…'; + const started = Date.now(); + const { lines, ok, headline } = await songVarietyReportLines({ + songs: Number(params.get('count')) || 6, + probes: Number(params.get('probes')) || 5, + }); + out.innerHTML = `
${lines.join('\n')}
`; + summaryEl.textContent = `${headline} · ${((Date.now() - started) / 1000).toFixed(1)}s`; + summaryEl.className = ok ? 'ok' : 'bad'; + window.__CHECKS__ = { songs: true, ok, lines }; + window.__CHECKS_DONE__ = true; + console.log('[song-variety]\n' + lines.join('\n')); + return; + } + const phaseArg = params.get('phase'); const phases = phaseArg ? phaseArg.split(',').map(Number) : null; const skipSlow = params.get('slow') !== '1'; diff --git a/flow-state/src/checks/phase12.js b/flow-state/src/checks/phase12.js index 6f3b24d..b4e972b 100644 --- a/flow-state/src/checks/phase12.js +++ b/flow-state/src/checks/phase12.js @@ -16,8 +16,7 @@ // distinct looks are there" is measured before "how many does a seed reach". import { check, expect } from './framework.js'; -import { FeatureTrack } from '../audio/FeatureTrack.js'; -import { synthesizeSectioned } from '../audio/synth.js'; +import { song } from '../audio/songbank.js'; import { Show } from '../Show.js'; import { generateLook } from '../look/LookGenerator.js'; import { scenes } from '../scenes/registry.js'; @@ -27,14 +26,11 @@ import { measureVariety, measureSpecDiversity, signatureForScene, librarySweep, } from './variety/report.js'; -let cachedTrack = null; -export function varietyTrack() { - if (!cachedTrack) { - cachedTrack = FeatureTrack.fromAudioBuffer( - synthesizeSectioned({ bpm: 124, duration: 90, changeAt: 30 }), { fps: 60 }); - } - return cachedTrack; -} +// `centre` is the bank's null hypothesis: the middle of every axis, a full +// six-stage arrangement. The gates used to run on a two-section synthetic whose +// only kinds were intro and outro, which left half the scene library unreachable +// and made every number here a measurement of that rather than of the seed. +export const varietyTrack = () => song('centre').track; // The library sweep is the most expensive thing in the suite — every scene // rendered — and three checks read it. Computed once per page load. diff --git a/flow-state/src/checks/variety/print.js b/flow-state/src/checks/variety/print.js index 6c4802d..cbc0735 100644 --- a/flow-state/src/checks/variety/print.js +++ b/flow-state/src/checks/variety/print.js @@ -5,9 +5,11 @@ // and everything below answers "where did the variety go" — which is the only // question worth printing a table for. -import { FeatureTrack } from '../../audio/FeatureTrack.js'; -import { synthesizeSectioned } from '../../audio/synth.js'; -import { measureVariety, measureSpecDiversity, librarySweep } from './report.js'; +import { song } from '../../audio/songbank.js'; +import { + measureVariety, measureSpecDiversity, librarySweep, + measureSongVariety, measureFingerprint, +} from './report.js'; import { generateLook, describeLook } from '../../look/LookGenerator.js'; const bar = (v, width = 24) => { @@ -18,8 +20,11 @@ const bar = (v, width = 24) => { const pct = (v) => `${(v * 100).toFixed(0)}%`.padStart(4); export async function varietyReportLines({ seeds = 8, probes = 5, library = true } = {}) { - const track = FeatureTrack.fromAudioBuffer( - synthesizeSectioned({ bpm: 124, duration: 90, changeAt: 30 }), { fps: 60 }); + // A real bank entry, not the two-section synthetic this used to run on. + // That one segmented into intro and outro only, and quiet kinds are + // restricted to the restful families for every director — a third of the + // library was unreachable and the score was measuring that, not the seed. + const track = song('centre').track; const lines = []; const spec = measureSpecDiversity(track, { seeds: Math.max(seeds, 24) }); @@ -36,7 +41,7 @@ export async function varietyReportLines({ seeds = 8, probes = 5, library = true lines.push(''); lines.push(` floor ${r.floor.toFixed(4)} one video against itself, across its own sections`); lines.push(` observed ${r.observed.toFixed(4)} two seeds against each other`); - lines.push(` ceiling ${r.ceiling.toFixed(4)} same pipeline, every layer recast at random`); + lines.push(` ceiling ${r.ceiling.toFixed(4)} same pipeline, casts that share no scenes at all`); lines.push(''); lines.push(` separation ${bar(r.separation)} ${r.separation.toFixed(2)}`); lines.push(' 0 = the seed changes nothing a viewer could name'); @@ -122,3 +127,65 @@ export async function varietyReportLines({ seeds = 8, probes = 5, library = true return { lines, ok, headline }; } + + +/** + * The SONG variety report. + * + * Deliberately laid out in the order the questions have to be answered. Are two + * songs different at all; is that difference DERIVED from the music or merely + * random; and what does every output have in common regardless — which is the + * one that speaks to "you can tell what made it". + */ +export async function songVarietyReportLines({ songs = 6, probes = 5 } = {}) { + const lines = []; + await new Promise((r) => setTimeout(r, 0)); + const r = measureSongVariety({ songs, probes }); + const f = measureFingerprint({ songs: Math.min(songs, 5), probes: 3 }); + + const ok = r.separation >= 0.45 && r.coupling >= 0.3; + const headline = `song separation ${r.separation.toFixed(2)} · coupling ${r.coupling.toFixed(2)} ` + + `(${ok ? 'acceptable' : 'TOO LOW'})`; + + lines.push('SONG VARIETY — different songs, each with its own audio-derived seed'); + lines.push(''); + for (const b of r.bank) lines.push(` ${b.name.padEnd(9)} ${b.kinds.join(' ')}`); + lines.push(''); + lines.push(` floor ${r.floor.toFixed(4)} one video against itself, across its own sections`); + lines.push(` observed ${r.observed.toFixed(4)} two songs against each other`); + lines.push(` ceiling ${r.ceiling.toFixed(4)} same pipeline, casts that share no scenes at all`); + lines.push(''); + lines.push(` separation ${bar(r.separation)} ${r.separation.toFixed(2)}`); + lines.push(` coupling ${bar(Math.max(0, r.coupling))} ${r.coupling.toFixed(2)}`); + lines.push(' coupling is how strongly musical distance predicts visual distance.'); + lines.push(' near 0 means the look is unrelated to the song — separation without'); + lines.push(' it is not variety, it is noise with a different seed per file.'); + lines.push(''); + lines.push('WHERE THE VARIETY IS'); + lines.push(''); + for (const [name, b] of Object.entries(r.byBlock)) { + const note = name === 'colour' ? ' (not counted)' : ''; + lines.push(` ${name.padEnd(8)} ${bar(b.ratio)} ${pct(b.ratio)}` + + ` ${b.between.toFixed(3)} of ${b.ceiling.toFixed(3)}${note}`); + } + lines.push(''); + lines.push('CLOSEST SONG PAIRS — two songs that came out as one video'); + lines.push(''); + for (const p of r.pairs.slice(0, 6)) { + lines.push(` ${p.total.toFixed(3)} ${p.a.padEnd(9)} ≈ ${p.b.padEnd(9)}` + + ` (they sound ${p.musical < 0.25 ? 'alike' : p.musical > 0.5 ? 'nothing alike' : 'somewhat alike'}` + + `, musical distance ${p.musical.toFixed(2)})`); + } + lines.push(''); + lines.push('THE HOUSE FINGERPRINT — what every output has in common'); + lines.push(' (variance across our videos as a fraction of variance across random ones;'); + lines.push(' a low number is a constant the generator imposes on everything it makes)'); + lines.push(''); + for (const t of f.tells) { + lines.push(` ${t.block.padEnd(8)} ${bar(Math.min(1, t.ratio))} ${pct(Math.min(1, t.ratio))}` + + ` ${t.frozen}/${t.dims} dimensions effectively frozen`); + } + lines.push(''); + + return { lines, ok, headline }; +} diff --git a/flow-state/src/checks/variety/report.js b/flow-state/src/checks/variety/report.js index 48e5aa3..26c6707 100644 --- a/flow-state/src/checks/variety/report.js +++ b/flow-state/src/checks/variety/report.js @@ -33,6 +33,8 @@ import { scenes } from '../../scenes/registry.js'; import { defaultValues, sampleValues } from '../../params/schema.js'; import { Rng } from '../../engine/rng.js'; import { videoSignature, signatureDistance, STRUCTURAL } from './signature.js'; +import { songBank } from '../../audio/songbank.js'; +import { hashString } from '../../engine/rng.js'; const RENDER = { width: 160, height: 90 }; @@ -83,15 +85,26 @@ export function signatureForChaos(track, seed, options = {}) { } } -/** Signature for a video forced onto ONE library scene. Used to validate the metric. */ +/** + * Signature for a video forced onto ONE library scene. + * + * `sampled` swaps default parameters for a seeded draw, which is what the + * ceiling wants: defaults are the middle of every range and make a scene look + * tamer than the generator would ever cast it. + */ export function signatureForScene(track, module, seed, options = {}) { + const { sampled = false, ...rest } = options; const show = new Show({ ...RENDER }); try { const look = generateLook(track, { seed: seed >>> 0 }); + const prng = new Rng((seed * 40503) >>> 0); for (const section of look.sections) { const layers = [{ module, - params: defaultValues(module), + params: sampled + ? sampleValues(module, prng, section.bias, + look.personality && look.personality.temperament) + : defaultValues(module), seed: seed >>> 0, blend: 'normal', opacity: 1, @@ -101,12 +114,64 @@ export function signatureForScene(track, module, seed, options = {}) { for (const shot of section.shots || []) shot.variant = 0; } show.useTrack(track, look); - return videoSignature(show, options); + return videoSignature(show, rest); } finally { show.dispose(); } } +/** + * The ceiling: videos with the same STRUCTURE as real ones, cast from pools that + * share no scenes at all. + * + * Getting this reference right took three attempts, and both failures were + * instructive enough to record. + * + * 1. Recast every layer at random. Averaging a dozen random scenes converges + * on the same generic busy image every time, so two "chaos" videos came out + * closer to each other than two real ones. + * 2. One scene per video, no rotation. That collapsed the other way: a video + * that never changes scene has almost no internal variation, so the ceiling + * landed BELOW the floor, which is a within-video quantity. + * + * The reference has to match what it is bounding. These keep the real pipeline — + * rosters, shots, per-section sampling, so a reference video rotates between + * three or four scenes exactly as a real one does — while the pools they draw + * from are disjoint slices of the library. Same complexity, nothing in common. + */ +export function ceilingSignatures(track, { count = 4, probes = 4, seed = 0xbadc0de } = {}) { + const rng = new Rng(seed >>> 0); + const pool = rng.shuffle(scenes.filter((m) => m.role !== 'accent')); + const slice = Math.max(2, Math.floor(pool.length / count)); + + const out = []; + for (let i = 0; i < count; i++) { + const mine = pool.slice(i * slice, (i + 1) * slice); + if (!mine.length) break; + const show = new Show({ ...RENDER }); + try { + const look = generateLook(track, { seed: (seed + i * 40503) >>> 0 }); + const prng = new Rng((seed + i * 2654435761) >>> 0); + const temperament = look.personality && look.personality.temperament; + for (const section of look.sections) { + for (const variant of section.variants) { + for (const layer of variant) { + layer.module = prng.pick(mine); + layer.params = sampleValues(layer.module, prng, section.bias, temperament); + layer.seed = prng.int(0, 0x7fffffff); + } + } + section.layers = section.variants[0]; + } + show.useTrack(track, look); + out.push(videoSignature(show, { probes })); + } finally { + show.dispose(); + } + } + return out; +} + /** * Every visualization in the library, measured structurally, against every * other one. @@ -235,11 +300,8 @@ export function measureVariety(track, { const between = pairwise(sigs, (a, b) => signatureDistance(a, b)); const observed = mean(between.map((d) => d.total)); - // --- ceiling: the same pipeline with the design thrown away ---------- - const refSigs = []; - for (let i = 0; i < refScenes; i++) { - refSigs.push(signatureForChaos(track, (0xbadc0de + i * 40503) >>> 0, { probes })); - } + // --- ceiling: single-scene videos, each on a different scene --------- + const refSigs = ceilingSignatures(track, { count: refScenes, probes }); const ceilingPairs = pairwise(refSigs, (a, b) => signatureDistance(a, b)); const ceiling = mean(ceilingPairs.map((d) => d.total)); @@ -359,3 +421,171 @@ export function measureSpecDiversity(track, { seeds = 32, seed0 = 0x5eed } = {}) anchorScenes: entropy(looks.map((l) => l.sections.map((s) => s.layers[0].module.name).join('>'))), }; } + + +// --- the SONG variety test ------------------------------------------------- +// +// The seed test holds the song fixed and varies the seed, which answers "does +// the generator's randomness do anything". This varies the SONG, which is the +// question that actually matters: two different tracks should not obviously +// come out of the same software. +// +// It needs one thing the seed test does not. Separation alone can be reached by +// a generator that ignores the audio entirely and hashes the file — that would +// score perfectly and be completely wrong, because the video would have nothing +// to do with the music. So coupling is measured alongside it: songs that sound +// alike should look alike, and songs that sound different should look different. +// A high separation with zero coupling is not variety, it is noise. + +/** Distance between two tracks as MUSIC, on the statistics the generator reads. */ +function musicalDistance(a, b) { + const axes = [ + [(t) => t.summary.bpm, 120], + [(t) => t.summary.meanCentroid, 0.7], + [(t) => t.summary.meanFlatness, 0.8], + [(t) => t.summary.dynamicRange, 0.7], + [(t) => t.sections.length, 6], + ]; + let d = 0; + for (const [of_, span] of axes) d += Math.min(1, Math.abs(of_(a) - of_(b)) / span); + return d / axes.length; +} + +/** Spearman rank correlation — monotone association, robust to the scales. */ +function spearman(xs, ys) { + const rank = (values) => { + const order = values.map((v, i) => [v, i]).sort((p, q) => p[0] - q[0]); + const r = new Array(values.length); + order.forEach(([, i], k) => { r[i] = k; }); + return r; + }; + const rx = rank(xs), ry = rank(ys); + const n = xs.length; + let sum = 0; + for (let i = 0; i < n; i++) sum += (rx[i] - ry[i]) ** 2; + return 1 - (6 * sum) / (n * (n * n - 1) || 1); +} + +/** + * @param {object} options + * @returns {object} report + */ +export function measureSongVariety({ songs = 6, probes = 5, refScenes = 4 } = {}) { + const bank = songBank({ count: songs }); + + // The seed is derived from the audio in the real pipeline, so each song must + // get its own — deriving it from the name is the same relationship without + // needing the samples. + const sigs = bank.map((entry) => + signatureForSeed(entry.track, hashString(entry.name), { probes })); + + const floor = mean(sigs.map((s) => s.drift)); + + const between = []; + const musical = []; + const visual = []; + for (let i = 0; i < bank.length; i++) { + for (let j = i + 1; j < bank.length; j++) { + const d = signatureDistance(sigs[i], sigs[j]); + between.push({ ...d, a: bank[i].name, b: bank[j].name }); + musical.push(musicalDistance(bank[i].track, bank[j].track)); + visual.push(d.total); + } + } + const observed = mean(visual); + + // Ceiling on the same songs, so it is not a different measurement. + const refSigs = ceilingSignatures(bank[0].track, { count: refScenes, probes }); + const ceilingPairs = []; + for (let i = 0; i < refSigs.length; i++) { + for (let j = i + 1; j < refSigs.length; j++) { + ceilingPairs.push(signatureDistance(refSigs[i], refSigs[j])); + } + } + const ceiling = mean(ceilingPairs.map((d) => d.total)); + + const byBlock = {}; + for (const block of [...STRUCTURAL, 'colour']) { + const b = mean(between.map((d) => d.byBlock[block] ?? 0)); + const c = mean(ceilingPairs.map((d) => d.byBlock[block] ?? 0)); + byBlock[block] = { between: b, ceiling: c, ratio: c > 1e-6 ? b / c : 0 }; + } + + const nearest = sigs.map((_, i) => { + let best = 1, at = i; + for (let j = 0; j < sigs.length; j++) { + if (i === j) continue; + const d = signatureDistance(sigs[i], sigs[j]).total; + if (d < best) { best = d; at = j; } + } + return { song: bank[i].name, nearest: bank[at].name, distance: best }; + }); + + return { + bank: bank.map((b) => ({ name: b.name, covers: b.covers, kinds: b.track.sections.map((s) => s.kind) })), + floor, + ceiling, + observed, + separation: (observed - floor) / Math.max(1e-6, ceiling - floor), + identity: ceiling > 1e-6 ? observed / ceiling : 0, + coupling: spearman(musical, visual), + byBlock, + nearest, + pairs: between.map((d, k) => ({ ...d, musical: musical[k] })) + .sort((x, y) => x.total - y.total), + }; +} + +/** + * The house fingerprint: which descriptor dimensions never move. + * + * This is the direct measurement of "you can tell it came from the same + * software". A dimension whose variance across real outputs is a small fraction + * of its variance across randomly assembled ones is a constant the generator + * imposes on every video it makes — and constants are exactly what a viewer + * learns to recognise. Reported per block, in the order they most give the game + * away. + */ +export function measureFingerprint({ songs = 6, probes = 4, refScenes = 5 } = {}) { + const bank = songBank({ count: songs }); + const ours = bank.map((entry) => + signatureForSeed(entry.track, hashString(entry.name), { probes })); + const refs = ceilingSignatures(bank[0].track, { count: refScenes, probes, seed: 0x1337 }); + + // One vector per VIDEO, not per probe. Pooling probes mixes in how much each + // video varies over its own length, which is large for everything and washed + // the answer out to a flat 100% — the measurement said nothing was frozen + // while the separation score said almost everything was. + const flatten = (sigs, block) => sigs.map((s) => { + const rows = s.probes.map((p) => p[block]); + const out = new Array(rows[0].length).fill(0); + for (const r of rows) for (let i = 0; i < r.length; i++) out[i] += r[i] / rows.length; + return out; + }); + const variance = (rows) => { + if (!rows.length) return []; + const n = rows[0].length; + const out = new Array(n).fill(0); + for (let d = 0; d < n; d++) { + const col = rows.map((r) => r[d]); + const m = col.reduce((a, b) => a + b, 0) / col.length; + out[d] = col.reduce((a, b) => a + (b - m) ** 2, 0) / col.length; + } + return out; + }; + + const tells = []; + for (const block of [...STRUCTURAL, 'colour']) { + const vo = variance(flatten(ours, block)); + const vr = variance(flatten(refs, block)); + const ratios = vo.map((v, d) => (vr[d] > 1e-12 ? v / vr[d] : 1)); + const blockRatio = mean(ratios); + tells.push({ + block, + ratio: blockRatio, + frozen: ratios.filter((r) => r < 0.15).length, + dims: ratios.length, + }); + } + return { tells: tells.sort((a, b) => a.ratio - b.ratio) }; +} diff --git a/flow-state/src/checks/variety/signature.js b/flow-state/src/checks/variety/signature.js index c3bc160..a2c3fd8 100644 --- a/flow-state/src/checks/variety/signature.js +++ b/flow-state/src/checks/variety/signature.js @@ -83,31 +83,50 @@ export function descriptorDistance(a, b) { } /** - * Probe frames spread across the track. + * Probe points across the track, each labelled with WHAT it is. * - * Taken at section midpoints where possible: a section boundary is where the - * look is designed to change, so sampling across boundaries is what makes the - * signature describe the video rather than one shot of it. + * Taken at section midpoints: a section boundary is where the look is designed + * to change, so sampling across boundaries is what makes the signature describe + * the video rather than one shot of it. + * + * The label is what lets two DIFFERENT songs be compared. They have different + * section counts, so probe 3 of one is not probe 3 of the other and matching by + * index would compare a drop against an outro and call the difference variety. + * Keyed by kind and by which occurrence of that kind it is, the comparison is + * this song's second drop against that song's second drop — the only pairing a + * viewer would accept as fair. */ export function probeFrames(show, count = 6) { const sections = show.look.sections; - const frames = []; - if (sections.length >= count) { - const step = sections.length / count; - for (let i = 0; i < count; i++) { - const s = sections[Math.floor(i * step)]; - frames.push(s.startFrame + Math.floor((s.endFrame - s.startFrame) * 0.5)); - } - } else { - for (const s of sections) { - const span = s.endFrame - s.startFrame; - const per = Math.max(1, Math.round(count / sections.length)); - for (let i = 0; i < per; i++) { - frames.push(s.startFrame + Math.floor(span * (i + 1) / (per + 1))); - } + const step = Math.max(1, sections.length / count); + const seen = new Map(); + const probes = []; + + for (let i = 0; i < sections.length && probes.length < count; i += step) { + const section = sections[Math.floor(i)]; + if (!section) continue; + const ordinal = seen.get(section.kind) || 0; + seen.set(section.kind, ordinal + 1); + probes.push({ + frame: section.startFrame + Math.floor((section.endFrame - section.startFrame) * 0.5), + key: `${section.kind}#${ordinal}`, + kind: section.kind, + }); + } + + // A short track can run out of sections before it runs out of probe budget. + // Rather than resample the same midpoints, spread extra probes inside the + // sections it does have — a two-section song still has a beginning, a middle + // and an end worth measuring. + for (let pass = 1; probes.length < count && pass < 4; pass++) { + for (const section of sections) { + if (probes.length >= count) break; + const span = section.endFrame - section.startFrame; + const at = section.startFrame + Math.floor(span * (pass / (pass + 2))); + probes.push({ frame: at, key: `${section.kind}@${pass}`, kind: section.kind }); } } - return frames.slice(0, count); + return probes.slice(0, count); } /** @@ -125,7 +144,8 @@ export function videoSignature(show, { probes = 6, gap = 5, warmup = 20 } = {}) const frames = probeFrames(show, probes); const perProbe = []; - for (const frame of frames) { + for (const probe of frames) { + const frame = probe.frame; show.engine.compositor.reset(); const start = Math.max(0, frame - warmup); for (let f = start; f < frame; f++) show.renderFrame(f); @@ -134,7 +154,13 @@ export function videoSignature(show, { probes = 6, gap = 5, warmup = 20 } = {}) const b = Uint8Array.from(show.readPixels(show.renderFrame(frame + gap))); const still = frameDescriptor(a, width, height); const moved = motionDescriptor(a, b, width, height); - perProbe.push({ ...still, motion: moved.scale.concat(moved.layout), energy: moved.energy }); + perProbe.push({ + ...still, + motion: moved.scale.concat(moved.layout), + energy: moved.energy, + key: probe.key, + kind: probe.kind, + }); } // Self-distance: how far this video travels from itself over its own length. @@ -183,13 +209,33 @@ function blockMean(byBlock) { * actionable once you know which block collapsed. */ export function signatureDistance(a, b) { - const n = Math.min(a.probes.length, b.probes.length); - const sums = {}; - for (let i = 0; i < n; i++) { - const d = descriptorDistance(a.probes[i], b.probes[i]); - for (const [block, v] of Object.entries(d)) sums[block] = (sums[block] || 0) + v / n; + // Pair probes by their LABEL, not their position: drop#0 against drop#0. + // Two seeds of one song produce the same labels in the same order, so this + // is identical to index matching there — it only starts mattering when the + // songs differ, which is exactly when index matching would compare a drop + // against an outro and score the mismatch as variety. + const byKey = new Map(b.probes.map((p) => [p.key, p])); + const pairs = []; + for (const probe of a.probes) { + const other = byKey.get(probe.key); + if (other) pairs.push([probe, other]); } - return { total: blockMean(sums), byBlock: sums }; + // Two songs can share no section kinds at all — a beatless ambient track + // against a club tool. Falling back to position is the only comparison left, + // and it is fair enough when neither has a structure to align to. + if (!pairs.length) { + const n = Math.min(a.probes.length, b.probes.length); + for (let i = 0; i < n; i++) pairs.push([a.probes[i], b.probes[i]]); + } + + const sums = {}; + for (const [x, y] of pairs) { + const d = descriptorDistance(x, y); + for (const [block, v] of Object.entries(d)) { + sums[block] = (sums[block] || 0) + v / pairs.length; + } + } + return { total: blockMean(sums), byBlock: sums, matched: pairs.length }; } export { blockMean };