Metabolism: song as substrate, ecology grown from the track
A new top-level section building the metabolism concept: the track supplies the food field, event impulses and per-section laws; the seed picks the species (a rule set, not a parameter vector). Same song + seed => bit-identical frames; different songs => different worlds. - analysis: single FFT pass -> per-frame feature table; score (onsets folded into world geometry via voice ring + pitch-class sector), wells (food placed by the harmony), graph (band correlations -> interaction matrix), laws (per-section regimes) - sim: typed-array world, 3 castes (builder/orbit, forager/commute, spark/orbit-wide) with causality rings, persistence trails, consequence field; fully deterministic - lens: software framebuffer renderer (PNG in node, ImageData in browser) + event-reactive camera (cuts on activity change, alternating shot sizes, drift-and-push) - metrics: coupling x residual fitness (not per-frame descriptors) plus a grown-structure fingerprint; report + strip tools; 15 tests green - DESIGN.md captures the concept, invariants and build order 3 reference test signals (drone/runner/dusk) grow measurably distinct worlds while seeds of one song stay in the same biome.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
out/
|
||||
@@ -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.
|
||||
Generated
+1058
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Onsets become places. The song does not move the agents directly; it puts
|
||||
// food down, and the agents grow on it.
|
||||
//
|
||||
// Three rules make the geography legible:
|
||||
// - the voice picks the ring: kick near the centre, body in the middle,
|
||||
// air at the rim, so a track's mix is visible as an annulus;
|
||||
// - the dominant pitch picks the angle, so twelve pitch classes become
|
||||
// twelve sectors and harmony becomes direction;
|
||||
// - strength sets how much food and how wide, so a hit is a place and a
|
||||
// fill is a region.
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
const RINGS = [0.24, 0.54, 0.82];
|
||||
|
||||
export function buildEvents(analysis, rng) {
|
||||
const events = [];
|
||||
const keys = analysis.summary.chroma ? 12 : 0;
|
||||
for (const o of analysis.onsets.all) {
|
||||
const f = o.frame;
|
||||
const hz = analysis.dominantHz[f] || 440;
|
||||
const semis = Math.round(12 * Math.log2(hz / 440));
|
||||
const pc = ((semis % 12) + 12 + 9) % 12;
|
||||
// A quiet, noisy band has an unstable dominant bin. Pull the angle
|
||||
// toward the event's own hash when the band is flat.
|
||||
const confidence = 0.55 + 0.45 * (1 - (analysis.flatness[f] ?? 0.3));
|
||||
const angle = ((pc / (keys || 12)) * TAU) * confidence +
|
||||
rng.range(-0.16, 0.16) +
|
||||
(o.voice - 1) * 0.05;
|
||||
const radius = RINGS[o.voice] * (0.82 + 0.36 * rng.next());
|
||||
const strength = Math.max(0, Math.min(1, 0.18 + 0.82 * o.strength));
|
||||
|
||||
events.push({
|
||||
frame: f,
|
||||
t: o.t,
|
||||
voice: o.voice,
|
||||
strength,
|
||||
x: 0.5 + Math.cos(angle) * radius * 0.5,
|
||||
y: 0.5 + Math.sin(angle) * radius * 0.5,
|
||||
// World units. A wide event is a region, not a point.
|
||||
spread: 0.014 + 0.040 * strength * strength,
|
||||
// Food units. Scaled so a dense arrangement supports roughly a
|
||||
// thousand agents and a sparse one a few hundred; see the report.
|
||||
amount: (10 + 34 * strength * strength) * 0.07,
|
||||
// Strong events seed life as well as food.
|
||||
spawn: strength > 0.62 ? (o.voice === 0 ? 3 : o.voice === 1 ? 2 : 1) : 0,
|
||||
born: 0,
|
||||
});
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Frame -> events, so the world steps in O(events this frame). */
|
||||
export function indexEvents(events, frames) {
|
||||
const index = Array.from({ length: frames }, () => []);
|
||||
for (const e of events) {
|
||||
if (e.frame >= 0 && e.frame < frames) index[e.frame].push(e);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// The track's key sets the world's hue. Noise sets how far the three castes
|
||||
// sit from each other on the wheel. Nothing here is a palette chosen by hand:
|
||||
// two songs in the same key will still separate on saturation and on the
|
||||
// contrast between the builder and the spark.
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
export function hsl(h, s, l) {
|
||||
h = ((h % 1) + 1) % 1;
|
||||
s = Math.max(0, Math.min(1, s));
|
||||
l = Math.max(0, Math.min(1, l));
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = (n) => {
|
||||
const k = (n + h * 12) % 12;
|
||||
return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1));
|
||||
};
|
||||
return [f(0), f(8), f(4)];
|
||||
}
|
||||
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
|
||||
export function buildPalette(summary, rng) {
|
||||
const key = summary.key ?? 0;
|
||||
const noise = Math.max(0, Math.min(1, summary.flatness));
|
||||
const bright = Math.max(0, Math.min(1, summary.centroid));
|
||||
const beat = Math.max(0, Math.min(1, summary.beatStrength));
|
||||
|
||||
// Hue comes from spectral tilt: how much of the mix lives above 2 kHz.
|
||||
// Bright, airy tracks land warm; bass-heavy ones land cold. It is not the
|
||||
// key, because every test signal in the bank is in C and a palette that
|
||||
// cannot separate them is not a palette.
|
||||
const total = summary.bandBalance[0] + summary.bandBalance[1] + summary.bandBalance[2] + 1e-9;
|
||||
const tilt = summary.bandBalance[2] / total;
|
||||
const hue = (0.74 - 1.55 * tilt + key / 12 * 0.03 + rng.range(-0.015, 0.015) + 1) % 1;
|
||||
const sat = 0.52 + 0.40 * noise + 0.08 * bright;
|
||||
const lift = 0.44 + 0.07 * bright;
|
||||
|
||||
// Builders take the key. Foragers sit a third away; sparks take the
|
||||
// complement, because the caste that moves fastest should read as "other".
|
||||
const c0 = hsl(hue, sat * 0.80, lift);
|
||||
const c1 = hsl(hue + 0.10 + 0.03 * beat, sat, lift + 0.07);
|
||||
const c2 = hsl(hue + 0.54 + (noise - 0.5) * 0.10, sat * 0.96, lift + 0.15);
|
||||
|
||||
const bg = hsl(hue + 0.5, 0.40, 0.026 + 0.012 * bright);
|
||||
const fog = hsl(hue + 0.5, 0.6, 0.075);
|
||||
|
||||
return {
|
||||
hue,
|
||||
sat,
|
||||
bg,
|
||||
fog,
|
||||
classes: [c0, c1, c2],
|
||||
// Trails sit below the agents: the world is dimmer than what moves in it.
|
||||
trails: [c0.map((v) => v * 0.55), c1.map((v) => v * 0.55), c2.map((v) => v * 0.55)],
|
||||
grain: hsl(hue + 0.5, 0.2, 0.5),
|
||||
ink: [0.92, 0.94, 0.96],
|
||||
};
|
||||
}
|
||||
|
||||
export { lerp, TAU };
|
||||
@@ -0,0 +1,33 @@
|
||||
// The song, as a prescription for a world. One call, no rendering, no clock.
|
||||
//
|
||||
// events where food appears, and when
|
||||
// genome what kind of creature eats it, and how they relate
|
||||
// laws how the rules change at section boundaries
|
||||
// palette the key, as colour
|
||||
//
|
||||
// Everything downstream is a pure function of this object plus a frame number.
|
||||
|
||||
import { Rng } from '../rng.js';
|
||||
import { buildPalette } from './palette.js';
|
||||
import { buildEvents, indexEvents } from './events.js';
|
||||
import { deriveGenome } from './species.js';
|
||||
|
||||
export function buildSong(analysis, seed = 1) {
|
||||
const rng = new Rng(seed >>> 0).fork('metabolism');
|
||||
const palette = buildPalette(analysis.summary, rng.fork('palette'));
|
||||
const events = buildEvents(analysis, rng.fork('events'));
|
||||
const genome = deriveGenome(analysis, rng.fork('genome'));
|
||||
return {
|
||||
seed: seed >>> 0,
|
||||
analysis,
|
||||
summary: analysis.summary,
|
||||
palette,
|
||||
events,
|
||||
index: indexEvents(events, analysis.frames),
|
||||
genome,
|
||||
name: genome.name,
|
||||
laws: genome.laws,
|
||||
};
|
||||
}
|
||||
|
||||
export { buildPalette, buildEvents, deriveGenome };
|
||||
@@ -0,0 +1,143 @@
|
||||
// The song's species.
|
||||
//
|
||||
// This is the part that makes the outcomes "derived" rather than "randomised".
|
||||
// The seed picks details; the track picks the kind of world. A tonal track
|
||||
// makes weavers with long memories and heavy trails. A noisy track makes
|
||||
// swarms: faster, jitterier, thinner trails, looser societies. A steady beat
|
||||
// makes them align; a beatless one lets them drift.
|
||||
//
|
||||
// Every number below is a claim about a mapping, not a taste. If the mapping
|
||||
// stops producing different worlds from different tracks, this file is wrong.
|
||||
|
||||
const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
|
||||
const BASE_CLASSES = [
|
||||
{
|
||||
name: 'builder',
|
||||
role: 'orbit', orbit: 0.038, spin: 0.45,
|
||||
speed: 0.034, turn: 1.35, sense: 0.090, size: 2.8,
|
||||
deposit: 1.55, seek: 0.85, split: 1.55, drain: 0.030, seed: 0.34,
|
||||
},
|
||||
{
|
||||
name: 'forager',
|
||||
role: 'commute',
|
||||
speed: 0.082, turn: 3.00, sense: 0.105, size: 2.0,
|
||||
deposit: 0.80, seek: 1.25, split: 1.10, drain: 0.042, seed: 0.44,
|
||||
},
|
||||
{
|
||||
name: 'spark',
|
||||
role: 'orbit', orbit: 0.105, spin: 1.30,
|
||||
speed: 0.135, turn: 5.20, sense: 0.070, size: 1.35,
|
||||
deposit: 0.16, seek: 0.72, split: 0.95, drain: 0.055, seed: 0.22,
|
||||
},
|
||||
];
|
||||
|
||||
// How a section changes the standing laws. Not a parameter ramp: the world
|
||||
// changes behaviour at a boundary, which is what makes a boundary audible in
|
||||
// the picture as well as in the track.
|
||||
const LAW_BY_KIND = {
|
||||
intro: { speed: 0.62, seek: 0.80, deposit: 1.45, split: 1.25, spawn: 0.60, turn: 0.85 },
|
||||
build: { speed: 1.00, seek: 1.15, deposit: 1.00, split: 1.00, spawn: 1.25, turn: 1.00 },
|
||||
drop: { speed: 1.55, seek: 1.35, deposit: 0.72, split: 0.62, spawn: 2.20, turn: 1.25 },
|
||||
breakdown: { speed: 0.42, seek: 0.55, deposit: 2.10, split: 1.75, spawn: 0.35, turn: 0.70 },
|
||||
sustain: { speed: 0.88, seek: 1.00, deposit: 1.12, split: 1.05, spawn: 0.85, turn: 0.95 },
|
||||
outro: { speed: 0.48, seek: 0.68, deposit: 1.70, split: 1.60, spawn: 0.25, turn: 0.75 },
|
||||
};
|
||||
|
||||
function deriveName(summary, genome) {
|
||||
const register = genome.pace < 0.45 ? 'slow' : genome.pace < 0.70 ? 'measured' : 'fast';
|
||||
const grain = summary.flatness < 0.12 ? 'tonal' : summary.flatness < 0.4 ? 'grained' : 'noisy';
|
||||
const society = genome.cohesion > 0.72 ? 'weaver' : genome.cohesion > 0.5 ? 'drifter' : 'swarm';
|
||||
return `${register} ${grain} ${society}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} analysis
|
||||
* @param {import('../rng.js').Rng} rng
|
||||
*/
|
||||
export function deriveGenome(analysis, rng) {
|
||||
const s = analysis.summary;
|
||||
const noise = clamp01(s.flatness);
|
||||
const bright = clamp01(s.centroid);
|
||||
const dyn = clamp01(s.dynamics);
|
||||
const density = clamp01(s.onsetDensity / 6);
|
||||
const beat = clamp01(s.beatStrength);
|
||||
const tempo = clamp01((s.bpm - 60) / 120);
|
||||
|
||||
// Pace separates "has a pulse" from "has a fast clock". A drone detected
|
||||
// at 160 BPM is still a drone; without a pulse it gets the slow laws.
|
||||
const pace = clamp01(tempo * (0.35 + 0.65 * beat));
|
||||
|
||||
const cohesion = clamp01(0.22 + 0.50 * (1 - noise) + 0.30 * beat);
|
||||
const separation = clamp01(0.25 + 0.55 * density);
|
||||
const alignment = clamp01(0.10 + 0.80 * beat);
|
||||
const jitter = clamp01(0.04 + 0.75 * noise);
|
||||
const aggression = clamp01(0.10 + 0.50 * dyn * (0.4 + 0.6 * bright));
|
||||
|
||||
const classes = BASE_CLASSES.map((base, i) => {
|
||||
const isHeavy = i === 0;
|
||||
return {
|
||||
...base,
|
||||
speed: base.speed * (isHeavy ? 0.40 + 0.60 * pace : 0.35 + 1.10 * pace),
|
||||
turn: base.turn * (0.55 + 0.9 * jitter + 0.35 * beat) * (1 - 0.15 * cohesion),
|
||||
sense: base.sense * (0.78 + 0.45 * bright),
|
||||
deposit: base.deposit * (0.35 + 1.25 * cohesion),
|
||||
split: base.split * (1.25 - 0.45 * density) * (0.8 + 0.3 * dyn),
|
||||
seek: base.seek * (0.75 + 0.4 * bright),
|
||||
drain: base.drain * 1.3 * (0.85 + 0.4 * dyn),
|
||||
};
|
||||
});
|
||||
|
||||
// Society comes from whether the song's bands move together. Correlated
|
||||
// bands attract; a band that lives its own life repels the others.
|
||||
const interaction = s.bandCorrelation.map((row, i) =>
|
||||
row.map((r, j) => {
|
||||
if (i === j) return 0;
|
||||
const proximity = 1 - 0.25 * Math.abs(i - j);
|
||||
return (r - 0.62) * 2.0 * proximity * (0.4 + 0.6 * aggression);
|
||||
})
|
||||
);
|
||||
|
||||
const laws = analysis.sections.map((sec) => {
|
||||
const base = LAW_BY_KIND[sec.kind] || LAW_BY_KIND.sustain;
|
||||
const e = clamp01(sec.energy);
|
||||
return {
|
||||
start: sec.start,
|
||||
end: sec.end,
|
||||
kind: sec.kind,
|
||||
energy: e,
|
||||
speed: base.speed * (0.72 + 0.55 * e),
|
||||
seek: base.seek,
|
||||
deposit: base.deposit,
|
||||
split: base.split,
|
||||
spawn: base.spawn * (0.55 + 0.9 * e),
|
||||
turn: base.turn,
|
||||
hue: (sec.centroid - s.centroid) * 0.35 + (sec.kind === 'drop' ? 0.03 : 0),
|
||||
};
|
||||
});
|
||||
|
||||
const genome = {
|
||||
seed: rng.seed,
|
||||
key: s.key,
|
||||
pace,
|
||||
cohesion,
|
||||
separation,
|
||||
alignment,
|
||||
jitter,
|
||||
aggression,
|
||||
classes,
|
||||
interaction,
|
||||
laws,
|
||||
// Memory: a tonal track keeps what it builds; a noisy one forgets.
|
||||
trailHalfLife: 15 - 7 * noise,
|
||||
fieldHalfLife: 28 - 10 * noise,
|
||||
maxAgents: 2600,
|
||||
seedAgents: 150 + Math.round(120 * density),
|
||||
name: '',
|
||||
};
|
||||
genome.name = deriveName(s, genome);
|
||||
return genome;
|
||||
}
|
||||
|
||||
export { LAW_BY_KIND, BASE_CLASSES, clamp01, lerp };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
// Node-only: WAV -> analysis -> song. The browser does the same with a
|
||||
// decoded AudioBuffer; this keeps the tools honest about the same pipeline.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { decodeWav, mono } from '../src/audio/wav.js';
|
||||
import { analyse } from '../src/audio/analyse.js';
|
||||
import { buildSong } from '../src/song/song.js';
|
||||
|
||||
export function loadSong(path, { fps = 30, seed = 12345 } = {}) {
|
||||
const wav = decodeWav(readFileSync(path));
|
||||
if (!wav) throw new Error(`not a WAV: ${path}`);
|
||||
const pcm = mono(wav.channels);
|
||||
const analysis = analyse(pcm, wav.sampleRate, { fps });
|
||||
const song = buildSong(analysis, seed);
|
||||
return { pcm, sampleRate: wav.sampleRate, analysis, song, name: path.split('/').pop().replace(/\.wav$/i, '') };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// The system-level report: does each episode keep time with its song
|
||||
// (coupling) while still having dynamics the song does not explain
|
||||
// (residual)? And do different songs — grown by different songs' laws —
|
||||
// actually diverge? Outputs a table and out/report.md.
|
||||
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadSong } from './load.js';
|
||||
import { Run } from '../src/film.js';
|
||||
import { evaluate, fingerprint, divergence } from '../src/metrics.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
const BANK = resolve(ROOT, '../flow-state/test/songs');
|
||||
const SEEDS = [999, 12345, 777777];
|
||||
|
||||
function args(argv) {
|
||||
const out = {};
|
||||
for (let i = 2; i < argv.length; i += 2) out[argv[i].replace(/^--/, '')] = argv[i + 1];
|
||||
return out;
|
||||
}
|
||||
|
||||
const opt = args(process.argv);
|
||||
const names = (opt.songs || 'drone,runner,dusk').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const seeds = opt.seeds ? opt.seeds.split(',').map(Number) : SEEDS;
|
||||
mkdirSync(resolve(ROOT, 'out'), { recursive: true });
|
||||
|
||||
const rows = [];
|
||||
for (const name of names) {
|
||||
for (const seed of seeds) {
|
||||
const { song } = loadSong(resolve(BANK, `${name}.wav`), { fps: 30, seed });
|
||||
const run = new Run(song, { fps: 30, width: 480, height: 270, seed });
|
||||
run.simulate();
|
||||
const world = run.world;
|
||||
const m = evaluate(world, song);
|
||||
const fp = fingerprint(world);
|
||||
rows.push({
|
||||
song: name,
|
||||
seed,
|
||||
species: song.name,
|
||||
peak: world.stats().peak,
|
||||
pop: world.count,
|
||||
sortCount: song.events.length,
|
||||
hue: Number(song.palette.hue.toFixed(2)),
|
||||
...m,
|
||||
fp,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const md = [];
|
||||
|
||||
md.push('# metabolism — song × seed report');
|
||||
md.push('');
|
||||
md.push(`Generated ${new Date().toISOString().slice(0, 10)} · 3 test signals (${names.join(', ')}) × ${seeds.length} seeds each.`);
|
||||
md.push('');
|
||||
md.push('| song | seed | species | peak | pop | coupling | residual | **fitness** | hue |');
|
||||
md.push('|---|---|---|---|---|---|---|---|---|');
|
||||
for (const r of rows) {
|
||||
md.push(`| ${r.song} | ${r.seed} | ${r.species} | ${r.peak} | ${r.pop} | ${fps2(r.coupling)} | ${fps2(r.residual)} | **${fps2(r.fitness)}** | ${r.hue} |`);
|
||||
}
|
||||
|
||||
md.push('');
|
||||
md.push('## Grown structure (average over seeds)');
|
||||
md.push('');
|
||||
md.push('| song | occupancy | cluster | trailFill | class mix b/f/s |');
|
||||
md.push('|---|---|---|---|---|');
|
||||
const bySong = new Map();
|
||||
for (const r of rows) {
|
||||
if (!bySong.has(r.song)) bySong.set(r.song, []);
|
||||
bySong.get(r.song).push(r);
|
||||
}
|
||||
for (const name of names) {
|
||||
const list = bySong.get(name) || [];
|
||||
const avg = {
|
||||
occupancy: (list.reduce((s, r) => s + r.fp.occupancy, 0) / list.length).toFixed(3),
|
||||
cluster: (list.reduce((s, r) => s + r.fp.cluster, 0) / list.length).toFixed(2),
|
||||
trailFill: (list.reduce((s, r) => s + r.fp.trailFill, 0) / list.length).toFixed(3),
|
||||
mix: [0, 1, 2].map((i) =>
|
||||
(list.reduce((s, r) => s + r.fp.classMix[i], 0) / list.length).toFixed(2)
|
||||
).join('/'),
|
||||
};
|
||||
md.push(`| ${name} | ${avg.occupancy} | ${avg.cluster} | ${avg.trailFill} | ${avg.mix} |`);
|
||||
}
|
||||
|
||||
md.push('');
|
||||
md.push('## Divergence (L1 distance between fingerprints)');
|
||||
md.push('');
|
||||
md.push('Across songs (seed 12345 each):');
|
||||
md.push('');
|
||||
md.push('| pair | divergence |');
|
||||
md.push('|---|---|');
|
||||
const songs = [...new Set(rows.map((r) => r.song))];
|
||||
const char = (song, seed) => rows.find((r) => r.song === song && r.seed === seed).fp;
|
||||
for (let i = 0; i < songs.length; i++) {
|
||||
for (let j = i + 1; j < songs.length; j++) {
|
||||
const d = divergence(char(songs[i], 12345), char(songs[j], 12345));
|
||||
md.push(`| ${songs[i]} ↔ ${songs[j]} | ${fps2(d)} |`);
|
||||
}
|
||||
}
|
||||
md.push('');
|
||||
md.push('Across seeds (drone):');
|
||||
md.push('');
|
||||
md.push('| pair | divergence |');
|
||||
md.push('|---|---|');
|
||||
for (let i = 0; i < seeds.length; i++) {
|
||||
for (let j = i + 1; j < seeds.length; j++) {
|
||||
const d = divergence(char('drone', seeds[i]), char('drone', seeds[j]));
|
||||
md.push(`| seed ${seeds[i]} ↔ ${seeds[j]} | ${fps2(d)} |`);
|
||||
}
|
||||
}
|
||||
|
||||
md.push('');
|
||||
md.push('_Note: coupling is correlation with the song envelope, residual is 1 − R² after');
|
||||
md.push('regressing out song features; fitness is a blend of both. See src/metrics.js._');
|
||||
|
||||
const text = md.join('\n') + '\n';
|
||||
const reportFile = resolve(ROOT, 'out', 'report.md');
|
||||
writeFileSync(reportFile, text);
|
||||
writeFileSync(resolve(ROOT, 'out', 'report.json'), JSON.stringify(rows, null, 2));
|
||||
|
||||
console.log(text);
|
||||
console.log('wrote ' + reportFile);
|
||||
|
||||
function fps2(v) {
|
||||
return v.toFixed(3).replace('-', '−');
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Render one filmstrip per song: N frames evenly across the track, tiled.
|
||||
//
|
||||
// node tools/strip.js --songs drone,runner,dusk
|
||||
// node tools/strip.js --songs drone --cols 6 --rows 2 --w 480 --h 270
|
||||
//
|
||||
// Frames come from the same Run as a full render, and the sim is advanced
|
||||
// through every frame in between, so a strip cannot show a world that the
|
||||
// video would not have.
|
||||
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadSong } from './load.js';
|
||||
import { Run } from '../src/film.js';
|
||||
import { encodePNG, tile } from '../src/render/png.js';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
const BANK = resolve(ROOT, '../flow-state/test/songs');
|
||||
|
||||
function args(argv) {
|
||||
const out = {};
|
||||
for (let i = 2; i < argv.length; i += 2) out[argv[i].replace(/^--/, '')] = argv[i + 1];
|
||||
return out;
|
||||
}
|
||||
|
||||
const opt = args(process.argv);
|
||||
const names = (opt.songs || 'drone,runner,dusk').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const seed = Number(opt.seed || 12345);
|
||||
const cols = Number(opt.cols || 4);
|
||||
const rows = Number(opt.rows || 3);
|
||||
const width = Number(opt.w || 480);
|
||||
const height = Number(opt.h || 270);
|
||||
const fps = 30;
|
||||
const everything = opt.combined === '1';
|
||||
const outDir = resolve(ROOT, opt.out || 'out');
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const count = cols * rows;
|
||||
const all = [];
|
||||
|
||||
for (const name of names) {
|
||||
const { song, analysis } = loadSong(resolve(BANK, `${name}.wav`), { fps, seed });
|
||||
const run = new Run(song, { fps, width, height, seed });
|
||||
const frames = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = (0.10 + 0.85 * (i / Math.max(1, count - 1))) * analysis.duration;
|
||||
frames.push(Math.min(analysis.frames - 1, Math.round(t * fps)));
|
||||
}
|
||||
const rendered = run.renderAt(frames);
|
||||
const shots = frames.map((f) => rendered.get(f));
|
||||
const zooms = shots.map((s) => `${s.cam.zoom.toFixed(1)}@${s.shot}`).join(' ');
|
||||
const sheet = tile(shots, cols, rows, 2);
|
||||
const file = resolve(outDir, `strip-${name}.png`);
|
||||
writeFileSync(file, encodePNG(sheet.w, sheet.h, sheet.data));
|
||||
const stats = run.world.stats();
|
||||
console.log(`${file} ${sheet.w}x${sheet.h} ${song.name} peak=${stats.peak} pop=${run.world.count} hue=${song.palette.hue.toFixed(2)} key=${song.summary.key}`);
|
||||
console.log(` shots: ${zooms}`);
|
||||
all.push({ name, song, shots, stats });
|
||||
}
|
||||
|
||||
if (everything) {
|
||||
// One row per song, same fractions of each track: the comparison sheet.
|
||||
const per = 4;
|
||||
const chosen = [];
|
||||
for (const entry of all) {
|
||||
for (let i = 0; i < per; i++) chosen.push(entry.shots[Math.round((i / (per - 1)) * (entry.shots.length - 1))]);
|
||||
}
|
||||
const sheet = tile(chosen, per, all.length, 3);
|
||||
const file = resolve(outDir, 'strip-all.png');
|
||||
writeFileSync(file, encodePNG(sheet.w, sheet.h, sheet.data));
|
||||
console.log(`${file} ${sheet.w}x${sheet.h}`);
|
||||
}
|
||||
Reference in New Issue
Block a user