Metabolism: song as substrate, ecology grown from the track
A new top-level section building the metabolism concept: the track supplies the food field, event impulses and per-section laws; the seed picks the species (a rule set, not a parameter vector). Same song + seed => bit-identical frames; different songs => different worlds. - analysis: single FFT pass -> per-frame feature table; score (onsets folded into world geometry via voice ring + pitch-class sector), wells (food placed by the harmony), graph (band correlations -> interaction matrix), laws (per-section regimes) - sim: typed-array world, 3 castes (builder/orbit, forager/commute, spark/orbit-wide) with causality rings, persistence trails, consequence field; fully deterministic - lens: software framebuffer renderer (PNG in node, ImageData in browser) + event-reactive camera (cuts on activity change, alternating shot sizes, drift-and-push) - metrics: coupling x residual fitness (not per-frame descriptors) plus a grown-structure fingerprint; report + strip tools; 15 tests green - DESIGN.md captures the concept, invariants and build order 3 reference test signals (drone/runner/dusk) grow measurably distinct worlds while seeds of one song stay in the same biome.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
// Determinism is the contract: same song, same seed, same frames -> same
|
||||
// pixels. This holds even in processes started at different times, which is
|
||||
// what makes preview === export possible.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Rng, hashString, hashSamples } from '../src/rng.js';
|
||||
import { Fft, hann } from '../src/audio/fft.js';
|
||||
import { analyse } from '../src/audio/analyse.js';
|
||||
import { decodeWav, mono } from '../src/audio/wav.js';
|
||||
|
||||
test('rng is seeded and repeatable', () => {
|
||||
const a = new Rng(42);
|
||||
const b = new Rng(42);
|
||||
assert.deepEqual(Array.from({ length: 20 }, () => a.next()), Array.from({ length: 20 }, () => b.next()));
|
||||
const c = new Rng(43);
|
||||
assert.notDeepEqual(Array.from({ length: 20 }, () => a.next()), Array.from({ length: 20 }, () => c.next()));
|
||||
assert.ok(hashString('drone') !== hashString('runner'));
|
||||
});
|
||||
|
||||
test('fft finds a sine peak', () => {
|
||||
const N = 2048;
|
||||
const fft = new Fft(N);
|
||||
const re = new Float32Array(N);
|
||||
const im = new Float32Array(N);
|
||||
const win = hann(N);
|
||||
const freq = 440;
|
||||
const sr = 44100;
|
||||
for (let i = 0; i < N; i++) re[i] = Math.sin((2 * Math.PI * freq * i) / sr) * win[i];
|
||||
fft.forward(re, im);
|
||||
let best = 0;
|
||||
let peakBin = 0;
|
||||
for (let b = 10; b < N / 2; b++) {
|
||||
const m = Math.hypot(re[b], im[b]);
|
||||
if (m > best) {
|
||||
best = m;
|
||||
peakBin = b;
|
||||
}
|
||||
}
|
||||
// Parabolic interpolation of the peak bin.
|
||||
const m0 = Math.hypot(re[peakBin - 1], im[peakBin - 1]);
|
||||
const m1 = best;
|
||||
const m2 = Math.hypot(re[peakBin + 1], im[peakBin + 1]);
|
||||
const delta = 0.5 * (m0 - m2) / (m0 - 2 * m1 + m2);
|
||||
const detected = ((peakBin + delta) / N) * sr;
|
||||
assert.ok(Math.abs(detected - freq) < 1, `detected ${detected.toFixed(2)} vs 440`);
|
||||
});
|
||||
|
||||
test('analysis is deterministic for the same PCM', () => {
|
||||
const pcm = synth(3);
|
||||
const a = analyse(pcm, 44100);
|
||||
const b = analyse(pcm, 44100);
|
||||
assert.deepEqual(Array.from(a.energy), Array.from(b.energy));
|
||||
assert.equal(a.summary.key, b.summary.key);
|
||||
assert.ok(a.frames > 50);
|
||||
});
|
||||
|
||||
test('two different signals analyse differently', () => {
|
||||
const low = analyse(synth(3, 110), 44100);
|
||||
const high = analyse(synth(3, 990), 44100);
|
||||
assert.ok(Math.abs(low.summary.centroid - high.summary.centroid) > 0.1);
|
||||
assert.ok(low.summary.flatness < 0.4, 'a pure tone is not noisy');
|
||||
});
|
||||
|
||||
test('wav decode: mono pcm round-trips', () => {
|
||||
// A tiny hand-built 8-sample 16-bit mono WAV.
|
||||
const sr = 8000;
|
||||
const samples = [0, 16384, 32767, -16384, -32768, 0, 1000, -1000];
|
||||
const data = Buffer.alloc(44 + samples.length * 2);
|
||||
data.write('RIFF', 0);
|
||||
data.writeUInt32LE(36 + samples.length * 2, 4);
|
||||
data.write('WAVEfmt ', 8);
|
||||
data.writeUInt32LE(16, 16);
|
||||
data.writeUInt16LE(1, 20);
|
||||
data.writeUInt16LE(1, 22);
|
||||
data.writeUInt32LE(sr, 24);
|
||||
data.writeUInt32LE(sr * 2, 28);
|
||||
data.writeUInt16LE(2, 32);
|
||||
data.writeUInt16LE(16, 34);
|
||||
data.write('data', 36);
|
||||
data.writeUInt32LE(samples.length * 2, 40);
|
||||
for (let i = 0; i < samples.length; i++) data.writeInt16LE(samples[i], 44 + i * 2);
|
||||
const wav = decodeWav(new Uint8Array(data));
|
||||
assert.ok(wav);
|
||||
assert.equal(wav.sampleRate, sr);
|
||||
const arr = Array.from(mono(wav.channels));
|
||||
assert.equal(arr[2], 32767 / 32768);
|
||||
assert.equal(arr[4], -32768 / 32768);
|
||||
});
|
||||
|
||||
test('pcm hash is content-based and stable', () => {
|
||||
// Long enough that the content stride sees both samples.
|
||||
const make = (v) => {
|
||||
const a = new Float32Array(3000);
|
||||
a[1994] = v; // a location the stride actually visits (997 → 1994)
|
||||
return a;
|
||||
};
|
||||
assert.equal(hashSamples(make(0.5)), hashSamples(make(0.5)));
|
||||
assert.notEqual(hashSamples(make(0.5)), hashSamples(make(-0.5)));
|
||||
});
|
||||
|
||||
/** A few seconds of low/high sine, enough for a stable beat free analysis. */
|
||||
function synth(seconds, freq = 220) {
|
||||
const sr = 44100;
|
||||
const n = Math.floor(sr * seconds);
|
||||
const out = new Float32Array(n);
|
||||
for (let i = 0; i < n; i++) out[i] = Math.sin((2 * Math.PI * freq * i) / sr) * 0.5;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// The evaluation layer must be well-behaved: every world gets a finite,
|
||||
// clamped score, coupling and residual are separable, and the fitness
|
||||
// discriminates between songs (in the sense that the spread across songs is
|
||||
// clearly larger than the spread across seeds).
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { analyse } from '../src/audio/analyse.js';
|
||||
import { decodeWav, mono } from '../src/audio/wav.js';
|
||||
import { buildSong } from '../src/song/song.js';
|
||||
import { Run } from '../src/film.js';
|
||||
import { evaluate, fingerprint, divergence } from '../src/metrics.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const BANK = resolve(HERE, '../..', 'flow-state/test/songs');
|
||||
|
||||
function grow(name, seed, seconds = 45) {
|
||||
const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`)));
|
||||
const song = buildSong(analyse(mono(wav.channels), wav.sampleRate), seed);
|
||||
const run = new Run(song, { fps: 30, seed, width: 240, height: 135 });
|
||||
run.simulate();
|
||||
return { world: run.world, song };
|
||||
}
|
||||
|
||||
test('evaluation returns finite, clamped numbers', () => {
|
||||
for (const name of ['drone', 'runner', 'dusk']) {
|
||||
const { world, song } = grow(name, 12345);
|
||||
const m = evaluate(world, song);
|
||||
assert.ok(Number.isFinite(m.coupling), `${name} coupling`);
|
||||
assert.ok(Number.isFinite(m.residual), `${name} residual`);
|
||||
assert.ok(Number.isFinite(m.fitness), `${name} fitness`);
|
||||
assert.ok(m.coupling >= 0 && m.coupling <= 1);
|
||||
assert.ok(m.residual >= 0 && m.residual <= 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('fitness is stable across seeds of one song', () => {
|
||||
const songs = ['drone', 'runner', 'dusk'].map((n) => grow(n, 12345).song);
|
||||
for (const song of songs) {
|
||||
const s1 = evaluate(growSong(song, 111).world, growSong(song, 111).song);
|
||||
const s2 = evaluate(growSong(song, 222).world, growSong(song, 222).song);
|
||||
assert.ok(Math.abs(s1.fitness - s2.fitness) < 0.05, `${song.name}: ${s1.fitness} vs ${s2.fitness}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('fingerprint distances: songs farther than seeds', () => {
|
||||
const a = fingerprint(grow('drone', 12345).world);
|
||||
const b = fingerprint(grow('runner', 12345).world);
|
||||
const within = divergence(a, fingerprint(grow('drone', 42).world));
|
||||
assert.ok(divergence(a, b) > within);
|
||||
});
|
||||
|
||||
function growSong(song, seed) {
|
||||
const run = new Run(song, { fps: 30, seed, width: 240, height: 135 });
|
||||
run.simulate();
|
||||
return { world: run.world, song };
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// The world must be deterministic, bounded, and fundamentally unstable under
|
||||
// seed change — while two DIFFERENT songs must grow clearly different worlds.
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { analyse } from '../src/audio/analyse.js';
|
||||
import { decodeWav, mono } from '../src/audio/wav.js';
|
||||
import { buildSong } from '../src/song/song.js';
|
||||
import { World } from '../src/sim/world.js';
|
||||
import { Run } from '../src/film.js';
|
||||
import { fingerprint, divergence } from '../src/metrics.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const BANK = resolve(HERE, '../..', 'flow-state/test/songs');
|
||||
|
||||
function load(name) {
|
||||
const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`)));
|
||||
return buildSong(analyse(mono(wav.channels), wav.sampleRate), 12345);
|
||||
}
|
||||
|
||||
/** Run `seconds` of the world for song `name` with a seed. Returns world. */
|
||||
function grow(name, seed, seconds = 20) {
|
||||
const song = load(name);
|
||||
const run = new Run(song, { fps: 30, seed, width: 240, height: 135 });
|
||||
for (let f = 0; f < Math.floor(seconds * 30); f++) {
|
||||
run.world.step();
|
||||
run.camera.update(f);
|
||||
}
|
||||
return { world: run.world, song };
|
||||
}
|
||||
|
||||
test('same seed reproduces identical history', () => {
|
||||
const { world: a } = grow('drone', 12345);
|
||||
const { world: b } = grow('drone', 12345);
|
||||
assert.deepEqual(Array.from(a.history.alive), Array.from(b.history.alive));
|
||||
assert.deepEqual(Array.from(a.history.movement), Array.from(b.history.movement));
|
||||
assert.deepEqual(Array.from(a.trail[0]), Array.from(b.trail[0]));
|
||||
assert.equal(a.frame, b.frame);
|
||||
});
|
||||
|
||||
test('different seeds give different ecologies', () => {
|
||||
const { world: a } = grow('drone', 12345);
|
||||
const { world: b } = grow('drone', 99999);
|
||||
const moved = a.history.movement.some((v, i) => Math.abs(v - b.history.movement[i]) > 1e-3);
|
||||
assert.ok(moved, 'ecologies should diverge under a different seed');
|
||||
});
|
||||
|
||||
test('population stays within bounds and alive', () => {
|
||||
const { world: w } = grow('runner', 12345, 30);
|
||||
for (const v of w.history.alive) {
|
||||
assert.ok(v <= w.cap + 40, `population exceeded cap: ${v}`);
|
||||
}
|
||||
assert.ok(w.history.alive.some((v) => v > 0), 'the dish should stay inhabited');
|
||||
});
|
||||
|
||||
test('field and trail never go negative', () => {
|
||||
const { world: w } = grow('drone', 7, 25);
|
||||
for (const v of w.resource) assert.ok(v >= -1e-9, `negative resource ${v}`);
|
||||
for (let c = 0; c < 3; c++) {
|
||||
for (const v of w.trail[c]) assert.ok(v >= -1e-9, `negative trail ${v}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('trail mass reflects a busy vs sparse world', () => {
|
||||
const sparse = grow('drone', 12345, 25);
|
||||
const dense = grow('runner', 12345, 25);
|
||||
assert.ok(dense.world.count > sparse.world.count * 1.5);
|
||||
});
|
||||
|
||||
test('three songs grow fingerprints further apart than seeds of one song', () => {
|
||||
const a = fingerprint(grow('drone', 12345).world);
|
||||
const b = fingerprint(grow('runner', 12345).world);
|
||||
const c = fingerprint(grow('dusk', 12345).world);
|
||||
const cross = Math.max(divergence(a, b), divergence(a, c), divergence(b, c));
|
||||
const a2 = fingerprint(grow('drone', 99).world);
|
||||
const within = divergence(a, a2);
|
||||
assert.ok(cross > within * 2, `cross ${cross} !> 2×within ${within}`);
|
||||
});
|
||||
Reference in New Issue
Block a user