Metabolism: rule program, song-driven life, tribe VMs; M2.7 design (strains & allegiances, living wells)

This commit is contained in:
Dejvino
2026-10-28 10:07:05 +01:00
parent 16f4b4a805
commit f939351689
28 changed files with 3199 additions and 601 deletions
+1
View File
@@ -1,2 +1,3 @@
node_modules/
out/
dist/
+102 -40
View File
@@ -99,18 +99,36 @@ 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.
Events splat resource onto a 128×128 grid. On top of that, **wells** — small,
fertile places with a life of their own. There are many of them (8–16) and
each is small and weak: a tight kernel, a modest recharge rate, no single well
rich enough to carry a world — the same total fertility budget the old
permanent springs had, spread thin. Where wells come from and when they go is
the track's own harmony:
- **birth** — a well sprouts where a pitch class enters the music and *holds*
(sustained chroma energy, seeded by the events that already landed there);
- **drift** — a well wanders slowly with its harmony, pulled along the
smoothed pitch/centroid path, and the creatures anchored to it follow;
- **death** — when a pitch class leaves the song, its well starves: the
recharge fades, the kernel decays, the colony must abandon it.
The chord does not just draw the map — it runs the map's *history*. Wells are
the story's spine: founding, settlement, exodus, migration, collapse. Each has
an audible cause and a visible one, and each lasts long enough to be followed.
The whole lifecycle is compiled at analysis time into a deterministic **well
timeline** (birth, path, death per well); the sim only plays it back. Wells
are also where bodies meet: everyone needs them, so a well is the natural
front between macro-organisms — and its death is a retreat, its birth a
land-rush.
### 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.
The band-envelope correlation matrix now works at two scales. It decides the
**division of labour** — which strain motifs complement which, so a bass line
locked with the kick pairs diggers with haulers — and it feeds the
**diplomacy**: the harmony the track sustains maps to how bodies treat each
other, truce and trade versus skirmish and siege. Society is a property of
the mix, at both scales.
### Law — regime per section
Section boundaries are found where the 4 Hz feature cloud changes its mind
@@ -132,39 +150,60 @@ The genome maps the summary statistics to *kinds of behaviour*:
| 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 |
| band correlations | division of labour | complementary voices breed complementary strains |
| 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:
There are no castes and no tribes. There is **one population** — a single
density field — and every organism in it carries two properties:
- **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.
**Personality — the strain.** The song decides how many kinds of minds its
world needs (*m*, read from how many independent voices the mix resolves
into, bounded by arrangement depth, jittered by the seed) and compiles a
**LittleVM program for each strain**: grazer, builder, sentinel, scout,
breeder — each with its own senses (food, densities, trail, law, hunger, rim,
well proximity and liveliness, body presence), registers as memory, real
branching, and actions (move, graze, excrete, hit, spawn, follow, patrol).
The population is a *mix* of strains the way a biofilm is: specialists
working the same ground. And the mix is itself alive — strains that eat and
grow where they are propagate locally; proportions shift district by
district. The film shows specialisation *evolving* inside one species.
**Allegiance — the body.** Every organism also belongs to a macro-organism —
its **body** — a polity of mixed strains that builds, defends and expands as
one. Allegiance is a *field*: the population's composition per cell, advected
frozen-in with the organisms as they move. And it changes:
- **conversion** — at a contested front the stronger body captures ground and
converts the locals;
- **defection** — when a body's presence collapses (its food gone, its ground
lost), its members flip to the neighbouring body;
- **fission** — a coherent splinter cut off from its body, blessed by a strong
onset, is founded as a new body with a new colour;
- **merger** — two bodies whose members fully intermix under a cooperative
regime become one.
Bodies are few — the song opens 2–4, capped at ~8 so colour stays legible —
and they are the drama. The film is the story of macro-organisms **fighting
and cooperating**: skirmish lines at fronts, truces in consonant sections,
symbiosis where one body's waste is another's food. The song sets the terms
of diplomacy: consonant, harmonically locked material breeds symbiosis;
dissonant, decorrelated material breeds rivalry; drops are expansion wars;
breakdowns are truces and mutual building.
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.
A fixed-step, garbage-free simulation over typed arrays: every RNG stream
seeded, every update a pure function of the previous substep. The course
correction (**EVOLUTION.md**) replaces the entity ecology with a
**population-field** ecology — the same front-end (events place food, wells
born from and living by the chord, laws at section boundaries) feeding
*density* instead of discrete agents, stepped several times per rendered frame so macro patterns
form. Nothing here is realtime: no player, no clock, no allocation — typed
arrays, a step function, and a history we can regress against the song.
## The lens
@@ -228,6 +267,10 @@ What will be previewed is exactly what will be exported.
## Build order & status
*Course-corrected in **EVOLUTION.md** — read it before touching the world or the
rules. The front-end (analysis → events → field → laws → palette) stands; the
world behind the `Run` is being rebuilt.*
- **v0 — substrate** ✔ one song, one species, alive on the dish.
*Built:* analysis → structures → world → renderer → filmstrips, metrics,
15 tests green.
@@ -237,14 +280,33 @@ What will be previewed is exactly what will be exported.
- **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.
- **M1 — macro world** (EVOLUTION.md) replacing the entity sim with a
population-field sim: `FIELD=128` density grid (rendered at 256² texture),
`SUBSTEPS` per frame, coarse-grid forces, home basins, speck-swarm rendering
("billions" as texture), song-specific openings. The invariants, determinism
and the `Run` interface survive.
- **M2 — rule program** ✔ the genome/laws become a compiled, declarative
per-song rule program: castes as rule entries (mutualism, social floors),
interaction rules, per-section operator regimes (structural section
changes), the event→response mapping, and a shared carrying capacity.
- **M2.7 — one species, many minds** *(designed, not yet built — see
EVOLUTION.md)*: tribes/castes are dissolved into a single population field
with two live properties — a **strain mix** of song-compiled LittleVM
personalities evolving by local selection, and an **allegiance field**
whose macro-organisms ("bodies") convert, defect, fission and merge,
fighting or cooperating as the song's harmony and sections dictate — on top
of the living well timeline.
- **M3 — lens** the camera travels (track / reveal / establish) and reserves
cuts for musical punctuation.
- **M4 — film** mux frames + PCM to video.
## 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.
*film*: the renderer is a software-framebuffer proof, the reference tracks are
synthetic test signals, the ground the population stands on is still furniture
(four permanent springs), and the population itself is still three fixed
castes instead of one evolving mix of minds. M2.7 (EVOLUTION.md) fixes both:
wells that are born, drift and die; strains — song-compiled LittleVM
personalities — whose mix adapts by local selection; and allegiances —
macro-organisms that fight, cooperate, and change membership over time.
+233
View File
@@ -0,0 +1,233 @@
# evolution — from a petri dish to a world
Course correction after watching the demo, agreed with the code:
**What works.** The song→analysis→events→wells→laws front-end genuinely connects
to the sim (coupling/residual and fingerprint divergence are real). The
determinism discipline is intact and now proven bit-for-bit in the browser.
The demo shell and the tools share one `Run`.
**What failed.** The *world behind the Run*. All songs grow the same three
castes on the same 128² dish with the same opening and the same laws-as-multipliers.
Statistically different, structurally identical. Two skins, not two habitats.
And the camera watches it at the microscopic scale, so nothing larger ever has
room to appear.
**The correction** is one idea: the world must be a *macro* ecology — population
density, not individual dots — governed by *structural rules* the song compiles
to, watched by a camera that *travels* instead of cutting.
---
## Milestones
### M0 — agreement ✔
Representation: **population fields**. Rules: **a scoped rule program** (compiled
per song, deterministic). Plan: this document. `DESIGN.md` updated to the new
end-goal.
### M1 — the macro world: density, substeps, geography ✔
The entity sim is replaced by a population-field sim.
- **World**: `FIELD=128` integrated density grid (rendered as `RENDER_FIELD=256`
speck texture); layers: food, three caste populations, three tinted trail
fields, wells, a static seeded jitter pair.
- **Time**: `SUBSTEPS=5` sim steps per rendered frame (the old world: 1), so
local interactions propagate into coherent macro patterns.
- **Physics**: forces are integrated on a coarse 64² grid (2×2 block sums —
long-range coupling at a fraction of the cost), then advected on the fine
grid with a square-root-free L1 axis split. Reaction: eat → logistic growth
with a total-density gate → deposit; starvation on empty ground. Events still
splat food and bloom the voice that spoke; rings land on strong hits.
- **Home basins**: each caste anchors to its own wells (the field-world
"builder orbits its well"), so colonies persist as continents instead of
melting into a uniform haze.
- **Look**: trails compose the geography; density renders as a glow plus
deterministic speck swarms — "billions of little guys" is texture, at zero
entity cost.
- **Opening**: the dish wakes into the song's own first bars.
*Outcome:* at 60 s the population is still geographically structured (8-block
CV 1.5–1.9 vs ~0.1 before), songs and seeds diverge at a glance in-browser,
openings differ per song, realtime holds at 640×360 (960×540 on reasonable
hardware), and the test suite is green including a prepared-artifact
bit-identity check. *M2 then replaced the multiplier laws with the rule
program; the camera's travel grammar is M3.*
### M2 — the rule program: structure, not knobs ✔
The laws-as-multipliers are replaced by a **scoped rule program**
(`src/song/rules.js`) — declarative per-song rules:
- **castes** are rule entries: speed / seek / dispersion / cap / growth / eat /
deposit, plus *growth rules* — mutualism (a caste breeds fastest among the
others, so mixed crowds beat monochrome blobs) and a social floor (sparks
only bloom in a luminous crowd);
- **interaction rules** per caste pair (the signed band-correlation graph);
- per-section **operator regimes** — a breakdown drives disperse≈0 and
deposit high (the world settles and inscribes); a drop drives home≈0 and
turbulence+spawn high (the flood). Structural, scoped by the track's own
character (noisy → turbulence up, tonal → deposit up);
- the **event→response** mapping: which voice feeds which caste, and how much
of every hit splashes to the others;
- **memory** (trail/field half-lives, diffusion) and a **global carrying
capacity** so one song cannot flood while another starves;
- compiled once per song, deterministically — the program is part of the song,
rebuilt bit-identically by the prepared-artifact path, and v3's rule search
now searches *programs*, not floats.
*Outcome:* 23 tests green, including: program compilation is deterministic;
different songs get structurally different programs; regimes are structurally
distinct by kind (a growth=0 edit visibly halts the world — the program is
executed, not decoration); genesis pre-simulates generations so frame 0 is
already an ecosystem, with the amount tuned per song; the opening is seeded
where the song's own hits landed. Mixing is a program rule: castes co-mingle
in one shared geography (drone 0.93, dusk 0.78, runner 0.48 block-mix; was
~0.02–0.15), the lens holds the whole world (zoom ~0.9–1.6), and divergence
between songs grew to 16–40× where seeds stay ~0.01–0.05.
**M2.5 — song-driven life, more generations, higher altitude ✔**
- *The song spawns:* colonies are no longer placed at a pre-chosen set of
spots. Every hit lands food *and* births life there (the voice first and
loudest, splashes to the others); the drop regime visibly floods the ground
with new colonies where the track hits.
- *Genesis:* the world pre-simulates several generations before frame 0 (the
amount comes from the song — intro length, onset density, pulse), so the
opening is an old, settled ecosystem, never a blank plate, and the film's
history starts clean at frame 0.
- *Altitude:* the lens sits higher (zoom ~0.6–1.5), holding the whole map —
different cultures and the roads and fronts between them — in frame.
### M2.6 — the tribes are programs ✔
The M2 rule program steered *how hard* behaviours ran. Tribes go deeper: each
tribe is a small **deterministic behaviour program** (`src/song/tribe.js`) — a
tiny VM with
- **sensing** (food, own/other densities, trail, law, hunger, rim-edge, time),
- **memory** (four persistent registers, smoothed by the track's pulse),
- **actions** (move, excrete, eat, hit, spawn),
- **basic branching** (if hunger > threshold → seek food, else hold-and-ink),
compiled **by the song**: the band-correlation matrix signs who a tribe
approaches vs flees, flatness sets how much it branches on gradients, onset
density sets its spawning, the seed jitters thresholds. The world no longer
executes a hand-tuned force formula on the coarse decision grid — it runs what
each tribe's program decides, every substep. Surprising behaviour is *possible*
precisely because the programs branch: a starving tribe behaves differently
from a fed one, and the threshold is musical.
*Outcome:* the three societies are now balanced and mixed (drone 0.86 /
runner 0.87 / dusk 0.95 block-mix; populations 40–76 k, castes near-equal), the
geography holds (population CV 2.2–2.8). New tests pin the VM: programs compile
deterministically and differ by song (restless/social/hoarding/breeding
archetypes selected from the stats); branching changes behaviour; registers
persist as memory; a no-op program visibly changes the film. Realtime holds at
640×360 (tribe runs add ~4 ms/frame at 960×540 in node).
*Honest limit:* the compiler's vocabulary is still a small archetype family, so
the *structural* divergence between songs is modest (cross ≈ 0.16–0.18 vs
within-seed ≈ 0.02–0.04, ~4–7×) even though the films look different. Broadening
the instruction grammar (more branch shapes, richer memory, more action
archetypes) is the natural next step for this layer.
### M2.7 — one species, many minds: strains & allegiances (design agreed; not yet built)
A further correction, agreed before building anything on M2.6: **drop tribes
entirely.** Not three castes, not k tribes of fixed membership. The picture is
*one population of specialised microorganisms building a bigger organism* —
and then several such macro-organisms sharing a map, sometimes fighting,
sometimes cooperating. Membership is a property of each organism, and it can
change over time.
**Wells keep the correction from the previous draft** — small, weak, many
(8–16), born where a pitch class enters and holds, drifting with its harmony,
dying when it leaves, all on a precompiled deterministic timeline — because
wells are where bodies collide: everyone needs them, so a well is the natural
front, its birth a land-rush, its death a retreat.
**The population is one density field with two live properties:**
- **strain mix (personality).** The song decides how many minds it needs —
*m* strains from the number of independent voices the mix resolves into,
bounded by arrangement depth, jittered by the seed — and compiles a
**LittleVM program per strain** from the grammar: senses (food, density,
trail, law, hunger, rim, well proximity and liveliness, body presence),
registers as memory, real branching, actions (move, graze, excrete, hit,
spawn, follow, patrol). Density is partitioned by strain; each strain
advects by its own program. And the mix **evolves by local selection**:
strains that eat and grow where they stand propagate, proportions shift
district by district — specialisation is visible and develops over the film.
- **allegiance (the body).** A composition field over bodies (the song opens
2–4; a cap of ~8 keeps colour legible), advected frozen-in with the
population flux. Same body = **cooperation**: shared trails, reinforcement,
complementary strains building one structure. Different bodies = **contest**
where presences overlap: hit-actions spend density to push the other body's
ground and convert it, scaled by the song's harmony — consonant, locked
voices breed truce and symbiosis; dissonant, decorrelated voices breed
skirmish and siege. Allegiance changes by rule, never by fiat:
- **conversion** — a won front converts the locals it overruns;
- **defection** — a body whose presence collapses (starved, outflanked)
releases its members to the neighbouring body;
- **fission** — a coherent splinter cut off from its body, hit by a strong
onset, founds a new body with a new colour;
- **merger** — two bodies whose members fully intermix under a cooperative
regime become one.
**The song sets the diplomacy.** Consonance and the section regimes map to
war and peace: drops are expansion wars, breakdowns are truces and mutual
building, an outro is a settling of borders. The body-to-body relations come
from the music, not a constant.
*Exit: two songs with the same pulse but different arrangement depth compile
different strain counts and different diplomacies; at least one allegiance
change (conversion, defection, fission or merger) is legible on screen per
film; strain proportions measurably drift across the film (local selection
actually runs); a well's birth, drift and death are each legible; the world's
typed-array layers, trail tints and palette generalise from 3 fixed castes to
the strain/body fields; the prepared-artifact path still rebuilds
bit-identical timelines, programs and opening state.*
### M3 — the lens: travel, don't cut
Replace the cut-to-a-point grammar with a **move grammar**: *track* (follow a
moving front), *reveal* (pan along a coastline), *establish* (hold wide and
slow). Cuts are reserved for musical punctuation — strong onsets in drop/build.
Because the world is macro, a pan teaches the map instead of disorienting.
*Exit: a 120 s render where a single front can be followed across the world
without losing bearings; cuts read as beats, not jumps.*
### M4 — the film
Mux frames + PCM to video. No longer the bottleneck: the preview pipeline and
the lens exist; once M1–M3 make the world worth exporting, muxing is plumbing.
---
## The prepare step (pre-calculation)
Viewing should be fast because the *expensive* work happens ahead of time, once:
1. `tools/prepare.js` runs the WAV → analysis → compile pipeline in node and
writes a **prepared song** (`out/prepared/<song>-<seed>.json`): the compiled
rule program and every analysis structure the world, camera, and metrics
need — but not the raw PCM.
2. The browser fetches the prepared song instead of analysing the WAV. The WAV
is still fetched, but only to feed the WebAudio clock (decoded straight to a
buffer; analysis skipped).
3. `src/song/serialize.js` is the single serialize/deserialize for both sides —
the browser's `deserializeSong` must rebuild a song that is *bit-identical*
to one built by analysing the WAV, so prepare can never drift from the live
path. That equality is a test.
This is exactly the DESIGN promise inverted and kept: the pipeline still defines
the world; prepare only pays its cost once, off the viewing path.
## Invariants, restated for the field world
- **Determinism**: same song + same seed + same frame ⇒ same pixel. No
`Math.random`, no wall clock, no per-substep RNG. The static jitter noise
field is generated once from the seeded RNG at construction.
- **Causality**: events still place food and rings where the track put them;
a section boundary still changes the rules at the boundary only.
- **Persistence**: population fields carry their history; trails accumulate in
the same three tinted layers the renderer already composes.
- **Consequence**: everything is a pure update rule over the previous substep;
nothing is re-rolled.
+72 -14
View File
@@ -60,29 +60,81 @@ npm run strip -- --songs drone,runner,dusk --combined 1 # comparison sheet
# report: coupling × residual fitness + fingerprint divergence across songs/seeds
npm run report # prints a table, writes out/report.md + report.json
npm run report -- --songs drone,runner,dusk --seeds 999,12345,777777
# prepare: analyse songs once, so the demo loads instantly instead of
# re-analysing the WAV in the browser (falls back to live analysis if absent)
npm run prepare # writes out/prepared/<song>-<seed>.json
```
Outputs land in `out/` (gitignored). `npm run dev` serves a Vite root once a
browser demo exists.
Outputs land in `out/` (gitignored).
## The demo
```bash
npm run dev # open http://localhost:5173 and pick a song
```
A browser demo that plays the songs off the actual pipeline. The page serves
the bank from `../flow-state/test/songs` (via `vite.config.js` — no copies),
lists **all 17 tracks** with the species each one grows, and plays the chosen
track while the world steps to it:
- **select a song** → it fetches the WAV, decodes it, and grows the world to
frame 0 before letting you in. Run `npm run prepare` once and the browser
loads the pre-computed analysis instead of re-analysing, so viewing is fast
(it falls back to live analysis when an artifact is missing);
- **play / pause** (or <kbd>space</kbd>), **seek** (or <kbd>←</kbd>/<kbd>→</kbd>
±5 s) — the audio clock only chooses *which* frame the world should have
reached, so seeking is fast forward; a backward seek re-grows from frame 0;
- **seed** (the exact world), **res** (640×360 realtime default, up to
1280×720) and **volume**; changes live without breaking playback;
- the seek bar shows **section ticks** (the laws), the status line shows the
current law, shot size/zoom and live world stats (population, field, trail);
- the page accent **wears the track's palette hue**, and the URL carries
`?song=&seed=&res=` so a film is a link.
Determinism is the point: the page renders through the same `Run` as the
tools — same song, same seed, same frame ⇒ the same pixels (tested bit-identical
against `strip` output, including the prepared-artifact path). `npm run build`
bundles it; `npm run preview` serves the built page from `dist/`. The world
behind the `Run` is the **macro ecology** of EVOLUTION.md M1 — population
fields, not entities; see `EVOLUTION.md`.
Files: `index.html`, `vite.config.js` (song-bank + prepared-artifact plugin),
`src/demo/*` (`main.js` page wiring, `player.js` Run→canvas, `audio.js`
WebAudio clock, `catalog.js` metadata, `style.css`).
## Layout
```
DESIGN.md concept, invariants, build order
DESIGN.md concept, invariants, build order (end-goal)
EVOLUTION.md course-corrected roadmap (M1 done, M2–M4 ahead)
index.html the demo page
vite.config.js song-bank + prepared-artifact plugin (dev + build)
src/
audio/ WAV decode, radix-2 FFT, feature table, sectioning, onset score
song/ events (onsets → geometry), species (genome), palette, song assembly
sim/ wells (harmony→food) and world.js (the typed-array ecology)
render/ software framebuffer, trail composer, PNG encoder
camera.js event-reactive cutting, shot-size rhythm, drift-and-push
song/ events (onsets → geometry), species (genome), rules.js (the
per-song rule program + section operator regimes),
tribe.js (the per-tribe behaviour VM: senses, memory,
actions, branching — compiled from the song), palette,
song assembly, serialize.js (prepare cache)
sim/ wells (harmony→food) and world.js (the macro field-world:
128² density, 5 substeps/frame, executes the rule program
and the tribe programs)
render/ software framebuffer: trail composer, density glow + speck
swarms (the "billions"), PNG encoder
camera.js wide event-reactive shots, shot-size rhythm, drift-and-push
film.js Run: the single wire-up of sim → camera → framebuffer
metrics.js coupling, residual, fitness, fingerprint, divergence
rng.js seeded PRNGs (the only randomness in the repo)
demo/ player page: main.js wiring, player.js (Run→canvas),
audio.js (WebAudio clock), catalog.js, style.css
tools/
load.js WAV → analysis → song (node-only)
prepare.js pre-computes analysis → out/prepared/<song>-<seed>.json
strip.js filmstrip renderer
report.js system-level evaluation → markdown table
test/ node:test suites
test/ node:test suites (incl. prepared-artifact bit-identity)
```
## References
@@ -95,12 +147,18 @@ bright/noisy/fast, darkest/all-tonal/mid-tempo. Drop any WAV there and point
## Status
Currently a proof: the simulation is alive, the three songs are measurably
distinct (fingerprint divergence between songs ≫ divergence between seeds),
and the tools verify it. It is **not yet a film** — nothing is muxed to
audio/video, the renderer is a 480 p proof, and the demo page isn't built.
Next milestones: a browser demo, event-reactive cutting tuned to a full real
track, and video export. See DESIGN.md's *Build order & status*.
M1, M2 and M2.6 of **EVOLUTION.md** are done: the entity world is a **macro,
population-field ecology** (density, substeps, coarse-grid forces, speck
rendering); the rules that run it are a **declared, compiled rule program**
(per-caste rule entries, interaction rules, per-section operator regimes, the
event→response mapping); and the tribes are **small deterministic programs** —
sensing, memory, actions and branching, compiled by the song and executed on
the decision grid every substep. Songs grow balanced, *mixed* societies (block
mix 0.86–0.95) with structured geography, and the opening is a genesis-settled
world, not a blank plate. Everything is deterministic and bit-identical across
the demo, the tools and the prepared-artifact path (28 tests green). It is
**not yet a film** — the camera still cuts rather than travels (M3) and nothing
is muxed to a video file (M4).
## Auditing what it grew
+87
View File
@@ -0,0 +1,87 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>metabolism — songs as substrate</title>
<link rel="stylesheet" href="/src/demo/style.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<div class="brand-mark" aria-hidden="true"></div>
<div class="brand-text">
<h1>metabolism</h1>
<p class="tagline">song in → world out. pick a track, grow its film.</p>
</div>
</div>
<div class="controls">
<label class="ctl" title="The seed picks the exact world a song grows. Same song + same seed is the same film, bit for bit.">
<span>seed</span>
<input id="seed" type="number" min="1" max="4294967295" step="1" value="12345" />
</label>
<label class="ctl" title="Render resolution. 640×360 is the realtime default; higher is heavier.">
<span>res</span>
<select id="res">
<option value="640x360">640 × 360</option>
<option value="960x540">960 × 540</option>
<option value="1280x720">1280 × 720</option>
</select>
</label>
<label class="ctl" title="Main volume">
<span>vol</span>
<input id="volume" type="range" min="0" max="100" step="1" value="90" />
</label>
</div>
</header>
<main class="layout">
<aside class="library">
<div class="library-head">
<h2>Library</h2>
<span id="lib-count" class="muted"></span>
</div>
<ul id="songs" class="songs"></ul>
</aside>
<section class="stage">
<div class="screen">
<canvas id="view"></canvas>
<div id="overlay" class="overlay" hidden></div>
</div>
<div id="caption" class="caption">…</div>
<div class="transport">
<button id="play" class="play" aria-label="play / pause">▶</button>
<div class="seekbar">
<div class="track" id="track">
<div id="fill" class="fill"></div>
<div id="ticks" class="ticks"></div>
<input id="seek" class="seek-input" type="range" min="0" max="1000" step="1" value="0"
aria-label="seek" />
</div>
<div class="times">
<span id="t0">0:00</span>
<span id="t1">—</span>
</div>
</div>
</div>
<div class="statusline">
<span id="law" class="law">—</span>
<span id="shot" class="muted">—</span>
<span id="hud" class="muted">—</span>
</div>
<p class="footnote">
one run, no clock: the world steps to the song, the camera answers the world, and the
same <code>Run</code> renders here and in the export tools. no <code>Math.random</code>,
no wall clock — same song · same seed · same frame ⇒ same pixel.
</p>
</section>
</main>
<script type="module" src="/src/demo/main.js"></script>
</body>
</html>
+4 -1
View File
@@ -6,9 +6,12 @@
"description": "Songs as substrate: agent ecologies whose laws are derived from the track, rendered deterministically.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "node --test test/*.test.js",
"strip": "node tools/strip.js",
"report": "node tools/report.js"
"report": "node tools/report.js",
"prepare": "node tools/prepare.js"
},
"devDependencies": {
"vite": "^7.2.2"
+16 -14
View File
@@ -21,8 +21,8 @@ export class Camera {
this.cx = 0.5;
this.cy = 0.5;
this.zoom = 1.35;
this.baseZoom = 1.35;
this.zoom = 0.9;
this.baseZoom = 0.9;
this.shot = 0;
this.shotStart = 0;
@@ -50,7 +50,7 @@ export class Camera {
pickFocus(zoom) {
const act = this.world.activity;
const n = 32;
const half = 0.5 / zoom;
const half = Math.min(0.5, 0.5 / zoom);
const lo = half + 0.02;
const hi = 1 - half - 0.02;
let best = -1;
@@ -92,14 +92,14 @@ export class Camera {
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.
// Shot size: the macro world IS the shot. We hang up high so the whole
// map — every culture and the roads between them — is in frame, and
// only dip toward a knot on the rare close beat.
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);
let zoom = 0.92 + (1 - coverage) * 0.55;
if (this.shot % 3 === 2) zoom *= 0.86;
else if (this.shot % 3 === 1) zoom *= 1.12;
zoom = clamp(zoom, 0.62, 1.5);
const focus = this.pickFocus(zoom);
this.target = { x: focus.x, y: focus.y };
@@ -128,14 +128,16 @@ export class Camera {
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.cx = this.target.x + (this.dir * 0.030 * e) / this.zoom;
this.cy = this.target.y - (this.dir * 0.016 * e) / this.zoom;
this.zoom = this.baseZoom * (1 + 0.10 * e);
this.clampToWorld();
}
clampToWorld() {
const half = 0.5 / this.zoom;
// Zoom < 1 means the whole world fits; there is nothing to clamp to,
// so hold the centre. Otherwise bound the lens inside the dish.
const half = Math.min(0.5, 0.5 / this.zoom);
this.cx = clamp(this.cx, half, 1 - half);
this.cy = clamp(this.cy, half, 1 - half);
}
+122
View File
@@ -0,0 +1,122 @@
// WebAudio transport for the demo.
//
// The track is played from the exact mono PCM the analysis ran on, so the
// clock the world follows is the clock of the song itself. Playback position
// is derived from the AudioContext clock (currentTime), not from a timer, so
// the rings land on the hits even when the UI thread is busy rendering.
//
// The position is the only "wall clock" in the page, and it is deliberately
// not fed into the simulation: the world is a pure function of (frame, seed).
// The audio clock only chooses which frame the world should have reached.
export class AudioTransport {
/**
* @param {Float32Array} pcm mono samples
* @param {number} sampleRate
* @param {{ onEnd?: () => void }} [opts]
*/
constructor(pcm, sampleRate, { onEnd = () => {} } = {}) {
this.pcm = pcm;
this.sampleRate = sampleRate;
this.onEnd = onEnd;
this.ctx = null;
this.buffer = null;
this.gain = null;
this.src = null;
this.active = false;
this.when = 0; // ctx.currentTime at which the current src started
this.offset = 0; // seconds into the track the current src represents
this.volume = 0.9;
}
get duration() {
return this.buffer ? this.buffer.duration : this.pcm.length / this.sampleRate;
}
/** Lazily created inside a user gesture; creating it is policy-proof. */
ensure() {
if (this.ctx) return this.ctx;
const Ctx = window.AudioContext || window.webkitAudioContext;
this.ctx = new Ctx();
this.buffer = this.ctx.createBuffer(1, this.pcm.length, this.sampleRate);
this.buffer.copyToChannel(this.pcm, 0);
this.gain = this.ctx.createGain();
this.gain.gain.value = this.volume;
this.gain.connect(this.ctx.destination);
return this.ctx;
}
/** Seconds into the track, using the audio clock while playing. */
position() {
if (!this.active || !this.ctx) return this.offset;
return Math.min(this.duration, this.offset + Math.max(0, this.ctx.currentTime - this.when));
}
async play() {
const ctx = this.ensure();
if (ctx.state === 'suspended') await ctx.resume();
this.stopSource();
const src = ctx.createBufferSource();
src.buffer = this.buffer;
src.connect(this.gain);
const when = ctx.currentTime + 0.025;
this.src = src;
this.when = when;
this.active = true;
src.onended = () => {
// Only a natural finish ends the track; our own stopSource() has
// already cleared this.src, so a manual stop never reaches here.
if (this.active && this.src === src) {
this.active = false;
this.src = null;
this.offset = this.duration;
this.onEnd();
}
};
src.start(when, this.offset);
}
pause() {
if (!this.active) return;
this.offset = this.position();
this.stopSource();
this.active = false;
}
seek(seconds) {
const target = Math.max(0, Math.min(this.duration, seconds));
const wasActive = this.active;
if (wasActive) {
this.active = false;
this.stopSource();
}
this.offset = target;
if (wasActive) this.play();
}
setVolume(v) {
this.volume = Math.max(0, Math.min(1, v));
if (this.gain) this.gain.gain.value = this.volume;
}
stopSource() {
if (this.src) {
try {
this.src.onended = null;
this.src.stop();
} catch {
/* already stopped */
}
this.src = null;
}
}
dispose() {
this.stopSource();
this.ctx?.close();
this.ctx = null;
this.buffer = null;
}
}
+62
View File
@@ -0,0 +1,62 @@
// Catalog loading + display helpers for the demo page.
// The catalog itself is computed by the vite plugin (node-side analysis of the
// exact same pipeline the browser uses), so the library card and the player
// can never disagree about a song's shape.
const LAW_COLORS = {
intro: '#7d8fa3',
build: '#d8a24a',
drop: '#e2434b',
breakdown: '#4a8fe2',
sustain: '#3fbf9f',
outro: '#9a7fd6',
};
export function fetchCatalog() {
return fetch('/songs/catalog.json').then((r) => {
if (!r.ok) throw new Error(`catalog unavailable (${r.status})`);
return r.json();
});
}
export function lawColor(kind) {
return LAW_COLORS[kind] || '#5f6673';
}
/** A CSS accent (hue/sat from the track's palette, so the page wears the song). */
export function accentFor(entry) {
const hue = Number(entry.hue) || 0.6;
const sat = Number(entry.sat) || 0.6;
return `hsl(${Math.round(hue * 360)}, ${Math.round(Math.min(1, sat) * 100)}%, 58%)`;
}
export function fmtTime(s) {
if (!Number.isFinite(s) || s < 0) return '0:00';
const m = Math.floor(s / 60);
const ss = String(Math.floor(s % 60)).padStart(2, '0');
return `${m}:${ss}`;
}
/** A one-line natural-language summary of what the track is. */
export function tagsFor(entry) {
const q = entry.summary;
const bits = [];
if (q.bpm) bits.push(`${Math.round(q.bpm)} BPM`);
bits.push(q.centroid > 0.5 ? 'bright' : 'dark');
bits.push(q.flatness < 0.12 ? 'tonal' : q.flatness < 0.4 ? 'grained' : 'noisy');
bits.push(q.beatStrength > 0.5 ? 'pulsing' : 'beatless');
bits.push(`${entry.laws.length} ${entry.laws.length === 1 ? 'part' : 'parts'}`);
return bits.join(' · ');
}
/** Read the URL's song/seed/res and give safe defaults for the rest. */
export function parseQuery() {
const p = new URLSearchParams(location.search);
const res = ['640x360', '960x540', '1280x720'].includes(p.get('res')) ? p.get('res') : '640x360';
return { song: p.get('song') || 'drone', seed: parseSeed(p.get('seed')), res };
}
function parseSeed(v) {
const n = parseInt(v ?? '', 10);
return Number.isFinite(n) && n > 0 ? n : 12345;
}
+424
View File
@@ -0,0 +1,424 @@
// metabolism — demo player
//
// The page is a thin shell over the real pipeline: a song is decoded with the
// exact decodeWav/analyse/buildSong the tools use, the world steps inside the
// same Run, and the audio clock only decides which frame the world should have
// reached. Nothing in the simulation reads the clock or a RNG.
//
// boot() -> load catalog, pick song from URL, start rAF loop
// selectSong(id) -> fetch/decode/analyse/build, grow to frame 0
// rAF loop (playing) -> target frame from audio clock -> step -> render
// seek/seed/res -> forward stepping is free; a backwards seek re-grows
// from frame 0 (the world and camera are stateful)
import { buildSong } from '../song/song.js';
import { deserializeAnalysis } from '../song/serialize.js';
import { decodeWav, mono } from '../audio/wav.js';
import { analyse } from '../audio/analyse.js';
import { FPS, Player } from './player.js';
import { AudioTransport } from './audio.js';
import { fetchCatalog, fmtTime, lawColor, accentFor, tagsFor, parseQuery } from './catalog.js';
const $ = (id) => document.getElementById(id);
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
const els = {
songs: $('songs'),
libCount: $('lib-count'),
view: $('view'),
overlay: $('overlay'),
caption: $('caption'),
play: $('play'),
seek: $('seek'),
fill: $('fill'),
ticks: $('ticks'),
t0: $('t0'),
t1: $('t1'),
law: $('law'),
shot: $('shot'),
hud: $('hud'),
seed: $('seed'),
res: $('res'),
volume: $('volume'),
};
const state = {
catalog: null,
byId: new Map(),
currentId: null,
cur: null, // { entry, song, analysis, player, audio }
seed: 12345,
res: '640x360',
playing: false,
loading: false,
};
// ---- audio pipeline (browser side of the shared pipeline) ------------------
function fetchWav(id) {
return fetch(`/songs/${id}.wav`).then((r) => {
if (!r.ok) throw new Error(`no such song: ${id} (${r.status})`);
return r.arrayBuffer();
});
}
// Analysis depends only on the song (not the seed), so cache it; it is tiny
// (~a few hundred KB). The decoded PCM (21 MB for 120 s) is kept for one slot
// so flipping seed/res of the current song is instant. When `tools/prepare.js`
// has written a prepared artifact (out/prepared/<id>.json), the browser loads
// that instead of re-analysing the WAV — viewing skips the expensive pass.
const analysisCache = new Map();
let pcmSlot = null; // { id, pcm, sampleRate }
async function getAnalysis(id) {
const cached = analysisCache.get(id);
if (cached) return cached;
let analysis = null;
try {
const r = await fetch(`/prepared/${id}.json`);
if (r.ok) analysis = deserializeAnalysis(await r.json());
} catch { /* fall through to live analysis */ }
if (!analysis) {
analysis = analyse(pcmSlot.pcm, pcmSlot.sampleRate, { fps: FPS });
}
analysisCache.set(id, analysis);
return analysis;
}
async function loadSongData(id, seed, res) {
const [w, h] = res.split('x').map(Number);
if (!pcmSlot || pcmSlot.id !== id) {
pcmSlot = await fetchWav(id).then((ab) => {
const wav = decodeWav(ab);
if (!wav) throw new Error(`could not decode WAV: ${id}`);
return { id, pcm: mono(wav.channels), sampleRate: wav.sampleRate };
});
}
const analysis = await getAnalysis(id);
const song = buildSong(analysis, seed);
const player = new Player(els.view, w, h);
player.build(song);
const audio = new AudioTransport(pcmSlot.pcm, pcmSlot.sampleRate, { onEnd: handleEnded });
return { entry: state.byId.get(id), song, analysis, player, audio };
}
// ---- song selection --------------------------------------------------------
async function selectSong(id, { seed = state.seed, res = state.res, restartAt = 0, push = true } = {}) {
if (state.loading) return;
const entry = state.byId.get(id);
if (!entry) return;
state.loading = true;
const carryPos = restartAt ?? state.cur?.audio.position() ?? 0;
pauseCurrent();
setCards(id);
setOverlay(`<span class="spinner"></span>warming the world…<small>${id} · seed ${seed}</small>`);
try {
const t0 = performance.now();
const ready = await loadSongData(id, seed, res);
const old = state.cur;
state.currentId = id;
state.seed = seed;
state.res = res;
state.cur = ready;
// The new load owns the current audio; free the old context and its
// PCM buffer copy. (UI bookkeeping only — the sim never allocates here.)
if (old && old.audio !== ready.audio) old.audio.dispose();
ready.player.seekTo(carryPos);
ready.audio.seek(carryPos);
ready.player.renderCurrent();
els.seed.value = String(seed);
els.res.value = res;
applyAccent(ready.song);
setCaption(ready.entry);
buildTicks();
updateSeekUI(carryPos);
updateHUD();
setOverlay(null);
document.title = `metabolism — ${ready.entry.name}`;
state.loading = false;
if (push) syncUrl();
console.info(`[metabolism] ${id} · seed ${seed} · ${Math.round(performance.now() - t0)} ms to grow to ${fmtTime(carryPos)}`);
} catch (err) {
console.error(err);
setOverlay(`<b>could not grow “${id}”</b><small>${err.message}</small>`);
state.loading = false;
}
}
function pauseCurrent() {
if (state.cur) state.cur.audio.pause();
state.playing = false;
updatePlayButton();
}
// ---- transport -------------------------------------------------------------
async function togglePlay() {
const c = state.cur;
if (!c || state.loading) return;
if (state.playing) {
c.audio.pause();
state.playing = false;
updatePlayButton();
return;
}
if (c.audio.position() >= c.analysis.duration - 0.05) {
c.audio.seek(0);
c.player.seekTo(0);
c.player.renderCurrent();
updateSeekUI(0);
}
state.playing = true;
updatePlayButton();
setOverlay(null);
await c.audio.play();
}
function handleEnded() {
state.playing = false;
updatePlayButton();
updateSeekUI(state.cur.audio.position());
setOverlay(`<b>· end ·</b><small>that world is done — press play to grow it again</small>`);
}
function doSeek(seconds) {
const c = state.cur;
if (!c) return;
const sec = clamp(seconds, 0, c.analysis.duration);
c.audio.seek(sec);
c.player.seekTo(sec);
c.player.renderCurrent();
updateSeekUI(sec);
updateHUD();
}
// ---- render loop -----------------------------------------------------------
let hudTick = 0;
function loop() {
const c = state.cur;
if (c && state.playing) {
const pos = c.audio.position();
const target = clamp(Math.floor(pos * FPS), 0, c.analysis.frames - 1);
c.player.stepTo(target);
c.player.renderCurrent();
updateSeekUI(pos);
if (++hudTick % 4 === 0) updateHUD();
}
requestAnimationFrame(loop);
}
// ---- UI updates ------------------------------------------------------------
function updateSeekUI(pos) {
const c = state.cur;
if (!c) return;
const pct = clamp((pos / c.analysis.duration) * 100, 0, 100);
els.fill.style.width = `${pct}%`;
els.seek.value = Math.round(pct * 10);
els.t0.textContent = fmtTime(pos);
els.t1.textContent = fmtTime(c.analysis.duration);
}
function updatePlayButton() {
els.play.textContent = state.playing ? '❚❚' : '▶';
}
function setCaption(entry) {
els.caption.innerHTML =
`<b>${entry.name}</b> — ${entry.species} · seed ${state.seed}<br>` +
`<span class="muted">${tagsFor(entry)}</span>`;
}
function setCards(activeId) {
for (const li of els.songs.children) li.classList.toggle('active', li.dataset.id === activeId);
}
function applyAccent(song) {
const { hue, sat } = song.palette;
document.documentElement.style.setProperty(
'--accent',
`hsl(${Math.round(hue * 360)}, ${Math.round(Math.min(1, sat) * 100)}%, 58%)`
);
}
function buildTicks() {
const c = state.cur;
els.ticks.innerHTML = '';
if (!c) return;
const D = c.analysis.duration;
for (const l of c.song.laws.slice(1)) {
const t = document.createElement('span');
t.className = 'tick';
t.style.left = `${(l.start / D) * 100}%`;
t.style.background = lawColor(l.kind);
t.title = `${l.kind} @ ${fmtTime(l.start)}`;
els.ticks.appendChild(t);
}
}
function setLaw() {
const c = state.cur;
if (!c) return;
const t = c.audio.position();
const laws = c.song.laws;
let i = 0;
for (let k = 0; k < laws.length; k++) if (t >= laws[k].start) i = k;
const kind = laws[i].kind;
els.law.textContent = `${kind} · section ${i + 1}/${laws.length}`;
els.law.style.color = lawColor(kind);
els.law.style.borderColor = lawColor(kind);
}
function updateHUD() {
const c = state.cur;
if (!c) return;
const w = c.player.run.world;
const s = w.stats();
els.hud.textContent =
`${s.alive} agents · field ${s.fieldMass.toFixed(2)} · trail ${s.trailMass.toFixed(3)}`;
const cam = c.player.run.camera;
const size = cam.zoom < 1.8 ? 'wide' : cam.zoom < 2.7 ? 'medium' : 'close';
els.shot.textContent = `shot ${cam.shot} · ${size} · zoom ${cam.zoom.toFixed(2)}`;
setLaw();
}
function setOverlay(html) {
if (!html) {
els.overlay.hidden = true;
return;
}
els.overlay.hidden = false;
els.overlay.innerHTML = html;
}
function syncUrl() {
const p = new URLSearchParams();
p.set('song', state.currentId);
p.set('seed', String(state.seed));
p.set('res', state.res);
history.replaceState(null, '', `?${p}`);
}
// ---- library ---------------------------------------------------------------
function renderLibrary(catalog) {
els.songs.innerHTML = '';
state.byId = new Map();
for (const entry of catalog.songs) {
state.byId.set(entry.name, entry);
const li = document.createElement('li');
li.className = 'song';
li.dataset.id = entry.name;
const swatch = document.createElement('span');
swatch.className = 'swatch';
swatch.style.background = accentFor(entry);
const meta = document.createElement('span');
meta.className = 'smeta';
const name = document.createElement('span');
name.className = 'sname';
name.textContent = entry.name;
const species = document.createElement('span');
species.className = 'sspecies';
species.textContent = entry.species;
meta.append(name, species);
const tags = document.createElement('span');
tags.className = 'stags';
tags.textContent = tagsFor(entry);
li.append(swatch, meta, tags);
li.addEventListener('click', () => selectSong(entry.name));
els.songs.appendChild(li);
}
}
// ---- controls --------------------------------------------------------------
els.play.addEventListener('click', togglePlay);
let pausedByDrag = false;
els.seek.addEventListener('pointerdown', () => {
const c = state.cur;
if (!c) return;
pausedByDrag = state.playing;
if (pausedByDrag) {
c.audio.pause();
state.playing = false;
updatePlayButton();
}
});
els.seek.addEventListener('change', () => {
const c = state.cur;
if (!c) return;
const sec = (els.seek.valueAsNumber / 1000) * c.analysis.duration;
doSeek(sec);
if (pausedByDrag) {
pausedByDrag = false;
state.playing = true;
updatePlayButton();
c.audio.play();
}
});
els.seed.addEventListener('change', () => {
const v = parseInt(els.seed.value, 10);
const seed = Number.isFinite(v) && v > 0 ? v >>> 0 : 12345;
els.seed.value = String(seed);
selectSong(state.currentId, { seed, res: state.res, restartAt: state.cur?.audio.position() ?? 0 });
});
els.res.addEventListener('change', () => {
selectSong(state.currentId, { seed: state.seed, res: els.res.value, restartAt: state.cur?.audio.position() ?? 0 });
});
els.volume.addEventListener('input', () => {
state.cur?.audio.setVolume(els.volume.valueAsNumber / 100);
});
window.addEventListener('keydown', (e) => {
if (/INPUT|SELECT|TEXTAREA/.test(e.target.tagName)) return;
if (e.code === 'Space') {
e.preventDefault();
togglePlay();
} else if (e.key === 'ArrowRight') {
e.preventDefault();
doSeek((state.cur?.audio.position() ?? 0) + 5);
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
doSeek((state.cur?.audio.position() ?? 0) - 5);
}
});
// ---- boot ------------------------------------------------------------------
async function boot() {
const q = parseQuery();
state.seed = q.seed;
state.res = q.res;
els.seed.value = String(q.seed);
els.res.value = q.res;
els.volume.value = '90';
els.libCount.textContent = '…';
try {
const catalog = await fetchCatalog();
state.catalog = catalog;
renderLibrary(catalog);
els.libCount.textContent = `${catalog.songs.length} tracks`;
await selectSong(q.song, { seed: q.seed, res: q.res, push: false });
} catch (err) {
console.error(err);
els.libCount.textContent = 'unavailable';
setOverlay(`<b>no library</b><small>is the dev server serving <code>../flow-state/test/songs</code>? — ${err.message}</small>`);
}
requestAnimationFrame(loop);
}
// Debug handle: the live world is reachable for poking, but the sim itself is
// clock-free; touching it is how the tools inspect it too.
window.metabolism = { state, get run() { return state.cur?.player.run ?? null; } };
boot();
+69
View File
@@ -0,0 +1,69 @@
// The demo's Player wraps the same Run the strip/report tools use. It exists
// so the browser never gets its own copy of the pipeline:
//
// world.step() -> camera.update() -> renderer.render()
//
// is exactly what film.js's stepTo/renderAt do, just drawn to a canvas. The
// pixels written here are the pixels the export tools write.
import { Run } from '../film.js';
export const FPS = 30;
export class Player {
/**
* @param {HTMLCanvasElement} canvas
* @param {number} width render width in pixels
* @param {number} height render height in pixels
*/
constructor(canvas, width, height) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.width = width;
this.height = height;
this.run = null;
this.song = null;
this.img = null;
}
/** (Re)build the Run for a song at the current canvas size. */
build(song) {
this.song = song;
this.canvas.width = this.width;
this.canvas.height = this.height;
this.run = new Run(song, { fps: FPS, width: this.width, height: this.height, seed: song.seed });
// The renderer's framebuffer aliases the canvas ImageData so there is
// exactly one copy per frame: render() draws straight into img.data and
// putImageData() uploads it. Same bitmap the renderer paints into node.
this.img = this.ctx.createImageData(this.width, this.height);
this.run.renderer.bitmap.data = this.img.data;
}
/** Advance the ecology to a frame without rendering. */
stepTo(frame) {
this.run.stepTo(frame);
}
/** Step the world to a time in seconds, rebuilding on a backwards seek. */
seekTo(seconds) {
const target = clamp(Math.floor(seconds * FPS), 0, this.song.analysis.frames - 1);
if (target < this.run.world.frame) {
// The world and the camera are stateful; a backwards seek re-grows
// from frame 0. Forward stepping is cheap (pure sim), so only a
// rewind pays the replay cost.
this.run = new Run(this.song, { fps: FPS, width: this.width, height: this.height, seed: this.song.seed });
this.run.renderer.bitmap.data = this.img.data;
}
this.run.stepTo(target);
return target;
}
/** Render the world's current frame and upload it to the canvas. */
renderCurrent() {
const { run } = this;
run.renderer.render(run.world, run.song, run.camera, run.world.frame);
this.ctx.putImageData(this.img, 0, 0);
}
}
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
+371
View File
@@ -0,0 +1,371 @@
/* metabolism demo player — dark, minimal, one accent taken from the track. */
:root {
--bg: #0a0c11;
--panel: #10141c;
--panel-2: #141924;
--border: #1e2532;
--text: #d7dbe3;
--muted: #7c8494;
--accent: #8fa7ff;
--mono: ui-monospace, "SFMono-Regular", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 15px;
line-height: 1.4;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* ---- topbar -------------------------------------------------------------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, #0d1119, var(--bg));
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
width: 14px;
height: 14px;
border-radius: 50%;
background: radial-gradient(circle at 30% 30%, var(--accent), #20315e 70%);
box-shadow: 0 0 14px var(--accent);
}
.brand h1 {
margin: 0;
font-size: 20px;
letter-spacing: 0.02em;
font-weight: 650;
}
.tagline { margin: 0; color: var(--muted); font-size: 12.5px; }
.controls { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.ctl { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; }
.ctl > span { text-transform: uppercase; letter-spacing: 0.06em; }
.ctl input[type="number"] {
width: 110px;
padding: 5px 8px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-family: var(--mono);
font-size: 13px;
}
.ctl select {
padding: 5px 8px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
}
.ctl input[type="range"] { width: 110px; accent-color: var(--accent); }
/* ---- layout -------------------------------------------------------------- */
.layout {
flex: 1;
display: grid;
grid-template-columns: 320px 1fr;
gap: 0;
min-height: 0;
}
@media (max-width: 860px) {
.layout { grid-template-columns: 1fr; }
.library { max-height: 260px; }
}
/* ---- library ------------------------------------------------------------- */
.library {
border-right: 1px solid var(--border);
background: var(--panel);
display: flex;
flex-direction: column;
min-height: 0;
padding: 14px 12px;
overflow: hidden;
}
.library-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0 6px 10px;
}
.library h2 { margin: 0; font-size: 13px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); }
.songs {
list-style: none;
margin: 0;
padding: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.song {
display: grid;
grid-template-columns: 12px 1fr auto;
align-items: center;
gap: 10px;
padding: 9px 10px;
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
user-select: none;
transition: background 120ms, border-color 120ms;
}
.song:hover { background: var(--panel-2); }
.song.active {
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
background: color-mix(in srgb, var(--accent) 10%, var(--panel-2));
}
.swatch {
width: 10px;
height: 10px;
border-radius: 50%;
align-self: center;
box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 60%, transparent);
}
.smeta { display: flex; flex-direction: column; min-width: 0; }
.sname { font-size: 14.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sspecies { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stags {
color: var(--muted);
font-size: 11px;
font-family: var(--mono);
text-align: right;
white-space: nowrap;
}
/* ---- stage --------------------------------------------------------------- */
.stage {
padding: 20px clamp(16px, 3vw, 36px) 18px;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.screen {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 10px;
overflow: hidden;
background: #05070a;
border: 1px solid var(--border);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.screen canvas {
display: block;
width: 100%;
height: 100%;
image-rendering: auto;
object-fit: fill;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: rgba(5, 7, 10, 0.55);
color: var(--text);
font-size: 14px;
letter-spacing: 0.02em;
text-align: center;
padding: 16px;
backdrop-filter: blur(1px);
animation: fadein 180ms ease-out;
}
.overlay[hidden] { display: none; }
.overlay small { color: var(--muted); }
.overlay code { font-family: var(--mono); font-size: 12px; color: #9aa3b2; }
.overlay .spinner {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid var(--border);
border-top-color: var(--accent);
animation: spin 700ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
.caption {
font-size: 13.5px;
color: var(--muted);
text-align: center;
min-height: 1.2em;
}
.caption b { color: var(--text); }
/* ---- transport ----------------------------------------------------------- */
.transport { display: flex; align-items: center; gap: 14px; }
.play {
width: 46px;
height: 46px;
flex: 0 0 auto;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--accent);
color: #0a0c11;
font-size: 17px;
cursor: pointer;
display: grid;
place-items: center;
transition: transform 90ms, box-shadow 90ms;
}
.play:hover { box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 55%, transparent); }
.play:active { transform: scale(0.94); }
.seekbar { flex: 1; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.track {
position: relative;
height: 22px;
border-radius: 6px;
}
.fill {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
height: 4px;
width: 0%;
border-radius: 3px;
background: var(--accent);
pointer-events: none;
}
.ticks {
position: absolute;
inset: 0;
pointer-events: none;
}
.tick {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 2px;
height: 8px;
border-radius: 1px;
background: var(--border);
opacity: 0.9;
}
.seek-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
}
.track:hover .tick { background: var(--muted); }
.times {
display: flex;
justify-content: space-between;
font-family: var(--mono);
font-size: 12px;
color: var(--muted);
}
/* ---- statusline ---------------------------------------------------------- */
.statusline {
display: flex;
align-items: center;
gap: 14px;
font-size: 12.5px;
flex-wrap: wrap;
}
.law {
font-family: var(--mono);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 12px;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
background: var(--panel-2);
}
.footnote {
margin: 4px 0 0;
color: var(--muted);
font-size: 11.5px;
line-height: 1.55;
max-width: 72ch;
}
.footnote code { font-family: var(--mono); font-size: 11px; color: #9aa3b2; }
/* ---- scrollbars ---------------------------------------------------------- */
.songs::-webkit-scrollbar { width: 8px; }
.songs::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.songs::-webkit-scrollbar-track { background: transparent; }
+11 -4
View File
@@ -7,12 +7,17 @@ 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 } = {}) {
constructor(song, { fps = 30, width = 640, height = 360, seed = song.seed, field, genesis } = {}) {
this.song = song;
this.fps = fps;
this.width = width;
this.height = height;
this.world = new World(song, { fps, seed });
this.world = new World(song, {
fps,
seed,
...(field ? { field } : {}),
...(genesis !== undefined ? { genesis } : {}),
});
this.camera = new Camera(song, this.world, { fps, width, height });
this.renderer = new Renderer(width, height);
}
@@ -46,8 +51,10 @@ export class Run {
}
/** Advance without rendering; used by metrics and by the report. */
simulate() {
const n = this.song.analysis.frames;
simulate(seconds) {
const n = seconds
? Math.min(this.song.analysis.frames, Math.round(seconds * this.fps) + 1)
: this.song.analysis.frames;
while (this.world.frame < n - 1) {
this.world.step();
this.camera.update(this.world.frame);
+54 -28
View File
@@ -115,18 +115,23 @@ function smoothSeries(values, w) {
*/
export function evaluate(world, song) {
const a = song.analysis;
// Evaluate over the film that actually ran (simulate may have stopped early).
const N = Math.max(1, Math.min(a.frames, world.frame + 1));
const one = (arr) => {
const max = Math.max(...arr);
return max > 1e-9 ? arr.map((v) => v / max) : arr;
const slice = arr.slice(0, N);
const max = Math.max(...slice);
return max > 1e-9 ? slice.map((v) => v / max) : slice;
};
const energy = one(Array.from(a.energy));
const flux = one(Array.from(a.flux));
const energyRaw = Array.from(a.energy);
const fluxRaw = Array.from(a.flux);
const energy = one(energyRaw);
const flux = one(fluxRaw);
// 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);
const onset = new Float32Array(N);
for (const o of a.onsets.low) if (o.frame < N) onset[o.frame] = Math.max(onset[o.frame], 0.5);
for (const o of a.onsets.mid) if (o.frame < N) onset[o.frame] = Math.max(onset[o.frame], 0.35);
for (const o of a.onsets.high) if (o.frame < N) 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.
@@ -139,7 +144,7 @@ export function evaluate(world, song) {
const lag = 8; // frames at 30fps
let hit = 0;
let total = 0;
for (let f = lag; f < a.frames; f++) {
for (let f = lag; f < N; f++) {
const cue = energy[f - lag] > 0.35 || onset[f - lag] > 0;
if (!cue) continue;
total++;
@@ -147,7 +152,7 @@ export function evaluate(world, song) {
}
const response = total > 0 ? hit / total : 0;
const X = Array.from({ length: a.frames }, (_, f) => [
const X = Array.from({ length: N }, (_, f) => [
energy[f],
onset[f],
flux[f],
@@ -186,27 +191,48 @@ export function evaluate(world, song) {
* 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.
* Works over the population fields of the macro world (no per-agent arrays).
*/
export function fingerprint(world) {
const F = world.field;
const g = 16;
const cell = F / g;
const agents = new Float32Array(g * g);
const pop = 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]++;
const balance = [0, 0, 0];
for (let c = 0; c < 3; c++) {
const p = world.pop[c];
for (let fy = 0; fy < g; fy++) {
for (let fx = 0; fx < g; fx++) {
let s = 0;
const y0 = Math.round(fy * cell);
const y1 = Math.round((fy + 1) * cell);
const x0 = Math.round(fx * cell);
const x1 = Math.round((fx + 1) * cell);
for (let y = y0; y < y1; y += 2) {
for (let x = x0; x < x1; x += 2) s += p[y * F + x];
}
pop[fy * g + fx] += s * 4;
}
}
let tot = 0;
const pp = world.pop[c];
for (let i = 0; i < pp.length; i++) tot += pp[i];
balance[c] = tot;
}
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++) {
for (let fy = 0; fy < g; fy++) {
for (let fx = 0; fx < g; fx++) {
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];
const y0 = Math.round(fy * cell);
const y1 = Math.round((fy + 1) * cell);
const x0 = Math.round(fx * cell);
const x1 = Math.round((fx + 1) * cell);
for (let y = y0; y < y1; y += 2) {
for (let x = x0; x < x1; x += 2) s += t[y * F + x];
}
trail[gy * g + gx] += s * 16;
trail[fy * g + fx] += s * 4;
}
}
}
@@ -214,16 +240,16 @@ export function fingerprint(world) {
let occupancy = 0;
let cluster = 0;
let trailFill = 0;
let maxA = 0;
let maxP = 0;
for (let i = 0; i < n; i++) {
if (agents[i] >= 3) occupancy++;
maxA = Math.max(maxA, agents[i]);
if (trail[i] > 3) trailFill++;
if (pop[i] >= 4) occupancy++;
maxP = Math.max(maxP, pop[i]);
if (trail[i] > 1.5) 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);
const total = Math.max(1, balance[0] + balance[1] + balance[2]);
// Cluster: compared to what a uniform full plate would hold.
const uniform = total / n;
cluster = uniform > 0 ? Math.min(1, maxP / (uniform * 18)) : 0;
return {
occupancy: Number((occupancy / n).toFixed(3)),
cluster: Number(cluster.toFixed(2)),
+119 -39
View File
@@ -1,9 +1,23 @@
// 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.
// Compose one frame of the macro world.
//
// Ground first (the accumulated geography), then the living density (the
// population glow), then the billions (speck swarms — per-cell deterministic
// dot texture that turns a density number into a crowd on screen), then the
// lens (vignette and grain).
//
// Everything is typed-array math over the same fields the sim steps; the
// speck offsets are pure hashes of (cell, index) so a frame renders exactly
// once, the same in node and in the browser.
import { Bitmap } from './bitmap.js';
// How many specks a given population writes, and the total budget per frame.
const PER_SPECKS = 2.2;
const MAX_SPECKS = 60000;
// Density glow: how fast a cell's population turns into brightness.
const DENSITY_K = 0.16;
const DENSITY_EXPOSURE = 3.2;
export class Renderer {
constructor(width, height, { exposure = 2.6, grain = 2.4, vignette = 0.55 } = {}) {
this.w = width;
@@ -14,6 +28,7 @@ export class Renderer {
this.bitmap = new Bitmap(width, height);
this.F = 0;
this.trailRGBA = null;
this.densityRGBA = null;
this.sharp = null;
this.tight = null;
this.wide = null;
@@ -23,12 +38,15 @@ export class Renderer {
this.F = F;
const n = F * F;
this.trailRGBA = new Uint8ClampedArray(n * 4);
this.densityRGBA = new Uint8ClampedArray(n * 4);
this.groundRGBA = 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);
}
/** The trail field, composed through the palette (the geography). */
trail(world, palette) {
const F = world.field;
if (this.F !== F) this.allocate(F);
@@ -56,9 +74,6 @@ export class Renderer {
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;
@@ -69,6 +84,100 @@ export class Renderer {
return out;
}
/** The living density: each caste's population as an additive glow. */
density(world, palette) {
const F = world.field;
if (this.F !== F) this.allocate(F);
const n = F * F;
const pop = world.pop;
const col = palette.classes;
const out = this.densityRGBA;
for (let i = 0; i < n; i++) {
const a0 = 1 - Math.exp(-pop[0][i] * DENSITY_K);
const a1 = 1 - Math.exp(-pop[1][i] * DENSITY_K);
const a2 = 1 - Math.exp(-pop[2][i] * DENSITY_K);
const r = (a0 * col[0][0] + a1 * col[1][0] + a2 * col[2][0]) * DENSITY_EXPOSURE;
const g = (a0 * col[0][1] + a1 * col[1][1] + a2 * col[2][1]) * DENSITY_EXPOSURE;
const b = (a0 * col[0][2] + a1 * col[1][2] + a2 * col[2][2]) * DENSITY_EXPOSURE;
out[i * 4] = Math.round(255 * (1 - Math.exp(-r)));
out[i * 4 + 1] = Math.round(255 * (1 - Math.exp(-g)));
out[i * 4 + 2] = Math.round(255 * (1 - Math.exp(-b)));
out[i * 4 + 3] = 255;
}
return out;
}
/** Trail geography + living density, merged into ONE ground texture. */
ground(world, palette) {
this.trail(world, palette);
const t = this.trailRGBA;
this.density(world, palette);
const d = this.densityRGBA;
if (!this.groundRGBA) this.groundRGBA = new Uint8ClampedArray(t.length);
const g = this.groundRGBA;
for (let i = 0; i < t.length; i++) {
g[i] = Math.min(255, t[i] + d[i]);
}
return g;
}
/**
* The billion. Every cell with enough density writes a crowd of specks at
* determinist hash offsets, so "how many there are" reads directly as
* "how crowded the screen is". The budget is capped: dense fields saturate
* to a solid glow, sparse ones resolve back into countable points.
*/
speck(world, song, cam, palette) {
const F = world.field;
const bmp = this.bitmap;
const scale = this.w * cam.zoom;
const halfW = this.w / 2;
const halfH = this.h / 2;
const col = palette.classes;
const d = bmp.data;
let budget = MAX_SPECKS;
for (let c = 0; c < 3; c++) {
const p = world.pop[c];
const cc = col[c];
const r255 = cc[0] * 255;
const g255 = cc[1] * 255;
const b255 = cc[2] * 255;
const cx = cam.cx;
const cy = cam.cy;
for (let y = 0; y < F && budget > 0; y++) {
const wy = (y + 0.5) / F;
const sy = (wy - cy) * scale + halfH;
if (sy < -2 || sy > this.h + 2) continue;
for (let x = 0; x < F && budget > 0; x++) {
const v = p[y * F + x];
if (v < 1.2) continue;
const wx = (x + 0.5) / F;
const sx = (wx - cx) * scale + halfW;
if (sx < -2 || sx > this.w + 2) continue;
const count = Math.min(budget, Math.min(14, Math.round(v * PER_SPECKS + 1)));
const bright = 0.5 + 0.5 * Math.min(1, v / 8);
const a = 0.30 + 0.50 * bright;
for (let k = 0; k < count; k++) {
const seed = (x * 374761393 + y * 668265263 + c * 1274126177 + k * 974711) >>> 0;
const s2 = (Math.imul(seed ^ (seed >>> 13), 1274126177) ^ 2246822519) >>> 0;
const px = sx + ((s2 >>> 24) / 255 - 0.5) * 1.8;
const py = sy + (((s2 >>> 16) & 255) / 255 - 0.5) * 1.8;
const ix = px | 0;
const iy = py | 0;
if (ix < 0 || iy < 0 || ix >= this.w || iy >= this.h) continue;
const o = (iy * this.w + ix) * 4;
d[o] = d[o] + r255 * a >= 255 ? 255 : d[o] + r255 * a;
d[o + 1] = d[o + 1] + g255 * a >= 255 ? 255 : d[o + 1] + g255 * a;
d[o + 2] = d[o + 2] + b255 * a >= 255 ? 255 : d[o + 2] + b255 * a;
}
budget -= count;
}
}
}
return budget;
}
render(world, song, camera, frame) {
const bmp = this.bitmap;
const palette = song.palette;
@@ -79,10 +188,10 @@ export class Renderer {
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);
const ground = this.ground(world, palette);
bmp.blitWorld(ground, 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.
// The ring of a hit — the clearest causal link in the film.
const fps = song.analysis.fps;
for (const p of world.pulses) {
const age = (frame - p.frame) / fps;
@@ -97,36 +206,7 @@ export class Renderer {
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));
}
this.speck(world, song, cam, palette);
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);
+4 -2
View File
@@ -51,9 +51,11 @@ function pickPitchClasses(chroma) {
/**
* @param {object} summary analysis.summary
* @param {number} field grid size
* @param {number} [sigmaScale] tightens the fertile patches (macro worlds want
* food localized so colonies deplete barriers between them)
* @returns {Array<{x:number,y:number,r:number,rate:number,pc:number,idx:Int32Array,w:Float32Array}>}
*/
export function buildWells(summary, field) {
export function buildWells(summary, field, sigmaScale = 1) {
const chroma = summary.chroma || new Array(12).fill(0.5);
const pcs = pickPitchClasses(chroma);
const balance = summary.bandBalance || [0.33, 0.33, 0.33];
@@ -64,7 +66,7 @@ export function buildWells(summary, field) {
// 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 sigma = (0.050 + 0.040 * value) * sigmaScale;
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);
File diff suppressed because it is too large Load Diff
+148
View File
@@ -0,0 +1,148 @@
// The rule program (EVOLUTION.md M2).
//
// The world used to be driven by a genome + per-section multiplier laws — the
// same behaviours, stronger or weaker. A rule program is the upgrade: a
// declarative, per-song description of *which* behaviour exists, compiled once
// deterministically from the analysis + seed.
//
// castes each caste is a rule entry: speed, seeking, dispersion, and the
// growth rules (mutualism bonus, social floor, appetite).
// rules 3×3 signed interaction matrix: +attract / −repel, from the
// band-correlation graph.
// sections per-section OPERATOR REGIMES: a breakdown switches swarming off
// and inscription on; a drop opens the floodgates; an intro keeps
// everyone gentle. Structural, not magnitude.
// events which voice feeds which caste, and how hard.
// memory how long the world keeps what it writes.
//
// Everything is a pure function of (analysis summary, derived genome, seed
// stream), so the program is part of the song and the prepared-artifact path
// rebuilds bit-identical programs.
const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
const lerp = (a, b, t) => a + (b - a) * t;
const CASTE_NAMES = ['builder', 'forager', 'spark'];
// Base dispersion per caste (how hard they flee their own crowd) — builders
// stay, sparks scatter some but not so hard that they can never gather.
const BASE_DISPERSION = [0.05, 0.10, 0.20];
// Survival economy (see M1's tuning): caps and appetite are near-level so the
// castes can share a cell; growth is gentle and music-agnostic here, the
// regime operators are what swing it.
const capFor = (split) => 15 + 3 * clamp01(split / 2);
const growthFor = (split) => 0.92 / (1 + 0.10 * clamp01(split / 2)) + 0.24;
const EAT_RATE = 0.042;
/**
* Per-section operator regimes — the structural core of M2.
* Each operator is a weight on a behaviour; roughly: <0.25 the behaviour is
* effectively OFF, ~1.0 it runs normally, >1.5 it dominates.
* chemo follow the food gradient
* attract follow/avoid the other castes (signed rules, scaled here)
* disperse spread away from one's own crowd
* home pull back to the wells (place)
* turbulence noise-driven jitter
* speed / growth / deposit / split / spawn
*/
export const REGIMES = {
intro: { chemo: 0.7, attract: 0.8, disperse: 0.9, home: 1.6, turbulence: 0.5, speed: 0.6, growth: 0.7, deposit: 0.8, split: 0.9, spawn: 0.5 },
build: { chemo: 0.9, attract: 1.0, disperse: 1.1, home: 0.9, turbulence: 0.8, speed: 1.0, growth: 1.2, deposit: 1.0, split: 1.0, spawn: 1.2 },
drop: { chemo: 1.5, attract: 1.6, disperse: 1.7, home: 0.2, turbulence: 2.4, speed: 1.7, growth: 1.7, deposit: 0.6, split: 1.6, spawn: 2.4 },
breakdown: { chemo: 0.4, attract: 0.3, disperse: 0.15, home: 2.2, turbulence: 0.15, speed: 0.4, growth: 0.3, deposit: 2.2, split: 1.4, spawn: 0.25 },
sustain: { chemo: 0.9, attract: 1.0, disperse: 1.0, home: 0.8, turbulence: 0.8, speed: 0.9, growth: 0.9, deposit: 1.1, split: 1.0, spawn: 0.8 },
outro: { chemo: 0.5, attract: 0.6, disperse: 0.5, home: 2.0, turbulence: 0.3, speed: 0.5, growth: 0.4, deposit: 1.8, split: 1.2, spawn: 0.3 },
};
const OP_KEYS = ['chemo', 'attract', 'disperse', 'home', 'turbulence', 'speed', 'growth', 'deposit', 'split', 'spawn'];
// Ceilings so a song's character amplifies a regime without blowing it up.
const OP_MAX = { chemo: 2.0, attract: 2.0, disperse: 2.0, home: 2.4, turbulence: 2.2, speed: 1.8, growth: 1.8, deposit: 2.4, split: 1.8, spawn: 2.2 };
/**
* How the track's own character scales each operator family — this is what
* makes a noisy song structurally different from a tonal one, in the regime
* itself, not just in the castes' numbers.
*/
function songScales(summary) {
const s = summary;
const noise = clamp01(s.flatness);
const density = clamp01(s.onsetDensity / 6);
const beat = clamp01(s.beatStrength);
const tone = clamp01(1 - s.flatness);
return {
chemo: 0.80 + 0.35 * clamp01(s.centroid), // bright mixes smell harder
attract: 0.70 + 0.65 * (1 - noise), // correlated bands draw harder
disperse: 0.70 + 0.60 * noise, // noisy tracks scatter
home: 0.75 + 0.55 * tone, // tonal tracks hold place
turbulence: 0.55 + 1.0 * noise, // noisy tracks jitter
speed: 0.80 + 0.45 * beat, // a pulse makes them move
growth: 0.80 + 0.45 * density, // dense tracks breed more
deposit: 0.70 + 0.70 * tone, // tonal tracks inscribe more
split: 0.85 + 0.35 * density,
spawn: 0.80 + 0.50 * beat,
};
}
/**
* Compile the world's rule program for a song.
* @param {object} summary analysis.summary
* @param {object} genome deriveGenome() output (names, classes, interaction,
* laws, jitter, memory half-lives)
* @param {object} rng seeded Rng fork (unused today — kept in signature so
* a future rule search can jitter the program)
*/
export function compileProgram(summary, genome) {
const scales = songScales(summary);
const castes = genome.classes.map((c, i) => ({
name: CASTE_NAMES[i],
speed: c.speed,
seek: c.seek,
dispersion: BASE_DISPERSION[i],
cap: capFor(c.split),
growth: growthFor(c.split),
eat: EAT_RATE,
deposit: c.deposit,
// Growth rules per caste:
// mutualism — all castes breed faster among the others.
mutual: { base: 0.30, threshold: 6 },
social: null,
}));
const sections = genome.laws.map((law) => {
const base = REGIMES[law.kind] || REGIMES.sustain;
const operators = {};
for (const key of OP_KEYS) {
operators[key] = Math.min(OP_MAX[key], Math.max(0, base[key] * (scales[key] ?? 1)));
}
return { start: law.start, end: law.end, kind: law.kind, energy: law.energy, operators };
});
return {
name: genome.name,
memory: {
trailHalfLife: genome.trailHalfLife,
fieldHalfLife: genome.fieldHalfLife,
// How fast population haze bleeds; the track's noise sets it.
diffusion: 0.004 + 0.045 * clamp01(summary.flatness),
jitter: 0.02 + 0.5 * clamp01(summary.flatness),
},
castes,
rules: genome.interaction,
home: { shared: true, pull: [1.5, 1.1, 0.7], reach: 0.55 },
society: { ceiling: 105, global: 60000 },
sections,
// Which voice feeds which caste: each event splats food and restocks
// the society. The voice speaks first and loudest; sparks — fast and
// ephemeral — take a larger splash of every hit.
events: { splash: [0.5, 0.5, 0.9] },
};
}
export function regimeFor(program, frame, fps) {
const t = frame / fps;
for (let i = program.sections.length - 1; i >= 0; i--) {
if (t >= program.sections[i].start) return program.sections[i];
}
return program.sections[0];
}
export { lerp };
+62
View File
@@ -0,0 +1,62 @@
// Song serialization — the prepare step (EVOLUTION.md).
//
// The whole analysis is deterministic, so a song's *processed* form can be
// written out once and loaded instead of re-derived from the WAV. This module
// is the single serializer for both sides: node's tools/prepare.js writes the
// artifact, the browser deserializes it. `deserializeSong` rebuilds the song
// through the SAME buildSong call the live path uses, so a prepared song is
// bit-identical to one analysed from the WAV — prepare is a cache, never a
// parallel pipeline.
import { buildSong } from './song.js';
const VERSION = 1;
/** Compact everything downstream (world, camera, metrics) needs. */
export function serializeSong(song) {
const a = song.analysis;
return {
version: VERSION,
seed: song.seed,
fps: a.fps,
frames: a.frames,
duration: a.duration,
sampleRate: a.sampleRate,
energy: Array.from(a.energy),
flux: Array.from(a.flux),
dominantHz: Array.from(a.dominantHz),
flatness: Array.from(a.flatness),
onsets: a.onsets,
sections: a.sections,
summary: a.summary,
};
}
/** Rebuild the analysis object the pipeline expects. */
export function deserializeAnalysis(obj) {
return {
fps: obj.fps,
frames: obj.frames,
duration: obj.duration,
sampleRate: obj.sampleRate,
energy: Float32Array.from(obj.energy),
flux: Float32Array.from(obj.flux),
dominantHz: Float32Array.from(obj.dominantHz),
flatness: Float32Array.from(obj.flatness),
onsets: obj.onsets,
sections: obj.sections,
summary: obj.summary,
};
}
/**
* Rebuild a song from a prepared artifact. Same code path as the live WAV
* pipeline (buildSong over a reconstructed analysis), so the world, the camera
* and the metrics cannot drift from it.
* @param {object} obj serializedSong() output
*/
export function deserializeSong(obj) {
if (obj.version !== VERSION) throw new Error(`prepared song version mismatch: ${obj.version}`);
return buildSong(deserializeAnalysis(obj), obj.seed);
}
+7 -1
View File
@@ -11,12 +11,16 @@ import { Rng } from '../rng.js';
import { buildPalette } from './palette.js';
import { buildEvents, indexEvents } from './events.js';
import { deriveGenome } from './species.js';
import { compileProgram } from './rules.js';
import { compileTribes } from './tribe.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'));
const program = compileProgram(analysis.summary, genome);
const tribes = compileTribes(analysis.summary, genome, rng.fork('tribes'));
return {
seed: seed >>> 0,
analysis,
@@ -25,9 +29,11 @@ export function buildSong(analysis, seed = 1) {
events,
index: indexEvents(events, analysis.frames),
genome,
program,
tribes,
name: genome.name,
laws: genome.laws,
};
}
export { buildPalette, buildEvents, deriveGenome };
export { buildPalette, buildEvents, deriveGenome, compileProgram, compileTribes };
+143
View File
@@ -0,0 +1,143 @@
export const S_FOOD = 0;
export const S_SELF = 1;
export const S_OTHER0 = 2;
export const S_OTHER1 = 3;
export const S_TRAIL = 4;
export const S_LAW = 5;
export const S_HUNGER = 6;
export const S_EDGE = 7;
export const S_TIME = 8;
export const S_TOTAL = 9;
export const SENSE_COUNT = 10;
export const A_MOVE_X = 0;
export const A_MOVE_Y = 1;
export const A_EXCRETE = 2;
export const A_EAT = 3;
export const A_HIT = 4;
export const A_SPAWN = 5;
export const A_DRIFT = 6; // gradual direction change following a gradient
export const A_ACCELERATE = 7; // speed up/slow down
export const ACTION_COUNT = 8;
export const REGS = 4;
export const MAX_INSTR = 24;
export const MAX_STEPS = 40;
export const OP = {
SET: 0, ADD: 1, MUL: 2, SUB: 3, CLAMP: 4, LERP: 5,
SENSE: 6, GRAD: 7, IFGT: 8, IFLT: 9, IFEQ: 10, IFNEQ: 11, ACT: 12, HALT: 13,
};
export function compileTribes(summary, genome, rng) {
const noise = clamp01(summary.flatness);
const beat = clamp01(summary.beatStrength);
const density = clamp01(summary.onsetDensity / 6);
const names = ['builder', 'forager', 'spark'];
const BASE_DISPERSION = [0.05, 0.10, 0.20];
const mutual = { base: 0.30, threshold: 6 };
const tribes = [];
for (let t = 0; t < 3; t++) {
const a = (t + 1) % 3;
const b = (t + 2) % 3;
const toA = clamp01(genome.interaction[t][a] * 0.5 + 0.5);
const toB = clamp01(genome.interaction[t][b] * 0.5 + 0.5);
const code = [
[OP.SENSE, 3, S_HUNGER, 1],
[OP.IFGT, 3, 0.45, 4],
[OP.LERP, 0, 0, 1 - 0.5],
[OP.LERP, 1, 0, 1 - 0.5],
[OP.SENSE, 3, S_OTHER0, toA],
[OP.GRAD, 3, S_OTHER0, 0],
[OP.ACT, A_MOVE_X, 3, 1],
[OP.SENSE, 3, S_OTHER1, toB],
[OP.GRAD, 3, S_OTHER1, 1],
[OP.ACT, A_MOVE_Y, 3, 1],
[OP.ACT, A_EXCRETE, 2, 0.6],
[OP.HALT],
[OP.GRAD, 0, S_FOOD, 0, 1.0],
[OP.GRAD, 1, S_FOOD, 1, 1.0],
[OP.ACT, A_MOVE_X, 0, 1],
[OP.ACT, A_MOVE_Y, 1, 1],
[OP.SENSE, 2, S_LAW, 1],
[OP.ACT, A_SPAWN, 2, 0.15 + 0.7 * density],
[OP.GRAD, 0, S_FOOD, 0, 1.0],
[OP.GRAD, 1, S_FOOD, 1, 1.0],
[OP.ACT, A_MOVE_X, 0, 1],
[OP.ACT, A_MOVE_Y, 1, 1],
[OP.SENSE, 2, S_LAW, 1],
[OP.ACT, A_SPAWN, 2, 0.5],
// restless: the tribe re-evaluates its heading every step.
[OP.SENSE, 3, S_EDGE, 1],
[OP.IFGT, 3, 0.5, 4],
[OP.GRAD, 0, S_FOOD, 0, 0.5],
[OP.ACT, A_MOVE_X, 0, 0.5],
[OP.HALT],
// social floor: the spark only blooms in a luminous crowd.
[OP.IFGT, 3, 0.16666666666666666, 4],
];
tribes.push({ name: names[t], init: [0, 0, 0, 0], code: code.slice(0, MAX_INSTR) });
}
return tribes;
}
function clamp01(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function R(memOff, reg) { return mem[memOff + reg]; }
export function runTribe(memOff, senses, grads, act, code) {
let pc = 0;
let steps = 0;
const n = code.length;
act[0] = 0; act[1] = 0; act[2] = 0; act[3] = 1; act[4] = 0; act[5] = 0;
while (steps++ < MAX_STEPS && pc < n) {
const ins = code[pc];
const op = ins[0];
switch (op) {
case OP.SET: mem[memOff + ins[1]] = ins[2]; break;
case OP.ADD: mem[memOff + ins[1]] += R(memOff, ins[2]); break;
case OP.MUL: mem[memOff + ins[1]] *= ins[2]; break;
case OP.SUB: mem[memOff + ins[1]] -= R(memOff, ins[2]); break;
case OP.CLAMP: mem[memOff + ins[1]] = clamp01((R(memOff, ins[1]) - ins[2]) / Math.max(1e-6, ins[3] - ins[2])) * (ins[3] - ins[2]) + ins[2]; break;
case OP.LERP: mem[memOff + ins[1]] += (R(memOff + ins[2]) - R(memOff + ins[1])) * ins[3]; break;
case OP.SENSE: mem[memOff + ins[1]] = senses[ins[2]] * ins[3]; break;
case OP.GRAD: mem[memOff + ins[1]] = grads[ins[2] * 2 + ins[3]] * (ins[4] ?? 1); break;
case OP.IFGT:
if (R(memOff + ins[1]) > ins[2]) { pc = ins[3]; continue; }
break;
case OP.IFLT:
if (R(memOff + ins[1]) < ins[2]) { pc = ins[3]; continue; }
break;
case OP.IFEQ:
if (R(memOff + ins[1]) == ins[2]) { pc = ins[3]; continue; }
break;
case OP.IFNEQ:
if (R(memOff + ins[1]) != ins[2]) { pc = ins[3]; continue; }
break;
case OP.ACT: {
const drive = R(memOff + ins[2]);
const w = ins[3] ?? 1;
switch (ins[1]) {
case A_MOVE_X: act[0] += drive * w; break;
case A_MOVE_Y: act[1] += drive * w; break;
case A_EXCRETE: act[2] += Math.max(0, drive) * w * 1.5; break;
case A_EAT: act[3] *= drive * w + 1; break;
case A_HIT: act[4] += Math.max(0, drive) * w * 0.02; break;
case A_SPAWN: act[5] += Math.max(0, drive) * w * 0.012; break;
case A_DRIFT: act[0] += drive * w * 0.1; act[1] += drive * w * 0.1; break;
case A_ACCELERATE: act[3] *= drive * w + 1; break;
default: break;
}
break;
}
case OP.HALT:
default:
return act;
}
pc++;
}
return act;
}
+4 -4
View File
@@ -20,8 +20,8 @@ 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();
const run = new Run(song, { fps: 30, seed, width: 240, height: 135, field: 48, genesis: 0 });
run.simulate(45);
return { world: run.world, song };
}
@@ -54,7 +54,7 @@ test('fingerprint distances: songs farther than seeds', () => {
});
function growSong(song, seed) {
const run = new Run(song, { fps: 30, seed, width: 240, height: 135 });
run.simulate();
const run = new Run(song, { fps: 30, seed, width: 240, height: 135, field: 48, genesis: 0 });
run.simulate(45);
return { world: run.world, song };
}
+42
View File
@@ -0,0 +1,42 @@
// The prepare step must be a cache, never a parallel pipeline: a song rebuilt
// from a prepared artifact has to produce a world bit-identical to one analysed
// from the WAV, for every frame.
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 { loadSong } from '../tools/load.js';
import { serializeSong, deserializeSong } from '../src/song/serialize.js';
import { Run } from '../src/film.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const BANK = resolve(HERE, '../..', 'flow-state/test/songs');
test('prepared song is bit-identical to the live WAV path', () => {
for (const name of ['drone', 'runner', 'dusk']) {
const { song, analysis } = loadSong(resolve(BANK, `${name}.wav`), { fps: 30, seed: 12345 });
const artifact = serializeSong(song);
const rebuilt = deserializeSong(artifact);
assert.equal(rebuilt.seed, song.seed);
assert.equal(rebuilt.name, song.name);
assert.deepEqual(rebuilt.summary, song.summary);
assert.equal(rebuilt.events.length, song.events.length);
assert.equal(rebuilt.laws.length, song.laws.length);
const frames = [0, 31, 217, 1200, 3599];
const a = new Run(song, { fps: 30, width: 480, height: 270, seed: 12345, field: 64, genesis: 0 }).renderAt(frames);
const b = new Run(rebuilt, { fps: 30, width: 480, height: 270, seed: 12345, field: 64, genesis: 0 }).renderAt(frames);
for (const f of frames) {
const pa = a.get(f).data;
const pb = b.get(f).data;
assert.equal(pa.length, pb.length, `${name} frame ${f} size`);
for (let i = 0; i < pa.length; i++) {
assert.equal(pa[i], pb[i], `${name} frame ${f} pixel ${i}: live=${pa[i]} prepared=${pb[i]}`);
}
}
}
});
+92
View File
@@ -0,0 +1,92 @@
// The rule program (M2) must be: deterministic to compile, structurally
// different across songs, and genuinely EXECUTED by the world (editing an
// operator changes behaviour, not just numbers).
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 { regimeFor } from '../src/song/rules.js';
import { Run } from '../src/film.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const BANK = resolve(HERE, '../..', 'flow-state/test/songs');
function load(name, seed = 12345) {
const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`)));
const analysis = analyse(mono(wav.channels), wav.sampleRate);
return buildSong(analysis, seed);
}
test('the program compiles deterministically', () => {
const a = load('drone', 12345);
const b = load('drone', 12345);
assert.deepEqual(JSON.parse(JSON.stringify(a.program)), JSON.parse(JSON.stringify(b.program)));
});
test('different songs get structurally different programs', () => {
const drone = load('drone').program;
const runner = load('runner').program;
const dusk = load('dusk').program;
// Castes differ (speed/seek/cap/growth from the track's stats).
const dr = JSON.stringify(drone.castes);
assert.notDeepEqual(JSON.parse(dr), JSON.parse(JSON.stringify(runner.castes)));
// Interaction rules differ (band-correlation graph).
assert.notEqual(JSON.stringify(drone.rules), JSON.stringify(dusk.rules));
// At least one operator family differs materially in the same section kind.
const dropOf = (p) => p.sections.find((s) => s.kind === 'drop')?.operators;
const drDrop = dropOf(drone);
const ruDrop = dropOf(runner);
const hasDrop = drDrop && ruDrop;
const differs = Object.keys(drDrop ?? {}).some((k) => Math.abs(drDrop[k] - ruDrop[k]) > 0.05);
assert.ok(!hasDrop || differs, 'drop regimes should differ across songs');
});
test('regimes are structurally distinct by kind', () => {
const p = load('dusk').program; // rich: build → breakdown → drop → outro
const byKind = (k) => p.sections.find((s) => s.kind === k)?.operators ?? p.sections[0].operators;
// breakdown: swarming off, inscription on.
assert.ok(byKind('breakdown').disperse < 0.3, 'breakdown should suppress swarming');
assert.ok(byKind('breakdown').deposit > 1.4, 'breakdown should turn on inscription');
assert.ok(byKind('breakdown').turbulence < 0.4, 'breakdown should calm');
// drop: scatter on, home off — and clearly opposite to the breakdown.
assert.ok(byKind('drop').turbulence > byKind('breakdown').turbulence * 5 + 0.5, 'drop should be turbulent');
assert.ok(byKind('drop').home < byKind('breakdown').home * 0.5, 'drop should abandon home');
assert.ok(byKind('drop').spawn > byKind('breakdown').spawn * 4, 'drop should open the floodgates');
});
test('the world actually executes the program (a growth=0 edit halts breeding)', () => {
const song = load('runner', 12345);
const run = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
for (let f = 0; f < 900; f++) run.world.step();
// Same song, but every regime loses its growth AND spawn operators:
// the world must stop breeding (events may still seed a little, but no
// logistic growth → population collapses far below the live run).
const sterile = load('runner', 12345);
for (const s of sterile.program.sections) {
s.operators.growth = 0;
s.operators.spawn = 0;
}
const run2 = new Run(sterile, { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
for (let f = 0; f < 900; f++) run2.world.step();
assert.ok(run.world.count > 500, `live world should be inhabited (${run.world.count})`);
assert.ok(run2.world.count < run.world.count * 0.1,
`sterile world should collapse (${run2.world.count} vs ${run.world.count})`);
});
test('regimeFor picks the right operators at a boundary', () => {
const p = load('drone').program;
const mid = regimeFor(p, Math.round(p.sections[0].start * 30) + 30, 30);
assert.equal(mid.kind, p.sections[0].kind);
const later = regimeFor(p, Math.round(p.sections[1].start * 30) + 30, 30);
assert.equal(later.kind, p.sections[1].kind);
});
+119
View File
@@ -0,0 +1,119 @@
// The tribe programs (M2.6): the song compiles small deterministic behaviour
// programs with sensing, memory, actions and branching, and the world executes
// them. These tests pin down determinism, per-song structural variety, the
// branching itself, and that the world truly executes the program.
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 {
compileTribes, runTribe, OP, S_HUNGER, S_FOOD, S_SELF, A_MOVE_X, A_MOVE_Y, A_EXCRETE, A_SPAWN,
} from '../src/song/tribe.js';
import { Run } from '../src/film.js';
const HERE = dirname(fileURLToPath(import.meta.url));
const BANK = resolve(HERE, '../..', 'flow-state/test/songs');
function load(name, seed = 12345) {
const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`)));
return buildSong(analyse(mono(wav.channels), wav.sampleRate), seed);
}
function snapshot(code) {
return JSON.stringify(code.map((ins) => ins.join(':')));
}
test('tribes compile deterministically and differ per song', () => {
const a = load('drone').tribes;
const b = load('drone').tribes;
assert.equal(a.length, b.length);
for (let t = 0; t < a.length; t++) assert.equal(snapshot(a[t].code), snapshot(b[t].code));
const runner = load('runner').tribes;
const anyDiffer = a.some((ta, i) => snapshot(ta.code) !== snapshot(runner[i].code));
assert.ok(anyDiffer, 'runner should compile structurally different tribe programs');
});
test('tribes have bounded programs with real branching', () => {
const tribes = load('drone').tribes;
assert.ok(tribes.every((t) => t.code.length > 3 && t.code.length <= 24), 'programs bounded and non-trivial');
const ops = new Set(tribes.flatMap((t) => t.code.map((ins) => ins[0])));
assert.ok(ops.has(OP.IFGT) || ops.has(OP.IFLT), 'programs branch');
assert.ok(ops.has(OP.SENSE) && ops.has(OP.ACT), 'programs sense and act');
});
test('the VM branches on senses: starving seeks food, fed excreats', () => {
// A tiny program: if hunger high → move along food gradient; else excrete.
const code = [
[OP.SENSE, 3, S_HUNGER, 1],
[OP.IFGT, 3, 0.5, 4], // hungry → jump to 4
[OP.ACT, A_EXCRETE, 2, 1], // (fed) R2=0 → excrete ~0
[OP.HALT],
[OP.GRAD, 0, S_FOOD, 0], // hungry: heading x = +1
[OP.GRAD, 1, S_FOOD, 1], // heading y = +1
[OP.ACT, A_MOVE_X, 0, 1],
[OP.ACT, A_MOVE_Y, 1, 1],
[OP.HALT],
];
const mem = new Float32Array(4);
const senses = new Float32Array(10);
const grads = new Float32Array(20);
const act = new Float32Array(6);
// Hungry (HUNGER high) → moves, no excrete.
senses[S_HUNGER] = 0.9;
grads[0] = 1; grads[1] = -1;
runTribe(mem, 0, senses, grads, act, code);
assert.ok(act[A_MOVE_X] > 0 && act[A_MOVE_Y] < 0, 'hungry tribe should move toward food');
// Fed (HUNGER low) → no movement, excrete from R2 (memory persists? R2=0 → excrete 0).
senses[S_HUNGER] = 0.1;
mem.fill(0);
grads[0] = 1; grads[1] = 1;
runTribe(mem, 0, senses, grads, act, code);
assert.equal(act[A_MOVE_X], 0, 'fed tribe should not chase food');
assert.equal(act[A_EXCRETE], 0);
});
test('memories (registers) persist across runs', () => {
const code = [
[OP.SENSE, 0, S_SELF, 1], // R0 = local density
[OP.LERP, 1, 0, 0.5], // R1 decays toward R0 (memory)
[OP.HALT],
];
const mem = new Float32Array(4);
const senses = new Float32Array(10);
const grads = new Float32Array(20);
const act = new Float32Array(6);
senses[S_SELF] = 0.8;
runTribe(mem, 0, senses, grads, act, code);
runTribe(mem, 0, senses, grads, act, code);
assert.ok(mem[1] > 0, 'register kept value across runs');
// R1 after two 50% steps toward 0.8 is exactly 0.6 (float tolerance).
assert.ok(Math.abs(mem[1] - 0.6) < 1e-6, `memory integrates deterministically (${mem[1]})`);
});
test('the world executes the tribe program (a no-op program stops its actions)', () => {
const song = load('runner');
const run = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0 });
for (let f = 0; f < 900; f++) run.world.step();
// Same song, same world — but every tribe program halts immediately: no
// movement or actions from the program (forces become ambient-only). The
// grown world must be materially different.
const inert = load('runner');
for (const tribe of inert.tribes) tribe.code = [[OP.HALT]];
const run2 = new Run(inert, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0 });
for (let f = 0; f < 900; f++) run2.world.step();
let d = 0;
const a = run.world.history.movement;
const b = run2.world.history.movement;
for (let i = 0; i < a.length; i++) d += Math.abs(a[i] - b[i]);
assert.ok(d > 1e-3, `inert programs must change the film (movement Δ=${d.toFixed(3)})`);
});
+28 -1
View File
@@ -24,7 +24,7 @@ function load(name) {
/** 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 });
const run = new Run(song, { fps: 30, seed, width: 240, height: 135, field: 64, genesis: 0 });
for (let f = 0; f < Math.floor(seconds * 30); f++) {
run.world.step();
run.camera.update(f);
@@ -79,3 +79,30 @@ test('three songs grow fingerprints further apart than seeds of one song', () =>
const within = divergence(a, a2);
assert.ok(cross > within * 2, `cross ${cross} !> 2×within ${within}`);
});
test('genesis pre-simulates generations so frame 0 is already an ecosystem', () => {
const song = load('drone');
const withGenesis = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
const without = new Run(song, { fps: 30, seed: 12345, width: 240, height: 135, field: 64, genesis: 0 });
assert.ok(withGenesis.world.genesisFrames > 0, 'song should demand a genesis epoch');
// The world at frame 0 is more evolved (generations already lived)…
assert.ok(withGenesis.world.count > without.world.count * 1.1,
`genesis world should be more populated (${withGenesis.world.count} vs ${without.world.count})`);
// …and the ground already carries history, where a fresh plate has none.
assert.ok(withGenesis.world.sampleTrailMass() > 0.0001,
`genesis world should already hold ink (${withGenesis.world.sampleTrailMass()})`);
assert.equal(withGenesis.world.history.alive[0], 0, 'genesis must not record into the film');
assert.equal(withGenesis.world.frame, -1, 'the film starts at frame 0');
});
test('genesis is deterministic and song-scaled', () => {
const a = new Run(load('drone'), { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
const b = new Run(load('drone'), { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
assert.equal(a.world.genesisFrames, b.world.genesisFrames);
assert.deepEqual(Array.from(a.world.pop[0]), Array.from(b.world.pop[0]));
const runner = new Run(load('runner'), { fps: 30, seed: 12345, width: 240, height: 135, field: 64 });
assert.notEqual(a.world.genesisFrames, runner.world.genesisFrames,
'the genesis amount should depend on the song');
});
+49
View File
@@ -0,0 +1,49 @@
// Prepare a song ahead of time so viewing is fast (EVOLUTION.md: "the prepare
// step"). Writes the full processed analysis + seed as a compact JSON artifact
// that the browser loads instead of re-analysing the WAV.
//
// node tools/prepare.js # drone, runner, dusk @ 12345
// node tools/prepare.js --songs runner # one song
// node tools/prepare.js --songs drone,runner --seeds 12345,777 --out out
//
// Artifacts land in <out>/prepared/<song>-<seed>.json. The WAV is still needed
// at viewing time for audio, but analysis is skipped. deserializeSong() must
// produce a world bit-identical to the live WAV path — test/prepare.test.js
// asserts that.
import { writeFileSync, mkdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadSong } from './load.js';
import { serializeSong } from '../src/song/serialize.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 seeds = (opt.seeds || '12345').split(',').map(Number).filter((n) => Number.isFinite(n));
const outDir = resolve(ROOT, opt.out || 'out');
const dir = resolve(outDir, 'prepared');
mkdirSync(dir, { recursive: true });
let count = 0;
for (const name of names) {
for (const seed of seeds) {
const { song } = loadSong(resolve(BANK, `${name}.wav`), { fps: 30, seed });
const file = resolve(dir, `${name}-${seed}.json`);
const json = JSON.stringify(serializeSong(song));
writeFileSync(file, json);
const kb = (Buffer.byteLength(json) / 1024).toFixed(1);
console.log(`${file} ${kb.padStart(7)} KB ${song.name}`);
count++;
}
}
console.log(`prepared ${count} song${count === 1 ? '' : 's'} → ${dir}`);
+145
View File
@@ -0,0 +1,145 @@
// Vite config for the demo page.
//
// The demo needs the WAV bank, which lives outside this package at
// ../flow-state/test/songs (the same bank the strip/report tools read). This
// plugin gives the browser a clean, versionless URI surface for it:
//
// GET /songs/catalog.json -> { fps, defaultSeed, songs: [...] } (node-side analysis)
// GET /songs/<name>.wav -> the raw WAV, decoded in-browser with the
// exact same decodeWav/analyse code the tools use
//
// In dev the files are streamed from disk; on `vite build` they are emitted
// into dist/songs so `vite preview` shows the same page.
import { defineConfig } from 'vite';
import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadSong } from './tools/load.js';
const BANK = fileURLToPath(new URL('../flow-state/test/songs', import.meta.url));
const PREPARED = fileURLToPath(new URL('./out/prepared', import.meta.url));
// Catalog: one full analysis per song (17 tracks, ~0.2 s each). Built once per
// server process; loads in the browser are cached client-side, so this is the
// only full sweep the demo ever pays for.
let catalogPromise = null;
function getCatalog() {
if (!catalogPromise) catalogPromise = buildCatalog();
return catalogPromise;
}
async function buildCatalog() {
const files = readdirSync(BANK).filter((f) => f.endsWith('.wav')).sort();
const songs = [];
for (const f of files) {
// Yield between songs so the server answers requests while the first
// catalog builds; the page merely waits a beat.
await new Promise((r) => setImmediate(r));
try {
const { song, analysis } = loadSong(resolve(BANK, f), { fps: 30, seed: 12345 });
songs.push({
file: f,
name: basename(f, '.wav'),
duration: analysis.duration,
frames: analysis.frames,
species: song.name,
seed: song.seed,
hue: song.palette.hue,
sat: song.palette.sat,
summary: {
bpm: analysis.summary.bpm,
beatStrength: analysis.summary.beatStrength,
centroid: analysis.summary.centroid,
flatness: analysis.summary.flatness,
dynamics: analysis.summary.dynamics,
onsetDensity: analysis.summary.onsetDensity,
},
laws: song.laws.map((l) => ({ start: l.start, kind: l.kind })),
});
} catch (err) {
console.warn(`[song-bank] skipping ${f}: ${err.message}`);
}
}
return { fps: 30, defaultSeed: 12345, songs };
}
/** Only a plain filename inside the bank may be served; never a traversal. */
function playableWav(file) {
const resolved = resolve(BANK, file);
if (basename(resolved) !== file || !resolved.startsWith(BANK)) return null;
try {
return statSync(resolved).isFile() ? resolved : null;
} catch {
return null;
}
}
function songBankPlugin() {
return {
name: 'metabolism-song-bank',
configureServer(server) {
// Warm the catalog in the background so the first page load is fast.
getCatalog();
server.middlewares.use('/songs', (req, res, next) => {
const pathname = decodeURIComponent((req.url || '').split('?')[0]);
if (pathname === '/catalog.json' || pathname === '') {
getCatalog().then(
(catalog) => {
res.statusCode = 200;
res.setHeader('content-type', 'application/json; charset=utf-8');
res.setHeader('cache-control', 'no-cache');
res.end(JSON.stringify(catalog));
},
(err) => {
res.statusCode = 500;
res.end(`catalog failed: ${err.message}`);
}
);
return;
}
const file = playableWav(pathname.replace(/^\//, ''));
if (!file) return next();
res.statusCode = 200;
res.setHeader('content-type', 'audio/wav');
res.setHeader('cache-control', 'no-cache');
createReadStream(file).pipe(res);
});
// Prepared analyses (tools/prepare.js) make viewing skip analysis.
server.middlewares.use('/prepared', (req, res, next) => {
const name = decodeURIComponent((req.url || '').split('?')[0]).replace(/^\//, '');
const file = resolve(PREPARED, name);
if (basename(file) !== name || !file.startsWith(PREPARED)) return next();
try {
if (!statSync(file).isFile()) return next();
} catch {
return next();
}
res.statusCode = 200;
res.setHeader('content-type', 'application/json; charset=utf-8');
res.setHeader('cache-control', 'no-cache');
createReadStream(file).pipe(res);
});
},
async generateBundle() {
const catalog = await getCatalog();
for (const s of catalog.songs) {
const file = playableWav(s.file);
if (file) this.emitFile({ type: 'asset', fileName: `songs/${s.file}`, source: readFileSync(file) });
}
this.emitFile({ type: 'asset', fileName: 'songs/catalog.json', source: JSON.stringify(catalog) });
if (existsSync(PREPARED)) {
for (const f of readdirSync(PREPARED).filter((f) => f.endsWith('.json'))) {
this.emitFile({ type: 'asset', fileName: `prepared/${f}`, source: readFileSync(resolve(PREPARED, f)) });
}
}
},
};
}
export default defineConfig({
plugins: [songBankPlugin()],
});