From 13b09392216f4470fb54f8b529d934c926954cc6 Mon Sep 17 00:00:00 2001 From: Dejvino Date: Sat, 24 Oct 2026 08:23:33 +0200 Subject: [PATCH] Metabolism: song as substrate, ecology grown from the track A new top-level section building the metabolism concept: the track supplies the food field, event impulses and per-section laws; the seed picks the species (a rule set, not a parameter vector). Same song + seed => bit-identical frames; different songs => different worlds. - analysis: single FFT pass -> per-frame feature table; score (onsets folded into world geometry via voice ring + pitch-class sector), wells (food placed by the harmony), graph (band correlations -> interaction matrix), laws (per-section regimes) - sim: typed-array world, 3 castes (builder/orbit, forager/commute, spark/orbit-wide) with causality rings, persistence trails, consequence field; fully deterministic - lens: software framebuffer renderer (PNG in node, ImageData in browser) + event-reactive camera (cuts on activity change, alternating shot sizes, drift-and-push) - metrics: coupling x residual fitness (not per-frame descriptors) plus a grown-structure fingerprint; report + strip tools; 15 tests green - DESIGN.md captures the concept, invariants and build order 3 reference test signals (drone/runner/dusk) grow measurably distinct worlds while seeds of one song stay in the same biome. --- metabolism/.gitignore | 2 + metabolism/DESIGN.md | 250 +++++++ metabolism/package-lock.json | 1058 ++++++++++++++++++++++++++++++ metabolism/package.json | 16 + metabolism/src/audio/analyse.js | 410 ++++++++++++ metabolism/src/audio/fft.js | 60 ++ metabolism/src/audio/wav.js | 107 +++ metabolism/src/camera.js | 146 +++++ metabolism/src/film.js | 57 ++ metabolism/src/metrics.js | 242 +++++++ metabolism/src/render/bitmap.js | 277 ++++++++ metabolism/src/render/png.js | 87 +++ metabolism/src/render/view.js | 175 +++++ metabolism/src/rng.js | 81 +++ metabolism/src/sim/wells.js | 107 +++ metabolism/src/sim/world.js | 632 ++++++++++++++++++ metabolism/src/song/events.js | 59 ++ metabolism/src/song/palette.js | 60 ++ metabolism/src/song/song.js | 33 + metabolism/src/song/species.js | 143 ++++ metabolism/test/analysis.test.js | 109 +++ metabolism/test/metrics.test.js | 60 ++ metabolism/test/world.test.js | 81 +++ metabolism/tools/load.js | 16 + metabolism/tools/report.js | 128 ++++ metabolism/tools/strip.js | 73 +++ 26 files changed, 4469 insertions(+) create mode 100644 metabolism/.gitignore create mode 100644 metabolism/DESIGN.md create mode 100644 metabolism/package-lock.json create mode 100644 metabolism/package.json create mode 100644 metabolism/src/audio/analyse.js create mode 100644 metabolism/src/audio/fft.js create mode 100644 metabolism/src/audio/wav.js create mode 100644 metabolism/src/camera.js create mode 100644 metabolism/src/film.js create mode 100644 metabolism/src/metrics.js create mode 100644 metabolism/src/render/bitmap.js create mode 100644 metabolism/src/render/png.js create mode 100644 metabolism/src/render/view.js create mode 100644 metabolism/src/rng.js create mode 100644 metabolism/src/sim/wells.js create mode 100644 metabolism/src/sim/world.js create mode 100644 metabolism/src/song/events.js create mode 100644 metabolism/src/song/palette.js create mode 100644 metabolism/src/song/song.js create mode 100644 metabolism/src/song/species.js create mode 100644 metabolism/test/analysis.test.js create mode 100644 metabolism/test/metrics.test.js create mode 100644 metabolism/test/world.test.js create mode 100644 metabolism/tools/load.js create mode 100644 metabolism/tools/report.js create mode 100644 metabolism/tools/strip.js diff --git a/metabolism/.gitignore b/metabolism/.gitignore new file mode 100644 index 0000000..252089d --- /dev/null +++ b/metabolism/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +out/ \ No newline at end of file diff --git a/metabolism/DESIGN.md b/metabolism/DESIGN.md new file mode 100644 index 0000000..6fad8da --- /dev/null +++ b/metabolism/DESIGN.md @@ -0,0 +1,250 @@ +# metabolism — design + +A music video generator whose output is **grown**, not mixed. The track is not +a parameter set the renderer modulates; it is the *substrate* a simulation +grows on. Given the same song and the same seed, the same world — bit for bit, +frame for frame, preview and export identical. + +``` + song (PCM) + │ analyse — whole track, up front, fixed-rate + ▼ + feature table ──► score events (what spoke, when, how hard) + (per frame) ──► field food, where the track put it + ──► graph relations between voices + ──► laws per-section regime + │ seed + ▼ + species genome ─────────────── rules the agents live by + ▼ + world ◄── agents on the field, laws switch at boundaries + ▼ + camera ◄── cuts to where the world just changed + ▼ + framebuffer ──► PNG (tools) / canvas (demo) / video (later) +``` + +--- + +## The idea + +Most music visualisers **modulate** one fixed stage with the song: parameters +go up and down, and every song ends up presenting the same toy in different +clothes. That is cheap to watch and cheap to build, and it is exactly what +we are not doing. + +We want each song to produce a world that looks and behaves like *that song* +and no other — a world with its own places, its own creatures, its own +disasters. The mechanism is **metabolism over modulation**: the song supplies +the resource field, the event impulses and the per-section laws; the seed +picks the species, and the species is a *rule set*, not a parameter vector. + +Two songs therefore differ in the way two habitats differ, not in the way two +skins differ. A noisy fast track grows a swarm with jittered motion and thin, +ephemeral trails. A tonal slow track grows weavers with long memories and +heavy ground markings. Same renderer, same simulation — different songs, so +different laws, so different worlds. + +### Why rules and not parameters + +A parameter changes *how strongly* a behaviour applies. A rule changes *what +behaviour exists*. Parameters are a knob; rules are a diet. The strongest +variation-per-cost you can buy is changing which rules a creature obeys, not +tuning how much of a rule it obeys: + +- small budget → different rules → wildly different outcomes +- large population → multiply across hundreds of interacting agents for free + +The genre of a track steers the *family* of rules; the seed steers the exact +world, the way a seed steers an ecology's exact history while the biome is +determined by the land. + +--- + +## Three invariants + +Every design decision below exists to keep these alive. If an edit weakens +one of them, the edit is wrong. + +1. **Persistence** — things last. Agents live for seconds; trails last tens + of seconds. The audience can *follow* something across the film rather than + decoding a new random field every frame. +2. **Causality** — every visible event has an audible cause and a visible + one. A hit lands as a ring on the ground where the track put it. A bloom + has a cause; a migration has one. Nothing happens for no reason. +3. **Consequence** — the state at t is made of the state at t−1. The field is + depleted by eating; trails accumulate from walking. The world records its + own history instead of being re-rolled each frame. + +A fourth, transport-level invariant: **determinism**. Same song, same seed, +same frame → same pixel, in node and in the browser, at any wall-clock time. + +--- + +## The analysis: four structures from one pass + +A single FFT pass over the whole track (fixed 44.1 kHz decode, 2048-bin FFT, +30 fps) produces a per-frame feature table: band energy (low/mid/high), +spectral flux per band, centroid (log-scaled), flatness, a dominant pitch, +energy, and chronicle of onsets. From that table derive four structures: + +### Score — events +Onsets picked per band with an adaptive threshold, merged so one transient +fires once (not three times for three bands). Each event carries a voice +(low/mid/high), a strength, and a time. + +**Where an event lands is musical.** The voice picks the ring (kick near the +centre, body in the middle, hats at the rim), the dominant pitch class picks +the angle (12 semitones → 12 sectors), strength picks how much food and how +wide. The track's rhythm is literally folded into the geometry of the food. + +### Field — food +Events splat resource onto a 128×128 grid. On top of that, **wells** — slow, +permanent fertile places — are placed by the track's own harmony: one well per +strong pitch class, on two rings, with richness proportional to the chroma +and the band balance. The chord is the map. Wells recharge slowly to a +ceiling, so a place has a carrying capacity, not an infinite tap. + +### Graph — relations +The 3×3 correlation matrix of the three band envelopes becomes the species' +*interaction matrix*: bands that move together attract; a band that lives its +own life repels the others. For a bass line that locks with the kick, the +builders court the foragers; for a high band that does its own thing, the +sparks are ostracised. Society is a property of the mix. + +### Law — regime per section +Section boundaries are found where the 4 Hz feature cloud changes its mind +(cheap self-similarity, not an FFT of the FFT), classified into +intro / build / drop / breakdown / sustain / outro. Each section becomes a +*law*: a multiplier set applied to the standing rules at the boundary. A drop +speeds everything up and opens the floodgates for spawning; a breakdown slows +the world and doubles deposit. A section boundary is the only place the rules +change — which is what makes the boundary audible in the picture. + +--- + +## The species + +The genome maps the summary statistics to *kinds of behaviour*: + +| song stats | breeding rule | consequence | +|---|---|---| +| flatness ↑ | jitter ↑ | noisy track = jittery creatures | +| flatness ↓ | trail half-life ↑, deposit ↑ | tonal track = heavy ground markings | +| beat strength | alignment and pace | a pulse makes them march | +| band correlations | interaction matrix | correlated bands attract | +| onset density | separation, split cost | dense track = thronged dish but crowded apart | +| spectral tilt | hue | bright mixes warm, dark mixes cold | + +There are three castes, derived from the three voices: + +- **builder** — slow, heavy, deposits a lot, keeps near its well. It is the + ink-dropper of the world: it paints the structure the synth earned. +- **forager** — commutes between wells and draws the roads. Direction lives + in its streaks. +- **spark** — small, fast, orbits wide, deposits almost nothing. The counter- + melody: it makes the film read as inhabited rather than decorated. + +A species name like "fast tonal weaver" falls out of the genome, so the report +can talk about the world and not the track. + +## The world + +A fixed-step simulation (~2600 typed, garbage-free agents; spatial hash for +neighbourhood; every RNG stream seeded). Per frame: + +1. inject this frame's events — food is placed, and strong events also + *spawn* life and leave a **ring** on the ground (the clearest causality in + the film); +2. decay field and trails; recharge wells (rate set by the section's law); +3. each agent: smell the food gradient, obey its caste's relation to its well, + feel its neighbours (separate / align / cohere / inter-caste attraction), + wander with jitter, hit soft walls, eat, move, lay ink, split on + accumulation, die on starvation; +4. compact. + +There is no player, no realtime budget, no entity objects — only typed arrays, +a step function, and a history we can regress against the song. + +## The lens + +### Camera +An observer with a motive, not a rig on rails: + +- **shots cut when the world changes**, never on a timer that ignores the + world. Cuts also fire on strong onsets during drop/build, so the edit hits + like the track hits; +- the **subject is recent activity** (decayed 32×32 cell grid), weighted away + from the previous subject — novelty without drift; +- **shot sizes alternate** in a deliberate rhythm — a wide establishing, a + medium, a close push — and zoom follows coverage: a broad field gets a wide + lens, a crowded knot gets a close-up; +- while held, the camera drifts and pushes in — a shot is an approach, not a + still. + +### Renderer +A tiny software framebuffer (additive blend, discs, rings, lines, bilinear +world blit) so the same pixels render in node (PNG, filmstrips) and in the +browser (ImageData). Layers: near-black key-tinted background → trail+field +composed at field resolution with a tight blur (line) and a faint wide blur +(glow) → event rings → agents as bodies with velocity streaks → vignette → +deterministic grain. + +The field is drawn faintly *under* the trails, so the audience can see *why* +the creatures are where they are. + +## Metrics: system, not pixels + +Per-frame descriptor distance was rejected long ago (it cannot tell a dungeon +from a car and rewards noise). Instead we score the whole episode against its +own song: + +- **coupling** — how much the ecology answers the track: cue response (does + movement edge up just after onsets?) plus long-form correlation (smoothed + movement vs smoothed energy); +- **residual** — 1 − R² of the sim's activity (motion *and* the population + curve) regressed on all song features: the internal dynamics the song does + not explain; +- **fitness = coupling × residual** — the best world keeps time *and* has a + life of its own. A pure visualiser scores ~0 (no residual). A pure random + walk scores ~0 (no coupling). The middle is the point. + +A separate **fingerprint** (occupancy, clustering, trail fill, class mix) +lets us measure whether different songs grow different worlds, independent of +aesthetic judgement — and whether different seeds of one song stay in the same +biome. + +## Determinism & preview === export + +- one decode rate, one analysis pass, one feature table; +- no `Math.random`, no wall clock, no `requestAnimationFrame` in the sim; +- the world steps in typed arrays; ordering is canonical; +- the camera and renderer are pure functions of `(song, seed, frame)`; +- tools and browser share the same `Run`. + +What will be previewed is exactly what will be exported. + +--- + +## Build order & status + +- **v0 — substrate** ✔ one song, one species, alive on the dish. + *Built:* analysis → structures → world → renderer → filmstrips, metrics, + 15 tests green. +- **v1 — variation** ✔ species sampled per seed; three reference songs + (drone / runner / dusk) grow measurably distinct worlds (fingerprint + divergence ≫ seed divergence; fitness separates them). +- **v2 — story** laws switch at section boundaries (done in the world); + event-reactive camera cutting (done in camera.js); filmstrip + report tools + wired. +- **v3 — search** rule-space search with the coupling × residual fitness — + only after the substrate exists, and only as a sampling tool, never as a + user-facing knob. + +## Current state, honestly + +The world is alive and the songs diverge on the metrics, but it is not yet a +*film*: the camera and the soundtrack are not muxed, the renderer is a proof +at 480p, and the three reference tracks are synthetic test signals rather than +music. The next milestone is three videos — one per song — that a person can +sit through and tell apart without the report. \ No newline at end of file diff --git a/metabolism/package-lock.json b/metabolism/package-lock.json new file mode 100644 index 0000000..dc53a1c --- /dev/null +++ b/metabolism/package-lock.json @@ -0,0 +1,1058 @@ +{ + "name": "metabolism", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "metabolism", + "version": "0.1.0", + "devDependencies": { + "vite": "^7.2.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.65.0.tgz", + "integrity": "sha512-t2AnVba25baFdq7gczzS13JyVcdubLVbFX1X04mxrggDrTQPUXNv3XM0k9H1hr5tqvg7AVvtQ720QHAj0iYqhg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.65.0.tgz", + "integrity": "sha512-dNONUaEwPGhl/nuO2KV8VABWlcjYyU6KtZGN8dQv5OS5JU7JSCxPijVNKXbRJd0iuTzTiJ4TjPt/4bbvDRNiMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.65.0.tgz", + "integrity": "sha512-zWQqUH+9zavr2Bxuzfpuv2SIF3X31cr58TvSQC9o1GhpOvkqRwr60GkZdQcudyT/BEgdpdSafjgd2I13MbTL7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.65.0.tgz", + "integrity": "sha512-RQYreBFWtO4//hIuOaZkYUnF/lPU4lz35dveDimDshWCJvVisVtJ/shmT9bkXK8wFJY+FJF89X6F3Cq1qI3LCw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.65.0.tgz", + "integrity": "sha512-A0NfMj6Cuh/9BE5k3awJKv6HoxFVXIcLAb6SZqAMoxUHPA1ZNNK4RsGuUPD/vL7nEAm9LG+ZzXWWTrnC0XtkpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.65.0.tgz", + "integrity": "sha512-BI6/H7q3aP0I7S7GOrjyxTjgEJlSnovXE38+bdxBpuKN6z+d//uF+T+VQ3Q7ltJL/71p17ufartARX9Ja5gOGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.65.0.tgz", + "integrity": "sha512-W1vUprf09RWaVWnsFP1vlUkEWJzTZlpObOEAYZXt9Fq0UVAOYEkeGr7wBX7McKqqIQskXdBnpiOMgwPujordgA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.65.0.tgz", + "integrity": "sha512-s8QTcZWgrl9haFzEGIqyE5BWyo89pFDGLMBFZ752WYxk8DJwNU/O1X6pZgPw829d9/sm6CCHyrMP3aurt76p1Q==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.65.0.tgz", + "integrity": "sha512-ke1f6dLRzkqveyjjF2fkEwQYZvxHtNNwjegY8nJrwcO0Bdqr8CzFO/pKE0Bns13cx0qz2Ba2Y0Hs3dSwMdnHxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.65.0.tgz", + "integrity": "sha512-926SMeUcy4y18Rz/w2aJiUKOnyAnm6czg72bXkQEaeemsll+Wr0hnsjb2BGRpHvWgwAMYy5ACJCotbvfLCfj0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.65.0.tgz", + "integrity": "sha512-6T7JNRP1hc4hqF2dUsEoVLOt9K8tmODoCLldtQRYnrddGGQY8Gqe2pM/qMMUo7EMowpP7oa2N95k0/8sYK/ipw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.65.0.tgz", + "integrity": "sha512-SImGFl1Je5kpyAgOCPONVT7h6ASV8mAUwGRjY7eZvVjPg//PfHOXgI+P241RF/+HqsA4PP3qD527ysWFKNQyhQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.65.0.tgz", + "integrity": "sha512-XeDTbmPF1Yu5asfXGPrMkWoxTDhgryQbmw5Q1+R582O51VNCk23J6f2pDrsAp5YkneBVBwtExx6ZGOb9D2aQ6A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.65.0.tgz", + "integrity": "sha512-VS55JUI/1nsljmV/jfqNkLZXFw8uMO8nNlfoHmSevNf6fO3neuhyDvx9dv3c/Ytt8r/431SL8Qax1dpYvOMZTg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.65.0.tgz", + "integrity": "sha512-Vblw84TbX7RS4la1Y4+yQ7aore/oSyaNtFbppuBr5Vs/4LfBeXKq9dIfoExw6zOvRERqd2rRAjnZRaMhH/0x5w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.65.0.tgz", + "integrity": "sha512-7+gHjtONvKgwR9InTKFaLq53f6YzBJkgeMRBENdWTsQr+spahGqQm8HF5aXpq8i4JaRrOqYyv6Zn1WltDTxaZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.65.0.tgz", + "integrity": "sha512-KvNDC0J6s4vVHhtJ6KrDXU0Er0au/hmRIBq/+1maCV8TseGupeKizq0bWcrbp2HE1T8xoknvN0Q/KZ/YF0qX3w==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.65.0.tgz", + "integrity": "sha512-GExdD15WPvu/x/drELjVam7vX8EsPgvTMOa/iJL/p/4m/oOqzbb7JXZg8fXR4hlXtg/sF2zyTZ3bR0AEtEMlhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.65.0.tgz", + "integrity": "sha512-8sT9PemIZfIMED4Vd4KGoOXVDj5CMSWNcGdbheRUx9TYS03yOWdT2Q0lXPyEyNBB9YTilwewIQKNIBk6Nxi0Kg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.65.0.tgz", + "integrity": "sha512-pO6Ehi0jo5yE0R6PltKhJhv9mlIiyzBJk5N5NUF2OF6xtVKI+1bdXxj/rb0jGXy3hck1ELVT0QPY3tzkAm360w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.65.0.tgz", + "integrity": "sha512-vo7N3/+cjjPDYr8vtxC+qSNgWa9sDvJ7g8N8R5SWqFY4WiNH1ULcAU70SKVyHeiIZOYYwNP9pTipmhN5sl1NPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.65.0.tgz", + "integrity": "sha512-ArwvgFAbjKcDM7qqotZ4OVYf+UMT9xH2YzkCzK4+AuZSDihMlz1Ekoe4INwBVXzn2mnJzuIYOcSx07iTBijGRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.65.0.tgz", + "integrity": "sha512-QGMmK4/cxKE/36FoLMSHQBCyymv/eFTnsCvvsBF0rgmOvVbvX78VWZRerL3dHa77f4pM6Xcc2iGDhJ10w/svKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.65.0.tgz", + "integrity": "sha512-XfKYFWHIPs4HPODIMsGF1IKlS3H1e3WokUZIgpmn4+uNnZBlnRDDHEylNyazbQXwhMKZre68OjtRYbKqb5+j8w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.65.0.tgz", + "integrity": "sha512-wPWi++K4Bs1VR4XihOhfGi4IGX6Cub7FlerTf8X2v+2BtDHddgSqWCXwGD+h2yAe4/niIhNDwAltioOC+wEogQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.65.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.65.0.tgz", + "integrity": "sha512-QVCvQUTGDN6JRwi3jVGCY3H4aTHvi9rX0LyoBMkGUvdh/Z6O51CzcLz85BWZaTCDGhl6ySGqYZs+xI1jmPqGcw==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.65.0", + "@rollup/rollup-android-arm64": "4.65.0", + "@rollup/rollup-darwin-arm64": "4.65.0", + "@rollup/rollup-darwin-x64": "4.65.0", + "@rollup/rollup-freebsd-arm64": "4.65.0", + "@rollup/rollup-freebsd-x64": "4.65.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.65.0", + "@rollup/rollup-linux-arm-musleabihf": "4.65.0", + "@rollup/rollup-linux-arm64-gnu": "4.65.0", + "@rollup/rollup-linux-arm64-musl": "4.65.0", + "@rollup/rollup-linux-loong64-gnu": "4.65.0", + "@rollup/rollup-linux-loong64-musl": "4.65.0", + "@rollup/rollup-linux-ppc64-gnu": "4.65.0", + "@rollup/rollup-linux-ppc64-musl": "4.65.0", + "@rollup/rollup-linux-riscv64-gnu": "4.65.0", + "@rollup/rollup-linux-riscv64-musl": "4.65.0", + "@rollup/rollup-linux-s390x-gnu": "4.65.0", + "@rollup/rollup-linux-x64-gnu": "4.65.0", + "@rollup/rollup-linux-x64-musl": "4.65.0", + "@rollup/rollup-openbsd-x64": "4.65.0", + "@rollup/rollup-openharmony-arm64": "4.65.0", + "@rollup/rollup-win32-arm64-msvc": "4.65.0", + "@rollup/rollup-win32-ia32-msvc": "4.65.0", + "@rollup/rollup-win32-x64-gnu": "4.65.0", + "@rollup/rollup-win32-x64-msvc": "4.65.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/metabolism/package.json b/metabolism/package.json new file mode 100644 index 0000000..823b375 --- /dev/null +++ b/metabolism/package.json @@ -0,0 +1,16 @@ +{ + "name": "metabolism", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Songs as substrate: agent ecologies whose laws are derived from the track, rendered deterministically.", + "scripts": { + "dev": "vite", + "test": "node --test test/*.test.js", + "strip": "node tools/strip.js", + "report": "node tools/report.js" + }, + "devDependencies": { + "vite": "^7.2.2" + } +} diff --git a/metabolism/src/audio/analyse.js b/metabolism/src/audio/analyse.js new file mode 100644 index 0000000..9de6923 --- /dev/null +++ b/metabolism/src/audio/analyse.js @@ -0,0 +1,410 @@ +// PCM -> per-frame feature table. Whole track, before the first frame renders. +// +// Nothing here reacts in realtime. The table is the song's score as numbers: +// band energy, spectral flux, centroid, flatness, a dominant pitch, and the +// places where the track changes. Everything downstream reads this table, so +// preview and export cannot diverge. + +import { Fft, hann } from './fft.js'; + +export const DEFAULT_FPS = 30; +const BANDS = [ + [30, 160], // low — weight, pulse, foundation + [160, 2000], // mid — body, voice, harmony + [2000, 11000], // high — air, noise, detail +]; + +const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x); + +function percentile(values, p) { + const a = Float32Array.from(values); + a.sort(); + const i = Math.min(a.length - 1, Math.max(0, Math.round((a.length - 1) * p))); + return a[i]; +} + +function normalise(series, loP = 0.03, hiP = 0.97) { + const lo = percentile(series, loP); + const hi = percentile(series, hiP); + const k = hi - lo > 1e-9 ? 1 / (hi - lo) : 0; + const out = new Float32Array(series.length); + for (let i = 0; i < series.length; i++) out[i] = clamp01((series[i] - lo) * k); + return out; +} + +/** Local-maximum picking with an adaptive threshold. Returns discrete events. */ +export function pickOnsets(flux, fps, { minGap = 0.07, threshold = 1.35, window = 0.5 } = {}) { + const n = flux.length; + const w = Math.max(1, Math.round(window * fps)); + const gap = Math.max(1, Math.round(minGap * fps)); + const events = []; + let sum = 0; + for (let i = 0; i < n; i++) { + sum += flux[i]; + if (i - w >= 0) sum -= flux[i - w]; + const mean = sum / Math.min(i + 1, w); + if (i < 2 || i >= n - 1) continue; + if (flux[i] <= flux[i - 1] || flux[i] < flux[i + 1]) continue; + const floor = mean * threshold + 0.004; + if (flux[i] < floor) continue; + if (events.length && i - events[events.length - 1].frame < gap) { + if (flux[i] > events[events.length - 1].strength) { + events.pop(); + } else { + continue; + } + } + events.push({ frame: i, t: i / fps, strength: clamp01((flux[i] - floor) / Math.max(1e-6, 1 - floor)) }); + } + return events; +} + +/** Tempo from the onset envelope's autocorrelation. */ +function estimateTempo(onset, fps) { + const minLag = Math.round((fps * 60) / 200); + const maxLag = Math.round((fps * 60) / 50); + let best = 0; + let bestLag = 0; + let total = 0; + for (let i = 0; i < onset.length; i++) total += onset[i] * onset[i]; + for (let lag = minLag; lag <= maxLag; lag++) { + let s = 0; + for (let i = lag; i < onset.length; i++) s += onset[i] * onset[i - lag]; + s /= onset.length - lag; + if (s > best) { + best = s; + bestLag = lag; + } + } + if (!bestLag || total <= 1e-9) return { bpm: 0, beatStrength: 0 }; + // Normalised autocorrelation: a steady 4/4 lands well above 0.3, a drone + // near 0.1. The sim uses strength, not bpm, to decide whether to march. + return { bpm: (60 * fps) / bestLag, beatStrength: clamp01(best / (total / onset.length)) }; +} + +/** + * Section boundaries: where the 4 Hz feature cloud changes its mind. + * A boundary is a peak in the distance between the mean of the eight buckets + * before it and the eight after it — cheap self-similarity, no FFT of the FFT. + */ +function findSections(frames4, fps4, duration) { + const n = frames4.length; + if (n < 8) return [{ start: 0, end: duration, index: 0 }]; + const half = 8; + const novelty = new Float32Array(n); + for (let i = half; i < n - half; i++) { + let d = 0; + for (let k = 0; k < 6; k++) { + let a = 0; + let b = 0; + for (let j = 1; j <= half; j++) { + a += frames4[i - j][k]; + b += frames4[i + j][k]; + } + d += ((a - b) / half) ** 2; + } + novelty[i] = Math.sqrt(d); + } + const peak = Math.max(...novelty); + if (peak <= 1e-8) return [{ start: 0, end: duration, index: 0 }]; + for (let i = 0; i < n; i++) novelty[i] /= peak; + + const minGap = Math.round(10 * fps4); + const accepted = []; + const guard = Math.round(4 * fps4); + for (let i = guard; i < n - guard; i++) { + if (novelty[i] < 0.45) continue; + if (novelty[i] < novelty[i - 1] || novelty[i] < novelty[i + 1]) continue; + if (accepted.length && i - accepted[accepted.length - 1] < minGap) { + if (novelty[i] > novelty[accepted[accepted.length - 1]]) accepted.pop(); + else continue; + } + accepted.push(i); + if (accepted.length >= 6) break; + } + let cuts = [0, ...accepted.map((i) => i / fps4), duration]; + // Absorb sections shorter than four seconds: a boundary that survives for + // two seconds is a swell, not an era. The sim cannot change laws that fast. + cuts = cuts.filter((c, i) => i === 0 || i === cuts.length - 1 || c - cuts[i - 1] >= 4); + const sections = []; + for (let i = 0; i < cuts.length - 1; i++) { + if (cuts[i + 1] - cuts[i] < 1.5) continue; + sections.push({ start: cuts[i], end: cuts[i + 1], index: sections.length }); + } + if (!sections.length) return [{ start: 0, end: duration, index: 0 }]; + return sections; +} + +function classifySections(sections, energyAt) { + const es = sections.map((s) => energyAt(s)); + const sorted = [...es].sort((a, b) => a - b); + const p20 = sorted[Math.floor(sorted.length * 0.2)]; + const p50 = sorted[Math.floor(sorted.length * 0.5)]; + const p80 = sorted[Math.floor(sorted.length * 0.8)]; + const kinds = sections.map((s, i) => { + const e = es[i]; + const prev = es[i - 1] ?? e; + if (i === 0 && e <= p50) return 'intro'; + if (i === sections.length - 1 && e <= p50) return 'outro'; + if (e >= p80) return 'drop'; + if (e <= p20) return 'breakdown'; + if (e > prev + 0.04) return 'build'; + return 'sustain'; + }); + return sections.map((s, i) => ({ + ...s, + kind: kinds[i], + energy: es[i], + centroid: s.centroid, + flatness: s.flatness, + })); +} + +/** + * @param {Float32Array} samples mono + * @param {number} sampleRate + * @param {{fps?:number, fftSize?:number}} options + */ +export function analyse(samples, sampleRate, { fps = DEFAULT_FPS, fftSize = 2048 } = {}) { + const duration = samples.length / sampleRate; + const frames = Math.max(1, Math.floor(duration * fps)); + const hop = sampleRate / fps; + const fft = new Fft(fftSize); + const win = hann(fftSize); + const re = new Float32Array(fftSize); + const im = new Float32Array(fftSize); + const mag = new Float32Array(fftSize / 2); + const prevMag = new Float32Array(fftSize / 2); + + const nyquist = sampleRate / 2; + const binHz = nyquist / (fftSize / 2); + const bandBins = BANDS.map(([lo, hi]) => [ + Math.max(1, Math.floor(lo / binHz)), + Math.min(fftSize / 2 - 1, Math.ceil(hi / binHz)), + ]); + + const energyRaw = new Float32Array(frames); + const bandsRaw = [new Float32Array(frames), new Float32Array(frames), new Float32Array(frames)]; + const fluxRaw = new Float32Array(frames); + const bandFluxRaw = [new Float32Array(frames), new Float32Array(frames), new Float32Array(frames)]; + const centroidRaw = new Float32Array(frames); + const flatnessRaw = new Float32Array(frames); + const dominantRaw = new Float32Array(frames); + const chroma = new Float32Array(12); + + for (let f = 0; f < frames; f++) { + const start = Math.min(samples.length - fftSize, Math.floor(f * hop)); + let rms = 0; + for (let i = 0; i < fftSize; i++) { + const s = start + i >= 0 ? samples[start + i] || 0 : 0; + re[i] = s * win[i]; + im[i] = 0; + rms += s * s; + } + energyRaw[f] = Math.sqrt(rms / fftSize); + fft.forward(re, im); + + let sumP = 0; + let sumLog = 0; + let weighted = 0; + let peakBin = 1; + let peakMag = 0; + let flux = 0; + for (let b = 1; b < fftSize / 2; b++) { + const m = Math.sqrt(re[b] * re[b] + im[b] * im[b]) / (fftSize / 2); + mag[b] = m; + const p = m * m + 1e-12; + sumP += p; + sumLog += Math.log(p); + weighted += b * binHz * p; + if (m > peakMag) { + peakMag = m; + peakBin = b; + } + const hz = b * binHz; + if (hz > 45 && hz < 2400) { + const semis = Math.round(12 * Math.log2(hz / 440)); + const pc = ((semis % 12) + 12 + 9) % 12; + chroma[pc] += m * m; + } + flux += Math.max(0, m - prevMag[b]); + } + const count = fftSize / 2 - 1; + // Log-frequency centroid: 50 Hz -> 0, Nyquist -> 1. A linear Hz centroid + // sits near 0.02 for every real track and cannot tell dark from bright. + const meanHz = weighted / Math.max(sumP, 1e-12); + centroidRaw[f] = clamp01(Math.log2(Math.max(50, meanHz) / 50) / Math.log2(nyquist / 50)); + flatnessRaw[f] = sumP > 1e-8 ? clamp01((Math.exp(sumLog / count) / (sumP / count)) * 20) : 0; + fluxRaw[f] = Math.min(1, flux * 4); + dominantRaw[f] = peakBin * binHz; + + for (let k = 0; k < 3; k++) { + const [lo, hi] = bandBins[k]; + let e = 0; + let fl = 0; + for (let b = lo; b <= hi; b++) { + const m = mag[b]; + e += m * m; + fl += Math.max(0, m - prevMag[b]); + } + bandsRaw[k][f] = Math.sqrt(e / (hi - lo + 1)); + bandFluxRaw[k][f] = Math.min(1, fl * 4); + } + prevMag.set(mag); + } + + const energy = normalise(energyRaw); + const low = normalise(bandsRaw[0]); + const mid = normalise(bandsRaw[1]); + const high = normalise(bandsRaw[2]); + const flux = normalise(fluxRaw); + const bandFlux = bandFluxRaw.map((s) => normalise(s)); + + const onsets = bandFlux.map((s, k) => pickOnsets(s, fps, { threshold: k === 2 ? 1.5 : 1.3 })); + const combined = new Float32Array(frames); + for (let i = 0; i < frames; i++) combined[i] = Math.max(bandFlux[0][i], bandFlux[1][i] * 0.8, bandFlux[2][i] * 0.5); + const allOnsets = mergeOnsets(onsets, fps); + const tempo = estimateTempo(combined, fps); + + // 4 Hz section view. + const fps4 = Math.min(fps, 4); + const per = fps / fps4; + const frames4 = []; + for (let i = 0; i * per < frames; i++) { + const a = Math.floor(i * per); + const b = Math.min(frames, Math.floor((i + 1) * per)); + const row = [0, 0, 0, 0, 0, 0]; + for (let j = a; j < b; j++) { + row[0] += energy[j]; + row[1] += low[j]; + row[2] += mid[j]; + row[3] += high[j]; + row[4] += centroidRaw[j]; + row[5] += flux[j]; + } + const k = 1 / Math.max(1, b - a); + frames4.push(row.map((v) => v * k)); + } + const rawSections = findSections(frames4, fps4, duration); + const sections = classifySections(rawSections, (s) => { + const a = Math.floor(s.start * fps); + const b = Math.max(a + 1, Math.floor(s.end * fps)); + let sum = 0; + for (let i = a; i < b; i++) sum += energy[i]; + return sum / (b - a); + }).map((s) => { + const a = Math.floor(s.start * fps); + const b = Math.max(a + 1, Math.floor(s.end * fps)); + let c = 0; + let fl = 0; + for (let i = a; i < b; i++) { + c += centroidRaw[i]; + fl += flatnessRaw[i]; + } + return { ...s, centroid: c / (b - a), flatness: fl / (b - a) }; + }); + + const bandCorrelation = correlate3([low, mid, high]); + const dynamicRange = Math.max(0, percentile(energyRaw, 0.96) - percentile(energyRaw, 0.1)) / + Math.max(1e-9, percentile(energyRaw, 0.96)); + let key = 0; + let keyEnergy = 0; + const chromaPeak = Math.max(...chroma); + for (let i = 0; i < 12; i++) { + if (chroma[i] > keyEnergy) { + keyEnergy = chroma[i]; + key = i; + } + } + const chromaNorm = Array.from(chroma, (v) => (chromaPeak > 1e-9 ? v / chromaPeak : 0)); + + const summary = { + duration, + bpm: tempo.bpm, + beatStrength: tempo.beatStrength, + centroid: mean(centroidRaw), + flatness: mean(flatnessRaw), + dynamics: clamp01(dynamicRange), + loudness: mean(energyRaw), + onsetDensity: allOnsets.length / Math.max(1, duration), + bandBalance: [mean(low), mean(mid), mean(high)], + bandCorrelation, + chroma: chromaNorm, + key, + sections: sections.length, + kinds: sections.map((s) => s.kind), + }; + + return { + fps, + frames, + duration, + sampleRate, + bands: { low, mid, high }, + bandFlux, + energy, + flux, + centroid: centroidRaw, + flatness: flatnessRaw, + dominantHz: dominantRaw, + onsets: { low: onsets[0], mid: onsets[1], high: onsets[2], all: allOnsets }, + sections, + summary, + }; +} + +function mean(a) { + let s = 0; + for (let i = 0; i < a.length; i++) s += a[i]; + return s / Math.max(1, a.length); +} + +/** + * One event per moment. Bands fire together on a transient; three simultaneous + * events would triple the food at the same instant. Keep the strongest, at + * least 120 ms apart, and carry the voice so the world knows what spoke. + */ +function mergeOnsets(bandOnsets, fps, minGap = 0.12) { + const all = []; + for (let voice = 0; voice < bandOnsets.length; voice++) { + for (const o of bandOnsets[voice]) all.push({ ...o, voice }); + } + all.sort((a, b) => a.frame - b.frame || b.strength - a.strength); + const gap = Math.max(1, Math.round(minGap * fps)); + const out = []; + for (const e of all) { + const last = out[out.length - 1]; + if (last && e.frame - last.frame < gap) { + if (e.strength > last.strength) out[out.length - 1] = e; + continue; + } + out.push(e); + } + return out; +} + +function correlate3(series) { + const out = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; + for (let i = 0; i < 3; i++) { + for (let j = i + 1; j < 3; j++) { + const a = series[i]; + const b = series[j]; + const ma = mean(a); + const mb = mean(b); + let num = 0; + let da = 0; + let db = 0; + for (let k = 0; k < a.length; k++) { + const x = a[k] - ma; + const y = b[k] - mb; + num += x * y; + da += x * x; + db += y * y; + } + const r = num / Math.sqrt(Math.max(1e-12, da * db)); + out[i][j] = r; + out[j][i] = r; + } + } + return out; +} diff --git a/metabolism/src/audio/fft.js b/metabolism/src/audio/fft.js new file mode 100644 index 0000000..2ac6c72 --- /dev/null +++ b/metabolism/src/audio/fft.js @@ -0,0 +1,60 @@ +// In-place iterative radix-2 FFT. Small on purpose: the analysis needs one +// magnitude spectrum per video frame and nothing else. + +export class Fft { + constructor(size) { + if ((size & (size - 1)) !== 0) throw new Error('FFT size must be a power of two'); + this.size = size; + this.levels = Math.log2(size) | 0; + this.cos = new Float32Array(size / 2); + this.sin = new Float32Array(size / 2); + for (let i = 0; i < size / 2; i++) { + this.cos[i] = Math.cos((2 * Math.PI * i) / size); + this.sin[i] = Math.sin((2 * Math.PI * i) / size); + } + this.rev = new Uint32Array(size); + for (let i = 0; i < size; i++) { + let r = 0; + for (let b = 0; b < this.levels; b++) r |= ((i >>> b) & 1) << (this.levels - 1 - b); + this.rev[i] = r; + } + } + + /** Forward transform in place. `im` is zeroed on entry by the caller. */ + forward(re, im) { + const n = this.size; + const rev = this.rev; + for (let i = 0; i < n; i++) { + const j = rev[i]; + if (j > i) { + let t = re[i]; re[i] = re[j]; re[j] = t; + t = im[i]; im[i] = im[j]; im[j] = t; + } + } + for (let len = 2; len <= n; len <<= 1) { + const half = len >> 1; + const step = n / len; + for (let i = 0; i < n; i += len) { + for (let j = 0; j < half; j++) { + const k = j * step; + const c = this.cos[k]; + const s = this.sin[k]; + const a = i + j; + const b = a + half; + const xr = re[b] * c + im[b] * s; + const xi = im[b] * c - re[b] * s; + re[b] = re[a] - xr; + im[b] = im[a] - xi; + re[a] += xr; + im[a] += xi; + } + } + } + } +} + +export function hann(size) { + const w = new Float32Array(size); + for (let i = 0; i < size; i++) w[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (size - 1)); + return w; +} diff --git a/metabolism/src/audio/wav.js b/metabolism/src/audio/wav.js new file mode 100644 index 0000000..33189a3 --- /dev/null +++ b/metabolism/src/audio/wav.js @@ -0,0 +1,107 @@ +// Exact WAV decode: 8/16/24/32-bit PCM and 32-bit float, any rate. +// Same samples in the browser and in node, so a strip rendered by a tool and +// a frame rendered by the demo agree. + +const ascii = (bytes, o, n) => { + let s = ''; + for (let i = 0; i < n; i++) s += String.fromCharCode(bytes[o + i]); + return s; +}; + +function readChunk(dv, bytes, o) { + return { + id: ascii(bytes, o, 4), + size: dv.getUint32(o + 4, true), + start: o + 8, + }; +} + +/** + * @param {ArrayBuffer|Uint8Array} input + * @returns {{sampleRate:number, channels:Float32Array[], bitDepth:number, format:string} | null} + */ +export function decodeWav(input) { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (bytes.length < 44 || ascii(bytes, 0, 4) !== 'RIFF' || ascii(bytes, 8, 4) !== 'WAVE') return null; + + let fmt = null; + let data = null; + let o = 12; + while (o + 8 <= bytes.length) { + const c = readChunk(dv, bytes, o); + if (c.id === 'fmt ') { + fmt = { + format: dv.getUint16(c.start, true), + channels: dv.getUint16(c.start + 2, true), + sampleRate: dv.getUint32(c.start + 4, true), + bits: dv.getUint16(c.start + 14, true), + }; + } else if (c.id === 'data') { + data = { start: c.start, size: Math.min(c.size, bytes.length - c.start) }; + break; + } + o = c.start + c.size + (c.size & 1); + } + if (!fmt || !data) return null; + + const { format, channels, sampleRate, bits } = fmt; + const framed = Math.floor(data.size / ((bits / 8) * channels)); + const out = Array.from({ length: channels }, () => new Float32Array(framed)); + const full = 1 << (bits - 1); + + if (format === 1 && bits === 16) { + for (let i = 0; i < framed; i++) { + for (let c = 0; c < channels; c++) { + out[c][i] = dv.getInt16(data.start + (i * channels + c) * 2, true) / 32768; + } + } + } else if (format === 1 && bits === 24) { + for (let i = 0; i < framed; i++) { + for (let c = 0; c < channels; c++) { + const p = data.start + (i * channels + c) * 3; + let v = bytes[p] | (bytes[p + 1] << 8) | (bytes[p + 2] << 16); + if (v & 0x800000) v |= -0x1000000; + out[c][i] = v / 8388608; + } + } + } else if (format === 1 && bits === 32) { + for (let i = 0; i < framed; i++) { + for (let c = 0; c < channels; c++) { + out[c][i] = dv.getInt32(data.start + (i * channels + c) * 4, true) / 2147483648; + } + } + } else if (format === 1 && bits === 8) { + for (let i = 0; i < framed; i++) { + for (let c = 0; c < channels; c++) { + out[c][i] = (bytes[data.start + i * channels + c] - 128) / 128; + } + } + } else if (format === 3 && bits === 32) { + for (let i = 0; i < framed; i++) { + for (let c = 0; c < channels; c++) { + out[c][i] = dv.getFloat32(data.start + (i * channels + c) * 4, true); + } + } + } else { + return null; + } + + return { sampleRate, channels: out, bitDepth: bits, format: format === 3 ? 'float' : 'pcm' }; +} + +/** Average all channels. Mono is the contract for analysis. */ +export function mono(channels) { + if (channels.length === 1) return channels[0]; + const n = channels[0].length; + const out = new Float32Array(n); + for (let c = 0; c < channels.length; c++) { + const ch = channels[c]; + for (let i = 0; i < n; i++) out[i] += ch[i]; + } + const k = 1 / channels.length; + for (let i = 0; i < n; i++) out[i] *= k; + return out; +} + +export { ascii }; diff --git a/metabolism/src/camera.js b/metabolism/src/camera.js new file mode 100644 index 0000000..8307946 --- /dev/null +++ b/metabolism/src/camera.js @@ -0,0 +1,146 @@ +// The camera is an observer with a motive: it cuts to where the world just +// changed, holds long enough to see what it changed into, and pushes in while +// it holds. It is not a rig on rails. +// +// Two rules make it read as cinematography rather than as a screensaver: +// - a cut is a hard change of subject, so shot sizes alternate (a close +// answered by a wide reads as two shots of one place); +// - the subject is chosen from recent ACTIVITY, not from the current state, +// because the interesting thing is the change, not the crowd. + +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); +const smooth = (t) => t * t * (3 - 2 * t); + +export class Camera { + constructor(song, world, { fps = 30, width = 640, height = 360 } = {}) { + this.song = song; + this.world = world; + this.fps = fps; + this.w = width; + this.h = height; + + this.cx = 0.5; + this.cy = 0.5; + this.zoom = 1.35; + this.baseZoom = 1.35; + + this.shot = 0; + this.shotStart = 0; + this.shotLen = 5; + this.nextCut = 0; + this.dir = 1; + this.prev = { x: 0.5, y: 0.5 }; + this.target = { x: 0.5, y: 0.5 }; + + this.cues = new Set(); + for (const o of song.analysis.onsets.all) { + if (o.strength > 0.72) this.cues.add(o.frame); + } + this.cut(); + } + + lawAt(frame) { + const t = frame / this.fps; + const laws = this.song.laws; + for (let i = laws.length - 1; i >= 0; i--) if (t >= laws[i].start) return laws[i]; + return laws[0]; + } + + /** Where is the world actually doing something, weighted away from the last subject? */ + pickFocus(zoom) { + const act = this.world.activity; + const n = 32; + const half = 0.5 / zoom; + const lo = half + 0.02; + const hi = 1 - half - 0.02; + let best = -1; + let bx = 0.5; + let by = 0.5; + let maxAct = 1e-9; + for (let i = 0; i < act.length; i++) maxAct = Math.max(maxAct, act[i]); + for (let y = 0; y < n; y++) { + for (let x = 0; x < n; x++) { + const wx = (x + 0.5) / n; + const wy = (y + 0.5) / n; + if (wx < lo || wx > hi || wy < lo || wy > hi) continue; + const a = act[y * n + x] / maxAct; + if (a < 0.08) continue; + const d = Math.hypot(wx - this.prev.x, wy - this.prev.y); + // Novel subject, real activity, not pinned to the frame edge. + const score = a * (0.35 + 0.65 * Math.min(1, d * 2.4)); + if (score > best) { + best = score; + bx = wx; + by = wy; + } + } + } + return { x: bx, y: by, value: maxAct }; + } + + activityCoverage() { + const act = this.world.activity; + let max = 1e-9; + for (let i = 0; i < act.length; i++) max = Math.max(max, act[i]); + let n = 0; + for (let i = 0; i < act.length; i++) if (act[i] > max * 0.12) n++; + return n / act.length; + } + + cut() { + const frame = Math.max(0, this.world.frame); + const law = this.lawAt(frame); + const energy = law.energy ?? 0.5; + + // Shot size: a spread-out world gets a wider lens; a concentrated one + // gets a close-up. Every third shot widens deliberately, so the edit + // has a rhythm of sizes and not just a rhythm of cuts. + const coverage = this.activityCoverage(); + let zoom = 1.35 + (1 - coverage) * 2.45; + if (this.shot % 3 === 2) zoom *= 0.74; + else if (this.shot % 3 === 1) zoom *= 1.16; + zoom = clamp(zoom, 1.25, 4.6); + + const focus = this.pickFocus(zoom); + this.target = { x: focus.x, y: focus.y }; + this.cx = focus.x; + this.cy = focus.y; + this.zoom = zoom; + this.baseZoom = zoom; + this.dir = this.shot % 2 === 0 ? 1 : -1; + + this.shotLen = clamp((3.6 + 3.4 * (1 - energy)) * (law.kind === 'breakdown' ? 1.25 : 1), 2.4, 9); + this.shotStart = frame; + this.nextCut = frame + Math.round(this.shotLen * this.fps); + this.prev = { x: focus.x, y: focus.y }; + this.shot++; + } + + update(frame) { + const age = frame - this.shotStart; + const law = this.lawAt(frame); + const cue = this.cues.has(frame); + if (frame >= this.nextCut || (cue && age > this.fps * 2.0 && (law.kind === 'drop' || law.kind === 'build'))) { + this.cut(); + } + + const t = clamp((frame - this.shotStart) / Math.max(1, this.nextCut - this.shotStart), 0, 1); + const e = smooth(t); + // A slow drift across the subject, in frame fractions rather than world + // units: the push must feel the same at a wide shot and a close-up. + this.cx = this.target.x + (this.dir * 0.016 * e) / this.zoom; + this.cy = this.target.y - (this.dir * 0.008 * e) / this.zoom; + this.zoom = this.baseZoom * (1 + 0.07 * e); + this.clampToWorld(); + } + + clampToWorld() { + const half = 0.5 / this.zoom; + this.cx = clamp(this.cx, half, 1 - half); + this.cy = clamp(this.cy, half, 1 - half); + } + + transform() { + return { cx: this.cx, cy: this.cy, zoom: this.zoom }; + } +} diff --git a/metabolism/src/film.js b/metabolism/src/film.js new file mode 100644 index 0000000..675e6fb --- /dev/null +++ b/metabolism/src/film.js @@ -0,0 +1,57 @@ +// One run: song in, world and frames out. The only place the three layers +// (audio, ecology, camera) are wired together, and the place that guarantees +// the order — step, then look — is the same for a strip and for a video. + +import { World } from './sim/world.js'; +import { Camera } from './camera.js'; +import { Renderer } from './render/view.js'; + +export class Run { + constructor(song, { fps = 30, width = 640, height = 360, seed = song.seed } = {}) { + this.song = song; + this.fps = fps; + this.width = width; + this.height = height; + this.world = new World(song, { fps, seed }); + this.camera = new Camera(song, this.world, { fps, width, height }); + this.renderer = new Renderer(width, height); + } + + /** Advance to a frame, updating the camera on every step. */ + stepTo(frame) { + while (this.world.frame < frame) { + this.world.step(); + this.camera.update(this.world.frame); + } + } + + /** Simulate everything, render only the frames asked for. */ + renderAt(frames) { + const sorted = [...frames].sort((a, b) => a - b); + const out = new Map(); + for (const f of sorted) { + this.stepTo(f); + const bmp = this.renderer.render(this.world, this.song, this.camera, f); + // The renderer owns one framebuffer. A strip needs the pixels as + // they were, not a handle to the buffer's latest state. + out.set(f, { + w: bmp.w, + h: bmp.h, + data: new Uint8ClampedArray(bmp.data), + cam: this.camera.transform(), + shot: this.camera.shot, + }); + } + return out; + } + + /** Advance without rendering; used by metrics and by the report. */ + simulate() { + const n = this.song.analysis.frames; + while (this.world.frame < n - 1) { + this.world.step(); + this.camera.update(this.world.frame); + } + return this.world; + } +} diff --git a/metabolism/src/metrics.js b/metabolism/src/metrics.js new file mode 100644 index 0000000..f83c0d5 --- /dev/null +++ b/metabolism/src/metrics.js @@ -0,0 +1,242 @@ +// System-level quality, not per-pixel quality. +// +// coupling — how much of the ecology's motion the song drives (correlation +// between sim activity and the track's energy/onset envelope). +// residual — how much of the ecology's motion the song does NOT explain +// (1 - R² of a linear regression on song features): the internal +// dynamics, the thing that makes it alive rather than a visualiser. +// fitness = coupling × residual — the best world is one that keeps time +// AND has a life of its own. A pure visualiser scores ~0; +// a pure random walk scores ~0. Both are easy to build. The +// middle is the point. +// +// divergence — a fingerprint of the world's grown structure (as distinct +// from the song's structure), used to check that two songs truly +// grow different worlds. + +const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x); + +function mean(a) { + let s = 0; + for (let i = 0; i < a.length; i++) s += a[i]; + return s / a.length; +} + +function pearson(a, b) { + const n = Math.min(a.length, b.length); + const ma = mean(a); + const mb = mean(b); + let num = 0; + let da = 0; + let db = 0; + for (let i = 0; i < n; i++) { + const x = a[i] - ma; + const y = b[i] - mb; + num += x * y; + da += x * x; + db += y * y; + } + const d = Math.sqrt(Math.max(1e-12, da * db)); + return num / d; +} + +/** Ordinary least squares with a touch of ridge. X (n×k) -> y. */ +function ols(X, y, ridge = 1e-6) { + const n = X.length; + const k = X[0].length; + const A = Array.from({ length: k }, () => new Array(k + 1).fill(0)); + for (let i = 0; i < n; i++) { + for (let r = 0; r < k; r++) { + for (let c = 0; c < k; c++) A[r][c] += X[i][r] * X[i][c]; + A[r][k] += X[i][r] * y[i]; + } + } + for (let r = 0; r < k; r++) A[r][r] += ridge; + for (let col = 0; col < k; col++) { + let piv = col; + for (let r = col + 1; r < k; r++) { + if (Math.abs(A[r][col]) > Math.abs(A[piv][col])) piv = r; + } + if (Math.abs(A[piv][col]) < 1e-12) continue; + [A[col], A[piv]] = [A[piv], A[col]]; + for (let r = col + 1; r < k; r++) { + const f = A[r][col] / A[col][col]; + for (let c = col; c <= k; c++) A[r][c] -= f * A[col][c]; + } + } + const beta = new Array(k).fill(0); + for (let r = k - 1; r >= 0; r--) { + let v = A[r][k]; + for (let c = r + 1; c < k; c++) v -= A[r][c] * beta[c]; + beta[r] = Math.abs(A[r][r]) < 1e-12 ? 0 : v / A[r][r]; + } + return beta; +} + +function rSquared(X, y, beta) { + const n = X.length; + let ssRes = 0; + let ssTot = 0; + const my = mean(y); + for (let i = 0; i < n; i++) { + let p = 0; + for (let c = 0; c < beta.length; c++) p += X[i][c] * beta[c]; + ssRes += (y[i] - p) ** 2; + ssTot += (y[i] - my) ** 2; + } + if (ssTot <= 1e-12) return 1; + return Math.max(0, 1 - ssRes / ssTot); +} + +/** Non-causal moving average over `w` frames. Preserves phase, kills jitter. */ +function smoothSeries(values, w) { + const out = new Float32Array(values.length); + let sum = 0; + const half = Math.floor(w / 2); + for (let i = 0; i < values.length; i++) { + sum += values[i]; + if (i - w >= 0) sum -= values[i - w]; + out[i] = sum / Math.min(i + 1, w); + } + // Shift by half a window so the smoothed value sits at the centre. + const shifted = new Float32Array(values.length); + for (let i = 0; i < values.length; i++) { + shifted[i] = out[Math.max(0, Math.min(values.length - 1, i + half))]; + } + return shifted; +} + +/** + * Evaluate one world against its own song. + * + * The analysis table is 0..F at fps; world.history is aligned to the same + * frames. Movement and deposit are the sim's instantaneous "what is it doing"; + * the song's energy + onset + flux are the driver set. + */ +export function evaluate(world, song) { + const a = song.analysis; + const one = (arr) => { + const max = Math.max(...arr); + return max > 1e-9 ? arr.map((v) => v / max) : arr; + }; + const energy = one(Array.from(a.energy)); + const flux = one(Array.from(a.flux)); + + // Onset pressure at each frame: 0..1 combining the three band onsets. + const onset = new Float32Array(a.frames); + for (const o of a.onsets.low) onset[o.frame] = Math.max(onset[o.frame], 0.5); + for (const o of a.onsets.mid) onset[o.frame] = Math.max(onset[o.frame], 0.35); + for (const o of a.onsets.high) onset[o.frame] = Math.max(onset[o.frame], 0.2); + + // The sim's activity, also in [0..1]. Movement is scaled per-track so a + // busy ecosystem and a sparse one are compared fairly. + const mov = one(Array.from(world.history.movement)); + const births = one(Array.from(world.history.births)); + + // A cue is a frame where something happened in the audio; the coupling is + // how often the ecology's motion edges up shortly after. Predict at a lag + // of 0..0.5s so an organism is allowed a reaction time. + const lag = 8; // frames at 30fps + let hit = 0; + let total = 0; + for (let f = lag; f < a.frames; f++) { + const cue = energy[f - lag] > 0.35 || onset[f - lag] > 0; + if (!cue) continue; + total++; + if (mov[f] > mov[f - 1] * 1.02 + 0.004) hit++; + } + const response = total > 0 ? hit / total : 0; + + const X = Array.from({ length: a.frames }, (_, f) => [ + energy[f], + onset[f], + flux[f], + ]); + + // Residual is what survives a regression of the sim's activity on ALL + // three song features: motion, plus the population curve — the slow, + // cumulative behaviour no envelope predicts directly. + const betaMove = ols(X, mov); + const betaAlive = ols(X, one(Array.from(world.history.alive))); + const r2Move = rSquared(X, mov, betaMove); + const r2Alive = rSquared(X, one(Array.from(world.history.alive)), betaAlive); + + // Coupling: how much the ecology answers the song (cue response plus the + // long form, their correlation after smoothing over a beat). + const smoothMov = smoothSeries(mov, 30); + const smoothEnergy = smoothSeries(energy, 18); + const longForm = Math.max(0, pearson(smoothMov, smoothEnergy)); + + const coupling = clamp01(0.55 * response + 0.45 * longForm); + const residual = (1 - clamp01(r2Move)) * 0.6 + (1 - clamp01(r2Alive)) * 0.4; + const fitness = coupling * residual; + + return { + coupling: Number(coupling.toFixed(3)), + residual: Number(residual.toFixed(3)), + fitness: Number(fitness.toFixed(3)), + response: Number(response.toFixed(3)), + longForm: Number(longForm.toFixed(3)), + r2Move: Number(clamp01(r2Move).toFixed(3)), + r2Alive: Number(clamp01(r2Alive).toFixed(3)), + }; +} + +/** + * A fingerprint of the world the song GROWED, not of the song itself: + * how occupied the plate is, how clustered the crowds are, how much of the + * ground is trail vs open space. Two songs should sit far apart on this. + */ +export function fingerprint(world) { + const F = world.field; + const g = 16; + const cell = F / g; + const agents = new Float32Array(g * g); + const trail = new Float32Array(g * g); + for (let i = 0; i < world.count; i++) { + const ax = Math.min(g - 1, (world.x[i] * g) | 0); + const ay = Math.min(g - 1, (world.y[i] * g) | 0); + agents[ay * g + ax]++; + } + for (let c = 0; c < 3; c++) { + const t = world.trail[c]; + for (let gy = 0; gy < g; gy++) { + for (let gx = 0; gx < g; gx++) { + let s = 0; + for (let y = gy * cell; y < (gy + 1) * cell; y += 4) { + for (let x = gx * cell; x < (gx + 1) * cell; x += 4) s += t[y * F + x]; + } + trail[gy * g + gx] += s * 16; + } + } + } + const n = g * g; + let occupancy = 0; + let cluster = 0; + let trailFill = 0; + let maxA = 0; + for (let i = 0; i < n; i++) { + if (agents[i] >= 3) occupancy++; + maxA = Math.max(maxA, agents[i]); + if (trail[i] > 3) trailFill++; + } + cluster = maxA > 30 ? 1 : maxA > 12 ? 0.6 : maxA > 5 ? 0.3 : 0; + const balance = [0, 0, 0]; + for (let i = 0; i < world.count; i++) balance[world.cls[i]]++; + const total = Math.max(1, world.count); + return { + occupancy: Number((occupancy / n).toFixed(3)), + cluster: Number(cluster.toFixed(2)), + trailFill: Number((trailFill / n).toFixed(3)), + classMix: balance.map((v) => Number((v / total).toFixed(2))), + }; +} + +/** L1 distance between two fingerprints. */ +export function divergence(a, b) { + let d = Math.abs(a.occupancy - b.occupancy) + + Math.abs(a.cluster - b.cluster) + + Math.abs(a.trailFill - b.trailFill); + for (let i = 0; i < 3; i++) d += Math.abs((a.classMix?.[i] ?? 0) - (b.classMix?.[i] ?? 0)); + return Number(d.toFixed(3)); +} \ No newline at end of file diff --git a/metabolism/src/render/bitmap.js b/metabolism/src/render/bitmap.js new file mode 100644 index 0000000..86ac820 --- /dev/null +++ b/metabolism/src/render/bitmap.js @@ -0,0 +1,277 @@ +// A tiny software framebuffer. The renderer is deliberately not a canvas: +// the same pixels come out in node (for strips and tests) and in the browser +// (blitted through ImageData), so preview and export cannot drift. + +const clamp255 = (v) => (v < 0 ? 0 : v > 255 ? 255 : v); + +export class Bitmap { + constructor(width, height) { + this.w = width; + this.h = height; + this.data = new Uint8ClampedArray(width * height * 4); + this.rowX = new Float32Array(width); + this.rowY = new Float32Array(height); + } + + clear(r, g, b) { + const R = Math.round(r * 255); + const G = Math.round(g * 255); + const B = Math.round(b * 255); + const d = this.data; + for (let i = 0; i < d.length; i += 4) { + d[i] = R; + d[i + 1] = G; + d[i + 2] = B; + d[i + 3] = 255; + } + } + + /** Additive single pixel, alpha in 0..1. */ + add(x, y, r, g, b, a = 1) { + const ix = x | 0; + const iy = y | 0; + if (ix < 0 || iy < 0 || ix >= this.w || iy >= this.h) return; + const i = (iy * this.w + ix) * 4; + const d = this.data; + d[i] = clamp255(d[i] + r * 255 * a); + d[i + 1] = clamp255(d[i + 1] + g * 255 * a); + d[i + 2] = clamp255(d[i + 2] + b * 255 * a); + } + + /** Additive antialiased disc. Used for agents; radius is in pixels. */ + disc(cx, cy, radius, r, g, b, intensity = 1) { + const rad = Math.max(0.45, radius); + const x0 = Math.max(0, Math.floor(cx - rad - 1)); + const x1 = Math.min(this.w - 1, Math.ceil(cx + rad + 1)); + const y0 = Math.max(0, Math.floor(cy - rad - 1)); + const y1 = Math.min(this.h - 1, Math.ceil(cy + rad + 1)); + const d = this.data; + for (let y = y0; y <= y1; y++) { + const dy = y - cy; + for (let x = x0; x <= x1; x++) { + const dx = x - cx; + const dist = Math.sqrt(dx * dx + dy * dy); + let a = rad - dist; + if (a <= 0) continue; + a = Math.min(1, a) * intensity; + // A brighter core: the disc reads as a body, not a smear. + const core = dist < rad * 0.45 ? 1.6 : 1; + const i = (y * this.w + x) * 4; + d[i] = clamp255(d[i] + r * 255 * a * core); + d[i + 1] = clamp255(d[i + 1] + g * 255 * a * core); + d[i + 2] = clamp255(d[i + 2] + b * 255 * a * core); + } + } + } + + /** Additive line, for motion streaks. */ + line(x0, y0, x1, y1, r, g, b, intensity = 1) { + const dx = x1 - x0; + const dy = y1 - y0; + const steps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)))); + const d = this.data; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + const x = (x0 + dx * t) | 0; + const y = (y0 + dy * t) | 0; + if (x < 0 || y < 0 || x >= this.w || y >= this.h) continue; + // Taper: the tail is history, the head is the body. + const a = intensity * (0.35 + 0.65 * t); + const o = (y * this.w + x) * 4; + d[o] = clamp255(d[o] + r * 255 * a); + d[o + 1] = clamp255(d[o + 1] + g * 255 * a); + d[o + 2] = clamp255(d[o + 2] + b * 255 * a); + } + } + + /** Additive antialiased ring: an event landing on the ground. */ + ring(cx, cy, radius, thickness, r, g, b, intensity = 1) { + const outer = radius + thickness + 1; + const x0 = Math.max(0, Math.floor(cx - outer)); + const x1 = Math.min(this.w - 1, Math.ceil(cx + outer)); + const y0 = Math.max(0, Math.floor(cy - outer)); + const y1 = Math.min(this.h - 1, Math.ceil(cy + outer)); + const d = this.data; + const inner = Math.max(0.01, radius); + for (let y = y0; y <= y1; y++) { + const dy = y - cy; + for (let x = x0; x <= x1; x++) { + const dx = x - cx; + const dist = Math.sqrt(dx * dx + dy * dy); + const edge = Math.abs(dist - inner); + if (edge > thickness) continue; + const a = (1 - edge / thickness) * intensity; + const i = (y * this.w + x) * 4; + d[i] = clamp255(d[i] + r * 255 * a); + d[i + 1] = clamp255(d[i + 1] + g * 255 * a); + d[i + 2] = clamp255(d[i + 2] + b * 255 * a); + } + } + } + + /** + * Draw a square RGBA source (the trail field) through the camera. + * Bilinear, because a 128-cell ground at 640 px would otherwise read as + * tiles; the source is already blurred, so this is a resample, not detail. + */ + blitWorld(src, sw, sh, cx, cy, zoom, gain = 1) { + const W = this.w; + const H = this.h; + const scale = W * zoom; + const d = this.data; + const rowX = this.rowX; + const rowY = this.rowY; + for (let x = 0; x < W; x++) rowX[x] = (cx * scale) - W / 2 + x; + for (let y = 0; y < H; y++) rowY[y] = (cy * scale) - H / 2 + y; + + for (let y = 0; y < H; y++) { + const sy = rowY[y] / scale; // 0..1 + if (sy < 0 || sy > 1) continue; + const gy = Math.min(sh - 1.001, Math.max(0, sy * (sh - 1))); + const y0 = gy | 0; + const ty = gy - y0; + const r0 = y0 * sh; + const r1 = (y0 + 1) * sh; + for (let x = 0; x < W; x++) { + const sx = rowX[x] / scale; + if (sx < 0 || sx > 1) continue; + const gx = Math.min(sw - 1.001, Math.max(0, sx * (sw - 1))); + const x0 = gx | 0; + const tx = gx - x0; + const i00 = (r0 + x0) * 4; + const i10 = (r0 + x0 + 1) * 4; + const i01 = (r1 + x0) * 4; + const i11 = (r1 + x0 + 1) * 4; + const w00 = (1 - tx) * (1 - ty); + const w10 = tx * (1 - ty); + const w01 = (1 - tx) * ty; + const w11 = tx * ty; + const o = (y * W + x) * 4; + d[o] = clamp255(d[o] + gain * ( + src[i00] * w00 + src[i10] * w10 + src[i01] * w01 + src[i11] * w11)); + d[o + 1] = clamp255(d[o + 1] + gain * ( + src[i00 + 1] * w00 + src[i10 + 1] * w10 + src[i01 + 1] * w01 + src[i11 + 1] * w11)); + d[o + 2] = clamp255(d[o + 2] + gain * ( + src[i00 + 2] * w00 + src[i10 + 2] * w10 + src[i01 + 2] * w01 + src[i11 + 2] * w11)); + } + } + } + + /** Multiply by a radial falloff. Keeps the eye on the middle. */ + vignette(strength = 0.55, tint = [0, 0, 0]) { + const W = this.w; + const H = this.h; + const d = this.data; + const cx = W / 2; + const cy = H / 2; + const maxR = Math.hypot(cx, cy); + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const r = Math.hypot(x - cx, y - cy) / maxR; + const k = 1 - strength * r * r; + const i = (y * W + x) * 4; + d[i] = clamp255(d[i] * k + tint[0] * 255 * (1 - k)); + d[i + 1] = clamp255(d[i + 1] * k + tint[1] * 255 * (1 - k)); + d[i + 2] = clamp255(d[i + 2] * k + tint[2] * 255 * (1 - k)); + } + } + } + + /** Deterministic grain. A still frame and the same frame in motion agree. */ + grain(amount = 3, seed = 1) { + let s = seed >>> 0; + const d = this.data; + for (let i = 0; i < d.length; i += 4) { + s = (Math.imul(s ^ (s >>> 15), 2246822519) + 0x9e3779b9) >>> 0; + const n = ((s >>> 24) - 128) * (amount / 128); + d[i] = clamp255(d[i] + n); + d[i + 1] = clamp255(d[i + 1] + n); + d[i + 2] = clamp255(d[i + 2] + n); + } + } + + toImageData() { + return new ImageData(new Uint8ClampedArray(this.data), this.w, this.h); + } +} + +/** + * The ground: three deposit grids summed through the palette into one RGBA + * image at field resolution, then blurred. Two blurs, one tight and one wide: + * the tight one keeps an edge, the wide one is the glow. + */ +export function composeTrail(world, palette, exposure = 2.2) { + const F = world.field; + const n = F * F; + const sharp = new Float32Array(n * 3); + const t0 = world.trail[0]; + const t1 = world.trail[1]; + const t2 = world.trail[2]; + const c0 = palette.trails[0]; + const c1 = palette.trails[1]; + const c2 = palette.trails[2]; + for (let i = 0; i < n; i++) { + const a = t0[i]; + const b = t1[i]; + const c = t2[i]; + sharp[i * 3] = a * c0[0] + b * c1[0] + c * c2[0]; + sharp[i * 3 + 1] = a * c0[1] + b * c1[1] + c * c2[1]; + sharp[i * 3 + 2] = a * c0[2] + b * c1[2] + c * c2[2]; + } + const wide = boxBlur(sharp, F, 2); + const tight = boxBlur(sharp, F, 1); + const out = new Uint8ClampedArray(n * 4); + for (let i = 0; i < n; i++) { + for (let c = 0; c < 3; c++) { + const v = (tight[i * 3 + c] * 0.55 + wide[i * 3 + c] * 0.65) * exposure; + // Soft saturation: bright regions keep their hue instead of clipping + // to white, which is what a physical glow does. + out[i * 4 + c] = Math.round(255 * (1 - Math.exp(-v))); + } + out[i * 4 + 3] = 255; + } + return out; +} + +function boxBlur(src, F, radius) { + const tmp = new Float32Array(src.length); + const out = new Float32Array(src.length); + const k = 1 / (radius * 2 + 1); + for (let y = 0; y < F; y++) { + for (let x = 0; x < F; x++) { + let r = 0; + let g = 0; + let b = 0; + for (let d = -radius; d <= radius; d++) { + const xx = Math.min(F - 1, Math.max(0, x + d)); + const i = (y * F + xx) * 3; + r += src[i]; + g += src[i + 1]; + b += src[i + 2]; + } + const o = (y * F + x) * 3; + tmp[o] = r * k; + tmp[o + 1] = g * k; + tmp[o + 2] = b * k; + } + } + for (let y = 0; y < F; y++) { + for (let x = 0; x < F; x++) { + let r = 0; + let g = 0; + let b = 0; + for (let d = -radius; d <= radius; d++) { + const yy = Math.min(F - 1, Math.max(0, y + d)); + const i = (yy * F + x) * 3; + r += tmp[i]; + g += tmp[i + 1]; + b += tmp[i + 2]; + } + const o = (y * F + x) * 3; + out[o] = r * k; + out[o + 1] = g * k; + out[o + 2] = b * k; + } + } + return out; +} diff --git a/metabolism/src/render/png.js b/metabolism/src/render/png.js new file mode 100644 index 0000000..e96e49d --- /dev/null +++ b/metabolism/src/render/png.js @@ -0,0 +1,87 @@ +// Minimal PNG writer (8-bit RGBA, no interlace). Node only: the browser demo +// blits the same framebuffer through ImageData and never touches this file. + +import zlib from 'node:zlib'; + +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); + +function crc32(buf) { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type, data) { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const body = Buffer.concat([Buffer.from(type, 'ascii'), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body), 0); + return Buffer.concat([len, body, crc]); +} + +/** + * @param {number} width + * @param {number} height + * @param {Uint8ClampedArray|Uint8Array} rgba + * @returns {Buffer} + */ +export function encodePNG(width, height, rgba) { + const stride = width * 4; + const raw = Buffer.alloc((stride + 1) * height); + for (let y = 0; y < height; y++) { + raw[y * (stride + 1)] = 0; + raw.set(rgba.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; + ihdr[9] = 6; + ihdr[10] = 0; + ihdr[11] = 0; + ihdr[12] = 0; + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', zlib.deflateSync(raw, { level: 6 })), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +/** Tile several bitmaps into one grid. Bitmaps are {w, h, data}. */ +export function tile(bitmaps, cols, rows, gap = 0) { + if (!bitmaps.length) throw new Error('tile: nothing to tile'); + const w = bitmaps[0].w; + const h = bitmaps[0].h; + const W = cols * w + (cols - 1) * gap; + const H = rows * h + (rows - 1) * gap; + const out = new Uint8ClampedArray(W * H * 4); + for (let i = 0; i < out.length; i += 4) { + out[i] = 8; + out[i + 1] = 8; + out[i + 2] = 10; + out[i + 3] = 255; + } + for (let i = 0; i < bitmaps.length; i++) { + const b = bitmaps[i]; + const cx = i % cols; + const cy = (i / cols) | 0; + const ox = cx * (w + gap); + const oy = cy * (h + gap); + for (let y = 0; y < h; y++) { + const src = y * b.w * 4; + const dst = ((oy + y) * W + ox) * 4; + out.set(b.data.subarray(src, src + b.w * 4), dst); + } + } + return { w: W, h: H, data: out }; +} diff --git a/metabolism/src/render/view.js b/metabolism/src/render/view.js new file mode 100644 index 0000000..43de9df --- /dev/null +++ b/metabolism/src/render/view.js @@ -0,0 +1,175 @@ +// Compose one frame. Ground first (history), then the agents (the present), +// then the lens (vignette and grain). The field is drawn faintly under the +// trail so the audience can see WHY the agents are where they are. + +import { Bitmap } from './bitmap.js'; + +export class Renderer { + constructor(width, height, { exposure = 2.6, grain = 2.4, vignette = 0.55 } = {}) { + this.w = width; + this.h = height; + this.exposure = exposure; + this.grainAmount = grain; + this.vignetteAmount = vignette; + this.bitmap = new Bitmap(width, height); + this.F = 0; + this.trailRGBA = null; + this.sharp = null; + this.tight = null; + this.wide = null; + } + + allocate(F) { + this.F = F; + const n = F * F; + this.trailRGBA = new Uint8ClampedArray(n * 4); + this.sharp = new Float32Array(n * 3); + this.tmp = new Float32Array(n * 3); + this.tight = new Float32Array(n * 3); + this.wide = new Float32Array(n * 3); + } + + trail(world, palette) { + const F = world.field; + if (this.F !== F) this.allocate(F); + const n = F * F; + const sharp = this.sharp; + const t0 = world.trail[0]; + const t1 = world.trail[1]; + const t2 = world.trail[2]; + const c0 = palette.trails[0]; + const c1 = palette.trails[1]; + const c2 = palette.trails[2]; + const fog = palette.fog; + const res = world.resource; + for (let i = 0; i < n; i++) { + const a = t0[i]; + const b = t1[i]; + const c = t2[i]; + const f = res[i] * 0.09; + sharp[i * 3] = a * c0[0] + b * c1[0] + c * c2[0] + f * fog[0]; + sharp[i * 3 + 1] = a * c0[1] + b * c1[1] + c * c2[1] + f * fog[1]; + sharp[i * 3 + 2] = a * c0[2] + b * c1[2] + c * c2[2] + f * fog[2]; + } + boxBlur(sharp, this.tmp, this.tight, F, 1); + boxBlur(sharp, this.tmp, this.wide, F, 3); + const out = this.trailRGBA; + for (let i = 0; i < n; i++) { + for (let c = 0; c < 3; c++) { + // Sharp for the line, a one-cell blur to keep it from aliasing, + // and a little of the wide blur so a dense knot glows. The wide + // blur must stay small or the history turns into weather. + const v = (sharp[i * 3 + c] * 0.55 + + this.tight[i * 3 + c] * 0.80 + + this.wide[i * 3 + c] * 0.04) * this.exposure; + out[i * 4 + c] = Math.round(255 * (1 - Math.exp(-v))); + } + out[i * 4 + 3] = 255; + } + return out; + } + + render(world, song, camera, frame) { + const bmp = this.bitmap; + const palette = song.palette; + const cam = camera.transform(); + const scale = this.w * cam.zoom; + const halfW = this.w / 2; + const halfH = this.h / 2; + bmp.clear(palette.bg[0], palette.bg[1], palette.bg[2]); + + const trail = this.trail(world, palette); + bmp.blitWorld(trail, this.F, this.F, cam.cx, cam.cy, cam.zoom, 1); + + // The ring of a hit. This is the clearest causal link in the film: the + // audience hears a transient and sees the ground answer it. + const fps = song.analysis.fps; + for (const p of world.pulses) { + const age = (frame - p.frame) / fps; + if (age < 0 || age > 0.95) continue; + const sx = (p.x - cam.cx) * scale + halfW; + const sy = (p.y - cam.cy) * scale + halfH; + const worldR = 0.012 + age * 0.085 * (0.7 + 0.45 * p.strength); + const rad = worldR * scale; + if (sx + rad < 0 || sy + rad < 0 || sx - rad > this.w || sy - rad > this.h) continue; + const a = Math.pow(1 - age / 0.95, 2.2) * (0.10 + 0.26 * p.strength); + const col = palette.classes[p.voice]; + bmp.ring(sx, sy, rad, Math.max(0.75, 0.0022 * scale), col[0], col[1], col[2], a); + } + + // Agents. Radius is a property of the body in world units, so a close + // shot actually gets closer to a creature instead of showing the same + // dot larger. The halo is what makes a body read against its own trail. + for (let i = 0; i < world.count; i++) { + const x = world.x[i]; + const y = world.y[i]; + const sx = (x - cam.cx) * scale + halfW; + const sy = (y - cam.cy) * scale + halfH; + const margin = 12; + if (sx < -margin || sy < -margin || sx > this.w + margin || sy > this.h + margin) continue; + const cls = world.cls[i]; + const col = palette.classes[cls]; + const e = world.energy[i]; + const rad = Math.max(1.0, song.genome.classes[cls].size * 0.00068 * scale); + // A velocity streak makes direction legible in a still frame and + // separates the castes: commuters become dashes, builders dots. + const vx = world.vx[i]; + const vy = world.vy[i]; + if (vx * vx + vy * vy > 1e-10) { + bmp.line( + sx - vx * 0.34 * scale, + sy - vy * 0.34 * scale, + sx, + sy, + col[0], col[1], col[2], + 0.10 + 0.13 * Math.min(1, e) + ); + } + bmp.disc(sx, sy, rad, col[0], col[1], col[2], 0.60 + 0.30 * Math.min(1, e)); + } + + bmp.vignette(this.vignetteAmount, [palette.bg[0] * 0.6, palette.bg[1] * 0.6, palette.bg[2] * 0.6]); + bmp.grain(this.grainAmount, frame * 2654435761 + song.seed); + return bmp; + } +} + +function boxBlur(src, tmp, out, F, radius) { + const k = 1 / (radius * 2 + 1); + for (let y = 0; y < F; y++) { + for (let x = 0; x < F; x++) { + let r = 0; + let g = 0; + let b = 0; + for (let d = -radius; d <= radius; d++) { + const xx = Math.min(F - 1, Math.max(0, x + d)); + const i = (y * F + xx) * 3; + r += src[i]; + g += src[i + 1]; + b += src[i + 2]; + } + const o = (y * F + x) * 3; + tmp[o] = r * k; + tmp[o + 1] = g * k; + tmp[o + 2] = b * k; + } + } + for (let y = 0; y < F; y++) { + for (let x = 0; x < F; x++) { + let r = 0; + let g = 0; + let b = 0; + for (let d = -radius; d <= radius; d++) { + const yy = Math.min(F - 1, Math.max(0, y + d)); + const i = (yy * F + x) * 3; + r += tmp[i]; + g += tmp[i + 1]; + b += tmp[i + 2]; + } + const o = (y * F + x) * 3; + out[o] = r * k; + out[o + 1] = g * k; + out[o + 2] = b * k; + } + } +} diff --git a/metabolism/src/rng.js b/metabolism/src/rng.js new file mode 100644 index 0000000..079045b --- /dev/null +++ b/metabolism/src/rng.js @@ -0,0 +1,81 @@ +// Seeded PRNG. Nothing in metabolism may call Math.random or read a clock: +// the same song, seed and frame must always produce the same pixel. + +export class Rng { + constructor(seed = 1) { + this.seed = seed >>> 0; + this.state = this.seed; + } + + reset(seed = this.seed) { + this.seed = seed >>> 0; + this.state = this.seed; + return this; + } + + /** Uniform [0, 1). */ + next() { + let t = (this.state += 0x6d2b79f5) >>> 0; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + + range(min, max) { + return min + this.next() * (max - min); + } + + int(min, max) { + return Math.floor(this.range(min, max + 1)); + } + + bool(p = 0.5) { + return this.next() < p; + } + + pick(array) { + return array[this.int(0, array.length - 1)]; + } + + /** Gaussian, mean 0 sd 1. Box-Muller; the mode is where most tracks live. */ + gauss() { + const u1 = Math.max(this.next(), 1e-7); + const u2 = this.next(); + return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); + } + + /** Symmetric around 0, mostly inside [-1, 1]. */ + signed() { + return Math.max(-1, Math.min(1, this.gauss() * 0.6)); + } + + fork(label) { + return new Rng(hashString(String(label), this.seed)); + } +} + +/** FNV-1a. Derives a stable seed from a name. */ +export function hashString(str, salt = 0x811c9dc5) { + let h = salt >>> 0; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h >>> 0; +} + +/** + * FNV-1a over PCM. The seed is a property of the audio content, so the same + * file always grows the same world regardless of its name. + */ +export function hashSamples(float32, stride = 997) { + let h = 0x811c9dc5 >>> 0; + for (let i = 0; i < float32.length; i += stride) { + const q = Math.round(float32[i] * 32767) & 0xffff; + h ^= q & 0xff; + h = Math.imul(h, 0x01000193) >>> 0; + h ^= (q >>> 8) & 0xff; + h = Math.imul(h, 0x01000193) >>> 0; + } + return h >>> 0; +} diff --git a/metabolism/src/sim/wells.js b/metabolism/src/sim/wells.js new file mode 100644 index 0000000..3297046 --- /dev/null +++ b/metabolism/src/sim/wells.js @@ -0,0 +1,107 @@ +// Fertile ground. +// +// The uniform ambient floor made every frame a fog: food everywhere is the same +// as geography nowhere. Wells fix that. They are placed by the track's own +// harmony — one well per strong pitch class, on a ring — so the chord is the +// map. The mix decides which wells are rich: bass-heavy tracks feed low +// classes, bright ones feed the upper ring. +// +// The wells are permanent. That is the point: a place the audience can learn, +// return to, and see change. Events still land food elsewhere on the song's +// grid; the wells are the slow layer underneath. + +import { TAU } from '../song/palette.js'; + +const clamp = (v, a, b) => (v < a ? a : v > b ? b : v); + +/** Circular distance between pitch classes, in semitones. */ +function pcDistance(a, b) { + const d = Math.abs(a - b) % 12; + return Math.min(d, 12 - d); +} + +function pickPitchClasses(chroma) { + const order = Array.from({ length: 12 }, (_, i) => i) + .sort((a, b) => (chroma[b] ?? 0) - (chroma[a] ?? 0) || a - b); + const picked = []; + for (const pc of order) { + if (picked.every((p) => pcDistance(p, pc) >= 2)) picked.push(pc); + if (picked.length >= 5) break; + } + // A two-note track still needs a world with more than two rooms. Fill the + // largest gaps with quiet wells so composition never collapses to a line. + while (picked.length < 4) { + const sorted = [...picked].sort((a, b) => a - b); + let gapAt = 0; + let gap = -1; + for (let i = 0; i < sorted.length; i++) { + const a = sorted[i]; + const b = sorted[(i + 1) % sorted.length]; + const d = ((b - a + 12) % 12) || 12; + if (d > gap) { + gap = d; + gapAt = a + Math.floor(d / 2); + } + } + picked.push(((gapAt % 12) + 12) % 12); + } + return picked.slice(0, 5); +} + +/** + * @param {object} summary analysis.summary + * @param {number} field grid size + * @returns {Array<{x:number,y:number,r:number,rate:number,pc:number,idx:Int32Array,w:Float32Array}>} + */ +export function buildWells(summary, field) { + const chroma = summary.chroma || new Array(12).fill(0.5); + const pcs = pickPitchClasses(chroma); + const balance = summary.bandBalance || [0.33, 0.33, 0.33]; + + return pcs.map((pc, i) => { + const value = clamp(chroma[pc] ?? 0.4, 0.05, 1); + const angle = (pc / 12) * TAU + 0.10 * Math.sin(i * 2.3); + // Two rings so the world is not a necklace: alternate radii. + const ring = i % 2 === 0 ? 0.28 : 0.36; + const radius = ring + 0.03 * Math.sin(i * 1.7); + const sigma = 0.050 + 0.040 * value; + const rate = (0.38 + 1.15 * value) * (0.7 + 0.6 * balance[i % 3]); + + const cells = buildKernel(field, 0.5 + Math.cos(angle) * radius, 0.5 + Math.sin(angle) * radius, sigma, field); + return { + pc, + x: 0.5 + Math.cos(angle) * radius, + y: 0.5 + Math.sin(angle) * radius, + r: sigma, + rate, + idx: cells.idx, + w: cells.w, + }; + }); +} + +function buildKernel(F, x, y, sigma, scale) { + const radiusPx = Math.ceil(sigma * scale * 2.6); + const cx = Math.round(x * (F - 1)); + const cy = Math.round(y * (F - 1)); + const sig = Math.max(1.2, sigma * F); + const denom = 1 / (2 * sig * sig); + const idx = []; + const w = []; + let total = 0; + for (let dy = -radiusPx; dy <= radiusPx; dy++) { + const py = cy + dy; + if (py < 0 || py >= F) continue; + for (let dx = -radiusPx; dx <= radiusPx; dx++) { + const px = cx + dx; + if (px < 0 || px >= F) continue; + const g = Math.exp(-(dx * dx + dy * dy) * denom); + if (g < 0.0125) continue; + idx.push(py * F + px); + w.push(g); + total += g; + } + } + const k = total > 0 ? 1 / total : 0; + return { idx: Int32Array.from(idx), w: Float32Array.from(w, (v) => v * k) }; +} diff --git a/metabolism/src/sim/world.js b/metabolism/src/sim/world.js new file mode 100644 index 0000000..9a26d11 --- /dev/null +++ b/metabolism/src/sim/world.js @@ -0,0 +1,632 @@ +// The ecology. Agents on a resource field, with the song's species and the +// song's laws. No player, no realtime budget, no rendering: a step function. +// +// Three invariants are load-bearing and every design choice here serves them: +// +// PERSISTENCE agents live for seconds, not frames, and the trail grid keeps +// what they did for tens of seconds. You can follow something. +// CAUSALITY food arrives where and when the track put it. A bloom has an +// audible cause; a migration has a visible one. +// CONSEQUENCE the field is depleted, the trail accumulates. The state at t +// is made of the state at t-1, not re-rolled each frame. + +import { Rng } from '../rng.js'; +import { buildWells } from './wells.js'; + +const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x); +const TAU = Math.PI * 2; +const H = 1.6; // field samples are one and a half cells apart + +export const FIELD = 128; + +export class World { + /** + * @param {ReturnType} song + */ + constructor(song, { fps = 30, field = FIELD, seed = song.seed } = {}) { + this.song = song; + this.fps = fps; + this.dt = 1 / fps; + this.field = field; + this.genome = song.genome; + this.palette = song.palette; + this.rng = new Rng(seed >>> 0).fork('world'); + + this.resource = new Float32Array(field * field); + this.wells = buildWells(song.summary, field); + // The dish is delivered already furnished: colonies exist at t=0 and + // the first bars are life forming, not a blank plate waiting for food. + for (const well of this.wells) { + const add = well.rate * 7; + const idx = well.idx; + const w = well.w; + for (let i = 0; i < idx.length; i++) { + this.resource[idx[i]] = Math.min(1.6, add * w[i]); + } + } + this.trail = [ + new Float32Array(field * field), + new Float32Array(field * field), + new Float32Array(field * field), + ]; + this.activity = new Float32Array(32 * 32); + + this.cap = this.genome.maxAgents; + this.x = new Float32Array(this.cap); + this.y = new Float32Array(this.cap); + this.vx = new Float32Array(this.cap); + this.vy = new Float32Array(this.cap); + this.energy = new Float32Array(this.cap); + this.age = new Float32Array(this.cap); + this.cls = new Uint8Array(this.cap); + this.wellOf = new Uint8Array(this.cap); + this.pulses = []; + this.count = 0; + + this.newborn = new Float32Array(this.cap * 7); + this.newbornCount = 0; + + // Neighbour grid. + this.gridN = 22; + this.cellCount = new Int32Array(this.gridN * this.gridN + 1); + this.cellStart = new Int32Array(this.gridN * this.gridN + 1); + this.cellItems = new Int32Array(this.cap); + + this.history = { + alive: new Uint16Array(song.analysis.frames), + movement: new Float32Array(song.analysis.frames), + food: new Float32Array(song.analysis.frames), + births: new Uint16Array(song.analysis.frames), + deaths: new Uint16Array(song.analysis.frames), + }; + + this.frame = -1; + this.birthsThisFrame = 0; + this.deathsThisFrame = 0; + + this.seedInitial(); + } + + /** The law for a frame. Section boundaries are the only place laws change. */ + lawForFrame(frame) { + const laws = this.song.laws; + const t = frame / this.fps; + for (let i = laws.length - 1; i >= 0; i--) { + if (t >= laws[i].start) return laws[i]; + } + return laws[0]; + } + + seedInitial() { + const n = this.genome.seedAgents; + const wells = this.wells; + for (let i = 0; i < n && this.count + this.newbornCount < this.cap; i++) { + // Most life starts at a well; a few are already in transit, so the + // first frame is a world with roads, not a row of dots. + let x = 0.5; + let y = 0.5; + if (i % 5 !== 4 && wells.length) { + const w = wells[i % wells.length]; + x = w.x + this.rng.gauss() * w.r; + y = w.y + this.rng.gauss() * w.r; + } else { + const r = Math.sqrt(this.rng.next()) * 0.44; + const a = this.rng.next() * TAU; + x = 0.5 + Math.cos(a) * r; + y = 0.5 + Math.sin(a) * r; + } + this.queueAgent( + Math.max(0.02, Math.min(0.98, x)), + Math.max(0.02, Math.min(0.98, y)), + this.pickClass(), + 0.52 + 0.24 * this.rng.next() + ); + } + this.flushNewborns(); + this.birthsThisFrame = 0; + this.deathsThisFrame = 0; + } + + nearestWell(x, y) { + let best = 0; + let bd = Infinity; + for (let i = 0; i < this.wells.length; i++) { + const w = this.wells[i]; + const d = (w.x - x) ** 2 + (w.y - y) ** 2; + if (d < bd) { + bd = d; + best = i; + } + } + return best; + } + + pickClass() { + const w = this.genome.classes.map((c) => c.seed); + let total = 0; + for (const v of w) total += v; + let r = this.rng.next() * total; + for (let i = 0; i < w.length; i++) { + r -= w[i]; + if (r <= 0) return i; + } + return 0; + } + + queueAgent(x, y, cls, energy) { + if (this.count + this.newbornCount >= this.cap) return false; + const p = this.newbornCount * 7; + this.newborn[p] = x; + this.newborn[p + 1] = y; + this.newborn[p + 2] = 0; + this.newborn[p + 3] = 0; + this.newborn[p + 4] = energy; + this.newborn[p + 5] = 0; + this.newborn[p + 6] = cls; + this.newbornCount++; + return true; + } + + flushNewborns() { + for (let k = 0; k < this.newbornCount; k++) { + if (this.count >= this.cap) break; + const p = k * 7; + const i = this.count++; + this.x[i] = this.newborn[p]; + this.y[i] = this.newborn[p + 1]; + this.vx[i] = this.newborn[p + 2]; + this.vy[i] = this.newborn[p + 3]; + this.energy[i] = this.newborn[p + 4]; + this.age[i] = this.newborn[p + 5]; + this.cls[i] = this.newborn[p + 6]; + this.wellOf[i] = this.nearestWell(this.x[i], this.y[i]); + } + this.newbornCount = 0; + } + + // ---- field ------------------------------------------------------------- + + sample(fx, fy) { + const F = this.field; + const gx = Math.max(0, Math.min(F - 1.001, fx * (F - 1))); + const gy = Math.max(0, Math.min(F - 1.001, fy * (F - 1))); + const x0 = gx | 0; + const y0 = gy | 0; + const tx = gx - x0; + const ty = gy - y0; + const i = y0 * F + x0; + const a = this.resource[i]; + const b = this.resource[i + 1]; + const c = this.resource[i + F]; + const d = this.resource[i + F + 1]; + return (a + (b - a) * tx) * (1 - ty) + (c + (d - c) * tx) * ty; + } + + /** Consume from the four cells under (fx, fy). Returns what was eaten. */ + eat(fx, fy, amount) { + const F = this.field; + const gx = Math.max(0, Math.min(F - 1.001, fx * (F - 1))); + const gy = Math.max(0, Math.min(F - 1.001, fy * (F - 1))); + const x0 = gx | 0; + const y0 = gy | 0; + const tx = gx - x0; + const ty = gy - y0; + const res = this.resource; + const i0 = y0 * F + x0; + const i1 = i0 + 1; + const i2 = i0 + F; + const i3 = i2 + 1; + const w0 = (1 - tx) * (1 - ty); + const w1 = tx * (1 - ty); + const w2 = (1 - tx) * ty; + const w3 = tx * ty; + let eaten = 0; + let take = Math.min(res[i0], amount * w0); + res[i0] -= take; eaten += take; + take = Math.min(res[i1], amount * w1); + res[i1] -= take; eaten += take; + take = Math.min(res[i2], amount * w2); + res[i2] -= take; eaten += take; + take = Math.min(res[i3], amount * w3); + res[i3] -= take; eaten += take; + return eaten; + } + + depositTrail(cls, x, y, amount) { + const F = this.field; + const ix = Math.max(0, Math.min(F - 1, (x * F) | 0)); + const iy = Math.max(0, Math.min(F - 1, (y * F) | 0)); + this.trail[cls][iy * F + ix] += amount; + const ax = Math.max(0, Math.min(31, (x * 32) | 0)); + const ay = Math.max(0, Math.min(31, (y * 32) | 0)); + this.activity[ay * 32 + ax] += amount * 3; + } + + splat(x, y, spread, amount) { + const F = this.field; + const sigma = Math.max(0.8, spread * F); + const r = Math.ceil(sigma * 2.4); + const cx = Math.round(x * (F - 1)); + const cy = Math.round(y * (F - 1)); + const denom = 1 / (2 * sigma * sigma); + let total = 0; + const idx = []; + const w = []; + for (let dy = -r; dy <= r; dy++) { + const py = cy + dy; + if (py < 0 || py >= F) continue; + for (let dx = -r; dx <= r; dx++) { + const px = cx + dx; + if (px < 0 || px >= F) continue; + const k = Math.exp(-(dx * dx + dy * dy) * denom); + if (k < 0.01) continue; + idx.push(py * F + px); + w.push(k); + total += k; + } + } + if (total <= 0) return; + const k = amount / total; + for (let i = 0; i < idx.length; i++) this.resource[idx[i]] += w[i] * k; + } + + // ---- neighbour grid ---------------------------------------------------- + + buildGrid() { + const n = this.gridN; + const counts = this.cellCount; + counts.fill(0); + for (let i = 0; i < this.count; i++) { + const cx = Math.min(n - 1, Math.max(0, (this.x[i] * n) | 0)); + const cy = Math.min(n - 1, Math.max(0, (this.y[i] * n) | 0)); + counts[cy * n + cx + 1]++; + } + for (let i = 1; i <= n * n; i++) counts[i] += counts[i - 1]; + this.cellStart.set(counts); + for (let i = 0; i < this.count; i++) { + const cx = Math.min(n - 1, Math.max(0, (this.x[i] * n) | 0)); + const cy = Math.min(n - 1, Math.max(0, (this.y[i] * n) | 0)); + const c = cy * n + cx; + this.cellItems[this.cellStart[c]++] = i; + } + } + + // ---- step -------------------------------------------------------------- + + step() { + this.frame++; + const f = this.frame; + const law = this.lawForFrame(f); + const cutoff = f - Math.ceil(this.fps * 1.6); + while (this.pulses.length && this.pulses[0].frame < cutoff) this.pulses.shift(); + + this.birthsThisFrame = 0; + this.deathsThisFrame = 0; + + this.inject(f, law); + this.decay(law); + this.buildGrid(); + + this.update(law); + this.compact(); + this.flushNewborns(); + this.record(law); + return law; + } + + inject(frame, law) { + const events = this.song.index[frame]; + if (!events) return; + for (const e of events) { + this.splat(e.x, e.y, e.spread, e.amount * law.spawn); + const births = Math.round(e.spawn * law.spawn); + for (let k = 0; k < births; k++) { + if (this.queueAgent( + e.x + this.rng.range(-0.02, 0.02), + e.y + this.rng.range(-0.02, 0.02), + e.voice, + 0.45 + 0.25 * e.strength + )) this.birthsThisFrame++; + } + const ax = Math.max(0, Math.min(31, (e.x * 32) | 0)); + const ay = Math.max(0, Math.min(31, (e.y * 32) | 0)); + this.activity[ay * 32 + ax] += 0.5 + 2 * e.strength; + // A visible consequence: the hit lands as a ring on the ground. + if (e.strength > 0.42) { + this.pulses.push({ x: e.x, y: e.y, frame, strength: e.strength, voice: e.voice }); + } + } + } + + decay(law) { + const dt = this.dt; + const fieldK = Math.pow(0.5, dt / (this.genome.fieldHalfLife * (0.8 + 0.4 * law.spawn))); + const trailK = Math.pow(0.5, dt / (this.genome.trailHalfLife * (0.75 + 0.35 * law.deposit))); + const res = this.resource; + for (let i = 0; i < res.length; i++) res[i] *= fieldK; + for (let c = 0; c < 3; c++) { + const t = this.trail[c]; + for (let i = 0; i < t.length; i++) t[i] *= trailK; + } + const act = this.activity; + const actK = Math.pow(0.5, dt / 1.4); + for (let i = 0; i < act.length; i++) act[i] *= actK; + + // The wells recharge. Slowly, and to a ceiling: a well is a place with + // a carrying capacity, not a faucet. The section's energy sets the rate, + // so a breakdown is still a leaner world. + const scale = (0.45 + 1.25 * law.energy) * dt; + for (const well of this.wells) { + const add = well.rate * scale; + const idx = well.idx; + const w = well.w; + for (let i = 0; i < idx.length; i++) { + const cell = idx[i]; + res[cell] = Math.min(1.6, res[cell] + add * w[i]); + } + } + } + + update(law) { + const dt = this.dt; + const n = this.gridN; + const sense = 0.13; + const sense2 = sense * sense; + const maxNeighbours = 24; + const g = this.genome; + const wells = this.wells; + + for (let i = 0; i < this.count; i++) { + const cls = this.cls[i]; + const c = g.classes[cls]; + const x = this.x[i]; + const y = this.y[i]; + let vx = this.vx[i]; + let vy = this.vy[i]; + + let fx = 0; + let fy = 0; + let homeDx = 0; + let homeDy = 0; + + // Food gradient. A hungry agent commits harder to what it smells. + const e = this.energy[i]; + const hunger = 0.55 + 0.85 * (1 - clamp01(e)); + const h = H / this.field; + const gx = (this.sample(x + h, y) - this.sample(x - h, y)) / (2 * h); + const gy = (this.sample(x, y + h) - this.sample(x, y - h)) / (2 * h); + const grad = Math.hypot(gx, gy); + if (grad > 1e-6) { + fx += (gx / grad) * c.seek * hunger; + fy += (gy / grad) * c.seek * hunger; + } + + // Place. Each caste has a different relation to the wells, and the + // song sets how strongly it holds: builders orbit and paint, + // foragers commute and draw roads, sparks orbit wide and scatter. + if (wells.length) { + const home = wells[this.wellOf[i]]; + const hx = home.x - x; + const hy = home.y - y; + const hd = Math.hypot(hx, hy) + 1e-6; + homeDx = hx / hd; + homeDy = hy / hd; + if (c.role === 'commute') { + const next = (this.wellOf[i] + 1) % wells.length; + const w = wells[next]; + const wx = w.x - x; + const wy = w.y - y; + const wd = Math.hypot(wx, wy) + 1e-6; + if (wd < 0.05) { + this.wellOf[i] = next; + } else { + const pull = 1.0 + 1.7 * g.separation; + fx += (wx / wd) * pull; + fy += (wy / wd) * pull; + } + } else { + const err = (hd - c.orbit) / c.orbit; + const k = 1.9 * g.cohesion; + fx += (hx / hd) * Math.max(-1.4, Math.min(1.4, err)) * k; + fy += (hy / hd) * Math.max(-1.4, Math.min(1.4, err)) * k; + const dir = cls === 2 ? -1 : 1; + fx += (-hy / hd) * dir * c.spin; + fy += (hx / hd) * dir * c.spin; + } + } + + // Society. + let alignX = 0; + let alignY = 0; + let cohX = 0; + let cohY = 0; + let sepX = 0; + let sepY = 0; + let neighbours = 0; + const cx = Math.min(n - 1, Math.max(0, (x * n) | 0)); + const cy = Math.min(n - 1, Math.max(0, (y * n) | 0)); + for (let oy = -1; oy <= 1 && neighbours < maxNeighbours; oy++) { + const gy2 = cy + oy; + if (gy2 < 0 || gy2 >= n) continue; + for (let ox = -1; ox <= 1 && neighbours < maxNeighbours; ox++) { + const gx2 = cx + ox; + if (gx2 < 0 || gx2 >= n) continue; + const cell = gy2 * n + gx2; + const a = this.cellStart[cell]; + const b = this.cellStart[cell + 1]; + for (let p = a; p < b; p++) { + const j = this.cellItems[p]; + if (j === i) continue; + const dx = this.x[j] - x; + const dy = this.y[j] - y; + const d2 = dx * dx + dy * dy; + if (d2 > sense2 || d2 < 1e-12) continue; + const d = Math.sqrt(d2); + alignX += this.vx[j]; + alignY += this.vy[j]; + cohX += dx; + cohY += dy; + const push = (1 - d / sense) / d; + sepX -= dx * push; + sepY -= dy * push; + const k = g.interaction[cls][this.cls[j]]; + fx += (dx / d) * k; + fy += (dy / d) * k; + if (++neighbours >= maxNeighbours) break; + } + } + } + if (neighbours > 0) { + const inv = 1 / neighbours; + fx += cohX * inv * g.cohesion * 5.0; + fy += cohY * inv * g.cohesion * 5.0; + fx += (alignX * inv - vx) * g.alignment * 3.2; + fy += (alignY * inv - vy) * g.alignment * 3.2; + fx += sepX * g.separation * 1.05; + fy += sepY * g.separation * 1.05; + } + // Crowding keeps bodies apart: a colony must never collapse to a + // single blob, or the species reads as paint instead of life. The + // push is outward, away from the well where the crowd is densest, + // and scales with how far the crowd has overshot. + if (neighbours > 9) { + const c = Math.min(2.6, (neighbours - 9) * 0.30); + fx -= homeDx * c; + fy -= homeDy * c; + } + + // Jitter: what a noisy track does to a well-meaning rule. + fx += this.rng.signed() * g.jitter * 0.26; + fy += this.rng.signed() * g.jitter * 0.26; + + // Soft walls: the dish has an edge, but it does not trap. + const m = 0.035; + if (x < m) fx += (m - x) * 14; + if (x > 1 - m) fx -= (x - (1 - m)) * 14; + if (y < m) fy += (m - y) * 14; + if (y > 1 - m) fy -= (y - (1 - m)) * 14; + + const speed = c.speed * law.speed; + const mag = Math.hypot(fx, fy); + if (mag > 1e-9) { + const turn = Math.min(1, c.turn * law.turn * dt); + vx += ((fx / mag) * speed - vx) * turn; + vy += ((fy / mag) * speed - vy) * turn; + } else { + vx *= 0.94; + vy *= 0.94; + } + + let nx = x + vx * dt; + let ny = y + vy * dt; + if (nx < 0.005) { nx = 0.005; vx = Math.abs(vx) * 0.6; } + if (nx > 0.995) { nx = 0.995; vx = -Math.abs(vx) * 0.6; } + if (ny < 0.005) { ny = 0.005; vy = Math.abs(vy) * 0.6; } + if (ny > 0.995) { ny = 0.995; vy = -Math.abs(vy) * 0.6; } + + // Metabolism. + const eaten = this.eat(nx, ny, 0.62 * dt); + let energy = e + eaten * 2.1; + const work = 0.35 + 0.65 * Math.min(1, Math.hypot(vx, vy) / Math.max(1e-6, speed)); + energy -= c.drain * dt * work; + energy = Math.min(1.7, energy); + + this.x[i] = nx; + this.y[i] = ny; + this.vx[i] = vx; + this.vy[i] = vy; + this.energy[i] = energy; + this.age[i] += dt; + + // History is written into the ground, not into a list of events. + // Ink, not fog. A body lays a line roughly its own width; if the + // deposit rate is high enough to fill the dish, the history is lost. + // The floor keeps a fast swarm from reading as invisible: every + // body leaves SOMETHING, however thin. + const deposit = (0.30 + 0.70 * c.deposit) * law.deposit * dt * 0.032; + this.depositTrail(cls, nx, ny, deposit); + + if (energy >= c.split * law.split && neighbours < 16) { + this.energy[i] = energy * 0.52; + if (this.queueAgent( + nx + this.rng.range(-0.012, 0.012), + ny + this.rng.range(-0.012, 0.012), + cls, + energy * 0.42 + )) this.birthsThisFrame++; + } else if (energy <= 0) { + this.energy[i] = 0; + this.deathsThisFrame++; + } + } + } + + compact() { + let w = 0; + for (let i = 0; i < this.count; i++) { + if (this.energy[i] <= 0) continue; + if (w !== i) { + this.x[w] = this.x[i]; + this.y[w] = this.y[i]; + this.vx[w] = this.vx[i]; + this.vy[w] = this.vy[i]; + this.energy[w] = this.energy[i]; + this.age[w] = this.age[i]; + this.cls[w] = this.cls[i]; + } + w++; + } + this.count = w; + } + + record(law) { + const h = this.history; + const f = this.frame; + if (f < 0 || f >= h.alive.length) return; + h.alive[f] = this.count; + let move = 0; + for (let i = 0; i < this.count; i++) move += Math.hypot(this.vx[i], this.vy[i]); + h.movement[f] = move; + let food = 0; + const res = this.resource; + for (let i = 0; i < res.length; i += 3) food += res[i]; + h.food[f] = (food * 3) / res.length; + h.births[f] = this.birthsThisFrame; + h.deaths[f] = this.deathsThisFrame; + } + + /** The camera's view of where something just happened. */ + activityCell(x, y) { + const ax = Math.max(0, Math.min(31, (x * 32) | 0)); + const ay = Math.max(0, Math.min(31, (y * 32) | 0)); + return this.activity[ay * 32 + ax]; + } + + stats() { + const h = this.history; + let peak = 0; + for (let i = 0; i < h.alive.length; i++) peak = Math.max(peak, h.alive[i]); + return { + alive: this.count, + peak, + fieldMass: this.sampleFieldMass(), + trailMass: this.sampleTrailMass(), + name: this.genome.name, + }; + } + + sampleFieldMass() { + let s = 0; + for (let i = 0; i < this.resource.length; i++) s += this.resource[i]; + return s / this.resource.length; + } + + sampleTrailMass() { + let s = 0; + for (let c = 0; c < 3; c++) { + const t = this.trail[c]; + for (let i = 0; i < t.length; i++) s += t[i]; + } + return s / (this.trail[0].length * 3); + } +} diff --git a/metabolism/src/song/events.js b/metabolism/src/song/events.js new file mode 100644 index 0000000..0f77664 --- /dev/null +++ b/metabolism/src/song/events.js @@ -0,0 +1,59 @@ +// Onsets become places. The song does not move the agents directly; it puts +// food down, and the agents grow on it. +// +// Three rules make the geography legible: +// - the voice picks the ring: kick near the centre, body in the middle, +// air at the rim, so a track's mix is visible as an annulus; +// - the dominant pitch picks the angle, so twelve pitch classes become +// twelve sectors and harmony becomes direction; +// - strength sets how much food and how wide, so a hit is a place and a +// fill is a region. + +const TAU = Math.PI * 2; +const RINGS = [0.24, 0.54, 0.82]; + +export function buildEvents(analysis, rng) { + const events = []; + const keys = analysis.summary.chroma ? 12 : 0; + for (const o of analysis.onsets.all) { + const f = o.frame; + const hz = analysis.dominantHz[f] || 440; + const semis = Math.round(12 * Math.log2(hz / 440)); + const pc = ((semis % 12) + 12 + 9) % 12; + // A quiet, noisy band has an unstable dominant bin. Pull the angle + // toward the event's own hash when the band is flat. + const confidence = 0.55 + 0.45 * (1 - (analysis.flatness[f] ?? 0.3)); + const angle = ((pc / (keys || 12)) * TAU) * confidence + + rng.range(-0.16, 0.16) + + (o.voice - 1) * 0.05; + const radius = RINGS[o.voice] * (0.82 + 0.36 * rng.next()); + const strength = Math.max(0, Math.min(1, 0.18 + 0.82 * o.strength)); + + events.push({ + frame: f, + t: o.t, + voice: o.voice, + strength, + x: 0.5 + Math.cos(angle) * radius * 0.5, + y: 0.5 + Math.sin(angle) * radius * 0.5, + // World units. A wide event is a region, not a point. + spread: 0.014 + 0.040 * strength * strength, + // Food units. Scaled so a dense arrangement supports roughly a + // thousand agents and a sparse one a few hundred; see the report. + amount: (10 + 34 * strength * strength) * 0.07, + // Strong events seed life as well as food. + spawn: strength > 0.62 ? (o.voice === 0 ? 3 : o.voice === 1 ? 2 : 1) : 0, + born: 0, + }); + } + return events; +} + +/** Frame -> events, so the world steps in O(events this frame). */ +export function indexEvents(events, frames) { + const index = Array.from({ length: frames }, () => []); + for (const e of events) { + if (e.frame >= 0 && e.frame < frames) index[e.frame].push(e); + } + return index; +} diff --git a/metabolism/src/song/palette.js b/metabolism/src/song/palette.js new file mode 100644 index 0000000..5377439 --- /dev/null +++ b/metabolism/src/song/palette.js @@ -0,0 +1,60 @@ +// The track's key sets the world's hue. Noise sets how far the three castes +// sit from each other on the wheel. Nothing here is a palette chosen by hand: +// two songs in the same key will still separate on saturation and on the +// contrast between the builder and the spark. + +const TAU = Math.PI * 2; + +export function hsl(h, s, l) { + h = ((h % 1) + 1) % 1; + s = Math.max(0, Math.min(1, s)); + l = Math.max(0, Math.min(1, l)); + const a = s * Math.min(l, 1 - l); + const f = (n) => { + const k = (n + h * 12) % 12; + return l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1)); + }; + return [f(0), f(8), f(4)]; +} + +const lerp = (a, b, t) => a + (b - a) * t; + +export function buildPalette(summary, rng) { + const key = summary.key ?? 0; + const noise = Math.max(0, Math.min(1, summary.flatness)); + const bright = Math.max(0, Math.min(1, summary.centroid)); + const beat = Math.max(0, Math.min(1, summary.beatStrength)); + + // Hue comes from spectral tilt: how much of the mix lives above 2 kHz. + // Bright, airy tracks land warm; bass-heavy ones land cold. It is not the + // key, because every test signal in the bank is in C and a palette that + // cannot separate them is not a palette. + const total = summary.bandBalance[0] + summary.bandBalance[1] + summary.bandBalance[2] + 1e-9; + const tilt = summary.bandBalance[2] / total; + const hue = (0.74 - 1.55 * tilt + key / 12 * 0.03 + rng.range(-0.015, 0.015) + 1) % 1; + const sat = 0.52 + 0.40 * noise + 0.08 * bright; + const lift = 0.44 + 0.07 * bright; + + // Builders take the key. Foragers sit a third away; sparks take the + // complement, because the caste that moves fastest should read as "other". + const c0 = hsl(hue, sat * 0.80, lift); + const c1 = hsl(hue + 0.10 + 0.03 * beat, sat, lift + 0.07); + const c2 = hsl(hue + 0.54 + (noise - 0.5) * 0.10, sat * 0.96, lift + 0.15); + + const bg = hsl(hue + 0.5, 0.40, 0.026 + 0.012 * bright); + const fog = hsl(hue + 0.5, 0.6, 0.075); + + return { + hue, + sat, + bg, + fog, + classes: [c0, c1, c2], + // Trails sit below the agents: the world is dimmer than what moves in it. + trails: [c0.map((v) => v * 0.55), c1.map((v) => v * 0.55), c2.map((v) => v * 0.55)], + grain: hsl(hue + 0.5, 0.2, 0.5), + ink: [0.92, 0.94, 0.96], + }; +} + +export { lerp, TAU }; diff --git a/metabolism/src/song/song.js b/metabolism/src/song/song.js new file mode 100644 index 0000000..9524155 --- /dev/null +++ b/metabolism/src/song/song.js @@ -0,0 +1,33 @@ +// The song, as a prescription for a world. One call, no rendering, no clock. +// +// events where food appears, and when +// genome what kind of creature eats it, and how they relate +// laws how the rules change at section boundaries +// palette the key, as colour +// +// Everything downstream is a pure function of this object plus a frame number. + +import { Rng } from '../rng.js'; +import { buildPalette } from './palette.js'; +import { buildEvents, indexEvents } from './events.js'; +import { deriveGenome } from './species.js'; + +export function buildSong(analysis, seed = 1) { + const rng = new Rng(seed >>> 0).fork('metabolism'); + const palette = buildPalette(analysis.summary, rng.fork('palette')); + const events = buildEvents(analysis, rng.fork('events')); + const genome = deriveGenome(analysis, rng.fork('genome')); + return { + seed: seed >>> 0, + analysis, + summary: analysis.summary, + palette, + events, + index: indexEvents(events, analysis.frames), + genome, + name: genome.name, + laws: genome.laws, + }; +} + +export { buildPalette, buildEvents, deriveGenome }; diff --git a/metabolism/src/song/species.js b/metabolism/src/song/species.js new file mode 100644 index 0000000..6e2b4c1 --- /dev/null +++ b/metabolism/src/song/species.js @@ -0,0 +1,143 @@ +// The song's species. +// +// This is the part that makes the outcomes "derived" rather than "randomised". +// The seed picks details; the track picks the kind of world. A tonal track +// makes weavers with long memories and heavy trails. A noisy track makes +// swarms: faster, jitterier, thinner trails, looser societies. A steady beat +// makes them align; a beatless one lets them drift. +// +// Every number below is a claim about a mapping, not a taste. If the mapping +// stops producing different worlds from different tracks, this file is wrong. + +const clamp01 = (x) => (x < 0 ? 0 : x > 1 ? 1 : x); +const lerp = (a, b, t) => a + (b - a) * t; + +const BASE_CLASSES = [ + { + name: 'builder', + role: 'orbit', orbit: 0.038, spin: 0.45, + speed: 0.034, turn: 1.35, sense: 0.090, size: 2.8, + deposit: 1.55, seek: 0.85, split: 1.55, drain: 0.030, seed: 0.34, + }, + { + name: 'forager', + role: 'commute', + speed: 0.082, turn: 3.00, sense: 0.105, size: 2.0, + deposit: 0.80, seek: 1.25, split: 1.10, drain: 0.042, seed: 0.44, + }, + { + name: 'spark', + role: 'orbit', orbit: 0.105, spin: 1.30, + speed: 0.135, turn: 5.20, sense: 0.070, size: 1.35, + deposit: 0.16, seek: 0.72, split: 0.95, drain: 0.055, seed: 0.22, + }, +]; + +// How a section changes the standing laws. Not a parameter ramp: the world +// changes behaviour at a boundary, which is what makes a boundary audible in +// the picture as well as in the track. +const LAW_BY_KIND = { + intro: { speed: 0.62, seek: 0.80, deposit: 1.45, split: 1.25, spawn: 0.60, turn: 0.85 }, + build: { speed: 1.00, seek: 1.15, deposit: 1.00, split: 1.00, spawn: 1.25, turn: 1.00 }, + drop: { speed: 1.55, seek: 1.35, deposit: 0.72, split: 0.62, spawn: 2.20, turn: 1.25 }, + breakdown: { speed: 0.42, seek: 0.55, deposit: 2.10, split: 1.75, spawn: 0.35, turn: 0.70 }, + sustain: { speed: 0.88, seek: 1.00, deposit: 1.12, split: 1.05, spawn: 0.85, turn: 0.95 }, + outro: { speed: 0.48, seek: 0.68, deposit: 1.70, split: 1.60, spawn: 0.25, turn: 0.75 }, +}; + +function deriveName(summary, genome) { + const register = genome.pace < 0.45 ? 'slow' : genome.pace < 0.70 ? 'measured' : 'fast'; + const grain = summary.flatness < 0.12 ? 'tonal' : summary.flatness < 0.4 ? 'grained' : 'noisy'; + const society = genome.cohesion > 0.72 ? 'weaver' : genome.cohesion > 0.5 ? 'drifter' : 'swarm'; + return `${register} ${grain} ${society}`; +} + +/** + * @param {object} analysis + * @param {import('../rng.js').Rng} rng + */ +export function deriveGenome(analysis, rng) { + const s = analysis.summary; + const noise = clamp01(s.flatness); + const bright = clamp01(s.centroid); + const dyn = clamp01(s.dynamics); + const density = clamp01(s.onsetDensity / 6); + const beat = clamp01(s.beatStrength); + const tempo = clamp01((s.bpm - 60) / 120); + + // Pace separates "has a pulse" from "has a fast clock". A drone detected + // at 160 BPM is still a drone; without a pulse it gets the slow laws. + const pace = clamp01(tempo * (0.35 + 0.65 * beat)); + + const cohesion = clamp01(0.22 + 0.50 * (1 - noise) + 0.30 * beat); + const separation = clamp01(0.25 + 0.55 * density); + const alignment = clamp01(0.10 + 0.80 * beat); + const jitter = clamp01(0.04 + 0.75 * noise); + const aggression = clamp01(0.10 + 0.50 * dyn * (0.4 + 0.6 * bright)); + + const classes = BASE_CLASSES.map((base, i) => { + const isHeavy = i === 0; + return { + ...base, + speed: base.speed * (isHeavy ? 0.40 + 0.60 * pace : 0.35 + 1.10 * pace), + turn: base.turn * (0.55 + 0.9 * jitter + 0.35 * beat) * (1 - 0.15 * cohesion), + sense: base.sense * (0.78 + 0.45 * bright), + deposit: base.deposit * (0.35 + 1.25 * cohesion), + split: base.split * (1.25 - 0.45 * density) * (0.8 + 0.3 * dyn), + seek: base.seek * (0.75 + 0.4 * bright), + drain: base.drain * 1.3 * (0.85 + 0.4 * dyn), + }; + }); + + // Society comes from whether the song's bands move together. Correlated + // bands attract; a band that lives its own life repels the others. + const interaction = s.bandCorrelation.map((row, i) => + row.map((r, j) => { + if (i === j) return 0; + const proximity = 1 - 0.25 * Math.abs(i - j); + return (r - 0.62) * 2.0 * proximity * (0.4 + 0.6 * aggression); + }) + ); + + const laws = analysis.sections.map((sec) => { + const base = LAW_BY_KIND[sec.kind] || LAW_BY_KIND.sustain; + const e = clamp01(sec.energy); + return { + start: sec.start, + end: sec.end, + kind: sec.kind, + energy: e, + speed: base.speed * (0.72 + 0.55 * e), + seek: base.seek, + deposit: base.deposit, + split: base.split, + spawn: base.spawn * (0.55 + 0.9 * e), + turn: base.turn, + hue: (sec.centroid - s.centroid) * 0.35 + (sec.kind === 'drop' ? 0.03 : 0), + }; + }); + + const genome = { + seed: rng.seed, + key: s.key, + pace, + cohesion, + separation, + alignment, + jitter, + aggression, + classes, + interaction, + laws, + // Memory: a tonal track keeps what it builds; a noisy one forgets. + trailHalfLife: 15 - 7 * noise, + fieldHalfLife: 28 - 10 * noise, + maxAgents: 2600, + seedAgents: 150 + Math.round(120 * density), + name: '', + }; + genome.name = deriveName(s, genome); + return genome; +} + +export { LAW_BY_KIND, BASE_CLASSES, clamp01, lerp }; diff --git a/metabolism/test/analysis.test.js b/metabolism/test/analysis.test.js new file mode 100644 index 0000000..14dc80a --- /dev/null +++ b/metabolism/test/analysis.test.js @@ -0,0 +1,109 @@ +// Determinism is the contract: same song, same seed, same frames -> same +// pixels. This holds even in processes started at different times, which is +// what makes preview === export possible. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Rng, hashString, hashSamples } from '../src/rng.js'; +import { Fft, hann } from '../src/audio/fft.js'; +import { analyse } from '../src/audio/analyse.js'; +import { decodeWav, mono } from '../src/audio/wav.js'; + +test('rng is seeded and repeatable', () => { + const a = new Rng(42); + const b = new Rng(42); + assert.deepEqual(Array.from({ length: 20 }, () => a.next()), Array.from({ length: 20 }, () => b.next())); + const c = new Rng(43); + assert.notDeepEqual(Array.from({ length: 20 }, () => a.next()), Array.from({ length: 20 }, () => c.next())); + assert.ok(hashString('drone') !== hashString('runner')); +}); + +test('fft finds a sine peak', () => { + const N = 2048; + const fft = new Fft(N); + const re = new Float32Array(N); + const im = new Float32Array(N); + const win = hann(N); + const freq = 440; + const sr = 44100; + for (let i = 0; i < N; i++) re[i] = Math.sin((2 * Math.PI * freq * i) / sr) * win[i]; + fft.forward(re, im); + let best = 0; + let peakBin = 0; + for (let b = 10; b < N / 2; b++) { + const m = Math.hypot(re[b], im[b]); + if (m > best) { + best = m; + peakBin = b; + } + } + // Parabolic interpolation of the peak bin. + const m0 = Math.hypot(re[peakBin - 1], im[peakBin - 1]); + const m1 = best; + const m2 = Math.hypot(re[peakBin + 1], im[peakBin + 1]); + const delta = 0.5 * (m0 - m2) / (m0 - 2 * m1 + m2); + const detected = ((peakBin + delta) / N) * sr; + assert.ok(Math.abs(detected - freq) < 1, `detected ${detected.toFixed(2)} vs 440`); +}); + +test('analysis is deterministic for the same PCM', () => { + const pcm = synth(3); + const a = analyse(pcm, 44100); + const b = analyse(pcm, 44100); + assert.deepEqual(Array.from(a.energy), Array.from(b.energy)); + assert.equal(a.summary.key, b.summary.key); + assert.ok(a.frames > 50); +}); + +test('two different signals analyse differently', () => { + const low = analyse(synth(3, 110), 44100); + const high = analyse(synth(3, 990), 44100); + assert.ok(Math.abs(low.summary.centroid - high.summary.centroid) > 0.1); + assert.ok(low.summary.flatness < 0.4, 'a pure tone is not noisy'); +}); + +test('wav decode: mono pcm round-trips', () => { + // A tiny hand-built 8-sample 16-bit mono WAV. + const sr = 8000; + const samples = [0, 16384, 32767, -16384, -32768, 0, 1000, -1000]; + const data = Buffer.alloc(44 + samples.length * 2); + data.write('RIFF', 0); + data.writeUInt32LE(36 + samples.length * 2, 4); + data.write('WAVEfmt ', 8); + data.writeUInt32LE(16, 16); + data.writeUInt16LE(1, 20); + data.writeUInt16LE(1, 22); + data.writeUInt32LE(sr, 24); + data.writeUInt32LE(sr * 2, 28); + data.writeUInt16LE(2, 32); + data.writeUInt16LE(16, 34); + data.write('data', 36); + data.writeUInt32LE(samples.length * 2, 40); + for (let i = 0; i < samples.length; i++) data.writeInt16LE(samples[i], 44 + i * 2); + const wav = decodeWav(new Uint8Array(data)); + assert.ok(wav); + assert.equal(wav.sampleRate, sr); + const arr = Array.from(mono(wav.channels)); + assert.equal(arr[2], 32767 / 32768); + assert.equal(arr[4], -32768 / 32768); +}); + +test('pcm hash is content-based and stable', () => { + // Long enough that the content stride sees both samples. + const make = (v) => { + const a = new Float32Array(3000); + a[1994] = v; // a location the stride actually visits (997 → 1994) + return a; + }; + assert.equal(hashSamples(make(0.5)), hashSamples(make(0.5))); + assert.notEqual(hashSamples(make(0.5)), hashSamples(make(-0.5))); +}); + +/** A few seconds of low/high sine, enough for a stable beat free analysis. */ +function synth(seconds, freq = 220) { + const sr = 44100; + const n = Math.floor(sr * seconds); + const out = new Float32Array(n); + for (let i = 0; i < n; i++) out[i] = Math.sin((2 * Math.PI * freq * i) / sr) * 0.5; + return out; +} \ No newline at end of file diff --git a/metabolism/test/metrics.test.js b/metabolism/test/metrics.test.js new file mode 100644 index 0000000..5ce3223 --- /dev/null +++ b/metabolism/test/metrics.test.js @@ -0,0 +1,60 @@ +// The evaluation layer must be well-behaved: every world gets a finite, +// clamped score, coupling and residual are separable, and the fitness +// discriminates between songs (in the sense that the spread across songs is +// clearly larger than the spread across seeds). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve, dirname } from 'node:path'; +import { analyse } from '../src/audio/analyse.js'; +import { decodeWav, mono } from '../src/audio/wav.js'; +import { buildSong } from '../src/song/song.js'; +import { Run } from '../src/film.js'; +import { evaluate, fingerprint, divergence } from '../src/metrics.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const BANK = resolve(HERE, '../..', 'flow-state/test/songs'); + +function grow(name, seed, seconds = 45) { + const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`))); + const song = buildSong(analyse(mono(wav.channels), wav.sampleRate), seed); + const run = new Run(song, { fps: 30, seed, width: 240, height: 135 }); + run.simulate(); + return { world: run.world, song }; +} + +test('evaluation returns finite, clamped numbers', () => { + for (const name of ['drone', 'runner', 'dusk']) { + const { world, song } = grow(name, 12345); + const m = evaluate(world, song); + assert.ok(Number.isFinite(m.coupling), `${name} coupling`); + assert.ok(Number.isFinite(m.residual), `${name} residual`); + assert.ok(Number.isFinite(m.fitness), `${name} fitness`); + assert.ok(m.coupling >= 0 && m.coupling <= 1); + assert.ok(m.residual >= 0 && m.residual <= 1); + } +}); + +test('fitness is stable across seeds of one song', () => { + const songs = ['drone', 'runner', 'dusk'].map((n) => grow(n, 12345).song); + for (const song of songs) { + const s1 = evaluate(growSong(song, 111).world, growSong(song, 111).song); + const s2 = evaluate(growSong(song, 222).world, growSong(song, 222).song); + assert.ok(Math.abs(s1.fitness - s2.fitness) < 0.05, `${song.name}: ${s1.fitness} vs ${s2.fitness}`); + } +}); + +test('fingerprint distances: songs farther than seeds', () => { + const a = fingerprint(grow('drone', 12345).world); + const b = fingerprint(grow('runner', 12345).world); + const within = divergence(a, fingerprint(grow('drone', 42).world)); + assert.ok(divergence(a, b) > within); +}); + +function growSong(song, seed) { + const run = new Run(song, { fps: 30, seed, width: 240, height: 135 }); + run.simulate(); + return { world: run.world, song }; +} \ No newline at end of file diff --git a/metabolism/test/world.test.js b/metabolism/test/world.test.js new file mode 100644 index 0000000..c980153 --- /dev/null +++ b/metabolism/test/world.test.js @@ -0,0 +1,81 @@ +// The world must be deterministic, bounded, and fundamentally unstable under +// seed change — while two DIFFERENT songs must grow clearly different worlds. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve, dirname } from 'node:path'; +import { analyse } from '../src/audio/analyse.js'; +import { decodeWav, mono } from '../src/audio/wav.js'; +import { buildSong } from '../src/song/song.js'; +import { World } from '../src/sim/world.js'; +import { Run } from '../src/film.js'; +import { fingerprint, divergence } from '../src/metrics.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const BANK = resolve(HERE, '../..', 'flow-state/test/songs'); + +function load(name) { + const wav = decodeWav(readFileSync(resolve(BANK, `${name}.wav`))); + return buildSong(analyse(mono(wav.channels), wav.sampleRate), 12345); +} + +/** Run `seconds` of the world for song `name` with a seed. Returns world. */ +function grow(name, seed, seconds = 20) { + const song = load(name); + const run = new Run(song, { fps: 30, seed, width: 240, height: 135 }); + for (let f = 0; f < Math.floor(seconds * 30); f++) { + run.world.step(); + run.camera.update(f); + } + return { world: run.world, song }; +} + +test('same seed reproduces identical history', () => { + const { world: a } = grow('drone', 12345); + const { world: b } = grow('drone', 12345); + assert.deepEqual(Array.from(a.history.alive), Array.from(b.history.alive)); + assert.deepEqual(Array.from(a.history.movement), Array.from(b.history.movement)); + assert.deepEqual(Array.from(a.trail[0]), Array.from(b.trail[0])); + assert.equal(a.frame, b.frame); +}); + +test('different seeds give different ecologies', () => { + const { world: a } = grow('drone', 12345); + const { world: b } = grow('drone', 99999); + const moved = a.history.movement.some((v, i) => Math.abs(v - b.history.movement[i]) > 1e-3); + assert.ok(moved, 'ecologies should diverge under a different seed'); +}); + +test('population stays within bounds and alive', () => { + const { world: w } = grow('runner', 12345, 30); + for (const v of w.history.alive) { + assert.ok(v <= w.cap + 40, `population exceeded cap: ${v}`); + } + assert.ok(w.history.alive.some((v) => v > 0), 'the dish should stay inhabited'); +}); + +test('field and trail never go negative', () => { + const { world: w } = grow('drone', 7, 25); + for (const v of w.resource) assert.ok(v >= -1e-9, `negative resource ${v}`); + for (let c = 0; c < 3; c++) { + for (const v of w.trail[c]) assert.ok(v >= -1e-9, `negative trail ${v}`); + } +}); + +test('trail mass reflects a busy vs sparse world', () => { + const sparse = grow('drone', 12345, 25); + const dense = grow('runner', 12345, 25); + assert.ok(dense.world.count > sparse.world.count * 1.5); +}); + +test('three songs grow fingerprints further apart than seeds of one song', () => { + const a = fingerprint(grow('drone', 12345).world); + const b = fingerprint(grow('runner', 12345).world); + const c = fingerprint(grow('dusk', 12345).world); + const cross = Math.max(divergence(a, b), divergence(a, c), divergence(b, c)); + const a2 = fingerprint(grow('drone', 99).world); + const within = divergence(a, a2); + assert.ok(cross > within * 2, `cross ${cross} !> 2×within ${within}`); +}); \ No newline at end of file diff --git a/metabolism/tools/load.js b/metabolism/tools/load.js new file mode 100644 index 0000000..163ed64 --- /dev/null +++ b/metabolism/tools/load.js @@ -0,0 +1,16 @@ +// Node-only: WAV -> analysis -> song. The browser does the same with a +// decoded AudioBuffer; this keeps the tools honest about the same pipeline. + +import { readFileSync } from 'node:fs'; +import { decodeWav, mono } from '../src/audio/wav.js'; +import { analyse } from '../src/audio/analyse.js'; +import { buildSong } from '../src/song/song.js'; + +export function loadSong(path, { fps = 30, seed = 12345 } = {}) { + const wav = decodeWav(readFileSync(path)); + if (!wav) throw new Error(`not a WAV: ${path}`); + const pcm = mono(wav.channels); + const analysis = analyse(pcm, wav.sampleRate, { fps }); + const song = buildSong(analysis, seed); + return { pcm, sampleRate: wav.sampleRate, analysis, song, name: path.split('/').pop().replace(/\.wav$/i, '') }; +} diff --git a/metabolism/tools/report.js b/metabolism/tools/report.js new file mode 100644 index 0000000..b6f6150 --- /dev/null +++ b/metabolism/tools/report.js @@ -0,0 +1,128 @@ +// The system-level report: does each episode keep time with its song +// (coupling) while still having dynamics the song does not explain +// (residual)? And do different songs — grown by different songs' laws — +// actually diverge? Outputs a table and out/report.md. + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadSong } from './load.js'; +import { Run } from '../src/film.js'; +import { evaluate, fingerprint, divergence } from '../src/metrics.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, '..'); +const BANK = resolve(ROOT, '../flow-state/test/songs'); +const SEEDS = [999, 12345, 777777]; + +function args(argv) { + const out = {}; + for (let i = 2; i < argv.length; i += 2) out[argv[i].replace(/^--/, '')] = argv[i + 1]; + return out; +} + +const opt = args(process.argv); +const names = (opt.songs || 'drone,runner,dusk').split(',').map((s) => s.trim()).filter(Boolean); +const seeds = opt.seeds ? opt.seeds.split(',').map(Number) : SEEDS; +mkdirSync(resolve(ROOT, 'out'), { recursive: true }); + +const rows = []; +for (const name of names) { + for (const seed of seeds) { + const { song } = loadSong(resolve(BANK, `${name}.wav`), { fps: 30, seed }); + const run = new Run(song, { fps: 30, width: 480, height: 270, seed }); + run.simulate(); + const world = run.world; + const m = evaluate(world, song); + const fp = fingerprint(world); + rows.push({ + song: name, + seed, + species: song.name, + peak: world.stats().peak, + pop: world.count, + sortCount: song.events.length, + hue: Number(song.palette.hue.toFixed(2)), + ...m, + fp, + }); + } +} + +const md = []; + +md.push('# metabolism — song × seed report'); +md.push(''); +md.push(`Generated ${new Date().toISOString().slice(0, 10)} · 3 test signals (${names.join(', ')}) × ${seeds.length} seeds each.`); +md.push(''); +md.push('| song | seed | species | peak | pop | coupling | residual | **fitness** | hue |'); +md.push('|---|---|---|---|---|---|---|---|---|'); +for (const r of rows) { + md.push(`| ${r.song} | ${r.seed} | ${r.species} | ${r.peak} | ${r.pop} | ${fps2(r.coupling)} | ${fps2(r.residual)} | **${fps2(r.fitness)}** | ${r.hue} |`); +} + +md.push(''); +md.push('## Grown structure (average over seeds)'); +md.push(''); +md.push('| song | occupancy | cluster | trailFill | class mix b/f/s |'); +md.push('|---|---|---|---|---|'); +const bySong = new Map(); +for (const r of rows) { + if (!bySong.has(r.song)) bySong.set(r.song, []); + bySong.get(r.song).push(r); +} +for (const name of names) { + const list = bySong.get(name) || []; + const avg = { + occupancy: (list.reduce((s, r) => s + r.fp.occupancy, 0) / list.length).toFixed(3), + cluster: (list.reduce((s, r) => s + r.fp.cluster, 0) / list.length).toFixed(2), + trailFill: (list.reduce((s, r) => s + r.fp.trailFill, 0) / list.length).toFixed(3), + mix: [0, 1, 2].map((i) => + (list.reduce((s, r) => s + r.fp.classMix[i], 0) / list.length).toFixed(2) + ).join('/'), + }; + md.push(`| ${name} | ${avg.occupancy} | ${avg.cluster} | ${avg.trailFill} | ${avg.mix} |`); +} + +md.push(''); +md.push('## Divergence (L1 distance between fingerprints)'); +md.push(''); +md.push('Across songs (seed 12345 each):'); +md.push(''); +md.push('| pair | divergence |'); +md.push('|---|---|'); +const songs = [...new Set(rows.map((r) => r.song))]; +const char = (song, seed) => rows.find((r) => r.song === song && r.seed === seed).fp; +for (let i = 0; i < songs.length; i++) { + for (let j = i + 1; j < songs.length; j++) { + const d = divergence(char(songs[i], 12345), char(songs[j], 12345)); + md.push(`| ${songs[i]} ↔ ${songs[j]} | ${fps2(d)} |`); + } +} +md.push(''); +md.push('Across seeds (drone):'); +md.push(''); +md.push('| pair | divergence |'); +md.push('|---|---|'); +for (let i = 0; i < seeds.length; i++) { + for (let j = i + 1; j < seeds.length; j++) { + const d = divergence(char('drone', seeds[i]), char('drone', seeds[j])); + md.push(`| seed ${seeds[i]} ↔ ${seeds[j]} | ${fps2(d)} |`); + } +} + +md.push(''); +md.push('_Note: coupling is correlation with the song envelope, residual is 1 − R² after'); +md.push('regressing out song features; fitness is a blend of both. See src/metrics.js._'); + +const text = md.join('\n') + '\n'; +const reportFile = resolve(ROOT, 'out', 'report.md'); +writeFileSync(reportFile, text); +writeFileSync(resolve(ROOT, 'out', 'report.json'), JSON.stringify(rows, null, 2)); + +console.log(text); +console.log('wrote ' + reportFile); + +function fps2(v) { + return v.toFixed(3).replace('-', '−'); +} \ No newline at end of file diff --git a/metabolism/tools/strip.js b/metabolism/tools/strip.js new file mode 100644 index 0000000..a4937f8 --- /dev/null +++ b/metabolism/tools/strip.js @@ -0,0 +1,73 @@ +// Render one filmstrip per song: N frames evenly across the track, tiled. +// +// node tools/strip.js --songs drone,runner,dusk +// node tools/strip.js --songs drone --cols 6 --rows 2 --w 480 --h 270 +// +// Frames come from the same Run as a full render, and the sim is advanced +// through every frame in between, so a strip cannot show a world that the +// video would not have. + +import { writeFileSync, mkdirSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadSong } from './load.js'; +import { Run } from '../src/film.js'; +import { encodePNG, tile } from '../src/render/png.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, '..'); +const BANK = resolve(ROOT, '../flow-state/test/songs'); + +function args(argv) { + const out = {}; + for (let i = 2; i < argv.length; i += 2) out[argv[i].replace(/^--/, '')] = argv[i + 1]; + return out; +} + +const opt = args(process.argv); +const names = (opt.songs || 'drone,runner,dusk').split(',').map((s) => s.trim()).filter(Boolean); +const seed = Number(opt.seed || 12345); +const cols = Number(opt.cols || 4); +const rows = Number(opt.rows || 3); +const width = Number(opt.w || 480); +const height = Number(opt.h || 270); +const fps = 30; +const everything = opt.combined === '1'; +const outDir = resolve(ROOT, opt.out || 'out'); +mkdirSync(outDir, { recursive: true }); + +const count = cols * rows; +const all = []; + +for (const name of names) { + const { song, analysis } = loadSong(resolve(BANK, `${name}.wav`), { fps, seed }); + const run = new Run(song, { fps, width, height, seed }); + const frames = []; + for (let i = 0; i < count; i++) { + const t = (0.10 + 0.85 * (i / Math.max(1, count - 1))) * analysis.duration; + frames.push(Math.min(analysis.frames - 1, Math.round(t * fps))); + } + const rendered = run.renderAt(frames); + const shots = frames.map((f) => rendered.get(f)); + const zooms = shots.map((s) => `${s.cam.zoom.toFixed(1)}@${s.shot}`).join(' '); + const sheet = tile(shots, cols, rows, 2); + const file = resolve(outDir, `strip-${name}.png`); + writeFileSync(file, encodePNG(sheet.w, sheet.h, sheet.data)); + const stats = run.world.stats(); + console.log(`${file} ${sheet.w}x${sheet.h} ${song.name} peak=${stats.peak} pop=${run.world.count} hue=${song.palette.hue.toFixed(2)} key=${song.summary.key}`); + console.log(` shots: ${zooms}`); + all.push({ name, song, shots, stats }); +} + +if (everything) { + // One row per song, same fractions of each track: the comparison sheet. + const per = 4; + const chosen = []; + for (const entry of all) { + for (let i = 0; i < per; i++) chosen.push(entry.shots[Math.round((i / (per - 1)) * (entry.shots.length - 1))]); + } + const sheet = tile(chosen, per, all.length, 3); + const file = resolve(outDir, 'strip-all.png'); + writeFileSync(file, encodePNG(sheet.w, sheet.h, sheet.data)); + console.log(`${file} ${sheet.w}x${sheet.h}`); +}