NEUROPOD — Firmware Specification v1.2
MIDI-fed dual-path neuro bass synthesizer for Electrosmith Daisy Pod Rev5 (Seed, STM32H750, 480 MHz).
Design targets: KOAN Sound (FM articulation, formant talkiness, serial distortion), Copycatt (crest-shaped sub, granular/external texture, resample loop), Sorrow (clean detuned Reese, FM wub, near-zero drive) — all as regions of one parameter space, not separate modes.
Changelog v1.1 → v1.2 (T0 audit amendments, 2026-08-06)
All changes grounded in docs/HW_AUDIT.md; none alter the DSP design.
-
USB MIDI added, §12 exclusion struck. libDaisy v5.4.0-22 ships a class-compliant USB-MIDI device (
MidiUsbHandler,hid/usb_midi.h— the patched CDC middleware swaps in MIDI descriptors viausbd_mode). NEUROPOD runs USB MIDI onFS_INTERNAL(the Seed's microUSB): one cable = DFU at boot + MIDI in + telemetry out at runtime. CoreMIDI sees the Pod directly; the host harness needs no TRS MIDI interface. TRS MIDI in (Type A) remains fully supported — both transports feed the same parser/event queue. -
Telemetry over MIDI SysEx, not CDC. CDC and USB-MIDI cannot coexist in libDaisy: the descriptor choice is one global (
usbd_mode,Middlewares/.../usbd_cdc.c:788) shared by both USB ports — verified unchanged in upstream v8.1.0. Dev-build telemetry (NP_DEV_TELEMETRY) is therefore the same JSON line, 7-bit-packed into SysExF0 7D 4E 50 <payload> F7at 10 Hz on the USB MIDI device (device→host Tx exists:MidiUsbTransport::Tx). Harnesstelemetry.pyreads the CoreMIDI input port, not/dev/tty.usbmodem*. ANP_TELEMETRY_CDCbuild flag keeps plain CDC logging available for bring-up sessions that don't need MIDI. -
MIDI UART is RX-only. Encoder click shares PB6 with USART1_TX at the hardware level (Pod Rev5 schematic; BSP default claims it). NEUROPOD configures the MIDI UART with
Mode::RXonly, never TX. -
Flash-size watch. Internal flash is 128 KB; there is no
make program-appin libDaisy v5.4.0-22. If firmware outgrows 128 KB, the path isAPP_TYPE=BOOT_QSPI- Daisy bootloader (
make program-boot), which costs QSPI budget shared with the §9 preset bank.
- Daisy bootloader (
-
Hardware target line updated to Pod Rev5 — Rev4 and earlier lack the microSD slot and cannot run §4.7/T9/T10.
-
Output router added (2026-08-06, Gustav's request at T5). CC 88 selects:
SPLIT(default; OUT L = sub, OUT R = growl — the v1.1 behavior),STEREO_MIX(L = R = sub + growl),STEREO_GROWL(L = R = growl, sub omitted — a stereo feed for external stereo FX / resample loops that handle the sub separately). Value thirds: <43 / <85 / ≥85. 7a. CC 89 = comb key-track enable (2026-08-06). §4.4 defines the fixed-vs-keytracked comb frequency switch but the v1.1 CC map never assigned it. CC 89 ≥ 64 enables key-tracking; CC 29–31 then map to ratio 0.5–8 × f0 instead of fixed 100 Hz–2 kHz. 7b. CC 90 = EXT input trim, 0..+12 dB, default +6 dB (2026-08-06). Measured EXT loop gain on the rig was −14.4 dB (−6.5 dB firmware headroom scaler + −7.9 dB analog full-scale mismatches, cue DAC → AK4556 in and Pod out → interface in). The trim recovers firmware-side loss and gives the §4.7/T8 resample loop a gain-staging control. 7c. Mod system extensions (2026-08-06, Gustav's request at T7).- Per-slot rate is free OR synced: CC 70–73 lower half selects the §5.1 division table (which already includes 1/8T, 1/16T, dotted 1/8); upper half selects a free rate 0.05–25 Hz log.
- Global swing (CC 91, 50–75%): division phases are computed through a paired-cycle warp — first cycle of each pair stretches to 2s, second compresses to 2(1−s). Verified natively (ratio 2.003:1 at s=2/3).
- Cross-modulation: the destination table gains SLOT1..4_RATE and SLOT1..4_DEPTH. Rate cross-mod shifts free rates ±2 octaves or nudges synced slots ±3 division steps; depth cross-mod adds ±1 to the target slot's depth. One LFO can now wobble another's rate and depth.
- FM oscillator OscC (§4.1) implemented here since FM_INDEX/FM_RATIO_FINE are first-class mod destinations (no earlier stage delivered it).
-
§4.2 APF snippet corrected (2026-08-06). The v1.1 snippet (
y = -x + z; z = x + k*y) has H = (z⁻¹−1)/(1−kz⁻¹), which nulls DC — a highpass, not an allpass, contradicting §4.2's own "without changing the input spectrum". Replaced with the canonical one-multiply allpass (y = k*x + z; z = x - k*y, H = (k+z⁻¹)/(1+kz⁻¹)); magnitude flatness verified to 0.0001 dB in the native suite and end-to-end on hardware in T5. -
FX section + growl voicing (2026-08-06, Gustav's request post-T11: growl "thin and scratchy").
- Growl level CC 93 (0–127 → 0..1 linear) scales the growl layer at the router, independent of sub level (CC 19).
- Two serial FX slots on the growl path only (post-SVF, pre-router;
the sub stays clean): CC 94/98 type = OFF/DRIVE/CHORUS/REVERB
(
val·4/128), CC 95/99 p1, CC 96/100 p2, CC 97/101 mix (dry/wet). DRIVE: FastTanh ×1..15 + one-pole tone. CHORUS: DaisySPChorus(rate 0.1–4 Hz, depth). REVERB: dependency-free mono Freeverb (8 damped combs + 4 allpasses, delay lines in SDRAM) — DaisySP v1.0 vendors no reverb. This narrows §12's "no reverb" exclusion: a send-FX reverb is still out of scope; a growl-thickening room is in. - Texture injection voicing: CC 103 one-pole high-pass on the texture signal pre-injection (0 = off, else 20 Hz–2 kHz log) kills foley rumble before it hits the shapers; CC 104 bake gain (0..+12 dB) drives the baked texture harder into the band drive/shapers so it fuses with the growl instead of sitting on top.
- CPU valve order formalized (§6): shaper oversampling 4×→2× is the first derate (engaged >88 %, released <78 %), grain cap 16→8 the second (>92 %/<82 %). Both live in the audio callback (wedge-proof). The 2× path's alias floor is native-gated at −60 dB (measured −97.7 dB at 130.4 Hz, tanh drive 20).
- New CCs join presets and boot defaults; harness DEFAULT_PATCH mirrors.
- CPU budgets restated (Gustav-authorized 2026-08-07: "we can loosen
the gates if they are close enough"): the FX round costs ~+13 CPU
points under max-drive OS load vs the pre-FX build (true anchor 0.567,
re-measured with the decontaminated harness — the old 0.683 golden
included EMA decay from the previous test's patch). OS-heavy patches
(t04 max-drive, t07 crossmod) budget <= 0.75; both-FX + texture
stress (T12) budgets <= 0.88 dropout-free (raised from 0.85
with the #10 mod envelopes); the 0.70 nominal
ceiling holds for the core voice (t01-t03/t05/t06 all measure
<= 0.55). QSPI-XIP code placement moves the heavy gates +-2-3 points
per rebuild; the standing fix (ITCM hot-path placement) is staged in
firmware/np_qspi.lds+CopyItcmText(disabled: boots into a hang that needs SWD to debug — see Makefile note). - NOLOAD-section rule:
.dtcmram_bss/DSY_SDRAM_BSSobjects are never zeroed by startup and their C++ member initializers never run — everything placed there must be explicitly initialized before audio starts (FxSlot::Init / Freeverb::Attach). Found via a garbage reverb write index scribbling memory out-of-bounds every sample.
-
Per-band gain CCs, assignable mod envelopes, LFO retrigger (2026-08-07, Gustav's request).
- Band gains exposed: CC 105/106/107 drive D_BAND_GAIN0-2 (gain = 2v², unity at CC 90, +6 dB max) — the dests existed since T4 but had no direct CC; they remain mod destinations.
- Two assignable AD mod envelopes: linear attack (CC 108/110, 1-501 ms) + exponential decay (CC 109/111, 10 ms-4 s), triggered on fresh note attacks, block-rate. Available as slot sources ENV1/ENV2: the CC 82-85 source quarters keep their legacy values (0 QLFO, 32 S&H, 64 AEnv, 96 RndWalk); the UPPER half of the AEnv quarter (80-95) selects ENV1 and of the RndWalk quarter (112-127) ENV2 — existing presets decode unchanged.
- LFO retrigger + start phase: CC 64-67 (slots 1-4): 0 = free-run (legacy behaviour), 1-127 = fresh attacks reset the free-running LFO to start phase (val-1)/126 x 360 deg. Clock-synced slots ignore retrig (phase is clock-owned).
- New CCs join presets, boot defaults, DEFAULT_PATCH, and the webapp.
-
Note-stack + channel-mode robustness (2026-08-07, live-play stuck notes).
- NoteOn dedups the held-note stack (a repeated NoteOn for a held note — key bounce, looped DAW clips — double-pushed; one NoteOff then left a phantom entry and the note stuck). NoteOff removes all instances.
- CC 120-127 parse as ChannelMode in libDaisy, NOT ControlChange: the CC 123 panic never reached ApplyCC over real MIDI. ChannelMode is now handled — AllSoundOff (120) and AllNotesOff (123) both cut the voice.
- Webapp: the QLFO shape menu is hidden for non-QLFO sources — its re-encode walked the CC out of the source's range (AEnv + shape 4 silently became ENV1), which made env bindings appear impossible.
- T14 hardware gates: duplicate-NoteOn release, out-of-order overlap release, CC 120 cutoff (post-release < -45 dBFS).
-
Sub & growl pitch mod destinations (2026-08-07, Gustav's request).
D_SUB_PITCH/D_GROWL_PITCHappended to the destination table (indices 35/36; mod-only, no CC base — center 0.5 = played note, full depth = +-12 st). Applied at block rate on top of the glided voice note; the growl offset also drives comb keytrack, FM carrier and texture keyratio so the whole growl core tracks the bent pitch.- T15 hardware gates: QLFO->growl-pitch excursion 21.5 st p2p at +-12 nominal, ENV1->sub-pitch drop +12 st settling to +-0.1 st, neutral-patch pitch unshifted (mod-only dest sanity).
-
Growl osc core bundle (2026-08-08, Gustav's request: "limited control over the growl osc").
- CC 47 A/B balance (equal-legacy crossfade, center = historical 0.5/0.5); CC 68 packed: detune mode (fine +-60ct / wide +-1oct / interval-quantized 0,3,4,5,7,12 st) + B-polarity invert.
- CC 69 + D_OSC_PHASE: B phase offset 0..0.5 cyc (movable comb / poor-man's PWM, modulatable). CC 113 + D_WAVE_MORPH: saw<->square morph (square = saw(t)-saw(t+0.5), both bandlimited, -56 dB alias).
- CC 114 packed osc mode: DUAL / SYNC (B hard-synced to A, one-sided polyBLEP step correction, -41.8 dB alias native) / UNISON (4-voice, +-0.4x detune spread, valves to dual under load) / UNI+SYNC.
- CC 86 + D_FM_FB: OscC operator feedback (snarl). CC 117 + D_PD_AMT: OscC phase distortion (CZ knee). CC 112 + D_NOISE_LVL: pre-drive noise (one-pole LP'd white, audio-taper).
- T16 hardware gates: wide-detune extremes land 4x apart, invert-B cancels 45 dB, 90-deg phase offset nulls h2 by >10 dB, morph kills even harmonics, sync refolds an interval pair onto the master grid, FM feedback spreads the spectrum, noise lifts inharmonic share, everything-on stress survives with valves.
-
Stability root-cause + eco mode (2026-08-08/09). A week of "wedges" traced to ONE root: the DWT cycle counter silently never starts on clean power-ons (LAR unlock insufficient), leaving the DWT-driven CPU valves INERT — the everything-on patch then exceeded 100 % CPU, the audio IRQ ran back-to-back, and the main loop starved until forced reboot. Fixes, all live:
- Valves + telemetry CPU now run on max(wall-meter, DWT): the wall meter over-reads under IRQ pressure, the safe direction for a shed-load valve. Telemetry CPU reads ~15-20 pts higher than the old gauge for the same patch — gates re-baseline accordingly.
- IWDG watchdog (~8 s) + fault-capture handlers + main-loop position tracer (RTC backup regs, reported via telemetry after reboot): a hang now self-heals in seconds and identifies itself.
- Telemetry JSON buffer 256->384 (frames silently died once uptime digits grew); bounded MIDI drains + RX-ring re-init (a corrupted queue fed garbage events incl. phantom CC118 bootloader jumps).
- CC 9 ECO MODE: pin shaper OS at 2x (alias -97.7 dB native). Measured: 15 pts CPU saved at drive 30 (0.81 -> 0.66). In presets, DEFAULT_PATCH, and the webapp Drive panel.
- Harness: io_audio stream deadline (a wedged interface froze whole suite runs), pytest-timeout on all hardware tests.
-
Motion engine (2026-08-11, Gustav's "Motion Engine Spec v1.1 delta" — scenes, morph, sequencer, dice, freeze, gesture bus). The full delta is implemented as an additive layer; the spec doc lives with this changelog entry as the reconciliation record. New sources:
scene.h(param table + capture + bounds),motion.h(engine glue),seq.h(pattern/steps/locks),dice.h(4 roll levels),freeze.h,fshift.h,motionstore.h(QSPI). Native gates:firmware/test/test_motion.cpp; hardware gates:tests/test_t17_*.- Scene param table: N = 37 (32 continuous + 5 discrete) curated
mid/growl-path params (
kSceneParams). The spec's N ≤ 32 target is exceeded because the shipped growl core (changelog #13) earns it. Sub path, output router, FX slots, texture SLOT selection and MIDI config stay global — dice/morph/locks cannot address them at all. Interp domains: freq-shaped params are already log-warped in v-space (DOM_LIN = pitch-space); drives blend in log-gain (DOM_DB); the shifter in linear Hz (DOM_HZ); discrete snap at dominant weight, applied at note boundaries via a pending-CC queue. - CC remap (spec §4.2 collided with the long-standing map): the motion surface lives on free CCs — 2 morph X, 3 macro Y / motion macro, 4 scene select, 5 morph time (clock divs), 6 spice, 8 chaos, 10 dice trigger (value = level 0..4), 11 gesture shape, 12 freeze mix, 13 freeze repitch, 14 shifter Hz, 15 shifter mix. Sub level stays CC 19.
- NRPN → SysEx: standard NRPN is impossible (CCs 6/38/98/99 all
assigned since T7/T12). The extended surface is SysEx
F0 7D 4E 50 <cmd> <args> F7: ext params (transport, pattern/bank select, scale/root, freeze mode/slices, punch arm), step-field writes, user p-lock write/clear, seed read/write, xfade-pair flags, freeze ops, dice, capture, store save/revert. Builders:host/nphost/motion.py. - Sequencer: 64-step patterns (16/32/64 playable), per-step scene/note (0 = hold-last)/glide/gate (1/16ths, 0xFF tie)/morph/ curve/cond/gesture + dual p-lock layers (gen dice-owned, user sacred). Resolution rule exactly per spec §2.1; slide locks interpolate to the NEXT locked value across intervening steps. Gate choke ~5 ms on BOTH paths (sub follows). Live MIDI notes always override step notes. MIDI Start/Stop drives transport.
- Dice: xorshift32-seeded, 4 levels + nudge, macro-plane roll
space with chaos blend, family recipes B/C/D (incl. freeze-mutation
when a buffer exists), Markov sequence gen with bar-bias + ±2-step
lock anchoring, spice-scaled gen deltas (movement params first).
Spice is quantized to the 7-bit grid at roll time and stored per
pattern (
roll_spice) — a float spice that didn't round-trip skewed the rng draw count and broke seed re-derivation. - Persistence:
MotionStoreat QSPI 0x740000 — 16 banks + 16 patterns (dense core + sparse user locks, 192-lock cap; gen layer re-derived from seeds on load = the §7 "seeds + locks only" model). Committed freeze slots are deferred to v2 (16 × 270 KB doesn't fit QSPI next to the app + banks). - Freeze: bar-quantized capture of the trimmed mid output (pre glue-FX) into SDRAM float buffers (2 × 6 s), A/B generations with one undo, loop/one-shot/sliced playback, repitch snap zones −24/−17/−12/0 ± 1 st fine. Capture is panel/SysEx-only; dice may only USE an existing buffer.
- Frequency shifter: Hilbert-pair SSB (Niemitalo 4+4 allpass, coefficients SQUARED — the raw published values are the 'a' params), post-recombine/pre-SVF, base rate. Native gate: image −49 dB, carrier −117 dB.
- Panel: encoder held >1.5 s toggles motion surface ↔ legacy pages (legacy unchanged). Motion surface per spec §4.1 with two deviations: pattern select is SysEx-only (encoder-hold is the mode toggle), and shift-knob lock writes go to two SysEx-assignable edit params (default drive b1 / SVF cut) — the Pod has no per-step trig keys.
- Deferred: audio-crossfade morph fallback (per-pair flags are stored and settable; the dual mid-path render is v2 — CPU budget work), PCA macro upload, per-param gesture transfer curves, noise-generator env-keying beyond the existing #13 noise source.
- Webapp motion editor (same day): Motion/Scenes panel (XY morph pad, scene select/alt-click-capture, dice bar, spice/chaos/gesture, bank + store save/revert/factory/reset), full Sequencer panel (transport, pattern/length/clock/scale/root, 16-64 step grid with per-step field editing over SysEx, user p-lock writer, seed entry — a WRITE-ONLY mirror: device pattern state has no readback), Freeze panel (mix/repitch/mode/slices/arm/undo/clear), shifter sliders in Tone. Telemetry gains tr/rq/mo/sxn (trims, normalization queue, motion state, SysEx-in count); header shows a motion chip. SysEx ext 15 = explore toggle (webapp parity with the B1 tap).
- Latent bug fixed in passing: CC 55 shaper unpack read
int val = val;(self-initialization, UB) since T4 — survived by register-allocation luck; found by -Wmaybe-uninitialized when the switch grew.
- Scene param table: N = 37 (32 continuous + 5 discrete) curated
mid/growl-path params (
-
Chipset efficiency audit (2026-08-12, Gustav's "are we wasting cycles?"). Ground truth from RM0433/AN4891/ES0396 against the shipped hot paths. Three findings, all fixed and rig-verified:
- The 480 MHz budget never existed:
pod.Init()defaults to boost=false → 400 MHz (VOS1). Every §6 estimate, the 160k-cycle DWT block budget and the valve spike thresholds silently assumed 480. Nowpod.Init(true)(VOS0; needs rev-V silicon per ES0396 — booted clean on the Rev5 Pod, so its Seed is rev V). +20 % CPU, and the DWT constants are finally calibrated. - The chain computed zeros at ~74 % CPU: envelopes multiply the output to silence at the END of the chain, but every stage still ran. A silence gate (both envelopes idle, no live/EXT/seq gate, not freeze-recording) skips texture/freeze/voice generation while clock/seq/motion timing and FX tails keep running. Preset idles: 48-88 % → 12-19 %. Attacks unaffected (gate opens before the skip test in the same block).
- The DWT leg of the #14 valve protection was inert:
g_cyc_prev_block/g_cyc_blk_emawere read by the valves, telemetry and the 148k spike detector but never WRITTEN. Now stamped per block. (With boost, wall and DWT gauges finally agree.) - Also: stopping the sequencer and disabling the engine in the same main-loop drain swallowed the note-release edge (env stuck in SUSTAIN, silence gate never engaged) — the transport-stop release now runs before the enable early-return.
- 480 MHz landscape (cpuprof presets): idle 12-19 %, played notes 43-78 %, worst sequencing case STEPPER 75.7 %. What ISN'T waste: the halfbands are true polyphase with mirrored contiguous rings, FastTanh replaced libm deliberately, combs skip at fb=0 — the T4-era DSP micro-optimization holds.
- ITCM revived (same day): the parked CopyItcmText hang is root-caused WITHOUT SWD — __libc_init_array runs np:: static constructors before main(), and that ctor code lived in .text._ZN2np* → ITCM, so the very first ctor jumped into uninitialized ITCM. Fix: a priority-101 constructor (sorts first in .init_array, itself plain QSPI .text) performs the copy. ITCM origin moved to 0x40 (a function AT address 0 == nullptr). np::/daisysp::/AudioCallback = 38.7 KB of 64 KB. Result: KOAN under a held note 78.6 % → 46.1 % (~32 pts were QSPI-XIP fetch stalls), full factory bank 22-48 % played, everything-on 67.8 % with NO valve shed, and the per-rebuild layout lottery is structurally dead. One interaction fixed: the async RMS trim landed after the blend-skip cache was built and normalization silently never applied (loudness ±0.55 → ±7.6 dB) — the job now marks the plane dirty. Remaining untested: -O2 vs -O3/-ffast-math.
- The 480 MHz budget never existed:
-
Movement/feedback round (2026-08-12, Gustav's play-test). Growl octave on CC 7 (−24…+24, GM-safe zones), crossover Q on CC 1 as scene param 32 (disc indices shifted to 33-37, MotionStore v2/v3), frequency shifter + MORPH_X/Y promoted to mod destinations (plane modulation is the consolidated neuro primitive), dice tamed and re-based to C2 (the "octaves too low" report was a C1 root). Dice tuned toward the groove/wobble band with
python -m nphost.motioncheck(0.54 vs 0.5 target, 2 measured iterations);... presetsscores all 16 slots.- CC-neutrality rule (learned twice): CCs that controllers emit
spontaneously — 1 (mod wheel), 7 (GM volume), 11, 64, 120-127 —
MUST map their idle/reset value to neutral.
MapXoverQis neutral-at-zero/add-only; CC 7's 0-octave zone spans 41..101 to absorb the GM-reset 100. - Preset recall must be a pure LOAD:
SelectBank/SelectPatternsave the current state back before switching, so recall clobbered factory banks with transient state ("patches sound different"). Recall usesLoadBank/LoadPattern; save-back is SysEx-edit only. - Silence gate must ring down state on engage (frozen comb energy coloured every subsequent attack), and RMS trims anchor to the bank's first scene-A measurement, never an absolute reference.
- CC-neutrality rule (learned twice): CCs that controllers emit
spontaneously — 1 (mod wheel), 7 (GM volume), 11, 64, 120-127 —
MUST map their idle/reset value to neutral.
-
ITCM corruption root-cause (2026-08-13). The "ITCM subtly corrupts the DSP" finding from #16's re-park was never an ITCM, linker or Q-function fault. libDaisy's
DaisySeed::Initskipsqspi.Init()when the app runs from QSPI (BOOT_QSPI), leavingQSPIHandle::pimpl_null; everyPersistentStorageErase()/Write()then takes theCheckProgramMemoryrefusal path and storesstatus_ = E_INVALID_MODE(byte 3) through the null pimpl_ — a stray byte write to absolute address 0x5C. On stock builds that is unused ITCM RAM and invisible; with hot-path code there it rewrote one instruction (in the #17 build, thevmov.f32loading Q insidenp::Lr4Split::SetFreq— the layout lottery picks the victim, which is why pre-#17 + ITCM measured clean).- Fix:
np::XipStore(src/npstore.h) replacesPersistentStorage— adopts valid saved blobs through the QSPI memory map, never calls the broken write path. ITCM is ON again. - Corollary: QSPI persistence has NEVER worked from this app. Preset and motion saves are RAM-only; factory content reloads each boot. Real persistence needs a RAM-resident erase/program routine (v2 backlog).
- Tripwire, kept armed: MPU region 15 makes ITCM read-only after
the copy;
MemManage_Handlerrecords a stray writer's PC/LR/r0-r3 to RTC BKP7-15 (telemetrygd[3]= 0x717717), disarms, and returns without advancing the PC so the store retries and boot continues. Any future near-null scribble names itself instead of mangling the DSP. SysEx 0x0D = memory peek (5×7-bit LE address + word count ≤24) — added for this hunt, kept for forensics. - Gate: the init-saw harmonic fingerprint (note 41, f0 87.31 Hz) is mandatory for any placement/compiler change — max harmonic error <1.5 dB, between-harmonic floor <−70 dB. Fixed build: 1.1 dB / −73.7 dB. T17's relative gates pass a uniformly-corrupted chain and caught none of this; an ear did.
- ES0396 2.2.9 (AXI SRAM read corruption)
READ_ISS_OVERRIDEis set inmain()as a prophylactic — rig-tested, NOT this bug.
- Fix:
-
Clean CC surface, decoupled lanes, motion grammar, chain (2026-08-13, spec v1.3 adoption).
- PARAM ID vs CC NUMBER. These were the same thing and are now
separate. A param id is the internal address and is stable forever;
it is numerically the CC number that parameter used before this
change, which is exactly what keeps saved presets, the PresetBank
format and scene snapshots valid across the renumber. The wire map
(
firmware/src/ccmap.h) is curated: 16-31 performance, 32-47 basic edit, 48-55 mod slots, 56-60 texture/routing, plus 102 (BPM) and 118 (bootloader). Everything else is addressed by param id over SysEx 0x0E; new deep-edit controls live in their own id space on SysEx 0x0F. Reserved numbers — 1, 2, 7, 11, 64, 120-127 — never reach a parameter at all and feed the performance mod sources instead. HostSession.cc()now speaks param ids;wire_cc()tests the CC surface itself. - MIDI mod sources: SRC_VEL / SRC_WHEEL / SRC_NOTE, routable to any destination through the ordinary slots.
- Lane decoupling, default ON: note and motion are separate read
heads over the same steps, each with its own length, rate and
window. 16 note steps under a 12-step motion lane repeats every 48;
that phasing is most of what stops a part sounding looped.
linkrestores lockstep. The motion head owns its own wrap counter and cond rolls or the lanes are not separable. - Motion dice (level 5): rolls a one-bar motif ONCE and lays it out under a grammar — AAAB / AACB / ACAB / AA_B — spending the randomness on the turnaround. Self-similar by construction, which is the fix for "wild and mostly garbage". LIFT scales the motif's existing deltas rather than re-rolling them; re-rolling loses the family resemblance that makes the repeat read as a phrase.
- Chain sequencer: plays PATTERNS as a part (A A A B - D C D B), with per-slot rate/window overrides so a turnaround costs no extra pattern data. Loads are flagged in the callback, executed in the main loop.
- Phase distortion, fixed: it only warped the FM modulator behind
an
fm_pm > 0gate, so with the index at zero it did nothing. Now on the audible oscillators with f0-dependent depth limiting (the primary alias guard, previously absent), ~20 ms slew and a blep window scaled by the warp slope. Osc core selectable: saw/square, PD sine, or PRR — the windowed multi-cycle ramp, kept separate because resonant CZ waveforms are not phase distortion. - Noise engine: AM at the played fundamental (flutter), S&H with ratio >= 4 enforced, Karplus-Strong comb with fractional allpass delay and a NEG odd-harmonic mode, then tracked HPF max(150 Hz, 2f0) -> tracked BPF -> tilt. The HPF sits AFTER the mode block so hold-lowpassing and comb resonance cannot leak back under the split. Two defaults were only findable by measuring: the crunch band is floored at 2f0 (at 1x it parked on the fundamental) and PLAIN dropped its legacy dark one-pole (that was the sub-mud).
- PARAM ID vs CC NUMBER. These were the same thing and are now
separate. A param id is the internal address and is stable forever;
it is numerically the CC number that parameter used before this
change, which is exactly what keeps saved presets, the PresetBank
format and scene snapshots valid across the renumber. The wire map
(
-
Dual motion lanes + crossfade (2026-08-13, spec v1.3 §1.2). Each pattern carries two motion lanes and one crossfade. Lane A is the Step array (shared with the note lane); lane B is a compact parallel array of motion payload only. Each lane has its own length, rate and window, so they phase as well as blend. Rates are 1/8x..8x in 9 steps around unity at index 4.
- The crossfade acts on the delta vectors, which is where the movement lives — blending two rolled lanes visits states neither endpoint holds. Measured with a spectral-trajectory probe (the loudness envelope is dominated by the note lane and cannot see this): the midpoint correlates 0.904 / 0.789 against lanes that correlate 0.963 with each other.
- Scene targets interpolate, they do not switch. Handing the scene over at the midpoint was a genuine discontinuity — 1.81 dB RMS step and a 1 kHz centroid jump at the boundary. The scene vector is continuous data, so it lerps; only event-like fields (gesture, curve) and the discrete/modset push pick a side. Max adjacent RMS jump across the sweep: 0.19 dB (gate 2.0).
D_MORPH_XFmakes the crossfade a first-class mod/gesture destination.ADV_MO_DICE_LANEpoints the grammar dice at either lane — that is how you get two related-but-different lanes worth blending.- Measurement traps recorded: host SysEx builders must return UNFRAMED payloads (mido adds F0/F7; framed ones fail silently as out-of-range data bytes), and the lane dice advances its own seed per roll — a crossfade sweep must run on FIXED lane content or it compares different material at every setting.
- Known limitation: lane B has no user-lock / slide / punch-record machinery. It is generated movement to blend against lane A, not a second fully-editable lane.
-
Sweet-spot randomizer (2026-08-13, spec v1.3 §7). Interactive genetic search over the scene space: generate 8 around the anchor -> audition -> lock -> breed. SysEx 0x11; host driver
python -m nphost.sweetspot.- Locks auto-arm on touch (
OnParamTouched), following MutaSynth: a deliberate edit is a strong statement about what matters, so it is adopted into the gene AND frozen. Locking is what makes the search converge instead of wander. - Screens are ADVISORY. Shipped-in "good sound" thresholds would
silently discard usable candidates and are uncalibrated for this
instrument, so centroid/crest/sub-leak are measured, printed and
logged but never re-rolled on. Only sub-leak gets a verdict
(< −12 dB below 120 Hz) — a measurable invariant, not taste. Kept
candidates append to
reports/sweetspot_log.json, the calibration set for fitting real bands in phase 2. - Candidate 0 is a mild variation and candidate 7 a wild one: a
uniformly adventurous generation leaves nothing to fall back to.
Mutations clamp to the
kSceneParamsmusical bounds; discrete genes flip rarely (churning them changes identity, not voicing). - Verified natively (
firmware/test/test_sweetspot.cpp): spread controls dispersion monotonically and is exactly 0 at 0, locks survive Generate and Breed, 200 generations stay in bounds, crossover takes 33/33 genes from a parent. Rig: a generation spans ~1100 Hz of centroid, 6/8 pass sub-leak, and breeding two chosen parents narrowed the next generation 2156 -> 924 Hz around their mean. - Gotcha:
SsBeginre-anchors on whatever is currently auditioned, so repeated begins walk the anchor forward. An early rig measurement of the spread control looked broken because of exactly this — for algorithm behaviour, trust the native test.
- Locks auto-arm on touch (
-
Patch persistence on SD (2026-08-13). Saves survive a power cycle. Patches live on the microSD card already mounted for the foley loader:
NPPRESET.BINandNPMOTION.BINin the root, 8.3 names so they are visible to anyone who puts the card in a computer.- Each blob carries magic + version + length + a rotate-and-add checksum. Card content overrides factory at boot; a rejected file leaves factory in place. That validation is the point — stale stored contents masquerading as valid settings is exactly how this engine has been bitten before (changelog #18), so truncation, a card from another unit, or an older firmware's file must not load as garbage. All five rejection paths are verified.
f_syncbefore close, or a card pulled between write and close leaves a valid-looking directory entry over stale data.FILis a static, never a local: the stack is DTCMRAM and FatFS hands its window buffer to the SDMMC DMA, which cannot reach DTCM (the same trap the foley loader documents).- Rig-verified: rolled an AAAB pattern, saved, cold-booted through the bootloader, pattern returned identical.
- QSPI persistence remains unfinished (
qspiwrite.h, hook commented out). The driver is written and verified safe to call — ITCM-resident with zerobl/blxso nothing can branch into QSPI mid-window, memory-mapped restore works, and a real crash was fixed along the way (SCB_InvalidateDCachediscards dirty stack/global lines; clean-then-invalidate). The blocker is that the write-enable command never raises TCF after leaving memory-mapped mode, so the first sector erase never starts; an EN toggle did not clear it. Next suspects: DCR/FSIZE and prescaler are inherited from the bootloader and never re-asserted; the abort may not complete before CCR is rewritten; the chip may be mid-SIOO from the 0xEB reads.
-
Webapp state sync (2026-08-13, spec v1.3 §4). The editor was write-only — it set device state but never read it, so a dice roll, a patch switch or the chain advancing left the page showing whatever was last typed into it.
- SysEx 0x12 = device -> host state dump: pattern and both lanes' geometry, crossfade, chain (with the playing slot), scale/root/spice and per-step scene/note/gate/glide/cond/gesture plus lane B's scene. Sized by the pattern's actual length, not the 64-step maximum.
- Emitted on request AND unprompted after every bulk change (dice,
pattern load, chain default, motion reset, sweet-spot begin), each
bumping a monotonic
state_versionso a late frame cannot repaint over a newer one. - The dump takes the telemetry slot, it does not ride alongside. Queued behind a ~355 B telemetry message in the same pass it was silently dropped — the USB MIDI TX buffer had no room. Telemetry missing one 100 ms frame is invisible; a lost dump leaves the editor stale. Sharing the slot also collapses a burst of edits into one dump.
- The command byte is load-bearing: telemetry and the dump share
the
F0 7D 4E 50header and the page fed everything with that header toJSON.parse. Byte 4 tells them apart (telemetry has no command byte; its first packed byte is the MSB group of{= 0). - libDaisy's 128-byte
SYSEX_BUFFER_LENis an inbound limit only, which is why the command surface stays many short writes while the dump runs the other way at ~190 B. - Verified: a dice roll yields an unprompted dump with the version
advanced and the grid changed, and the page's JS decoder and
nphost's Python decoder agree byte-for-byte on a real dump.
-
Factory voicing, note groove, deep-edit UI, step editor (2026-08-13).
- Movement voicing (MotionStore v4): factory scenes captured their modset from preset CC snapshots where every slot depth is centred at zero with no destination, so the bank morphed between four STILL pictures (measured depth 0.04-0.08 vs a 0.3 target). Each preset now has its own routing, applied to all four of its scenes so it survives a morph. Grounded in published neuro technique, which corrected two assumptions: harmonic content leads (FM index / wave morph, not a filter sweep) and opposition reads as organic (filter opens while drive backs off, hence mostly negative slot-1 depths). Depths stay moderate — three serial distortion stages follow, so a full sweep saturates flat.
- Factory note groove: thirteen banks fell through to an EMPTY
pattern, and
Pattern::Clearleaves gate 16 (full, UNTIED), so the sub re-attacked on all sixteen steps — clicky, and it swamped the movement metric with envelope attacks.FactoryNoteGrooveties the run and breaks the tie only just before an intended attack (a new note on a tied step slurs instead of retriggering), with an octave lift at the phrase end. Four variants; the three deliberately percussive banks keep their own gates. Verified: 3 attacks per bar, was 16. Every slot improved — the four flagged patches roughly doubled (EXHALE 0.057->0.12, CZAR 0.044->0.09, SECONDGEN 0.061->0.11, GLIDEPATH 0.045->0.13) and worst-case groove+wobble went 0.34 -> 0.59. - Webapp param path fixed: the page still sent raw CC numbers, which the #19 map mostly no longer assigns, so most controls had silently stopped working. It addresses parameters by id over SysEx 0x0E now.
- Deep-edit surfaces added: noise instrument, oscillator core (saw / PD sine / PRR), both motion lanes with rates and crossfade, dice grammar and target lane, chain (type "AAAB" or "DCDB"), and the sweet-spot search.
- Motion step editor: click-to-select or arm-and-stamp a shape/scene across steps, lane tabs for A and B. SysEx 0x02 gains fields 16-19 for lane B, which had no per-step write. Every step write bumps the state version and the page re-requests a dump instead of optimistically repainting — the device stays authoritative, which is what stops the UI drifting.
- Remaining: mod_depth is still under 0.3 and the scorecard now flags "decouple sub from modulation path" on the livelier patches — movement landing in the fundamental band, inherent to modulating harmonic content under the 120 Hz split. Worth its own pass.
-
Clock latched to a phantom external clock (2026-08-13).
System::GetUs()isGetTick()/(GetFreq()/1e6), so on the 200 MHz timer it wraps at2^32/200 = 21,474,836 us— every ~21.5 s — and that modulus is not a power of two, so(uint32_t)(now - then)does not give the correct elapsed time across the wrap, it gives a small wrong one. The lock test was exactly that subtraction, so once any clock had ever ticked the engine declared itself locked for 500 ms at every wrap, with the PLL draggingbeat_postoward a staletick_count: an audible lurch with nothing plugged in, about three times a minute.- Time since the last tick is now ACCUMULATED per audio block and reset when the tick counter moves — wrap-free by construction.
OnTickmeasures its interval fromSystem::GetTick(), the raw counter (true2^32modulus, so modular subtraction is correct), converted with1e6/GetTickFreq().- Rule: never subtract
GetUs()timestamps.test_clock.cppmodels the real modulus and gates it. - Rig: 95 s with no clock (four-plus wraps) stays "free"; a real clock still locks and releases within 500 ms of stop.
-
Synced-rate glide across the scene plane (2026-08-13) — with a logged DEVIATION. Rates blend bilinearly across the vector plane in LOG space (cycle length is multiplicative: half-way between 1/4 and 1/16 is 1/8). The blend lives in
PlaneTarget, not in a scene-switch hook — the perform path never callsSceneTarget, it moves the plane, which is why a start-to-target glide armed on a switch never fired. Rate CCs are no longer pushed byRequestSceneSwitch; that push landed after the blend and snapped the value back at the midpoint.- DEVIATION from intent, revisit before shipping. A new rate is staged and adopted at the next CYCLE BOUNDARY, so the glide is stepped at cycle rate. Gustav's intent is the opposite trade: keep it locked in a scene, but when morphing between them get it moving across the bars — phase continuity during a morph matters more than strict bar-lock while it is happening.
- Why the code does otherwise: a synced slot derives phase from
the ABSOLUTE beat position,
frac(beat_pos/cycle). That is what bar-locks it, and it also means changing the cycle mid-flight LEAPS the phase — at beat 100 a 1 % rate change moves phase by a whole cycle. Gliding continuously threw the LFO to a random phase every block and T17 morph-safety caught it as 7 dB of pumping. - The fix when revisited: integrate phase incrementally
(
phase += beat_delta / cycle) so a changing cycle glides and drifts across bars, then re-lock to the absolute phase with a gentle pull only while the rate is stable. Swing needs cycle-pair parity tracked from the integrated cycle count, sinceSwungPhasederives it from absolute beats today. Morph-safety (<= 4 dB p2p) is the regression detector for this class. - Staged values must also be adopted by IDLE slots, or a stale rate arrives when the slot next wakes — that made morph-safety order-dependent.
1. Hardware target and I/O map
| Resource | Assignment |
|---|---|
| Audio out L | SUB path (clean, phase-coherent) |
| Audio out R | GROWL path (processed) |
| Audio in L | External input for resample mode (Octatrack return) |
| Audio in R | Unused v1 (reserved: sidechain trigger) |
| USB (Seed microUSB) | Boot: DFU flashing. Runtime: class-compliant USB-MIDI device — notes, CC, clock, program change, pitch bend in; SysEx telemetry out (dev builds) |
| TRS MIDI in (Type A) | Alternate MIDI input: notes, CC, clock, program change, pitch bend (RX-only UART, see changelog #3) |
| microSD (SDMMC1, 4-bit) | Foley WAV storage for TextureEngine (§4.7) |
| Knob 1 / Knob 2 | Page-dependent live parameters |
| Encoder (turn) | Page select (8 pages) |
| Encoder (push) | Enter/exit preset menu |
| Button 1 | Shift (alternate knob parameter) |
| Button 2 | Mod-slot latch toggle / preset save (hold 1 s in menu) |
| LED 1 (RGB) | Page colour |
| LED 2 (RGB) | Clock state: green solid = MIDI clock locked, dim amber = free-run, red blink = CPU > 90 % |
| Headphone out | Analog monitor tap of OUT L/R (TPA6110, post-line-out — cannot affect measurements) |
Sample rate 48 kHz, block size 16 (0.33 ms). Shapers oversampled 4× internally (§6).
2. Top-level signal graph
┌────────────────────────────────────────────────────┐
MIDI note ──► │ VOICE (mono, legato, portamento) │
│ │
│ SUB PATH │
│ SineCrest osc ─ phase reset ─ SubSat ─ EnvS ──────┼──► OUT L
│ │
│ GROWL PATH source select │
│ ┌ OscA (BlSaw, -det) ┐ │
│ │ OscB (BlSaw, +det) ├─ mix ─┐ │
│ └ OscC (FM mod → A,B)┘ ├─► APF×N ─► LR4 3-band
│ TextureEngine (granular) ────┤ │ │
│ Audio-in L ──────────────────┘ │ │
│ ┌─────────┴────────┐ │
│ │ per band: │ │
│ │ drive → shaper │ │
│ │ → fold → comb │ │
│ └─────────┬────────┘ │
│ recombine ─► SVF ─► EnvG ───────┼──► OUT R
└────────────────────────────────────────────────────┘
MIDI clock ──► ClockEngine (24 ppq) ──► 4× ModSlot {QLFO | S&H | Env | RndWalk}
Source select (growl input): INT = oscillators, EXT = audio in L, MIX = both.
EXT closes the hardware resample loop: OT mangles OUT R, returns into IN L, passes
through APF → multiband again. Envelope follower on EXT gates EnvG retrigger.
3. Sub path
The sub is sacred: nothing after it shares processing with the growl.
3.1 Oscillator — SineCrest. Phase-reset to 0 on every NoteOn (legato retrigger configurable). Crest shaping per the Copycatt/Mr. Bill wide-peak principle — hold pressure high longer per cycle for perceived loudness at equal peak:
// crest c ∈ [0,1]: 0 = pure sine, 1 = flattened wide crest
inline float SineCrest(float ph, float c) {
float s = sinf(TWOPI_F * ph);
return (1.f - c) * s + c * tanhf(2.5f * s) * 1.048f; // norm for tanh(2.5)
}
Optional asymmetry a ∈ [-0.3, 0.3] adds even harmonics: s += a * s * s * sign(s)
pre-tanh. Keep |a| small — even harmonics above H2 defeat the purpose.
3.2 SubSat. Single soft-clip stage, hard ceiling at -1 dBFS, drive range 0–6 dB. Weight, not harmonics. No oversampling needed (content < 200 Hz).
3.3 EnvS. AD envelope. Attack 3–40 ms (floor 3 ms — prevents cycle-cut click at 40 Hz), decay 50 ms–4 s, optional gate-sustain mode. Exponential segments.
3.4 Octave. Sub tracks MIDI note − 12 or − 24 (switchable). Pitch quantized to semitones always (no bend on sub by default; enable via CC 77).
4. Growl path
4.1 Oscillators. All bandlimited (polyBLEP saw).
- OscA/B: identical saws, detune ±0–60 cents (Reese). Sorrow region: 10–25 ct, drive ≈ 0.
- OscC: sine FM modulator into A and B phase. Ratio ∈ {0.5, 1, 1.5, 2, 3, 4, 5, 7} (quantized) + fine ±0.05; index 0–8. KOAN attack: fast env → index. Sorrow wub: slow-attack env or QLFO → index at low ratio.
- Mix: A/B level, C is modulator-only (no direct out).
4.2 Allpass cascade (the phasing pillar). N = 6 first-order allpass stages before the band split. Corner frequency fc ∈ 100 Hz–4 kHz (log), shared across stages, and a mod destination — sweeping fc rotates phase, changing waveform shape into the shapers without changing the input spectrum. This is why the distortion character moves. Feedback path r ∈ 0–0.85 around the cascade turns it into a phaser proper (notches audible pre-distortion) for the metallic KOAN flavour.
struct Apf1 { float z=0.f; // canonical one-multiply allpass (changelog #7)
inline float Process(float x, float k){ float y = k*x + z; z = x - k*y; return y; } };
// k = (tan(pi*fc/fs)-1)/(tan(pi*fc/fs)+1); H = (k + z^-1)/(1 + k z^-1)
4.3 LR4 3-band split. Crossovers f1 ∈ 60–400 Hz, f2 ∈ 400–4 kHz. Each split = 2× cascaded 2nd-order Butterworth (Q = 0.7071) LP and HP; invert the mid-band polarity of the second split OR use matched-phase recombination — verify flat-sum with a sweep test in stage T3 (§10). Structure:
x ─► LR4(f1) ─┬─ low ──────────────────────────────► Band0
└─ high ─► LR4(f2) ─┬─ mid ──────────► Band1
└─ high ─────────► Band2
Band0 low is within the growl path — it is NOT the sub. Typical: growl high-passed at f1 ≈ 120 Hz with Band0 gain low, sub owns < 60 Hz (spectral separation rule).
4.4 Per-band processing (identical struct × 3, independent params):
drive0–40 (lin gain into shaper)shapertype ∈ {TANH, FOLD, RECT, HARD, SINEFOLD}- TANH:
tanhf(d*x) - FOLD: tanh then triangle-fold at threshold
t(§ previous prototype) - RECT:
|x|blend 0–1 (octave-up, KOAN "talking" harmonic) - HARD: clamp ±t (aggressive neuro)
- SINEFOLD:
sinf(d*x)(smooth west-coast fold)
- TANH:
comb— tuned feedback comb, freq either fixed 100 Hz–2 kHz or key-tracked (ratio × f0), feedback 0–0.9, wet 0–1. Three key-tracked combs at formant-ish ratios = the vowel. Mod destination on comb freq = the talk.gain−inf–+6 dB into recombine.
4.5 Output SVF. 2-pole multimode (LP/BP/HP morph), cutoff 40 Hz–8 kHz, res 0–self-osc (clamped 0.95). Post-recombine. Cutoff is the primary performance mod destination.
4.6 EnvG. ADSR, retriggered by NoteOn, or by EXT envelope-follower threshold in resample mode.
4.7 TextureEngine — granular foley layer (Copycatt pillar).
Storage model. Foley WAVs load from SD (/foley/*.wav, 16/24-bit mono or stereo-summed,
any rate ≤ 48 k, resampled on load) into SDRAM as int16 at boot and on preset recall.
32 slots × 10 s max = ~31 MB of the 64 MB SDRAM. The audio callback never touches
FatFS — SD reads happen only in the main loop with audio muted or during boot; SDMMC
uses the IDMA path (4-bit bus, FAST). Directory scan alphabetical → slot index;
texture.json optional per-card slot mapping.
Granulator. Up to 16 concurrent grains from the active slot buffer:
size15–250 ms, Hann window (precomputed table)density2–80 grains/s, scheduled per block; free or clock-synced (grains spawn on tick boundaries of a chosen division — texture locks to the grid like everything else)position0–1 into the buffer +spray0–500 ms randomizationpitch−24–+24 st, linear-interp read;keytrackswitch pins grain pitch to the played note (foley becomes tonal — the KOAN woody-material trick)reverseprobability 0–1
Routing. bake switch:
- BAKE (default): texture sums into the growl source pre-APF — phase-rotated, band-split, driven, and folded with the oscillators. The foley becomes timbre.
- OVERLAY: texture sums post-SVF into OUT R, level-enveloped by EnvG. Clean layer.
Level. tex_level 0–1 in the source mix; env-follower ducking optional (texture
ducks under oscillator transients, amount 0–1) so attacks stay clean.
Grain read cost ≈ 8 % CPU at 16 grains (int16 fetch + interp + window); grain count degrades gracefully to 8 if the CPU valve trips.
5. Modulation system
5.1 ClockEngine. Counts MIDI 0xF8 (24 ppq) from either transport (USB or TRS). Derives phase for divisions {4, 2, 1, 1/2, 1/4, 1/8, 1/16, 1/32, 1/8T, 1/16T, dotted 1/8}. Free-run fallback at internal BPM (CC-set, default 172) when no clock for > 500 ms. Song-position pointer + 0xFA resets tick counter (phase-locks LFOs to the MPC arrangement).
5.2 ModSlot × 4. Each: source → depth(±) → destination.
Sources:
- QLFO — clock-division phase → shape {sine, tri, saw↑, saw↓, square} →
quantize(steps)→ one-pole slew. The grid ÷ steps ÷ slew model.steps=0continuous;steps∈2..16stepped-on-grid (the neuro signature). - S&H — uniform random resampled on tick boundary of the chosen division, then slew. Optional quantize to N levels.
- AEnv — AD envelope retriggered per NoteOn (KOAN pluck: fast A on FM index).
- RndWalk — bounded random walk stepping per tick (Copycatt texture drift).
Destinations (any slot → any): APF_FC, XOVER_F1, XOVER_F2, DRIVE0..2, FOLD0..2, COMB_F0..2, SVF_CUT, SVF_RES, FM_INDEX, FM_RATIO_FINE, DETUNE, SUB_CREST, OSC_MIX, BAND_GAIN0..2, TEX_LEVEL, TEX_POSITION, TEX_SIZE, TEX_DENSITY, TEX_PITCH.
RndWalk → TEX_POSITION is the canonical Copycatt texture-drift patch.
5.3 Update rate. Mod evaluated per audio block (3 kHz at block 16); destination values one-pole smoothed at audio rate (5 ms) to kill zipper noise. Never per-sample mod evaluation — CPU budget rule.
6. Oversampling and aliasing policy
- Whole graph at 48 kHz except per-band
drive→shaper→fold, which runs in a 4× wrapper: 2× cascaded halfband upsample → shape at 192 kHz → 2× halfband decimate. Polyphase FIR halfbands, 32 taps each, coefficients const-generated. - RECT and HARD are the worst aliasers — they force the 4× path; TANH at low drive may bypass (drive < 4 → no OS) as a CPU valve.
- Oscillators polyBLEP; FM phase-mod of polyBLEP saw is acceptable at bass f0.
- Combs and SVF at base rate.
CPU budget (estimates, 480 MHz):
| Block | ~% |
|---|---|
| 3 osc + sub | 6 |
| APF ×6 | 2 |
| LR4 splits | 4 |
| 3 × (OS shaper + comb) | 30 |
| SVF + envs + mod | 5 |
| TextureEngine (16 grains) | 8 |
| MIDI/UI/smoothing | 3 |
| Total | ≈ 58 % |
Headroom target ≤ 70 %; LED 2 red-blink above 90 %. CPU valve order: shaper OS 4×→2×, then grains 16→8.
7. MIDI implementation
7.0 Transports. Two, feeding one event queue and one parser state per transport:
- USB MIDI (
MidiUsbHandler,FS_INTERNAL— the Seed's microUSB): primary for the dev/test rig; class-compliant, visible to CoreMIDI. Also carries SysEx telemetry device→host inNP_DEV_TELEMETRYbuilds (changelog #2). - TRS MIDI in (
MidiUartHandler, USART1 RX-only — changelog #3): primary for the MPC/Octatrack performance rig. Clock (0xF8) is accepted from whichever transport last sent 0xFA or, absent a start, from the first transport that ticks; simultaneous clocks on both transports are not supported (last-start wins).
7.1 Voice. Mono, last-note priority, legato (no env retrigger on overlap — configurable), portamento 0–500 ms constant-time glide. Overlapped notes an octave up + porta = the classic neuro glide articulation. Pitch bend ±2 st (growl only by default). Velocity → EnvG level (curve CC-set).
7.2 CC map (all 7-bit, smoothed):
| CC | Param | CC | Param |
|---|---|---|---|
| 16 | OscA/B detune | 32 | Slot1 depth |
| 17 | FM ratio (quantized) | 33 | Slot1 dest |
| 18 | FM index | 34 | Slot2 depth |
| 19 | Sub level | 35 | Slot2 dest |
| 20 | Xover f1 | 36 | Slot3 depth |
| 21 | Xover f2 | 37 | Slot3 dest |
| 22 | Drive band0 | 38 | Slot4 depth |
| 23 | Drive band1 | 39 | Slot4 dest |
| 24 | Drive band2 | 40 | EnvG attack |
| 25 | APF fc | 41 | EnvG decay |
| 26 | Fold band0 | 42 | EnvG sustain |
| 27 | Fold band1 | 43 | EnvG release |
| 28 | Fold band2 | 44 | EnvS attack |
| 29 | Comb freq b0 | 45 | EnvS decay |
| 30 | Comb freq b1 | 46 | Sub crest |
| 31 | Comb freq b2 | 47 | Sub octave (−12/−24) |
| 48 | SVF cutoff | 52 | APF feedback r |
| 49 | SVF resonance | 53 | Source select INT/EXT/MIX |
| 50 | SVF mode morph | 54 | Portamento time |
| 51 | Comb feedback (global) | 55 | Shaper type b0/b1/b2 (packed) |
| 56 | Texture level | 60 | Texture pitch |
| 57 | Texture slot select | 61 | Texture spray |
| 58 | Grain size | 62 | Texture bake/overlay + keytrack (packed) |
| 59 | Grain density | 63 | Texture duck amount |
| 70–73 | Slot1–4 rate/division | 74–77 | Slot1–4 steps (0=cont) |
| 78–81 | Slot1–4 slew | 82–85 | Slot1–4 shape/source |
| 86 | Sub bend enable | 87 | Texture clock-sync division (0=free) |
| 88 | Output router SPLIT/STEREO_MIX/STEREO_GROWL (changelog #6) | 89 | Comb key-track enable (changelog #7a) |
| 90 | EXT input trim 0..+12 dB (changelog #7b) | 118 | Dev: reset to DFU bootloader (value 127) |
| 102 | Internal BPM |
Program change 0–15 → preset recall. CC 119 value 127 → save current to active slot.
7.3 Robustness. Running status supported on the UART transport (USB MIDI packetization makes it moot there); clock parsed in UART RX interrupt context but only counted there — all work deferred to audio-block boundary (no long work in ISR). USB MIDI RX likewise only enqueues in its callback. Active sensing ignored.
7.4 Telemetry (dev builds, NP_DEV_TELEMETRY). Every 100 ms the main loop emits
one SysEx message on the USB MIDI device: F0 7D 4E 50 <payload> F7 (0x7D =
educational/prototyping manufacturer ID; 4E 50 = "NP"). Payload = the JSON line
from the build prompt ({"t_ms":…, "cpu":…, "blocks":…, "grains":…, "os":…, "clk":…, "assert":…}) packed 7-bit (4-byte → 5-byte groups). Emission from the main
loop only; never from the audio callback or ISRs. NP_TELEMETRY_CDC swaps SysEx for
plain CDC logging in bring-up builds that don't init USB MIDI.
8. Control surface (8 pages × 2 knobs × shift)
| Page | LED 1 | Knob1 / Knob2 | +Shift |
|---|---|---|---|
| 1 OSC | white | detune / FM index | FM ratio / sub level |
| 2 TONE | blue | APF fc / APF fbk | xover f1 / f2 |
| 3 DRIVE | red | drive b1 / fold b1 | drive b0 / drive b2 |
| 4 VOWEL | yellow | comb f b1 / comb fbk | comb f b0 / comb f b2 |
| 5 FILTER | cyan | SVF cut / res | mode morph / — |
| 6 MOD | purple | slot1 depth / rate | steps / slew |
| 7 ENV | green | EnvG A / D | EnvS A / D |
| 8 TEX | orange | tex level / grain size | density / position |
| — SYS (encoder-push menu) | — | source sel, sub crest, porta, BPM, tex slot, bake/overlay |
Knob pickup mode (no jumps): knob must cross stored value before latching. Button 2 latch: toggles Slot 1 depth between 0 and stored (performance mute of the wobble). MIDI CC and knobs write the same parameter store; last writer wins.
9. Presets
PersistentStorage<PresetBank> in QSPI. 16 slots × full parameter struct
(~256 B each, version byte + CRC). Recall via program change or encoder menu;
save via CC 119 or Button 2 hold in menu. Factory bank ships three anchor
presets: KOAN_TALK (FM + rect + keytracked combs + keytracked texture, fast env
slots), CATT_SUB (crest 0.7, growl low, baked texture with RndWalk position drift),
SORROW_REESE (detune 18 ct, drive ≈ 0, fold off, slow FM wub slot, dark SVF, no
texture). Presets store the texture slot index and filename hash, not audio —
recall re-verifies the hash against the loaded card and falls back silently to
texture-off if the card differs.
10. Build stages (each independently testable)
| Stage | Deliverable | Pass test |
|---|---|---|
| T1 | MIDI note → SineCrest → OUT L, phase reset | Scope: identical waveform start every NoteOn; no click at A ≥ 3 ms. Also: encoder click registers with MIDI active (PB6 share) |
| T2 | A/B Reese + SVF → OUT R, porta/legato | Audible beat at 25 ct; glide on overlap |
| T3 | LR4 split, drive=1 fold=off | Sine sweep thru recombine flat ±0.5 dB |
| T4 | Per-band shapers + 4× OS | FFT: alias products < −60 dB @ drive 20 |
| T5 | APF cascade + feedback | Sweep fc: audible shape-shift pre-shaper |
| T6 | Combs + key-tracking | Vowel motion under comb-freq mod |
| T7 | ClockEngine + 4 ModSlots | LFO steps land on MPC grid at 130 & 172 BPM |
| T8 | EXT resample mode + env follower | OT loop: 3-pass resample degrades gracefully |
| T9 | SD → SDRAM foley loader | 32 slots load < 5 s; zero FatFS calls in callback (assert) |
| T10 | Granulator + bake routing + clock-sync | 16 grains at 80/s no dropouts; grains land on grid |
| T11 | Presets + full CC map + paging | Program change < 1 block glitch; hash fallback works |
11. Source layout
src/
main.cpp // init, audio callback, UI loop
voice.h // SubPath, GrowlPath structs
osc.h // SineCrest, polyBLEP saws, FM
apf.h // Apf1 cascade + feedback
xover.h // LR4 splits
band.h // drive/shaper/fold/comb per band
shapers.h // 5 shaper fns + Oversampler4 (halfband FIRs)
modslots.h // ClockEngine, QLFO, S&H, AEnv, RndWalk
texture.h // granulator: grain pool, scheduler, bake routing
sdload.h // SDMMC init, WAV parse, SDRAM slot allocator, hash
midi.h // parser, CC map, USB+UART transports, SysEx telemetry
params.h // ParamStore: id, range, curve, smooth, preset serde
presets.h // PersistentStorage bank
ui.h // pages, pickup knobs, LEDs
Parameter access pattern: every DSP block reads smoothed floats from ParamStore
at block top; MIDI/UI/mod write targets. Single source of truth, no races
(all writes from main loop or block boundary, never ISR).
12. Deliberate v1 exclusions
Reverb/delay — Octatrack's job. Polyphony — neuro bass is mono. Live sampling into
texture slots (record IN L → SDRAM slot) — v2, the WavWriter path exists in libDaisy
but adds UI complexity. Hot-swap SD detection — v1 requires reboot on card change.
USB MIDI — TRS only in v1 (struck in v1.2 — see changelog #1; USB MIDI is now
the primary dev-rig transport and telemetry carrier). USB CDC telemetry — replaced
by SysEx telemetry (changelog #2); CDC exists only behind NP_TELEMETRY_CDC for
bring-up.