Metabolism: rule program, song-driven life, tribe VMs; M2.7 design (strains & allegiances, living wells)
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
out/
|
out/
|
||||||
|
dist/
|
||||||
+102
-40
@@ -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.
|
wide. The track's rhythm is literally folded into the geometry of the food.
|
||||||
|
|
||||||
### Field — food
|
### Field — food
|
||||||
Events splat resource onto a 128×128 grid. On top of that, **wells** — slow,
|
Events splat resource onto a 128×128 grid. On top of that, **wells** — small,
|
||||||
permanent fertile places — are placed by the track's own harmony: one well per
|
fertile places with a life of their own. There are many of them (8–16) and
|
||||||
strong pitch class, on two rings, with richness proportional to the chroma
|
each is small and weak: a tight kernel, a modest recharge rate, no single well
|
||||||
and the band balance. The chord is the map. Wells recharge slowly to a
|
rich enough to carry a world — the same total fertility budget the old
|
||||||
ceiling, so a place has a carrying capacity, not an infinite tap.
|
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
|
### Graph — relations
|
||||||
The 3×3 correlation matrix of the three band envelopes becomes the species'
|
The band-envelope correlation matrix now works at two scales. It decides the
|
||||||
*interaction matrix*: bands that move together attract; a band that lives its
|
**division of labour** — which strain motifs complement which, so a bass line
|
||||||
own life repels the others. For a bass line that locks with the kick, the
|
locked with the kick pairs diggers with haulers — and it feeds the
|
||||||
builders court the foragers; for a high band that does its own thing, the
|
**diplomacy**: the harmony the track sustains maps to how bodies treat each
|
||||||
sparks are ostracised. Society is a property of the mix.
|
other, truce and trade versus skirmish and siege. Society is a property of
|
||||||
|
the mix, at both scales.
|
||||||
|
|
||||||
### Law — regime per section
|
### Law — regime per section
|
||||||
Section boundaries are found where the 4 Hz feature cloud changes its mind
|
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 ↑ | jitter ↑ | noisy track = jittery creatures |
|
||||||
| flatness ↓ | trail half-life ↑, deposit ↑ | tonal track = heavy ground markings |
|
| flatness ↓ | trail half-life ↑, deposit ↑ | tonal track = heavy ground markings |
|
||||||
| beat strength | alignment and pace | a pulse makes them march |
|
| 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 |
|
| onset density | separation, split cost | dense track = thronged dish but crowded apart |
|
||||||
| spectral tilt | hue | bright mixes warm, dark mixes cold |
|
| 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
|
**Personality — the strain.** The song decides how many kinds of minds its
|
||||||
ink-dropper of the world: it paints the structure the synth earned.
|
world needs (*m*, read from how many independent voices the mix resolves
|
||||||
- **forager** — commutes between wells and draws the roads. Direction lives
|
into, bounded by arrangement depth, jittered by the seed) and compiles a
|
||||||
in its streaks.
|
**LittleVM program for each strain**: grazer, builder, sentinel, scout,
|
||||||
- **spark** — small, fast, orbits wide, deposits almost nothing. The counter-
|
breeder — each with its own senses (food, densities, trail, law, hunger, rim,
|
||||||
melody: it makes the film read as inhabited rather than decorated.
|
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
|
A species name like "fast tonal weaver" falls out of the genome, so the report
|
||||||
can talk about the world and not the track.
|
can talk about the world and not the track.
|
||||||
|
|
||||||
## The world
|
## The world
|
||||||
|
|
||||||
A fixed-step simulation (~2600 typed, garbage-free agents; spatial hash for
|
A fixed-step, garbage-free simulation over typed arrays: every RNG stream
|
||||||
neighbourhood; every RNG stream seeded). Per frame:
|
seeded, every update a pure function of the previous substep. The course
|
||||||
|
correction (**EVOLUTION.md**) replaces the entity ecology with a
|
||||||
1. inject this frame's events — food is placed, and strong events also
|
**population-field** ecology — the same front-end (events place food, wells
|
||||||
*spawn* life and leave a **ring** on the ground (the clearest causality in
|
born from and living by the chord, laws at section boundaries) feeding
|
||||||
the film);
|
*density* instead of discrete agents, stepped several times per rendered frame so macro patterns
|
||||||
2. decay field and trails; recharge wells (rate set by the section's law);
|
form. Nothing here is realtime: no player, no clock, no allocation — typed
|
||||||
3. each agent: smell the food gradient, obey its caste's relation to its well,
|
arrays, a step function, and a history we can regress against the song.
|
||||||
feel its neighbours (separate / align / cohere / inter-caste attraction),
|
|
||||||
wander with jitter, hit soft walls, eat, move, lay ink, split on
|
|
||||||
accumulation, die on starvation;
|
|
||||||
4. compact.
|
|
||||||
|
|
||||||
There is no player, no realtime budget, no entity objects — only typed arrays,
|
|
||||||
a step function, and a history we can regress against the song.
|
|
||||||
|
|
||||||
## The lens
|
## The lens
|
||||||
|
|
||||||
@@ -228,6 +267,10 @@ What will be previewed is exactly what will be exported.
|
|||||||
|
|
||||||
## Build order & status
|
## 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.
|
- **v0 — substrate** ✔ one song, one species, alive on the dish.
|
||||||
*Built:* analysis → structures → world → renderer → filmstrips, metrics,
|
*Built:* analysis → structures → world → renderer → filmstrips, metrics,
|
||||||
15 tests green.
|
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);
|
- **v2 — story** laws switch at section boundaries (done in the world);
|
||||||
event-reactive camera cutting (done in camera.js); filmstrip + report tools
|
event-reactive camera cutting (done in camera.js); filmstrip + report tools
|
||||||
wired.
|
wired.
|
||||||
- **v3 — search** rule-space search with the coupling × residual fitness —
|
- **M1 — macro world** (EVOLUTION.md) replacing the entity sim with a
|
||||||
only after the substrate exists, and only as a sampling tool, never as a
|
population-field sim: `FIELD=128` density grid (rendered at 256² texture),
|
||||||
user-facing knob.
|
`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
|
## Current state, honestly
|
||||||
|
|
||||||
The world is alive and the songs diverge on the metrics, but it is not yet a
|
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
|
*film*: the renderer is a software-framebuffer proof, the reference tracks are
|
||||||
at 480p, and the three reference tracks are synthetic test signals rather than
|
synthetic test signals, the ground the population stands on is still furniture
|
||||||
music. The next milestone is three videos — one per song — that a person can
|
(four permanent springs), and the population itself is still three fixed
|
||||||
sit through and tell apart without the report.
|
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.
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# evolution — from a petri dish to a world
|
||||||
|
|
||||||
|
Course correction after watching the demo, agreed with the code:
|
||||||
|
|
||||||
|
**What works.** The song→analysis→events→wells→laws front-end genuinely connects
|
||||||
|
to the sim (coupling/residual and fingerprint divergence are real). The
|
||||||
|
determinism discipline is intact and now proven bit-for-bit in the browser.
|
||||||
|
The demo shell and the tools share one `Run`.
|
||||||
|
|
||||||
|
**What failed.** The *world behind the Run*. All songs grow the same three
|
||||||
|
castes on the same 128² dish with the same opening and the same laws-as-multipliers.
|
||||||
|
Statistically different, structurally identical. Two skins, not two habitats.
|
||||||
|
And the camera watches it at the microscopic scale, so nothing larger ever has
|
||||||
|
room to appear.
|
||||||
|
|
||||||
|
**The correction** is one idea: the world must be a *macro* ecology — population
|
||||||
|
density, not individual dots — governed by *structural rules* the song compiles
|
||||||
|
to, watched by a camera that *travels* instead of cutting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
### M0 — agreement ✔
|
||||||
|
Representation: **population fields**. Rules: **a scoped rule program** (compiled
|
||||||
|
per song, deterministic). Plan: this document. `DESIGN.md` updated to the new
|
||||||
|
end-goal.
|
||||||
|
|
||||||
|
### M1 — the macro world: density, substeps, geography ✔
|
||||||
|
The entity sim is replaced by a population-field sim.
|
||||||
|
|
||||||
|
- **World**: `FIELD=128` integrated density grid (rendered as `RENDER_FIELD=256`
|
||||||
|
speck texture); layers: food, three caste populations, three tinted trail
|
||||||
|
fields, wells, a static seeded jitter pair.
|
||||||
|
- **Time**: `SUBSTEPS=5` sim steps per rendered frame (the old world: 1), so
|
||||||
|
local interactions propagate into coherent macro patterns.
|
||||||
|
- **Physics**: forces are integrated on a coarse 64² grid (2×2 block sums —
|
||||||
|
long-range coupling at a fraction of the cost), then advected on the fine
|
||||||
|
grid with a square-root-free L1 axis split. Reaction: eat → logistic growth
|
||||||
|
with a total-density gate → deposit; starvation on empty ground. Events still
|
||||||
|
splat food and bloom the voice that spoke; rings land on strong hits.
|
||||||
|
- **Home basins**: each caste anchors to its own wells (the field-world
|
||||||
|
"builder orbits its well"), so colonies persist as continents instead of
|
||||||
|
melting into a uniform haze.
|
||||||
|
- **Look**: trails compose the geography; density renders as a glow plus
|
||||||
|
deterministic speck swarms — "billions of little guys" is texture, at zero
|
||||||
|
entity cost.
|
||||||
|
- **Opening**: the dish wakes into the song's own first bars.
|
||||||
|
|
||||||
|
*Outcome:* at 60 s the population is still geographically structured (8-block
|
||||||
|
CV 1.5–1.9 vs ~0.1 before), songs and seeds diverge at a glance in-browser,
|
||||||
|
openings differ per song, realtime holds at 640×360 (960×540 on reasonable
|
||||||
|
hardware), and the test suite is green including a prepared-artifact
|
||||||
|
bit-identity check. *M2 then replaced the multiplier laws with the rule
|
||||||
|
program; the camera's travel grammar is M3.*
|
||||||
|
|
||||||
|
### M2 — the rule program: structure, not knobs ✔
|
||||||
|
The laws-as-multipliers are replaced by a **scoped rule program**
|
||||||
|
(`src/song/rules.js`) — declarative per-song rules:
|
||||||
|
|
||||||
|
- **castes** are rule entries: speed / seek / dispersion / cap / growth / eat /
|
||||||
|
deposit, plus *growth rules* — mutualism (a caste breeds fastest among the
|
||||||
|
others, so mixed crowds beat monochrome blobs) and a social floor (sparks
|
||||||
|
only bloom in a luminous crowd);
|
||||||
|
- **interaction rules** per caste pair (the signed band-correlation graph);
|
||||||
|
- per-section **operator regimes** — a breakdown drives disperse≈0 and
|
||||||
|
deposit high (the world settles and inscribes); a drop drives home≈0 and
|
||||||
|
turbulence+spawn high (the flood). Structural, scoped by the track's own
|
||||||
|
character (noisy → turbulence up, tonal → deposit up);
|
||||||
|
- the **event→response** mapping: which voice feeds which caste, and how much
|
||||||
|
of every hit splashes to the others;
|
||||||
|
- **memory** (trail/field half-lives, diffusion) and a **global carrying
|
||||||
|
capacity** so one song cannot flood while another starves;
|
||||||
|
- compiled once per song, deterministically — the program is part of the song,
|
||||||
|
rebuilt bit-identically by the prepared-artifact path, and v3's rule search
|
||||||
|
now searches *programs*, not floats.
|
||||||
|
|
||||||
|
*Outcome:* 23 tests green, including: program compilation is deterministic;
|
||||||
|
different songs get structurally different programs; regimes are structurally
|
||||||
|
distinct by kind (a growth=0 edit visibly halts the world — the program is
|
||||||
|
executed, not decoration); genesis pre-simulates generations so frame 0 is
|
||||||
|
already an ecosystem, with the amount tuned per song; the opening is seeded
|
||||||
|
where the song's own hits landed. Mixing is a program rule: castes co-mingle
|
||||||
|
in one shared geography (drone 0.93, dusk 0.78, runner 0.48 block-mix; was
|
||||||
|
~0.02–0.15), the lens holds the whole world (zoom ~0.9–1.6), and divergence
|
||||||
|
between songs grew to 16–40× where seeds stay ~0.01–0.05.
|
||||||
|
|
||||||
|
**M2.5 — song-driven life, more generations, higher altitude ✔**
|
||||||
|
- *The song spawns:* colonies are no longer placed at a pre-chosen set of
|
||||||
|
spots. Every hit lands food *and* births life there (the voice first and
|
||||||
|
loudest, splashes to the others); the drop regime visibly floods the ground
|
||||||
|
with new colonies where the track hits.
|
||||||
|
- *Genesis:* the world pre-simulates several generations before frame 0 (the
|
||||||
|
amount comes from the song — intro length, onset density, pulse), so the
|
||||||
|
opening is an old, settled ecosystem, never a blank plate, and the film's
|
||||||
|
history starts clean at frame 0.
|
||||||
|
- *Altitude:* the lens sits higher (zoom ~0.6–1.5), holding the whole map —
|
||||||
|
different cultures and the roads and fronts between them — in frame.
|
||||||
|
|
||||||
|
### M2.6 — the tribes are programs ✔
|
||||||
|
The M2 rule program steered *how hard* behaviours ran. Tribes go deeper: each
|
||||||
|
tribe is a small **deterministic behaviour program** (`src/song/tribe.js`) — a
|
||||||
|
tiny VM with
|
||||||
|
|
||||||
|
- **sensing** (food, own/other densities, trail, law, hunger, rim-edge, time),
|
||||||
|
- **memory** (four persistent registers, smoothed by the track's pulse),
|
||||||
|
- **actions** (move, excrete, eat, hit, spawn),
|
||||||
|
- **basic branching** (if hunger > threshold → seek food, else hold-and-ink),
|
||||||
|
|
||||||
|
compiled **by the song**: the band-correlation matrix signs who a tribe
|
||||||
|
approaches vs flees, flatness sets how much it branches on gradients, onset
|
||||||
|
density sets its spawning, the seed jitters thresholds. The world no longer
|
||||||
|
executes a hand-tuned force formula on the coarse decision grid — it runs what
|
||||||
|
each tribe's program decides, every substep. Surprising behaviour is *possible*
|
||||||
|
precisely because the programs branch: a starving tribe behaves differently
|
||||||
|
from a fed one, and the threshold is musical.
|
||||||
|
|
||||||
|
*Outcome:* the three societies are now balanced and mixed (drone 0.86 /
|
||||||
|
runner 0.87 / dusk 0.95 block-mix; populations 40–76 k, castes near-equal), the
|
||||||
|
geography holds (population CV 2.2–2.8). New tests pin the VM: programs compile
|
||||||
|
deterministically and differ by song (restless/social/hoarding/breeding
|
||||||
|
archetypes selected from the stats); branching changes behaviour; registers
|
||||||
|
persist as memory; a no-op program visibly changes the film. Realtime holds at
|
||||||
|
640×360 (tribe runs add ~4 ms/frame at 960×540 in node).
|
||||||
|
|
||||||
|
*Honest limit:* the compiler's vocabulary is still a small archetype family, so
|
||||||
|
the *structural* divergence between songs is modest (cross ≈ 0.16–0.18 vs
|
||||||
|
within-seed ≈ 0.02–0.04, ~4–7×) even though the films look different. Broadening
|
||||||
|
the instruction grammar (more branch shapes, richer memory, more action
|
||||||
|
archetypes) is the natural next step for this layer.
|
||||||
|
|
||||||
|
### M2.7 — one species, many minds: strains & allegiances (design agreed; not yet built)
|
||||||
|
|
||||||
|
A further correction, agreed before building anything on M2.6: **drop tribes
|
||||||
|
entirely.** Not three castes, not k tribes of fixed membership. The picture is
|
||||||
|
*one population of specialised microorganisms building a bigger organism* —
|
||||||
|
and then several such macro-organisms sharing a map, sometimes fighting,
|
||||||
|
sometimes cooperating. Membership is a property of each organism, and it can
|
||||||
|
change over time.
|
||||||
|
|
||||||
|
**Wells keep the correction from the previous draft** — small, weak, many
|
||||||
|
(8–16), born where a pitch class enters and holds, drifting with its harmony,
|
||||||
|
dying when it leaves, all on a precompiled deterministic timeline — because
|
||||||
|
wells are where bodies collide: everyone needs them, so a well is the natural
|
||||||
|
front, its birth a land-rush, its death a retreat.
|
||||||
|
|
||||||
|
**The population is one density field with two live properties:**
|
||||||
|
|
||||||
|
- **strain mix (personality).** The song decides how many minds it needs —
|
||||||
|
*m* strains from the number of independent voices the mix resolves into,
|
||||||
|
bounded by arrangement depth, jittered by the seed — and compiles a
|
||||||
|
**LittleVM program per strain** from the grammar: senses (food, density,
|
||||||
|
trail, law, hunger, rim, well proximity and liveliness, body presence),
|
||||||
|
registers as memory, real branching, actions (move, graze, excrete, hit,
|
||||||
|
spawn, follow, patrol). Density is partitioned by strain; each strain
|
||||||
|
advects by its own program. And the mix **evolves by local selection**:
|
||||||
|
strains that eat and grow where they stand propagate, proportions shift
|
||||||
|
district by district — specialisation is visible and develops over the film.
|
||||||
|
- **allegiance (the body).** A composition field over bodies (the song opens
|
||||||
|
2–4; a cap of ~8 keeps colour legible), advected frozen-in with the
|
||||||
|
population flux. Same body = **cooperation**: shared trails, reinforcement,
|
||||||
|
complementary strains building one structure. Different bodies = **contest**
|
||||||
|
where presences overlap: hit-actions spend density to push the other body's
|
||||||
|
ground and convert it, scaled by the song's harmony — consonant, locked
|
||||||
|
voices breed truce and symbiosis; dissonant, decorrelated voices breed
|
||||||
|
skirmish and siege. Allegiance changes by rule, never by fiat:
|
||||||
|
- **conversion** — a won front converts the locals it overruns;
|
||||||
|
- **defection** — a body whose presence collapses (starved, outflanked)
|
||||||
|
releases its members to the neighbouring body;
|
||||||
|
- **fission** — a coherent splinter cut off from its body, hit by a strong
|
||||||
|
onset, founds a new body with a new colour;
|
||||||
|
- **merger** — two bodies whose members fully intermix under a cooperative
|
||||||
|
regime become one.
|
||||||
|
|
||||||
|
**The song sets the diplomacy.** Consonance and the section regimes map to
|
||||||
|
war and peace: drops are expansion wars, breakdowns are truces and mutual
|
||||||
|
building, an outro is a settling of borders. The body-to-body relations come
|
||||||
|
from the music, not a constant.
|
||||||
|
|
||||||
|
*Exit: two songs with the same pulse but different arrangement depth compile
|
||||||
|
different strain counts and different diplomacies; at least one allegiance
|
||||||
|
change (conversion, defection, fission or merger) is legible on screen per
|
||||||
|
film; strain proportions measurably drift across the film (local selection
|
||||||
|
actually runs); a well's birth, drift and death are each legible; the world's
|
||||||
|
typed-array layers, trail tints and palette generalise from 3 fixed castes to
|
||||||
|
the strain/body fields; the prepared-artifact path still rebuilds
|
||||||
|
bit-identical timelines, programs and opening state.*
|
||||||
|
|
||||||
|
### M3 — the lens: travel, don't cut
|
||||||
|
Replace the cut-to-a-point grammar with a **move grammar**: *track* (follow a
|
||||||
|
moving front), *reveal* (pan along a coastline), *establish* (hold wide and
|
||||||
|
slow). Cuts are reserved for musical punctuation — strong onsets in drop/build.
|
||||||
|
Because the world is macro, a pan teaches the map instead of disorienting.
|
||||||
|
|
||||||
|
*Exit: a 120 s render where a single front can be followed across the world
|
||||||
|
without losing bearings; cuts read as beats, not jumps.*
|
||||||
|
|
||||||
|
### M4 — the film
|
||||||
|
Mux frames + PCM to video. No longer the bottleneck: the preview pipeline and
|
||||||
|
the lens exist; once M1–M3 make the world worth exporting, muxing is plumbing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The prepare step (pre-calculation)
|
||||||
|
|
||||||
|
Viewing should be fast because the *expensive* work happens ahead of time, once:
|
||||||
|
|
||||||
|
1. `tools/prepare.js` runs the WAV → analysis → compile pipeline in node and
|
||||||
|
writes a **prepared song** (`out/prepared/<song>-<seed>.json`): the compiled
|
||||||
|
rule program and every analysis structure the world, camera, and metrics
|
||||||
|
need — but not the raw PCM.
|
||||||
|
2. The browser fetches the prepared song instead of analysing the WAV. The WAV
|
||||||
|
is still fetched, but only to feed the WebAudio clock (decoded straight to a
|
||||||
|
buffer; analysis skipped).
|
||||||
|
3. `src/song/serialize.js` is the single serialize/deserialize for both sides —
|
||||||
|
the browser's `deserializeSong` must rebuild a song that is *bit-identical*
|
||||||
|
to one built by analysing the WAV, so prepare can never drift from the live
|
||||||
|
path. That equality is a test.
|
||||||
|
|
||||||
|
This is exactly the DESIGN promise inverted and kept: the pipeline still defines
|
||||||
|
the world; prepare only pays its cost once, off the viewing path.
|
||||||
|
|
||||||
|
## Invariants, restated for the field world
|
||||||
|
|
||||||
|
- **Determinism**: same song + same seed + same frame ⇒ same pixel. No
|
||||||
|
`Math.random`, no wall clock, no per-substep RNG. The static jitter noise
|
||||||
|
field is generated once from the seeded RNG at construction.
|
||||||
|
- **Causality**: events still place food and rings where the track put them;
|
||||||
|
a section boundary still changes the rules at the boundary only.
|
||||||
|
- **Persistence**: population fields carry their history; trails accumulate in
|
||||||
|
the same three tinted layers the renderer already composes.
|
||||||
|
- **Consequence**: everything is a pure update rule over the previous substep;
|
||||||
|
nothing is re-rolled.
|
||||||
+72
-14
@@ -60,29 +60,81 @@ npm run strip -- --songs drone,runner,dusk --combined 1 # comparison sheet
|
|||||||
# report: coupling × residual fitness + fingerprint divergence across songs/seeds
|
# report: coupling × residual fitness + fingerprint divergence across songs/seeds
|
||||||
npm run report # prints a table, writes out/report.md + report.json
|
npm run report # prints a table, writes out/report.md + report.json
|
||||||
npm run report -- --songs drone,runner,dusk --seeds 999,12345,777777
|
npm run report -- --songs drone,runner,dusk --seeds 999,12345,777777
|
||||||
|
|
||||||
|
# prepare: analyse songs once, so the demo loads instantly instead of
|
||||||
|
# re-analysing the WAV in the browser (falls back to live analysis if absent)
|
||||||
|
npm run prepare # writes out/prepared/<song>-<seed>.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Outputs land in `out/` (gitignored). `npm run dev` serves a Vite root once a
|
Outputs land in `out/` (gitignored).
|
||||||
browser demo exists.
|
|
||||||
|
## The demo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev # open http://localhost:5173 and pick a song
|
||||||
|
```
|
||||||
|
|
||||||
|
A browser demo that plays the songs off the actual pipeline. The page serves
|
||||||
|
the bank from `../flow-state/test/songs` (via `vite.config.js` — no copies),
|
||||||
|
lists **all 17 tracks** with the species each one grows, and plays the chosen
|
||||||
|
track while the world steps to it:
|
||||||
|
|
||||||
|
- **select a song** → it fetches the WAV, decodes it, and grows the world to
|
||||||
|
frame 0 before letting you in. Run `npm run prepare` once and the browser
|
||||||
|
loads the pre-computed analysis instead of re-analysing, so viewing is fast
|
||||||
|
(it falls back to live analysis when an artifact is missing);
|
||||||
|
- **play / pause** (or <kbd>space</kbd>), **seek** (or <kbd>←</kbd>/<kbd>→</kbd>
|
||||||
|
±5 s) — the audio clock only chooses *which* frame the world should have
|
||||||
|
reached, so seeking is fast forward; a backward seek re-grows from frame 0;
|
||||||
|
- **seed** (the exact world), **res** (640×360 realtime default, up to
|
||||||
|
1280×720) and **volume**; changes live without breaking playback;
|
||||||
|
- the seek bar shows **section ticks** (the laws), the status line shows the
|
||||||
|
current law, shot size/zoom and live world stats (population, field, trail);
|
||||||
|
- the page accent **wears the track's palette hue**, and the URL carries
|
||||||
|
`?song=&seed=&res=` so a film is a link.
|
||||||
|
|
||||||
|
Determinism is the point: the page renders through the same `Run` as the
|
||||||
|
tools — same song, same seed, same frame ⇒ the same pixels (tested bit-identical
|
||||||
|
against `strip` output, including the prepared-artifact path). `npm run build`
|
||||||
|
bundles it; `npm run preview` serves the built page from `dist/`. The world
|
||||||
|
behind the `Run` is the **macro ecology** of EVOLUTION.md M1 — population
|
||||||
|
fields, not entities; see `EVOLUTION.md`.
|
||||||
|
|
||||||
|
Files: `index.html`, `vite.config.js` (song-bank + prepared-artifact plugin),
|
||||||
|
`src/demo/*` (`main.js` page wiring, `player.js` Run→canvas, `audio.js`
|
||||||
|
WebAudio clock, `catalog.js` metadata, `style.css`).
|
||||||
|
|
||||||
## Layout
|
## 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/
|
src/
|
||||||
audio/ WAV decode, radix-2 FFT, feature table, sectioning, onset score
|
audio/ WAV decode, radix-2 FFT, feature table, sectioning, onset score
|
||||||
song/ events (onsets → geometry), species (genome), palette, song assembly
|
song/ events (onsets → geometry), species (genome), rules.js (the
|
||||||
sim/ wells (harmony→food) and world.js (the typed-array ecology)
|
per-song rule program + section operator regimes),
|
||||||
render/ software framebuffer, trail composer, PNG encoder
|
tribe.js (the per-tribe behaviour VM: senses, memory,
|
||||||
camera.js event-reactive cutting, shot-size rhythm, drift-and-push
|
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
|
film.js Run: the single wire-up of sim → camera → framebuffer
|
||||||
metrics.js coupling, residual, fitness, fingerprint, divergence
|
metrics.js coupling, residual, fitness, fingerprint, divergence
|
||||||
rng.js seeded PRNGs (the only randomness in the repo)
|
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/
|
tools/
|
||||||
load.js WAV → analysis → song (node-only)
|
load.js WAV → analysis → song (node-only)
|
||||||
|
prepare.js pre-computes analysis → out/prepared/<song>-<seed>.json
|
||||||
strip.js filmstrip renderer
|
strip.js filmstrip renderer
|
||||||
report.js system-level evaluation → markdown table
|
report.js system-level evaluation → markdown table
|
||||||
test/ node:test suites
|
test/ node:test suites (incl. prepared-artifact bit-identity)
|
||||||
```
|
```
|
||||||
|
|
||||||
## References
|
## References
|
||||||
@@ -95,12 +147,18 @@ bright/noisy/fast, darkest/all-tonal/mid-tempo. Drop any WAV there and point
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Currently a proof: the simulation is alive, the three songs are measurably
|
M1, M2 and M2.6 of **EVOLUTION.md** are done: the entity world is a **macro,
|
||||||
distinct (fingerprint divergence between songs ≫ divergence between seeds),
|
population-field ecology** (density, substeps, coarse-grid forces, speck
|
||||||
and the tools verify it. It is **not yet a film** — nothing is muxed to
|
rendering); the rules that run it are a **declared, compiled rule program**
|
||||||
audio/video, the renderer is a 480 p proof, and the demo page isn't built.
|
(per-caste rule entries, interaction rules, per-section operator regimes, the
|
||||||
Next milestones: a browser demo, event-reactive cutting tuned to a full real
|
event→response mapping); and the tribes are **small deterministic programs** —
|
||||||
track, and video export. See DESIGN.md's *Build order & status*.
|
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
|
## Auditing what it grew
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>metabolism — songs as substrate</title>
|
||||||
|
<link rel="stylesheet" href="/src/demo/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-mark" aria-hidden="true"></div>
|
||||||
|
<div class="brand-text">
|
||||||
|
<h1>metabolism</h1>
|
||||||
|
<p class="tagline">song in → world out. pick a track, grow its film.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<label class="ctl" title="The seed picks the exact world a song grows. Same song + same seed is the same film, bit for bit.">
|
||||||
|
<span>seed</span>
|
||||||
|
<input id="seed" type="number" min="1" max="4294967295" step="1" value="12345" />
|
||||||
|
</label>
|
||||||
|
<label class="ctl" title="Render resolution. 640×360 is the realtime default; higher is heavier.">
|
||||||
|
<span>res</span>
|
||||||
|
<select id="res">
|
||||||
|
<option value="640x360">640 × 360</option>
|
||||||
|
<option value="960x540">960 × 540</option>
|
||||||
|
<option value="1280x720">1280 × 720</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="ctl" title="Main volume">
|
||||||
|
<span>vol</span>
|
||||||
|
<input id="volume" type="range" min="0" max="100" step="1" value="90" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="layout">
|
||||||
|
<aside class="library">
|
||||||
|
<div class="library-head">
|
||||||
|
<h2>Library</h2>
|
||||||
|
<span id="lib-count" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
<ul id="songs" class="songs"></ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="stage">
|
||||||
|
<div class="screen">
|
||||||
|
<canvas id="view"></canvas>
|
||||||
|
<div id="overlay" class="overlay" hidden></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="caption" class="caption">…</div>
|
||||||
|
|
||||||
|
<div class="transport">
|
||||||
|
<button id="play" class="play" aria-label="play / pause">▶</button>
|
||||||
|
<div class="seekbar">
|
||||||
|
<div class="track" id="track">
|
||||||
|
<div id="fill" class="fill"></div>
|
||||||
|
<div id="ticks" class="ticks"></div>
|
||||||
|
<input id="seek" class="seek-input" type="range" min="0" max="1000" step="1" value="0"
|
||||||
|
aria-label="seek" />
|
||||||
|
</div>
|
||||||
|
<div class="times">
|
||||||
|
<span id="t0">0:00</span>
|
||||||
|
<span id="t1">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="statusline">
|
||||||
|
<span id="law" class="law">—</span>
|
||||||
|
<span id="shot" class="muted">—</span>
|
||||||
|
<span id="hud" class="muted">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="footnote">
|
||||||
|
one run, no clock: the world steps to the song, the camera answers the world, and the
|
||||||
|
same <code>Run</code> renders here and in the export tools. no <code>Math.random</code>,
|
||||||
|
no wall clock — same song · same seed · same frame ⇒ same pixel.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script type="module" src="/src/demo/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,9 +6,12 @@
|
|||||||
"description": "Songs as substrate: agent ecologies whose laws are derived from the track, rendered deterministically.",
|
"description": "Songs as substrate: agent ecologies whose laws are derived from the track, rendered deterministically.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
"test": "node --test test/*.test.js",
|
"test": "node --test test/*.test.js",
|
||||||
"strip": "node tools/strip.js",
|
"strip": "node tools/strip.js",
|
||||||
"report": "node tools/report.js"
|
"report": "node tools/report.js",
|
||||||
|
"prepare": "node tools/prepare.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^7.2.2"
|
"vite": "^7.2.2"
|
||||||
|
|||||||
+16
-14
@@ -21,8 +21,8 @@ export class Camera {
|
|||||||
|
|
||||||
this.cx = 0.5;
|
this.cx = 0.5;
|
||||||
this.cy = 0.5;
|
this.cy = 0.5;
|
||||||
this.zoom = 1.35;
|
this.zoom = 0.9;
|
||||||
this.baseZoom = 1.35;
|
this.baseZoom = 0.9;
|
||||||
|
|
||||||
this.shot = 0;
|
this.shot = 0;
|
||||||
this.shotStart = 0;
|
this.shotStart = 0;
|
||||||
@@ -50,7 +50,7 @@ export class Camera {
|
|||||||
pickFocus(zoom) {
|
pickFocus(zoom) {
|
||||||
const act = this.world.activity;
|
const act = this.world.activity;
|
||||||
const n = 32;
|
const n = 32;
|
||||||
const half = 0.5 / zoom;
|
const half = Math.min(0.5, 0.5 / zoom);
|
||||||
const lo = half + 0.02;
|
const lo = half + 0.02;
|
||||||
const hi = 1 - half - 0.02;
|
const hi = 1 - half - 0.02;
|
||||||
let best = -1;
|
let best = -1;
|
||||||
@@ -92,14 +92,14 @@ export class Camera {
|
|||||||
const law = this.lawAt(frame);
|
const law = this.lawAt(frame);
|
||||||
const energy = law.energy ?? 0.5;
|
const energy = law.energy ?? 0.5;
|
||||||
|
|
||||||
// Shot size: a spread-out world gets a wider lens; a concentrated one
|
// Shot size: the macro world IS the shot. We hang up high so the whole
|
||||||
// gets a close-up. Every third shot widens deliberately, so the edit
|
// map — every culture and the roads between them — is in frame, and
|
||||||
// has a rhythm of sizes and not just a rhythm of cuts.
|
// only dip toward a knot on the rare close beat.
|
||||||
const coverage = this.activityCoverage();
|
const coverage = this.activityCoverage();
|
||||||
let zoom = 1.35 + (1 - coverage) * 2.45;
|
let zoom = 0.92 + (1 - coverage) * 0.55;
|
||||||
if (this.shot % 3 === 2) zoom *= 0.74;
|
if (this.shot % 3 === 2) zoom *= 0.86;
|
||||||
else if (this.shot % 3 === 1) zoom *= 1.16;
|
else if (this.shot % 3 === 1) zoom *= 1.12;
|
||||||
zoom = clamp(zoom, 1.25, 4.6);
|
zoom = clamp(zoom, 0.62, 1.5);
|
||||||
|
|
||||||
const focus = this.pickFocus(zoom);
|
const focus = this.pickFocus(zoom);
|
||||||
this.target = { x: focus.x, y: focus.y };
|
this.target = { x: focus.x, y: focus.y };
|
||||||
@@ -128,14 +128,16 @@ export class Camera {
|
|||||||
const e = smooth(t);
|
const e = smooth(t);
|
||||||
// A slow drift across the subject, in frame fractions rather than world
|
// 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.
|
// 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.cx = this.target.x + (this.dir * 0.030 * e) / this.zoom;
|
||||||
this.cy = this.target.y - (this.dir * 0.008 * e) / this.zoom;
|
this.cy = this.target.y - (this.dir * 0.016 * e) / this.zoom;
|
||||||
this.zoom = this.baseZoom * (1 + 0.07 * e);
|
this.zoom = this.baseZoom * (1 + 0.10 * e);
|
||||||
this.clampToWorld();
|
this.clampToWorld();
|
||||||
}
|
}
|
||||||
|
|
||||||
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.cx = clamp(this.cx, half, 1 - half);
|
||||||
this.cy = clamp(this.cy, half, 1 - half);
|
this.cy = clamp(this.cy, half, 1 - half);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
// metabolism — demo player
|
||||||
|
//
|
||||||
|
// The page is a thin shell over the real pipeline: a song is decoded with the
|
||||||
|
// exact decodeWav/analyse/buildSong the tools use, the world steps inside the
|
||||||
|
// same Run, and the audio clock only decides which frame the world should have
|
||||||
|
// reached. Nothing in the simulation reads the clock or a RNG.
|
||||||
|
//
|
||||||
|
// boot() -> load catalog, pick song from URL, start rAF loop
|
||||||
|
// selectSong(id) -> fetch/decode/analyse/build, grow to frame 0
|
||||||
|
// rAF loop (playing) -> target frame from audio clock -> step -> render
|
||||||
|
// seek/seed/res -> forward stepping is free; a backwards seek re-grows
|
||||||
|
// from frame 0 (the world and camera are stateful)
|
||||||
|
|
||||||
|
import { buildSong } from '../song/song.js';
|
||||||
|
import { deserializeAnalysis } from '../song/serialize.js';
|
||||||
|
import { decodeWav, mono } from '../audio/wav.js';
|
||||||
|
import { analyse } from '../audio/analyse.js';
|
||||||
|
import { FPS, Player } from './player.js';
|
||||||
|
import { AudioTransport } from './audio.js';
|
||||||
|
import { fetchCatalog, fmtTime, lawColor, accentFor, tagsFor, parseQuery } from './catalog.js';
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const clamp = (v, a, b) => (v < a ? a : v > b ? b : v);
|
||||||
|
|
||||||
|
const els = {
|
||||||
|
songs: $('songs'),
|
||||||
|
libCount: $('lib-count'),
|
||||||
|
view: $('view'),
|
||||||
|
overlay: $('overlay'),
|
||||||
|
caption: $('caption'),
|
||||||
|
play: $('play'),
|
||||||
|
seek: $('seek'),
|
||||||
|
fill: $('fill'),
|
||||||
|
ticks: $('ticks'),
|
||||||
|
t0: $('t0'),
|
||||||
|
t1: $('t1'),
|
||||||
|
law: $('law'),
|
||||||
|
shot: $('shot'),
|
||||||
|
hud: $('hud'),
|
||||||
|
seed: $('seed'),
|
||||||
|
res: $('res'),
|
||||||
|
volume: $('volume'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
catalog: null,
|
||||||
|
byId: new Map(),
|
||||||
|
currentId: null,
|
||||||
|
cur: null, // { entry, song, analysis, player, audio }
|
||||||
|
seed: 12345,
|
||||||
|
res: '640x360',
|
||||||
|
playing: false,
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- audio pipeline (browser side of the shared pipeline) ------------------
|
||||||
|
|
||||||
|
function fetchWav(id) {
|
||||||
|
return fetch(`/songs/${id}.wav`).then((r) => {
|
||||||
|
if (!r.ok) throw new Error(`no such song: ${id} (${r.status})`);
|
||||||
|
return r.arrayBuffer();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analysis depends only on the song (not the seed), so cache it; it is tiny
|
||||||
|
// (~a few hundred KB). The decoded PCM (21 MB for 120 s) is kept for one slot
|
||||||
|
// so flipping seed/res of the current song is instant. When `tools/prepare.js`
|
||||||
|
// has written a prepared artifact (out/prepared/<id>.json), the browser loads
|
||||||
|
// that instead of re-analysing the WAV — viewing skips the expensive pass.
|
||||||
|
const analysisCache = new Map();
|
||||||
|
let pcmSlot = null; // { id, pcm, sampleRate }
|
||||||
|
|
||||||
|
async function getAnalysis(id) {
|
||||||
|
const cached = analysisCache.get(id);
|
||||||
|
if (cached) return cached;
|
||||||
|
let analysis = null;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/prepared/${id}.json`);
|
||||||
|
if (r.ok) analysis = deserializeAnalysis(await r.json());
|
||||||
|
} catch { /* fall through to live analysis */ }
|
||||||
|
if (!analysis) {
|
||||||
|
analysis = analyse(pcmSlot.pcm, pcmSlot.sampleRate, { fps: FPS });
|
||||||
|
}
|
||||||
|
analysisCache.set(id, analysis);
|
||||||
|
return analysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSongData(id, seed, res) {
|
||||||
|
const [w, h] = res.split('x').map(Number);
|
||||||
|
if (!pcmSlot || pcmSlot.id !== id) {
|
||||||
|
pcmSlot = await fetchWav(id).then((ab) => {
|
||||||
|
const wav = decodeWav(ab);
|
||||||
|
if (!wav) throw new Error(`could not decode WAV: ${id}`);
|
||||||
|
return { id, pcm: mono(wav.channels), sampleRate: wav.sampleRate };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const analysis = await getAnalysis(id);
|
||||||
|
const song = buildSong(analysis, seed);
|
||||||
|
const player = new Player(els.view, w, h);
|
||||||
|
player.build(song);
|
||||||
|
const audio = new AudioTransport(pcmSlot.pcm, pcmSlot.sampleRate, { onEnd: handleEnded });
|
||||||
|
return { entry: state.byId.get(id), song, analysis, player, audio };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- song selection --------------------------------------------------------
|
||||||
|
|
||||||
|
async function selectSong(id, { seed = state.seed, res = state.res, restartAt = 0, push = true } = {}) {
|
||||||
|
if (state.loading) return;
|
||||||
|
const entry = state.byId.get(id);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
state.loading = true;
|
||||||
|
const carryPos = restartAt ?? state.cur?.audio.position() ?? 0;
|
||||||
|
pauseCurrent();
|
||||||
|
setCards(id);
|
||||||
|
setOverlay(`<span class="spinner"></span>warming the world…<small>${id} · seed ${seed}</small>`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const t0 = performance.now();
|
||||||
|
const ready = await loadSongData(id, seed, res);
|
||||||
|
const old = state.cur;
|
||||||
|
state.currentId = id;
|
||||||
|
state.seed = seed;
|
||||||
|
state.res = res;
|
||||||
|
state.cur = ready;
|
||||||
|
// The new load owns the current audio; free the old context and its
|
||||||
|
// PCM buffer copy. (UI bookkeeping only — the sim never allocates here.)
|
||||||
|
if (old && old.audio !== ready.audio) old.audio.dispose();
|
||||||
|
|
||||||
|
ready.player.seekTo(carryPos);
|
||||||
|
ready.audio.seek(carryPos);
|
||||||
|
ready.player.renderCurrent();
|
||||||
|
|
||||||
|
els.seed.value = String(seed);
|
||||||
|
els.res.value = res;
|
||||||
|
applyAccent(ready.song);
|
||||||
|
setCaption(ready.entry);
|
||||||
|
buildTicks();
|
||||||
|
updateSeekUI(carryPos);
|
||||||
|
updateHUD();
|
||||||
|
setOverlay(null);
|
||||||
|
document.title = `metabolism — ${ready.entry.name}`;
|
||||||
|
state.loading = false;
|
||||||
|
if (push) syncUrl();
|
||||||
|
console.info(`[metabolism] ${id} · seed ${seed} · ${Math.round(performance.now() - t0)} ms to grow to ${fmtTime(carryPos)}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
setOverlay(`<b>could not grow “${id}”</b><small>${err.message}</small>`);
|
||||||
|
state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pauseCurrent() {
|
||||||
|
if (state.cur) state.cur.audio.pause();
|
||||||
|
state.playing = false;
|
||||||
|
updatePlayButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- transport -------------------------------------------------------------
|
||||||
|
|
||||||
|
async function togglePlay() {
|
||||||
|
const c = state.cur;
|
||||||
|
if (!c || state.loading) return;
|
||||||
|
if (state.playing) {
|
||||||
|
c.audio.pause();
|
||||||
|
state.playing = false;
|
||||||
|
updatePlayButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (c.audio.position() >= c.analysis.duration - 0.05) {
|
||||||
|
c.audio.seek(0);
|
||||||
|
c.player.seekTo(0);
|
||||||
|
c.player.renderCurrent();
|
||||||
|
updateSeekUI(0);
|
||||||
|
}
|
||||||
|
state.playing = true;
|
||||||
|
updatePlayButton();
|
||||||
|
setOverlay(null);
|
||||||
|
await c.audio.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEnded() {
|
||||||
|
state.playing = false;
|
||||||
|
updatePlayButton();
|
||||||
|
updateSeekUI(state.cur.audio.position());
|
||||||
|
setOverlay(`<b>· end ·</b><small>that world is done — press play to grow it again</small>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doSeek(seconds) {
|
||||||
|
const c = state.cur;
|
||||||
|
if (!c) return;
|
||||||
|
const sec = clamp(seconds, 0, c.analysis.duration);
|
||||||
|
c.audio.seek(sec);
|
||||||
|
c.player.seekTo(sec);
|
||||||
|
c.player.renderCurrent();
|
||||||
|
updateSeekUI(sec);
|
||||||
|
updateHUD();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- render loop -----------------------------------------------------------
|
||||||
|
|
||||||
|
let hudTick = 0;
|
||||||
|
function loop() {
|
||||||
|
const c = state.cur;
|
||||||
|
if (c && state.playing) {
|
||||||
|
const pos = c.audio.position();
|
||||||
|
const target = clamp(Math.floor(pos * FPS), 0, c.analysis.frames - 1);
|
||||||
|
c.player.stepTo(target);
|
||||||
|
c.player.renderCurrent();
|
||||||
|
updateSeekUI(pos);
|
||||||
|
if (++hudTick % 4 === 0) updateHUD();
|
||||||
|
}
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- UI updates ------------------------------------------------------------
|
||||||
|
|
||||||
|
function updateSeekUI(pos) {
|
||||||
|
const c = state.cur;
|
||||||
|
if (!c) return;
|
||||||
|
const pct = clamp((pos / c.analysis.duration) * 100, 0, 100);
|
||||||
|
els.fill.style.width = `${pct}%`;
|
||||||
|
els.seek.value = Math.round(pct * 10);
|
||||||
|
els.t0.textContent = fmtTime(pos);
|
||||||
|
els.t1.textContent = fmtTime(c.analysis.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlayButton() {
|
||||||
|
els.play.textContent = state.playing ? '❚❚' : '▶';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCaption(entry) {
|
||||||
|
els.caption.innerHTML =
|
||||||
|
`<b>${entry.name}</b> — ${entry.species} · seed ${state.seed}<br>` +
|
||||||
|
`<span class="muted">${tagsFor(entry)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCards(activeId) {
|
||||||
|
for (const li of els.songs.children) li.classList.toggle('active', li.dataset.id === activeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAccent(song) {
|
||||||
|
const { hue, sat } = song.palette;
|
||||||
|
document.documentElement.style.setProperty(
|
||||||
|
'--accent',
|
||||||
|
`hsl(${Math.round(hue * 360)}, ${Math.round(Math.min(1, sat) * 100)}%, 58%)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTicks() {
|
||||||
|
const c = state.cur;
|
||||||
|
els.ticks.innerHTML = '';
|
||||||
|
if (!c) return;
|
||||||
|
const D = c.analysis.duration;
|
||||||
|
for (const l of c.song.laws.slice(1)) {
|
||||||
|
const t = document.createElement('span');
|
||||||
|
t.className = 'tick';
|
||||||
|
t.style.left = `${(l.start / D) * 100}%`;
|
||||||
|
t.style.background = lawColor(l.kind);
|
||||||
|
t.title = `${l.kind} @ ${fmtTime(l.start)}`;
|
||||||
|
els.ticks.appendChild(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLaw() {
|
||||||
|
const c = state.cur;
|
||||||
|
if (!c) return;
|
||||||
|
const t = c.audio.position();
|
||||||
|
const laws = c.song.laws;
|
||||||
|
let i = 0;
|
||||||
|
for (let k = 0; k < laws.length; k++) if (t >= laws[k].start) i = k;
|
||||||
|
const kind = laws[i].kind;
|
||||||
|
els.law.textContent = `${kind} · section ${i + 1}/${laws.length}`;
|
||||||
|
els.law.style.color = lawColor(kind);
|
||||||
|
els.law.style.borderColor = lawColor(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHUD() {
|
||||||
|
const c = state.cur;
|
||||||
|
if (!c) return;
|
||||||
|
const w = c.player.run.world;
|
||||||
|
const s = w.stats();
|
||||||
|
els.hud.textContent =
|
||||||
|
`${s.alive} agents · field ${s.fieldMass.toFixed(2)} · trail ${s.trailMass.toFixed(3)}`;
|
||||||
|
const cam = c.player.run.camera;
|
||||||
|
const size = cam.zoom < 1.8 ? 'wide' : cam.zoom < 2.7 ? 'medium' : 'close';
|
||||||
|
els.shot.textContent = `shot ${cam.shot} · ${size} · zoom ${cam.zoom.toFixed(2)}`;
|
||||||
|
setLaw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOverlay(html) {
|
||||||
|
if (!html) {
|
||||||
|
els.overlay.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
els.overlay.hidden = false;
|
||||||
|
els.overlay.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncUrl() {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set('song', state.currentId);
|
||||||
|
p.set('seed', String(state.seed));
|
||||||
|
p.set('res', state.res);
|
||||||
|
history.replaceState(null, '', `?${p}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- library ---------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderLibrary(catalog) {
|
||||||
|
els.songs.innerHTML = '';
|
||||||
|
state.byId = new Map();
|
||||||
|
for (const entry of catalog.songs) {
|
||||||
|
state.byId.set(entry.name, entry);
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'song';
|
||||||
|
li.dataset.id = entry.name;
|
||||||
|
const swatch = document.createElement('span');
|
||||||
|
swatch.className = 'swatch';
|
||||||
|
swatch.style.background = accentFor(entry);
|
||||||
|
const meta = document.createElement('span');
|
||||||
|
meta.className = 'smeta';
|
||||||
|
const name = document.createElement('span');
|
||||||
|
name.className = 'sname';
|
||||||
|
name.textContent = entry.name;
|
||||||
|
const species = document.createElement('span');
|
||||||
|
species.className = 'sspecies';
|
||||||
|
species.textContent = entry.species;
|
||||||
|
meta.append(name, species);
|
||||||
|
const tags = document.createElement('span');
|
||||||
|
tags.className = 'stags';
|
||||||
|
tags.textContent = tagsFor(entry);
|
||||||
|
li.append(swatch, meta, tags);
|
||||||
|
li.addEventListener('click', () => selectSong(entry.name));
|
||||||
|
els.songs.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- controls --------------------------------------------------------------
|
||||||
|
|
||||||
|
els.play.addEventListener('click', togglePlay);
|
||||||
|
|
||||||
|
let pausedByDrag = false;
|
||||||
|
els.seek.addEventListener('pointerdown', () => {
|
||||||
|
const c = state.cur;
|
||||||
|
if (!c) return;
|
||||||
|
pausedByDrag = state.playing;
|
||||||
|
if (pausedByDrag) {
|
||||||
|
c.audio.pause();
|
||||||
|
state.playing = false;
|
||||||
|
updatePlayButton();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
els.seek.addEventListener('change', () => {
|
||||||
|
const c = state.cur;
|
||||||
|
if (!c) return;
|
||||||
|
const sec = (els.seek.valueAsNumber / 1000) * c.analysis.duration;
|
||||||
|
doSeek(sec);
|
||||||
|
if (pausedByDrag) {
|
||||||
|
pausedByDrag = false;
|
||||||
|
state.playing = true;
|
||||||
|
updatePlayButton();
|
||||||
|
c.audio.play();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
els.seed.addEventListener('change', () => {
|
||||||
|
const v = parseInt(els.seed.value, 10);
|
||||||
|
const seed = Number.isFinite(v) && v > 0 ? v >>> 0 : 12345;
|
||||||
|
els.seed.value = String(seed);
|
||||||
|
selectSong(state.currentId, { seed, res: state.res, restartAt: state.cur?.audio.position() ?? 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
els.res.addEventListener('change', () => {
|
||||||
|
selectSong(state.currentId, { seed: state.seed, res: els.res.value, restartAt: state.cur?.audio.position() ?? 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
els.volume.addEventListener('input', () => {
|
||||||
|
state.cur?.audio.setVolume(els.volume.valueAsNumber / 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('keydown', (e) => {
|
||||||
|
if (/INPUT|SELECT|TEXTAREA/.test(e.target.tagName)) return;
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
e.preventDefault();
|
||||||
|
togglePlay();
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
doSeek((state.cur?.audio.position() ?? 0) + 5);
|
||||||
|
} else if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
doSeek((state.cur?.audio.position() ?? 0) - 5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- boot ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
const q = parseQuery();
|
||||||
|
state.seed = q.seed;
|
||||||
|
state.res = q.res;
|
||||||
|
els.seed.value = String(q.seed);
|
||||||
|
els.res.value = q.res;
|
||||||
|
els.volume.value = '90';
|
||||||
|
els.libCount.textContent = '…';
|
||||||
|
try {
|
||||||
|
const catalog = await fetchCatalog();
|
||||||
|
state.catalog = catalog;
|
||||||
|
renderLibrary(catalog);
|
||||||
|
els.libCount.textContent = `${catalog.songs.length} tracks`;
|
||||||
|
await selectSong(q.song, { seed: q.seed, res: q.res, push: false });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
els.libCount.textContent = 'unavailable';
|
||||||
|
setOverlay(`<b>no library</b><small>is the dev server serving <code>../flow-state/test/songs</code>? — ${err.message}</small>`);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(loop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug handle: the live world is reachable for poking, but the sim itself is
|
||||||
|
// clock-free; touching it is how the tools inspect it too.
|
||||||
|
window.metabolism = { state, get run() { return state.cur?.player.run ?? null; } };
|
||||||
|
|
||||||
|
boot();
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
/* metabolism demo player — dark, minimal, one accent taken from the track. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0a0c11;
|
||||||
|
--panel: #10141c;
|
||||||
|
--panel-2: #141924;
|
||||||
|
--border: #1e2532;
|
||||||
|
--text: #d7dbe3;
|
||||||
|
--muted: #7c8494;
|
||||||
|
--accent: #8fa7ff;
|
||||||
|
--mono: ui-monospace, "SFMono-Regular", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||||
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- topbar -------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: linear-gradient(180deg, #0d1119, var(--bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; }
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle at 30% 30%, var(--accent), #20315e 70%);
|
||||||
|
box-shadow: 0 0 14px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline { margin: 0; color: var(--muted); font-size: 12.5px; }
|
||||||
|
|
||||||
|
.controls { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
.ctl { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; }
|
||||||
|
.ctl > span { text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
|
||||||
|
.ctl input[type="number"] {
|
||||||
|
width: 110px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctl select {
|
||||||
|
padding: 5px 8px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ctl input[type="range"] { width: 110px; accent-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---- layout -------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
gap: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.layout { grid-template-columns: 1fr; }
|
||||||
|
.library { max-height: 260px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- library ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.library {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 14px 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.library h2 { margin: 0; font-size: 13px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); }
|
||||||
|
|
||||||
|
.songs {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 12px 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: background 120ms, border-color 120ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song:hover { background: var(--panel-2); }
|
||||||
|
|
||||||
|
.song.active {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--accent) 10%, var(--panel-2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
align-self: center;
|
||||||
|
box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.smeta { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
|
||||||
|
.sname { font-size: 14.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.sspecies { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.stags {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- stage --------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.stage {
|
||||||
|
padding: 20px clamp(16px, 3vw, 36px) 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #05070a;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.screen canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
image-rendering: auto;
|
||||||
|
object-fit: fill;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: rgba(5, 7, 10, 0.55);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px;
|
||||||
|
backdrop-filter: blur(1px);
|
||||||
|
animation: fadein 180ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay[hidden] { display: none; }
|
||||||
|
|
||||||
|
.overlay small { color: var(--muted); }
|
||||||
|
|
||||||
|
.overlay code { font-family: var(--mono); font-size: 12px; color: #9aa3b2; }
|
||||||
|
|
||||||
|
.overlay .spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: spin 700ms linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
.caption {
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caption b { color: var(--text); }
|
||||||
|
|
||||||
|
/* ---- transport ----------------------------------------------------------- */
|
||||||
|
|
||||||
|
.transport { display: flex; align-items: center; gap: 14px; }
|
||||||
|
|
||||||
|
.play {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0a0c11;
|
||||||
|
font-size: 17px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
transition: transform 90ms, box-shadow 90ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play:hover { box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 55%, transparent); }
|
||||||
|
.play:active { transform: scale(0.94); }
|
||||||
|
|
||||||
|
.seekbar { flex: 1; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||||
|
|
||||||
|
.track {
|
||||||
|
position: relative;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fill {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
height: 4px;
|
||||||
|
width: 0%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--accent);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tick {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 2px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seek-input {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track:hover .tick { background: var(--muted); }
|
||||||
|
|
||||||
|
.times {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- statusline ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
.statusline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.law {
|
||||||
|
font-family: var(--mono);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footnote {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
max-width: 72ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footnote code { font-family: var(--mono); font-size: 11px; color: #9aa3b2; }
|
||||||
|
|
||||||
|
/* ---- scrollbars ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
.songs::-webkit-scrollbar { width: 8px; }
|
||||||
|
.songs::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
|
||||||
|
.songs::-webkit-scrollbar-track { background: transparent; }
|
||||||
+11
-4
@@ -7,12 +7,17 @@ import { Camera } from './camera.js';
|
|||||||
import { Renderer } from './render/view.js';
|
import { Renderer } from './render/view.js';
|
||||||
|
|
||||||
export class Run {
|
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.song = song;
|
||||||
this.fps = fps;
|
this.fps = fps;
|
||||||
this.width = width;
|
this.width = width;
|
||||||
this.height = height;
|
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.camera = new Camera(song, this.world, { fps, width, height });
|
||||||
this.renderer = new Renderer(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. */
|
/** Advance without rendering; used by metrics and by the report. */
|
||||||
simulate() {
|
simulate(seconds) {
|
||||||
const n = this.song.analysis.frames;
|
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) {
|
while (this.world.frame < n - 1) {
|
||||||
this.world.step();
|
this.world.step();
|
||||||
this.camera.update(this.world.frame);
|
this.camera.update(this.world.frame);
|
||||||
|
|||||||
+54
-28
@@ -115,18 +115,23 @@ function smoothSeries(values, w) {
|
|||||||
*/
|
*/
|
||||||
export function evaluate(world, song) {
|
export function evaluate(world, song) {
|
||||||
const a = song.analysis;
|
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 one = (arr) => {
|
||||||
const max = Math.max(...arr);
|
const slice = arr.slice(0, N);
|
||||||
return max > 1e-9 ? arr.map((v) => v / max) : arr;
|
const max = Math.max(...slice);
|
||||||
|
return max > 1e-9 ? slice.map((v) => v / max) : slice;
|
||||||
};
|
};
|
||||||
const energy = one(Array.from(a.energy));
|
const energyRaw = Array.from(a.energy);
|
||||||
const flux = one(Array.from(a.flux));
|
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.
|
// Onset pressure at each frame: 0..1 combining the three band onsets.
|
||||||
const onset = new Float32Array(a.frames);
|
const onset = new Float32Array(N);
|
||||||
for (const o of a.onsets.low) onset[o.frame] = Math.max(onset[o.frame], 0.5);
|
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) onset[o.frame] = Math.max(onset[o.frame], 0.35);
|
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) onset[o.frame] = Math.max(onset[o.frame], 0.2);
|
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
|
// The sim's activity, also in [0..1]. Movement is scaled per-track so a
|
||||||
// busy ecosystem and a sparse one are compared fairly.
|
// busy ecosystem and a sparse one are compared fairly.
|
||||||
@@ -139,7 +144,7 @@ export function evaluate(world, song) {
|
|||||||
const lag = 8; // frames at 30fps
|
const lag = 8; // frames at 30fps
|
||||||
let hit = 0;
|
let hit = 0;
|
||||||
let total = 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;
|
const cue = energy[f - lag] > 0.35 || onset[f - lag] > 0;
|
||||||
if (!cue) continue;
|
if (!cue) continue;
|
||||||
total++;
|
total++;
|
||||||
@@ -147,7 +152,7 @@ export function evaluate(world, song) {
|
|||||||
}
|
}
|
||||||
const response = total > 0 ? hit / total : 0;
|
const response = total > 0 ? hit / total : 0;
|
||||||
|
|
||||||
const X = Array.from({ length: a.frames }, (_, f) => [
|
const X = Array.from({ length: N }, (_, f) => [
|
||||||
energy[f],
|
energy[f],
|
||||||
onset[f],
|
onset[f],
|
||||||
flux[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:
|
* 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
|
* 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.
|
* 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) {
|
export function fingerprint(world) {
|
||||||
const F = world.field;
|
const F = world.field;
|
||||||
const g = 16;
|
const g = 16;
|
||||||
const cell = F / g;
|
const cell = F / g;
|
||||||
const agents = new Float32Array(g * g);
|
const pop = new Float32Array(g * g);
|
||||||
const trail = new Float32Array(g * g);
|
const trail = new Float32Array(g * g);
|
||||||
for (let i = 0; i < world.count; i++) {
|
const balance = [0, 0, 0];
|
||||||
const ax = Math.min(g - 1, (world.x[i] * g) | 0);
|
for (let c = 0; c < 3; c++) {
|
||||||
const ay = Math.min(g - 1, (world.y[i] * g) | 0);
|
const p = world.pop[c];
|
||||||
agents[ay * g + ax]++;
|
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++) {
|
for (let c = 0; c < 3; c++) {
|
||||||
const t = world.trail[c];
|
const t = world.trail[c];
|
||||||
for (let gy = 0; gy < g; gy++) {
|
for (let fy = 0; fy < g; fy++) {
|
||||||
for (let gx = 0; gx < g; gx++) {
|
for (let fx = 0; fx < g; fx++) {
|
||||||
let s = 0;
|
let s = 0;
|
||||||
for (let y = gy * cell; y < (gy + 1) * cell; y += 4) {
|
const y0 = Math.round(fy * cell);
|
||||||
for (let x = gx * cell; x < (gx + 1) * cell; x += 4) s += t[y * F + x];
|
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 occupancy = 0;
|
||||||
let cluster = 0;
|
let cluster = 0;
|
||||||
let trailFill = 0;
|
let trailFill = 0;
|
||||||
let maxA = 0;
|
let maxP = 0;
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
if (agents[i] >= 3) occupancy++;
|
if (pop[i] >= 4) occupancy++;
|
||||||
maxA = Math.max(maxA, agents[i]);
|
maxP = Math.max(maxP, pop[i]);
|
||||||
if (trail[i] > 3) trailFill++;
|
if (trail[i] > 1.5) trailFill++;
|
||||||
}
|
}
|
||||||
cluster = maxA > 30 ? 1 : maxA > 12 ? 0.6 : maxA > 5 ? 0.3 : 0;
|
const total = Math.max(1, balance[0] + balance[1] + balance[2]);
|
||||||
const balance = [0, 0, 0];
|
// Cluster: compared to what a uniform full plate would hold.
|
||||||
for (let i = 0; i < world.count; i++) balance[world.cls[i]]++;
|
const uniform = total / n;
|
||||||
const total = Math.max(1, world.count);
|
cluster = uniform > 0 ? Math.min(1, maxP / (uniform * 18)) : 0;
|
||||||
return {
|
return {
|
||||||
occupancy: Number((occupancy / n).toFixed(3)),
|
occupancy: Number((occupancy / n).toFixed(3)),
|
||||||
cluster: Number(cluster.toFixed(2)),
|
cluster: Number(cluster.toFixed(2)),
|
||||||
|
|||||||
+119
-39
@@ -1,9 +1,23 @@
|
|||||||
// Compose one frame. Ground first (history), then the agents (the present),
|
// Compose one frame of the macro world.
|
||||||
// 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.
|
// 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';
|
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 {
|
export class Renderer {
|
||||||
constructor(width, height, { exposure = 2.6, grain = 2.4, vignette = 0.55 } = {}) {
|
constructor(width, height, { exposure = 2.6, grain = 2.4, vignette = 0.55 } = {}) {
|
||||||
this.w = width;
|
this.w = width;
|
||||||
@@ -14,6 +28,7 @@ export class Renderer {
|
|||||||
this.bitmap = new Bitmap(width, height);
|
this.bitmap = new Bitmap(width, height);
|
||||||
this.F = 0;
|
this.F = 0;
|
||||||
this.trailRGBA = null;
|
this.trailRGBA = null;
|
||||||
|
this.densityRGBA = null;
|
||||||
this.sharp = null;
|
this.sharp = null;
|
||||||
this.tight = null;
|
this.tight = null;
|
||||||
this.wide = null;
|
this.wide = null;
|
||||||
@@ -23,12 +38,15 @@ export class Renderer {
|
|||||||
this.F = F;
|
this.F = F;
|
||||||
const n = F * F;
|
const n = F * F;
|
||||||
this.trailRGBA = new Uint8ClampedArray(n * 4);
|
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.sharp = new Float32Array(n * 3);
|
||||||
this.tmp = new Float32Array(n * 3);
|
this.tmp = new Float32Array(n * 3);
|
||||||
this.tight = new Float32Array(n * 3);
|
this.tight = new Float32Array(n * 3);
|
||||||
this.wide = new Float32Array(n * 3);
|
this.wide = new Float32Array(n * 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The trail field, composed through the palette (the geography). */
|
||||||
trail(world, palette) {
|
trail(world, palette) {
|
||||||
const F = world.field;
|
const F = world.field;
|
||||||
if (this.F !== F) this.allocate(F);
|
if (this.F !== F) this.allocate(F);
|
||||||
@@ -56,9 +74,6 @@ export class Renderer {
|
|||||||
const out = this.trailRGBA;
|
const out = this.trailRGBA;
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
for (let c = 0; c < 3; c++) {
|
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 +
|
const v = (sharp[i * 3 + c] * 0.55 +
|
||||||
this.tight[i * 3 + c] * 0.80 +
|
this.tight[i * 3 + c] * 0.80 +
|
||||||
this.wide[i * 3 + c] * 0.04) * this.exposure;
|
this.wide[i * 3 + c] * 0.04) * this.exposure;
|
||||||
@@ -69,6 +84,100 @@ export class Renderer {
|
|||||||
return out;
|
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) {
|
render(world, song, camera, frame) {
|
||||||
const bmp = this.bitmap;
|
const bmp = this.bitmap;
|
||||||
const palette = song.palette;
|
const palette = song.palette;
|
||||||
@@ -79,10 +188,10 @@ export class Renderer {
|
|||||||
bmp.clear(palette.bg[0], palette.bg[1], palette.bg[2]);
|
bmp.clear(palette.bg[0], palette.bg[1], palette.bg[2]);
|
||||||
|
|
||||||
const trail = this.trail(world, palette);
|
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
|
// The ring of a hit — the clearest causal link in the film.
|
||||||
// audience hears a transient and sees the ground answer it.
|
|
||||||
const fps = song.analysis.fps;
|
const fps = song.analysis.fps;
|
||||||
for (const p of world.pulses) {
|
for (const p of world.pulses) {
|
||||||
const age = (frame - p.frame) / fps;
|
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);
|
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
|
this.speck(world, song, cam, palette);
|
||||||
// shot actually gets closer to a creature instead of showing the same
|
|
||||||
// dot larger. The halo is what makes a body read against its own trail.
|
|
||||||
for (let i = 0; i < world.count; i++) {
|
|
||||||
const x = world.x[i];
|
|
||||||
const y = world.y[i];
|
|
||||||
const sx = (x - cam.cx) * scale + halfW;
|
|
||||||
const sy = (y - cam.cy) * scale + halfH;
|
|
||||||
const margin = 12;
|
|
||||||
if (sx < -margin || sy < -margin || sx > this.w + margin || sy > this.h + margin) continue;
|
|
||||||
const cls = world.cls[i];
|
|
||||||
const col = palette.classes[cls];
|
|
||||||
const e = world.energy[i];
|
|
||||||
const rad = Math.max(1.0, song.genome.classes[cls].size * 0.00068 * scale);
|
|
||||||
// A velocity streak makes direction legible in a still frame and
|
|
||||||
// separates the castes: commuters become dashes, builders dots.
|
|
||||||
const vx = world.vx[i];
|
|
||||||
const vy = world.vy[i];
|
|
||||||
if (vx * vx + vy * vy > 1e-10) {
|
|
||||||
bmp.line(
|
|
||||||
sx - vx * 0.34 * scale,
|
|
||||||
sy - vy * 0.34 * scale,
|
|
||||||
sx,
|
|
||||||
sy,
|
|
||||||
col[0], col[1], col[2],
|
|
||||||
0.10 + 0.13 * Math.min(1, e)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
bmp.disc(sx, sy, rad, col[0], col[1], col[2], 0.60 + 0.30 * Math.min(1, e));
|
|
||||||
}
|
|
||||||
|
|
||||||
bmp.vignette(this.vignetteAmount, [palette.bg[0] * 0.6, palette.bg[1] * 0.6, palette.bg[2] * 0.6]);
|
bmp.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);
|
bmp.grain(this.grainAmount, frame * 2654435761 + song.seed);
|
||||||
|
|||||||
@@ -51,9 +51,11 @@ function pickPitchClasses(chroma) {
|
|||||||
/**
|
/**
|
||||||
* @param {object} summary analysis.summary
|
* @param {object} summary analysis.summary
|
||||||
* @param {number} field grid size
|
* @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}>}
|
* @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 chroma = summary.chroma || new Array(12).fill(0.5);
|
||||||
const pcs = pickPitchClasses(chroma);
|
const pcs = pickPitchClasses(chroma);
|
||||||
const balance = summary.bandBalance || [0.33, 0.33, 0.33];
|
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.
|
// Two rings so the world is not a necklace: alternate radii.
|
||||||
const ring = i % 2 === 0 ? 0.28 : 0.36;
|
const ring = i % 2 === 0 ? 0.28 : 0.36;
|
||||||
const radius = ring + 0.03 * Math.sin(i * 1.7);
|
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 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);
|
const cells = buildKernel(field, 0.5 + Math.cos(angle) * radius, 0.5 + Math.sin(angle) * radius, sigma, field);
|
||||||
|
|||||||
+602
-446
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,12 +11,16 @@ import { Rng } from '../rng.js';
|
|||||||
import { buildPalette } from './palette.js';
|
import { buildPalette } from './palette.js';
|
||||||
import { buildEvents, indexEvents } from './events.js';
|
import { buildEvents, indexEvents } from './events.js';
|
||||||
import { deriveGenome } from './species.js';
|
import { deriveGenome } from './species.js';
|
||||||
|
import { compileProgram } from './rules.js';
|
||||||
|
import { compileTribes } from './tribe.js';
|
||||||
|
|
||||||
export function buildSong(analysis, seed = 1) {
|
export function buildSong(analysis, seed = 1) {
|
||||||
const rng = new Rng(seed >>> 0).fork('metabolism');
|
const rng = new Rng(seed >>> 0).fork('metabolism');
|
||||||
const palette = buildPalette(analysis.summary, rng.fork('palette'));
|
const palette = buildPalette(analysis.summary, rng.fork('palette'));
|
||||||
const events = buildEvents(analysis, rng.fork('events'));
|
const events = buildEvents(analysis, rng.fork('events'));
|
||||||
const genome = deriveGenome(analysis, rng.fork('genome'));
|
const genome = deriveGenome(analysis, rng.fork('genome'));
|
||||||
|
const program = compileProgram(analysis.summary, genome);
|
||||||
|
const tribes = compileTribes(analysis.summary, genome, rng.fork('tribes'));
|
||||||
return {
|
return {
|
||||||
seed: seed >>> 0,
|
seed: seed >>> 0,
|
||||||
analysis,
|
analysis,
|
||||||
@@ -25,9 +29,11 @@ export function buildSong(analysis, seed = 1) {
|
|||||||
events,
|
events,
|
||||||
index: indexEvents(events, analysis.frames),
|
index: indexEvents(events, analysis.frames),
|
||||||
genome,
|
genome,
|
||||||
|
program,
|
||||||
|
tribes,
|
||||||
name: genome.name,
|
name: genome.name,
|
||||||
laws: genome.laws,
|
laws: genome.laws,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { buildPalette, buildEvents, deriveGenome };
|
export { buildPalette, buildEvents, deriveGenome, compileProgram, compileTribes };
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -20,8 +20,8 @@ const BANK = resolve(HERE, '../..', 'flow-state/test/songs');
|
|||||||
function grow(name, seed, seconds = 45) {
|
function grow(name, seed, seconds = 45) {
|
||||||
const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`)));
|
const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`)));
|
||||||
const song = buildSong(analyse(mono(wav.channels), wav.sampleRate), seed);
|
const song = buildSong(analyse(mono(wav.channels), wav.sampleRate), seed);
|
||||||
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: 48, genesis: 0 });
|
||||||
run.simulate();
|
run.simulate(45);
|
||||||
return { world: run.world, song };
|
return { world: run.world, song };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ test('fingerprint distances: songs farther than seeds', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function growSong(song, seed) {
|
function growSong(song, seed) {
|
||||||
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: 48, genesis: 0 });
|
||||||
run.simulate();
|
run.simulate(45);
|
||||||
return { world: run.world, song };
|
return { world: run.world, song };
|
||||||
}
|
}
|
||||||
@@ -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]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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)})`);
|
||||||
|
});
|
||||||
@@ -24,7 +24,7 @@ function load(name) {
|
|||||||
/** Run `seconds` of the world for song `name` with a seed. Returns world. */
|
/** Run `seconds` of the world for song `name` with a seed. Returns world. */
|
||||||
function grow(name, seed, seconds = 20) {
|
function grow(name, seed, seconds = 20) {
|
||||||
const song = load(name);
|
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++) {
|
for (let f = 0; f < Math.floor(seconds * 30); f++) {
|
||||||
run.world.step();
|
run.world.step();
|
||||||
run.camera.update(f);
|
run.camera.update(f);
|
||||||
@@ -79,3 +79,30 @@ test('three songs grow fingerprints further apart than seeds of one song', () =>
|
|||||||
const within = divergence(a, a2);
|
const within = divergence(a, a2);
|
||||||
assert.ok(cross > within * 2, `cross ${cross} !> 2×within ${within}`);
|
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');
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Prepare a song ahead of time so viewing is fast (EVOLUTION.md: "the prepare
|
||||||
|
// step"). Writes the full processed analysis + seed as a compact JSON artifact
|
||||||
|
// that the browser loads instead of re-analysing the WAV.
|
||||||
|
//
|
||||||
|
// node tools/prepare.js # drone, runner, dusk @ 12345
|
||||||
|
// node tools/prepare.js --songs runner # one song
|
||||||
|
// node tools/prepare.js --songs drone,runner --seeds 12345,777 --out out
|
||||||
|
//
|
||||||
|
// Artifacts land in <out>/prepared/<song>-<seed>.json. The WAV is still needed
|
||||||
|
// at viewing time for audio, but analysis is skipped. deserializeSong() must
|
||||||
|
// produce a world bit-identical to the live WAV path — test/prepare.test.js
|
||||||
|
// asserts that.
|
||||||
|
|
||||||
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { loadSong } from './load.js';
|
||||||
|
import { serializeSong } from '../src/song/serialize.js';
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = resolve(HERE, '..');
|
||||||
|
const BANK = resolve(ROOT, '../flow-state/test/songs');
|
||||||
|
|
||||||
|
function args(argv) {
|
||||||
|
const out = {};
|
||||||
|
for (let i = 2; i < argv.length; i += 2) out[argv[i].replace(/^--/, '')] = argv[i + 1];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opt = args(process.argv);
|
||||||
|
const names = (opt.songs || 'drone,runner,dusk').split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
const seeds = (opt.seeds || '12345').split(',').map(Number).filter((n) => Number.isFinite(n));
|
||||||
|
const outDir = resolve(ROOT, opt.out || 'out');
|
||||||
|
const dir = resolve(outDir, 'prepared');
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const name of names) {
|
||||||
|
for (const seed of seeds) {
|
||||||
|
const { song } = loadSong(resolve(BANK, `${name}.wav`), { fps: 30, seed });
|
||||||
|
const file = resolve(dir, `${name}-${seed}.json`);
|
||||||
|
const json = JSON.stringify(serializeSong(song));
|
||||||
|
writeFileSync(file, json);
|
||||||
|
const kb = (Buffer.byteLength(json) / 1024).toFixed(1);
|
||||||
|
console.log(`${file} ${kb.padStart(7)} KB ${song.name}`);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`prepared ${count} song${count === 1 ? '' : 's'} → ${dir}`);
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// Vite config for the demo page.
|
||||||
|
//
|
||||||
|
// The demo needs the WAV bank, which lives outside this package at
|
||||||
|
// ../flow-state/test/songs (the same bank the strip/report tools read). This
|
||||||
|
// plugin gives the browser a clean, versionless URI surface for it:
|
||||||
|
//
|
||||||
|
// GET /songs/catalog.json -> { fps, defaultSeed, songs: [...] } (node-side analysis)
|
||||||
|
// GET /songs/<name>.wav -> the raw WAV, decoded in-browser with the
|
||||||
|
// exact same decodeWav/analyse code the tools use
|
||||||
|
//
|
||||||
|
// In dev the files are streamed from disk; on `vite build` they are emitted
|
||||||
|
// into dist/songs so `vite preview` shows the same page.
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||||
|
import { resolve, basename } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { loadSong } from './tools/load.js';
|
||||||
|
|
||||||
|
const BANK = fileURLToPath(new URL('../flow-state/test/songs', import.meta.url));
|
||||||
|
const PREPARED = fileURLToPath(new URL('./out/prepared', import.meta.url));
|
||||||
|
|
||||||
|
// Catalog: one full analysis per song (17 tracks, ~0.2 s each). Built once per
|
||||||
|
// server process; loads in the browser are cached client-side, so this is the
|
||||||
|
// only full sweep the demo ever pays for.
|
||||||
|
let catalogPromise = null;
|
||||||
|
|
||||||
|
function getCatalog() {
|
||||||
|
if (!catalogPromise) catalogPromise = buildCatalog();
|
||||||
|
return catalogPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildCatalog() {
|
||||||
|
const files = readdirSync(BANK).filter((f) => f.endsWith('.wav')).sort();
|
||||||
|
const songs = [];
|
||||||
|
for (const f of files) {
|
||||||
|
// Yield between songs so the server answers requests while the first
|
||||||
|
// catalog builds; the page merely waits a beat.
|
||||||
|
await new Promise((r) => setImmediate(r));
|
||||||
|
try {
|
||||||
|
const { song, analysis } = loadSong(resolve(BANK, f), { fps: 30, seed: 12345 });
|
||||||
|
songs.push({
|
||||||
|
file: f,
|
||||||
|
name: basename(f, '.wav'),
|
||||||
|
duration: analysis.duration,
|
||||||
|
frames: analysis.frames,
|
||||||
|
species: song.name,
|
||||||
|
seed: song.seed,
|
||||||
|
hue: song.palette.hue,
|
||||||
|
sat: song.palette.sat,
|
||||||
|
summary: {
|
||||||
|
bpm: analysis.summary.bpm,
|
||||||
|
beatStrength: analysis.summary.beatStrength,
|
||||||
|
centroid: analysis.summary.centroid,
|
||||||
|
flatness: analysis.summary.flatness,
|
||||||
|
dynamics: analysis.summary.dynamics,
|
||||||
|
onsetDensity: analysis.summary.onsetDensity,
|
||||||
|
},
|
||||||
|
laws: song.laws.map((l) => ({ start: l.start, kind: l.kind })),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[song-bank] skipping ${f}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { fps: 30, defaultSeed: 12345, songs };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only a plain filename inside the bank may be served; never a traversal. */
|
||||||
|
function playableWav(file) {
|
||||||
|
const resolved = resolve(BANK, file);
|
||||||
|
if (basename(resolved) !== file || !resolved.startsWith(BANK)) return null;
|
||||||
|
try {
|
||||||
|
return statSync(resolved).isFile() ? resolved : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function songBankPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'metabolism-song-bank',
|
||||||
|
|
||||||
|
configureServer(server) {
|
||||||
|
// Warm the catalog in the background so the first page load is fast.
|
||||||
|
getCatalog();
|
||||||
|
server.middlewares.use('/songs', (req, res, next) => {
|
||||||
|
const pathname = decodeURIComponent((req.url || '').split('?')[0]);
|
||||||
|
if (pathname === '/catalog.json' || pathname === '') {
|
||||||
|
getCatalog().then(
|
||||||
|
(catalog) => {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader('content-type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('cache-control', 'no-cache');
|
||||||
|
res.end(JSON.stringify(catalog));
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end(`catalog failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = playableWav(pathname.replace(/^\//, ''));
|
||||||
|
if (!file) return next();
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader('content-type', 'audio/wav');
|
||||||
|
res.setHeader('cache-control', 'no-cache');
|
||||||
|
createReadStream(file).pipe(res);
|
||||||
|
});
|
||||||
|
// Prepared analyses (tools/prepare.js) make viewing skip analysis.
|
||||||
|
server.middlewares.use('/prepared', (req, res, next) => {
|
||||||
|
const name = decodeURIComponent((req.url || '').split('?')[0]).replace(/^\//, '');
|
||||||
|
const file = resolve(PREPARED, name);
|
||||||
|
if (basename(file) !== name || !file.startsWith(PREPARED)) return next();
|
||||||
|
try {
|
||||||
|
if (!statSync(file).isFile()) return next();
|
||||||
|
} catch {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader('content-type', 'application/json; charset=utf-8');
|
||||||
|
res.setHeader('cache-control', 'no-cache');
|
||||||
|
createReadStream(file).pipe(res);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateBundle() {
|
||||||
|
const catalog = await getCatalog();
|
||||||
|
for (const s of catalog.songs) {
|
||||||
|
const file = playableWav(s.file);
|
||||||
|
if (file) this.emitFile({ type: 'asset', fileName: `songs/${s.file}`, source: readFileSync(file) });
|
||||||
|
}
|
||||||
|
this.emitFile({ type: 'asset', fileName: 'songs/catalog.json', source: JSON.stringify(catalog) });
|
||||||
|
if (existsSync(PREPARED)) {
|
||||||
|
for (const f of readdirSync(PREPARED).filter((f) => f.endsWith('.json'))) {
|
||||||
|
this.emitFile({ type: 'asset', fileName: `prepared/${f}`, source: readFileSync(resolve(PREPARED, f)) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [songBankPlugin()],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user