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