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