From f93935168938f800de5eaa8bcfb0c26ce34ec181 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Wed, 28 Oct 2026 10:07:05 +0100 Subject: [PATCH] Metabolism: rule program, song-driven life, tribe VMs; M2.7 design (strains & allegiances, living wells) --- metabolism/.gitignore | 3 +- metabolism/DESIGN.md | 142 ++-- metabolism/EVOLUTION.md | 233 +++++++ metabolism/README.md | 86 ++- metabolism/index.html | 87 +++ metabolism/package.json | 5 +- metabolism/src/camera.js | 30 +- metabolism/src/demo/audio.js | 122 ++++ metabolism/src/demo/catalog.js | 62 ++ metabolism/src/demo/main.js | 424 ++++++++++++ metabolism/src/demo/player.js | 69 ++ metabolism/src/demo/style.css | 371 +++++++++++ metabolism/src/film.js | 15 +- metabolism/src/metrics.js | 82 ++- metabolism/src/render/view.js | 160 +++-- metabolism/src/sim/wells.js | 6 +- metabolism/src/sim/world.js | 1058 +++++++++++++++++------------- metabolism/src/song/rules.js | 148 +++++ metabolism/src/song/serialize.js | 62 ++ metabolism/src/song/song.js | 8 +- metabolism/src/song/tribe.js | 143 ++++ metabolism/test/metrics.test.js | 8 +- metabolism/test/prepare.test.js | 42 ++ metabolism/test/rules.test.js | 92 +++ metabolism/test/tribe.test.js | 119 ++++ metabolism/test/world.test.js | 29 +- metabolism/tools/prepare.js | 49 ++ metabolism/vite.config.js | 145 ++++ 28 files changed, 3199 insertions(+), 601 deletions(-) create mode 100644 metabolism/EVOLUTION.md create mode 100644 metabolism/index.html create mode 100644 metabolism/src/demo/audio.js create mode 100644 metabolism/src/demo/catalog.js create mode 100644 metabolism/src/demo/main.js create mode 100644 metabolism/src/demo/player.js create mode 100644 metabolism/src/demo/style.css create mode 100644 metabolism/src/song/rules.js create mode 100644 metabolism/src/song/serialize.js create mode 100644 metabolism/src/song/tribe.js create mode 100644 metabolism/test/prepare.test.js create mode 100644 metabolism/test/rules.test.js create mode 100644 metabolism/test/tribe.test.js create mode 100644 metabolism/tools/prepare.js create mode 100644 metabolism/vite.config.js diff --git a/metabolism/.gitignore b/metabolism/.gitignore index 252089d..cc9d9b4 100644 --- a/metabolism/.gitignore +++ b/metabolism/.gitignore @@ -1,2 +1,3 @@ node_modules/ -out/ \ No newline at end of file +out/ +dist/ \ No newline at end of file diff --git a/metabolism/DESIGN.md b/metabolism/DESIGN.md index 6fad8da..75d404a 100644 --- a/metabolism/DESIGN.md +++ b/metabolism/DESIGN.md @@ -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. \ No newline at end of file +*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. \ No newline at end of file diff --git a/metabolism/EVOLUTION.md b/metabolism/EVOLUTION.md new file mode 100644 index 0000000..d511f55 --- /dev/null +++ b/metabolism/EVOLUTION.md @@ -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/-.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. diff --git a/metabolism/README.md b/metabolism/README.md index 3ba72c2..c520ec8 100644 --- a/metabolism/README.md +++ b/metabolism/README.md @@ -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/-.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 space), **seek** (or ←/→ + ±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/-.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 diff --git a/metabolism/index.html b/metabolism/index.html new file mode 100644 index 0000000..96fada7 --- /dev/null +++ b/metabolism/index.html @@ -0,0 +1,87 @@ + + + + + + metabolism — songs as substrate + + + +
+
+ +
+

metabolism

+

song in → world out. pick a track, grow its film.

+
+
+
+ + + +
+
+ +
+ + +
+
+ + +
+ +
…
+ +
+ +
+
+
+
+ +
+
+ 0:00 + — +
+
+
+ +
+ — + — + — +
+ +

+ one run, no clock: the world steps to the song, the camera answers the world, and the + same Run renders here and in the export tools. no Math.random, + no wall clock — same song · same seed · same frame ⇒ same pixel. +

+
+
+ + + + \ No newline at end of file diff --git a/metabolism/package.json b/metabolism/package.json index 823b375..def615c 100644 --- a/metabolism/package.json +++ b/metabolism/package.json @@ -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" diff --git a/metabolism/src/camera.js b/metabolism/src/camera.js index 8307946..2741ca0 100644 --- a/metabolism/src/camera.js +++ b/metabolism/src/camera.js @@ -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); } diff --git a/metabolism/src/demo/audio.js b/metabolism/src/demo/audio.js new file mode 100644 index 0000000..a34b095 --- /dev/null +++ b/metabolism/src/demo/audio.js @@ -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; + } +} \ No newline at end of file diff --git a/metabolism/src/demo/catalog.js b/metabolism/src/demo/catalog.js new file mode 100644 index 0000000..de0cad7 --- /dev/null +++ b/metabolism/src/demo/catalog.js @@ -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; +} \ No newline at end of file diff --git a/metabolism/src/demo/main.js b/metabolism/src/demo/main.js new file mode 100644 index 0000000..eb5ad04 --- /dev/null +++ b/metabolism/src/demo/main.js @@ -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/.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(`warming the world…${id} · seed ${seed}`); + + 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(`could not grow “${id}”${err.message}`); + 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(`· end ·that world is done — press play to grow it again`); +} + +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 = + `${entry.name} — ${entry.species} · seed ${state.seed}
` + + `${tagsFor(entry)}`; +} + +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(`no libraryis the dev server serving ../flow-state/test/songs? — ${err.message}`); + } + 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(); \ No newline at end of file diff --git a/metabolism/src/demo/player.js b/metabolism/src/demo/player.js new file mode 100644 index 0000000..c2c1e65 --- /dev/null +++ b/metabolism/src/demo/player.js @@ -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); \ No newline at end of file diff --git a/metabolism/src/demo/style.css b/metabolism/src/demo/style.css new file mode 100644 index 0000000..7111aa7 --- /dev/null +++ b/metabolism/src/demo/style.css @@ -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; } \ No newline at end of file diff --git a/metabolism/src/film.js b/metabolism/src/film.js index 675e6fb..bf366e9 100644 --- a/metabolism/src/film.js +++ b/metabolism/src/film.js @@ -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); diff --git a/metabolism/src/metrics.js b/metabolism/src/metrics.js index f83c0d5..3398de7 100644 --- a/metabolism/src/metrics.js +++ b/metabolism/src/metrics.js @@ -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)), diff --git a/metabolism/src/render/view.js b/metabolism/src/render/view.js index 43de9df..76958b3 100644 --- a/metabolism/src/render/view.js +++ b/metabolism/src/render/view.js @@ -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); @@ -172,4 +252,4 @@ function boxBlur(src, tmp, out, F, radius) { out[o + 2] = b * k; } } -} +} \ No newline at end of file diff --git a/metabolism/src/sim/wells.js b/metabolism/src/sim/wells.js index 3297046..1cd1541 100644 --- a/metabolism/src/sim/wells.js +++ b/metabolism/src/sim/wells.js @@ -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); diff --git a/metabolism/src/sim/world.js b/metabolism/src/sim/world.js index 9a26d11..1f87191 100644 --- a/metabolism/src/sim/world.js +++ b/metabolism/src/sim/world.js @@ -1,93 +1,209 @@ -// The ecology. Agents on a resource field, with the song's species and the -// song's laws. No player, no realtime budget, no rendering: a step function. +// The macro ecology (EVOLUTION.md M1). // -// Three invariants are load-bearing and every design choice here serves them: +// Replaces the ~2 600-entity sim with *population fields*: every cell holds a +// density per caste, stepped several times per rendered frame so local +// interactions propagate into coherent macro patterns. "Billions of little +// guys" is a density, rendered as texture — not entities, which would cap out +// at a few thousand. // -// PERSISTENCE agents live for seconds, not frames, and the trail grid keeps -// what they did for tens of seconds. You can follow something. -// CAUSALITY food arrives where and when the track put it. A bloom has an -// audible cause; a migration has a visible one. -// CONSEQUENCE the field is depleted, the trail accumulates. The state at t -// is made of the state at t-1, not re-rolled each frame. +// The front-end the entity world used is unchanged: events land food, wells +// from the chord recharge slowly, the section's law switches the rules at its +// boundary, palette comes from the key. The invariants survive: +// +// PERSISTENCE trail fields accumulate the geography for tens of seconds. +// CAUSALITY a hit still lands food, a ring and a local bloom exactly +// where the track put it. +// CONSEQUENCE every substep is a pure update of the previous one. +// DETERMINISM no Math.random, no wall clock, no per-substep RNG: the static +// jitter noise field is generated once from the seeded Rng and +// the substep order is canonical. +// +// Performance: forces are integrated on a coarse "force grid" (2×2 block sums), +// then sampled down onto the fine field for advection. That both cuts the work +// and gives each cell long-range coupling to its neighbourhood — the macro +// coherence the film needs. The whole hot path uses L1 directions, so there +// are no square roots per substep. import { Rng } from '../rng.js'; import { buildWells } from './wells.js'; +import { runTribe, SENSE_COUNT, ACTION_COUNT, REGS } from '../song/tribe.js'; -const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x); -const TAU = Math.PI * 2; -const H = 1.6; // field samples are one and a half cells apart +const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); export const FIELD = 128; +export const RENDER_FIELD = 256; // speck/texture resolution at render (2× up) +export const SUBSTEPS = 5; // sim steps per rendered frame (the old world: 1) + +// Ink per individual per second, world units (tuned against a ~15 s half-life +// so a full cell writes a visible line in seconds). +const DEPOSIT_SCALE = 0.012; export class World { - /** - * @param {ReturnType} song - */ - constructor(song, { fps = 30, field = FIELD, seed = song.seed } = {}) { + constructor(song, { fps = 30, field = FIELD, seed = song.seed, substeps = SUBSTEPS, genesis } = {}) { this.song = song; this.fps = fps; this.dt = 1 / fps; this.field = field; + this.F = field; + this.substeps = substeps; this.genome = song.genome; + this.program = song.program; + this.tribes = song.tribes; this.palette = song.palette; this.rng = new Rng(seed >>> 0).fork('world'); - this.resource = new Float32Array(field * field); - this.wells = buildWells(song.summary, field); - // The dish is delivered already furnished: colonies exist at t=0 and - // the first bars are life forming, not a blank plate waiting for food. - for (const well of this.wells) { - const add = well.rate * 7; - const idx = well.idx; - const w = well.w; - for (let i = 0; i < idx.length; i++) { - this.resource[idx[i]] = Math.min(1.6, add * w[i]); - } - } - this.trail = [ - new Float32Array(field * field), - new Float32Array(field * field), - new Float32Array(field * field), - ]; + // Pre-simulation (genesis): how long the world evolves before frame 0, + // so the opening is an old ecosystem, not a blank plate. The amount is + // the song's own: a long intro matures the opening world longer, a + // dense or beatless track settles more slowly. + const g = genesis !== undefined + ? genesis + : 1.5 + + 2.5 * clamp01(Math.min(1, (song.laws[1]?.start ?? 4) / 16)) + + 1.5 * clamp01(song.summary.onsetDensity / 6) + + 2 * (1 - clamp01(song.summary.beatStrength)); + this.genesisFrames = Math.max(0, Math.round(clamp01((g - 2) / 7) * 9 * fps)); + + const n = field * field; + this.resource = new Float32Array(n); + // Tighter fertile patches than the entity world: food localized, so + // colonies deplete the ground between them into habitat barriers. + this.wells = buildWells(song.summary, field, 0.6); + this.pop = [new Float32Array(n), new Float32Array(n), new Float32Array(n)]; + this.popNext = [new Float32Array(n), new Float32Array(n), new Float32Array(n)]; + // Trail history, three tinted layers — the geography the audience sees. + this.trail = [new Float32Array(n), new Float32Array(n), new Float32Array(n)]; this.activity = new Float32Array(32 * 32); - this.cap = this.genome.maxAgents; - this.x = new Float32Array(this.cap); - this.y = new Float32Array(this.cap); - this.vx = new Float32Array(this.cap); - this.vy = new Float32Array(this.cap); - this.energy = new Float32Array(this.cap); - this.age = new Float32Array(this.cap); - this.cls = new Uint8Array(this.cap); - this.wellOf = new Uint8Array(this.cap); - this.pulses = []; - this.count = 0; + // Deterministic jitter: static signed noise fields from the seeded RNG, + // read every substep. The track's flatness sets how loud it is. + this.noise = new Float32Array(n); + this.noise2 = new Float32Array(n); + for (let i = 0; i < n; i++) { + this.noise[i] = this.rng.signed(); + this.noise2[i] = this.rng.signed(); + } - this.newborn = new Float32Array(this.cap * 7); - this.newbornCount = 0; + // Fine-grid 4-neighbour indices, precomputed once (no bounds checks). + this.nUp = new Int32Array(n); + this.nDown = new Int32Array(n); + this.nLeft = new Int32Array(n); + this.nRight = new Int32Array(n); + for (let y = 0; y < field; y++) { + for (let x = 0; x < field; x++) { + const i = y * field + x; + this.nUp[i] = y === 0 ? i : i - field; + this.nDown[i] = y === field - 1 ? i : i + field; + this.nLeft[i] = x === 0 ? i : i - 1; + this.nRight[i] = x === field - 1 ? i : i + 1; + } + } - // Neighbour grid. - this.gridN = 22; - this.cellCount = new Int32Array(this.gridN * this.gridN + 1); - this.cellStart = new Int32Array(this.gridN * this.gridN + 1); - this.cellItems = new Int32Array(this.cap); + // Coarse force grid: block sums of food + castes, forces per block, + // plus a fine→coarse index table. Field 128 → 64² (2×2 blocks); the + // test fields go smaller so their sims stay cheap. + const fcSide = Math.max(16, Math.round(field / 2)); + const fc = fcSide * fcSide; + this.FC = fcSide; + this.blkR = new Float32Array(fc); + this.blkP = [new Float32Array(fc), new Float32Array(fc), new Float32Array(fc)]; + this.blkT = [new Float32Array(fc), new Float32Array(fc), new Float32Array(fc)]; + this.fxX = [new Float32Array(fc), new Float32Array(fc), new Float32Array(fc)]; + this.fxY = [new Float32Array(fc), new Float32Array(fc), new Float32Array(fc)]; + this.cUp = new Int32Array(fc); + this.cDown = new Int32Array(fc); + this.cLeft = new Int32Array(fc); + this.cRight = new Int32Array(fc); + for (let y = 0; y < fcSide; y++) { + for (let x = 0; x < fcSide; x++) { + const i = y * fcSide + x; + this.cUp[i] = y === 0 ? i : i - fcSide; + this.cDown[i] = y === fcSide - 1 ? i : i + fcSide; + this.cLeft[i] = x === 0 ? i : i - 1; + this.cRight[i] = x === fcSide - 1 ? i : i + 1; + } + } + this.coarseOf = new Int32Array(n); + const bs = field / fcSide; // fine cells per coarse side (2) + for (let y = 0; y < field; y++) { + const cy = Math.min(fcSide - 1, (y / bs) | 0); + for (let x = 0; x < field; x++) { + const cx = Math.min(fcSide - 1, (x / bs) | 0); + this.coarseOf[y * field + x] = cy * fcSide + cx; + } + } + // Tribe programs run on this decision grid: registers are the tribes' + // persistent memory, the action slots carry what each block decided. + const tCount = this.tribes.length; + this.trMem = new Float32Array(tCount * REGS * fc); + this.trAct = new Float32Array(tCount * ACTION_COUNT * fc); + this.trSs = new Float32Array(SENSE_COUNT); + this.trGg = new Float32Array(SENSE_COUNT * 2); + this.trScratch = new Float32Array(ACTION_COUNT); + + // Home basins: every caste is anchored to the SAME nearest well (the + // rule program's "place") so the castes interleave in one continent + // instead of colonising separate blobs. + const home = this.program.home; + const anchorStrength = home.pull; + this.anchorX = [new Float32Array(fc), new Float32Array(fc), new Float32Array(fc)]; + this.anchorY = [new Float32Array(fc), new Float32Array(fc), new Float32Array(fc)]; + for (let y = 0; y < fcSide; y++) { + const wy = (y + 0.5) / fcSide; + for (let x = 0; x < fcSide; x++) { + const wx = (x + 0.5) / fcSide; + let bx = 0; + let by = 0; + let bd = Infinity; + for (const well of this.wells) { + const dx = well.x - wx; + const dy = well.y - wy; + const d2 = dx * dx + dy * dy; + if (d2 < bd) { + bd = d2; + bx = dx; + by = dy; + } + } + if (bd < Infinity) { + const d = Math.sqrt(bd) + 1e-6; + for (let c = 0; c < 3; c++) { + const pull = clamp01(1 - d / home.reach) * anchorStrength[c]; + const i = y * fcSide + x; + this.anchorX[c][i] = (bx / d) * pull; + this.anchorY[c][i] = (by / d) * pull; + } + } + } + } + + this.cap = 0; + this.caps = [this.program.castes[0].cap, this.program.castes[1].cap, this.program.castes[2].cap]; + this.growth = [this.program.castes[0].growth, this.program.castes[1].growth, this.program.castes[2].growth]; + this.eatR = [this.program.castes[0].eat, this.program.castes[1].eat, this.program.castes[2].eat]; + for (let c = 0; c < 3; c++) this.cap += this.caps[c] * n; + + // History aligned with the analysis frames (metrics regress on it). this.history = { - alive: new Uint16Array(song.analysis.frames), + alive: new Float32Array(song.analysis.frames), movement: new Float32Array(song.analysis.frames), food: new Float32Array(song.analysis.frames), - births: new Uint16Array(song.analysis.frames), - deaths: new Uint16Array(song.analysis.frames), + births: new Float32Array(song.analysis.frames), + deaths: new Float32Array(song.analysis.frames), }; this.frame = -1; + this.pulses = []; + this.totalPop = 0; + this.moveAccum = 0; this.birthsThisFrame = 0; this.deathsThisFrame = 0; this.seedInitial(); + this.runGenesis(); } - /** The law for a frame. Section boundaries are the only place laws change. */ lawForFrame(frame) { const laws = this.song.laws; const t = frame / this.fps; @@ -97,94 +213,102 @@ export class World { return laws[0]; } + /** The rule program's current section: operator regime + energy. */ + regimeForFrame(frame) { + const sections = this.program.sections; + const t = frame / this.fps; + for (let i = sections.length - 1; i >= 0; i--) { + if (t >= sections[i].start) return sections[i]; + } + return sections[0]; + } + + /** + * Furnish the dish and wake it into the song's own opening state. + * + * The wells furnish FOOD (the permanent fertile places) but not life: the + * opening colonies are spawned where the track's first bars actually land + * — the song drives where organisms appear. runGenesis() then lets several + * generations settle before frame 0. + */ seedInitial() { - const n = this.genome.seedAgents; - const wells = this.wells; - for (let i = 0; i < n && this.count + this.newbornCount < this.cap; i++) { - // Most life starts at a well; a few are already in transit, so the - // first frame is a world with roads, not a row of dots. - let x = 0.5; - let y = 0.5; - if (i % 5 !== 4 && wells.length) { - const w = wells[i % wells.length]; - x = w.x + this.rng.gauss() * w.r; - y = w.y + this.rng.gauss() * w.r; - } else { - const r = Math.sqrt(this.rng.next()) * 0.44; - const a = this.rng.next() * TAU; - x = 0.5 + Math.cos(a) * r; - y = 0.5 + Math.sin(a) * r; - } - this.queueAgent( - Math.max(0.02, Math.min(0.98, x)), - Math.max(0.02, Math.min(0.98, y)), - this.pickClass(), - 0.52 + 0.24 * this.rng.next() - ); - } - this.flushNewborns(); - this.birthsThisFrame = 0; - this.deathsThisFrame = 0; - } - - nearestWell(x, y) { - let best = 0; - let bd = Infinity; - for (let i = 0; i < this.wells.length; i++) { - const w = this.wells[i]; - const d = (w.x - x) ** 2 + (w.y - y) ** 2; - if (d < bd) { - bd = d; - best = i; + const F = this.field; + const res = this.resource; + for (const well of this.wells) { + let peak = 0; + for (let i = 0; i < well.w.length; i++) if (well.w[i] > peak) peak = well.w[i]; + well.peak = Math.max(1e-6, peak); + const idx = well.idx; + const w = well.w; + for (let i = 0; i < idx.length; i++) { + res[idx[i]] = Math.min(1.6, 1.2 * (w[i] / well.peak)); } } - return best; - } - - pickClass() { - const w = this.genome.classes.map((c) => c.seed); - let total = 0; - for (const v of w) total += v; - let r = this.rng.next() * total; - for (let i = 0; i < w.length; i++) { - r -= w[i]; - if (r <= 0) return i; + // The song's own opening, replayed as the genesis: each event places + // food AND spawns every caste there (the voice first and loudest), so + // colonies rise where the track put them — not at a pre-chosen set of + // spots. A sparse opening (long beatless intro) tops itself up from the + // song's earliest hits so the world never wakes empty. + const reach = this.genesisFrames || this.fps * 3; + const pool = []; + for (const e of this.song.events) { + if (pool.length >= 16 || e.frame >= reach) break; + pool.push(e); } - return 0; - } - - queueAgent(x, y, cls, energy) { - if (this.count + this.newbornCount >= this.cap) return false; - const p = this.newbornCount * 7; - this.newborn[p] = x; - this.newborn[p + 1] = y; - this.newborn[p + 2] = 0; - this.newborn[p + 3] = 0; - this.newborn[p + 4] = energy; - this.newborn[p + 5] = 0; - this.newborn[p + 6] = cls; - this.newbornCount++; - return true; - } - - flushNewborns() { - for (let k = 0; k < this.newbornCount; k++) { - if (this.count >= this.cap) break; - const p = k * 7; - const i = this.count++; - this.x[i] = this.newborn[p]; - this.y[i] = this.newborn[p + 1]; - this.vx[i] = this.newborn[p + 2]; - this.vy[i] = this.newborn[p + 3]; - this.energy[i] = this.newborn[p + 4]; - this.age[i] = this.newborn[p + 5]; - this.cls[i] = this.newborn[p + 6]; - this.wellOf[i] = this.nearestWell(this.x[i], this.y[i]); + for (const e of this.song.events) { + if (pool.length >= 6) break; + if (!pool.includes(e)) pool.push(e); } - this.newbornCount = 0; + for (const e of pool) { + this.splat(e.x, e.y, e.spread, e.amount); + const cell = Math.min(F - 1, (e.y * F) | 0) * F + Math.min(F - 1, (e.x * F) | 0); + const burst = 2.0 + 5.0 * e.strength; + this.pop[e.voice][cell] += burst; + this.pop[(e.voice + 1) % 3][cell] += burst * 0.5; + this.pop[(e.voice + 2) % 3][cell] += burst * 0.5; + } + this.recount(); + this.record(-1); } - // ---- field ------------------------------------------------------------- + /** + * Pre-simulate the genesis epoch (frames −G … −1). No events fire (the + * index has nothing below 0), so the world settles under the intro regime: + * several generations of growth, diffusion, deposition and decay decide + * where the opening's cultures actually stand. History stays untouched + * (record() skips negative frames); frame 0 is then a lived-in world. + */ + runGenesis() { + const G = this.genesisFrames; + for (let f = 0; f < G; f++) { + this.frame++; + const regime = this.regimeForFrame(this.frame); + this.pulses.length = 0; + this.birthsThisFrame = 0; + this.deathsThisFrame = 0; + this.moveAccum = 0; + this.inject(this.frame, regime); + for (let s = 0; s < this.substeps; s++) this.substep(regime); + this.decay(regime); + } + this.frame = -1; + this.pulses.length = 0; + } + + recount() { + let s = 0; + for (let c = 0; c < 3; c++) { + const p = this.pop[c]; + for (let i = 0; i < p.length; i++) s += p[i]; + } + this.totalPop = s; + } + + get count() { + return this.totalPop; + } + + // ---- field access ---------------------------------------------------- sample(fx, fy) { const F = this.field; @@ -202,46 +326,6 @@ export class World { return (a + (b - a) * tx) * (1 - ty) + (c + (d - c) * tx) * ty; } - /** Consume from the four cells under (fx, fy). Returns what was eaten. */ - eat(fx, fy, amount) { - const F = this.field; - const gx = Math.max(0, Math.min(F - 1.001, fx * (F - 1))); - const gy = Math.max(0, Math.min(F - 1.001, fy * (F - 1))); - const x0 = gx | 0; - const y0 = gy | 0; - const tx = gx - x0; - const ty = gy - y0; - const res = this.resource; - const i0 = y0 * F + x0; - const i1 = i0 + 1; - const i2 = i0 + F; - const i3 = i2 + 1; - const w0 = (1 - tx) * (1 - ty); - const w1 = tx * (1 - ty); - const w2 = (1 - tx) * ty; - const w3 = tx * ty; - let eaten = 0; - let take = Math.min(res[i0], amount * w0); - res[i0] -= take; eaten += take; - take = Math.min(res[i1], amount * w1); - res[i1] -= take; eaten += take; - take = Math.min(res[i2], amount * w2); - res[i2] -= take; eaten += take; - take = Math.min(res[i3], amount * w3); - res[i3] -= take; eaten += take; - return eaten; - } - - depositTrail(cls, x, y, amount) { - const F = this.field; - const ix = Math.max(0, Math.min(F - 1, (x * F) | 0)); - const iy = Math.max(0, Math.min(F - 1, (y * F) | 0)); - this.trail[cls][iy * F + ix] += amount; - const ax = Math.max(0, Math.min(31, (x * 32) | 0)); - const ay = Math.max(0, Math.min(31, (y * 32) | 0)); - this.activity[ay * 32 + ax] += amount * 3; - } - splat(x, y, spread, amount) { const F = this.field; const sigma = Math.max(0.8, spread * F); @@ -267,81 +351,369 @@ export class World { } if (total <= 0) return; const k = amount / total; - for (let i = 0; i < idx.length; i++) this.resource[idx[i]] += w[i] * k; + const res = this.resource; + for (let i = 0; i < idx.length; i++) res[idx[i]] += w[i] * k; } - // ---- neighbour grid ---------------------------------------------------- - - buildGrid() { - const n = this.gridN; - const counts = this.cellCount; - counts.fill(0); - for (let i = 0; i < this.count; i++) { - const cx = Math.min(n - 1, Math.max(0, (this.x[i] * n) | 0)); - const cy = Math.min(n - 1, Math.max(0, (this.y[i] * n) | 0)); - counts[cy * n + cx + 1]++; - } - for (let i = 1; i <= n * n; i++) counts[i] += counts[i - 1]; - this.cellStart.set(counts); - for (let i = 0; i < this.count; i++) { - const cx = Math.min(n - 1, Math.max(0, (this.x[i] * n) | 0)); - const cy = Math.min(n - 1, Math.max(0, (this.y[i] * n) | 0)); - const c = cy * n + cx; - this.cellItems[this.cellStart[c]++] = i; - } - } - - // ---- step -------------------------------------------------------------- + // ---- step ------------------------------------------------------------ step() { this.frame++; const f = this.frame; - const law = this.lawForFrame(f); + const regime = this.regimeForFrame(f); const cutoff = f - Math.ceil(this.fps * 1.6); while (this.pulses.length && this.pulses[0].frame < cutoff) this.pulses.shift(); this.birthsThisFrame = 0; this.deathsThisFrame = 0; + this.moveAccum = 0; - this.inject(f, law); - this.decay(law); - this.buildGrid(); - - this.update(law); - this.compact(); - this.flushNewborns(); - this.record(law); - return law; + this.inject(f, regime); + for (let s = 0; s < this.substeps; s++) this.substep(regime); + this.decay(regime); + this.record(f); + return regime; } - inject(frame, law) { + inject(frame, regime) { const events = this.song.index[frame]; if (!events) return; + const F = this.field; + const spawn = regime.operators.spawn; + const splash = this.program.events.splash; + // The song spawns: how hard a hit births new life where it lands. The + // drop regime's spawn operator floods the ground with colonies; the + // intro barely seeds. + const scale = 0.4 + 1.1 * spawn; for (const e of events) { - this.splat(e.x, e.y, e.spread, e.amount * law.spawn); - const births = Math.round(e.spawn * law.spawn); - for (let k = 0; k < births; k++) { - if (this.queueAgent( - e.x + this.rng.range(-0.02, 0.02), - e.y + this.rng.range(-0.02, 0.02), - e.voice, - 0.45 + 0.25 * e.strength - )) this.birthsThisFrame++; - } + // Food where the track put it. The voice that spoke feeds first + // and loudest; the splash rule gives the other castes their share. + this.splat(e.x, e.y, e.spread, e.amount * scale); + const cell = Math.min(F - 1, (e.y * F) | 0) * F + Math.min(F - 1, (e.x * F) | 0); + const boost = (0.5 + 3.0 * e.strength) * scale; + this.pop[e.voice][cell] += boost; + this.pop[(e.voice + 1) % 3][cell] += boost * splash[(e.voice + 1) % 3]; + this.pop[(e.voice + 2) % 3][cell] += boost * splash[(e.voice + 2) % 3]; const ax = Math.max(0, Math.min(31, (e.x * 32) | 0)); const ay = Math.max(0, Math.min(31, (e.y * 32) | 0)); this.activity[ay * 32 + ax] += 0.5 + 2 * e.strength; - // A visible consequence: the hit lands as a ring on the ground. + // The clearest causality: a hit lands as a ring on the ground. if (e.strength > 0.42) { this.pulses.push({ x: e.x, y: e.y, frame, strength: e.strength, voice: e.voice }); } } } - decay(law) { + /** + * Run every tribe's program on every decision block. Senses are read from + * the block sums + gradients; the program branches on them, updates its + * persistent registers (memory) and emits actions. The MOVE actions become + * the advection forces; the others are stored for the reaction phase. + * A faint home anchor stays so colonies keep their place. + */ + runTribes(regime) { + const FC = this.FC; + const fc = FC * FC; + const bP = this.blkP; + const blkT = this.blkT; + const blkR = this.blkR; + const tribes = this.tribes; + const T = tribes.length; + const ss = this.trSs; + const gg = this.trGg; + const act = this.trScratch; + const mem = this.trMem; + const actBuf = this.trAct; + const cU = this.cUp; + const cD = this.cDown; + const cL = this.cLeft; + const cR = this.cRight; + const fxX = this.fxX; + const fxY = this.fxY; + const now = this.frame / this.fps; + const start = regime.start; + const span = Math.max(0.001, regime.end - regime.start); + const progT = clamp01((now - start) / span); + const lawSpawn = regime.operators.spawn; + + for (let b = 0; b < fc; b++) { + const ug = cU[b]; + const dg = cD[b]; + const lg = cL[b]; + const rg = cR[b]; + const r0 = blkR[b]; + const tot = bP[0][b] + bP[1][b] + bP[2][b]; + // Gradients of food and the three castes at this block. + const gRx = blkR[rg] - blkR[lg]; + const gRy = blkR[dg] - blkR[ug]; + const g0x = bP[0][rg] - bP[0][lg]; + const g0y = bP[0][dg] - bP[0][ug]; + const g1x = bP[1][rg] - bP[1][lg]; + const g1y = bP[1][dg] - bP[1][ug]; + const g2x = bP[2][rg] - bP[2][lg]; + const g2y = bP[2][dg] - bP[2][ug]; + const gr = [g0x, g1x, g2x]; + const gy2 = [g0y, g1y, g2y]; + + for (let t = 0; t < T; t++) { + const a = (t + 1) % T; + const bb = (t + 2) % T; + const own = bP[t][b]; + const oA = bP[a][b]; + const oB = bP[bb][b]; + const nb = (bP[t][ug] + bP[t][dg] + bP[t][lg] + bP[t][rg]) / 4; + + // ---- senses ------------------------------------------------- + ss[0] = clamp01(r0 * 0.12); // FOOD + ss[1] = clamp01(own * 0.008); // SELF + ss[2] = clamp01(oA * 0.008); // OTHER0 + ss[3] = clamp01(oB * 0.008); // OTHER1 + ss[4] = clamp01(blkT[t][b] * 0.05); // TRAIL + ss[5] = clamp01(lawSpawn / 2.2); // LAW (spawn) + ss[6] = 1 - ss[0]; // HUNGER + ss[7] = clamp01(Math.sign(own - nb) * 0.5 + 0.5); // EDGE (rim) + ss[8] = progT; // TIME + ss[9] = clamp01(tot * 0.005); // TOTAL + + // ---- gradients (sign per field × axis) ----------------------- + gg[0] = Math.sign(gRx); + gg[1] = Math.sign(gRy); + gg[2] = Math.sign(gr[t]); + gg[3] = Math.sign(gy2[t]); + gg[4] = Math.sign(gr[a]); + gg[5] = Math.sign(gy2[a]); + gg[6] = Math.sign(gr[bb]); + gg[7] = Math.sign(gy2[bb]); + gg[8] = gg[2]; + gg[9] = gg[3]; + gg[10] = Math.sign(gRx + gr[0] + gr[1] + gr[2]); + gg[11] = Math.sign(gRy + gy2[0] + gy2[1] + gy2[2]); + + runTribe(mem, t * REGS * fc + b, ss, gg, act, tribes[t].code); + + // ---- actions → forces + reaction mods -------------------------- + fxX[t][b] = act[0] + this.anchorX[t][b] * 0.3 + gRx * 0.02; + fxY[t][b] = act[1] + this.anchorY[t][b] * 0.3 + gRy * 0.02; + const off = t * ACTION_COUNT * fc + b; + actBuf[off + 2 * fc] = act[2]; // EXCRETE + actBuf[off + 3 * fc] = act[3]; // EAT + actBuf[off + 4 * fc] = act[4]; // HIT + actBuf[off + 5 * fc] = act[5]; // SPAWN + } + } + } + + /** + * One simulation substep, three phases: + * 1. forces on the coarse grid (block sums → scaled gradients); + * 2. advection + diffusion on the fine field (L1 axis split, no sqrt); + * 3. reaction: eat → grow → deposit, starve. + * Deterministic throughout: the only randomness is the static noise field. + */ + substep(regime) { + const F = this.field; + const FC = this.FC; + const sub = 1 / (this.fps * this.substeps); + const ops = regime.operators; + const prog = this.program; + const castes = prog.castes; + const diff = prog.memory.diffusion; + const jitterAmt = prog.memory.jitter * (0.4 + 1.35 * ops.turbulence); + const capMul = 0.7 + 0.6 * ops.split; + + const pop = this.pop; + const next = this.popNext; + const trail = this.trail; + const res = this.resource; + const nUp = this.nUp; + const nDown = this.nDown; + const nLeft = this.nLeft; + const nRight = this.nRight; + const cUp = this.cUp; + const cDown = this.cDown; + const cLeft = this.cLeft; + const cRight = this.cRight; + const coarseOf = this.coarseOf; + const noise = this.noise; + const noise2 = this.noise2; + const pCount = pop[0].length; + const p0 = pop[0]; + const p1 = pop[1]; + const p2 = pop[2]; + + // ---- 1. block sums → tribe programs decide the forces -------------------- + const blkR = this.blkR; + const bP0 = this.blkP[0]; + const bP1 = this.blkP[1]; + const bP2 = this.blkP[2]; + const bT0 = this.blkT[0]; + const bT1 = this.blkT[1]; + const bT2 = this.blkT[2]; + blkR.fill(0); + bP0.fill(0); + bP1.fill(0); + bP2.fill(0); + bT0.fill(0); + bT1.fill(0); + bT2.fill(0); + for (let i = 0; i < pCount; i++) { + const b = coarseOf[i]; + blkR[b] += res[i]; + bP0[b] += p0[i]; + bP1[b] += p1[i]; + bP2[b] += p2[i]; + bT0[b] += trail[0][i]; + bT1[b] += trail[1][i]; + bT2[b] += trail[2][i]; + } + this.runTribes(regime); + + // ---- 2. advection + diffusion on the fine field ------------------------- + for (let c = 0; c < 3; c++) { + const p = pop[c]; + const q = next[c]; + q.fill(0); + const fxArr = this.fxX[c]; + const fyArr = this.fxY[c]; + const speed = castes[c].speed * ops.speed; + const frac = Math.min(0.4, speed * sub * 240); + + for (let i = 0; i < pCount; i++) { + const v = p[i]; + if (v <= 1e-6) continue; + const b = coarseOf[i]; + const vx = fxArr[b] + noise[i] * jitterAmt; + const vy = fyArr[b] + noise2[i] * jitterAmt; + const w = Math.abs(vx) + Math.abs(vy); + + const leave = v * frac; + if (leave > 0 && w > 1e-6) { + const mx = (leave * Math.abs(vx)) / w; + const my = (leave * Math.abs(vy)) / w; + q[i] += v - leave; + q[vx > 0.02 ? nRight[i] : vx < -0.02 ? nLeft[i] : i] += mx; + q[vy > 0.02 ? nDown[i] : vy < -0.02 ? nUp[i] : i] += my; + + const ax = ((i % F) * 32 / F) | 0; + const ay = ((i / F) * 32 / F) | 0; + this.activity[Math.min(31, ay) * 32 + Math.min(31, ax)] += leave * 6; + this.moveAccum += leave; + } else { + q[i] += v; + } + + // Diffusion through the four neighbours (conservative). + const d = (p[nUp[i]] + p[nDown[i]] + p[nLeft[i]] + p[nRight[i]] - 4 * v) * diff * sub; + q[i] += d * 0.5; + q[nUp[i]] -= d * 0.125; + q[nDown[i]] -= d * 0.125; + q[nLeft[i]] -= d * 0.125; + q[nRight[i]] -= d * 0.125; + } + } + + // ---- 3. react: eat, grow, die, deposit -------------------------------- + const mutual = castes.map((c) => c.mutual); + const social = castes.map((c) => c.social); + const societyCeil = prog.society.ceiling; + const globalCap = prog.society.global; + const actBuf = this.trAct; + const actStride = ACTION_COUNT * this.FC * this.FC; + const actFC = this.FC * this.FC; + // The dish's carrying capacity: as the whole world approaches it, + // breeding eases off everywhere (soft, so it never bites hard). + const globalDamp = Math.max(0, 1 - Math.max(0, this.totalPop - globalCap) / globalCap); + for (let i = 0; i < pCount; i++) { + const r0 = res[i]; + let pa = next[0][i]; + let pb = next[1][i]; + let pc = next[2][i]; + if (r0 < 1e-6 && pa < 1e-6 && pb < 1e-6 && pc < 1e-6) continue; + const b = coarseOf[i]; + const base0 = b; + const base1 = actStride + b; + const base2 = actStride * 2 + b; + // What this block's tribes decided this substep. + const eat0 = actBuf[base0 + 3 * actFC]; + const eat1 = actBuf[base1 + 3 * actFC]; + const eat2 = actBuf[base2 + 3 * actFC]; + const exc0 = actBuf[base0 + 2 * actFC]; + const exc1 = actBuf[base1 + 2 * actFC]; + const exc2 = actBuf[base2 + 2 * actFC]; + const spn0 = actBuf[base0 + 5 * actFC]; + const spn1 = actBuf[base1 + 5 * actFC]; + const spn2 = actBuf[base2 + 5 * actFC]; + const hit0 = actBuf[base0 + 4 * actFC]; + const hit1 = actBuf[base1 + 4 * actFC]; + const hit2 = actBuf[base2 + 4 * actFC]; + + // Saturated food factor (rational approximation, no transcendentals). + const foodFactor = (r0 * 5) / (1 + r0 * 5); + const gate = 0.10 + 0.9 * foodFactor; + + // Aggression: a tribe that hit thins its rivals in this cell. + if (hit0 > 1e-9 || hit1 > 1e-9 || hit2 > 1e-9) { + const thin = (p) => Math.max(0, p - p * Math.min(0.04, Math.max(0, hit0 + hit1 + hit2) * 0.4) * sub); + pb = thin(pb); + pc = thin(pc); + pa = thin(pa); + } + + let demand = pa * this.eatR[0] * eat0 + pb * this.eatR[1] * eat1 + pc * this.eatR[2] * eat2; + demand *= sub; + res[i] = demand > 0 ? Math.max(0, r0 - Math.min(r0, demand)) : r0; + + const grow = (p, base, cap, spn) => + p * base * ops.growth * globalDamp * (1 + spn) * sub * (1 - p / (cap * capMul)) * gate; + const starve = (1 - foodFactor) * (0.10 + 0.9 * ops.spawn) * sub; + // A soft society ceiling (~2× the sum of caste caps): the three can + // pack a cell but none can flood the dish. + const society = Math.max(0, 1 - (pa + pb + pc) / societyCeil); + // Mutualism: a caste grows best among the others — mixed crowds + // out-breed monochrome blobs (the rule program says so). + const bonus0 = mutual[0].base + (1 - mutual[0].base) * clamp01((pb + pc) / mutual[0].threshold); + const bonus1 = mutual[1].base + (1 - mutual[1].base) * clamp01((pa + pc) / mutual[1].threshold); + const bonus2 = mutual[2].base + (1 - mutual[2].base) * clamp01((pa + pb) / mutual[2].threshold); + // Social floor: the spark only blooms among fellow sparks. + const socialSpark = social[2] ? clamp01((pc - social[2].floor) / social[2].curve) : 1; + const na = Math.max(0, pa + grow(pa, this.growth[0], this.caps[0], spn0) * society * bonus0 - pa * starve * this.eatR[0] * eat0); + const nb = Math.max(0, pb + grow(pb, this.growth[1], this.caps[1], spn1) * society * bonus1 - pb * starve * this.eatR[1] * eat1); + const nc = Math.max(0, pc + grow(pc, this.growth[2], this.caps[2], spn2) * society * bonus2 * socialSpark - pc * starve * this.eatR[2] * eat2); + + next[0][i] = na; + next[1][i] = nb; + next[2][i] = nc; + + trail[0][i] += na * castes[0].deposit * ops.deposit * (1 + exc0) * sub * DEPOSIT_SCALE; + trail[1][i] += nb * castes[1].deposit * ops.deposit * (1 + exc1) * sub * DEPOSIT_SCALE; + trail[2][i] += nc * castes[2].deposit * ops.deposit * (1 + exc2) * sub * DEPOSIT_SCALE; + + if (na > pa) this.birthsThisFrame += na - pa; + if (nb > pb) this.birthsThisFrame += nb - pb; + if (nc > pc) this.birthsThisFrame += nc - pc; + if (na < pa) this.deathsThisFrame += pa - na; + if (nb < pb) this.deathsThisFrame += pb - nb; + if (nc < pc) this.deathsThisFrame += pc - nc; + } + + // Swap and re-sum the population (count feeds history and the HUD). + let total = 0; + for (let c = 0; c < 3; c++) { + const t = pop[c]; + pop[c] = next[c]; + next[c] = t; + const p = pop[c]; + for (let i = 0; i < p.length; i++) total += p[i]; + } + this.totalPop = total; + } + + decay(regime) { const dt = this.dt; - const fieldK = Math.pow(0.5, dt / (this.genome.fieldHalfLife * (0.8 + 0.4 * law.spawn))); - const trailK = Math.pow(0.5, dt / (this.genome.trailHalfLife * (0.75 + 0.35 * law.deposit))); + const memory = this.program.memory; + const ops = regime.operators; + const fieldK = Math.pow(0.5, dt / (memory.fieldHalfLife * (0.8 + 0.4 * ops.spawn))); + const trailK = Math.pow(0.5, dt / (memory.trailHalfLife * (0.75 + 0.35 * ops.deposit))); const res = this.resource; for (let i = 0; i < res.length; i++) res[i] *= fieldK; for (let c = 0; c < 3; c++) { @@ -352,12 +724,10 @@ export class World { const actK = Math.pow(0.5, dt / 1.4); for (let i = 0; i < act.length; i++) act[i] *= actK; - // The wells recharge. Slowly, and to a ceiling: a well is a place with - // a carrying capacity, not a faucet. The section's energy sets the rate, - // so a breakdown is still a leaner world. - const scale = (0.45 + 1.25 * law.energy) * dt; + // Wells recharge to a ceiling; the section's energy sets the rate. + const scale = (0.22 + 0.7 * regime.energy) * dt; for (const well of this.wells) { - const add = well.rate * scale; + const add = well.rate * scale / well.peak; const idx = well.idx; const w = well.w; for (let i = 0; i < idx.length; i++) { @@ -367,226 +737,11 @@ export class World { } } - update(law) { - const dt = this.dt; - const n = this.gridN; - const sense = 0.13; - const sense2 = sense * sense; - const maxNeighbours = 24; - const g = this.genome; - const wells = this.wells; - - for (let i = 0; i < this.count; i++) { - const cls = this.cls[i]; - const c = g.classes[cls]; - const x = this.x[i]; - const y = this.y[i]; - let vx = this.vx[i]; - let vy = this.vy[i]; - - let fx = 0; - let fy = 0; - let homeDx = 0; - let homeDy = 0; - - // Food gradient. A hungry agent commits harder to what it smells. - const e = this.energy[i]; - const hunger = 0.55 + 0.85 * (1 - clamp01(e)); - const h = H / this.field; - const gx = (this.sample(x + h, y) - this.sample(x - h, y)) / (2 * h); - const gy = (this.sample(x, y + h) - this.sample(x, y - h)) / (2 * h); - const grad = Math.hypot(gx, gy); - if (grad > 1e-6) { - fx += (gx / grad) * c.seek * hunger; - fy += (gy / grad) * c.seek * hunger; - } - - // Place. Each caste has a different relation to the wells, and the - // song sets how strongly it holds: builders orbit and paint, - // foragers commute and draw roads, sparks orbit wide and scatter. - if (wells.length) { - const home = wells[this.wellOf[i]]; - const hx = home.x - x; - const hy = home.y - y; - const hd = Math.hypot(hx, hy) + 1e-6; - homeDx = hx / hd; - homeDy = hy / hd; - if (c.role === 'commute') { - const next = (this.wellOf[i] + 1) % wells.length; - const w = wells[next]; - const wx = w.x - x; - const wy = w.y - y; - const wd = Math.hypot(wx, wy) + 1e-6; - if (wd < 0.05) { - this.wellOf[i] = next; - } else { - const pull = 1.0 + 1.7 * g.separation; - fx += (wx / wd) * pull; - fy += (wy / wd) * pull; - } - } else { - const err = (hd - c.orbit) / c.orbit; - const k = 1.9 * g.cohesion; - fx += (hx / hd) * Math.max(-1.4, Math.min(1.4, err)) * k; - fy += (hy / hd) * Math.max(-1.4, Math.min(1.4, err)) * k; - const dir = cls === 2 ? -1 : 1; - fx += (-hy / hd) * dir * c.spin; - fy += (hx / hd) * dir * c.spin; - } - } - - // Society. - let alignX = 0; - let alignY = 0; - let cohX = 0; - let cohY = 0; - let sepX = 0; - let sepY = 0; - let neighbours = 0; - const cx = Math.min(n - 1, Math.max(0, (x * n) | 0)); - const cy = Math.min(n - 1, Math.max(0, (y * n) | 0)); - for (let oy = -1; oy <= 1 && neighbours < maxNeighbours; oy++) { - const gy2 = cy + oy; - if (gy2 < 0 || gy2 >= n) continue; - for (let ox = -1; ox <= 1 && neighbours < maxNeighbours; ox++) { - const gx2 = cx + ox; - if (gx2 < 0 || gx2 >= n) continue; - const cell = gy2 * n + gx2; - const a = this.cellStart[cell]; - const b = this.cellStart[cell + 1]; - for (let p = a; p < b; p++) { - const j = this.cellItems[p]; - if (j === i) continue; - const dx = this.x[j] - x; - const dy = this.y[j] - y; - const d2 = dx * dx + dy * dy; - if (d2 > sense2 || d2 < 1e-12) continue; - const d = Math.sqrt(d2); - alignX += this.vx[j]; - alignY += this.vy[j]; - cohX += dx; - cohY += dy; - const push = (1 - d / sense) / d; - sepX -= dx * push; - sepY -= dy * push; - const k = g.interaction[cls][this.cls[j]]; - fx += (dx / d) * k; - fy += (dy / d) * k; - if (++neighbours >= maxNeighbours) break; - } - } - } - if (neighbours > 0) { - const inv = 1 / neighbours; - fx += cohX * inv * g.cohesion * 5.0; - fy += cohY * inv * g.cohesion * 5.0; - fx += (alignX * inv - vx) * g.alignment * 3.2; - fy += (alignY * inv - vy) * g.alignment * 3.2; - fx += sepX * g.separation * 1.05; - fy += sepY * g.separation * 1.05; - } - // Crowding keeps bodies apart: a colony must never collapse to a - // single blob, or the species reads as paint instead of life. The - // push is outward, away from the well where the crowd is densest, - // and scales with how far the crowd has overshot. - if (neighbours > 9) { - const c = Math.min(2.6, (neighbours - 9) * 0.30); - fx -= homeDx * c; - fy -= homeDy * c; - } - - // Jitter: what a noisy track does to a well-meaning rule. - fx += this.rng.signed() * g.jitter * 0.26; - fy += this.rng.signed() * g.jitter * 0.26; - - // Soft walls: the dish has an edge, but it does not trap. - const m = 0.035; - if (x < m) fx += (m - x) * 14; - if (x > 1 - m) fx -= (x - (1 - m)) * 14; - if (y < m) fy += (m - y) * 14; - if (y > 1 - m) fy -= (y - (1 - m)) * 14; - - const speed = c.speed * law.speed; - const mag = Math.hypot(fx, fy); - if (mag > 1e-9) { - const turn = Math.min(1, c.turn * law.turn * dt); - vx += ((fx / mag) * speed - vx) * turn; - vy += ((fy / mag) * speed - vy) * turn; - } else { - vx *= 0.94; - vy *= 0.94; - } - - let nx = x + vx * dt; - let ny = y + vy * dt; - if (nx < 0.005) { nx = 0.005; vx = Math.abs(vx) * 0.6; } - if (nx > 0.995) { nx = 0.995; vx = -Math.abs(vx) * 0.6; } - if (ny < 0.005) { ny = 0.005; vy = Math.abs(vy) * 0.6; } - if (ny > 0.995) { ny = 0.995; vy = -Math.abs(vy) * 0.6; } - - // Metabolism. - const eaten = this.eat(nx, ny, 0.62 * dt); - let energy = e + eaten * 2.1; - const work = 0.35 + 0.65 * Math.min(1, Math.hypot(vx, vy) / Math.max(1e-6, speed)); - energy -= c.drain * dt * work; - energy = Math.min(1.7, energy); - - this.x[i] = nx; - this.y[i] = ny; - this.vx[i] = vx; - this.vy[i] = vy; - this.energy[i] = energy; - this.age[i] += dt; - - // History is written into the ground, not into a list of events. - // Ink, not fog. A body lays a line roughly its own width; if the - // deposit rate is high enough to fill the dish, the history is lost. - // The floor keeps a fast swarm from reading as invisible: every - // body leaves SOMETHING, however thin. - const deposit = (0.30 + 0.70 * c.deposit) * law.deposit * dt * 0.032; - this.depositTrail(cls, nx, ny, deposit); - - if (energy >= c.split * law.split && neighbours < 16) { - this.energy[i] = energy * 0.52; - if (this.queueAgent( - nx + this.rng.range(-0.012, 0.012), - ny + this.rng.range(-0.012, 0.012), - cls, - energy * 0.42 - )) this.birthsThisFrame++; - } else if (energy <= 0) { - this.energy[i] = 0; - this.deathsThisFrame++; - } - } - } - - compact() { - let w = 0; - for (let i = 0; i < this.count; i++) { - if (this.energy[i] <= 0) continue; - if (w !== i) { - this.x[w] = this.x[i]; - this.y[w] = this.y[i]; - this.vx[w] = this.vx[i]; - this.vy[w] = this.vy[i]; - this.energy[w] = this.energy[i]; - this.age[w] = this.age[i]; - this.cls[w] = this.cls[i]; - } - w++; - } - this.count = w; - } - - record(law) { + record(f) { + if (f < 0 || f >= this.history.alive.length) return; const h = this.history; - const f = this.frame; - if (f < 0 || f >= h.alive.length) return; - h.alive[f] = this.count; - let move = 0; - for (let i = 0; i < this.count; i++) move += Math.hypot(this.vx[i], this.vy[i]); - h.movement[f] = move; + h.alive[f] = this.totalPop; + h.movement[f] = this.moveAccum; let food = 0; const res = this.resource; for (let i = 0; i < res.length; i += 3) food += res[i]; @@ -607,7 +762,7 @@ export class World { let peak = 0; for (let i = 0; i < h.alive.length; i++) peak = Math.max(peak, h.alive[i]); return { - alive: this.count, + alive: Math.round(this.totalPop), peak, fieldMass: this.sampleFieldMass(), trailMass: this.sampleTrailMass(), @@ -617,8 +772,9 @@ export class World { sampleFieldMass() { let s = 0; - for (let i = 0; i < this.resource.length; i++) s += this.resource[i]; - return s / this.resource.length; + const res = this.resource; + for (let i = 0; i < res.length; i++) s += res[i]; + return s / res.length; } sampleTrailMass() { diff --git a/metabolism/src/song/rules.js b/metabolism/src/song/rules.js new file mode 100644 index 0000000..82fef63 --- /dev/null +++ b/metabolism/src/song/rules.js @@ -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 }; diff --git a/metabolism/src/song/serialize.js b/metabolism/src/song/serialize.js new file mode 100644 index 0000000..50a14f4 --- /dev/null +++ b/metabolism/src/song/serialize.js @@ -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); +} + diff --git a/metabolism/src/song/song.js b/metabolism/src/song/song.js index 9524155..7b1242f 100644 --- a/metabolism/src/song/song.js +++ b/metabolism/src/song/song.js @@ -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 }; diff --git a/metabolism/src/song/tribe.js b/metabolism/src/song/tribe.js new file mode 100644 index 0000000..42c7218 --- /dev/null +++ b/metabolism/src/song/tribe.js @@ -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; +} \ No newline at end of file diff --git a/metabolism/test/metrics.test.js b/metabolism/test/metrics.test.js index 5ce3223..4de92bb 100644 --- a/metabolism/test/metrics.test.js +++ b/metabolism/test/metrics.test.js @@ -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 }; } \ No newline at end of file diff --git a/metabolism/test/prepare.test.js b/metabolism/test/prepare.test.js new file mode 100644 index 0000000..679a658 --- /dev/null +++ b/metabolism/test/prepare.test.js @@ -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]}`); + } + } + } +}); diff --git a/metabolism/test/rules.test.js b/metabolism/test/rules.test.js new file mode 100644 index 0000000..4a49c7c --- /dev/null +++ b/metabolism/test/rules.test.js @@ -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); +}); diff --git a/metabolism/test/tribe.test.js b/metabolism/test/tribe.test.js new file mode 100644 index 0000000..b3f05ac --- /dev/null +++ b/metabolism/test/tribe.test.js @@ -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)})`); +}); diff --git a/metabolism/test/world.test.js b/metabolism/test/world.test.js index c980153..964c132 100644 --- a/metabolism/test/world.test.js +++ b/metabolism/test/world.test.js @@ -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); @@ -78,4 +78,31 @@ test('three songs grow fingerprints further apart than seeds of one song', () => const a2 = fingerprint(grow('drone', 99).world); 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'); }); \ No newline at end of file diff --git a/metabolism/tools/prepare.js b/metabolism/tools/prepare.js new file mode 100644 index 0000000..b84f5da --- /dev/null +++ b/metabolism/tools/prepare.js @@ -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 /prepared/-.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}`); \ No newline at end of file diff --git a/metabolism/vite.config.js b/metabolism/vite.config.js new file mode 100644 index 0000000..29c7440 --- /dev/null +++ b/metabolism/vite.config.js @@ -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/.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()], +}); \ No newline at end of file