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,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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user