Files
music-video-gen/metabolism/src/song/events.js
T
Dejvino 13b0939221 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.
2026-10-24 08:23:33 +02:00

60 lines
2.4 KiB
JavaScript

// 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;
}