Run both variety tests against the song bank, and fix the ceiling twice more
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 <noreply@anthropic.com>
This commit is contained in:
parent
e0c0dd664a
commit
2bbf5bcf42
@ -82,8 +82,9 @@ const DURATION = 120;
|
|||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
|
|
||||||
/** One song from the bank, analysed. */
|
/** One song from the bank, analysed. */
|
||||||
export function song(name, { fps = 60 } = {}) {
|
export function song(name, { fps = 60, duration = DURATION } = {}) {
|
||||||
if (cache.has(name)) return cache.get(name);
|
const cacheKey = `${name}:${fps}:${duration}`;
|
||||||
|
if (cache.has(cacheKey)) return cache.get(cacheKey);
|
||||||
const spec = SONGS.find((s) => s.name === name);
|
const spec = SONGS.find((s) => s.name === name);
|
||||||
if (!spec) throw new Error(`no song named "${name}" — have: ${SONGS.map((s) => s.name).join(', ')}`);
|
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,
|
...spec,
|
||||||
track: FeatureTrack.fromAudioBuffer(synthesizeSong({
|
track: FeatureTrack.fromAudioBuffer(synthesizeSong({
|
||||||
bpm: spec.bpm,
|
bpm: spec.bpm,
|
||||||
duration: DURATION,
|
duration,
|
||||||
arrangement: spec.arrangement,
|
arrangement: spec.arrangement,
|
||||||
brightness: spec.brightness,
|
brightness: spec.brightness,
|
||||||
noise: spec.noise,
|
noise: spec.noise,
|
||||||
@ -101,7 +102,7 @@ export function song(name, { fps = 60 } = {}) {
|
|||||||
seed: 1 + SONGS.indexOf(spec),
|
seed: 1 + SONGS.indexOf(spec),
|
||||||
}), { fps }),
|
}), { fps }),
|
||||||
};
|
};
|
||||||
cache.set(name, entry);
|
cache.set(cacheKey, entry);
|
||||||
return 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
|
* `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.
|
* 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
|
const specs = count && count < SONGS.length
|
||||||
? Array.from({ length: count }, (_, i) => SONGS[Math.round(i * (SONGS.length - 1) / (count - 1))])
|
? Array.from({ length: count }, (_, i) => SONGS[Math.round(i * (SONGS.length - 1) / (count - 1))])
|
||||||
: SONGS;
|
: 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. */
|
/** Feature axes the bank claims to span, and where each is read. */
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { runAll, summarize, allChecks } from './framework.js';
|
import { runAll, summarize, allChecks } from './framework.js';
|
||||||
import { runSceneGate } from './scene-gate.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.
|
// Registering a phase's checks is a side effect of importing it.
|
||||||
import './phase0.js';
|
import './phase0.js';
|
||||||
@ -78,6 +78,26 @@ async function main() {
|
|||||||
return;
|
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 = `<pre class="scene-gate">${lines.join('\n')}</pre>`;
|
||||||
|
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 phaseArg = params.get('phase');
|
||||||
const phases = phaseArg ? phaseArg.split(',').map(Number) : null;
|
const phases = phaseArg ? phaseArg.split(',').map(Number) : null;
|
||||||
const skipSlow = params.get('slow') !== '1';
|
const skipSlow = params.get('slow') !== '1';
|
||||||
|
|||||||
@ -16,8 +16,7 @@
|
|||||||
// distinct looks are there" is measured before "how many does a seed reach".
|
// distinct looks are there" is measured before "how many does a seed reach".
|
||||||
|
|
||||||
import { check, expect } from './framework.js';
|
import { check, expect } from './framework.js';
|
||||||
import { FeatureTrack } from '../audio/FeatureTrack.js';
|
import { song } from '../audio/songbank.js';
|
||||||
import { synthesizeSectioned } from '../audio/synth.js';
|
|
||||||
import { Show } from '../Show.js';
|
import { Show } from '../Show.js';
|
||||||
import { generateLook } from '../look/LookGenerator.js';
|
import { generateLook } from '../look/LookGenerator.js';
|
||||||
import { scenes } from '../scenes/registry.js';
|
import { scenes } from '../scenes/registry.js';
|
||||||
@ -27,14 +26,11 @@ import {
|
|||||||
measureVariety, measureSpecDiversity, signatureForScene, librarySweep,
|
measureVariety, measureSpecDiversity, signatureForScene, librarySweep,
|
||||||
} from './variety/report.js';
|
} from './variety/report.js';
|
||||||
|
|
||||||
let cachedTrack = null;
|
// `centre` is the bank's null hypothesis: the middle of every axis, a full
|
||||||
export function varietyTrack() {
|
// six-stage arrangement. The gates used to run on a two-section synthetic whose
|
||||||
if (!cachedTrack) {
|
// only kinds were intro and outro, which left half the scene library unreachable
|
||||||
cachedTrack = FeatureTrack.fromAudioBuffer(
|
// and made every number here a measurement of that rather than of the seed.
|
||||||
synthesizeSectioned({ bpm: 124, duration: 90, changeAt: 30 }), { fps: 60 });
|
export const varietyTrack = () => song('centre').track;
|
||||||
}
|
|
||||||
return cachedTrack;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The library sweep is the most expensive thing in the suite — every scene
|
// The library sweep is the most expensive thing in the suite — every scene
|
||||||
// rendered — and three checks read it. Computed once per page load.
|
// rendered — and three checks read it. Computed once per page load.
|
||||||
|
|||||||
@ -5,9 +5,11 @@
|
|||||||
// and everything below answers "where did the variety go" — which is the only
|
// and everything below answers "where did the variety go" — which is the only
|
||||||
// question worth printing a table for.
|
// question worth printing a table for.
|
||||||
|
|
||||||
import { FeatureTrack } from '../../audio/FeatureTrack.js';
|
import { song } from '../../audio/songbank.js';
|
||||||
import { synthesizeSectioned } from '../../audio/synth.js';
|
import {
|
||||||
import { measureVariety, measureSpecDiversity, librarySweep } from './report.js';
|
measureVariety, measureSpecDiversity, librarySweep,
|
||||||
|
measureSongVariety, measureFingerprint,
|
||||||
|
} from './report.js';
|
||||||
import { generateLook, describeLook } from '../../look/LookGenerator.js';
|
import { generateLook, describeLook } from '../../look/LookGenerator.js';
|
||||||
|
|
||||||
const bar = (v, width = 24) => {
|
const bar = (v, width = 24) => {
|
||||||
@ -18,8 +20,11 @@ const bar = (v, width = 24) => {
|
|||||||
const pct = (v) => `${(v * 100).toFixed(0)}%`.padStart(4);
|
const pct = (v) => `${(v * 100).toFixed(0)}%`.padStart(4);
|
||||||
|
|
||||||
export async function varietyReportLines({ seeds = 8, probes = 5, library = true } = {}) {
|
export async function varietyReportLines({ seeds = 8, probes = 5, library = true } = {}) {
|
||||||
const track = FeatureTrack.fromAudioBuffer(
|
// A real bank entry, not the two-section synthetic this used to run on.
|
||||||
synthesizeSectioned({ bpm: 124, duration: 90, changeAt: 30 }), { fps: 60 });
|
// 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 lines = [];
|
||||||
const spec = measureSpecDiversity(track, { seeds: Math.max(seeds, 24) });
|
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('');
|
||||||
lines.push(` floor ${r.floor.toFixed(4)} one video against itself, across its own sections`);
|
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(` 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('');
|
||||||
lines.push(` separation ${bar(r.separation)} ${r.separation.toFixed(2)}`);
|
lines.push(` separation ${bar(r.separation)} ${r.separation.toFixed(2)}`);
|
||||||
lines.push(' 0 = the seed changes nothing a viewer could name');
|
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 };
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@ -33,6 +33,8 @@ import { scenes } from '../../scenes/registry.js';
|
|||||||
import { defaultValues, sampleValues } from '../../params/schema.js';
|
import { defaultValues, sampleValues } from '../../params/schema.js';
|
||||||
import { Rng } from '../../engine/rng.js';
|
import { Rng } from '../../engine/rng.js';
|
||||||
import { videoSignature, signatureDistance, STRUCTURAL } from './signature.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 };
|
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 = {}) {
|
export function signatureForScene(track, module, seed, options = {}) {
|
||||||
|
const { sampled = false, ...rest } = options;
|
||||||
const show = new Show({ ...RENDER });
|
const show = new Show({ ...RENDER });
|
||||||
try {
|
try {
|
||||||
const look = generateLook(track, { seed: seed >>> 0 });
|
const look = generateLook(track, { seed: seed >>> 0 });
|
||||||
|
const prng = new Rng((seed * 40503) >>> 0);
|
||||||
for (const section of look.sections) {
|
for (const section of look.sections) {
|
||||||
const layers = [{
|
const layers = [{
|
||||||
module,
|
module,
|
||||||
params: defaultValues(module),
|
params: sampled
|
||||||
|
? sampleValues(module, prng, section.bias,
|
||||||
|
look.personality && look.personality.temperament)
|
||||||
|
: defaultValues(module),
|
||||||
seed: seed >>> 0,
|
seed: seed >>> 0,
|
||||||
blend: 'normal',
|
blend: 'normal',
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
@ -101,12 +114,64 @@ export function signatureForScene(track, module, seed, options = {}) {
|
|||||||
for (const shot of section.shots || []) shot.variant = 0;
|
for (const shot of section.shots || []) shot.variant = 0;
|
||||||
}
|
}
|
||||||
show.useTrack(track, look);
|
show.useTrack(track, look);
|
||||||
return videoSignature(show, options);
|
return videoSignature(show, rest);
|
||||||
} finally {
|
} finally {
|
||||||
show.dispose();
|
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
|
* Every visualization in the library, measured structurally, against every
|
||||||
* other one.
|
* other one.
|
||||||
@ -235,11 +300,8 @@ export function measureVariety(track, {
|
|||||||
const between = pairwise(sigs, (a, b) => signatureDistance(a, b));
|
const between = pairwise(sigs, (a, b) => signatureDistance(a, b));
|
||||||
const observed = mean(between.map((d) => d.total));
|
const observed = mean(between.map((d) => d.total));
|
||||||
|
|
||||||
// --- ceiling: the same pipeline with the design thrown away ----------
|
// --- ceiling: single-scene videos, each on a different scene ---------
|
||||||
const refSigs = [];
|
const refSigs = ceilingSignatures(track, { count: refScenes, probes });
|
||||||
for (let i = 0; i < refScenes; i++) {
|
|
||||||
refSigs.push(signatureForChaos(track, (0xbadc0de + i * 40503) >>> 0, { probes }));
|
|
||||||
}
|
|
||||||
const ceilingPairs = pairwise(refSigs, (a, b) => signatureDistance(a, b));
|
const ceilingPairs = pairwise(refSigs, (a, b) => signatureDistance(a, b));
|
||||||
const ceiling = mean(ceilingPairs.map((d) => d.total));
|
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('>'))),
|
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) };
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
* Taken at section midpoints: a section boundary is where the look is designed
|
||||||
* look is designed to change, so sampling across boundaries is what makes the
|
* to change, so sampling across boundaries is what makes the signature describe
|
||||||
* signature describe the video rather than one shot of it.
|
* 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) {
|
export function probeFrames(show, count = 6) {
|
||||||
const sections = show.look.sections;
|
const sections = show.look.sections;
|
||||||
const frames = [];
|
const step = Math.max(1, sections.length / count);
|
||||||
if (sections.length >= count) {
|
const seen = new Map();
|
||||||
const step = sections.length / count;
|
const probes = [];
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
const s = sections[Math.floor(i * step)];
|
for (let i = 0; i < sections.length && probes.length < count; i += step) {
|
||||||
frames.push(s.startFrame + Math.floor((s.endFrame - s.startFrame) * 0.5));
|
const section = sections[Math.floor(i)];
|
||||||
}
|
if (!section) continue;
|
||||||
} else {
|
const ordinal = seen.get(section.kind) || 0;
|
||||||
for (const s of sections) {
|
seen.set(section.kind, ordinal + 1);
|
||||||
const span = s.endFrame - s.startFrame;
|
probes.push({
|
||||||
const per = Math.max(1, Math.round(count / sections.length));
|
frame: section.startFrame + Math.floor((section.endFrame - section.startFrame) * 0.5),
|
||||||
for (let i = 0; i < per; i++) {
|
key: `${section.kind}#${ordinal}`,
|
||||||
frames.push(s.startFrame + Math.floor(span * (i + 1) / (per + 1)));
|
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 frames = probeFrames(show, probes);
|
||||||
const perProbe = [];
|
const perProbe = [];
|
||||||
|
|
||||||
for (const frame of frames) {
|
for (const probe of frames) {
|
||||||
|
const frame = probe.frame;
|
||||||
show.engine.compositor.reset();
|
show.engine.compositor.reset();
|
||||||
const start = Math.max(0, frame - warmup);
|
const start = Math.max(0, frame - warmup);
|
||||||
for (let f = start; f < frame; f++) show.renderFrame(f);
|
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 b = Uint8Array.from(show.readPixels(show.renderFrame(frame + gap)));
|
||||||
const still = frameDescriptor(a, width, height);
|
const still = frameDescriptor(a, width, height);
|
||||||
const moved = motionDescriptor(a, b, 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.
|
// 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.
|
* actionable once you know which block collapsed.
|
||||||
*/
|
*/
|
||||||
export function signatureDistance(a, b) {
|
export function signatureDistance(a, b) {
|
||||||
const n = Math.min(a.probes.length, b.probes.length);
|
// Pair probes by their LABEL, not their position: drop#0 against drop#0.
|
||||||
const sums = {};
|
// Two seeds of one song produce the same labels in the same order, so this
|
||||||
for (let i = 0; i < n; i++) {
|
// is identical to index matching there — it only starts mattering when the
|
||||||
const d = descriptorDistance(a.probes[i], b.probes[i]);
|
// songs differ, which is exactly when index matching would compare a drop
|
||||||
for (const [block, v] of Object.entries(d)) sums[block] = (sums[block] || 0) + v / n;
|
// 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 };
|
export { blockMean };
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user