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
+2
View File
@@ -0,0 +1,2 @@
node_modules/
out/
+250
View File
@@ -0,0 +1,250 @@
# metabolism — design
A music video generator whose output is **grown**, not mixed. The track is not
a parameter set the renderer modulates; it is the *substrate* a simulation
grows on. Given the same song and the same seed, the same world — bit for bit,
frame for frame, preview and export identical.
```
song (PCM)
│ analyse — whole track, up front, fixed-rate
▼
feature table ──► score events (what spoke, when, how hard)
(per frame) ──► field food, where the track put it
──► graph relations between voices
──► laws per-section regime
│ seed
▼
species genome ─────────────── rules the agents live by
▼
world ◄── agents on the field, laws switch at boundaries
▼
camera ◄── cuts to where the world just changed
▼
framebuffer ──► PNG (tools) / canvas (demo) / video (later)
```
---
## The idea
Most music visualisers **modulate** one fixed stage with the song: parameters
go up and down, and every song ends up presenting the same toy in different
clothes. That is cheap to watch and cheap to build, and it is exactly what
we are not doing.
We want each song to produce a world that looks and behaves like *that song*
and no other — a world with its own places, its own creatures, its own
disasters. The mechanism is **metabolism over modulation**: the song supplies
the resource field, the event impulses and the per-section laws; the seed
picks the species, and the species is a *rule set*, not a parameter vector.
Two songs therefore differ in the way two habitats differ, not in the way two
skins differ. A noisy fast track grows a swarm with jittered motion and thin,
ephemeral trails. A tonal slow track grows weavers with long memories and
heavy ground markings. Same renderer, same simulation — different songs, so
different laws, so different worlds.
### Why rules and not parameters
A parameter changes *how strongly* a behaviour applies. A rule changes *what
behaviour exists*. Parameters are a knob; rules are a diet. The strongest
variation-per-cost you can buy is changing which rules a creature obeys, not
tuning how much of a rule it obeys:
- small budget → different rules → wildly different outcomes
- large population → multiply across hundreds of interacting agents for free
The genre of a track steers the *family* of rules; the seed steers the exact
world, the way a seed steers an ecology's exact history while the biome is
determined by the land.
---
## Three invariants
Every design decision below exists to keep these alive. If an edit weakens
one of them, the edit is wrong.
1. **Persistence** — things last. Agents live for seconds; trails last tens
of seconds. The audience can *follow* something across the film rather than
decoding a new random field every frame.
2. **Causality** — every visible event has an audible cause and a visible
one. A hit lands as a ring on the ground where the track put it. A bloom
has a cause; a migration has one. Nothing happens for no reason.
3. **Consequence** — the state at t is made of the state at t−1. The field is
depleted by eating; trails accumulate from walking. The world records its
own history instead of being re-rolled each frame.
A fourth, transport-level invariant: **determinism**. Same song, same seed,
same frame → same pixel, in node and in the browser, at any wall-clock time.
---
## The analysis: four structures from one pass
A single FFT pass over the whole track (fixed 44.1 kHz decode, 2048-bin FFT,
30 fps) produces a per-frame feature table: band energy (low/mid/high),
spectral flux per band, centroid (log-scaled), flatness, a dominant pitch,
energy, and chronicle of onsets. From that table derive four structures:
### Score — events
Onsets picked per band with an adaptive threshold, merged so one transient
fires once (not three times for three bands). Each event carries a voice
(low/mid/high), a strength, and a time.
**Where an event lands is musical.** The voice picks the ring (kick near the
centre, body in the middle, hats at the rim), the dominant pitch class picks
the angle (12 semitones → 12 sectors), strength picks how much food and how
wide. The track's rhythm is literally folded into the geometry of the food.
### Field — food
Events splat resource onto a 128×128 grid. On top of that, **wells** — slow,
permanent fertile places — are placed by the track's own harmony: one well per
strong pitch class, on two rings, with richness proportional to the chroma
and the band balance. The chord is the map. Wells recharge slowly to a
ceiling, so a place has a carrying capacity, not an infinite tap.
### Graph — relations
The 3×3 correlation matrix of the three band envelopes becomes the species'
*interaction matrix*: bands that move together attract; a band that lives its
own life repels the others. For a bass line that locks with the kick, the
builders court the foragers; for a high band that does its own thing, the
sparks are ostracised. Society is a property of the mix.
### Law — regime per section
Section boundaries are found where the 4 Hz feature cloud changes its mind
(cheap self-similarity, not an FFT of the FFT), classified into
intro / build / drop / breakdown / sustain / outro. Each section becomes a
*law*: a multiplier set applied to the standing rules at the boundary. A drop
speeds everything up and opens the floodgates for spawning; a breakdown slows
the world and doubles deposit. A section boundary is the only place the rules
change — which is what makes the boundary audible in the picture.
---
## The species
The genome maps the summary statistics to *kinds of behaviour*:
| song stats | breeding rule | consequence |
|---|---|---|
| flatness ↑ | jitter ↑ | noisy track = jittery creatures |
| flatness ↓ | trail half-life ↑, deposit ↑ | tonal track = heavy ground markings |
| beat strength | alignment and pace | a pulse makes them march |
| band correlations | interaction matrix | correlated bands attract |
| onset density | separation, split cost | dense track = thronged dish but crowded apart |
| spectral tilt | hue | bright mixes warm, dark mixes cold |
There are three castes, derived from the three voices:
- **builder** — slow, heavy, deposits a lot, keeps near its well. It is the
ink-dropper of the world: it paints the structure the synth earned.
- **forager** — commutes between wells and draws the roads. Direction lives
in its streaks.
- **spark** — small, fast, orbits wide, deposits almost nothing. The counter-
melody: it makes the film read as inhabited rather than decorated.
A species name like "fast tonal weaver" falls out of the genome, so the report
can talk about the world and not the track.
## The world
A fixed-step simulation (~2600 typed, garbage-free agents; spatial hash for
neighbourhood; every RNG stream seeded). Per frame:
1. inject this frame's events — food is placed, and strong events also
*spawn* life and leave a **ring** on the ground (the clearest causality in
the film);
2. decay field and trails; recharge wells (rate set by the section's law);
3. each agent: smell the food gradient, obey its caste's relation to its well,
feel its neighbours (separate / align / cohere / inter-caste attraction),
wander with jitter, hit soft walls, eat, move, lay ink, split on
accumulation, die on starvation;
4. compact.
There is no player, no realtime budget, no entity objects — only typed arrays,
a step function, and a history we can regress against the song.
## The lens
### Camera
An observer with a motive, not a rig on rails:
- **shots cut when the world changes**, never on a timer that ignores the
world. Cuts also fire on strong onsets during drop/build, so the edit hits
like the track hits;
- the **subject is recent activity** (decayed 32×32 cell grid), weighted away
from the previous subject — novelty without drift;
- **shot sizes alternate** in a deliberate rhythm — a wide establishing, a
medium, a close push — and zoom follows coverage: a broad field gets a wide
lens, a crowded knot gets a close-up;
- while held, the camera drifts and pushes in — a shot is an approach, not a
still.
### Renderer
A tiny software framebuffer (additive blend, discs, rings, lines, bilinear
world blit) so the same pixels render in node (PNG, filmstrips) and in the
browser (ImageData). Layers: near-black key-tinted background → trail+field
composed at field resolution with a tight blur (line) and a faint wide blur
(glow) → event rings → agents as bodies with velocity streaks → vignette →
deterministic grain.
The field is drawn faintly *under* the trails, so the audience can see *why*
the creatures are where they are.
## Metrics: system, not pixels
Per-frame descriptor distance was rejected long ago (it cannot tell a dungeon
from a car and rewards noise). Instead we score the whole episode against its
own song:
- **coupling** — how much the ecology answers the track: cue response (does
movement edge up just after onsets?) plus long-form correlation (smoothed
movement vs smoothed energy);
- **residual** — 1 − R² of the sim's activity (motion *and* the population
curve) regressed on all song features: the internal dynamics the song does
not explain;
- **fitness = coupling × residual** — the best world keeps time *and* has a
life of its own. A pure visualiser scores ~0 (no residual). A pure random
walk scores ~0 (no coupling). The middle is the point.
A separate **fingerprint** (occupancy, clustering, trail fill, class mix)
lets us measure whether different songs grow different worlds, independent of
aesthetic judgement — and whether different seeds of one song stay in the same
biome.
## Determinism & preview === export
- one decode rate, one analysis pass, one feature table;
- no `Math.random`, no wall clock, no `requestAnimationFrame` in the sim;
- the world steps in typed arrays; ordering is canonical;
- the camera and renderer are pure functions of `(song, seed, frame)`;
- tools and browser share the same `Run`.
What will be previewed is exactly what will be exported.
---
## Build order & status
- **v0 — substrate** ✔ one song, one species, alive on the dish.
*Built:* analysis → structures → world → renderer → filmstrips, metrics,
15 tests green.
- **v1 — variation** ✔ species sampled per seed; three reference songs
(drone / runner / dusk) grow measurably distinct worlds (fingerprint
divergence ≫ seed divergence; fitness separates them).
- **v2 — story** laws switch at section boundaries (done in the world);
event-reactive camera cutting (done in camera.js); filmstrip + report tools
wired.
- **v3 — search** rule-space search with the coupling × residual fitness —
only after the substrate exists, and only as a sampling tool, never as a
user-facing knob.
## Current state, honestly
The world is alive and the songs diverge on the metrics, but it is not yet a
*film*: the camera and the soundtrack are not muxed, the renderer is a proof
at 480p, and the three reference tracks are synthetic test signals rather than
music. The next milestone is three videos — one per song — that a person can
sit through and tell apart without the report.
+1058
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "metabolism",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Songs as substrate: agent ecologies whose laws are derived from the track, rendered deterministically.",
"scripts": {
"dev": "vite",
"test": "node --test test/*.test.js",
"strip": "node tools/strip.js",
"report": "node tools/report.js"
},
"devDependencies": {
"vite": "^7.2.2"
}
}
+410
View File
@@ -0,0 +1,410 @@
// PCM -> per-frame feature table. Whole track, before the first frame renders.
//
// Nothing here reacts in realtime. The table is the song's score as numbers:
// band energy, spectral flux, centroid, flatness, a dominant pitch, and the
// places where the track changes. Everything downstream reads this table, so
// preview and export cannot diverge.
import { Fft, hann } from './fft.js';
export const DEFAULT_FPS = 30;
const BANDS = [
[30, 160], // low — weight, pulse, foundation
[160, 2000], // mid — body, voice, harmony
[2000, 11000], // high — air, noise, detail
];
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
function percentile(values, p) {
const a = Float32Array.from(values);
a.sort();
const i = Math.min(a.length - 1, Math.max(0, Math.round((a.length - 1) * p)));
return a[i];
}
function normalise(series, loP = 0.03, hiP = 0.97) {
const lo = percentile(series, loP);
const hi = percentile(series, hiP);
const k = hi - lo > 1e-9 ? 1 / (hi - lo) : 0;
const out = new Float32Array(series.length);
for (let i = 0; i < series.length; i++) out[i] = clamp01((series[i] - lo) * k);
return out;
}
/** Local-maximum picking with an adaptive threshold. Returns discrete events. */
export function pickOnsets(flux, fps, { minGap = 0.07, threshold = 1.35, window = 0.5 } = {}) {
const n = flux.length;
const w = Math.max(1, Math.round(window * fps));
const gap = Math.max(1, Math.round(minGap * fps));
const events = [];
let sum = 0;
for (let i = 0; i < n; i++) {
sum += flux[i];
if (i - w >= 0) sum -= flux[i - w];
const mean = sum / Math.min(i + 1, w);
if (i < 2 || i >= n - 1) continue;
if (flux[i] <= flux[i - 1] || flux[i] < flux[i + 1]) continue;
const floor = mean * threshold + 0.004;
if (flux[i] < floor) continue;
if (events.length && i - events[events.length - 1].frame < gap) {
if (flux[i] > events[events.length - 1].strength) {
events.pop();
} else {
continue;
}
}
events.push({ frame: i, t: i / fps, strength: clamp01((flux[i] - floor) / Math.max(1e-6, 1 - floor)) });
}
return events;
}
/** Tempo from the onset envelope's autocorrelation. */
function estimateTempo(onset, fps) {
const minLag = Math.round((fps * 60) / 200);
const maxLag = Math.round((fps * 60) / 50);
let best = 0;
let bestLag = 0;
let total = 0;
for (let i = 0; i < onset.length; i++) total += onset[i] * onset[i];
for (let lag = minLag; lag <= maxLag; lag++) {
let s = 0;
for (let i = lag; i < onset.length; i++) s += onset[i] * onset[i - lag];
s /= onset.length - lag;
if (s > best) {
best = s;
bestLag = lag;
}
}
if (!bestLag || total <= 1e-9) return { bpm: 0, beatStrength: 0 };
// Normalised autocorrelation: a steady 4/4 lands well above 0.3, a drone
// near 0.1. The sim uses strength, not bpm, to decide whether to march.
return { bpm: (60 * fps) / bestLag, beatStrength: clamp01(best / (total / onset.length)) };
}
/**
* Section boundaries: where the 4 Hz feature cloud changes its mind.
* A boundary is a peak in the distance between the mean of the eight buckets
* before it and the eight after it — cheap self-similarity, no FFT of the FFT.
*/
function findSections(frames4, fps4, duration) {
const n = frames4.length;
if (n < 8) return [{ start: 0, end: duration, index: 0 }];
const half = 8;
const novelty = new Float32Array(n);
for (let i = half; i < n - half; i++) {
let d = 0;
for (let k = 0; k < 6; k++) {
let a = 0;
let b = 0;
for (let j = 1; j <= half; j++) {
a += frames4[i - j][k];
b += frames4[i + j][k];
}
d += ((a - b) / half) ** 2;
}
novelty[i] = Math.sqrt(d);
}
const peak = Math.max(...novelty);
if (peak <= 1e-8) return [{ start: 0, end: duration, index: 0 }];
for (let i = 0; i < n; i++) novelty[i] /= peak;
const minGap = Math.round(10 * fps4);
const accepted = [];
const guard = Math.round(4 * fps4);
for (let i = guard; i < n - guard; i++) {
if (novelty[i] < 0.45) continue;
if (novelty[i] < novelty[i - 1] || novelty[i] < novelty[i + 1]) continue;
if (accepted.length && i - accepted[accepted.length - 1] < minGap) {
if (novelty[i] > novelty[accepted[accepted.length - 1]]) accepted.pop();
else continue;
}
accepted.push(i);
if (accepted.length >= 6) break;
}
let cuts = [0, ...accepted.map((i) => i / fps4), duration];
// Absorb sections shorter than four seconds: a boundary that survives for
// two seconds is a swell, not an era. The sim cannot change laws that fast.
cuts = cuts.filter((c, i) => i === 0 || i === cuts.length - 1 || c - cuts[i - 1] >= 4);
const sections = [];
for (let i = 0; i < cuts.length - 1; i++) {
if (cuts[i + 1] - cuts[i] < 1.5) continue;
sections.push({ start: cuts[i], end: cuts[i + 1], index: sections.length });
}
if (!sections.length) return [{ start: 0, end: duration, index: 0 }];
return sections;
}
function classifySections(sections, energyAt) {
const es = sections.map((s) => energyAt(s));
const sorted = [...es].sort((a, b) => a - b);
const p20 = sorted[Math.floor(sorted.length * 0.2)];
const p50 = sorted[Math.floor(sorted.length * 0.5)];
const p80 = sorted[Math.floor(sorted.length * 0.8)];
const kinds = sections.map((s, i) => {
const e = es[i];
const prev = es[i - 1] ?? e;
if (i === 0 && e <= p50) return 'intro';
if (i === sections.length - 1 && e <= p50) return 'outro';
if (e >= p80) return 'drop';
if (e <= p20) return 'breakdown';
if (e > prev + 0.04) return 'build';
return 'sustain';
});
return sections.map((s, i) => ({
...s,
kind: kinds[i],
energy: es[i],
centroid: s.centroid,
flatness: s.flatness,
}));
}
/**
* @param {Float32Array} samples mono
* @param {number} sampleRate
* @param {{fps?:number, fftSize?:number}} options
*/
export function analyse(samples, sampleRate, { fps = DEFAULT_FPS, fftSize = 2048 } = {}) {
const duration = samples.length / sampleRate;
const frames = Math.max(1, Math.floor(duration * fps));
const hop = sampleRate / fps;
const fft = new Fft(fftSize);
const win = hann(fftSize);
const re = new Float32Array(fftSize);
const im = new Float32Array(fftSize);
const mag = new Float32Array(fftSize / 2);
const prevMag = new Float32Array(fftSize / 2);
const nyquist = sampleRate / 2;
const binHz = nyquist / (fftSize / 2);
const bandBins = BANDS.map(([lo, hi]) => [
Math.max(1, Math.floor(lo / binHz)),
Math.min(fftSize / 2 - 1, Math.ceil(hi / binHz)),
]);
const energyRaw = new Float32Array(frames);
const bandsRaw = [new Float32Array(frames), new Float32Array(frames), new Float32Array(frames)];
const fluxRaw = new Float32Array(frames);
const bandFluxRaw = [new Float32Array(frames), new Float32Array(frames), new Float32Array(frames)];
const centroidRaw = new Float32Array(frames);
const flatnessRaw = new Float32Array(frames);
const dominantRaw = new Float32Array(frames);
const chroma = new Float32Array(12);
for (let f = 0; f < frames; f++) {
const start = Math.min(samples.length - fftSize, Math.floor(f * hop));
let rms = 0;
for (let i = 0; i < fftSize; i++) {
const s = start + i >= 0 ? samples[start + i] || 0 : 0;
re[i] = s * win[i];
im[i] = 0;
rms += s * s;
}
energyRaw[f] = Math.sqrt(rms / fftSize);
fft.forward(re, im);
let sumP = 0;
let sumLog = 0;
let weighted = 0;
let peakBin = 1;
let peakMag = 0;
let flux = 0;
for (let b = 1; b < fftSize / 2; b++) {
const m = Math.sqrt(re[b] * re[b] + im[b] * im[b]) / (fftSize / 2);
mag[b] = m;
const p = m * m + 1e-12;
sumP += p;
sumLog += Math.log(p);
weighted += b * binHz * p;
if (m > peakMag) {
peakMag = m;
peakBin = b;
}
const hz = b * binHz;
if (hz > 45 && hz < 2400) {
const semis = Math.round(12 * Math.log2(hz / 440));
const pc = ((semis % 12) + 12 + 9) % 12;
chroma[pc] += m * m;
}
flux += Math.max(0, m - prevMag[b]);
}
const count = fftSize / 2 - 1;
// Log-frequency centroid: 50 Hz -> 0, Nyquist -> 1. A linear Hz centroid
// sits near 0.02 for every real track and cannot tell dark from bright.
const meanHz = weighted / Math.max(sumP, 1e-12);
centroidRaw[f] = clamp01(Math.log2(Math.max(50, meanHz) / 50) / Math.log2(nyquist / 50));
flatnessRaw[f] = sumP > 1e-8 ? clamp01((Math.exp(sumLog / count) / (sumP / count)) * 20) : 0;
fluxRaw[f] = Math.min(1, flux * 4);
dominantRaw[f] = peakBin * binHz;
for (let k = 0; k < 3; k++) {
const [lo, hi] = bandBins[k];
let e = 0;
let fl = 0;
for (let b = lo; b <= hi; b++) {
const m = mag[b];
e += m * m;
fl += Math.max(0, m - prevMag[b]);
}
bandsRaw[k][f] = Math.sqrt(e / (hi - lo + 1));
bandFluxRaw[k][f] = Math.min(1, fl * 4);
}
prevMag.set(mag);
}
const energy = normalise(energyRaw);
const low = normalise(bandsRaw[0]);
const mid = normalise(bandsRaw[1]);
const high = normalise(bandsRaw[2]);
const flux = normalise(fluxRaw);
const bandFlux = bandFluxRaw.map((s) => normalise(s));
const onsets = bandFlux.map((s, k) => pickOnsets(s, fps, { threshold: k === 2 ? 1.5 : 1.3 }));
const combined = new Float32Array(frames);
for (let i = 0; i < frames; i++) combined[i] = Math.max(bandFlux[0][i], bandFlux[1][i] * 0.8, bandFlux[2][i] * 0.5);
const allOnsets = mergeOnsets(onsets, fps);
const tempo = estimateTempo(combined, fps);
// 4 Hz section view.
const fps4 = Math.min(fps, 4);
const per = fps / fps4;
const frames4 = [];
for (let i = 0; i * per < frames; i++) {
const a = Math.floor(i * per);
const b = Math.min(frames, Math.floor((i + 1) * per));
const row = [0, 0, 0, 0, 0, 0];
for (let j = a; j < b; j++) {
row[0] += energy[j];
row[1] += low[j];
row[2] += mid[j];
row[3] += high[j];
row[4] += centroidRaw[j];
row[5] += flux[j];
}
const k = 1 / Math.max(1, b - a);
frames4.push(row.map((v) => v * k));
}
const rawSections = findSections(frames4, fps4, duration);
const sections = classifySections(rawSections, (s) => {
const a = Math.floor(s.start * fps);
const b = Math.max(a + 1, Math.floor(s.end * fps));
let sum = 0;
for (let i = a; i < b; i++) sum += energy[i];
return sum / (b - a);
}).map((s) => {
const a = Math.floor(s.start * fps);
const b = Math.max(a + 1, Math.floor(s.end * fps));
let c = 0;
let fl = 0;
for (let i = a; i < b; i++) {
c += centroidRaw[i];
fl += flatnessRaw[i];
}
return { ...s, centroid: c / (b - a), flatness: fl / (b - a) };
});
const bandCorrelation = correlate3([low, mid, high]);
const dynamicRange = Math.max(0, percentile(energyRaw, 0.96) - percentile(energyRaw, 0.1)) /
Math.max(1e-9, percentile(energyRaw, 0.96));
let key = 0;
let keyEnergy = 0;
const chromaPeak = Math.max(...chroma);
for (let i = 0; i < 12; i++) {
if (chroma[i] > keyEnergy) {
keyEnergy = chroma[i];
key = i;
}
}
const chromaNorm = Array.from(chroma, (v) => (chromaPeak > 1e-9 ? v / chromaPeak : 0));
const summary = {
duration,
bpm: tempo.bpm,
beatStrength: tempo.beatStrength,
centroid: mean(centroidRaw),
flatness: mean(flatnessRaw),
dynamics: clamp01(dynamicRange),
loudness: mean(energyRaw),
onsetDensity: allOnsets.length / Math.max(1, duration),
bandBalance: [mean(low), mean(mid), mean(high)],
bandCorrelation,
chroma: chromaNorm,
key,
sections: sections.length,
kinds: sections.map((s) => s.kind),
};
return {
fps,
frames,
duration,
sampleRate,
bands: { low, mid, high },
bandFlux,
energy,
flux,
centroid: centroidRaw,
flatness: flatnessRaw,
dominantHz: dominantRaw,
onsets: { low: onsets[0], mid: onsets[1], high: onsets[2], all: allOnsets },
sections,
summary,
};
}
function mean(a) {
let s = 0;
for (let i = 0; i < a.length; i++) s += a[i];
return s / Math.max(1, a.length);
}
/**
* One event per moment. Bands fire together on a transient; three simultaneous
* events would triple the food at the same instant. Keep the strongest, at
* least 120 ms apart, and carry the voice so the world knows what spoke.
*/
function mergeOnsets(bandOnsets, fps, minGap = 0.12) {
const all = [];
for (let voice = 0; voice < bandOnsets.length; voice++) {
for (const o of bandOnsets[voice]) all.push({ ...o, voice });
}
all.sort((a, b) => a.frame - b.frame || b.strength - a.strength);
const gap = Math.max(1, Math.round(minGap * fps));
const out = [];
for (const e of all) {
const last = out[out.length - 1];
if (last && e.frame - last.frame < gap) {
if (e.strength > last.strength) out[out.length - 1] = e;
continue;
}
out.push(e);
}
return out;
}
function correlate3(series) {
const out = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
for (let i = 0; i < 3; i++) {
for (let j = i + 1; j < 3; j++) {
const a = series[i];
const b = series[j];
const ma = mean(a);
const mb = mean(b);
let num = 0;
let da = 0;
let db = 0;
for (let k = 0; k < a.length; k++) {
const x = a[k] - ma;
const y = b[k] - mb;
num += x * y;
da += x * x;
db += y * y;
}
const r = num / Math.sqrt(Math.max(1e-12, da * db));
out[i][j] = r;
out[j][i] = r;
}
}
return out;
}
+60
View File
@@ -0,0 +1,60 @@
// In-place iterative radix-2 FFT. Small on purpose: the analysis needs one
// magnitude spectrum per video frame and nothing else.
export class Fft {
constructor(size) {
if ((size & (size - 1)) !== 0) throw new Error('FFT size must be a power of two');
this.size = size;
this.levels = Math.log2(size) | 0;
this.cos = new Float32Array(size / 2);
this.sin = new Float32Array(size / 2);
for (let i = 0; i < size / 2; i++) {
this.cos[i] = Math.cos((2 * Math.PI * i) / size);
this.sin[i] = Math.sin((2 * Math.PI * i) / size);
}
this.rev = new Uint32Array(size);
for (let i = 0; i < size; i++) {
let r = 0;
for (let b = 0; b < this.levels; b++) r |= ((i >>> b) & 1) << (this.levels - 1 - b);
this.rev[i] = r;
}
}
/** Forward transform in place. `im` is zeroed on entry by the caller. */
forward(re, im) {
const n = this.size;
const rev = this.rev;
for (let i = 0; i < n; i++) {
const j = rev[i];
if (j > i) {
let t = re[i]; re[i] = re[j]; re[j] = t;
t = im[i]; im[i] = im[j]; im[j] = t;
}
}
for (let len = 2; len <= n; len <<= 1) {
const half = len >> 1;
const step = n / len;
for (let i = 0; i < n; i += len) {
for (let j = 0; j < half; j++) {
const k = j * step;
const c = this.cos[k];
const s = this.sin[k];
const a = i + j;
const b = a + half;
const xr = re[b] * c + im[b] * s;
const xi = im[b] * c - re[b] * s;
re[b] = re[a] - xr;
im[b] = im[a] - xi;
re[a] += xr;
im[a] += xi;
}
}
}
}
}
export function hann(size) {
const w = new Float32Array(size);
for (let i = 0; i < size; i++) w[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (size - 1));
return w;
}
+107
View File
@@ -0,0 +1,107 @@
// Exact WAV decode: 8/16/24/32-bit PCM and 32-bit float, any rate.
// Same samples in the browser and in node, so a strip rendered by a tool and
// a frame rendered by the demo agree.
const ascii = (bytes, o, n) => {
let s = '';
for (let i = 0; i < n; i++) s += String.fromCharCode(bytes[o + i]);
return s;
};
function readChunk(dv, bytes, o) {
return {
id: ascii(bytes, o, 4),
size: dv.getUint32(o + 4, true),
start: o + 8,
};
}
/**
* @param {ArrayBuffer|Uint8Array} input
* @returns {{sampleRate:number, channels:Float32Array[], bitDepth:number, format:string} | null}
*/
export function decodeWav(input) {
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input);
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (bytes.length < 44 || ascii(bytes, 0, 4) !== 'RIFF' || ascii(bytes, 8, 4) !== 'WAVE') return null;
let fmt = null;
let data = null;
let o = 12;
while (o + 8 <= bytes.length) {
const c = readChunk(dv, bytes, o);
if (c.id === 'fmt ') {
fmt = {
format: dv.getUint16(c.start, true),
channels: dv.getUint16(c.start + 2, true),
sampleRate: dv.getUint32(c.start + 4, true),
bits: dv.getUint16(c.start + 14, true),
};
} else if (c.id === 'data') {
data = { start: c.start, size: Math.min(c.size, bytes.length - c.start) };
break;
}
o = c.start + c.size + (c.size & 1);
}
if (!fmt || !data) return null;
const { format, channels, sampleRate, bits } = fmt;
const framed = Math.floor(data.size / ((bits / 8) * channels));
const out = Array.from({ length: channels }, () => new Float32Array(framed));
const full = 1 << (bits - 1);
if (format === 1 && bits === 16) {
for (let i = 0; i < framed; i++) {
for (let c = 0; c < channels; c++) {
out[c][i] = dv.getInt16(data.start + (i * channels + c) * 2, true) / 32768;
}
}
} else if (format === 1 && bits === 24) {
for (let i = 0; i < framed; i++) {
for (let c = 0; c < channels; c++) {
const p = data.start + (i * channels + c) * 3;
let v = bytes[p] | (bytes[p + 1] << 8) | (bytes[p + 2] << 16);
if (v & 0x800000) v |= -0x1000000;
out[c][i] = v / 8388608;
}
}
} else if (format === 1 && bits === 32) {
for (let i = 0; i < framed; i++) {
for (let c = 0; c < channels; c++) {
out[c][i] = dv.getInt32(data.start + (i * channels + c) * 4, true) / 2147483648;
}
}
} else if (format === 1 && bits === 8) {
for (let i = 0; i < framed; i++) {
for (let c = 0; c < channels; c++) {
out[c][i] = (bytes[data.start + i * channels + c] - 128) / 128;
}
}
} else if (format === 3 && bits === 32) {
for (let i = 0; i < framed; i++) {
for (let c = 0; c < channels; c++) {
out[c][i] = dv.getFloat32(data.start + (i * channels + c) * 4, true);
}
}
} else {
return null;
}
return { sampleRate, channels: out, bitDepth: bits, format: format === 3 ? 'float' : 'pcm' };
}
/** Average all channels. Mono is the contract for analysis. */
export function mono(channels) {
if (channels.length === 1) return channels[0];
const n = channels[0].length;
const out = new Float32Array(n);
for (let c = 0; c < channels.length; c++) {
const ch = channels[c];
for (let i = 0; i < n; i++) out[i] += ch[i];
}
const k = 1 / channels.length;
for (let i = 0; i < n; i++) out[i] *= k;
return out;
}
export { ascii };
+146
View File
@@ -0,0 +1,146 @@
// The camera is an observer with a motive: it cuts to where the world just
// changed, holds long enough to see what it changed into, and pushes in while
// it holds. It is not a rig on rails.
//
// Two rules make it read as cinematography rather than as a screensaver:
// - a cut is a hard change of subject, so shot sizes alternate (a close
// answered by a wide reads as two shots of one place);
// - the subject is chosen from recent ACTIVITY, not from the current state,
// because the interesting thing is the change, not the crowd.
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const smooth = (t) => t * t * (3 - 2 * t);
export class Camera {
constructor(song, world, { fps = 30, width = 640, height = 360 } = {}) {
this.song = song;
this.world = world;
this.fps = fps;
this.w = width;
this.h = height;
this.cx = 0.5;
this.cy = 0.5;
this.zoom = 1.35;
this.baseZoom = 1.35;
this.shot = 0;
this.shotStart = 0;
this.shotLen = 5;
this.nextCut = 0;
this.dir = 1;
this.prev = { x: 0.5, y: 0.5 };
this.target = { x: 0.5, y: 0.5 };
this.cues = new Set();
for (const o of song.analysis.onsets.all) {
if (o.strength > 0.72) this.cues.add(o.frame);
}
this.cut();
}
lawAt(frame) {
const t = frame / this.fps;
const laws = this.song.laws;
for (let i = laws.length - 1; i >= 0; i--) if (t >= laws[i].start) return laws[i];
return laws[0];
}
/** Where is the world actually doing something, weighted away from the last subject? */
pickFocus(zoom) {
const act = this.world.activity;
const n = 32;
const half = 0.5 / zoom;
const lo = half + 0.02;
const hi = 1 - half - 0.02;
let best = -1;
let bx = 0.5;
let by = 0.5;
let maxAct = 1e-9;
for (let i = 0; i < act.length; i++) maxAct = Math.max(maxAct, act[i]);
for (let y = 0; y < n; y++) {
for (let x = 0; x < n; x++) {
const wx = (x + 0.5) / n;
const wy = (y + 0.5) / n;
if (wx < lo || wx > hi || wy < lo || wy > hi) continue;
const a = act[y * n + x] / maxAct;
if (a < 0.08) continue;
const d = Math.hypot(wx - this.prev.x, wy - this.prev.y);
// Novel subject, real activity, not pinned to the frame edge.
const score = a * (0.35 + 0.65 * Math.min(1, d * 2.4));
if (score > best) {
best = score;
bx = wx;
by = wy;
}
}
}
return { x: bx, y: by, value: maxAct };
}
activityCoverage() {
const act = this.world.activity;
let max = 1e-9;
for (let i = 0; i < act.length; i++) max = Math.max(max, act[i]);
let n = 0;
for (let i = 0; i < act.length; i++) if (act[i] > max * 0.12) n++;
return n / act.length;
}
cut() {
const frame = Math.max(0, this.world.frame);
const law = this.lawAt(frame);
const energy = law.energy ?? 0.5;
// Shot size: a spread-out world gets a wider lens; a concentrated one
// gets a close-up. Every third shot widens deliberately, so the edit
// has a rhythm of sizes and not just a rhythm of cuts.
const coverage = this.activityCoverage();
let zoom = 1.35 + (1 - coverage) * 2.45;
if (this.shot % 3 === 2) zoom *= 0.74;
else if (this.shot % 3 === 1) zoom *= 1.16;
zoom = clamp(zoom, 1.25, 4.6);
const focus = this.pickFocus(zoom);
this.target = { x: focus.x, y: focus.y };
this.cx = focus.x;
this.cy = focus.y;
this.zoom = zoom;
this.baseZoom = zoom;
this.dir = this.shot % 2 === 0 ? 1 : -1;
this.shotLen = clamp((3.6 + 3.4 * (1 - energy)) * (law.kind === 'breakdown' ? 1.25 : 1), 2.4, 9);
this.shotStart = frame;
this.nextCut = frame + Math.round(this.shotLen * this.fps);
this.prev = { x: focus.x, y: focus.y };
this.shot++;
}
update(frame) {
const age = frame - this.shotStart;
const law = this.lawAt(frame);
const cue = this.cues.has(frame);
if (frame >= this.nextCut || (cue && age > this.fps * 2.0 && (law.kind === 'drop' || law.kind === 'build'))) {
this.cut();
}
const t = clamp((frame - this.shotStart) / Math.max(1, this.nextCut - this.shotStart), 0, 1);
const e = smooth(t);
// A slow drift across the subject, in frame fractions rather than world
// units: the push must feel the same at a wide shot and a close-up.
this.cx = this.target.x + (this.dir * 0.016 * e) / this.zoom;
this.cy = this.target.y - (this.dir * 0.008 * e) / this.zoom;
this.zoom = this.baseZoom * (1 + 0.07 * e);
this.clampToWorld();
}
clampToWorld() {
const half = 0.5 / this.zoom;
this.cx = clamp(this.cx, half, 1 - half);
this.cy = clamp(this.cy, half, 1 - half);
}
transform() {
return { cx: this.cx, cy: this.cy, zoom: this.zoom };
}
}
+57
View File
@@ -0,0 +1,57 @@
// One run: song in, world and frames out. The only place the three layers
// (audio, ecology, camera) are wired together, and the place that guarantees
// the order — step, then look — is the same for a strip and for a video.
import { World } from './sim/world.js';
import { Camera } from './camera.js';
import { Renderer } from './render/view.js';
export class Run {
constructor(song, { fps = 30, width = 640, height = 360, seed = song.seed } = {}) {
this.song = song;
this.fps = fps;
this.width = width;
this.height = height;
this.world = new World(song, { fps, seed });
this.camera = new Camera(song, this.world, { fps, width, height });
this.renderer = new Renderer(width, height);
}
/** Advance to a frame, updating the camera on every step. */
stepTo(frame) {
while (this.world.frame < frame) {
this.world.step();
this.camera.update(this.world.frame);
}
}
/** Simulate everything, render only the frames asked for. */
renderAt(frames) {
const sorted = [...frames].sort((a, b) => a - b);
const out = new Map();
for (const f of sorted) {
this.stepTo(f);
const bmp = this.renderer.render(this.world, this.song, this.camera, f);
// The renderer owns one framebuffer. A strip needs the pixels as
// they were, not a handle to the buffer's latest state.
out.set(f, {
w: bmp.w,
h: bmp.h,
data: new Uint8ClampedArray(bmp.data),
cam: this.camera.transform(),
shot: this.camera.shot,
});
}
return out;
}
/** Advance without rendering; used by metrics and by the report. */
simulate() {
const n = this.song.analysis.frames;
while (this.world.frame < n - 1) {
this.world.step();
this.camera.update(this.world.frame);
}
return this.world;
}
}
+242
View File
@@ -0,0 +1,242 @@
// System-level quality, not per-pixel quality.
//
// coupling — how much of the ecology's motion the song drives (correlation
// between sim activity and the track's energy/onset envelope).
// residual — how much of the ecology's motion the song does NOT explain
// (1 - R² of a linear regression on song features): the internal
// dynamics, the thing that makes it alive rather than a visualiser.
// fitness = coupling × residual — the best world is one that keeps time
// AND has a life of its own. A pure visualiser scores ~0;
// a pure random walk scores ~0. Both are easy to build. The
// middle is the point.
//
// divergence — a fingerprint of the world's grown structure (as distinct
// from the song's structure), used to check that two songs truly
// grow different worlds.
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
function mean(a) {
let s = 0;
for (let i = 0; i < a.length; i++) s += a[i];
return s / a.length;
}
function pearson(a, b) {
const n = Math.min(a.length, b.length);
const ma = mean(a);
const mb = mean(b);
let num = 0;
let da = 0;
let db = 0;
for (let i = 0; i < n; i++) {
const x = a[i] - ma;
const y = b[i] - mb;
num += x * y;
da += x * x;
db += y * y;
}
const d = Math.sqrt(Math.max(1e-12, da * db));
return num / d;
}
/** Ordinary least squares with a touch of ridge. X (n×k) -> y. */
function ols(X, y, ridge = 1e-6) {
const n = X.length;
const k = X[0].length;
const A = Array.from({ length: k }, () => new Array(k + 1).fill(0));
for (let i = 0; i < n; i++) {
for (let r = 0; r < k; r++) {
for (let c = 0; c < k; c++) A[r][c] += X[i][r] * X[i][c];
A[r][k] += X[i][r] * y[i];
}
}
for (let r = 0; r < k; r++) A[r][r] += ridge;
for (let col = 0; col < k; col++) {
let piv = col;
for (let r = col + 1; r < k; r++) {
if (Math.abs(A[r][col]) > Math.abs(A[piv][col])) piv = r;
}
if (Math.abs(A[piv][col]) < 1e-12) continue;
[A[col], A[piv]] = [A[piv], A[col]];
for (let r = col + 1; r < k; r++) {
const f = A[r][col] / A[col][col];
for (let c = col; c <= k; c++) A[r][c] -= f * A[col][c];
}
}
const beta = new Array(k).fill(0);
for (let r = k - 1; r >= 0; r--) {
let v = A[r][k];
for (let c = r + 1; c < k; c++) v -= A[r][c] * beta[c];
beta[r] = Math.abs(A[r][r]) < 1e-12 ? 0 : v / A[r][r];
}
return beta;
}
function rSquared(X, y, beta) {
const n = X.length;
let ssRes = 0;
let ssTot = 0;
const my = mean(y);
for (let i = 0; i < n; i++) {
let p = 0;
for (let c = 0; c < beta.length; c++) p += X[i][c] * beta[c];
ssRes += (y[i] - p) ** 2;
ssTot += (y[i] - my) ** 2;
}
if (ssTot <= 1e-12) return 1;
return Math.max(0, 1 - ssRes / ssTot);
}
/** Non-causal moving average over `w` frames. Preserves phase, kills jitter. */
function smoothSeries(values, w) {
const out = new Float32Array(values.length);
let sum = 0;
const half = Math.floor(w / 2);
for (let i = 0; i < values.length; i++) {
sum += values[i];
if (i - w >= 0) sum -= values[i - w];
out[i] = sum / Math.min(i + 1, w);
}
// Shift by half a window so the smoothed value sits at the centre.
const shifted = new Float32Array(values.length);
for (let i = 0; i < values.length; i++) {
shifted[i] = out[Math.max(0, Math.min(values.length - 1, i + half))];
}
return shifted;
}
/**
* Evaluate one world against its own song.
*
* The analysis table is 0..F at fps; world.history is aligned to the same
* frames. Movement and deposit are the sim's instantaneous "what is it doing";
* the song's energy + onset + flux are the driver set.
*/
export function evaluate(world, song) {
const a = song.analysis;
const one = (arr) => {
const max = Math.max(...arr);
return max > 1e-9 ? arr.map((v) => v / max) : arr;
};
const energy = one(Array.from(a.energy));
const flux = one(Array.from(a.flux));
// Onset pressure at each frame: 0..1 combining the three band onsets.
const onset = new Float32Array(a.frames);
for (const o of a.onsets.low) onset[o.frame] = Math.max(onset[o.frame], 0.5);
for (const o of a.onsets.mid) onset[o.frame] = Math.max(onset[o.frame], 0.35);
for (const o of a.onsets.high) onset[o.frame] = Math.max(onset[o.frame], 0.2);
// The sim's activity, also in [0..1]. Movement is scaled per-track so a
// busy ecosystem and a sparse one are compared fairly.
const mov = one(Array.from(world.history.movement));
const births = one(Array.from(world.history.births));
// A cue is a frame where something happened in the audio; the coupling is
// how often the ecology's motion edges up shortly after. Predict at a lag
// of 0..0.5s so an organism is allowed a reaction time.
const lag = 8; // frames at 30fps
let hit = 0;
let total = 0;
for (let f = lag; f < a.frames; f++) {
const cue = energy[f - lag] > 0.35 || onset[f - lag] > 0;
if (!cue) continue;
total++;
if (mov[f] > mov[f - 1] * 1.02 + 0.004) hit++;
}
const response = total > 0 ? hit / total : 0;
const X = Array.from({ length: a.frames }, (_, f) => [
energy[f],
onset[f],
flux[f],
]);
// Residual is what survives a regression of the sim's activity on ALL
// three song features: motion, plus the population curve — the slow,
// cumulative behaviour no envelope predicts directly.
const betaMove = ols(X, mov);
const betaAlive = ols(X, one(Array.from(world.history.alive)));
const r2Move = rSquared(X, mov, betaMove);
const r2Alive = rSquared(X, one(Array.from(world.history.alive)), betaAlive);
// Coupling: how much the ecology answers the song (cue response plus the
// long form, their correlation after smoothing over a beat).
const smoothMov = smoothSeries(mov, 30);
const smoothEnergy = smoothSeries(energy, 18);
const longForm = Math.max(0, pearson(smoothMov, smoothEnergy));
const coupling = clamp01(0.55 * response + 0.45 * longForm);
const residual = (1 - clamp01(r2Move)) * 0.6 + (1 - clamp01(r2Alive)) * 0.4;
const fitness = coupling * residual;
return {
coupling: Number(coupling.toFixed(3)),
residual: Number(residual.toFixed(3)),
fitness: Number(fitness.toFixed(3)),
response: Number(response.toFixed(3)),
longForm: Number(longForm.toFixed(3)),
r2Move: Number(clamp01(r2Move).toFixed(3)),
r2Alive: Number(clamp01(r2Alive).toFixed(3)),
};
}
/**
* A fingerprint of the world the song GROWED, not of the song itself:
* how occupied the plate is, how clustered the crowds are, how much of the
* ground is trail vs open space. Two songs should sit far apart on this.
*/
export function fingerprint(world) {
const F = world.field;
const g = 16;
const cell = F / g;
const agents = new Float32Array(g * g);
const trail = new Float32Array(g * g);
for (let i = 0; i < world.count; i++) {
const ax = Math.min(g - 1, (world.x[i] * g) | 0);
const ay = Math.min(g - 1, (world.y[i] * g) | 0);
agents[ay * g + ax]++;
}
for (let c = 0; c < 3; c++) {
const t = world.trail[c];
for (let gy = 0; gy < g; gy++) {
for (let gx = 0; gx < g; gx++) {
let s = 0;
for (let y = gy * cell; y < (gy + 1) * cell; y += 4) {
for (let x = gx * cell; x < (gx + 1) * cell; x += 4) s += t[y * F + x];
}
trail[gy * g + gx] += s * 16;
}
}
}
const n = g * g;
let occupancy = 0;
let cluster = 0;
let trailFill = 0;
let maxA = 0;
for (let i = 0; i < n; i++) {
if (agents[i] >= 3) occupancy++;
maxA = Math.max(maxA, agents[i]);
if (trail[i] > 3) trailFill++;
}
cluster = maxA > 30 ? 1 : maxA > 12 ? 0.6 : maxA > 5 ? 0.3 : 0;
const balance = [0, 0, 0];
for (let i = 0; i < world.count; i++) balance[world.cls[i]]++;
const total = Math.max(1, world.count);
return {
occupancy: Number((occupancy / n).toFixed(3)),
cluster: Number(cluster.toFixed(2)),
trailFill: Number((trailFill / n).toFixed(3)),
classMix: balance.map((v) => Number((v / total).toFixed(2))),
};
}
/** L1 distance between two fingerprints. */
export function divergence(a, b) {
let d = Math.abs(a.occupancy - b.occupancy) +
Math.abs(a.cluster - b.cluster) +
Math.abs(a.trailFill - b.trailFill);
for (let i = 0; i < 3; i++) d += Math.abs((a.classMix?.[i] ?? 0) - (b.classMix?.[i] ?? 0));
return Number(d.toFixed(3));
}
+277
View File
@@ -0,0 +1,277 @@
// A tiny software framebuffer. The renderer is deliberately not a canvas:
// the same pixels come out in node (for strips and tests) and in the browser
// (blitted through ImageData), so preview and export cannot drift.
const clamp255 = (v) => (v < 0 ? 0 : v > 255 ? 255 : v);
export class Bitmap {
constructor(width, height) {
this.w = width;
this.h = height;
this.data = new Uint8ClampedArray(width * height * 4);
this.rowX = new Float32Array(width);
this.rowY = new Float32Array(height);
}
clear(r, g, b) {
const R = Math.round(r * 255);
const G = Math.round(g * 255);
const B = Math.round(b * 255);
const d = this.data;
for (let i = 0; i < d.length; i += 4) {
d[i] = R;
d[i + 1] = G;
d[i + 2] = B;
d[i + 3] = 255;
}
}
/** Additive single pixel, alpha in 0..1. */
add(x, y, r, g, b, a = 1) {
const ix = x | 0;
const iy = y | 0;
if (ix < 0 || iy < 0 || ix >= this.w || iy >= this.h) return;
const i = (iy * this.w + ix) * 4;
const d = this.data;
d[i] = clamp255(d[i] + r * 255 * a);
d[i + 1] = clamp255(d[i + 1] + g * 255 * a);
d[i + 2] = clamp255(d[i + 2] + b * 255 * a);
}
/** Additive antialiased disc. Used for agents; radius is in pixels. */
disc(cx, cy, radius, r, g, b, intensity = 1) {
const rad = Math.max(0.45, radius);
const x0 = Math.max(0, Math.floor(cx - rad - 1));
const x1 = Math.min(this.w - 1, Math.ceil(cx + rad + 1));
const y0 = Math.max(0, Math.floor(cy - rad - 1));
const y1 = Math.min(this.h - 1, Math.ceil(cy + rad + 1));
const d = this.data;
for (let y = y0; y <= y1; y++) {
const dy = y - cy;
for (let x = x0; x <= x1; x++) {
const dx = x - cx;
const dist = Math.sqrt(dx * dx + dy * dy);
let a = rad - dist;
if (a <= 0) continue;
a = Math.min(1, a) * intensity;
// A brighter core: the disc reads as a body, not a smear.
const core = dist < rad * 0.45 ? 1.6 : 1;
const i = (y * this.w + x) * 4;
d[i] = clamp255(d[i] + r * 255 * a * core);
d[i + 1] = clamp255(d[i + 1] + g * 255 * a * core);
d[i + 2] = clamp255(d[i + 2] + b * 255 * a * core);
}
}
}
/** Additive line, for motion streaks. */
line(x0, y0, x1, y1, r, g, b, intensity = 1) {
const dx = x1 - x0;
const dy = y1 - y0;
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy))));
const d = this.data;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
const x = (x0 + dx * t) | 0;
const y = (y0 + dy * t) | 0;
if (x < 0 || y < 0 || x >= this.w || y >= this.h) continue;
// Taper: the tail is history, the head is the body.
const a = intensity * (0.35 + 0.65 * t);
const o = (y * this.w + x) * 4;
d[o] = clamp255(d[o] + r * 255 * a);
d[o + 1] = clamp255(d[o + 1] + g * 255 * a);
d[o + 2] = clamp255(d[o + 2] + b * 255 * a);
}
}
/** Additive antialiased ring: an event landing on the ground. */
ring(cx, cy, radius, thickness, r, g, b, intensity = 1) {
const outer = radius + thickness + 1;
const x0 = Math.max(0, Math.floor(cx - outer));
const x1 = Math.min(this.w - 1, Math.ceil(cx + outer));
const y0 = Math.max(0, Math.floor(cy - outer));
const y1 = Math.min(this.h - 1, Math.ceil(cy + outer));
const d = this.data;
const inner = Math.max(0.01, radius);
for (let y = y0; y <= y1; y++) {
const dy = y - cy;
for (let x = x0; x <= x1; x++) {
const dx = x - cx;
const dist = Math.sqrt(dx * dx + dy * dy);
const edge = Math.abs(dist - inner);
if (edge > thickness) continue;
const a = (1 - edge / thickness) * intensity;
const i = (y * this.w + x) * 4;
d[i] = clamp255(d[i] + r * 255 * a);
d[i + 1] = clamp255(d[i + 1] + g * 255 * a);
d[i + 2] = clamp255(d[i + 2] + b * 255 * a);
}
}
}
/**
* Draw a square RGBA source (the trail field) through the camera.
* Bilinear, because a 128-cell ground at 640 px would otherwise read as
* tiles; the source is already blurred, so this is a resample, not detail.
*/
blitWorld(src, sw, sh, cx, cy, zoom, gain = 1) {
const W = this.w;
const H = this.h;
const scale = W * zoom;
const d = this.data;
const rowX = this.rowX;
const rowY = this.rowY;
for (let x = 0; x < W; x++) rowX[x] = (cx * scale) - W / 2 + x;
for (let y = 0; y < H; y++) rowY[y] = (cy * scale) - H / 2 + y;
for (let y = 0; y < H; y++) {
const sy = rowY[y] / scale; // 0..1
if (sy < 0 || sy > 1) continue;
const gy = Math.min(sh - 1.001, Math.max(0, sy * (sh - 1)));
const y0 = gy | 0;
const ty = gy - y0;
const r0 = y0 * sh;
const r1 = (y0 + 1) * sh;
for (let x = 0; x < W; x++) {
const sx = rowX[x] / scale;
if (sx < 0 || sx > 1) continue;
const gx = Math.min(sw - 1.001, Math.max(0, sx * (sw - 1)));
const x0 = gx | 0;
const tx = gx - x0;
const i00 = (r0 + x0) * 4;
const i10 = (r0 + x0 + 1) * 4;
const i01 = (r1 + x0) * 4;
const i11 = (r1 + x0 + 1) * 4;
const w00 = (1 - tx) * (1 - ty);
const w10 = tx * (1 - ty);
const w01 = (1 - tx) * ty;
const w11 = tx * ty;
const o = (y * W + x) * 4;
d[o] = clamp255(d[o] + gain * (
src[i00] * w00 + src[i10] * w10 + src[i01] * w01 + src[i11] * w11));
d[o + 1] = clamp255(d[o + 1] + gain * (
src[i00 + 1] * w00 + src[i10 + 1] * w10 + src[i01 + 1] * w01 + src[i11 + 1] * w11));
d[o + 2] = clamp255(d[o + 2] + gain * (
src[i00 + 2] * w00 + src[i10 + 2] * w10 + src[i01 + 2] * w01 + src[i11 + 2] * w11));
}
}
}
/** Multiply by a radial falloff. Keeps the eye on the middle. */
vignette(strength = 0.55, tint = [0, 0, 0]) {
const W = this.w;
const H = this.h;
const d = this.data;
const cx = W / 2;
const cy = H / 2;
const maxR = Math.hypot(cx, cy);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const r = Math.hypot(x - cx, y - cy) / maxR;
const k = 1 - strength * r * r;
const i = (y * W + x) * 4;
d[i] = clamp255(d[i] * k + tint[0] * 255 * (1 - k));
d[i + 1] = clamp255(d[i + 1] * k + tint[1] * 255 * (1 - k));
d[i + 2] = clamp255(d[i + 2] * k + tint[2] * 255 * (1 - k));
}
}
}
/** Deterministic grain. A still frame and the same frame in motion agree. */
grain(amount = 3, seed = 1) {
let s = seed >>> 0;
const d = this.data;
for (let i = 0; i < d.length; i += 4) {
s = (Math.imul(s ^ (s >>> 15), 2246822519) + 0x9e3779b9) >>> 0;
const n = ((s >>> 24) - 128) * (amount / 128);
d[i] = clamp255(d[i] + n);
d[i + 1] = clamp255(d[i + 1] + n);
d[i + 2] = clamp255(d[i + 2] + n);
}
}
toImageData() {
return new ImageData(new Uint8ClampedArray(this.data), this.w, this.h);
}
}
/**
* The ground: three deposit grids summed through the palette into one RGBA
* image at field resolution, then blurred. Two blurs, one tight and one wide:
* the tight one keeps an edge, the wide one is the glow.
*/
export function composeTrail(world, palette, exposure = 2.2) {
const F = world.field;
const n = F * F;
const sharp = new Float32Array(n * 3);
const t0 = world.trail[0];
const t1 = world.trail[1];
const t2 = world.trail[2];
const c0 = palette.trails[0];
const c1 = palette.trails[1];
const c2 = palette.trails[2];
for (let i = 0; i < n; i++) {
const a = t0[i];
const b = t1[i];
const c = t2[i];
sharp[i * 3] = a * c0[0] + b * c1[0] + c * c2[0];
sharp[i * 3 + 1] = a * c0[1] + b * c1[1] + c * c2[1];
sharp[i * 3 + 2] = a * c0[2] + b * c1[2] + c * c2[2];
}
const wide = boxBlur(sharp, F, 2);
const tight = boxBlur(sharp, F, 1);
const out = new Uint8ClampedArray(n * 4);
for (let i = 0; i < n; i++) {
for (let c = 0; c < 3; c++) {
const v = (tight[i * 3 + c] * 0.55 + wide[i * 3 + c] * 0.65) * exposure;
// Soft saturation: bright regions keep their hue instead of clipping
// to white, which is what a physical glow does.
out[i * 4 + c] = Math.round(255 * (1 - Math.exp(-v)));
}
out[i * 4 + 3] = 255;
}
return out;
}
function boxBlur(src, F, radius) {
const tmp = new Float32Array(src.length);
const out = new Float32Array(src.length);
const k = 1 / (radius * 2 + 1);
for (let y = 0; y < F; y++) {
for (let x = 0; x < F; x++) {
let r = 0;
let g = 0;
let b = 0;
for (let d = -radius; d <= radius; d++) {
const xx = Math.min(F - 1, Math.max(0, x + d));
const i = (y * F + xx) * 3;
r += src[i];
g += src[i + 1];
b += src[i + 2];
}
const o = (y * F + x) * 3;
tmp[o] = r * k;
tmp[o + 1] = g * k;
tmp[o + 2] = b * k;
}
}
for (let y = 0; y < F; y++) {
for (let x = 0; x < F; x++) {
let r = 0;
let g = 0;
let b = 0;
for (let d = -radius; d <= radius; d++) {
const yy = Math.min(F - 1, Math.max(0, y + d));
const i = (yy * F + x) * 3;
r += tmp[i];
g += tmp[i + 1];
b += tmp[i + 2];
}
const o = (y * F + x) * 3;
out[o] = r * k;
out[o + 1] = g * k;
out[o + 2] = b * k;
}
}
return out;
}
+87
View File
@@ -0,0 +1,87 @@
// Minimal PNG writer (8-bit RGBA, no interlace). Node only: the browser demo
// blits the same framebuffer through ImageData and never touches this file.
import zlib from 'node:zlib';
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body), 0);
return Buffer.concat([len, body, crc]);
}
/**
* @param {number} width
* @param {number} height
* @param {Uint8ClampedArray|Uint8Array} rgba
* @returns {Buffer}
*/
export function encodePNG(width, height, rgba) {
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (stride + 1)] = 0;
raw.set(rgba.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8;
ihdr[9] = 6;
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', zlib.deflateSync(raw, { level: 6 })),
chunk('IEND', Buffer.alloc(0)),
]);
}
/** Tile several bitmaps into one grid. Bitmaps are {w, h, data}. */
export function tile(bitmaps, cols, rows, gap = 0) {
if (!bitmaps.length) throw new Error('tile: nothing to tile');
const w = bitmaps[0].w;
const h = bitmaps[0].h;
const W = cols * w + (cols - 1) * gap;
const H = rows * h + (rows - 1) * gap;
const out = new Uint8ClampedArray(W * H * 4);
for (let i = 0; i < out.length; i += 4) {
out[i] = 8;
out[i + 1] = 8;
out[i + 2] = 10;
out[i + 3] = 255;
}
for (let i = 0; i < bitmaps.length; i++) {
const b = bitmaps[i];
const cx = i % cols;
const cy = (i / cols) | 0;
const ox = cx * (w + gap);
const oy = cy * (h + gap);
for (let y = 0; y < h; y++) {
const src = y * b.w * 4;
const dst = ((oy + y) * W + ox) * 4;
out.set(b.data.subarray(src, src + b.w * 4), dst);
}
}
return { w: W, h: H, data: out };
}
+175
View File
@@ -0,0 +1,175 @@
// Compose one frame. Ground first (history), then the agents (the present),
// then the lens (vignette and grain). The field is drawn faintly under the
// trail so the audience can see WHY the agents are where they are.
import { Bitmap } from './bitmap.js';
export class Renderer {
constructor(width, height, { exposure = 2.6, grain = 2.4, vignette = 0.55 } = {}) {
this.w = width;
this.h = height;
this.exposure = exposure;
this.grainAmount = grain;
this.vignetteAmount = vignette;
this.bitmap = new Bitmap(width, height);
this.F = 0;
this.trailRGBA = null;
this.sharp = null;
this.tight = null;
this.wide = null;
}
allocate(F) {
this.F = F;
const n = F * F;
this.trailRGBA = new Uint8ClampedArray(n * 4);
this.sharp = new Float32Array(n * 3);
this.tmp = new Float32Array(n * 3);
this.tight = new Float32Array(n * 3);
this.wide = new Float32Array(n * 3);
}
trail(world, palette) {
const F = world.field;
if (this.F !== F) this.allocate(F);
const n = F * F;
const sharp = this.sharp;
const t0 = world.trail[0];
const t1 = world.trail[1];
const t2 = world.trail[2];
const c0 = palette.trails[0];
const c1 = palette.trails[1];
const c2 = palette.trails[2];
const fog = palette.fog;
const res = world.resource;
for (let i = 0; i < n; i++) {
const a = t0[i];
const b = t1[i];
const c = t2[i];
const f = res[i] * 0.09;
sharp[i * 3] = a * c0[0] + b * c1[0] + c * c2[0] + f * fog[0];
sharp[i * 3 + 1] = a * c0[1] + b * c1[1] + c * c2[1] + f * fog[1];
sharp[i * 3 + 2] = a * c0[2] + b * c1[2] + c * c2[2] + f * fog[2];
}
boxBlur(sharp, this.tmp, this.tight, F, 1);
boxBlur(sharp, this.tmp, this.wide, F, 3);
const out = this.trailRGBA;
for (let i = 0; i < n; i++) {
for (let c = 0; c < 3; c++) {
// Sharp for the line, a one-cell blur to keep it from aliasing,
// and a little of the wide blur so a dense knot glows. The wide
// blur must stay small or the history turns into weather.
const v = (sharp[i * 3 + c] * 0.55 +
this.tight[i * 3 + c] * 0.80 +
this.wide[i * 3 + c] * 0.04) * this.exposure;
out[i * 4 + c] = Math.round(255 * (1 - Math.exp(-v)));
}
out[i * 4 + 3] = 255;
}
return out;
}
render(world, song, camera, frame) {
const bmp = this.bitmap;
const palette = song.palette;
const cam = camera.transform();
const scale = this.w * cam.zoom;
const halfW = this.w / 2;
const halfH = this.h / 2;
bmp.clear(palette.bg[0], palette.bg[1], palette.bg[2]);
const trail = this.trail(world, palette);
bmp.blitWorld(trail, this.F, this.F, cam.cx, cam.cy, cam.zoom, 1);
// The ring of a hit. This is the clearest causal link in the film: the
// audience hears a transient and sees the ground answer it.
const fps = song.analysis.fps;
for (const p of world.pulses) {
const age = (frame - p.frame) / fps;
if (age < 0 || age > 0.95) continue;
const sx = (p.x - cam.cx) * scale + halfW;
const sy = (p.y - cam.cy) * scale + halfH;
const worldR = 0.012 + age * 0.085 * (0.7 + 0.45 * p.strength);
const rad = worldR * scale;
if (sx + rad < 0 || sy + rad < 0 || sx - rad > this.w || sy - rad > this.h) continue;
const a = Math.pow(1 - age / 0.95, 2.2) * (0.10 + 0.26 * p.strength);
const col = palette.classes[p.voice];
bmp.ring(sx, sy, rad, Math.max(0.75, 0.0022 * scale), col[0], col[1], col[2], a);
}
// Agents. Radius is a property of the body in world units, so a close
// shot actually gets closer to a creature instead of showing the same
// dot larger. The halo is what makes a body read against its own trail.
for (let i = 0; i < world.count; i++) {
const x = world.x[i];
const y = world.y[i];
const sx = (x - cam.cx) * scale + halfW;
const sy = (y - cam.cy) * scale + halfH;
const margin = 12;
if (sx < -margin || sy < -margin || sx > this.w + margin || sy > this.h + margin) continue;
const cls = world.cls[i];
const col = palette.classes[cls];
const e = world.energy[i];
const rad = Math.max(1.0, song.genome.classes[cls].size * 0.00068 * scale);
// A velocity streak makes direction legible in a still frame and
// separates the castes: commuters become dashes, builders dots.
const vx = world.vx[i];
const vy = world.vy[i];
if (vx * vx + vy * vy > 1e-10) {
bmp.line(
sx - vx * 0.34 * scale,
sy - vy * 0.34 * scale,
sx,
sy,
col[0], col[1], col[2],
0.10 + 0.13 * Math.min(1, e)
);
}
bmp.disc(sx, sy, rad, col[0], col[1], col[2], 0.60 + 0.30 * Math.min(1, e));
}
bmp.vignette(this.vignetteAmount, [palette.bg[0] * 0.6, palette.bg[1] * 0.6, palette.bg[2] * 0.6]);
bmp.grain(this.grainAmount, frame * 2654435761 + song.seed);
return bmp;
}
}
function boxBlur(src, tmp, out, F, radius) {
const k = 1 / (radius * 2 + 1);
for (let y = 0; y < F; y++) {
for (let x = 0; x < F; x++) {
let r = 0;
let g = 0;
let b = 0;
for (let d = -radius; d <= radius; d++) {
const xx = Math.min(F - 1, Math.max(0, x + d));
const i = (y * F + xx) * 3;
r += src[i];
g += src[i + 1];
b += src[i + 2];
}
const o = (y * F + x) * 3;
tmp[o] = r * k;
tmp[o + 1] = g * k;
tmp[o + 2] = b * k;
}
}
for (let y = 0; y < F; y++) {
for (let x = 0; x < F; x++) {
let r = 0;
let g = 0;
let b = 0;
for (let d = -radius; d <= radius; d++) {
const yy = Math.min(F - 1, Math.max(0, y + d));
const i = (yy * F + x) * 3;
r += tmp[i];
g += tmp[i + 1];
b += tmp[i + 2];
}
const o = (y * F + x) * 3;
out[o] = r * k;
out[o + 1] = g * k;
out[o + 2] = b * k;
}
}
}
+81
View File
@@ -0,0 +1,81 @@
// Seeded PRNG. Nothing in metabolism may call Math.random or read a clock:
// the same song, seed and frame must always produce the same pixel.
export class Rng {
constructor(seed = 1) {
this.seed = seed >>> 0;
this.state = this.seed;
}
reset(seed = this.seed) {
this.seed = seed >>> 0;
this.state = this.seed;
return this;
}
/** Uniform [0, 1). */
next() {
let t = (this.state += 0x6d2b79f5) >>> 0;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
range(min, max) {
return min + this.next() * (max - min);
}
int(min, max) {
return Math.floor(this.range(min, max + 1));
}
bool(p = 0.5) {
return this.next() < p;
}
pick(array) {
return array[this.int(0, array.length - 1)];
}
/** Gaussian, mean 0 sd 1. Box-Muller; the mode is where most tracks live. */
gauss() {
const u1 = Math.max(this.next(), 1e-7);
const u2 = this.next();
return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}
/** Symmetric around 0, mostly inside [-1, 1]. */
signed() {
return Math.max(-1, Math.min(1, this.gauss() * 0.6));
}
fork(label) {
return new Rng(hashString(String(label), this.seed));
}
}
/** FNV-1a. Derives a stable seed from a name. */
export function hashString(str, salt = 0x811c9dc5) {
let h = salt >>> 0;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}
/**
* FNV-1a over PCM. The seed is a property of the audio content, so the same
* file always grows the same world regardless of its name.
*/
export function hashSamples(float32, stride = 997) {
let h = 0x811c9dc5 >>> 0;
for (let i = 0; i < float32.length; i += stride) {
const q = Math.round(float32[i] * 32767) & 0xffff;
h ^= q & 0xff;
h = Math.imul(h, 0x01000193) >>> 0;
h ^= (q >>> 8) & 0xff;
h = Math.imul(h, 0x01000193) >>> 0;
}
return h >>> 0;
}
+107
View File
@@ -0,0 +1,107 @@
// Fertile ground.
//
// The uniform ambient floor made every frame a fog: food everywhere is the same
// as geography nowhere. Wells fix that. They are placed by the track's own
// harmony — one well per strong pitch class, on a ring — so the chord is the
// map. The mix decides which wells are rich: bass-heavy tracks feed low
// classes, bright ones feed the upper ring.
//
// The wells are permanent. That is the point: a place the audience can learn,
// return to, and see change. Events still land food elsewhere on the song's
// grid; the wells are the slow layer underneath.
import { TAU } from '../song/palette.js';
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
/** Circular distance between pitch classes, in semitones. */
function pcDistance(a, b) {
const d = Math.abs(a - b) % 12;
return Math.min(d, 12 - d);
}
function pickPitchClasses(chroma) {
const order = Array.from({ length: 12 }, (_, i) => i)
.sort((a, b) => (chroma[b] ?? 0) - (chroma[a] ?? 0) || a - b);
const picked = [];
for (const pc of order) {
if (picked.every((p) => pcDistance(p, pc) >= 2)) picked.push(pc);
if (picked.length >= 5) break;
}
// A two-note track still needs a world with more than two rooms. Fill the
// largest gaps with quiet wells so composition never collapses to a line.
while (picked.length < 4) {
const sorted = [...picked].sort((a, b) => a - b);
let gapAt = 0;
let gap = -1;
for (let i = 0; i < sorted.length; i++) {
const a = sorted[i];
const b = sorted[(i + 1) % sorted.length];
const d = ((b - a + 12) % 12) || 12;
if (d > gap) {
gap = d;
gapAt = a + Math.floor(d / 2);
}
}
picked.push(((gapAt % 12) + 12) % 12);
}
return picked.slice(0, 5);
}
/**
* @param {object} summary analysis.summary
* @param {number} field grid size
* @returns {Array<{x:number,y:number,r:number,rate:number,pc:number,idx:Int32Array,w:Float32Array}>}
*/
export function buildWells(summary, field) {
const chroma = summary.chroma || new Array(12).fill(0.5);
const pcs = pickPitchClasses(chroma);
const balance = summary.bandBalance || [0.33, 0.33, 0.33];
return pcs.map((pc, i) => {
const value = clamp(chroma[pc] ?? 0.4, 0.05, 1);
const angle = (pc / 12) * TAU + 0.10 * Math.sin(i * 2.3);
// Two rings so the world is not a necklace: alternate radii.
const ring = i % 2 === 0 ? 0.28 : 0.36;
const radius = ring + 0.03 * Math.sin(i * 1.7);
const sigma = 0.050 + 0.040 * value;
const rate = (0.38 + 1.15 * value) * (0.7 + 0.6 * balance[i % 3]);
const cells = buildKernel(field, 0.5 + Math.cos(angle) * radius, 0.5 + Math.sin(angle) * radius, sigma, field);
return {
pc,
x: 0.5 + Math.cos(angle) * radius,
y: 0.5 + Math.sin(angle) * radius,
r: sigma,
rate,
idx: cells.idx,
w: cells.w,
};
});
}
function buildKernel(F, x, y, sigma, scale) {
const radiusPx = Math.ceil(sigma * scale * 2.6);
const cx = Math.round(x * (F - 1));
const cy = Math.round(y * (F - 1));
const sig = Math.max(1.2, sigma * F);
const denom = 1 / (2 * sig * sig);
const idx = [];
const w = [];
let total = 0;
for (let dy = -radiusPx; dy <= radiusPx; dy++) {
const py = cy + dy;
if (py < 0 || py >= F) continue;
for (let dx = -radiusPx; dx <= radiusPx; dx++) {
const px = cx + dx;
if (px < 0 || px >= F) continue;
const g = Math.exp(-(dx * dx + dy * dy) * denom);
if (g < 0.0125) continue;
idx.push(py * F + px);
w.push(g);
total += g;
}
}
const k = total > 0 ? 1 / total : 0;
return { idx: Int32Array.from(idx), w: Float32Array.from(w, (v) => v * k) };
}
+632
View File
@@ -0,0 +1,632 @@
// The ecology. Agents on a resource field, with the song's species and the
// song's laws. No player, no realtime budget, no rendering: a step function.
//
// Three invariants are load-bearing and every design choice here serves them:
//
// PERSISTENCE agents live for seconds, not frames, and the trail grid keeps
// what they did for tens of seconds. You can follow something.
// CAUSALITY food arrives where and when the track put it. A bloom has an
// audible cause; a migration has a visible one.
// CONSEQUENCE the field is depleted, the trail accumulates. The state at t
// is made of the state at t-1, not re-rolled each frame.
import { Rng } from '../rng.js';
import { buildWells } from './wells.js';
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
const TAU = Math.PI * 2;
const H = 1.6; // field samples are one and a half cells apart
export const FIELD = 128;
export class World {
/**
* @param {ReturnType<import('../song/song.js').buildSong>} song
*/
constructor(song, { fps = 30, field = FIELD, seed = song.seed } = {}) {
this.song = song;
this.fps = fps;
this.dt = 1 / fps;
this.field = field;
this.genome = song.genome;
this.palette = song.palette;
this.rng = new Rng(seed >>> 0).fork('world');
this.resource = new Float32Array(field * field);
this.wells = buildWells(song.summary, field);
// The dish is delivered already furnished: colonies exist at t=0 and
// the first bars are life forming, not a blank plate waiting for food.
for (const well of this.wells) {
const add = well.rate * 7;
const idx = well.idx;
const w = well.w;
for (let i = 0; i < idx.length; i++) {
this.resource[idx[i]] = Math.min(1.6, add * w[i]);
}
}
this.trail = [
new Float32Array(field * field),
new Float32Array(field * field),
new Float32Array(field * field),
];
this.activity = new Float32Array(32 * 32);
this.cap = this.genome.maxAgents;
this.x = new Float32Array(this.cap);
this.y = new Float32Array(this.cap);
this.vx = new Float32Array(this.cap);
this.vy = new Float32Array(this.cap);
this.energy = new Float32Array(this.cap);
this.age = new Float32Array(this.cap);
this.cls = new Uint8Array(this.cap);
this.wellOf = new Uint8Array(this.cap);
this.pulses = [];
this.count = 0;
this.newborn = new Float32Array(this.cap * 7);
this.newbornCount = 0;
// Neighbour grid.
this.gridN = 22;
this.cellCount = new Int32Array(this.gridN * this.gridN + 1);
this.cellStart = new Int32Array(this.gridN * this.gridN + 1);
this.cellItems = new Int32Array(this.cap);
this.history = {
alive: new Uint16Array(song.analysis.frames),
movement: new Float32Array(song.analysis.frames),
food: new Float32Array(song.analysis.frames),
births: new Uint16Array(song.analysis.frames),
deaths: new Uint16Array(song.analysis.frames),
};
this.frame = -1;
this.birthsThisFrame = 0;
this.deathsThisFrame = 0;
this.seedInitial();
}
/** The law for a frame. Section boundaries are the only place laws change. */
lawForFrame(frame) {
const laws = this.song.laws;
const t = frame / this.fps;
for (let i = laws.length - 1; i >= 0; i--) {
if (t >= laws[i].start) return laws[i];
}
return laws[0];
}
seedInitial() {
const n = this.genome.seedAgents;
const wells = this.wells;
for (let i = 0; i < n && this.count + this.newbornCount < this.cap; i++) {
// Most life starts at a well; a few are already in transit, so the
// first frame is a world with roads, not a row of dots.
let x = 0.5;
let y = 0.5;
if (i % 5 !== 4 && wells.length) {
const w = wells[i % wells.length];
x = w.x + this.rng.gauss() * w.r;
y = w.y + this.rng.gauss() * w.r;
} else {
const r = Math.sqrt(this.rng.next()) * 0.44;
const a = this.rng.next() * TAU;
x = 0.5 + Math.cos(a) * r;
y = 0.5 + Math.sin(a) * r;
}
this.queueAgent(
Math.max(0.02, Math.min(0.98, x)),
Math.max(0.02, Math.min(0.98, y)),
this.pickClass(),
0.52 + 0.24 * this.rng.next()
);
}
this.flushNewborns();
this.birthsThisFrame = 0;
this.deathsThisFrame = 0;
}
nearestWell(x, y) {
let best = 0;
let bd = Infinity;
for (let i = 0; i < this.wells.length; i++) {
const w = this.wells[i];
const d = (w.x - x) ** 2 + (w.y - y) ** 2;
if (d < bd) {
bd = d;
best = i;
}
}
return best;
}
pickClass() {
const w = this.genome.classes.map((c) => c.seed);
let total = 0;
for (const v of w) total += v;
let r = this.rng.next() * total;
for (let i = 0; i < w.length; i++) {
r -= w[i];
if (r <= 0) return i;
}
return 0;
}
queueAgent(x, y, cls, energy) {
if (this.count + this.newbornCount >= this.cap) return false;
const p = this.newbornCount * 7;
this.newborn[p] = x;
this.newborn[p + 1] = y;
this.newborn[p + 2] = 0;
this.newborn[p + 3] = 0;
this.newborn[p + 4] = energy;
this.newborn[p + 5] = 0;
this.newborn[p + 6] = cls;
this.newbornCount++;
return true;
}
flushNewborns() {
for (let k = 0; k < this.newbornCount; k++) {
if (this.count >= this.cap) break;
const p = k * 7;
const i = this.count++;
this.x[i] = this.newborn[p];
this.y[i] = this.newborn[p + 1];
this.vx[i] = this.newborn[p + 2];
this.vy[i] = this.newborn[p + 3];
this.energy[i] = this.newborn[p + 4];
this.age[i] = this.newborn[p + 5];
this.cls[i] = this.newborn[p + 6];
this.wellOf[i] = this.nearestWell(this.x[i], this.y[i]);
}
this.newbornCount = 0;
}
// ---- field -------------------------------------------------------------
sample(fx, fy) {
const F = this.field;
const gx = Math.max(0, Math.min(F - 1.001, fx * (F - 1)));
const gy = Math.max(0, Math.min(F - 1.001, fy * (F - 1)));
const x0 = gx | 0;
const y0 = gy | 0;
const tx = gx - x0;
const ty = gy - y0;
const i = y0 * F + x0;
const a = this.resource[i];
const b = this.resource[i + 1];
const c = this.resource[i + F];
const d = this.resource[i + F + 1];
return (a + (b - a) * tx) * (1 - ty) + (c + (d - c) * tx) * ty;
}
/** Consume from the four cells under (fx, fy). Returns what was eaten. */
eat(fx, fy, amount) {
const F = this.field;
const gx = Math.max(0, Math.min(F - 1.001, fx * (F - 1)));
const gy = Math.max(0, Math.min(F - 1.001, fy * (F - 1)));
const x0 = gx | 0;
const y0 = gy | 0;
const tx = gx - x0;
const ty = gy - y0;
const res = this.resource;
const i0 = y0 * F + x0;
const i1 = i0 + 1;
const i2 = i0 + F;
const i3 = i2 + 1;
const w0 = (1 - tx) * (1 - ty);
const w1 = tx * (1 - ty);
const w2 = (1 - tx) * ty;
const w3 = tx * ty;
let eaten = 0;
let take = Math.min(res[i0], amount * w0);
res[i0] -= take; eaten += take;
take = Math.min(res[i1], amount * w1);
res[i1] -= take; eaten += take;
take = Math.min(res[i2], amount * w2);
res[i2] -= take; eaten += take;
take = Math.min(res[i3], amount * w3);
res[i3] -= take; eaten += take;
return eaten;
}
depositTrail(cls, x, y, amount) {
const F = this.field;
const ix = Math.max(0, Math.min(F - 1, (x * F) | 0));
const iy = Math.max(0, Math.min(F - 1, (y * F) | 0));
this.trail[cls][iy * F + ix] += amount;
const ax = Math.max(0, Math.min(31, (x * 32) | 0));
const ay = Math.max(0, Math.min(31, (y * 32) | 0));
this.activity[ay * 32 + ax] += amount * 3;
}
splat(x, y, spread, amount) {
const F = this.field;
const sigma = Math.max(0.8, spread * F);
const r = Math.ceil(sigma * 2.4);
const cx = Math.round(x * (F - 1));
const cy = Math.round(y * (F - 1));
const denom = 1 / (2 * sigma * sigma);
let total = 0;
const idx = [];
const w = [];
for (let dy = -r; dy <= r; dy++) {
const py = cy + dy;
if (py < 0 || py >= F) continue;
for (let dx = -r; dx <= r; dx++) {
const px = cx + dx;
if (px < 0 || px >= F) continue;
const k = Math.exp(-(dx * dx + dy * dy) * denom);
if (k < 0.01) continue;
idx.push(py * F + px);
w.push(k);
total += k;
}
}
if (total <= 0) return;
const k = amount / total;
for (let i = 0; i < idx.length; i++) this.resource[idx[i]] += w[i] * k;
}
// ---- neighbour grid ----------------------------------------------------
buildGrid() {
const n = this.gridN;
const counts = this.cellCount;
counts.fill(0);
for (let i = 0; i < this.count; i++) {
const cx = Math.min(n - 1, Math.max(0, (this.x[i] * n) | 0));
const cy = Math.min(n - 1, Math.max(0, (this.y[i] * n) | 0));
counts[cy * n + cx + 1]++;
}
for (let i = 1; i <= n * n; i++) counts[i] += counts[i - 1];
this.cellStart.set(counts);
for (let i = 0; i < this.count; i++) {
const cx = Math.min(n - 1, Math.max(0, (this.x[i] * n) | 0));
const cy = Math.min(n - 1, Math.max(0, (this.y[i] * n) | 0));
const c = cy * n + cx;
this.cellItems[this.cellStart[c]++] = i;
}
}
// ---- step --------------------------------------------------------------
step() {
this.frame++;
const f = this.frame;
const law = this.lawForFrame(f);
const cutoff = f - Math.ceil(this.fps * 1.6);
while (this.pulses.length && this.pulses[0].frame < cutoff) this.pulses.shift();
this.birthsThisFrame = 0;
this.deathsThisFrame = 0;
this.inject(f, law);
this.decay(law);
this.buildGrid();
this.update(law);
this.compact();
this.flushNewborns();
this.record(law);
return law;
}
inject(frame, law) {
const events = this.song.index[frame];
if (!events) return;
for (const e of events) {
this.splat(e.x, e.y, e.spread, e.amount * law.spawn);
const births = Math.round(e.spawn * law.spawn);
for (let k = 0; k < births; k++) {
if (this.queueAgent(
e.x + this.rng.range(-0.02, 0.02),
e.y + this.rng.range(-0.02, 0.02),
e.voice,
0.45 + 0.25 * e.strength
)) this.birthsThisFrame++;
}
const ax = Math.max(0, Math.min(31, (e.x * 32) | 0));
const ay = Math.max(0, Math.min(31, (e.y * 32) | 0));
this.activity[ay * 32 + ax] += 0.5 + 2 * e.strength;
// A visible consequence: the hit lands as a ring on the ground.
if (e.strength > 0.42) {
this.pulses.push({ x: e.x, y: e.y, frame, strength: e.strength, voice: e.voice });
}
}
}
decay(law) {
const dt = this.dt;
const fieldK = Math.pow(0.5, dt / (this.genome.fieldHalfLife * (0.8 + 0.4 * law.spawn)));
const trailK = Math.pow(0.5, dt / (this.genome.trailHalfLife * (0.75 + 0.35 * law.deposit)));
const res = this.resource;
for (let i = 0; i < res.length; i++) res[i] *= fieldK;
for (let c = 0; c < 3; c++) {
const t = this.trail[c];
for (let i = 0; i < t.length; i++) t[i] *= trailK;
}
const act = this.activity;
const actK = Math.pow(0.5, dt / 1.4);
for (let i = 0; i < act.length; i++) act[i] *= actK;
// The wells recharge. Slowly, and to a ceiling: a well is a place with
// a carrying capacity, not a faucet. The section's energy sets the rate,
// so a breakdown is still a leaner world.
const scale = (0.45 + 1.25 * law.energy) * dt;
for (const well of this.wells) {
const add = well.rate * scale;
const idx = well.idx;
const w = well.w;
for (let i = 0; i < idx.length; i++) {
const cell = idx[i];
res[cell] = Math.min(1.6, res[cell] + add * w[i]);
}
}
}
update(law) {
const dt = this.dt;
const n = this.gridN;
const sense = 0.13;
const sense2 = sense * sense;
const maxNeighbours = 24;
const g = this.genome;
const wells = this.wells;
for (let i = 0; i < this.count; i++) {
const cls = this.cls[i];
const c = g.classes[cls];
const x = this.x[i];
const y = this.y[i];
let vx = this.vx[i];
let vy = this.vy[i];
let fx = 0;
let fy = 0;
let homeDx = 0;
let homeDy = 0;
// Food gradient. A hungry agent commits harder to what it smells.
const e = this.energy[i];
const hunger = 0.55 + 0.85 * (1 - clamp01(e));
const h = H / this.field;
const gx = (this.sample(x + h, y) - this.sample(x - h, y)) / (2 * h);
const gy = (this.sample(x, y + h) - this.sample(x, y - h)) / (2 * h);
const grad = Math.hypot(gx, gy);
if (grad > 1e-6) {
fx += (gx / grad) * c.seek * hunger;
fy += (gy / grad) * c.seek * hunger;
}
// Place. Each caste has a different relation to the wells, and the
// song sets how strongly it holds: builders orbit and paint,
// foragers commute and draw roads, sparks orbit wide and scatter.
if (wells.length) {
const home = wells[this.wellOf[i]];
const hx = home.x - x;
const hy = home.y - y;
const hd = Math.hypot(hx, hy) + 1e-6;
homeDx = hx / hd;
homeDy = hy / hd;
if (c.role === 'commute') {
const next = (this.wellOf[i] + 1) % wells.length;
const w = wells[next];
const wx = w.x - x;
const wy = w.y - y;
const wd = Math.hypot(wx, wy) + 1e-6;
if (wd < 0.05) {
this.wellOf[i] = next;
} else {
const pull = 1.0 + 1.7 * g.separation;
fx += (wx / wd) * pull;
fy += (wy / wd) * pull;
}
} else {
const err = (hd - c.orbit) / c.orbit;
const k = 1.9 * g.cohesion;
fx += (hx / hd) * Math.max(-1.4, Math.min(1.4, err)) * k;
fy += (hy / hd) * Math.max(-1.4, Math.min(1.4, err)) * k;
const dir = cls === 2 ? -1 : 1;
fx += (-hy / hd) * dir * c.spin;
fy += (hx / hd) * dir * c.spin;
}
}
// Society.
let alignX = 0;
let alignY = 0;
let cohX = 0;
let cohY = 0;
let sepX = 0;
let sepY = 0;
let neighbours = 0;
const cx = Math.min(n - 1, Math.max(0, (x * n) | 0));
const cy = Math.min(n - 1, Math.max(0, (y * n) | 0));
for (let oy = -1; oy <= 1 && neighbours < maxNeighbours; oy++) {
const gy2 = cy + oy;
if (gy2 < 0 || gy2 >= n) continue;
for (let ox = -1; ox <= 1 && neighbours < maxNeighbours; ox++) {
const gx2 = cx + ox;
if (gx2 < 0 || gx2 >= n) continue;
const cell = gy2 * n + gx2;
const a = this.cellStart[cell];
const b = this.cellStart[cell + 1];
for (let p = a; p < b; p++) {
const j = this.cellItems[p];
if (j === i) continue;
const dx = this.x[j] - x;
const dy = this.y[j] - y;
const d2 = dx * dx + dy * dy;
if (d2 > sense2 || d2 < 1e-12) continue;
const d = Math.sqrt(d2);
alignX += this.vx[j];
alignY += this.vy[j];
cohX += dx;
cohY += dy;
const push = (1 - d / sense) / d;
sepX -= dx * push;
sepY -= dy * push;
const k = g.interaction[cls][this.cls[j]];
fx += (dx / d) * k;
fy += (dy / d) * k;
if (++neighbours >= maxNeighbours) break;
}
}
}
if (neighbours > 0) {
const inv = 1 / neighbours;
fx += cohX * inv * g.cohesion * 5.0;
fy += cohY * inv * g.cohesion * 5.0;
fx += (alignX * inv - vx) * g.alignment * 3.2;
fy += (alignY * inv - vy) * g.alignment * 3.2;
fx += sepX * g.separation * 1.05;
fy += sepY * g.separation * 1.05;
}
// Crowding keeps bodies apart: a colony must never collapse to a
// single blob, or the species reads as paint instead of life. The
// push is outward, away from the well where the crowd is densest,
// and scales with how far the crowd has overshot.
if (neighbours > 9) {
const c = Math.min(2.6, (neighbours - 9) * 0.30);
fx -= homeDx * c;
fy -= homeDy * c;
}
// Jitter: what a noisy track does to a well-meaning rule.
fx += this.rng.signed() * g.jitter * 0.26;
fy += this.rng.signed() * g.jitter * 0.26;
// Soft walls: the dish has an edge, but it does not trap.
const m = 0.035;
if (x < m) fx += (m - x) * 14;
if (x > 1 - m) fx -= (x - (1 - m)) * 14;
if (y < m) fy += (m - y) * 14;
if (y > 1 - m) fy -= (y - (1 - m)) * 14;
const speed = c.speed * law.speed;
const mag = Math.hypot(fx, fy);
if (mag > 1e-9) {
const turn = Math.min(1, c.turn * law.turn * dt);
vx += ((fx / mag) * speed - vx) * turn;
vy += ((fy / mag) * speed - vy) * turn;
} else {
vx *= 0.94;
vy *= 0.94;
}
let nx = x + vx * dt;
let ny = y + vy * dt;
if (nx < 0.005) { nx = 0.005; vx = Math.abs(vx) * 0.6; }
if (nx > 0.995) { nx = 0.995; vx = -Math.abs(vx) * 0.6; }
if (ny < 0.005) { ny = 0.005; vy = Math.abs(vy) * 0.6; }
if (ny > 0.995) { ny = 0.995; vy = -Math.abs(vy) * 0.6; }
// Metabolism.
const eaten = this.eat(nx, ny, 0.62 * dt);
let energy = e + eaten * 2.1;
const work = 0.35 + 0.65 * Math.min(1, Math.hypot(vx, vy) / Math.max(1e-6, speed));
energy -= c.drain * dt * work;
energy = Math.min(1.7, energy);
this.x[i] = nx;
this.y[i] = ny;
this.vx[i] = vx;
this.vy[i] = vy;
this.energy[i] = energy;
this.age[i] += dt;
// History is written into the ground, not into a list of events.
// Ink, not fog. A body lays a line roughly its own width; if the
// deposit rate is high enough to fill the dish, the history is lost.
// The floor keeps a fast swarm from reading as invisible: every
// body leaves SOMETHING, however thin.
const deposit = (0.30 + 0.70 * c.deposit) * law.deposit * dt * 0.032;
this.depositTrail(cls, nx, ny, deposit);
if (energy >= c.split * law.split && neighbours < 16) {
this.energy[i] = energy * 0.52;
if (this.queueAgent(
nx + this.rng.range(-0.012, 0.012),
ny + this.rng.range(-0.012, 0.012),
cls,
energy * 0.42
)) this.birthsThisFrame++;
} else if (energy <= 0) {
this.energy[i] = 0;
this.deathsThisFrame++;
}
}
}
compact() {
let w = 0;
for (let i = 0; i < this.count; i++) {
if (this.energy[i] <= 0) continue;
if (w !== i) {
this.x[w] = this.x[i];
this.y[w] = this.y[i];
this.vx[w] = this.vx[i];
this.vy[w] = this.vy[i];
this.energy[w] = this.energy[i];
this.age[w] = this.age[i];
this.cls[w] = this.cls[i];
}
w++;
}
this.count = w;
}
record(law) {
const h = this.history;
const f = this.frame;
if (f < 0 || f >= h.alive.length) return;
h.alive[f] = this.count;
let move = 0;
for (let i = 0; i < this.count; i++) move += Math.hypot(this.vx[i], this.vy[i]);
h.movement[f] = move;
let food = 0;
const res = this.resource;
for (let i = 0; i < res.length; i += 3) food += res[i];
h.food[f] = (food * 3) / res.length;
h.births[f] = this.birthsThisFrame;
h.deaths[f] = this.deathsThisFrame;
}
/** The camera's view of where something just happened. */
activityCell(x, y) {
const ax = Math.max(0, Math.min(31, (x * 32) | 0));
const ay = Math.max(0, Math.min(31, (y * 32) | 0));
return this.activity[ay * 32 + ax];
}
stats() {
const h = this.history;
let peak = 0;
for (let i = 0; i < h.alive.length; i++) peak = Math.max(peak, h.alive[i]);
return {
alive: this.count,
peak,
fieldMass: this.sampleFieldMass(),
trailMass: this.sampleTrailMass(),
name: this.genome.name,
};
}
sampleFieldMass() {
let s = 0;
for (let i = 0; i < this.resource.length; i++) s += this.resource[i];
return s / this.resource.length;
}
sampleTrailMass() {
let s = 0;
for (let c = 0; c < 3; c++) {
const t = this.trail[c];
for (let i = 0; i < t.length; i++) s += t[i];
}
return s / (this.trail[0].length * 3);
}
}
+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 };
+109
View File
@@ -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;
}
+60
View File
@@ -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 };
}
+81
View File
@@ -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}`);
});
+16
View File
@@ -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, '') };
}
+128
View File
@@ -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('-', '−');
}
+73
View File
@@ -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}`);
}