∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿
██████╗ ██╗ ██████╗ ██████╗██████╗ █████╗ ██████╗██╗ ██╗
██╔══██╗██║██╔═══██╗██╔════╝██╔══██╗██╔══██╗██╔════╝╚██╗ ██╔╝
██████╔╝██║██║ ██║██║ ██████╔╝███████║██║ ╚████╔╝
██╔══██╗██║██║ ██║██║ ██╔══██╗██╔══██║██║ ╚██╔╝
██████╔╝██║╚██████╔╝╚██████╗██║ ██║██║ ██║╚██████╗ ██║
╚═════╝ ╚═╝ ╚═════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝
███████╗███╗ ██╗ ██████╗ ██╗███╗ ██╗███████╗
██╔════╝████╗ ██║██╔════╝ ██║████╗ ██║██╔════╝
█████╗ ██╔██╗ ██║██║ ███╗██║██╔██╗ ██║█████╗
██╔══╝ ██║╚██╗██║██║ ██║██║██║╚██╗██║██╔══╝
███████╗██║ ╚████║╚██████╔╝██║██║ ╚████║███████╗
╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═╝╚═╝ ╚═══╝╚══════╝
cybernetic feedback → multispecies parliament
∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿─∿
A live audiovisual instrument and a deployable public artifact that couples three registers into a single feedback loop: a public blockchain, a deliberative assembly (the multispecies parliament), and the phenology of a tropical dry forest. Every control parameter simultaneously drives SuperCollider audio synthesis and visual modules (slots 0–9, P, F, B, E, and R) via a bidirectional OSC/WebSocket bridge.
Rather than "visualizing data," the engine performs a cybernetic coupling where the forest, blockchain protocols, and human actions hold equal standing as political agents.
Slot F · DarkForest — the forest reading itself while the chain flows. Strata after Humboldt; every binomial is a member of the parliament.
The core contribution of the BiocracyEngine lies in translating critical, decolonial, and political theory into working technical constraints in software. It stands as a concrete, deployable counter-model to Nature Fintech and "Ecological State Protocols" by compiling philosophy into executable rules rather than citing it as external authority.
- Glissant's Right to Opacity: Implemented as a software constraint. The Opacity Clause (visualized via the
opacityFloorparameter) withholds a deterministic fraction of active species labels from the projection. This clause is declared untranslatable to sound (it does not alter the SuperCollider synthesis), honoring Glissant's assertion that the subaltern must have a right to remain opaque and unconsumed by the Western gaze. - Agamben's Coming Community: Seated in the code as a parliament of singularities, never identities. The assembly does not classify species by their economic value or utility, but by their sheer presence.
- "Absence is Voice": In slot P (Phenological Calendar) and slot F (DarkForest), species that fall below the sensory detection threshold are not deleted or set to zero; instead, they persist in the background as 1-bit dither or visual shimmer. Their absence speaks as a low-level frequency, asserting that what is unmeasured still participates.
- Seasonal Benches: The membership and voting weight of the parliament's benches recompose dynamically following the seasonal cycles of the phenological calendar.
The pipeline used here is: Acoustic Sensor → Vectorization → Smart Contract.
An important architectural claim of this work is that the same sensing pipeline constitutes either surveillance or a parliament depending only on the architecture of power surrounding it. Vectorization and remote sensing are not inherently tools of extraction; they can be configured to establish local sovereignty, turning a surveillance mesh into a site of representation.
The BioToken inverts the "tokenize-the-planet" logic of carbon credits and biodiversity offsets. It is:
- A unit of political inscription (participation) rather than a tradable asset (commodity).
- A non-financialized protocol designed to register validated conservation actions and deep listening.
- A buildable counter-model to speculative "Ecological State Protocols" and Nature Fintech.
The system routes conservation value and decision-making sovereignty directly to the local, marginal community (El Balzal, Córdoba, Colombia). Data sovereignty is kept local, and the honest limits of the system—such as the dependencies and boundaries of chain-level governance—are made visible in the interface rather than hidden behind greenwashed UI templates.
Rather than using the standardized global taxonomies of the IUCN Red List as an absolute authority, the engine maps the forest's own local seasonal calendar using a 572-species inventory from the Reserva Manakai. Ecological time governs the synthesis: the seasonal weight and active-species fraction are fed back into SuperCollider to drive harmonicrich and texturedepth.
Rooted in SubAmérica and technodiversity (Yuk Hui), this project fuses Investigación-Acción Participativa (IAP, after Orlando Fals Borda) with on-chain governance. The result is delivered as a liminal research object rather than a finished artwork, making it reproducible and adaptable by other territorial communities.
The project is released across three software repositories and a community-facing field tool:
- BiocracyEngine: The core audiovisual synthesis, WebGL/Three.js projection, and MIDI/OSC bridge engine.
- bioacoustic-scripts: The python-based web3 blockchain parser and audio-vector feature extraction tools.
- dIAP (Decolonial IAP): Decentralized action research protocols and on-chain assembly tools.
- Biomap SoundWalk App: A participatory listening and conservation instrument. It turns guided soundwalks in Reserva Manakai into logged acts of ecological presence, fusing deep listening and passive acoustic monitoring (PAM) in one field tool. The app carries the incentive layer, distributing BioToken-registered rewards to the El Balzal community for validated conservation actions, closing the loop between listening, inscription, and economic sustainability.
ETH Blockchain
│
▼
eth_sonify.py (web3 Python scraper)
│ OSC → UDP:57120
▼
SuperCollider
├─ 1_server_config.scd MOTU/CoreAudio auto-detect
├─ 2_midi_control.scd Faderfox LC2 → ~buses (20 CC)
├─ 3_synthdefs.scd SynthDefs (opalKick/Perc/Drone/Dust/Bell)
├─ 4_gui.scd SC GUI (1-bit monospace) + matrix mixer
├─ 5_beat_engine.scd Evolving beat engine (TX-driven melodic pool)
├─ 6_osc_handlers.scd OSC in from HTML/bridge → ~buses
├─ 10_sample_system.scd samples/ playback + paulstretch
├─ 14_phenological_corpus.scd AudioMoth corpus on the 365-day ring
└─ audio out → MOTU 828x or CoreAudio stereo
│
│ OSC echo → UDP:3333 (~visualsDest)
▼
parliament-bridge.js (Node.js, OSC↔WebSocket)
│ UDP:3333 ← SC / MIDI echo
│ WS:3334 ↔ browser
│ HTTP:3335 /diag
│
│ SC_TO_CH path translation:
│ /soneth/* → /ch/setXxx (method-trigger)
│ /parliament/* and /agent/* → raw pass-through
│
▼
nw_wrld Electron browser (parliament.html)
│
├─ HTML sliders (34 sliders, 4 rows + Beat Engine)
│ └─ input → sendOSC → WS → bridge → SC bus
│ └─ patchStoreFromSlider → __applySonethToViz (DIAG-tracked)
│
├─ SC echo → onmessage → __applySonethToViz (DIAG-tracked)
│
└─ applySonethToViz(key, v) ─────────────────────────────────────────┐
│ │
├─ Slot 0 ParliamentStage.js (Three.js) amber + phosphor │
├─ Slot 1 AsteroidWaves (p5.js) → __slot1Soneth │
├─ Slot 2 LowEarthPoint (Three.js) │
├─ Slot 3 PerlinBlob (p5.js) → __slot3Soneth │
├─ Slot 4 TimeTravel (p5.js) → __slot4Soneth │
├─ Slot 5 DynamicGraphs (p5.js) → __slot5Soneth │
├─ Slot 6 DynamicOptimality (p5.js) → __slot6Soneth │
├─ Slot 7 Geometry (p5.js) → __slot7Soneth │
├─ Slot 8 MemoryHierarchy (p5.js) → __slot8Soneth │
├─ Slot 9 Hashing (p5.js) → __slot9Soneth │
├─ Slot P PhenologicalCalendar (Three.js · fetched module) │
├─ Slot F DarkForest (Three.js · fetched module) │
├─ Slot B Transito (Three.js · fetched module) │
│ └─ reverse: throughput → /soneth/drone* → bridge → SC │
├─ Slot E Estratos (Three.js · fetched module) │
│ └─ forward: __phenoParams → bancada pins the strata, │
│ activityThreshold → population, opacityFloor + │
│ seasonalWeight → which species are present today │
├─ Slot R Registro (canvas · pretext ASCII field × slot 6) │
│ └─ reverse: buffer → /soneth/memoryfeed, consensus → │
│ atmospheremix │
└─ Slot A Antifonía (Three.js · fetched module) ───────┘
├─ forward: /tide/state → chorus density, votes → the room
│ speaks, __ednaBio → per-stratum weight
├─ events: calls → /antifonia/call → SC picks the recording
└─ reverse: chorus → /soneth/texturedepth, spread →
spatialspread, machine share → noiselevel
MIDI (Faderfox Micromodul LC2) ──► SC buses ──► OSC echo ──► bridge ──► browser
The six data-structure slots were flat diagrams on orthographic cameras that read control values and never the sound. They are now the six voices of the engine, one each and no repeats — the instrument laid out across six screens:
| Slot | Instrument | SC voice | Register |
|---|---|---|---|
| 4 | DRONE | \opalDrone |
the sustained bed |
| 5 | CAMPANAS | \elektronBell |
pads |
| 6 | PERCUSIÓN | \opalPerc |
pulse |
| 7 | BOMBO | \opalKick |
sub |
| 8 | POLVO | \opalDust |
granular |
| 9 | MUESTRAS | samplePlayer* |
field recordings |
Each has a perspective camera the viewer can orbit, real depth in its geometry (the drone's traces recede by age, the bell lattice breathes on Z, the tree stands in layers, the kick radiates as a pressure front, the cache is a stack you could walk into, the hash table is a ring), and the same idle drift as every other slot.
The instrument's name used to be drawn into the scene as a sprite floating over each one. That is gone. The name was a caption on a projection surface — the one element in six otherwise wordless slots that addressed the viewer instead of the room, and it sat in the same upper third the performance projects into. The binding it announced is the real one and it survives untouched: each slot still reads its own band of the spectrum and its own voice's onsets, per the table above.
And now they play it. Each of the six was a listener: bound to one voice, reading that voice's band and onset, drawing what it heard. They also speak now, from their own structural events — the screen plays the instrument:
| Slot | Voice | The structure's own event |
|---|---|---|
| 4 | DRONE | the reticule completes a full sweep → a sustained partial joins over the bed |
| 5 | CAMPANAS | an edge forms — two nodes that were not connected now are |
| 6 | PERCUSIÓN | a node arrives at its target — a rebalance has actually completed |
| 7 | BOMBO | a target is acquired — a ray crosses a sweep |
| 8 | POLVO | a layer overflows its own level — blocks spilling past the edge |
| 9 | MUESTRAS | a hash collision — and which bucket collided picks the recording |
A slot plays the engine's voice, not a new instrument. The first version made
them separate: pitches from independent linear maps, envelopes from literals, spawns
unbundled. They did not blend, and one of them did not move at all — \elektronBell
clamps its fundamental to 28–180 Hz (3_synthdefs.scd:241), so a linear map to MIDI
48–84 crossed the ceiling at tone 0.15 and 85 % of the range played one identical
pitch. Now:
- the pad snaps to the semitone grid and octave-folds below 160 Hz the way the ETH
pads do, and is queued on
~padQueueso the drain gives it\polyComp— spawning direct made it up to 4.9× louder than a concurrent engine pad and corrupted the engine's own1/√ncompensation by staying invisible to~padLive; - the perc draws from
~computePitchPool×~speciesBand, the engine's own mode, rather than a continuous sweep that touched those notes by coincidence; - the kick walks the engine's seven discrete steps (45.5–54.5 Hz) and rings for its 0.9–1.3 s rather than clicking for 0.28;
- everything spawns inside
s.makeBundle(s.latency, …), as every engine voice does.
Slot 4 adds a partial; it does not re-pitch the bed. It used to call
~opalDroneSynth.set(\freq, …) — a second owner of a node the beat engine walks every
four bars, with no arbitration (\drone is the one voice the engine never stamps into
~lastVoiceAt). On a 4-second glide (droneFade × 2) the bed spent most of its life in
transit and never arrived. It now spawns its own low-amplitude \opalDrone, capped at
two concurrent and released after nine seconds. One owner each.
A slot speaks on an EXCURSION, not on a change. These counts jitter every
frame — slot 6 adds Math.random() to node positions on the line after it counts
which nodes have arrived, so "arrived" is frame noise by construction. A naive
"has it risen since last time?" is therefore true whenever the rate gate reopens,
and the gate stops being a limit and becomes the clock: measured, slot 6 fired
8×/s (a vibration) and slot 7 at a dead-steady ~83 BPM (a drum machine). Neither
was the structure speaking; both were the rate limiter. Each slot now runs a
Schmitt trigger on a slow baseline — the measure has to rise ~35% above what it
has lately been doing, and come back down before it can speak again. Measured
after: 0.05–1.35 onsets/s, irregular.
The slot does not decide whether the note happens. The beat engine already
owns when kick, perc and dust speak, and the ETH handler owns the bell; a slot
deciding the same thing would be a second owner of one rule, which is the
failure this codebase keeps having to undo — the seven /rhythm/ toggles that
were removed for it, the tide exclusivity enforced in exactly one place.
So a slot requests, on /slot/voice [voiceIdx, amp, tone], and
15_slot_voices.scd decides. Both the engine and the scheduler stamp one shared
onset clock, ~lastVoiceAt, and a request landing inside a voice's minimum gap
is dropped rather than layered. A slot can therefore only speak where the engine
has left room — the pulse stays the engine's, the punctuation is the slot's.
Measured: a runaway emitter at 100 requests/second is capped to 13.7 onsets/s on
dust, and a slot asking for a kick immediately after the engine fired one is
refused.
Gaps are set by what the voice is for, not by taste: drone 6 s (a re-pitch
is structural), dust 0.07 s (granular, it should be able to swarm), sample
1.6 s (these are 30-second field recordings, and two a second is a collage).
/slot/voices/enable 0 puts all six back to listening without unmounting them.
The trigger never comes from audio. A slot firing its own voice from its own band energy is a feedback loop — it would play because it is playing. Every emitter is driven by the simulation, which is also the whole point.
They react to the sound, not to the intention. \masterScope analyses the
master bus after the limiter and sends 16 log-spaced bands at 20 Hz — that had
been arriving all along with nothing listening, so the spectrogram was running on
its synthetic fallback. It now feeds window.__scAudio, and SC additionally
broadcasts /voice/* at the moment each note starts. Energy in a band tells you
a bell is ringing; the onset tells you it was struck, and without it every
visual is late and smeared.
Each slot reads its own register, normalised against its own recent peak — a kick visual must not brighten because a bell rang, and measured on a live engine the low band runs ~40× hotter than the high one, so a raw reading leaves the treble slots looking dead while they work.
The slider reaches all sixteen slots now. It reached exactly one before — the phenological calendar, where it sets the year-sweep rate, not any rotation.
src/projector/vizMotion.ts publishes window.__vizMotion (mutated in place,
like __ednaBio): { rotation, idle, factor, speed, angle, t }. Interaction is
captured once at the document — pointerdown, wheel, keydown, input, in
capture phase — so both the control panel and a camera drag reset the clock,
with no per-module wiring. After 8 s idle the drift eases in over 4 s
(smoothstep, so it neither starts nor settles with a corner) and reaches roughly
one turn every three minutes at rotation = 1.0.
The seven OrbitControls slots need nothing of their own: there is exactly one
new OrbitControls in the tree, so helpers/threeBase.ts enables autoRotate
and feeds autoRotateSpeed from the shared value on a 200 ms timer. That same
change removed the "change" → render listener: with damping on it fired
every update(), so every one of those slots was rendering the same frame
twice.
The nine remaining slots each got an idiom rather than a literal spin — a flat chart that slowly tilts reads as broken. Slot 1 drifts the phase of its noise field; slots 4 and 7 add to their radar sweep; 5 and 6 precess; 8 leans like a settling shelf; 9 precesses its bucket ring.
Votes reached 9 slots and missed 7 (2, 4–9). Consensus was dead in 5: slot 1
ignored it, slot 8 never received it, the calendar had no path at all, and
DarkForest and Antifonía wrote it into a coherence field no render path read.
Each now has a reaction in its own vocabulary — a radar ping, an edge cascade, a
forced rebalance, a flush wave down the hierarchy, a forced rehash, a ripple
through the point cloud; consensus becomes wave alignment, cache coherence,
phenological quorum, flow straightness, chorus synchrony.
"failed" was handled in eight places and produced in none. SC reports real
outcomes on /parliament/vote/result, and parliamentStore already ingested
them — the result simply never reached __voteEvent. It does now, so a rejected
motion looks different from a carried one.
Every parameter change is reflected across all three control surfaces:
HTML slider ──► SC bus ──► SC GUI knob (visual update)
└──► OSC echo ──► HTML slider (position sync)
└──► applySonethToViz (12 slots)
MIDI CC ────► SC bus ──► SC GUI knob (visual update)
└──► OSC echo ──► HTML slider (position sync)
└──► applySonethToViz (12 slots)
SC GUI knob ► SC bus ──► OSC echo ──► HTML slider (position sync)
└──► applySonethToViz (12 slots)
Four processes are managed by start_ecosystem.sh:
| App | Process | Ports | Role |
|---|---|---|---|
| Python ETH | eth_sonify.py (venv) |
→ UDP 57120 | web3 scraper; per-tx /eth/note + /eth/tx_info, per-block /eth/block |
| SuperCollider | sclang start_sonification.scd |
in 57120 (OSC) + MIDI; out 3333; scsynth 57110 | audio engine, GUI, beat engine, drone, master limiter |
| Bridge | parliament-bridge.js (Node) |
in UDP 3333; WS 3334; out UDP 57120; HTTP 3335 /diag |
OSC ↔ WebSocket, path translation |
| Browser | webpack-dev-server + Electron | HTTP 9001; WS 3334 | parliament.html GUI, store, visual slots |
| Laser (opt) | laser-bridge.js (Node, LASER=1) |
WS 3337 in; USB → Helios DAC | vector frames → ILDA / laser onto the forest |
10 core parameters × 10 visual slots = 100 bindings. Every slider/knob/CC drives both SC audio buses and all visualizations simultaneously. 32 parameters in total, all generated from one registry entry each (~paramDefs in 0_parameters.scd).
| Param | MIDI CC | SC Audio | Slot 0 Parliament | Slot 1 Asteroid | Slot 2 LowEarth | Slot 3 Perlin |
|---|---|---|---|---|---|---|
| volume | CC 0 | master volume | pt light intensity | wave stroke alpha | white cloud opacity | stroke opacity |
| pitchShift | CC 1 | freq ±2 oct | species Z amplitude | lane X offset | cloud Y-stretch | noise intensity |
| timeDilation | CC 2 | env stretch ×0.5–6 | orbit speed | noise X zoom | rotation damping | cycle frames |
| spectralShift | CC 3 | LPF sweep 80–3000 Hz | bloom threshold | amber-cyan tint | line hue shift | layer compression |
| spatialSpread | CC 4 | quad pan L↔R | camera distance | lane spread | lines XY spread | blob X/Y offset |
| textureDepth | CC 32 | granular density | film grain | grid line density | point size | stroke weight |
| atmosphereMix | CC 33 | reverb 0–0.9 | afterimage damp | background ghosting | red cloud opacity | layer count |
| memoryFeed | CC 34 | delay feedback 0–0.8 | bloom strength | ghost trail alpha | red lines opacity | ghost alpha |
| harmonicRich | CC 35 | FM ratio 0.1–8 | lissajous complexity | harmonic overlay | Bézier Z-scale | hue drift |
| resonantBody | CC 36 | filter Q 0.1–0.8 | chroma aberration | peak dot glow | red cloud scale | inner weight |
| Param | MIDI CC | SC Audio |
|---|---|---|
| masterAmp | CC 5 | layer trim — pads, drone and beat engine from one control |
| filterCutoff | CC 6 | broad tone tilt, under spectralShift's absolute setting |
| noiseLevel | CC 7 | pink-noise breath under the pad |
| noiseFilt | CC 8 | noise LPF 200–2000 Hz |
| droneDepth | CC 9 | how far the sub body sinks |
| droneFade | CC 37 | glide time on the drone's own controls — including its pitch (see below) |
| droneSpace | CC 38 | reverb room size |
| droneMix | CC 39 | dry drone ↔ fully bloomed (wash + sub) |
| delayFeedback | CC 40 | comb delay feedback |
| transactionInfluence | CC 41 | how far chain activity bends the engine |
Six of these (CC 5, 6, 9, 37, 38, 39) previously wrote to control buses that no UGen read —
\opalDronedid not declare them and\elektronBellread them into variables it discarded. They now shape the drone.
The drone glides between pitches. Every four bars the beat engine walks
#[55, 62, 73, 82, 49, 65, 55, 41]Hz on the phrase counter — about every 45 to 50 seconds at the usual tempo — and it did it with a bare.set(\freq, …).freqwas the one parameter in\opalDronewithout a lag, so a voice that had been holding a note for most of a minute stepped a fifth or a sixth instantly, which on a continuous drone reads as a fault rather than a change. It now ridesdroneFadelike every other continuous control in that SynthDef, doubled — a pitch move needs noticeably longer than a filter move to stop sounding like an edit. At the 2 s default that is a 4 s portamento; the top of the fader takes it to 10.
14_phenological_corpus.scd plays 261 AudioMoth clips from La Luna / Planeta
Rica across the phenological ring of Article 42. Only 34 of 365 days carry a
recording; the other 331 are silence, and under Article 44 that silence is
the piece's dominant material, never interpolated.
| Param | MIDI CC | SC Audio |
|---|---|---|
| activityThreshold | CC 10 | Art. 45 — presence above 0.5 lights the seat; below it the species is in the territory but silent in the Chamber |
| windowWidth | CC 11 | Art. 43 — Gaussian reach in ring days. 0.4 leaves recordings isolated points in silence; 2.5 lets a real day be heard from across a gap (it never invents one) |
| seasonalBias | CC 12 | pulls selection toward Seca (−1) or lluvias (+1) independently of the cursor |
| absenceWeight | CC 13 | Art. 44 — 0 leaves unrecorded days truly silent; above it they sound the ×8 ultrasonic layer, so what fills the silence is what human hearing cannot reach |
| pulseGain | CC 14 | Art. 45 — how hard quórum sensible pushes back into harmonicRich, textureDepth and /bio/consensus |
| opacityFloor | CC 15 | Art. 47 — raising it withholds more of the corpus from analysis, projection and the laser |
| bancada | CC 16 | Art. 43 — 0 = todas, then the detector's four ecological roles |
| phenoRate | CC 21 | ring speed in days/second. Default 0.0167 = one day per minute = a 6 h 05 m year; full range 91 s → 48 h |
| corpusLevel | CC 22 | the field recordings against the synthesis |
Before this layer, five of these buses (
windowWidth,seasonalBias,absenceWeight,pulseGain,opacityFloor) were allocated and reachable by MIDI and OSC but had zero readers in SuperCollider. The corpus is what they were built for.
The ring answers while the day is still running. Every control in this row
is consulted in one place — ~phenoPool, called once per phenological day —
and the ring used to sleep out the whole day in a single wait. At the default
rate that is sixty seconds between turning a knob and hearing it, and eight
minutes at the slow end, so the entire bench read as unwired. The same fault hit
the transport from the other side: NEXT REC. DAY ▶ set the cursor correctly
and the routine slept through it (the log shows two skip -> doy 211 a few
seconds apart and the day itself arriving much later).
The ring still turns at phenoRate. What changed is that the wait is sliced
(0.25 s), and on each slice the Chamber re-asks who is admitted today:
- skip requests are honoured immediately —
/pheno/next,/pheno/gotoand the button wake the ring and release what is sounding, so the jump is audible instead of buried under a clip with fifty seconds left to run; - selection is re-decided twice a second, diffed by clip key, so a knob sweep starts only what has genuinely just crossed the threshold and stops only what has fallen below it — nothing retriggers while you drag;
pulseGainis picked up on a deadband rather than once a day, so the reverse breath follows the fader without fighting the performer's ownharmonicRich.
Releasing a corpus voice needs a negative gate. Both corpus envelopes are
Env.new([0,1,1,0], …)— fixed length, no release node — and for those EnvGen treatsgateas a pure trigger:.set(\gate, 0)does nothing at all and the clip plays out its full atk+hold+rel. The forced release isgate < 0, over-1.0 - gateseconds.~phenoPanichad always used a zero gate, which is why/pheno/stopstopped the ring clock and left every voice sounding.
Two playback paths, because 384 kHz is not optional. A 60 s AudioMoth clip is 92 MB as a server Buffer — the corpus would be 24 GB resident. Nothing reads the originals at run time; two derived tiers carry the layer:
corpus/audible/(48 kHz) feeds a fixed pool of 16 RAM slots recycled by the ring's look-ahead. Nothing is allocated at trigger time.corpus/expanded/(×8 time-expanded) is streamed withDiskInfor the absence voice — 4 cue buffers, ~2 MB.
Resident cost ≈ 230 MB, so memSize and numBuffers are unchanged.
Why the expansion is baked offline.
DiskInperforms no sample-rate conversion, so pointing it at a raw 384 kHz file at a 48 kHz server expands ×8 for free — a tempting trick, and wrong. It drops everything three octaves, so the loud audible band lands at 125 Hz–2.5 kHz and buries the ultrasound it was meant to reveal. The renderer high-passes at 38 kHz (24 dB/oct) before expanding, so only what was genuinely inaudible arrives, at 4.75–24 kHz.
Gain staging. ~trimCorpus = 2.60 × ~trimMaster, measured rather than
guessed. The seven MP3s in samples/ average −22.2 dB mean; corpus/audible/
averages −21.3 dB after the single global gain — within ~1 dB, so parity of
trim is parity of loudness. Since corpusLevel sits in this layer's path and
defaults to 0.5, the trim compensates: the layer lands within ~1 dB of the
sample layer at the fader's default and ~6 dB below the drone bed, leaving the
top half of the fader as real headroom.
The build applies one global gain across the whole corpus, never per-clip
normalisation — a quiet dry-season night has to stay quiet against a rainy
insect chorus, since activity and richness are exactly the signal per-clip
normalisation would flatten.
Build the derived library (~4.2 GB, one-off) with:
python3 tools/build_corpus.py --dry-run # counts and projected sizes
python3 tools/build_corpus.py # renders + writes corpus/manifest.jsonRing transport: /pheno/goto <doy>, /pheno/next, /pheno/stop, /pheno/start.
The ring opens on a recorded day. Only 34 of 365 days carry audio and the first is doy 9, so starting the cursor at doy 1 meant the instrument began with eight minutes of nothing — and since the two arcs are separated by gaps of 178 and 131 days, it can then be silent for up to three hours at the default rate. Absence is the material (Art. 44), but it should be arrived at, not booted into.
/pheno/nextand the NEXT REC. DAY ▶ button skip to the next day that actually has audio.
Browser-only sliders (no MIDI CC), five species × two controls, taken from the
live IUCN roster. For as long as they existed they emitted /agents/species/*
into UDP 57120 where no OSCdef received them — the bridge's /diag showed
30 messages sent and nothing returning — so FREQ read a hardcoded 440Hz and
VOT a hardcoded 0 for every session.
| Control | Emits | SC effect |
|---|---|---|
| Species Activity (×5) | /agents/species/activity [id, v] |
weights how often that seat is picked for a percussion hit |
| Species Presence (×5) | /agents/species/presence [id, v] |
how loudly the seat speaks, and it owns a register |
| eDNA Biodiversity | /agents/edna/biodiversity [id, v] |
site reading; echoed back with a decaying validation |
The corpus cannot carry taxonomy — it is indexed by ecological role, which is why Article 43's bancadas are labelled by role. So a species becomes audible in the percussion layer instead, where a pitch pool and a trigger already exist. The division of labour is deliberate: the pool still chooses the degree and the seat only chooses the register. One species does not get to overwrite the melody; it gets to say which octave the chamber hears it in.
~speciesBand is [1.0, 1.33, 1.78, 2.37, 3.16] — ~5-semitone steps, upward
only, seat 0 at unity. Measured against the real pool rather than guessed: a
symmetric set around 1.0 put the lower seats under the 40 Hz floor \opalPerc
enforces, and at the bottom of the harmonicRich fader two or three of them
collapsed onto 40 Hz and became the same voice (21 clipped notes, adjacent-seat
ratio 1.00 — identical). Upward-only clips nothing and holds a full 1.33 between
seats across the whole fader range, topping out near 780 Hz. Unity at seat 0
means the layer's original register is not lost, just assigned to the first
seat — which, at the default presences, is also the most likely pick.
A species votes by sounding. ~speciesVotes[i] increments at the moment of
the hit, and the seat reports back on
/agent/species/state [id, presence, activity, votes, freq] from the engine's
existing throttled broadcast. parliamentStore.ts has parsed that message, in
exactly that argument order, since it was written — it simply had no emitter.
The panel's formula was static text and it disagreed with the code it described:
it read Presence × Duration where bioTokenTerms() has always multiplied by
activity, and printed IUCN as the raw ×5 multiplier while the factor applied
is that over 5. Two of its six factors were frozen constants left behind when
the Fungi Networks and Gaia AI Core panels were removed. Every term now carries
its live value beside it:
| Term | Source |
|---|---|
| Presence | mean of species[].presence |
| Activity | mean of species[].activity — relabelled from "Duration" to match the code |
| eDNA.biodiv | mean over the surfaced sites only — it averaged all eight while only Córdoba has a fader, so seven frozen 0.5s permanently damped the token |
| Fungi.chem | ← /bio/nutrient, the mycelial pulse the Eco panel already shows |
| AI.optim | ← /bio/density, transaction density |
| IUCN.weight | max(IUCN_MULT) / 5, shown normalised |
| Param | MIDI CC | Layer |
|---|---|---|
| mixDrone | CC 42 | \opalDrone — the continuous bed |
| mixPad | CC 43 | \elektronBell |
| mixKick | CC 44 | \opalKick |
| mixPerc | CC 45 | \opalPerc |
| mixDust | CC 46 | \opalDust |
| mixSample | CC 47 | samples/ via \samplePlayer* |
| mixCorpus | CC 48 | the AudioMoth audible layer |
| mixUltra | CC 49 | the ×8 absence voice |
Every other control on the surface shapes timbre. Before this row, the
balance between layers lived only in the hardcoded gain budget of
3_synthdefs.scd (~trimDrone, ~trimPad, …), fixed at load and unreachable
while playing — so the instrument could not be mixed.
Unity is 1.0 at mid-throw: at boot these multiply by exactly 1 and the
engine sounds as it did before. 0 is a true mute, 2.0 is +6 dB. They
multiply the trims rather than replacing them, so the documented gain budget
stays meaningful. Measured on the drone layer: unity 0.0262 RMS, 0.5 → 0.0132
(−6 dB), 2.0 → 0.0531 (+6 dB), 0 → silence.
The SC GUI strips carry a MUTE that remembers the fader position, so
unmuting restores the exact level. Mixer faders appear on both surfaces and
follow MIDI, browser and preset loads through the same ~setParam path as
every other control.
Antiphony is alternating song between groups: a real bioacoustic phenomenon (duetting) and the oldest form of parliament, speaking in turns. Each sound source is a member taking the floor, and one session lasts a day.
The vertical axis is height. It reuses the same Humboldt strata that order DarkForest [F] and Estratos [E] — whoever sings, sings from a height: the howler from the emergent crowns, the frog from the understory, the bat crossing the canopy. A call at 20 m lands in the canopy in all three slots, and a check asserts the stack has not drifted apart between them.
The stand is a simulated LiDAR sweep, not scenery: one ceiba (Ceiba
pentandra) with a clean bole and a flat tiered crown, campanos (Albizia
saman) in umbrella domes wider than they are tall, ~50 ordinary dry-forest
canopy trees, and exactly two wine palms (Attalea butyracea) — ~57 trees,
counted at runtime and published on window.__antifoniaStand. The exact
composition shifts when anything upstream changes how many numbers the seeded
generator has drawn, which is why it is counted and asserted rather than
declared: that is how a change to the ceiba silently took the palms to zero
once already. They are sown,
not placed: regeneration nuclei scattered through the lobe, cohorts crowding
inward, and a minimum-exclusion test so no two crowns occupy the same cubic
metre. A hand-written list of positions read as a maquette — even spacing, and
the ceiba alone in a clearing nobody planted. It now stands off-centre with its
retinue touching it, because an emergent lives surrounded. An
aerial flight is simulated (canopy returns strongly, ground moderately, vertical
boles barely), because that asymmetry is what makes an aerial cloud look the way
it does. The ground boundary is amorphous — polar sampling with an angular
lobe, thinned at the rim so the plot fades out instead of ending on a cartesian
edge.
The ceiba's crown is asymmetric, and that is load-bearing. Its tiers were built as wheels — N branches at exact angular steps, all the same length, all concentric on the axis. From above, a radar sweep; from the front, five concentric discs. No emergent looks like that: a forty-metre ceiba has lost limbs, the ones left are of very different lengths, and each tier leans toward the light it found. Every branch is now described before it is sown — irregular angular step, its own length, its own droop, its own curve in plan, and a one-in- six chance it is simply missing — and points are distributed by branch length, because distributing them per branch would make a short limb as dense as one twice its size, which is the same symmetry wearing a disguise. Measured on the points rather than on the source: the old crown reached 0.74–0.93 R in all 24 azimuth sectors (CV 0.06); it now reaches 0.00–1.09 R (CV 0.41), with sky through the gaps.
The cloud is deliberately sparse and small-pointed: not a survey, but what
the machine manages to see of the forest — a spectral presence rather than a
model. Seeded, so the stand is identical every boot. Six THREE.Points, one per
stratum, shuffled at build so the adaptive LOD can trim the draw range into a
uniform subsample without regenerating anything. The wind sways each stratum
(more with height, driven by the geophony bench) by moving six positions per
frame — not one vertex is touched. Measured at 8.3 ms median, the same as
DarkForest and Estratos.
Each call lights the stratum it came from, so the forest is the body that speaks rather than the backdrop it speaks in front of.
Every call is a Japanese candlestick — the one from a trading chart. Thin wick from high to low, thick body where the energy sits, filled if it closed above the previous call of its own species and dimmed if below. The "price" is frequency. This is not a visual joke: the engine already sonifies a blockchain, and putting the forest into the same instrument a currency is quoted with says out loud what the whole apparatus does — try to measure nature in real time, with the wrong tool, leaving the seam visible.
The candles are immersed. The fauna are drawn as LiDAR returns like
everything else — denser clusters of the same white phosphor, not painted
silhouettes: every mark in this scene comes from the same scan. The howler sings
from a howler (Alouatta seniculus) moving through the ceiba's branches —
one animal, not two. Two of the same size moving through the same crown read as
a matched pair, which is a decorative relation; one is a presence. It is drawn
at 0.85 of the size it was, and because PointsMaterial attenuates by distance
and not by object transform, shrinking the animal does not shrink its
returns: a smaller cluster of the same dots, which is what a real scan would
give. — body, head and
prehensile tail, pausing long between moves as the animal does. They can only
be in the ceiba, because it is the one emergent: the same confinement that
already governed the call, now visible. Other canopy sources sing from a bird
actually crossing the stand — seven of them fly at canopy and emergent height, wings
beating, and the sky empties outside their hours. First you see who is
speaking, then what they said. Failing a bird, the call takes a perch: a
tree in this stand tall enough to reach its stratum. The howler can only be in
the ceiba, because it is the one emergent. The aircraft has no perch — it is in
the atmosphere, which is what it is. The strip along the bottom draws the same
reading as a full chart, time on x and frequency on y.
The suelo has inhabitants now: a paujil piquiazul (Crax alberti, CR endemic) walks between the boles rather than flying, and a file of leafcutter ants (Atta cephalotes) crosses from nest to tree. Both have voices — the paujil a deep boom in the register where the kick lives, the ants a faint high stridulation. Atta farm fungus, so the file lights the mycelium it passes over: the two elements are one system rather than two decorations.
Mycelium runs under the ground and keeps going past the plot and out of frame on every side. That it leaves is the claim, not a framing slip: the network does not recognise the parcel boundary or the viewport. The unit the eye thinks it is looking at — this stand, this rectangle — is an administrative cut across something continuous. The forest above can be framed; the one below cannot. It is split into seven sub-networks, each keyed to its own band of the live master spectrum, so different paths light with different parts of the sound and the net reads as carrying traffic rather than breathing as one body — the "pulse" it had before was a free-running sine tied to nothing. Kick and dust onsets give the flashes, band energy the sustain. Seven opacity writes per frame; a per-vertex update would be ~200 KB/frame, 350× the bird and howler systems combined.
Frequency does not fight for that axis. Each call is a glyph whose length is its bandwidth; the spectrum reads as morphology. A separate strip along the bottom carries time on x and log frequency on y — the acoustic niche, species partitioning bands and hours so as not to mask one another.
Three benches, not one. Biophony, geophony, and anthropophony. The machine
is not an intruder in this chamber; it is the third bench, and its noise grows
into ambient through the deep-listening transitions instead of sitting beside
them. When the tide rises the forest speaks; when it falls, the machine holds
the air. That inversion is literal: anthropophony's spawn weight is driven by
(1 - tide).
It really sounds — and now with the forest's own voice. A call goes out on
/antifonia/call, and SuperCollider chooses the recording (16_corpus_calls.scd).
Twelve sources shared seven MP3s: four species split the aves bed alone, and
LLUVIA and VIENTO carried smp: -1, drawn on screen and never sounding at all.
The corpus built from the AudioMoth survey holds 261 clips of the actual site,
and slot A could not reach any of it — the ring in 14_phenological_corpus.scd
plays that material on the 365-day calendar, which is a calendar and not a call.
The bank now carries 116 two-second grains (already cut by build_corpus.py
from each ring day's highest-confidence events) and six geophony stems, so
rain and wind finally have a recording. ≈114 MB resident.
The species→role map lives in SuperCollider, because the corpus carries ecological roles and no taxonomy — that is the same fact that makes Article 43's bancadas role-labelled. A call therefore says who is speaking and at what hour; SC decides which recording answers:
| Source | answers from |
|---|---|
| aullador · rana · murciélago | nocturnal_voice (the bat weighted toward clips that carry ultrasound) |
| chicharra · arriera | insect_chorus |
| aves · oropéndola · paujil | dusk_ / dawn_chorus_participant |
| lluvia · viento | geophony stems |
| avión · cinta | their MP3s — the corpus has no anthropophony to offer |
Selection weights confidence against hour proximity on a 24-hour ring, through a Gaussian of σ ≈ 3 h, about the width of a dawn chorus. A linear falloff was tried first and does not work: it spans only 5× across the whole clock, and the corpus is so nocturnal that the mass of far clips outvoted the near ones — a call at 20 h still drew a median grain from 03 h. The Gaussian gives ~8×, which is the difference between a preference and a rounding error.
Article 47 is enforced before the clip is chosen and again in the voice.
opacityFloor (CC 15) filters the eligible pool exactly as it does for the ring,
and samplePlayer* now carries the same veil \corpusVoice has always had —
that SynthDef had none, so routing recorded material through it would have
sounded what the Chamber had withheld. Measured: floor 0 admits 114 of 122 corpus
entries, 0.5 admits 73, 0.8 admits 23.
A call opens a window into the recording rather than truncating it. Five of
the seven MP3s are 51–360 s soundscape beds, not isolated calls, so a call's
duration shapes an envelope — attack, hold, release — over an excerpt taken from
a varying offset. Previously the duration was a hard .free, which cut a
51-second howler after 3% of itself with no release at all: a broadband click on
every call, and because the reverb and delay live inside the voice, the acoustic
space vanished with it.
The envelope now has to fit the recording. Its span is atk+hold+rel ≈ 2.15 × the hold, and that span was never computed anywhere — survivable while every file ran 51–360 s, wrong the moment a 2-second grain arrived: it became two seconds of forest followed by eight of silence holding one of twelve voices. The span is built explicitly now and scaled to the material when it overruns. Which also makes true, at last, what this section always claimed: the two short MP3s (ranas 4.9 s, oropéndola 6.2 s) are heard whole. They were not — a 10.75 s envelope over a 6.2 s file ran off its own end.
Only biophony is published to __activeSpecies: rain is not a species and
neither is an aircraft, and that field feeds the parliament's living census and
the laser's opacity clause.
Inspired by AveRosetta™ (NeotropicalScience), a forest-communication visualizer crossing a LiDAR cloud with annotated calls. No AveRosetta code or data is used here; the debt is conceptual and is credited on screen.
Real annotations drop in at assets/json/antifonia_calls.json (schema in the
module header); absent that, the session is generated from the source table.
The rhythm is not a grid. There is no step pattern deciding what sounds; a slow
swell decides how likely any onset is, and events are placed by probability
with ±45% of a tick of jitter so nothing lands on an audible pulse. The kick can
only occur where the chain itself has a seam — a new block — and even there only
with probability tide², so the low end is present at the crest and absent
through the trough.
The arc is measured in blocks, not seconds, so it stays locked to the chain's own cadence rather than drifting against it when the network speeds up or stalls.
| Control | OSC | MIDI CC | Effect |
|---|---|---|---|
| ARCO CORTO | /tide/short |
CC 17 | ~3.5 blocks (40–50 s) |
| ARCO MEDIO | /tide/media |
CC 18 | ~8 blocks (1.5–2 min) — default |
| ARCO LARGO | /tide/larga |
CC 19 | ~25 blocks (4–6 min) |
| PULSO | /tide/pulse |
CC 20 | sub-bass heartbeat, one per block, crosses the troughs |
The three arcs are mutually exclusive, and SC owns that rule — it is
enforced once in ~setParam (exclusiveGroup), the single write path every
surface already shares, so ticking one in the browser also unticks the others
on the SC GUI and under MIDI. The browser only reflects the echo; it never
enforces. All three off is a legal state: flat density, no swell.
Watch it on [MON]: tide:<arc>/<phase>=<swell> and puls:.
The master bus previously ran at outPk 3–6 against a full scale of 1.0 with
the limiter holding back 90–98% continuously — a compressor, not a limiter,
which is why the level barely responded to masterVolume or masterAmp. The
cause was singular: the pad layer summed linearly with concurrency (20 pads
= 1.96) while every other layer was ≤ 0.17.
Pads get polyphony compensation (1/√n, so the layer grows as √n), and a
single ~trimMaster sets the absolute level.
The balance was then wrong in a second way, which peak measurements could not
see. Calibrating on the crest left nothing holding the floor: measured
over a full tidal arc the mix was below 0.05 — inaudible — in 48% of
windows, with a 340:1 crest factor. Half the piece was silence punctuated by
peaks. The drone is now the bed rather than a quiet reference, the tidal
trough thins to 0.42 instead of 0.15, and the field recordings sustain and
cross-fade instead of punctuating. Result: inaudible 48% → 16%, crest
340:1 → 37:1, median level ×1.8, outPk p90 0.745.
These trims remain the structure of the balance. What Row 8 adds is a live multiplier on each of them, so the structure can be adjusted while playing without editing constants and rebooting.
ParliamentStage.js rendered every element in one hue, which made the chamber
read as a single instrument panel rather than an assembly — nothing could stand
apart from the amber because nothing was allowed to. Two elements now burn
white phosphorous (#f2fff4, a trace of green so it stays a phosphor rather
than a UI white):
- the outermost radar ring — the boundary of what the instrument can see. It is the only ring with real presence (base opacity 0.28 against 0.06), so a change of hue there is seen rather than inferred.
- Alouatta, the howler — Article 46 of the Cámara Fenológica gives it the one alert protocol in the statute, obliging the Corporation to attend to its silence. The species the instrument is bound to listen for is the species that is not amber. It keeps the same three-step dim→bright activity ramp as the others, in phosphor rather than amber, so it reads as the same state machine in a different substance — not as a node stuck at one colour while the rest of the chamber breathes.
Black and white only, one typeface (Menlo), labels in caps. The previous amber scheme carried five hues that each encoded a state — green ok, red stop, yellow armed — none of which survived a projector or a photograph, and which made hue do the work that state should. Every control is white on black and an engaged control inverts to black on white.
Two things are deliberately not 1-bit, because inversion needs a body to invert and neither of these has one.
The status lights are red when live, dark when dead. A control has a shape
you can read; a light has nothing but its own state. When the palette went
1-bit, mainTheme.green and mainTheme.red both became Color.white — and the
LED drawFunc still chose between those two names as its only state signal, so
all five lights were identical white discs in every condition. They now carry
two literal colours of their own; an unlit light keeps its rim, so only the
filament goes out. Nothing else on the window is red, so the status row is the
one thing that can catch your eye across a stage.
Three of them were also answering the wrong question — testing whether something had been registered rather than whether it was running, which becomes true at boot and stays true through a dead feed:
| Light | Was | Is |
|---|---|---|
| SERVER | Server.default.serverRunning |
unchanged |
| BEAT | ~beatRoutine.notNil — never nil'd after .stop, so a stopped engine read as running |
~lastBeatTime within 3 s, stamped once per step |
| OSC | OSCdef(\txHandler).notNil — registration, not traffic |
~lastOscTime within 5 s |
| ETH | (did not exist) | ~lastEthTime within 30 s — the feed the OSC light used to claim, and never watched |
| MIDI | a device is enumerated — stays lit through a dead cable | ~lastMidiTime within 5 s, any CC |
| BRIDGE RX | ~lastBrowserOscTime within 5 s |
unchanged — the only one that was ever live |
The knob grid is seven across. Four performance rows of 5/5/5/6 became three of
seven — the Cámara row already proved seven fits (7 × 132 px cells + gaps + margins =
992 px inside 1100) — and phenoRate joined the Cámara row to make it seven too. Seven
rows became six.
bancada is a button row, not a knob. It is the last stepped spec that was still a
Knob, and a Knob cannot serve one here: ~makeKnob rebuilds the ControlSpec from
(min, max, warp) and drops the step, ~setParam re-quantises against the real spec
and writes the rounded value back into the widget, and a \vert-mode Knob drags
relative to its current value. So the write-back reset the accumulator on every mouse
event, and crossing into position 1 needed 0.125 normalised in a single event — about
16 px between two consecutive Qt moves. The knob was not sending nothing; it was sending
0, repeatedly. MIDI CC 16 and the browser's five buttons were always fine. Five radio
buttons now, reconciled from the bus at 2 Hz so every source relights them.
Row 5, the Cámara Fenológica, is tinted amber. It is the one bench whose controls change who speaks rather than how the engine sounds. The tint marks the row; it does not restart the hue-as-state habit the rewrite removed.
Projects the engine's vector geometry onto a real-world forest. Lasers draw sparse bright strokes (not rasterised images), so the browser sends a small laser-friendly scene — not the 3-D framebuffer.
browser laserTap ──WS:3337──► laser-bridge.js ──USB──► Helios DAC ──► laser
└──────────────► frames.ild (ILDA fmt 5)
Enable: LASER=1 ./start_ecosystem.sh (off by default). With no DAC and no native binding it runs DRY (logs only) — safe to start anywhere.
Frame contract (browser → bridge): normalised, centre (0,0), x,y ∈ −1..1.
{ "type":"laserFrame", "pps":30000,
"points":[ {"x":-0.8,"y":0.0,"r":0,"g":200,"b":90,"blank":false}, … ] }Frame source (src/projector/laserTap.ts, started by parliamentEntry.init):
window.__laserFrame— any module may publish its own vector scene.- slot-P default — the phenological year-ring + a marker at today's active species (
window.__activeSpecies). A sensitive species is not drawn: the opacity clause (Glissant) extended into physical space — the vulnerable being is never cast onto the real forest.
Stacked ridgelines — the Unknown Pleasures / B1919+21 image. Successive observations of the same object drawn one above another, so a pattern invisible in a single pass emerges from the stack. pulsarPlot.ts publishes window.__laserFrame, which laserTap.ts already prefers over its year-ring default.
Four sources, ticked independently from the SC GUI (/laser/src/*, registry-backed so presets carry them). Each draws in its own hue so a mixed stack stays legible. With none ticked the year ring returns — that is how the ring stays reachable without a control of its own.
| Tick | One row is | Hue |
|---|---|---|
MIX |
a spectrum snapshot of everything sounding | green |
CORPUS |
the same, over the corpus bus alone — the forest's own voice | amber |
DÍA |
the corpus spectrum measured while a /pheno/clip day is sounding |
blue |
CHAIN |
a block's worth of transactions; x is arrival order, height is the bid | red |
Article 47 is carried, not re-litigated: an opaque clip is never announced, and a sensitive species contributes no row at all — the same refusal laserTap.ts already makes for the ring.
The galvo limits make this a path-length problem. Every point is scanned FRAME_HZ times a second, so a frame gets a fixed quantity of ink:
ink per frame = OMEGA_MAX / FRAME_HZ / deg_per_unit
= 10000 / 30 / 22.5 = 14.8 normalised units
A full-width row costs ~1.5 units before it wiggles. The album's 80 rows would need ~420 units. Six is the ceiling, so the depth of the stack lives in time — the plot scrolls, and the pattern emerges for someone who watches rather than glances.
Two things are load-bearing rather than stylistic:
- Serpentine scanning. Drawing every row left-to-right means retracing the full width between them, and the mirror travels that whether the beam is on or not. Measured: 19.36 units, 131 % of budget → whole frame blanked. Alternating direction makes the only inter-row move the row step. Serpentine: 12.03 units, 81 %.
- Arc-length sampling. Spacing points evenly in x and sizing that spacing to the step limit leaves nothing for the vertical component, so every sloped segment exceeds the limit and gets interpolated. Measured: a 516-point frame became 1041, and a path using only 77 % of the ink budget hit 128 % of the point budget and was blanked.
The generator is self-budgeting — it sheds the oldest row until the frame fits, before sending. Auto-blanking is a safety net, and content that lands in the net is content that is not being projected. Measured end to end: 646 points, 81 % scan budget, 0 over-speed, nothing blanked.
A laser projector is driven by a waveform: at the DAC's point rate each point is one sample, X on the left channel and Y on the right. The limits below come from the fixture datasheet, and every one is an env var.
Scan Speed 30 kpps @ 8° is a rate–angle pair, not a rate. A scanner that tracks 30 000 points/s across 8° cannot track 30 000 points/s across 45° — the mirror has five times as far to travel per point. So the step limit is derived from an angular-velocity ceiling rather than picked:
OMEGA_MAX = RATED_ANGLE × RATED_PPS / TRAVERSE_PTS = 8° × 30000 / 24 = 10 000 °/s
MAX_STEP = OMEGA_MAX / pps / (SCAN_ANGLE / 2) ≈ 0.0185 at 24 kpps
TRAVERSE_PTS is the one figure the datasheet does not publish (the ILDA test pattern's point count) and is set deliberately low, putting the ceiling at the bottom of the range a 30 K scanner is credited with.
| Env | Default | Source |
|---|---|---|
LASER_PPS |
24000 |
derated to 80 % of the rating; clamped to LASER_RATED_PPS |
LASER_RATED_PPS |
30000 |
Scan Speed 30 kpps @ 8° |
LASER_RATED_ANGLE |
8 |
the angle that rating is quoted at |
LASER_SCAN_ANGLE |
45 |
Scan Angle 45°, full field |
LASER_TRAVERSE_PTS |
24 |
modelling constant — lower = more headroom |
LASER_POWER_W |
1.7 |
Power > 1.7 W |
LASER_BEAM_MM / LASER_DIVERGE |
5 / 1.1 |
Beam 5 × 3 mm, < 1.1 mrad |
LASER_THROW_M / LASER_DWELL_MS |
10 / 1.0 |
projection distance; dwell window |
LASER_MAX_STEP |
(unset) | override only — unset, it is computed above |
Dwell is physical, not a guessed epsilon: the beam must clear its own width at the throw distance within LASER_DWELL_MS, or successive points are landing in the same spot.
Interpolation makes most jumps survivable but cannot make every frame compliant — LASER_MAX_POINTS and the scan budget are hard ceilings. Past them the beam is switched off rather than projected, targeted wherever targeting is meaningful:
| Fault | Response |
|---|---|
| over-speed | blank the point being jumped to — the mirrors still travel the gap, but dark. Does not repair the mechanical over-command, so the raw count is still reported separately. |
| dwell | once a stationary unblanked run reaches LASER_DWELL_MS, the rest of it is blanked. The one case where blanking removes the hazard outright. |
| budget > 100 % | cannot be targeted — no subset is being drawn at the rate it was authored for. The whole frame goes dark. |
Held for LASER_BLANK_HOLD_MS (250 ms) after the last fault, so a frame sitting on the threshold cannot strobe the beam at the frame rate. Disable with LASER_SAFE_BLANK=0.
The scope reports what was done separately from what was measured — a dwell of 0 because the beam was switched off is a different fact from a dwell of 0 because nothing ever stopped moving. Verified:
gentle 300 pts 679/10000 deg/s field 2.7 deg budget 56% within scanner spec
parked 400 pts 0/10000 deg/s dwell 958 us BLANKED 376 parked pts
dense 1200 pts budget 225% BLANKED — needs 54000 pps
laser-bridge.js sends /laser/scope to sclang at 12 Hz carrying the frame after sanitisation — the signal the DAC actually receives. The SC GUI draws it in a fixed 452 px column beside the scroll area (window 1570 px), so it stays readable while the hands are on the knobs; the scope itself is 440 × 847. Three labelled lanes: X, Y, and STEP / LIMIT — a step limit is a limit on slope, so velocity is what the scope has to show, and it is not the same kind of quantity as the two above it. Decimated buckets carry the worst step inside them, never the step between surviving points.
Two lamps, in the same visual language as the feed lights but without their age column (neither is a feed whose silence means anything):
| Lamp | Lit when |
|---|---|
| DAC | the bridge has bound a real Helios. Dry run and no-bridge both read dark — in neither case is anything reaching a laser. |
| BLANK | the beam is down right now. Follows the bridge's own LASER_BLANK_HOLD_MS window, not the frame that tripped it: a 250 ms blanking reported for one 12 Hz frame would flash for 80 ms and be missed. |
Both go dark when the bridge stops speaking — an unlit BLANK must never be readable as not blanking when in truth nothing is being projected at all.
Four states, all reachable:
| Condition | Reads |
|---|---|
| Within spec | 9274 / 10000 deg/s, scan budget 48 % |
| Frame wider than the rating | field 31.5 deg (rated 8.0) → wide field |
| Beam stopped moving | BEAM PARKED 13.3 ms |
| Too dense to scan | budget 180 %, residual OVER-SPEED ×460 |
| Bridge absent | no bridge — never "compliant" |
Not a safety system. This is a Class 4 fixture (> 1.7 W RGB). People are protected by the interlock, E-stop, key, aperture mask, the fixture's own Scan Guard, beam-path design and trained operation. The scope keeps the engine inside the scanner's published mechanical envelope and makes loss of beam motion visible. It does not make anything eye-safe.
./start_ecosystem.shLaunches all services: nw_wrld, parliament-bridge, SuperCollider, Python ETH scraper.
cd nw_wrld_local && node diag-sweep.jsSends all 22 params through the bridge (0 → 1 → 0.5), then runs a continuous volume LFO.
The single most useful tool when something "sounds wrong". SuperCollider posts one line every 2 s describing what it is actually receiving and doing:
tail -f sclang_log.txt | grep MON[MON] flags:B-E---S bells:47/gate:310/cap:12/bar:88 env(atk/dec/amp):0.81/0.83/0.071
prio:0.264 ent:0.517 dens:0.312 blk:25670857 txN:290 idx:289 base:0.051 synths:9
| Field | Answers |
|---|---|
bells |
pads spawned, and skipped by which gate: gate time-gate, cap synth ceiling, drop queue overflow; q: shows pending/total queued |
kick perc err |
beat-engine spawns, and any errors the guarded loop caught and recovered from |
tg amp |
transport gain (0.05 = Stop Parliament latched) and the performer's master level |
outPk gr |
peak level reaching the limiter, and how hard it is pulling back |
env |
atk/dec/amp of the last pad — if these stop being identical, the envelope is responding to the transaction |
prio ent dens |
live chain-derived values; a constant here means a mapping has saturated |
blk txN idx base |
whether the enriched eth_sonify.py payload is arriving at all |
Three OSC controls, from anything that can reach SC on 57120:
| Address | Effect |
|---|---|
/diag/osctrace 1 |
OSCFunc.trace — post every inbound OSC message; the definitive test of whether a control reaches SC |
/diag/monitor 0 |
silence the [MON] line |
/diag/reset |
zero the pad counters to measure a fresh window |
The registry banner in sclang_log.txt confirms the engine loaded the current
sources — worth checking first when a change appears to have no effect, since
start_sonification.scd reads all twelve .scd files from disk at boot:
Parameter registry loaded: 35 parameters, 39 OSC routes, 34 MIDI CCs.
Master limiter active (2 ch, ceiling 0.92) — output can no longer clip.
MIT License
