memlnaut-nisps/CLAUDE.md

311 lines
19 KiB
Markdown
Raw Normal View History

2026-02-08 15:49:16 +01:00
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
MEMLNaut-NISPS (Neural Interactive Shaping of Parameter Spaces) is firmware for the MEMLNaut hardware platform - a custom embedded audio device built on Raspberry Pi Pico (RP2040). It implements interactive machine learning for real-time audio synthesis and processing, enabling users to shape sound parameters through reinforcement learning.
Project documentation: https://musicallyembodiedml.github.io/memlnaut/approaches/nisps
feat: extract nisps-core platform-agnostic ML library Extract the interactive machine learning engine from MEMLNaut-NISPS firmware into a standalone, platform-agnostic C++20 header-only library. What is nisps-core? ------------------- NISPS (Neural Interactive Shaping of Parameter Spaces) core is a parameter mapping engine. It takes N input parameters (joystick, sensors, audio features) and maps them to M output parameters through an interactively-trained neural network. Use it to control: synthesizers, effects, lights, robots, game parameters, or anything that responds to continuous control data. Key Features ------------ - Header-only: No compilation needed, just include and use - Platform-agnostic: Pure C++20, works anywhere - Zero dependencies: Only standard library - Interactive learning: Train by demonstration - Lightweight: ~3,500 lines of optimized neural network code - Flexible: Map 1-100 inputs to 1-100 outputs Architecture ------------ Core components: - IML: High-level interactive ML interface - MLP: Multi-layer perceptron (feedforward neural network) - Dataset: Training data management with replay memory - Layer/Node: Neural network building blocks - Loss: MSE and categorical cross-entropy functions - Utils: Activation functions (sigmoid, ReLU, tanh, etc.) Transformations Applied ----------------------- ✅ Removed Arduino/RP2040 dependencies (Serial, SD, Pico SDK) ✅ Removed audio synthesis code (nisps-core is control-only) ✅ Added nisps namespace to all code ✅ Converted to header-only library with _impl.hpp pattern ✅ Updated to C++20 (required for std::span) ✅ Removed platform-specific serialization ✅ Replaced debug macros with no-op stubs ✅ Added comprehensive documentation and examples Files Added ----------- - nisps-core/README.md: Complete documentation and API reference - nisps-core/CHANGELOG.md: Version history and migration guide - nisps-core/include/nisps/*.hpp: 13 header files (~3,500 lines) - nisps-core/test/main.cpp: XOR test demonstrating basic usage - nisps-core/examples/simple_mapping.cpp: Interactive demo - nisps-core/CMakeLists.txt: Build system for tests Testing ------- ✅ Compiles with GCC 14.2 (C++20) ✅ All tests passing ✅ Successfully instantiates networks and runs inference Performance ----------- - Inference: 1-10 µs for small networks (2-10-10-4) - Training: 10-100 ms for 100 examples, 1000 iterations - Memory: ~1 KB per hidden neuron Migration from Embedded IMLInterface ------------------------------------ Old (embedded): IMLInterface iml(n_inputs, n_outputs); New (nisps-core): nisps::IML<float> iml(n_inputs, n_outputs); All method names remain the same, just add the namespace. Related ------- - Implements: NISPS_CORE_EXTRACTION_PLAN.md - Task graph: NISPS_CORE_TASKS.md - Origin: MEMLNaut-NISPS firmware - Docs: https://musicallyembodiedml.github.io/memlnaut/ Co-authored-by: Claude Code <claude@anthropic.com>
2026-02-08 17:47:23 +01:00
## NISPS Core Library
The `nisps-core/` directory contains a platform-agnostic C++20 extraction of the interactive ML engine. This header-only library can be used in any C++ project for neural network-based parameter mapping.
**Key differences from firmware**:
- ✅ Platform-agnostic (no Arduino/RP2040 dependencies)
- ✅ Header-only (just include and use)
- ✅ C++20 (uses std::span)
- ✅ Namespaced (`nisps::`)
- ❌ No audio synthesis (use it to *control* your synth)
- ❌ No hardware drivers
**Use case**: Control synthesizers, effects, lights, game parameters, or any system that responds to continuous parameters.
See `nisps-core/README.md` for complete documentation and examples.
## Web Playground
The `playground/` directory contains a browser-based interactive demo of the NISPS ML engine. No build step or dependencies — serve statically.
- **2 inputs** (virtual joystick X/Y) mapped through a `[3, 32, 48, 64, 126]` MLP to **126 outputs**
- **Four output modes**:
- **Visual**: first 20 outputs control a Canvas2D flow-field particle system
- **Synth (C15)**: all 126 outputs control the C15 WASM synthesizer
- **MIDI CC**: outputs mapped to configurable MIDI CC messages via WebMIDI
- **Audio Canvas**: 36 outputs drive a generative audio sampler
- **Two learning modes**: Examples (set slider targets, add examples, train) and RL Feedback (thumbs up/down with exploration noise)
- **Serve statically**: `cd playground && python3 -m http.server`
- **Mobile-first**: designed for touch/foldable phone use
Key files: `js/nisps/` (WASM engine + dataset), `js/ui/` (visualizer, joystick, controls, input pipeline, control surface), `js/synth/` (C15 bridge, param map, arpeggiator), `js/a-app.js` (immersive app wiring).
### WASM ML Engine
The immersive app (`a-immersive.html` / `a-app.js`) uses a WASM-compiled MLP for all inference and training. The legacy JS engine (`iml.js`, `mlp.js`, `layer.js`, `node.js`) is still used by the three older playground variants (`app.js`, `b-app.js`, `c-app.js`) but is slated for migration to WASM (see meml-dj9).
**Architecture:**
```
Main thread Worker thread
WasmIML (nisps-wasm.js) nisps-wasm-worker.js
├─ WASM instance A (persistent) └─ WASM instance B (lazy)
│ inference() — every rAF tick trainEx() — off-thread
│ inferBatch() — heatmap sampling returns: weights + loss curve
│ moveWeightsEx() — RL exploration
│ evalLoss() — non-destructive query
│ getLayerStats() — per-layer health
│ getWeights/setWeights — sync w/ worker
└─ Dataset (JS-side, dataset.js)
├─ FIFO ring buffer (max 100 examples)
└─ computeWeights() — recency/spatial/combined sample weighting
```
**WASM bindings** (`playground/wasm/nisps_bindings.cpp`) expose a flat C API compiled via Emscripten:
| Function | Purpose |
|----------|---------|
| `nisps_mlp_create/destroy` | Lifecycle |
| `nisps_mlp_inference` | Single forward pass |
| `nisps_mlp_infer_batch` | N forward passes in one call (heatmap) |
| `nisps_mlp_train` | SGD training, returns final loss only |
| `nisps_mlp_train_ex` | SGD training with full per-iteration loss curve |
| `nisps_mlp_draw_weights_spread` | Xavier-aware weight randomization |
| `nisps_mlp_move_weights_spread` | RL noise with weight decay |
| `nisps_mlp_move_weights_ex` | Same + native output pin mask |
| `nisps_mlp_eval_loss` | Forward pass + MSE, no weight update |
| `nisps_mlp_get_layer_stats` | Per-layer: mean|w|, max|w|, dead%, saturating% |
| `nisps_mlp_get/set_weights` | Flat weight serialization |
| `nisps_mlp_weight_count` | Total weight count |
**Building the WASM:**
```bash
cd playground/wasm && ./build.sh # requires emcc (Emscripten)
```
**Key difference from JS engine:** WASM uses float32 (not float64). The `spread`-aware `drawWeights`/`moveWeights` functions are implemented in the bindings file, not in nisps-core proper — they're playground-specific.
**Known issue:** Both the C++ `Train()` and WASM `train_ex` double-scale the loss when no sample weights are provided (each sample loss is weighted by 1/n, then the sum is multiplied by 1/n again). This is a backward-compat pattern from the C++ core (meml-ues).
### Debug Probe
The immersive app exposes `window.__nisps` when loaded with `?debug=1`. Used by Playwright e2e tests. Zero footprint in production.
| Method | Returns |
|--------|---------|
| `getOutputs()` | Current 126-element output vector |
| `getLoss()` | Last training loss (or null) |
| `getWeights()` | Flat weight array (~13K floats) |
| `getExampleCount()` | Number of training examples |
| `setInputs(x, y)` | Set joystick position + run inference |
| `thumbsUp()` / `thumbsDown()` | Trigger RL feedback |
| `train()` | Sync training with full UI update |
| `trainAsync()` | Async training (returns Promise) |
| `randomise()` | Randomize weights |
| `clearExamples()` | Clear dataset |
| `saveState()` | Force localStorage save |
| `evalLoss()` | Non-destructive loss query |
| `inferBatch(points)` | Batch inference |
| `getLayerStats()` | Per-layer weight health |
### URL Parameters
| Param | Range | Default | Effect |
|-------|-------|---------|--------|
| `tame` | 01 | 1 | Constrains synth output ranges toward safe limits |
| `spread` | 01 | 0.6 | Controls weight initialization, RL noise scaling, and weight decay (see below) |
| `preset` | preset id | _(none)_ | Auto-loads a synth parameter preset on first visit (e.g. `?preset=beginner-1`) |
#### `spread` — sigmoid saturation control
The MLP uses ReLU hidden layers with a sigmoid output layer. With uniform [-1,1] weights, the sum of many weighted inputs at each layer drives sigmoid pre-activations far from zero (std dev ≈ √fan_in), causing outputs to saturate near 0 or 1. The `spread` parameter addresses this:
- **`spread=0`** (polarised): Weights drawn from uniform [-1,1]. RL noise cap = 0.3. Noise applied uniformly across layers. Outputs cluster at extremes — good for exploration of radical mappings.
- **`spread=1`** (centered): Weights scaled by 1/√fan_in per layer (Xavier initialization). RL noise cap = 0.05. Noise also scaled per-layer. Weight decay prevents magnitude drift. Outputs spread across the full [0,1] range — better for fine-grained RL shaping.
- **Intermediate values** interpolate linearly between these two regimes.
Affects four code paths:
1. **`drawWeights(spread)`** — initial randomisation weight scale
2. **`moveWeights(speed, spread)`** — RL exploration noise scale per layer
3. **Weight decay in `moveWeights`** — each call decays weights by `10% * spread` before adding noise, preventing unbounded magnitude drift from repeated thumbs-down. At spread=0 there is no decay (original behavior). At spread=1, weights decay ~10% per call, creating a natural equilibrium where exploration noise and decay balance out rather than weights growing until sigmoid permanently saturates.
4. **Noise cap** in thumbs-down handler — `0.3*(1-spread) + 0.05*spread`
### C15 Parameter Map
The 126 synth parameters in `js/synth/param-map.js` were curated from the C15's 287 total parameters. Excluded categories:
| Excluded | Count | Reason |
|----------|-------|--------|
| Hardware Amount/Source | 56 | No physical MIDI hardware in browser |
| Macro Controls/Times | 12 | Meta-routing layer conflicts with direct ML control |
| Scale offsets | 13 | Microtuning would break pitch unpredictably |
| Key tracking (`*_KT`) | 11 | Pitch-dependent scaling needs calibrated defaults |
| Velocity (`*_Vel`) | 11 | Velocity-dependent, ML can't observe key velocity |
| Envelope mod depths (`*_Env_A/B/C`) | 19 | Multiplicative interaction with envelope shapes makes space too hard to learn |
| Discrete/structural | 15 | Osc Pitch (full sweep), Master Vol/Tune, Voice Mute/Fade, Unison Voices, Mono modes, Split, Osc Reset |
| Secondary config | 7 | Att Curve, Elevate, Chirp, Decay Gate, Retrigger |
| PM shaper blend | 4 | Secondary routing params |
| FB Mix source selects | 4 | Discrete A/B selectors |
### Synth Presets
Presets (`js/synth/presets.js`) control which parameters the ML engine can modify, with unselected params muted at safe defaults. Each preset defines per-param `{ muted, fixedValue, min, max, curve }` — no training examples or model weights.
4 tiers of progressive complexity:
| Tier | Presets | Active params | What's exposed |
|------|---------|---------------|----------------|
| 1 (Beginner) | 1.11.4 | 15 | Basic ADSR, SVF cutoff/res, Shaper A drive/fold, output levels, reverb mix |
| 2 (Intermediate) | 2.12.4 | 40 | + Env B/C, filter FM, effects (reverb/echo/flanger), cabinet, stereo panning |
| 3 (Advanced) | 3.13.3 | ~95 | + Cross-oscillator PM, feedback mixer, dual shapers, comb/gap filters, ring mod |
| 4 (Expert) | 4.14.2 | 126 | Full engine |
Presets use `curve` values to bias parameter distributions (< 0.5 = spend more time low, > 0.5 = bias high) without clamping extremes. Users can tweak any preset via the group drawer after loading.
### Control Surface (Phase 1)
The immersive app (`a-immersive.html`) has a control surface system for tuning how exploration and learning feel. Full spec: `playground/SPEC-controls.md`.
feat(playground): implement Phases 2-4 of control surface spec Phase 2 — Pinning + History: - snapshot-stack.js: ring buffer (20 max) with auto-snapshot on train/randomize/thumbs-down, multi-level undo, tagged entries - ab-compare.js: A/B weight state comparison with capture/toggle/accept/revert - region-pin.js: pin rectangular input-space regions (Approach A: example pinning), pinned examples always included in training - param-pin.js: per-output pin flags, pin mask skips pinned nodes in moveWeights - phase2-ui.js: undo button with history popup, A/B toggle, long-press region pin, double-tap param pin - Modified mlp.js/iml.js/nisps-wasm.js to accept outputPinMask in moveWeights Phase 3 — Input Refinement + Exploration: - pressure-feedback.js: touch force + hold duration → intensity multiplier - auto-explore.js: automated thumbs-down at configurable interval, zoom-scaled - input-heatmap.js: 16×16 MLP sampling, 3 color modes (luminance/variance/ divergence), zoom-aware resampling, offscreen canvas rendering - phase3-ui.js: auto-explore toggle with progress ring, heatmap eye icon, pressure indicators, settings drawer section - joy-map-enhanced.js: added setHeatmap() for background layer rendering Phase 4 — Output Pipeline + Visualization + Polish: - output-pipeline.js: global curve → smoothing → slew rate → freeze gate - weight-health.js: weight magnitude histogram, dead/saturating/healthy status - gradient-flow.js: per-layer weight-delta analysis, vanishing/exploding detection - session-presets.js: save/load full state, URL sharing via compact params - phase4-ui.js: freeze button, network health panel, session preset UI All phases merged into a-app.js with proper integration: auto-snapshots, pressure-modulated RL, heatmap triggers, output pipeline in routeOutputs, gradient capture around training, persistence for all new state.
2026-03-26 09:48:12 +01:00
**Architecture** — modular ES modules organized by phase, wired into `a-app.js`:
| Module | Phase | Purpose |
|--------|-------|---------|
| `js/ui/input-pipeline.js` | 1 | Processes raw joystick input through deadzone → zoom → curve → smoothing → momentum-as-zoom. Pure math, no DOM. |
| `js/ui/control-surface.js` | 1 | Compound axes (Boldness, Memory, Precision) that map single sliders to multiple underlying params. Offset-based override resolution (trim-pot model). 6 built-in control presets. |
| `js/ui/control-surface-ui.js` | 1 | DOM layer: 3 axis sliders on floating bar, gear icon settings drawer with per-param overrides. Injects its own CSS. |
| `js/ui/joy-map-enhanced.js` | 1 | Enhanced joy-map canvas: zoom minimap with adaptive grid, vanishing trail with Catmull-Rom spline and tap-to-return, dual concentric noise rings, frozen state overlay. |
| `js/ui/snapshot-stack.js` | 2 | Ring buffer (20 max) of weight snapshots. Auto-snapshot on train/randomize/thumbs-down. Multi-level undo. |
| `js/ui/ab-compare.js` | 2 | Rapid A/B weight state comparison. Capture, toggle, accept or revert. |
| `js/ui/region-pin.js` | 2 | Pins rectangular input-space regions (Approach A: example pinning). Pinned examples always included in training. |
| `js/ui/param-pin.js` | 2 | Per-output pin flags. Pin mask passed to `moveWeights()` to skip pinned output nodes. |
| `js/ui/phase2-ui.js` | 2 | DOM: undo button with history popup, A/B toggle, region pin via long-press, param pin via double-tap. |
| `js/ui/pressure-feedback.js` | 3 | Touch force + hold duration → intensity multiplier for noise growth/decay. |
| `js/ui/auto-explore.js` | 3 | Automated thumbs-down at configurable interval. Zoom-scaled intensity. |
| `js/ui/input-heatmap.js` | 3 | 2D color field sampling MLP across input space. 3 color modes, zoom-aware resampling. Supports `inferBatchFn` for single-call WASM batch inference. |
feat(playground): implement Phases 2-4 of control surface spec Phase 2 — Pinning + History: - snapshot-stack.js: ring buffer (20 max) with auto-snapshot on train/randomize/thumbs-down, multi-level undo, tagged entries - ab-compare.js: A/B weight state comparison with capture/toggle/accept/revert - region-pin.js: pin rectangular input-space regions (Approach A: example pinning), pinned examples always included in training - param-pin.js: per-output pin flags, pin mask skips pinned nodes in moveWeights - phase2-ui.js: undo button with history popup, A/B toggle, long-press region pin, double-tap param pin - Modified mlp.js/iml.js/nisps-wasm.js to accept outputPinMask in moveWeights Phase 3 — Input Refinement + Exploration: - pressure-feedback.js: touch force + hold duration → intensity multiplier - auto-explore.js: automated thumbs-down at configurable interval, zoom-scaled - input-heatmap.js: 16×16 MLP sampling, 3 color modes (luminance/variance/ divergence), zoom-aware resampling, offscreen canvas rendering - phase3-ui.js: auto-explore toggle with progress ring, heatmap eye icon, pressure indicators, settings drawer section - joy-map-enhanced.js: added setHeatmap() for background layer rendering Phase 4 — Output Pipeline + Visualization + Polish: - output-pipeline.js: global curve → smoothing → slew rate → freeze gate - weight-health.js: weight magnitude histogram, dead/saturating/healthy status - gradient-flow.js: per-layer weight-delta analysis, vanishing/exploding detection - session-presets.js: save/load full state, URL sharing via compact params - phase4-ui.js: freeze button, network health panel, session preset UI All phases merged into a-app.js with proper integration: auto-snapshots, pressure-modulated RL, heatmap triggers, output pipeline in routeOutputs, gradient capture around training, persistence for all new state.
2026-03-26 09:48:12 +01:00
| `js/ui/phase3-ui.js` | 3 | DOM: auto-explore toggle with progress ring, heatmap eye icon, pressure indicators. |
| `js/ui/output-pipeline.js` | 4 | Global curve → smoothing → slew rate → freeze gate on MLP outputs before synth/visual routing. |
| `js/ui/weight-health.js` | 4 | Weight magnitude histogram, dead/saturating/healthy status detection, ambient visualization. |
| `js/ui/gradient-flow.js` | 4 | Per-layer weight-delta analysis after training. Vanishing/exploding/converged detection. |
| `js/ui/session-presets.js` | 4 | Save/load full session state. URL sharing via compact params. |
| `js/ui/phase4-ui.js` | 4 | DOM: freeze button, network health panel, session preset UI, output pipeline slider wiring. |
**Compound Axes** — each controls 4-6 underlying parameters via interpolation tables:
- **Boldness** (Caution ↔ Bold): input zoom, noise cap, noise growth, learning rate, weight decay, noise distribution
- **Memory** (Amnesia ↔ Elephant): max examples, example decay, weight decay, noise decay, convergence threshold
- **Precision** (Raw ↔ Precise): input curve, deadzone, smoothing, slew rate, momentum-zoom mode
When a user manually overrides an individual param, the offset from the axis-derived value persists as the axis moves (like a trim pot on a mixing desk). Double-tap an axis to re-link all params.
**Input Pipeline** — sits between physical joystick and MLP. Key feature: **zoom** narrows the effective input window around an anchor point (`effective = anchor + (raw - 0.5) * zoom_level`). Zoom-at-zero freezes input. Three anchor modes: auto (anchor follows current position when zoom changes), sticky (explicit anchor), center (always 0.5).
**Control Presets**: Default, First Touch, Jazz Hands, Sculptor, Improviser, Microscope. These set compound axis positions — they don't include network weights or synth preset selection.
**Integration** — the control surface dispatches `controlsurface:change` CustomEvents. `a-app.js` listens and updates the input pipeline config, spread level, and RL parameters (noise cap, growth, decay, floor, zoom-aware feedback scaling). Pipeline-processed coordinates are cached (`_lastPipeX/Y`) so `getCurrentInputs()` and `setCurrentInputs()` use the same values the MLP sees. State is persisted to localStorage alongside existing app state.
feat(playground): implement Phases 2-4 of control surface spec Phase 2 — Pinning + History: - snapshot-stack.js: ring buffer (20 max) with auto-snapshot on train/randomize/thumbs-down, multi-level undo, tagged entries - ab-compare.js: A/B weight state comparison with capture/toggle/accept/revert - region-pin.js: pin rectangular input-space regions (Approach A: example pinning), pinned examples always included in training - param-pin.js: per-output pin flags, pin mask skips pinned nodes in moveWeights - phase2-ui.js: undo button with history popup, A/B toggle, long-press region pin, double-tap param pin - Modified mlp.js/iml.js/nisps-wasm.js to accept outputPinMask in moveWeights Phase 3 — Input Refinement + Exploration: - pressure-feedback.js: touch force + hold duration → intensity multiplier - auto-explore.js: automated thumbs-down at configurable interval, zoom-scaled - input-heatmap.js: 16×16 MLP sampling, 3 color modes (luminance/variance/ divergence), zoom-aware resampling, offscreen canvas rendering - phase3-ui.js: auto-explore toggle with progress ring, heatmap eye icon, pressure indicators, settings drawer section - joy-map-enhanced.js: added setHeatmap() for background layer rendering Phase 4 — Output Pipeline + Visualization + Polish: - output-pipeline.js: global curve → smoothing → slew rate → freeze gate - weight-health.js: weight magnitude histogram, dead/saturating/healthy status - gradient-flow.js: per-layer weight-delta analysis, vanishing/exploding detection - session-presets.js: save/load full state, URL sharing via compact params - phase4-ui.js: freeze button, network health panel, session preset UI All phases merged into a-app.js with proper integration: auto-snapshots, pressure-modulated RL, heatmap triggers, output pipeline in routeOutputs, gradient capture around training, persistence for all new state.
2026-03-26 09:48:12 +01:00
**Remaining**: Engine configuration panel (Part 8 of spec) — network architecture, loss function, optimizer selection.
## Testing
Playwright e2e tests cover the immersive app's ML engine, UI state machines, input pipeline, and persistence. Tests run headless Chromium against a Python HTTP server.
```bash
# Run all tests (starts server automatically on port 7331)
npx playwright test
# Run with browser visible
npx playwright test --headed
# Run a specific test file
npx playwright test tests/e2e/ml-engine.spec.js
```
**Test files** (`tests/e2e/`):
| File | Coverage |
|------|----------|
| `ml-engine.spec.js` | WASM inference bounds, training loss, thumbs up/down, async training, example capture |
| `ui-interactions.spec.js` | Drawer open/close, mode switching, heatmap bars, presets, keyboard shortcuts (1/2/Z) |
| `input-pipeline.spec.js` | Input→output variation, clamping, joystick drag, post-training bounds |
| `persistence.spec.js` | URL params (?preset, ?spread), localStorage round-trip |
| `wasm-api.spec.js` | Batch inference, evalLoss, getLayerStats, loss history curve, pin mask |
Tests use the `?debug=1` probe (`window.__nisps`) for programmatic access to the ML engine. The `helpers.js` module provides `loadApp(page)` which clears localStorage, sets `nisps-help-seen`, and waits for WASM initialization.
2026-02-08 15:49:16 +01:00
## Build System
2026-04-15 17:32:32 +02:00
This is an Arduino project targeting the MEMLNaut RP2350 hardware. Build and flash it with the repo-local helper scripts, which wrap the correct board target and compiler settings.
2026-02-08 15:49:16 +01:00
```bash
Stream 6: extract firmware glue under firmware/ Move the Arduino sketch into firmware/MEMLNaut-NISPS/ and bridge the hardware (memllib) to the platform-agnostic nisps/ library through a slim glue layer. Delete the legacy root-level *AudioApp.hpp, modes/MEMLNautMode*.hpp, voicespaces/, IMLInterface.hpp, XiasriAnalysis, and the src/memlp submodule. Glue layout (firmware/MEMLNaut-NISPS/glue/): audio_driver.hpp - bridge memllib block callback to Mode::process via per-Mode templated trampoline (no virtual dispatch) peripherals.hpp - joystick/pots/buttons -> Mode::set_input + ML primitives midi_io.hpp - MIDI in -> mode.note_on/update_bpm/set_playing, drains mode ControlEvent ring -> MIDI UART mode_select.hpp - using-aliases mapping MEMLNautMode<Name> to nisps::modes::*Mode (build script rewrites the #define MEMLNAUT_MODE_TYPE line) input_router.hpp / output_router.hpp - top-level wire/drain entry points The sketch tree uses src/{memllib,daisysp,nisps} symlinks because Arduino-CLI rejects ".." in include paths from sketch-tree headers. mode_select.hpp #undefs Arduino's sq/min/max/abs/round macros before including nisps headers (some nisps engines use those identifiers as method names). The audio bridge struct is extern in the header and defined in the .ino because inline + __not_in_flash section attribute collide at link time. Verification: arduino-cli compile succeeds for PAFSynth, ChannelStrip, and BreakOr (rp2040:rp2040:solderparty_rp2350_stamp_xl:opt=Optimize3, -std=gnu++20). Host C++ tests under nisps/build still pass (3 binaries, 110+ tests). Build script (scripts/build-firmware.sh) updated to point at the new sketch path; mode-rewrite logic unchanged. Closes meml-gkm.
2026-04-29 16:05:38 +02:00
# Initialize submodules (required for memllib)
2026-02-08 15:49:16 +01:00
git submodule update --init --recursive
2026-04-15 17:32:32 +02:00
# Build only
scripts/build-firmware.sh
# Build a specific variant
scripts/build-firmware.sh MEMLCelium
2026-04-15 17:32:32 +02:00
# Flash a previously-built UF2
scripts/flash-firmware.sh
# Build then flash
scripts/build-and-flash-firmware.sh
2026-02-08 15:49:16 +01:00
```
Stream 6: extract firmware glue under firmware/ Move the Arduino sketch into firmware/MEMLNaut-NISPS/ and bridge the hardware (memllib) to the platform-agnostic nisps/ library through a slim glue layer. Delete the legacy root-level *AudioApp.hpp, modes/MEMLNautMode*.hpp, voicespaces/, IMLInterface.hpp, XiasriAnalysis, and the src/memlp submodule. Glue layout (firmware/MEMLNaut-NISPS/glue/): audio_driver.hpp - bridge memllib block callback to Mode::process via per-Mode templated trampoline (no virtual dispatch) peripherals.hpp - joystick/pots/buttons -> Mode::set_input + ML primitives midi_io.hpp - MIDI in -> mode.note_on/update_bpm/set_playing, drains mode ControlEvent ring -> MIDI UART mode_select.hpp - using-aliases mapping MEMLNautMode<Name> to nisps::modes::*Mode (build script rewrites the #define MEMLNAUT_MODE_TYPE line) input_router.hpp / output_router.hpp - top-level wire/drain entry points The sketch tree uses src/{memllib,daisysp,nisps} symlinks because Arduino-CLI rejects ".." in include paths from sketch-tree headers. mode_select.hpp #undefs Arduino's sq/min/max/abs/round macros before including nisps headers (some nisps engines use those identifiers as method names). The audio bridge struct is extern in the header and defined in the .ino because inline + __not_in_flash section attribute collide at link time. Verification: arduino-cli compile succeeds for PAFSynth, ChannelStrip, and BreakOr (rp2040:rp2040:solderparty_rp2350_stamp_xl:opt=Optimize3, -std=gnu++20). Host C++ tests under nisps/build still pass (3 binaries, 110+ tests). Build script (scripts/build-firmware.sh) updated to point at the new sketch path; mode-rewrite logic unchanged. Closes meml-gkm.
2026-04-29 16:05:38 +02:00
The scripts build for `rp2040:rp2040:solderparty_rp2350_stamp_xl:opt=Optimize3` and force C++20 via `compiler.cpp.extra_flags=-std=gnu++20`. The sketch lives at `firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino`; everything platform-agnostic (ML, DSP, engines, modes) is under `nisps/` and reached via in-sketch `src/` symlinks.
If no variant is passed to `build-firmware.sh` in an interactive shell, it parses the available `MEMLNautMode*` options from the sketch, prompts for one, and rewrites the active `MEMLNAUT_MODE_TYPE` before compiling.
2026-04-15 17:32:32 +02:00
2026-02-08 15:49:16 +01:00
## Architecture
### Dual-Core Design
The RP2040's dual cores are used for separation of concerns:
- **Core 0**: UI loop, ML inference, hardware interface polling (5ms period)
- **Core 1**: Real-time audio processing, parameter updates, MIDI polling
Inter-core synchronization uses memory barriers (`MEMORY_BARRIER()`, `WRITE_VOLATILE()`, `READ_VOLATILE()`) and RP2040 queues (`queue_t`).
### Mode System
Stream 6: extract firmware glue under firmware/ Move the Arduino sketch into firmware/MEMLNaut-NISPS/ and bridge the hardware (memllib) to the platform-agnostic nisps/ library through a slim glue layer. Delete the legacy root-level *AudioApp.hpp, modes/MEMLNautMode*.hpp, voicespaces/, IMLInterface.hpp, XiasriAnalysis, and the src/memlp submodule. Glue layout (firmware/MEMLNaut-NISPS/glue/): audio_driver.hpp - bridge memllib block callback to Mode::process via per-Mode templated trampoline (no virtual dispatch) peripherals.hpp - joystick/pots/buttons -> Mode::set_input + ML primitives midi_io.hpp - MIDI in -> mode.note_on/update_bpm/set_playing, drains mode ControlEvent ring -> MIDI UART mode_select.hpp - using-aliases mapping MEMLNautMode<Name> to nisps::modes::*Mode (build script rewrites the #define MEMLNAUT_MODE_TYPE line) input_router.hpp / output_router.hpp - top-level wire/drain entry points The sketch tree uses src/{memllib,daisysp,nisps} symlinks because Arduino-CLI rejects ".." in include paths from sketch-tree headers. mode_select.hpp #undefs Arduino's sq/min/max/abs/round macros before including nisps headers (some nisps engines use those identifiers as method names). The audio bridge struct is extern in the header and defined in the .ino because inline + __not_in_flash section attribute collide at link time. Verification: arduino-cli compile succeeds for PAFSynth, ChannelStrip, and BreakOr (rp2040:rp2040:solderparty_rp2350_stamp_xl:opt=Optimize3, -std=gnu++20). Host C++ tests under nisps/build still pass (3 binaries, 110+ tests). Build script (scripts/build-firmware.sh) updated to point at the new sketch path; mode-rewrite logic unchanged. Closes meml-gkm.
2026-04-29 16:05:38 +02:00
The active mode is selected at compile-time via `#define MEMLNAUT_MODE_TYPE` in `firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino`. The macro expands to a type alias defined in `firmware/MEMLNaut-NISPS/glue/mode_select.hpp` that maps each `MEMLNautMode<Name>` identifier to a concrete `nisps::modes::*Mode` type. Each mode satisfies the C++20 `nisps::Mode` concept (`nisps/core/concepts.hpp`):
2026-02-08 15:49:16 +01:00
| Mode | Purpose |
|------|---------|
| `MEMLNautModeChannelStrip` | Audio channel strip (EQ, compression, gain staging) |
| `MEMLNautModePAFSynth` | PAF (Phase Aligned Formant) synthesis with MIDI |
| `MEMLNautModeXIASRI` | Audio-reactive mode using machine listening analysis |
| `MEMLNautModeSoundAnalysisMIDI` | Sound analysis with MIDI output |
Stream 6: extract firmware glue under firmware/ Move the Arduino sketch into firmware/MEMLNaut-NISPS/ and bridge the hardware (memllib) to the platform-agnostic nisps/ library through a slim glue layer. Delete the legacy root-level *AudioApp.hpp, modes/MEMLNautMode*.hpp, voicespaces/, IMLInterface.hpp, XiasriAnalysis, and the src/memlp submodule. Glue layout (firmware/MEMLNaut-NISPS/glue/): audio_driver.hpp - bridge memllib block callback to Mode::process via per-Mode templated trampoline (no virtual dispatch) peripherals.hpp - joystick/pots/buttons -> Mode::set_input + ML primitives midi_io.hpp - MIDI in -> mode.note_on/update_bpm/set_playing, drains mode ControlEvent ring -> MIDI UART mode_select.hpp - using-aliases mapping MEMLNautMode<Name> to nisps::modes::*Mode (build script rewrites the #define MEMLNAUT_MODE_TYPE line) input_router.hpp / output_router.hpp - top-level wire/drain entry points The sketch tree uses src/{memllib,daisysp,nisps} symlinks because Arduino-CLI rejects ".." in include paths from sketch-tree headers. mode_select.hpp #undefs Arduino's sq/min/max/abs/round macros before including nisps headers (some nisps engines use those identifiers as method names). The audio bridge struct is extern in the header and defined in the .ino because inline + __not_in_flash section attribute collide at link time. Verification: arduino-cli compile succeeds for PAFSynth, ChannelStrip, and BreakOr (rp2040:rp2040:solderparty_rp2350_stamp_xl:opt=Optimize3, -std=gnu++20). Host C++ tests under nisps/build still pass (3 binaries, 110+ tests). Build script (scripts/build-firmware.sh) updated to point at the new sketch path; mode-rewrite logic unchanged. Closes meml-gkm.
2026-04-29 16:05:38 +02:00
| `MEMLNautModeBreakOr` | Break\|\| 8-track ratio sequencer |
| `MEMLNautModeVerbFX` | Reverb/effects engine |
| `MEMLNautModeElysiamorfs` | Elysiamorf granular sequencer |
| `MEMLNautModeMEMLCelium` | MEMLCelium dual-voice synth + sequencer |
2026-02-08 15:49:16 +01:00
### Voice Spaces
Stream 6: extract firmware glue under firmware/ Move the Arduino sketch into firmware/MEMLNaut-NISPS/ and bridge the hardware (memllib) to the platform-agnostic nisps/ library through a slim glue layer. Delete the legacy root-level *AudioApp.hpp, modes/MEMLNautMode*.hpp, voicespaces/, IMLInterface.hpp, XiasriAnalysis, and the src/memlp submodule. Glue layout (firmware/MEMLNaut-NISPS/glue/): audio_driver.hpp - bridge memllib block callback to Mode::process via per-Mode templated trampoline (no virtual dispatch) peripherals.hpp - joystick/pots/buttons -> Mode::set_input + ML primitives midi_io.hpp - MIDI in -> mode.note_on/update_bpm/set_playing, drains mode ControlEvent ring -> MIDI UART mode_select.hpp - using-aliases mapping MEMLNautMode<Name> to nisps::modes::*Mode (build script rewrites the #define MEMLNAUT_MODE_TYPE line) input_router.hpp / output_router.hpp - top-level wire/drain entry points The sketch tree uses src/{memllib,daisysp,nisps} symlinks because Arduino-CLI rejects ".." in include paths from sketch-tree headers. mode_select.hpp #undefs Arduino's sq/min/max/abs/round macros before including nisps headers (some nisps engines use those identifiers as method names). The audio bridge struct is extern in the header and defined in the .ino because inline + __not_in_flash section attribute collide at link time. Verification: arduino-cli compile succeeds for PAFSynth, ChannelStrip, and BreakOr (rp2040:rp2040:solderparty_rp2350_stamp_xl:opt=Optimize3, -std=gnu++20). Host C++ tests under nisps/build still pass (3 binaries, 110+ tests). Build script (scripts/build-firmware.sh) updated to point at the new sketch path; mode-rewrite logic unchanged. Closes meml-gkm.
2026-04-29 16:05:38 +02:00
Voice spaces map ML output vectors → engine parameters. They live as static lambdas inside each engine in `nisps/engines/*.hpp` (no longer in a separate `voicespaces/` directory). The mode picks a voice space at runtime via `engine().set_voice_space(idx)`.
2026-02-08 15:49:16 +01:00
### Key Components
Stream 6: extract firmware glue under firmware/ Move the Arduino sketch into firmware/MEMLNaut-NISPS/ and bridge the hardware (memllib) to the platform-agnostic nisps/ library through a slim glue layer. Delete the legacy root-level *AudioApp.hpp, modes/MEMLNautMode*.hpp, voicespaces/, IMLInterface.hpp, XiasriAnalysis, and the src/memlp submodule. Glue layout (firmware/MEMLNaut-NISPS/glue/): audio_driver.hpp - bridge memllib block callback to Mode::process via per-Mode templated trampoline (no virtual dispatch) peripherals.hpp - joystick/pots/buttons -> Mode::set_input + ML primitives midi_io.hpp - MIDI in -> mode.note_on/update_bpm/set_playing, drains mode ControlEvent ring -> MIDI UART mode_select.hpp - using-aliases mapping MEMLNautMode<Name> to nisps::modes::*Mode (build script rewrites the #define MEMLNAUT_MODE_TYPE line) input_router.hpp / output_router.hpp - top-level wire/drain entry points The sketch tree uses src/{memllib,daisysp,nisps} symlinks because Arduino-CLI rejects ".." in include paths from sketch-tree headers. mode_select.hpp #undefs Arduino's sq/min/max/abs/round macros before including nisps headers (some nisps engines use those identifiers as method names). The audio bridge struct is extern in the header and defined in the .ino because inline + __not_in_flash section attribute collide at link time. Verification: arduino-cli compile succeeds for PAFSynth, ChannelStrip, and BreakOr (rp2040:rp2040:solderparty_rp2350_stamp_xl:opt=Optimize3, -std=gnu++20). Host C++ tests under nisps/build still pass (3 binaries, 110+ tests). Build script (scripts/build-firmware.sh) updated to point at the new sketch path; mode-rewrite logic unchanged. Closes meml-gkm.
2026-04-29 16:05:38 +02:00
- **`nisps::ModeBase`** (`nisps/modes/base.hpp`): CRTP scaffold; absorbs input forwarding, ML inference, voice-space dispatch, control-event ring buffer.
- **`nisps::ml::MLP<NIn, NHidden..., NOut>`** (`nisps/ml/mlp.hpp`): Templated MLP with SGD/RMSProp, RL primitives (`move_weights`, `draw_weights`).
- **`firmware/glue/audio_driver.hpp`**: Bridge from memllib `AudioDriver` per-block callback to `Mode::process(stereosample_t)` per-sample.
- **`firmware/glue/peripherals.hpp`**: Joystick / pots / buttons → `Mode::set_input` and ML primitives.
- **`nisps::AnalysisEngine`** (`nisps/engines/analysis.hpp`): Real-time audio feature extraction (pitch, aperiodicity, energy, brightness).
2026-02-08 15:49:16 +01:00
### Submodules (in `src/`)
Stream 6: extract firmware glue under firmware/ Move the Arduino sketch into firmware/MEMLNaut-NISPS/ and bridge the hardware (memllib) to the platform-agnostic nisps/ library through a slim glue layer. Delete the legacy root-level *AudioApp.hpp, modes/MEMLNautMode*.hpp, voicespaces/, IMLInterface.hpp, XiasriAnalysis, and the src/memlp submodule. Glue layout (firmware/MEMLNaut-NISPS/glue/): audio_driver.hpp - bridge memllib block callback to Mode::process via per-Mode templated trampoline (no virtual dispatch) peripherals.hpp - joystick/pots/buttons -> Mode::set_input + ML primitives midi_io.hpp - MIDI in -> mode.note_on/update_bpm/set_playing, drains mode ControlEvent ring -> MIDI UART mode_select.hpp - using-aliases mapping MEMLNautMode<Name> to nisps::modes::*Mode (build script rewrites the #define MEMLNAUT_MODE_TYPE line) input_router.hpp / output_router.hpp - top-level wire/drain entry points The sketch tree uses src/{memllib,daisysp,nisps} symlinks because Arduino-CLI rejects ".." in include paths from sketch-tree headers. mode_select.hpp #undefs Arduino's sq/min/max/abs/round macros before including nisps headers (some nisps engines use those identifiers as method names). The audio bridge struct is extern in the header and defined in the .ino because inline + __not_in_flash section attribute collide at link time. Verification: arduino-cli compile succeeds for PAFSynth, ChannelStrip, and BreakOr (rp2040:rp2040:solderparty_rp2350_stamp_xl:opt=Optimize3, -std=gnu++20). Host C++ tests under nisps/build still pass (3 binaries, 110+ tests). Build script (scripts/build-firmware.sh) updated to point at the new sketch path; mode-rewrite logic unchanged. Closes meml-gkm.
2026-04-29 16:05:38 +02:00
- **memllib**: Hardware abstraction, audio drivers, MIDI, display.
- **daisysp**: DSP library (filters, drums, effects, synthesis).
2026-02-08 15:49:16 +01:00
## Memory Sections
The codebase uses RP2040-specific memory placement:
- `AUDIO_MEM` / `AUDIO_FUNC`: Place audio-critical code/data in SRAM
- `APP_SRAM` / `__not_in_flash("app")`: Keep frequently-accessed data out of flash
## Audio Parameters
Sample rate is defined in `AudioDriver::GetSampleRate()`. The audio callback `audio_block_callback` runs on Core 1 and processes stereo audio (`stereosample_t`).