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:
Dejvino
2026-10-24 08:23:33 +02:00
parent 6a2af2e45f
commit 13b0939221
26 changed files with 4469 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
// Onsets become places. The song does not move the agents directly; it puts
// food down, and the agents grow on it.
//
// Three rules make the geography legible:
// - the voice picks the ring: kick near the centre, body in the middle,
// air at the rim, so a track's mix is visible as an annulus;
// - the dominant pitch picks the angle, so twelve pitch classes become
// twelve sectors and harmony becomes direction;
// - strength sets how much food and how wide, so a hit is a place and a
// fill is a region.
const TAU = Math.PI * 2;
const RINGS = [0.24, 0.54, 0.82];
export function buildEvents(analysis, rng) {
const events = [];
const keys = analysis.summary.chroma ? 12 : 0;
for (const o of analysis.onsets.all) {
const f = o.frame;
const hz = analysis.dominantHz[f] || 440;
const semis = Math.round(12 * Math.log2(hz / 440));
const pc = ((semis % 12) + 12 + 9) % 12;
// A quiet, noisy band has an unstable dominant bin. Pull the angle
// toward the event's own hash when the band is flat.
const confidence = 0.55 + 0.45 * (1 - (analysis.flatness[f] ?? 0.3));
const angle = ((pc / (keys || 12)) * TAU) * confidence +
rng.range(-0.16, 0.16) +
(o.voice - 1) * 0.05;
const radius = RINGS[o.voice] * (0.82 + 0.36 * rng.next());
const strength = Math.max(0, Math.min(1, 0.18 + 0.82 * o.strength));
events.push({
frame: f,
t: o.t,
voice: o.voice,
strength,
x: 0.5 + Math.cos(angle) * radius * 0.5,
y: 0.5 + Math.sin(angle) * radius * 0.5,
// World units. A wide event is a region, not a point.
spread: 0.014 + 0.040 * strength * strength,
// Food units. Scaled so a dense arrangement supports roughly a
// thousand agents and a sparse one a few hundred; see the report.
amount: (10 + 34 * strength * strength) * 0.07,
// Strong events seed life as well as food.
spawn: strength > 0.62 ? (o.voice === 0 ? 3 : o.voice === 1 ? 2 : 1) : 0,
born: 0,
});
}
return events;
}
/** Frame -> events, so the world steps in O(events this frame). */
export function indexEvents(events, frames) {
const index = Array.from({ length: frames }, () => []);
for (const e of events) {
if (e.frame >= 0 && e.frame < frames) index[e.frame].push(e);
}
return index;
}
+60
View File
@@ -0,0 +1,60 @@
// The track's key sets the world's hue. Noise sets how far the three castes
// sit from each other on the wheel. Nothing here is a palette chosen by hand:
// two songs in the same key will still separate on saturation and on the
// contrast between the builder and the spark.
const TAU = Math.PI * 2;
export function hsl(h, s, l) {
h = ((h % 1) + 1) % 1;
s = Math.max(0, Math.min(1, s));
l = Math.max(0, Math.min(1, l));
const a = s * Math.min(l, 1 - l);
const f = (n) => {
const k = (n + h * 12) % 12;
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
};
return [f(0), f(8), f(4)];
}
const lerp = (a, b, t) => a + (b - a) * t;
export function buildPalette(summary, rng) {
const key = summary.key ?? 0;
const noise = Math.max(0, Math.min(1, summary.flatness));
const bright = Math.max(0, Math.min(1, summary.centroid));
const beat = Math.max(0, Math.min(1, summary.beatStrength));
// Hue comes from spectral tilt: how much of the mix lives above 2 kHz.
// Bright, airy tracks land warm; bass-heavy ones land cold. It is not the
// key, because every test signal in the bank is in C and a palette that
// cannot separate them is not a palette.
const total = summary.bandBalance[0] + summary.bandBalance[1] + summary.bandBalance[2] + 1e-9;
const tilt = summary.bandBalance[2] / total;
const hue = (0.74 - 1.55 * tilt + key / 12 * 0.03 + rng.range(-0.015, 0.015) + 1) % 1;
const sat = 0.52 + 0.40 * noise + 0.08 * bright;
const lift = 0.44 + 0.07 * bright;
// Builders take the key. Foragers sit a third away; sparks take the
// complement, because the caste that moves fastest should read as "other".
const c0 = hsl(hue, sat * 0.80, lift);
const c1 = hsl(hue + 0.10 + 0.03 * beat, sat, lift + 0.07);
const c2 = hsl(hue + 0.54 + (noise - 0.5) * 0.10, sat * 0.96, lift + 0.15);
const bg = hsl(hue + 0.5, 0.40, 0.026 + 0.012 * bright);
const fog = hsl(hue + 0.5, 0.6, 0.075);
return {
hue,
sat,
bg,
fog,
classes: [c0, c1, c2],
// Trails sit below the agents: the world is dimmer than what moves in it.
trails: [c0.map((v) => v * 0.55), c1.map((v) => v * 0.55), c2.map((v) => v * 0.55)],
grain: hsl(hue + 0.5, 0.2, 0.5),
ink: [0.92, 0.94, 0.96],
};
}
export { lerp, TAU };
+33
View File
@@ -0,0 +1,33 @@
// The song, as a prescription for a world. One call, no rendering, no clock.
//
// events where food appears, and when
// genome what kind of creature eats it, and how they relate
// laws how the rules change at section boundaries
// palette the key, as colour
//
// Everything downstream is a pure function of this object plus a frame number.
import { Rng } from '../rng.js';
import { buildPalette } from './palette.js';
import { buildEvents, indexEvents } from './events.js';
import { deriveGenome } from './species.js';
export function buildSong(analysis, seed = 1) {
const rng = new Rng(seed >>> 0).fork('metabolism');
const palette = buildPalette(analysis.summary, rng.fork('palette'));
const events = buildEvents(analysis, rng.fork('events'));
const genome = deriveGenome(analysis, rng.fork('genome'));
return {
seed: seed >>> 0,
analysis,
summary: analysis.summary,
palette,
events,
index: indexEvents(events, analysis.frames),
genome,
name: genome.name,
laws: genome.laws,
};
}
export { buildPalette, buildEvents, deriveGenome };
+143
View File
@@ -0,0 +1,143 @@
// The song's species.
//
// This is the part that makes the outcomes "derived" rather than "randomised".
// The seed picks details; the track picks the kind of world. A tonal track
// makes weavers with long memories and heavy trails. A noisy track makes
// swarms: faster, jitterier, thinner trails, looser societies. A steady beat
// makes them align; a beatless one lets them drift.
//
// Every number below is a claim about a mapping, not a taste. If the mapping
// stops producing different worlds from different tracks, this file is wrong.
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
const lerp = (a, b, t) => a + (b - a) * t;
const BASE_CLASSES = [
{
name: 'builder',
role: 'orbit', orbit: 0.038, spin: 0.45,
speed: 0.034, turn: 1.35, sense: 0.090, size: 2.8,
deposit: 1.55, seek: 0.85, split: 1.55, drain: 0.030, seed: 0.34,
},
{
name: 'forager',
role: 'commute',
speed: 0.082, turn: 3.00, sense: 0.105, size: 2.0,
deposit: 0.80, seek: 1.25, split: 1.10, drain: 0.042, seed: 0.44,
},
{
name: 'spark',
role: 'orbit', orbit: 0.105, spin: 1.30,
speed: 0.135, turn: 5.20, sense: 0.070, size: 1.35,
deposit: 0.16, seek: 0.72, split: 0.95, drain: 0.055, seed: 0.22,
},
];
// How a section changes the standing laws. Not a parameter ramp: the world
// changes behaviour at a boundary, which is what makes a boundary audible in
// the picture as well as in the track.
const LAW_BY_KIND = {
intro: { speed: 0.62, seek: 0.80, deposit: 1.45, split: 1.25, spawn: 0.60, turn: 0.85 },
build: { speed: 1.00, seek: 1.15, deposit: 1.00, split: 1.00, spawn: 1.25, turn: 1.00 },
drop: { speed: 1.55, seek: 1.35, deposit: 0.72, split: 0.62, spawn: 2.20, turn: 1.25 },
breakdown: { speed: 0.42, seek: 0.55, deposit: 2.10, split: 1.75, spawn: 0.35, turn: 0.70 },
sustain: { speed: 0.88, seek: 1.00, deposit: 1.12, split: 1.05, spawn: 0.85, turn: 0.95 },
outro: { speed: 0.48, seek: 0.68, deposit: 1.70, split: 1.60, spawn: 0.25, turn: 0.75 },
};
function deriveName(summary, genome) {
const register = genome.pace < 0.45 ? 'slow' : genome.pace < 0.70 ? 'measured' : 'fast';
const grain = summary.flatness < 0.12 ? 'tonal' : summary.flatness < 0.4 ? 'grained' : 'noisy';
const society = genome.cohesion > 0.72 ? 'weaver' : genome.cohesion > 0.5 ? 'drifter' : 'swarm';
return `${register} ${grain} ${society}`;
}
/**
* @param {object} analysis
* @param {import('../rng.js').Rng} rng
*/
export function deriveGenome(analysis, rng) {
const s = analysis.summary;
const noise = clamp01(s.flatness);
const bright = clamp01(s.centroid);
const dyn = clamp01(s.dynamics);
const density = clamp01(s.onsetDensity / 6);
const beat = clamp01(s.beatStrength);
const tempo = clamp01((s.bpm - 60) / 120);
// Pace separates "has a pulse" from "has a fast clock". A drone detected
// at 160 BPM is still a drone; without a pulse it gets the slow laws.
const pace = clamp01(tempo * (0.35 + 0.65 * beat));
const cohesion = clamp01(0.22 + 0.50 * (1 - noise) + 0.30 * beat);
const separation = clamp01(0.25 + 0.55 * density);
const alignment = clamp01(0.10 + 0.80 * beat);
const jitter = clamp01(0.04 + 0.75 * noise);
const aggression = clamp01(0.10 + 0.50 * dyn * (0.4 + 0.6 * bright));
const classes = BASE_CLASSES.map((base, i) => {
const isHeavy = i === 0;
return {
...base,
speed: base.speed * (isHeavy ? 0.40 + 0.60 * pace : 0.35 + 1.10 * pace),
turn: base.turn * (0.55 + 0.9 * jitter + 0.35 * beat) * (1 - 0.15 * cohesion),
sense: base.sense * (0.78 + 0.45 * bright),
deposit: base.deposit * (0.35 + 1.25 * cohesion),
split: base.split * (1.25 - 0.45 * density) * (0.8 + 0.3 * dyn),
seek: base.seek * (0.75 + 0.4 * bright),
drain: base.drain * 1.3 * (0.85 + 0.4 * dyn),
};
});
// Society comes from whether the song's bands move together. Correlated
// bands attract; a band that lives its own life repels the others.
const interaction = s.bandCorrelation.map((row, i) =>
row.map((r, j) => {
if (i === j) return 0;
const proximity = 1 - 0.25 * Math.abs(i - j);
return (r - 0.62) * 2.0 * proximity * (0.4 + 0.6 * aggression);
})
);
const laws = analysis.sections.map((sec) => {
const base = LAW_BY_KIND[sec.kind] || LAW_BY_KIND.sustain;
const e = clamp01(sec.energy);
return {
start: sec.start,
end: sec.end,
kind: sec.kind,
energy: e,
speed: base.speed * (0.72 + 0.55 * e),
seek: base.seek,
deposit: base.deposit,
split: base.split,
spawn: base.spawn * (0.55 + 0.9 * e),
turn: base.turn,
hue: (sec.centroid - s.centroid) * 0.35 + (sec.kind === 'drop' ? 0.03 : 0),
};
});
const genome = {
seed: rng.seed,
key: s.key,
pace,
cohesion,
separation,
alignment,
jitter,
aggression,
classes,
interaction,
laws,
// Memory: a tonal track keeps what it builds; a noisy one forgets.
trailHalfLife: 15 - 7 * noise,
fieldHalfLife: 28 - 10 * noise,
maxAgents: 2600,
seedAgents: 150 + Math.round(120 * density),
name: '',
};
genome.name = deriveName(s, genome);
return genome;
}
export { LAW_BY_KIND, BASE_CLASSES, clamp01, lerp };