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,16 @@
|
||||
// Node-only: WAV -> analysis -> song. The browser does the same with a
|
||||
// decoded AudioBuffer; this keeps the tools honest about the same pipeline.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { decodeWav, mono } from '../src/audio/wav.js';
|
||||
import { analyse } from '../src/audio/analyse.js';
|
||||
import { buildSong } from '../src/song/song.js';
|
||||
|
||||
export function loadSong(path, { fps = 30, seed = 12345 } = {}) {
|
||||
const wav = decodeWav(readFileSync(path));
|
||||
if (!wav) throw new Error(`not a WAV: ${path}`);
|
||||
const pcm = mono(wav.channels);
|
||||
const analysis = analyse(pcm, wav.sampleRate, { fps });
|
||||
const song = buildSong(analysis, seed);
|
||||
return { pcm, sampleRate: wav.sampleRate, analysis, song, name: path.split('/').pop().replace(/\.wav$/i, '') };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// The system-level report: does each episode keep time with its song
|
||||
// (coupling) while still having dynamics the song does not explain
|
||||
// (residual)? And do different songs — grown by different songs' laws —
|
||||
// actually diverge? Outputs a table and out/report.md.
|
||||
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadSong } from './load.js';
|
||||
import { Run } from '../src/film.js';
|
||||
import { evaluate, fingerprint, divergence } from '../src/metrics.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
const BANK = resolve(ROOT, '../flow-state/test/songs');
|
||||
const SEEDS = [999, 12345, 777777];
|
||||
|
||||
function args(argv) {
|
||||
const out = {};
|
||||
for (let i = 2; i < argv.length; i += 2) out[argv[i].replace(/^--/, '')] = argv[i + 1];
|
||||
return out;
|
||||
}
|
||||
|
||||
const opt = args(process.argv);
|
||||
const names = (opt.songs || 'drone,runner,dusk').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const seeds = opt.seeds ? opt.seeds.split(',').map(Number) : SEEDS;
|
||||
mkdirSync(resolve(ROOT, 'out'), { recursive: true });
|
||||
|
||||
const rows = [];
|
||||
for (const name of names) {
|
||||
for (const seed of seeds) {
|
||||
const { song } = loadSong(resolve(BANK, `${name}.wav`), { fps: 30, seed });
|
||||
const run = new Run(song, { fps: 30, width: 480, height: 270, seed });
|
||||
run.simulate();
|
||||
const world = run.world;
|
||||
const m = evaluate(world, song);
|
||||
const fp = fingerprint(world);
|
||||
rows.push({
|
||||
song: name,
|
||||
seed,
|
||||
species: song.name,
|
||||
peak: world.stats().peak,
|
||||
pop: world.count,
|
||||
sortCount: song.events.length,
|
||||
hue: Number(song.palette.hue.toFixed(2)),
|
||||
...m,
|
||||
fp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const md = [];
|
||||
|
||||
md.push('# metabolism — song × seed report');
|
||||
md.push('');
|
||||
md.push(`Generated ${new Date().toISOString().slice(0, 10)} · 3 test signals (${names.join(', ')}) × ${seeds.length} seeds each.`);
|
||||
md.push('');
|
||||
md.push('| song | seed | species | peak | pop | coupling | residual | **fitness** | hue |');
|
||||
md.push('|---|---|---|---|---|---|---|---|---|');
|
||||
for (const r of rows) {
|
||||
md.push(`| ${r.song} | ${r.seed} | ${r.species} | ${r.peak} | ${r.pop} | ${fps2(r.coupling)} | ${fps2(r.residual)} | **${fps2(r.fitness)}** | ${r.hue} |`);
|
||||
}
|
||||
|
||||
md.push('');
|
||||
md.push('## Grown structure (average over seeds)');
|
||||
md.push('');
|
||||
md.push('| song | occupancy | cluster | trailFill | class mix b/f/s |');
|
||||
md.push('|---|---|---|---|---|');
|
||||
const bySong = new Map();
|
||||
for (const r of rows) {
|
||||
if (!bySong.has(r.song)) bySong.set(r.song, []);
|
||||
bySong.get(r.song).push(r);
|
||||
}
|
||||
for (const name of names) {
|
||||
const list = bySong.get(name) || [];
|
||||
const avg = {
|
||||
occupancy: (list.reduce((s, r) => s + r.fp.occupancy, 0) / list.length).toFixed(3),
|
||||
cluster: (list.reduce((s, r) => s + r.fp.cluster, 0) / list.length).toFixed(2),
|
||||
trailFill: (list.reduce((s, r) => s + r.fp.trailFill, 0) / list.length).toFixed(3),
|
||||
mix: [0, 1, 2].map((i) =>
|
||||
(list.reduce((s, r) => s + r.fp.classMix[i], 0) / list.length).toFixed(2)
|
||||
).join('/'),
|
||||
};
|
||||
md.push(`| ${name} | ${avg.occupancy} | ${avg.cluster} | ${avg.trailFill} | ${avg.mix} |`);
|
||||
}
|
||||
|
||||
md.push('');
|
||||
md.push('## Divergence (L1 distance between fingerprints)');
|
||||
md.push('');
|
||||
md.push('Across songs (seed 12345 each):');
|
||||
md.push('');
|
||||
md.push('| pair | divergence |');
|
||||
md.push('|---|---|');
|
||||
const songs = [...new Set(rows.map((r) => r.song))];
|
||||
const char = (song, seed) => rows.find((r) => r.song === song && r.seed === seed).fp;
|
||||
for (let i = 0; i < songs.length; i++) {
|
||||
for (let j = i + 1; j < songs.length; j++) {
|
||||
const d = divergence(char(songs[i], 12345), char(songs[j], 12345));
|
||||
md.push(`| ${songs[i]} ↔ ${songs[j]} | ${fps2(d)} |`);
|
||||
}
|
||||
}
|
||||
md.push('');
|
||||
md.push('Across seeds (drone):');
|
||||
md.push('');
|
||||
md.push('| pair | divergence |');
|
||||
md.push('|---|---|');
|
||||
for (let i = 0; i < seeds.length; i++) {
|
||||
for (let j = i + 1; j < seeds.length; j++) {
|
||||
const d = divergence(char('drone', seeds[i]), char('drone', seeds[j]));
|
||||
md.push(`| seed ${seeds[i]} ↔ ${seeds[j]} | ${fps2(d)} |`);
|
||||
}
|
||||
}
|
||||
|
||||
md.push('');
|
||||
md.push('_Note: coupling is correlation with the song envelope, residual is 1 − R² after');
|
||||
md.push('regressing out song features; fitness is a blend of both. See src/metrics.js._');
|
||||
|
||||
const text = md.join('\n') + '\n';
|
||||
const reportFile = resolve(ROOT, 'out', 'report.md');
|
||||
writeFileSync(reportFile, text);
|
||||
writeFileSync(resolve(ROOT, 'out', 'report.json'), JSON.stringify(rows, null, 2));
|
||||
|
||||
console.log(text);
|
||||
console.log('wrote ' + reportFile);
|
||||
|
||||
function fps2(v) {
|
||||
return v.toFixed(3).replace('-', '−');
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Render one filmstrip per song: N frames evenly across the track, tiled.
|
||||
//
|
||||
// node tools/strip.js --songs drone,runner,dusk
|
||||
// node tools/strip.js --songs drone --cols 6 --rows 2 --w 480 --h 270
|
||||
//
|
||||
// Frames come from the same Run as a full render, and the sim is advanced
|
||||
// through every frame in between, so a strip cannot show a world that the
|
||||
// video would not have.
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadSong } from './load.js';
|
||||
import { Run } from '../src/film.js';
|
||||
import { encodePNG, tile } from '../src/render/png.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
const BANK = resolve(ROOT, '../flow-state/test/songs');
|
||||
|
||||
function args(argv) {
|
||||
const out = {};
|
||||
for (let i = 2; i < argv.length; i += 2) out[argv[i].replace(/^--/, '')] = argv[i + 1];
|
||||
return out;
|
||||
}
|
||||
|
||||
const opt = args(process.argv);
|
||||
const names = (opt.songs || 'drone,runner,dusk').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const seed = Number(opt.seed || 12345);
|
||||
const cols = Number(opt.cols || 4);
|
||||
const rows = Number(opt.rows || 3);
|
||||
const width = Number(opt.w || 480);
|
||||
const height = Number(opt.h || 270);
|
||||
const fps = 30;
|
||||
const everything = opt.combined === '1';
|
||||
const outDir = resolve(ROOT, opt.out || 'out');
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const count = cols * rows;
|
||||
const all = [];
|
||||
|
||||
for (const name of names) {
|
||||
const { song, analysis } = loadSong(resolve(BANK, `${name}.wav`), { fps, seed });
|
||||
const run = new Run(song, { fps, width, height, seed });
|
||||
const frames = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = (0.10 + 0.85 * (i / Math.max(1, count - 1))) * analysis.duration;
|
||||
frames.push(Math.min(analysis.frames - 1, Math.round(t * fps)));
|
||||
}
|
||||
const rendered = run.renderAt(frames);
|
||||
const shots = frames.map((f) => rendered.get(f));
|
||||
const zooms = shots.map((s) => `${s.cam.zoom.toFixed(1)}@${s.shot}`).join(' ');
|
||||
const sheet = tile(shots, cols, rows, 2);
|
||||
const file = resolve(outDir, `strip-${name}.png`);
|
||||
writeFileSync(file, encodePNG(sheet.w, sheet.h, sheet.data));
|
||||
const stats = run.world.stats();
|
||||
console.log(`${file} ${sheet.w}x${sheet.h} ${song.name} peak=${stats.peak} pop=${run.world.count} hue=${song.palette.hue.toFixed(2)} key=${song.summary.key}`);
|
||||
console.log(` shots: ${zooms}`);
|
||||
all.push({ name, song, shots, stats });
|
||||
}
|
||||
|
||||
if (everything) {
|
||||
// One row per song, same fractions of each track: the comparison sheet.
|
||||
const per = 4;
|
||||
const chosen = [];
|
||||
for (const entry of all) {
|
||||
for (let i = 0; i < per; i++) chosen.push(entry.shots[Math.round((i / (per - 1)) * (entry.shots.length - 1))]);
|
||||
}
|
||||
const sheet = tile(chosen, per, all.length, 3);
|
||||
const file = resolve(outDir, 'strip-all.png');
|
||||
writeFileSync(file, encodePNG(sheet.w, sheet.h, sheet.data));
|
||||
console.log(`${file} ${sheet.w}x${sheet.h}`);
|
||||
}
|
||||
Reference in New Issue
Block a user