# BioMimicry — development log Why the card is built the way it is, and what listening to it changed. Written as we went, newest last. --- ## 0.1.0 — the five engines Built the scaffold into a working card: five physics engines driving four agents, synthesized voices, an alt-boot PCM path, and the CV/gate routing the Workshop Computer's real I/O allows. Three hardware constraints shaped the whole design and are worth restating, because they look like arbitrary choices otherwise: - **There are two pulse outs, not four.** Agents 3 and 4 fire as calibrated 5 V blips on the CV outs, so every agent still gets a physical trigger. - **There is one switch, not two.** A Down tap cycles the mode; the Up/Middle position selects routing. Holding Down at power-on picks the boot mode. - **There are six LEDs.** With five modes the sixth showed activity; at six modes they share (see 0.2.0). Physics run at a **1.5 kHz control rate** (every 32nd sample) while voices, gates and CV run at the full 48 kHz. That split is what makes four agents of Kuramoto coupling affordable, and 0.67 ms of timing granularity is far finer than the ear resolves for triggers. ### What simulation caught before any hardware trip `tools/simulate.py` models the engine maths and reports trigger rates. Four real defects, none of which a compiler would have found: | Defect | Symptom | |---|---| | Kuramoto coupling ~2% authority | Frogs could **never** synchronise — the model was decorative | | Geese excitation saturating | Knob was a switch: silence, or a flat 16 Hz buzz | | Rain topping out at 100 Hz | Hiss, not rhythm | | Meteors topping out at 47 Hz | Same | A useful lesson from this round: **flat trigger rates do not mean coupling is broken.** Synchronised oscillators fire at the same rate, they just align in time. Measuring the Kuramoto order parameter showed the coupling working when the rate table suggested it wasn't. --- ## Reality check — the gaits were wrong Asked directly whether the horse gaits were actually correct. They were not. Three separate errors: - **The gallop was byte-identical to the walk.** A `kGaitSteps` table meant to compress it into a burst-plus-suspension was `{4,4,4,4}` and silenced behind a `(void)` cast. The knob's entire top quadrant was a faster walk. - **The walk wasn't a walk.** It fired both forelegs consecutively. A real walk is 4-beat *lateral* — same-side legs follow each other. - **The canter had no suspension.** Evenly spaced 90° beats read as a waltz. Underneath was a structural flaw: **four free-running clocks at 0.95–1.03× cannot hold a gait.** A trot's diagonal pair separated within seconds. The drift was the mode's poetry, but only the walk survived it. Rebuilt around **one stride clock** with per-hoof landing offsets from real equine footfall timing. Chaos jitters each hoof's *timing* rather than its rate, so gaits stay locked however long they run. `python tools/simulate.py gaits` asserts all four biomechanically. Also this round: Rain got downstream splash coupling (it was statistically close to Meteors — Poisson with a refractory period), Geese and Meteors went to 12-member swarms, and Pulse In 2 became an entrainment clock rather than a second spook. --- ## 0.2.0 — Cicadas, round robins, stereo Added a sixth ecosystem coupling on **amplitude**, where Frogs couple on phase and Geese on events. It took two attempts, both caught in simulation: 1. With insects tiring only when *calling*, the swarm self-organised into an even spread and never surged — defeating the entire point of the mode. Ambient fatigue (being in a loud field is itself tiring) more than doubled the swing. 2. The knob still did nothing, because feedback speeds calls while fatigue is call-driven — they cancelled. Scaling **both halves** of the loop by the knob fixed it. Round robins were wired for meaning rather than variety: in Horses the variant **is the hoof**, hinds pitched lower and heavier than fores. Stereo placement comes from the ecosystem — fixed for the horse, spread for flocks, random per hit for rain and meteors. Six modes filled all six LEDs, so mode and activity now share one light: the active mode glows dim and flares to full on each trigger. --- ## Hardware: "the horse sounds like one horse" Correct, and a real bug rather than a tuning problem — one I introduced when consolidating to a single stride clock. `HorsesEngine::tick()` **never read `c.population`**. Knob X only reached the mode through the generic agent mask, which here removed *legs from one animal*. Turning it up added nothing; turning it down made a lame horse. Population is now a **herd**: one stride clock per horse, all four hooves intact, with per-animal speed offsets so the animals slide in and out of step. That is the phasing the old per-leg drift was reaching for, at the level where it doesn't destroy the gaits. `EngineOut::member` carries which hoof landed so each animal plays all four of its own. --- ## Hardware: trot and canter sound like a density drop Also correct, with an acoustic explanation. Trot and canter were the **only** gaits with mathematically coincident landings — and two identical clops fired on the same sample don't sound like two hooves, they **sum into one louder clop**. So a trot genuinely was half the density of a walk, not just perceptually. Real diagonal pairs land 10–30 ms apart. The second foot of each pair now holds back by a fixed delay (trot 12/18 ms, canter 15 ms), given in absolute time rather than as a fraction of the stride — a real animal's flam doesn't stretch with tempo. Walk and gallop already had four distinct landings and are untouched. Frogs also went to a pond of twelve. That needed the Kuramoto sum rewritten mean-field (O(n) instead of O(n²)) — **and the first attempt had the angle identity's terms swapped and was 100% wrong.** Checking against a direct O(n²) sum over 300 random ponds caught it; the corrected form agrees to 0.006%. Worth recording because it compiled and ran silently. --- ## Alt-boot became Drone Alt-boot was spending a whole boot mode on a sample toggle. Sample playback is now a **build-time** choice (bake `samples/` or don't), which freed it for **Drone**: the same six engines driving sustained tone instead of triggers. Both Audio In jacks were also completely unused. Audio In 1 is now loudness (a loud room agitates geese, silences cicadas), Audio In 2 transient-sensitive — sudden movement alarms animals, steady noise doesn't. --- ## Hardware: "samples playing far far too slow — like racing cars" They were not playing at all. **Every boot was entering Drone mode**, whose old Meteors root was a 40 Hz saw pair through a sweeping filter — which is exactly what a passing vehicle sounds like. `ComputerCard` derives the switch from `knobs[3]`, which comes off a ~60 Hz smoothing filter **initialised to zero — and zero decodes as `Switch::Down`**. For roughly the first 5 ms of every boot the card reports Down wherever the switch actually is. The boot window latched on "Down seen at any point", so it latched on *every* boot. Two follow-ups from the bench settled it: *"both boot modes seem to be doing the same"* pointed at the latch, and *"four sounds, rising in pitch"* was the synth hoof pitches (153/202/164/216 Hz), proving the PCM path was never reached. The fix is a single reading after settling — what WorkshopZX's `BootSelector` does, and what is proven on this hardware. I had claimed to be following that pattern and was not. There is now a **boot splash** — Rhythm lights the left LED column, Drone the right — because the whole diagnosis was slow for want of any way to tell the modes apart. Found while tracing it: the PCM end-of-sample test used `pcmLen << 16`, which wraps above 65536 bytes. It was silently truncating the two longest meteor swooshes to under half their length. --- ## Drone was useless, and rightly called out The first Drone mapped engine `state[]` to oscillator pitch. Most engines put a **phase ramp** in `state[]`, so the pitch swept upward and snapped back forever, in half the modes — and it ignored the entire sample library in favour of saw oscillators. Rebuilt with **no oscillators**: Drone now granulates the same recordings, up to eight overlapping grains per voice started at random points and played stretched. The engines drive grain *density* and *spread*, never pitch. Grain periods were sized against the real sample lengths so overlap lands at 2–4×; the first pass asked for 16× overlap against four grain slots, which would have stolen grains mid-playback and chopped. --- ## Hardware: cicadas galloping Reported as clusters then silence, *"almost like galloping horses"* at CW. That detail was the diagnosis — it said the clusters were **periodic**. Measured: every 0.32 s, standard deviation 0.01 s. A metronome. **My first guess was wrong.** I assumed the twelve insects shared one fatigue time constant, gave each its own recovery rate and stamina, and it stayed rhythmic in every configuration. Measuring phase clustering instead found the real problem: phases converged from R=0.24 to **R=0.88 within five seconds** and froze, because every insect ran off one shared rate law with no natural frequency of its own. Detuning them dropped clustering to R=0.26. That stopped the unison but the field still pulsed, for a reason no amount of tuning fixes: > One shared field, plus "everyone speeds up when it's loud and tires when they > call", **is a relaxation oscillator**. Charge, discharge, repeat. It has exactly > one period. So the field became **four patches** that mostly hear themselves. Patches charge and discharge independently — measured correlation **0.01** — and cluster spacing went from a fixed 0.32 s to an irregular 0.4–8.6 s (cv 0.06 → 0.44–0.78). *Note for anyone reading `simulate.py`:* `cicadas_swing` reports **one patch**, not the mean. The mean is deliberately flat because independent patches cancel, and that cancellation is the point. --- ## Real samples, and a way to change them 42 Pixabay recordings, already 48 kHz 16-bit mono, with levels spanning ~36 dB. All of them fit (834 KB of 1943 KB free), so rather than discarding three-quarters of the round robins the firmware went to **eight variants** — except Horses, whose variants are the hooves. Levels are matched by **RMS, not peak**. A peak is usually one transient: the quietest goose had a body at RMS 0.004 against another at 0.059, yet both peaked near 1.0, so peak-normalising would have left the quiet one still sounding quiet. Corrections ran −15.5 dB to +20.6 dB; the library came out matched to 1.07×. The web editor then removes the toolchain from the loop entirely: drag WAVs into a browser, they are converted and loudness-matched there and streamed over WebMIDI SysEx into a reserved 1 MB flash region, overriding the baked recordings per slot. The card mutes during the write because flash writes halt execution — honest about the constraint rather than glitching through it. The firmware image now sits only ~95 KB below that region, and adding baked audio would silently push it over, making flashing destroy uploads and uploads destroy firmware. `tools/checksize.cmake` reads the real image end from the ELF and fails the build if it ever reaches the boundary. --- ## A developer's correction: /32 does not buy you time A Workshop Computer developer read the code and pointed out: > *"Unless I misunderstand, calling the physics once per 32 samples doesn't help > with performance — because the physics still needs to finish within that ~20us > sample in which it is run?"* He is right, and it invalidated a claim we had been repeating. `controlTick()` is called **inline** from `ProcessSample()`, which runs inside the DMA interrupt handler (`ComputerCard::AudioCallback`). So on the one sample in 32 where the physics run, the whole engine still has to finish inside that single **20.83 µs** slot. Dividing by `kCtrlDiv` lowers the *average* load. It does not move the deadline. The figure in this log — "~55 cycles/sample amortised against a 2604-cycle budget" — was therefore measuring **throughput**, not the thing that decides whether audio glitches. What matters is the **worst single sample**, and that had never been measured. Static instruction counts could not settle it either: the Geese tick is 1233 instructions but contains 243 branches, so the count is a very loose upper bound rather than a real path. The honest response was to measure rather than argue, so `profile.h` was added: a SysTick-based cycle counter around the whole callback and each of its phases, reporting the worst case on the LEDs (one LED per ~16% of budget, all six flashing on an overrun). It compiles to nothing unless `-DBIO_PROFILE=ON`, and this was verified by checking that the normal build is byte-for-byte identical to the released firmware. Geese is the mode to watch: its excitation spread is the only O(n²) path left, and a full cascade is 12×11 = 132 inner iterations in one tick. Frogs was already made O(n) by the mean-field rewrite. > **Both halves of that paragraph turned out to be wrong.** Geese measured > *fourth* of six, and Frogs' O(n) rewrite is still second worst. See "The > measurements, and two wrong predictions" below. Left here unedited because the > reasoning looked sound right up until it was checked. His second point stands too: if the measurement shows headroom, the control rate can go *up* for finer trigger timing. That is a one-line change to `kCtrlDiv` — but worth making only once the headroom is a number rather than an assumption. **Status: measured — see below.** --- ## The measurements, and two wrong predictions Three rounds of hardware readings settled it. The first two rounds each fixed a real problem and each ended with a guess about what would matter next. **Both guesses were wrong**, and the sweep that proved it took ten minutes. ### Round 1 — the overruns were XIP flash misses Moving the hot path into RAM: Engine 3.5× faster, Voices 3×, Outputs 4×, overruns down ~92%. Everything the card computes itself came inside budget. ### Round 2 — USB was the entire remaining problem `tud_task()` measured **29895–35850 cycles**, up to 14× the whole 20.83 µs sample. TinyUSB's device stack is unbounded by design and was being called from inside the audio interrupt. It moved to core 1. **That worked completely.** USB now measures **0 cycles** on core 0 in every mode. It is the one unambiguous success in this log. The same round split `uiTick()` (LED rendering) half a divider away from the engine so the two costs never land in the same slot. That worked too: Outputs peaks at 336–526 cycles across all six modes. ### Round 3 — the full sweep | Mode | Engine | Total | Overruns | |------|-------:|------:|---------:| | **Cicadas** | **7830** (300.7%) | 8708 (334.4%) | 1225 | | **Frogs** | **5606** (215.3%) | 6509 (250.0%) | 1640 | | Horses (pop 4) | 3847 (147.7%) | 4581 (175.9%) | **11030** | | Geese | 3118 (119.7%) | 3900 (149.8%) | 249 | | Drips | 2648 (101.7%) | 3530 (135.6%) | 3 | | Meteors | 2139 (82.1%) | 2915 (111.9%) | 3 | Budget is 2604 cycles. **Every mode is over on Total. Four of six are over on Engine alone.** ### The two wrong predictions **"Geese is the mode to watch."** It is *fourth*. The reasoning was that its excitation spread is the only O(n²) path left, 12×11 = 132 inner iterations. But Cicadas is **2× worse** than Horses and 3× over budget with no O(n²) path at all — it walks its swarm up to four separate times per tick (spook, loudness, main loop, patch reduction), and each iteration is heavy. **Iteration count did not predict cost; per-iteration weight did.** Same class of error as the "/32 buys you time" claim: reasoning about the code instead of measuring it. **"Frogs was already made O(n) by the mean-field rewrite."** True, and it is still second worst at 5606 cycles. O(n) is not the same as cheap. ### Peak and frequency are different faults The overrun counts do not track the peaks. Horses has **11030** overruns at 3847 cycles; Cicadas has **1225** at 7830. Peak says *how far* over, overrun count says *how often*. Horses overruns constantly by a little, Cicadas rarely but massively. Both are audible, differently — and a fix that only chases the peak would leave Horses' 11030 in place. ### Horses: the cost is mostly fixed, not per-agent Sweeping population on Horses: | Population | Engine | Overruns | |-----------:|-------:|---------:| | 1 | 2065 (79.3%) | 3 | | 4 | 3847 (147.7%) | 11030 | Three extra horses cost 1782 cycles — **~594 per horse**, leaving a fixed floor around **1470 cycles**, roughly 70% of the population-1 cost. The `Engine` scope wraps all of `controlTick()`, so knob reads, CV reads, clock tracking and voice dispatch are inside that floor. Two loops in the Horses tick also run `kNumAgents²` = 16 iterations regardless of population (`c.spook` reset and the `gait != lastGait_` reset), ignoring the early-out at `engines.cpp:143`. A prediction that per-agent splitting would quarter the cost was therefore also wrong: it caps the variable part but leaves the floor untouched. ### What the numbers rule out The developer's second point — that headroom could buy a *higher* control rate — is dead. There is no headroom. `kCtrlDiv` cannot go below 32 until Engine fits. Per-mode micro-optimisation is the wrong strategy: the two modes worth starting on by intuition (Horses, Geese) are third and fourth. The costs share one shape — **every engine does its whole swarm in one tick, inside one 20.83 µs slot** — and `kCtrlDiv` cannot help, because it lowers average load and not the deadline. **Caveat on this data:** one reading per mode, and peaks reset on each read. The Horses sweep showed the range *within* one mode is wider than the gap between modes, so Drips and Meteors sitting at 3 overruns is provisional — it may only mean that knob position was never swept. **Status: measured. Fix not yet attempted.** --- ## Drone was the real glitch, and the uploader never worked at all The measured sweep above was **Rhythm mode only**, and that turned out to matter. ### The ear found what the sweep missed Asked whether the overruns were actually audible, the answer was that they were mostly masked — chirps and hooves are broadband and transient-dense, so a one-sample discontinuity hides in material that already sounds like noise. Fair, and it nearly stopped the work. But then: discontinuity **in Drone**, on Geese, Frogs and Meteors, with a guess that it was "the longer samples". Meteors measures 2139 cycles — the one mode never over on Engine — so Engine could not be the cause. Reading timing in Drone: | bucket | Rhythm (Geese) | Drone (Geese) | |--------|---------------:|--------------:| | Engine | 3118 | 3145 | | **Voices** | **800** | **17546** | | Total | 3900 | 18230 | | overruns | 249 | **373862** | **674% of budget, and 1500x the overruns.** The engines were never the problem in Drone. `droneRender()` is, and unlike the physics it runs on *every* sample. The cause was one line: a triangular grain window computing `(dist << 16) / half` per grain per sample — 8 grains x 4 voices = **32 hardware divides at 48kHz** on a core with no divider. `g.len` never changes once a grain launches, so the whole thing was recomputing a constant 48000 times a second. The reciprocal is now taken once at launch and the render loop multiplies. The "longer samples" hunch was right, and better than my reasoning: longer grains stay active longer, so more of the 32 divides are live at once. ### Getting the reciprocal right took three attempts Worth recording, because the first two would have shipped audible bugs and a Python check caught both before flashing: 1. **Q16 reciprocal** — `(0xFFFFFFFF/half)>>16`. Fine for short grains; on `meteors_5` (118596 bytes) it truncates to 1 against a true 1.105, a **10% window error**, and an uploaded 1MB sample truncates it to **0** — silence. 2. **Q48 reciprocal** — fixes the long grains, **overflows 32 bits** on short ones. 3. **Q32 with a clamped `dist`** — the range of `half` (8 to ~500000) is too wide for any single fixed scale. The survivor also needs `dist < half`, because an odd `len` lets `dist == half`, and `half * (2^32/half)` is exactly 2^32, which wraps to zero and turns the window's **peak** into silence. Verified exhaustively against the original across every real sample length: zero overflows, max error 0.19% of full scale on the shortest baked recording. ### The uploader deadlocked the card, every time Reported as: web UI says it will go silent, then no sound, no LEDs, no control, until a power cycle. Three separate defects, all in a path that had **never once run on hardware**: 1. **The park loop was inside the DMA interrupt handler.** Core 0 spun in `ProcessSample()` waiting for core 1 to finish writing flash. Spinning for the seconds an erase takes starves the audio DMA, and it never restarts. 2. **`ProcessSample()` is `virtual`.** Merely *dispatching* to it reads a vtable that lives in flash — so with XIP down, core 0 faults before reaching any guard. The RAM-residency of the function body cannot help you get to it. 3. **Every upload erased the whole 1MB region** and `memset` the slot table, so replacing one 6KB recording destroyed the entire library, after a multi-second stall the browser read as a hang. Fixed by giving up on the pretence. `EnterUploadMode()` disables `DMA_IRQ_0` outright, the whole USB path is now RAM-resident, core 1 drives the progress LEDs (core 0 is not running at all), and the card **reboots** when the upload finishes. The web UI already said "the card mutes while uploading"; this makes that true instead of aspirational. Uploads are now incremental: the header is seeded from what is already on the card and new audio is appended, so untouched slots survive. The append point is rounded **up** to a sector boundary — erase works a sector at a time, and starting mid-sector would have wiped the tail of the previous recording, which is the exact corruption the change existed to prevent. Space is reclaimed with "revert to built-in", which empties the region. ### Drips: the first 40% of the knob did nothing Reported as basically never firing until ~40%, then firing often. Measured, and exactly right: **0.00 triggers/sec everywhere below 45%**, then straight to 3.3. This was not a knob-taper problem, so remapping the curve would only have moved the cliff. Below a certain inflow the buckets leak as fast as they fill, so the level sits at an equilibrium under the threshold and *nothing ever fires* — a uniform random drop cannot cross it, no matter how the knob is scaled. Two changes. The inflow law is square-rooted (`fast_sqrt_q16`, a new bitwise integer sqrt — no libm, no float, no divide) so the bottom of the travel moves fastest, with a small floor. And the drop is now **heavy-tailed**: cubing the random keeps the mean low but lengthens the tail, so a weak downpour still tips a bucket occasionally. That tail is what removes the dead zone, and it is closer to real dripping, where water gathers and then lets go. Result: drips from 2% of travel, rising smoothly to ~19/sec, under the ~25/sec ceiling at which a rhythm stops reading as one. The obvious `* 4 / 5` gain trim compiled to an `__aeabi_idiv` call **inside the inner loop**, checked in the disassembly and replaced with a Q16 multiply. The same M0+-has-no-divider lesson the Cicadas patch walk already learned. **Status: all three built and staged, none heard on hardware yet.** --- ## Round two on all three, from hardware ### Drone: the divide was half of it `droneRender()` went **17546 -> 9608** cycles. Real, and not enough — still 369% of budget, and Geese still audibly glitched. The other half was in the same loop and the same shape: `mul_q16()` widens to `int64_t`, which on the M0+ is an **`__aeabi_lmul` library call**. Two of them per grain per sample, up to 32 grains, at 48kHz. Confirmed in the disassembly rather than guessed. Neither needed 64 bits: `|s| <= 2048` and `win <= 65536`, so the product peaks at 1.3e8 and the accumulator at 5.4e8, both comfortably inside int32. They are now plain 32-bit multiplies, and `droneRender()` contains **no library calls at all**. `droneUpdate()` had three more (`* 2 / 5`, `% 32`, `% kNumGrains`) — the first became a Q16 multiply, the other two masks, since both divisors are powers of two. It runs at control rate so it matters ~32x less, but it sits in the same Engine bucket that measured 4676 in Drone. The one remaining 64-bit divide is the window reciprocal at grain launch, which is the whole point: one divide per grain instead of one per grain per sample. ### The uploader: the ack never left the device "Upload did nothing, revert reset to stock samples" — so the header never committed, and the browser saw nothing. `tud_midi_stream_write()` only fills a FIFO. **`tud_task()` is what puts bytes on the wire**, and this TinyUSB has no MIDI flush call. Every reply was queued and then immediately followed by an erase, a page program, or a `busy_wait` before the reboot — all of which block without servicing USB. So: - chunk acks sat in the buffer while the browser waited for one before sending the next chunk, and - the final ack died with the `watchdog_reboot()` 120ms later. `Send()` now pumps `tud_task()` itself (guarded against unbounded recursion, since it is called from inside `tud_task()`), and the reboot paths spin on `FlushUsb()` instead of a blind `busy_wait`. Worth noting this bug was invisible to the profiler reads, which worked fine: those reply once and then return to the normal `Task()` loop, which flushes them on the next iteration. Only the upload path blocks immediately after replying. ### Drips: right shape, wrong speed The dead zone was gone but the first drips arrived faster than one a second, where the ask was nearer one every four. Lowering the floor barely moved it and lowering the gain capped the torrent at the top. The fix was to stop treating gain as a constant: it now **ramps 0.4 -> 0.8 across the sweep**, so the bottom is sparse without flattening the top. With the floor raised to 9000, drips start at ~4% of travel at 0.41/s (one every 2.4s), pass 1/s around 10%, and reach ~18/s at full. **Status: built and staged; not yet heard.** --- ## The uploader, third attempt: masking an interrupt is not stopping a core Two rewrites in, upload still hung the card. The mistake was the same both times, just better hidden: **`irq_set_enabled(DMA_IRQ_0, false)` stops the interrupt from firing again. It does not stop core 0 from executing flash.** Three things were still live when `flash_range_erase` dropped XIP: - `ComputerCard::AudioCallback` and `BufferFull` are **in flash**. Core 0 can be inside them at the moment of the erase — masking the IRQ does not evict it. - `AudioWorker`'s outer `while(1)` is RAM-resident, but it returns into and calls flash-resident code. - The CV outputs run a **second flash-resident ISR**, `PWM_IRQ_WRAP -> OnCVPWMWrap`, which was never masked at all and kept firing throughout. Any one of them faults the chip. `ProcessSample` being `__not_in_flash_func` was never the point: what matters is everything *around* it. Core 0 now parks inside `ProcessSample` itself — which is RAM-resident — mutes its outputs, sets `core0Parked`, and **spins forever**. It never returns, so the flash-resident caller never runs again. Core 1 raises `uploadMode`, waits for the acknowledgement (the next callback is 21us away), and only *then* masks both IRQs and touches flash. Order matters: masking first would mean `ProcessSample` never runs again, never sees the flag, and never parks — a deadlock built out of the fix for a deadlock. This is safe now in a way it was not in the first attempt, and the reason is worth stating: USB moved to core 1, so spinning core 0 no longer stalls the very transfer it is waiting on. ### Two browser bugs, both reported rather than found - **The file picker opened twice per sample.** Each slot was a `