# NIBBLE-KO — working notes for Claude Code A program card for the **Music Thing Modular Workshop System Computer** (RP2040), built on the header-only **ComputerCard** library. Sibling project to `../WoskshopButtons` (NIBBLE, repo `WorkshopNibble`), `../WorkshopBio` and `../WorkshopZX` — reuse their conventions and structure where they fit. **NIBBLE-KO is the expanded percussion half of NIBBLE**: a twelve-voice drum machine reading one output of the Workshop System's Four Voltages module, where every voice is independently synthesised or sample-based, chosen and uploaded from a browser WebUI — the sample-management pattern from `../WorkshopBio`. ## Current status: RELEASED, v1.2.0 Builds to `build/nibbleko.uf2` — **7.78% flash, 87.64% RAM**. Everything is played and confirmed on a Workshop Computer: drums, the four-bar looper with lossless overdub, three mute groups, twelve performance effects with two-lane recording, three pattern slots, sample playback, the browser sample manager (connect, assign, upload, rename, delete, erase), flash-saved calibration, and the whole CV expansion including the CV Out 2 → Pulse In 2 self-patch. **Shipped.** `info.yaml` is `draft: false`, `Status: Released`. The card is in the community catalogue as `releases/102_Nibble-KO` in `TomWhitwell/Workshop_Computer` — merged there at 1.0.0 (PR #362), with 1.0.1 following in PR #365. Own repo: `uglifruit/Nibble-KO`. **The RAM figure is the WebUI's 160KB upload staging buffer**, not a leak. It only fits because USB is modal — nothing instantiates `WebUI` until switch+B+D. Same figure WorkshopBio ships on identical hardware. Watch `--print-memory-usage`. Note that entering the WebUI **no longer stops the loop**. It used to, and that was wrong for the job the WebUI mostly does: re-pointing a voice at a different sound is a judgement made BY EAR, against the pattern the sound has to sit in, and `MSG_SET_SOURCE` is instant and flash-free precisely so the change is audible immediately. Only RECORDING stops on entry. An UPLOAD still silences and parks the card, but through `EnterUploadMode()` / `core0Parked` — the real mechanism — rather than through the mode change. **Pattern slots are RAM-only on the card, by design** — they die at power-off, and the browser is where they persist: the Patterns tab saves a slot to a JSON file on the PC and loads one back over SysEx (`MSG_PAT_GET`/`MSG_PAT_SET`). Confirmed on hardware, including on dense patterns, which is where it first failed — the dump now drains in one `Task()` pass because core 1 gets very little time while core 0 is busy. Note that **a reboot clears the slots**, and every flash write reboots — so leaving the WebUI, saving the kit or uploading a sample all lose unsaved patterns. The Patterns tab says so; it is the one sharp edge in the feature. **Loop length is settable, 4-16 beats** (`MSG_LOOP_GET`/`MSG_LOOP_SET`), which is what makes odd metres playable. It is a PLAYBACK setting: events are always recorded against the full sixteen beats and only the wrap point moves, so shortening hides rather than erases. See `Looper::SetLoopBeats`. **Calibration is flash-persisted** (`calibstore.h`) — a normal boot loads the last saved levels and is playable within the splash; only an alt-boot (switch Down through the boot-settle window) forces a fresh learn, and a successful learn is saved automatically. ## Build Toolchain comes from the Pico VS Code extension install at `~/.pico-sdk/`. `CMakeLists.txt` includes `~/.pico-sdk/cmake/pico-vscode.cmake`, which pins SDK 2.2.0 / GCC 14_2_Rel1 / picotool 2.2.0-a4. From PowerShell: ```powershell $env:PICO_SDK_PATH = "$env:USERPROFILE\.pico-sdk\sdk\2.2.0" $env:PATH = "$env:USERPROFILE\.pico-sdk\cmake\v3.31.5\bin;$env:USERPROFILE\.pico-sdk\ninja\v1.12.1;$env:USERPROFILE\.pico-sdk\toolchain\14_2_Rel1\bin;$env:PATH" cmake -B build -G Ninja cmake --build build ``` Output: `build/nibbleko.uf2`. Copy to `FLASHME/` for flashing (git-ignored). `cmake`/`ninja` are **not** on the default PATH — always set it as above. ## Hard rules Identical platform constraints to NIBBLE and WorkshopBio — see `docs/LESSONS.md` §3 for the full list with the reasoning. The short version: - `ProcessSample()` runs at **48 kHz** on core 0, inside a DMA interrupt. Allocation-free, no `malloc`, no blocking, no `float` in the hot path — fixed-point only. - Audio/CV I/O is signed 12-bit (`-2048..2047`). `KnobVal()` is unsigned 12-bit (`0..4095`). - **Never** do hardware setup in the `ComputerCard` constructor — it wedges the chip. Setup goes in `main()`. - `PICO_XOSC_STARTUP_DELAY_MULTIPLIER=64` is required for the Workshop Computer's crystal. Don't remove it. - The switch reads **Down for the first few milliseconds of every boot** (ComputerCard derives it from `knobs[3]`, off a ~60 Hz filter starting at zero, and zero decodes as Down). Latch any alt-boot from **one** reading after a settle window — never "Down seen at any point". Three sibling cards have shipped that bug between them. - `CVOutMillivolts()` / `CVOutMIDINote()` are **flash-resident**. Cache the last value and only call them on a change, or they put XIP reads in the hot loop. - Writing flash while ComputerCard runs **will hang the card** unless the five-step protocol in `docs/LESSONS.md` is followed. Two places do it: `calibstore.h` (no USB in that path, so it needs fewer steps — its header says which) and `webui.cpp`'s `EnterUploadMode()`/`WriteStagedBuffer()` (all five). Read `docs/LESSONS.md` before touching either. - **Every flash write with USB up ends in a reboot. There is no exception for small writes.** `USBCTRL_IRQ` must be masked, and once it is, TinyUSB cannot be resumed. An attempt to keep USB alive across a header-only write hung the card and left the sample directory half erased — the story is in `docs/LESSONS.md` and `webui.cpp`'s `CommitHeaderAndReboot()`. If you find yourself reasoning "the handler is not reached during the erase", stop: interrupts are not control flow. - **Never call `tud_task()` from inside a SysEx handler.** `HandleSysex()` runs inside `Task()`'s packet loop, so a nested call re-enters it and corrupts the parser mid-message. A reply too big for the 256-byte TX FIFO must be deferred to `Task()` and drip-fed instead — see `SendNextLibraryEntry()`. Doing this wrong made an upload land its audio and then report an empty library, which looks like a flash bug and is not. - **USB runs on core 1 and is MODAL.** TinyUSB is not initialised, and the card does not enumerate, until switch+B+D sets `WebUI::usbMode` — which is what keeps `USBCTRL_IRQ` (flash-resident) off the audio path while the card is being played. Core 1 is launched from `main()`, never the constructor. ## What's here Ported and adapted from `../WoskshopButtons` (NIBBLE) and `../WorkshopBio` (BioMimicry), namespace `nko` throughout (NIBBLE's was `nib`): | Path | Status | Source | |------|--------|--------| | `nibbleko.h` | Ported, trimmed | NIBBLE's `nibble.h`, minus `BootMode`/scale vocabulary (KEYS-only) | | `levels.h/.cpp` | Ported unchanged | NIBBLE's level detector + **the ghost rule** + `Shift()` | | `looper.h/.cpp` | Ported unchanged | NIBBLE's event looper | | `drums.h/.cpp` | Ported + PCM backend | NIBBLE's synth engine, plus sample playback and the D-bank transport effects | | `fastmath.h/.cpp` | Ported unchanged (namespace only) | NIBBLE's fixed-point helpers | | `samples_default.h` | Written new | The baked sample BANK plus `kVoiceSample`, the voice→bank mapping | | `samplestore.h` | Written new, wired in | `ResolveSample()` is called per hit from `DrumKit::TriggerVoice` | | `calibstore.h` | Written new, wired in | Flash-persisted calibration, sibling layout to `samplestore.h`; `SaveCalibration()`/`LoadSavedCalibration()` called from `main.cpp`'s `LearnTick()` and boot splash | | `webui.h` | Written new | WorkshopBio's message set on 12 flat slots + `MSG_SET_SOURCE` | | `webui.cpp` | Ported, tested on hardware | WorkshopBio's, with mode×variant collapsed to flat voices | | `tusb_config.h`, `usb_descriptors.c` | Ported | WorkshopBio's, byte-identical but for the product string | | `web/index.html` | Written new | Mockup's visual shell + WorkshopBio's connection/upload logic | | `tools/importbank.py`, `mksamples.py` | Written new | WAV → numbered bank entries; `importwav.py` supplies the DSP | | `fx.h/.cpp` | Written new | Twelve performance effects, four slots chained in series | | `tools/checksize.cmake`, `tools/bin2h.py` | Ported unchanged | WorkshopBio | | `tools/ghostsim.py`, `loopsim.py`, `dspsim.py`, `syntax.sh`, `checkyaml.py`, `kittable.py`, `crosscheck.py` | Ported unchanged | NIBBLE — all pass against the ported `.cpp` files (see Verifying changes) | | `tools/patsim.py` | Written new | Pattern transfer across the `webui.cpp` ↔ `web/index.html` seam, plus `SetPatternRaw()`'s trust boundary | | `ComputerCard.h`, `pico_sdk_import.cmake` | Vendored, byte-identical | NIBBLE — **do not edit** | | `main.cpp` | Written new | The mode machine, Drum Performance, LEDs, calibration. Structure follows NIBBLE's `main.cpp` closely | | `CMakeLists.txt` | Written new | Builds, with TinyUSB + `pico_multicore` and the `checksize` guard | | `info.yaml` | Written new | `draft: false`, `Status: Released`, v1.0.1 | ## The control surface The switch is a **mode selector**, not a trigger — this is the single biggest departure from NIBBLE, and `main.cpp`'s file header explains it in full. Briefly: hold the switch **Down** and press a button to choose a mode, which **latches** on release; then **Middle** plays that mode and **Up** plays and records it. Latching is what lets every mode be recorded through one mechanism, since Down and Up never need to be held at once. switch+A DRUMS (default) switch+AB UNDO switch+B MUTE switch+AC QUANTISE (cycle record grid) switch+C FX (twelve) switch+CD PLAY/STOP switch+D PATTERN (three) switch+BD WebUI setup **Singles commit on RELEASE; pairs fire IMMEDIATELY.** That asymmetry is not an oversight and should not be "fixed" — both halves fall out of the ghost rule. Four Voltages holds its last voltage, so the CV may already be sitting on the single you are trying to select, in which case pressing it produces no transition and a press-driven select would never fire. Reading the *state* on release is immune to that. A pair cannot get stuck the same way, because releasing a pair falls back onto one of its members and `levels.cpp` sets `current_` to that **single** — so the resting state is never a pair, and a pair press is always a genuine transition. See `main.cpp`'s header. ## Gotchas — the ones that have already bitten Every one of these was found on hardware, and most were invisible in the code. They are here because the same shapes will recur. ### FOUR VOLTAGES LATCHES. Almost everything traces back to this. The output holds the last-pressed level indefinitely. There is no rest voltage, and a "held" button is indistinguishable from one pressed and released. Three separate bugs came from forgetting it: - **A hold-to-store gesture cannot work.** Any "held for N ticks" test passes eventually, because the level just sits there. The pattern store was written this way and every recall became a store. The switch decides the verb instead. - **A press-driven mode select never fires** when the CV is already on the button you want. Hence commit-on-release for singles. - **A bare-press toggle can turn something on and never off**, because pressing the same button twice needs a transition that never happens. Hence shift-and-tap for mutes. - **A latched single is NOT evidence that a finger is on the button**, and anything that treats it as intent will misfire once the hand has gone. `FxUpdate()` assigns `fxParShift_` from a bare `Current()`, which is correct — but `FxSlotDepth()` then read the LIVE Main knob for that slot on that basis alone, so one slot permanently ignored its recorded depth curve and followed the knob's resting position instead. It presented as "FX depth records under shift C but not under B": C is mode C's own button, so the latch usually rested there, and what sounded like C working was the live knob being heard rather than the recording. The fix is the rule the recording side already followed — **the hand only wins while it is actually MOVING the knob** (`AutoKnob::HandOwns()`). A latched voltage plus a still knob means the recording drives. Any future "the player is doing X because the CV says so" needs the same test. ### Ordering inside ControlTick is load-bearing `FireCombo()` runs BEFORE `PlayControl()`, so anything `PlayControl()` assigns is one tick stale when a button handler reads it. That is harmless for values that only play, and destructive for values that decide whether to *write*: `PatternPress` read `recording_` this way and a stale-true reading silently overwrote a pattern slot. **Read `SwitchVal()` directly** in a button handler rather than the cached flag. ### Reverse and TapeStop are no longer the same shape They were grouped as "the transport effects, applied at trigger time" — and that was right for Reverse, which has to know where a recording ENDS before it can play backwards from it, so it can only ever be set as a voice starts. TapeStop had no such constraint. `SetTapeStop()` just installs a Q16 ramp, so applying it to a voice already ringing is legal and cheap — and a real tape stop grabs whatever is spinning. Applied only at trigger time, holding D+B during playback did nothing audible between recorded hits, which is exactly how it was reported from the bench. It now does both: `DrumKit::TapeStopAll()` brakes what is sounding, fired on the effect's RISING EDGE from `PlayControl()`, while `TriggerVoice()` still catches hits that start during the hold. **Edge, not level** — calling it every tick reinstalls the ramp at full scale and the kit never stops. ### `Current()` versus `Sounding()` is not a style choice `Sounding()` reports the pair for as long as the ghost is armed — i.e. after the tapping finger has lifted. Right for a DRUM, whose hit is still ringing. Wrong for anything momentary: FX read `Sounding()` at first and effects stayed latched after release, and re-tapping the same button produced no change at all, so one whole bank appeared not to work. ### Replacing the event array invalidates the cursor `cursor_` is an index into `events_`. Undo and pattern recall both replace that array wholesale, so it must be **rebuilt from the playhead**. Resetting it to zero looks safe and makes `Fire()` re-fire everything between the loop start and the current position, all on one tick. **And the rebuild has two ends, not one.** Walking forward from the playhead is only half of it: if the new array is SHORTER than the playhead's position — undoing a pass whose hits were all behind it, or recalling a sparser pattern mid-bar — the walk runs off the end and leaves `cursor_ == count_`. That is not a harmless resting place. `Fire()` only ever moves the cursor forward, and `Advance()` only rewinds it when `playHead_` reaches 0, so the loop plays NOTHING for the rest of the pass. It shipped that way and came back from the bench as "undo sometimes silences the loop til start of next pass" — intermittent-sounding, because it only bites when the undone pass was behind the playhead. Both paths now go through `Looper::RebuildCursor()`, which wraps to 0 on running out. Wrapping is correct rather than merely non-silent: the array is a RING sorted by fire time, so "nothing left this pass" and "the next event is the first one, next pass" are the same statement. `tools/loopsim.py` covers both ends — `test_undo_does_not_replay_the_bar_so_far` for the forward walk, `test_undo_past_every_surviving_event_does_not_go_silent` and `test_recall_a_sparser_pattern_does_not_go_silent` for the wrap. ### "Forget" a recorded value means HOLD it, not revert to the knob `AutoKnob::Forget()` (now `HoldWhereItIs()`) used to set `playback_ = -1`, which makes `Update()` fall back to the live knob position. That is correct when the loop itself is wiped (recalibration — `ForgetToKnob()` still does this), but wrong for Undo: dropping a deleted automation curve by handing the lane back to the *physical knob* means the filter jumps to wherever the player's hand happened to be resting, which can be minutes old and unrelated to the music. Reported from the bench as "undo puts the filter in weird places". The fix keeps `playback_` exactly as it was — an undo is silent in the tone, and a real hand movement or the next recorded event still takes over normally. Two different "forget" behaviours were needed because two different things were being forgotten: a curve with nothing left to belong to (reset to knob) versus a curve that is merely stale (hold). ### Every SysEx burst reply needs to survive a superseding request `MSG_PAT_GET` used to start a new drip-fed dump without cancelling one already in flight, and `Task()` sends one queued burst per pass — so a request that arrived mid-drain got a stale chunk from the *previous* dump ahead of its own header. It surfaced as an "unexpected reply" the browser couldn't explain, because the bytes were real, just from the wrong conversation. The general rule: any handler that arms a multi-pass `Task()`-driven reply (see `SendNextLibraryEntry()`, `SendNextPatternChunk()`) must clear every other send-in-progress flag before arming its own, and the browser side should skip non-matching replies for a bounded number of attempts rather than fail on the first mismatch — a busy card can take many `Task()` passes to catch up. ### A USB product string is not an identity The browser tool found the card by matching its MIDI port name against `/nibble|workshop|pico/i`. A user could not connect on macOS for weeks: card in WebUI mode, pads breathing, 1.2.0, enumerating fine, page stuck on "Waiting…". Every card in the Workshop System family ships the **same VID/PID** (`2E8A:10C1` — it is the Computer's own, and cannot change). macOS CoreMIDI caches a MIDI Studio name against that USB identity, so a card plugged in after a sibling **inherits the sibling's name**. Theirs came up as `MTMComputer`, and nothing matched. Windows truncates to ~31 characters or reports a generic `USB Audio Device`; ALSA truncates to `NIBBLE-KO (Work` and matched `/nibble/` by luck rather than design. The name is the HOST's guess at what the device is. Two things made it worse than a failed match. The retry watcher tested the **same name** the search did, so the Waiting… state could not resolve even once the card was properly in WebUI mode — the page ignored the very event it was waiting for. And it never said what it HAD seen, though it had the port list all along, which is why it cost the reporter two sessions weeks apart. `web/index.html` now identifies the card by **asking**: `probeForCard()` sends `MSG_HELLO` to each plausible port and takes whatever answers with a valid `MSG_INFO`. The name survives only as the search ORDER, so the ordinary case still costs one round trip. `MSG_HELLO` is read-only on the card, which is what makes probing a stranger's port acceptable. The general rule: **when a device cannot be told apart by its USB identity, it must be identified by what it ANSWERS.** Anything that matches on a name is matching on a string some other layer is free to rewrite. `tools/discoversim.js` models this; note that its two acceptance tests guard different things — the `MSG_INFO` type test rejects a loopback echoing our own HELLO back, the 21-byte length test rejects a v1 card. Mutation-testing found that the type test could be deleted with nothing noticing, which is how the third case in that model came to exist. ### The Python models are not decoration They have caught, before hardware: a stalling slew, a 0.33x soft clip, a looper sorting by the wrong key, a double-fire, and a lane-encoding drift where `loopsim` stored raw knob values while `looper.cpp` stored `knob >> 4` — invisible until the FX lane packed data into all eight bits. **If you change `levels.cpp`, `looper.cpp` or `drums.cpp`, change the model too**, or delete it rather than let it lie. ### A profiler is a claim, and it needs its own proof before you trust it The 1.2.0 CPU investigation took far longer than the fix did, because the *instrument* was wrong in three separate ways before it said anything true, and each wrong reading produced a confident wrong diagnosis: - **No compiler barrier around the timestamps.** `GlitchTick()` is inlined into `PlayControl()` (2500+ instructions), and with nothing pinning two `systick_hw->cvr` reads apart, the optimiser hoisted unrelated work between them. A function with no division and no loop over the event array measured at 17000+ cycles — the region timed was not the region named. Fixed by wrapping every read in `asm volatile("" ::: "memory")` on both sides. - **Peaks taken independently, then added.** Two costly things (`RecordKnobs`, `Advance`) run on different ticks by construction — one is gated by a countdown, the other by the loop wrap — so their two *separate* worst-case peaks routinely summed to more than the measured whole. The fix was to capture a breakdown from **one execution**, not to add two peaks and trust the arithmetic. - **A one-off cost mistaken for the steady state.** The boot splash reads saved calibration from flash inside `ProcessSample()`, once, before a note is played. Because peaks persist until read, that single tick set the headline "control tick" figure for every reading taken afterwards — a card sitting idle reported 289% of budget while its worst *genuine* tick was 72%. Boot is now excluded from profiling outright. The instinct to distrust was there from the start and got overridden anyway: early readings had sub-timings that summed to more than the total, which is mathematically impossible for a single execution and should have stopped the investigation immediately. It didn't, because each new reading was treated as more informative than the last rather than as another data point from a still-unproven instrument. **Calibrate the tool against a known quantity before trusting what it reports** — here, a 1ms `busy_wait_us` should have read back as ~192000 SysTick ticks, and did, but only once someone thought to check. The other standing lesson: **read the disassembly before believing a cycle count.** Every real finding this session — the six division calls, the functions still in flash, the reloaded loop invariants — was confirmed by `objdump`, not by timing. Every wrong turn was timing trusted on its own. Also: an "audible as grit" label written into a debug UI is a hypothesis, not a fact, and it is very easy to spend hours treating your own earlier guess as established evidence. ComputerCard double-buffers audio output (DMA re-arms *before* `ProcessSample()` runs, into the buffer for the *next* cycle), so an isolated overrun on one tick does not necessarily produce an audible artefact — "trust your ears over the counter" turned out to be the right call. ### The control tick has no divide instruction, and no flash either Two things bite anything added to `ControlTick()`/`PlayControl()`, both found by reading the disassembly rather than by timing: - **Cortex-M0+ has no divide.** Every `/` and `%` on a runtime value is a call into `__aeabi_idiv`/`ldivmod` at 100+ cycles, and a 64-bit divide is worse. `SamplesPerBeat()` is cached for exactly this reason (`RecacheTempo()` — any new writer of `tickInc_` must call it), and `GlitchTick()` uses lookup tables and reciprocal multiplies instead of the obvious arithmetic. - **`__not_in_flash_func` does not propagate to callees.** A RAM-resident function calling a flash-resident one still stalls on XIP. The control-rate path — `Looper::Advance/Fire/RecordHit/RecordKnobs`, `DrumKit::TriggerVoice`, `LevelTracker::Match`, `FxRack::SetSlot` — is annotated for that reason. Check the map, not the annotation on the entry point: ```sh grep -oE "^[0-9a-f]{8} <_ZN3nko[^>]+>:" build/nibbleko.dis | sort ``` `0x2000…` is RAM, `0x1000…` is flash. What may stay in flash is genuinely cold: the WebUI, calibration setup, and one-shot gestures. A third trap is in the compiler rather than the chip: `RecordLane`'s scan reloaded `playHead_` and `loopTicks_` from memory on EVERY iteration, because nothing proved the loop left them alone. Hoisting them into locals turned the body into register work. If a hot loop reads members, check what it compiled to before assuming it is cheap. ### Units: loop ticks are not control ticks Eight loop ticks is 42ms at 240bpm and 250ms at 40bpm — 125 versus 750 control ticks. Anything measured in one and counted in the other is wrong across most of the tempo range. The FX playback hold was written in control ticks first and expired instantly at slow tempos. ## What's NOT here — the actual next work Roughly in dependency order: 1. **Patterns are saved to the PC, not to the card.** The three slots are still RAM only and still die at power-off — deliberately. Persistence is the browser's job: the Patterns tab saves a slot as a JSON file and loads one back (`MSG_PAT_GET`/`MSG_PAT_DATA`/`MSG_PAT_SET`), so the file on disk is the permanent copy. No flash write is involved anywhere in that path, which is why it needs none of `docs/LESSONS.md`'s five-step protocol and never reboots the card — the same "instant, RAM only" shape as `MSG_SET_SOURCE`. A pattern carries no audio (`what` is a voice index), so one saved against any kit plays against any other. **Not yet exercised on hardware** — the codec round-trip and the wire framing are verified in software only. An on-card flash store was considered and rejected: it would duplicate the persistence the file already provides, and cost a flash region plus a write path for it. 2. **The WebUI covers the KIT, SAMPLES and PATTERNS.** Connect, assignment (`MSG_SET_SOURCE`, instant), saving the kit (`MSG_SAVE_MAP`), the whole sample library — upload, name, delete, erase — and pattern save/load all work. What is still missing needs NEW SysEx messages, none of which exist: - **mute-group assignment** — `MuteGroupOf()` is still a compile-time function, see item 4 - **loop setup** (bars, swing, quantise grid) — card-side only The Mutes and FX tabs, and everything on the Patterns tab below the save/load panel, are reference displays that say so rather than controls that quietly do nothing. 3. **Deleting a sample frees the SLOT, not the space.** Uploads append, and nothing compacts the region — only `MSG_ERASE` reclaims bytes. The Samples tab reports both numbers (live audio vs append watermark) rather than letting the difference look like a bug. A compaction pass would mean rewriting the whole region with audio moving under live offsets, which is a much bigger job than it looks. 4. **Loop LENGTH is still fixed at four bars.** `kLoopTicks` is only ever used in ordinary wrap arithmetic in `looper.cpp` — nothing is sized by it — so making it runtime-settable (2 bars, 1 bar) is a small change, and a natural WebUI setting. The quantise grid has already made this move; see `QuantGrid` for the shape. 5. **Mute groups are hardcoded.** `MuteGroupOf()` splits the kit three ways by voice index so the mode is testable; the real mapping belongs in the WebUI alongside sample assignment. Note that mutes NOT being recorded is a decision, not a gap — see the note in `MutePress`. A mute is a mixer move rather than part of the music, so it stays card state, persists across pattern recalls, and is outside Undo. Do not "finish" it. 6. **The published Map artifact's layout was never fixed.** Asked for on the bench and deliberately deferred: lay the ABCD gesture pads out as a 2×2 grid and the LEDs as 2×3 (matching `nibbleko.h`'s stated physical layout, `0 1 / 2 3 / 4 5`), and set the type larger. This applies to the standalone **mockup artifact**, not `web/index.html` — the shipped tool already got the 2×2 pads and the +3px type. Cosmetic, no code depends on it. ## USB: what is proven, and what is not **Confirmed on hardware, v2 library protocol:** entering WebUI mode (switch+B+D), enumeration, `MSG_HELLO`/`MSG_INFO`, live re-assignment to any source (synth/baked/user), `MSG_SAVE_MAP`, a WAV upload including the five-step flash write, uploaded audio and the slot map both surviving a power cycle, `MSG_LIBRARY`'s drip-fed listing, and `MSG_ERASE`. Two real bugs were found this way and are not hypothetical: `CommitHeaderLive()`'s attempt to keep USB up across a flash write hung the card (reverted — see "every flash write reboots", above), and `MSG_LIBRARY`'s first drip-feed attempt called `tud_task()` from inside `HandleSysex()` and corrupted the reply stream, so an upload would land its audio and then report an empty library. Both are written up in `docs/LESSONS.md`. **Not yet exercised on the current protocol:** `MSG_NAME` and `MSG_DELETE` (same commit path as `MSG_SAVE_MAP`, which is proven, but not separately confirmed), a library at or near `kMaxUserSamples` (32 entries) — everything tested so far has been a handful of samples — and the whole pattern-transfer trio `MSG_PAT_GET`/`MSG_PAT_DATA`/`MSG_PAT_SET`. Pattern transfer touches no flash, so it cannot hang the card the way an upload can; what is unproven is the drip-fed reply under a real browser's timing, which is exactly where `MSG_LIBRARY` went wrong the first time. The codec and the chunk framing are verified in software (a 512-event round trip through both implementations, high bits and the `kKnobEvent` flag included), but software cannot tell you whether the burst survives WebMIDI. **GET and SET share one buffer and one cursor**, so the two directions are not independent: `patBuf_`/`patCursor_`/`patBytes_` mean "the transfer in progress", whichever way it is going. A `MSG_PAT_SET` header therefore clears `patSending_` — without it, a load started while a save was still draining had `SendNextPatternChunk()` transmitting the ARRIVING pattern back out and advancing the cursor underneath the receive path, corrupting both at once. The browser serialises the two, so this only shows up on a retry or a second tab: it is a defence, not a normal path, and it will not appear on a bench test that saves and loads one at a time. Any new message reusing that buffer needs the same guard. **A v2 card cannot read a v1 upload.** `kUserMagic` changed, so `HaveUserSamples()` answers false and the card falls back to baked samples — which is the intended failure, not a bug. ## Known gaps in what IS written Things that compile and look finished but are not, worth knowing before trusting them: - **Reverse on a synth voice is hard to judge.** The swell runs over roughly the length the decay would have taken, derived from each voice's decay shift — one expression in `SetReverse()`. Whether that ratio is musical across the kit is unsettled. - **`kSelectMinTicks` (50ms) is a guess.** It is the debounce that stops a knock against the switch re-latching the mode. - **Gate is not tempo-synced.** Its rate divides the sample clock, so it does not lock to the loop. Deliberate — a gate that slowed with the pattern is just tremolo — but worth revisiting. - **The CV expansion is confirmed working on hardware** — CV In 2 override, Pulse In 2's random effect, and the CV Out 1/2 glitch gates (including the CV Out 2 → Pulse In 2 self-patch) have all been played, not just built. Two real bugs were found and fixed this way before this note was last updated: gate width was first a fixed millisecond figure (too short to hear the effect it triggered) and then sized against the beat (which overran the next candidate and held the output permanently high) — see `nibbleko.h`'s `kGateLongNum`/`kGateShortNum`/`kGateRatchetNum` for the fraction-of-the-division fix that actually worked. - **The glitch probabilities are tuned on paper, not by ear.** The curve itself (5% unpatched, 75% ceiling, divisions widening at `kChaosDivisionOpens`, ratchets at `kChaosRatchetOpens`) was checked arithmetically but not tuned by listening — confirmed present and audible on hardware, not confirmed as the *right* feel. Treat the constants in `nibbleko.h` as a first draft. - **An intermittent fault is recorded in `docs/OPEN-BUGS.md`**, seen once and never reproduced. ## Why the namespace is `nko`, not `nib` Every ported file's `namespace nib { ... }` was renamed to `namespace nko` and `#include "nibble.h"` to `#include "nibbleko.h"`. Nothing else changed in `levels.h/.cpp`, `looper.h/.cpp`, or `fastmath.h/.cpp` — they're logically identical to NIBBLE's, just renamed to avoid colliding if both projects are ever built against each other or diffed side by side. ## Layout | Path | Purpose | |------|---------| | `main.cpp` | Card entry, `ProcessSample()`, boot latch, the mode machine, switch gestures, LEDs, output routing | | `nibbleko.h` | Shared constants, combo indices, rates, LED helpers | | `levels.h/.cpp` | Level detection, settle/match, **the ghost rule**, `Shift()` | | `drums.h/.cpp` | Twelve voices: synth engine (working), sample backend (TODO), DJ filter | | `looper.h/.cpp` | Event loop: record, overdub, tempo, external clock | | `samplestore.h` | Flash layout for user-uploaded samples, per voice slot (not wired in) | | `calibstore.h` | Flash layout for the saved calibration levels — wired in | | `samples_default.h` | `__has_include` shim so the build works with or without baked samples | | `webui.h/.cpp` | USB-MIDI SysEx transport, upload state machine, pattern transfer — runs on core 1 | | `tusb_config.h`, `usb_descriptors.c` | TinyUSB setup; the product string is what the browser matches on | | `web/index.html` | Browser setup tool. Kit, Samples and Patterns save/load are live; other tabs are reference — see `web/README.md` | | `fastmath.h/.cpp` | Fixed-point helpers, sine LUT, PRNG | | `ComputerCard.h` | Vendored MTM library — **do not edit** | | `tools/` | Python verification models, `syntax.sh`, `checkyaml.py`, sample pipeline | | `info.yaml` | Workshop System card registry metadata (`draft: false`, released) | | `docs/LESSONS.md` | NIBBLE's handover doc — **read this first**, most of the reasoning behind what's ported here lives there | ## Starting work on this card Read **`docs/LESSONS.md`** first — it's NIBBLE's handover, written explicitly anticipating this card ("NibbleDrumMachine": percussion only, with real samples). §4 in particular ("For NibbleDrumMachine specifically") is the closest thing to a design brief that exists: what to take wholesale, what NIBBLE deliberately chose that should be *reconsidered* for a sample-based card (not inherited blindly), and the exact five-step flash-write protocol for uploads. ## Verifying changes **There is no host C++ compiler on this machine.** Same two things fill the gap as on NIBBLE: ```sh sh tools/syntax.sh # type-check every .cpp with the ARM compiler, ~1s python tools/ghostsim.py # the ghost rule + learn round-trip python tools/dspsim.py # DJ filter stability, soft clip python tools/loopsim.py # event ordering, overdub, tempo python tools/patsim.py # pattern transfer: wire framing + the trust boundary python tools/checkyaml.py # info.yaml parses AND is structurally complete node tools/discoversim.js # the browser finding the card, under any port name ``` `patsim.py` is the one model that spans a language boundary rather than mirroring a single `.cpp`. Pattern transfer is split between `webui.cpp` and `web/index.html` with no compiler across the seam, so it checks the bytes one puts on the wire against the offsets the other reads — and it reads the chunk size out of BOTH source files, because a mismatch there is silent: each side stays self-consistent, so a round trip within either one still passes. It was written by mutation-testing the checks, which is what caught `webui.cpp` claiming a chunk size was "the largest that fits" when the cap is not reached until twice that. `discoversim.js` is the other one, and it is JavaScript because the code it models is. Rather than mirroring the page, it **runs it**: it extracts the block between the `// >>> discovery` and `// <<< discovery` sentinels in `web/index.html` and evaluates it against fake MIDI ports, so it cannot drift from what ships. Keep that block DOM-free and dependent on nothing but `MFR` and `MSG`, or the model stops being able to load it. It takes an optional page path (`node tools/discoversim.js some/copy.html`), which is how its checks get mutation-tested against a deliberately broken copy. All pass, and the card builds clean with `-Wall -Wextra -Wdouble-promotion -Wfloat-conversion`. `tools/syntax.sh` does **not** link, so it cannot catch a missing symbol — run a real `cmake --build` before believing anything. It also missed the duplicate `kFlashBase` in `calibstore.h`/`samplestore.h`, because that only became an error once one translation unit included both. **There IS a node on this machine** (`/c/Program Files/nodejs/node`), so `web/index.html`'s script can be genuinely parsed rather than eyeballed: extract the `