diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c41d566..a897613 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,7 +4,8 @@ name: CI
#
# Three parallel jobs:
# * cpp-tests — builds nisps host C++ tests, builds nisps.wasm, runs
-# the parity check, runs the lint script.
+# the parity check, runs the lint script, and reports
+# per-engine throughput on both host targets.
# * manifold-tests — typechecks the React manifold app, runs bun unit
# tests, builds the production bundle, runs Playwright
# e2e tests.
@@ -86,6 +87,19 @@ jobs:
- name: Lint
run: bash scripts/lint-cpp.sh
+ # Throughput is reported, not asserted — the same call the firmware
+ # flash/RAM step makes below, for a stronger reason: a wall-clock
+ # threshold on a shared runner is either slack enough to be meaningless
+ # or tight enough to fail on someone else's noisy neighbour. What this
+ # buys is (a) a per-commit ns/sample + realtime-factor record in the log,
+ # where a 2-3x regression — the failure mode ALIGNMENT defect 5 names —
+ # is unmissable even at runner noise levels, (b) proof the bench still
+ # builds and runs on BOTH targets, and (c) the wasm/native ratio.
+ # Runner noise makes small moves unreadable; for a real comparison run
+ # scripts/bench-engines.sh --compare locally.
+ - name: Engine throughput report (native + WASM, not asserted)
+ run: bash scripts/bench-engines.sh --target-ms 60
+
- name: Upload parity blobs on failure
if: failure()
uses: actions/upload-artifact@v4
@@ -119,6 +133,9 @@ jobs:
bun run generate.ts
bun run generate-midi-devices.ts
bun run tests/golden_test.ts
+ # The schemas' per-voice-space `curve` declarations are DESCRIPTIVE
+ # of nisps/engines/*.hpp. Prove they still describe it.
+ bun run tests/curve_drift_test.ts
cd ..
GEN_DIRS="nisps/modes/generated nisps/midi/generated nisps/ml/generated \
manifold/src/modes/generated manifold/src/midi-devices/generated"
@@ -186,14 +203,17 @@ jobs:
- name: Install PlatformIO
run: pip install --upgrade platformio
- # Three representative envs rather than all sixteen: they differ only in
- # which mode type is instantiated, so a compile break is almost always
- # common to all. slpworkshop is the shipped default, pafsynth is the
- # heaviest RAM user, and selftest takes the separate NISPS_SELFTEST fork
- # through main.cpp — the one that broke unnoticed before.
+ # Four representative envs rather than all sixteen: they differ mostly in
+ # which mode type is instantiated, so a compile break is usually common to
+ # all. slpworkshop is the shipped default, pafsynth is the heaviest RAM
+ # user, selftest takes the separate NISPS_SELFTEST fork through main.cpp
+ # (the one that broke unnoticed before), and soundanalysismidi is the ONLY
+ # microphone variant — the sole mode whose DriverConfig differs from its
+ # engine's, via an on_driver_config() override. Drop it and the mic path
+ # stops being compiled by anything.
- name: Build representative firmware variants
working-directory: firmware/MEMLNaut-NISPS
- run: pio run -e slpworkshop -e pafsynth -e selftest
+ run: pio run -e slpworkshop -e pafsynth -e selftest -e soundanalysismidi
# Sizes are reported, not asserted. A threshold would either be slack
# enough to be meaningless or tight enough to fail on unrelated work;
@@ -205,7 +225,7 @@ jobs:
working-directory: firmware/MEMLNaut-NISPS
run: |
SIZE=$(find ~/.platformio/packages -name 'arm-none-eabi-size' | head -1)
- for e in slpworkshop pafsynth selftest; do
+ for e in slpworkshop pafsynth selftest soundanalysismidi; do
"$SIZE" -A ".pio/build/$e/firmware.elf" | awk -v e="$e" '
/^\.text/{t=$2} /^\.rodata/{r=$2} /^\.data/{d=$2}
/^\.bss/{b=$2} /^\.ram_vector_table/{v=$2}
diff --git a/ALIGNMENT.md b/ALIGNMENT.md
index b72ad28..676f300 100644
--- a/ALIGNMENT.md
+++ b/ALIGNMENT.md
@@ -26,7 +26,7 @@ The clean-slate rewrite (2026-04-29) consolidated everything into one C++20 code
**Why it blocks the mission.** The default experience is supposed to be curated presets; the advanced surface is the authoring tool. Neither exists, and the decorative stratum actively misleads research use.
-**Rough cost.** Product-model decision first (plan §7.6), then incremental: picker is days; the curated-preset model seeds from `backends/presets.ts` + schemas; disclosure via per-drawer depth levels. Deleting the decorative stratum is part of the Phase-1 sweep.
+**Rough cost.** Product-model decision first (plan §7.6), then incremental: picker is days; the curated-preset model seeds from `backends/presets.ts` + schemas; disclosure via per-drawer depth levels — which as of §6.5e (2026-07-21) has its first genuinely advanced-only consumer, the training-health panel, so the mechanism is proven rather than theoretical. The decorative stratum itself went in the Phase-1 sweep.
### 3. Manifold-as-hardware-editor is a facade (2026-07-21)
@@ -44,33 +44,26 @@ The clean-slate rewrite (2026-04-29) consolidated everything into one C++20 code
**Rough cost.** Plan phase 3 (~2–3 days, codegen takes ownership) plus the docs disposition pass (§8). The behaviour bugs found en route (dataset-cap divergence, VCV 2-D input truncation, VCV audio-thread race and JSON) were fixed in phase 2 on 2026-07-21.
-### 5. No performance measurement despite a performance-defined mission (2026-07-21)
+### 5. Performance is measured on the host but not on the target that constrains it (2026-07-21)
-**What.** The "super performance-sensitive" constraint is enforced only by static discipline (the no-heap lint — false negatives closed in Phase 2 — and section attrs). *Half-closed 2026-07-21:* the Phase 4 firmware CI job now reports per-variant flash/RAM on every push, so size regressions are at least visible. **Still missing: any measure of time.** No benchmark, no CPU-load assertion, no blocks-per-second number on either target — nothing would catch an engine getting 3x slower.
+**What.** *Mostly closed 2026-07-21.* Size: the Phase 4 firmware CI job reports per-variant
+flash/RAM on every push. Time: `scripts/bench-engines.sh` now reports per-engine ns/sample,
+blocks/s and realtime factor on native AND WASM from one source
+(`tests/cpp/engine_bench.cpp`), engines driven into a working state, with `--compare` for
+per-engine deltas and a report step in CI. An engine getting 3x slower is now visible.
-**Rough cost.** ~Half a day now: a host-side blocks-per-second benchmark for `engine_process_block`, native + WASM (plan §6.5f).
+**What is left.** The numbers are HOST numbers. The mission's performance constraint is the
+**RP2350 at 150 MHz**, and nothing measures there — a host realtime factor of 100x says
+nothing about whether an engine fits in the MCU's per-block budget, and the two targets have
+different FPU, cache and memory behaviour. The honest next step is an on-device timing report
+(cycle counter around the audio callback, published over the existing display/serial surface),
+which lands naturally with the hardware editor (defect 3) since that is what gives firmware a
+command surface to report through.
-### 6. Training-health telemetry: decided, not yet built (2026-07-21)
+**Rough cost.** Host half is done. On-device: ~a day, and it wants defect 3's serial protocol
+to have somewhere to send the number.
-**What.** Four fragments of one feature. Fragment 3 (decorative gradient-health UI) was deleted in
-Phase 1. The other three stand: a 16 KB loss-history buffer in every firmware MLP that nothing
-reads (`nisps/ml/mlp.hpp`); a WASM worker faking a **1-element** loss history
-(`manifold/src/engine/wasm-worker.ts:310`, `new Float32Array([loss])`); and a real `layer_stats` /
-`nisps_ml_get_layer_stats` API plumbed end-to-end and consumed by nobody.
-
-**Decisions are now complete** (operator, §7.3 + L25): telemetry becomes **real, browser-only,
-behind a feature flag**; the fakes go; and the **firmware buffer stays** — it is the on-device
-record the hardware editor (defect 3) will want, and it costs flash we demonstrably have (16% RAM
-on the largest variant). So the remaining work is one coherent job, not a judgement call: plumb
-`loss_history` through the C API (`Drawers.tsx:263` already marks the gap), replace the worker's
-fake with it, and put the display plus `get_layer_stats` behind the advanced-mode flag.
-
-**Why it blocks the mission.** "Is the network learning?" is a core research affordance, and today
-it *looks* answered while being fabricated — worse than absent.
-
-**Rough cost.** ~A day, spec-light: plan §6.5e, no longer gated on anything.
-
-### 7. RMSProp still deferred from `nisps/ml/` (2026-04-29; reaffirmed 2026-07-21)
+### 6. RMSProp still deferred from `nisps/ml/` (2026-04-29; reaffirmed 2026-07-21)
**What.** `training.hpp` ships SGD only; the legacy firmware used RMSProp for `TrainBatch`. Optimizer choice is a research axis. Not blocking current fits; will matter for harder loss landscapes. Port target: upstream MusicallyEmbodiedML `memlp` (the in-repo `src/memlp` copy is deleted; use the GitHub remote or archive branch).
@@ -90,22 +83,6 @@ Schemas declare per-mode dims and since P5.3 both targets honour them. Is the mi
Legacy a-immersive was mobile-first; Manifold is desktop-first. Defer until user data exists.
-### Q4: Who owns memllib? — DECIDED, half-executed (2026-07-21)
-
-Operator decision: **vendor**, self-contained in this repo. The inventory
-(`docs/specs/recon/memllib-usage-inventory.md`) settled the shape: there is no small load-bearing
-subset — it is all of memllib bar `examples/` (~1.8 MB, 24/24 compiled TUs link). The fork is
-dissolved: its three commits touch only `examples/`, which the firmware never compiles and whose
-content already lives in `nisps/ml/{jolt,ou_noise,feedback,geo_push}.hpp`, so the submodule now
-points at upstream and is pinned to current `main`. Verified by building: it brings the
-`l r input swap` hardware fix plus the `NavigateToView` the SelfTest variant was already written
-against, and costs **+216 bytes of a 16 MB flash**. **Remaining: the vendoring copy itself**, which
-lands with the PlatformIO cut (plan §5). Delete this entry when it does.
-
-### Q5: Legacy feedback modes — delete or keep for A/B? (2026-07-21)
-
-`RandomiseOutputs`/`RandomiseMlp`/`Diffuse`/`on_drag` have no product consumer, but `docs/adr/rl-feedback-design.md` explicitly kept Diffuse for A/B comparison. Deleting reverses a recorded decision — operator call (plan §7.1).
-
## Deferred / accepted debt
- **EOC effects chain, ShapeSeq sequencer, modular engine (Phase E)** — legacy features consciously out of the v1 rewrite; revisit only if a mode wants them.
@@ -116,6 +93,26 @@ lands with the PlatformIO cut (plan §5). Delete this entry when it does.
## Recently resolved (delete after a few weeks)
+- 2026-07-21: **Q4 (who owns memllib) closed.** Vendored at `firmware/MEMLNaut-NISPS/lib/memllib/`
+ from upstream `e291192`; the submodule and the fork are both gone. **Q5 (legacy feedback modes)
+ closed** — operator kept all four (`RandomiseOutputs`/`RandomiseMlp`/`Diffuse`/`on_drag`) as
+ building blocks for comparing how instruments feel under different behaviours, which upholds
+ rather than reverses `docs/adr/rl-feedback-design.md`.
+- 2026-07-21: **Training-health telemetry (old defect 6) is gone** — the browser reads the real
+ per-iteration loss the core records, both fabrication sites are deleted (`wasm-worker.ts`'s
+ 1-element array AND `wasm-iml.ts`'s sync-train twin, which the audit missed), and the display
+ sits behind the existing `expanded` drawer depth. The firmware buffer stays, per the L25 call.
+
+- 2026-07-21: **Training-health telemetry (old defect 6) is real.** `nisps_ml_loss_history` now
+ crosses all five WASM registration layers, so the browser reads the SAME per-iteration curve the
+ firmware MLP records; `wasm-worker.ts`'s 1-element `new Float32Array([loss])` fake is gone from
+ both the sync and the async train paths; and the curve + the already-plumbed `get_layer_stats`
+ render in `manifold/src/console/TrainingHealth.tsx` at the Learning drawer's `expanded` depth.
+ The firmware buffer stays, per the operator call — it is the record the hardware editor
+ (defect 3) will read. One more fabrication went with it: `ConsoleCtx.loss`, a synthetic
+ `prev * 0.82` series no drawer read. With no history the panel says "no training run yet"
+ rather than drawing a plausible curve.
+
- 2026-07-21: **Arduino-CLI build machinery (old defect 3) is gone.** Phase 4 replaced it with a
PlatformIO project: one `[env:]` per variant is now the only variant registry, the `.ino`-mutating
Python/sed machinery and the `NISPS_ST_*` token-paste table and the sketch symlink forest and the
diff --git a/MAP.md b/MAP.md
index b5581eb..0dbe7f1 100644
--- a/MAP.md
+++ b/MAP.md
@@ -10,7 +10,7 @@ MEMLNaut-NISPS — Neural Interactive Shaping of Parameter Spaces. One C++20 cod
- `nisps/pipeline/` — the control-rate input/output processing chains (P4): `input_chain.hpp` (`InputChain` — invert→deadzone→circular clamp→momentum-modulated zoom→centred power→EMA→momentum; caller-supplied dt, internal clock, fixed velocity ring, serialisable state) and `output_chain.hpp` (`OutputChain` — curve→EMA→slew→freeze(+mask), capacity-templated). Behaviour contract = the retired manifold TS pipelines, pinned by `manifold/tests/fixtures/` and parity stage 7.
- `nisps/dsp/` — `biquad.hpp`, `delay.hpp`, `reverb.hpp`, `filter.hpp`, `env.hpp`, `osc.hpp`, `pitch_shift.hpp`, `dc_blocker.hpp`, plus the sequencer primitives shared by the sequencer engines: `ratio_seq.hpp` and `seq_clock.hpp` (bar phasor + MIDI clock + bpm). Lean primitives extracted from maximilian; daisysp PitchShifter replaced with custom granular impl.
- `nisps/engines/` — eight audio engines, each satisfying `AudioEngine`: `paf_synth.hpp`, `channel_strip.hpp`, `xiasri.hpp`, `verb_fx.hpp`, `memlcelium.hpp`, `breakor.hpp` (sequencer, NoOp audio), `elysiamorf.hpp` (sequencer, NoOp audio), `analysis.hpp` (input-side spectral features). Plus `base.hpp` (`NoOpEngine`, engine_id "thru").
-- `nisps/modes/` — platform-agnostic modes binding `{ML config, engine, voice space lambdas, abstract I/O channels}`. Files: `paf_synth.hpp`, `channel_strip.hpp`, `xiasri.hpp`, `verb_fx.hpp`, `memlcelium.hpp`, `slp_workshop.hpp` (`SLPWorkshopMode` — the Synth Library Portland workshop build; reuses the MEMLCelium engine + MLP shape, foregrounds the Jolt + OU explore gestures), `breakor.hpp`, `elysiamorf.hpp`, `sound_analysis_midi.hpp`, `external_synth_midi.hpp` (`ExternalSynthMIDIMode` — joystick→MLP→MIDI CC for an external synth; compile-time device from `nisps/midi`; `consteval pick_cc_slots` curates which params fill the NOut slots; NoOpEngine, `kRouteOutputsToEngine=false`). `base.hpp` provides a CRTP scaffold eliminating the duplication that previously plagued firmware modes. `generated/` contains codegen output (do not edit by hand): per-mode `kSchema` ParamSchema instances, the `MLP` type aliases built from the schema's own dims, and `schema_types.hpp` which now owns the `ParamSchema` struct itself. Mode headers no longer hand-write either their schema aggregate or their net shape.
+- `nisps/modes/` — platform-agnostic modes binding `{ML config, engine, voice space lambdas, abstract I/O channels}`. Files: `paf_synth.hpp`, `channel_strip.hpp`, `xiasri.hpp`, `verb_fx.hpp`, `memlcelium.hpp`, `slp_workshop.hpp` (`SLPWorkshopMode` — the Synth Library Portland workshop build; reuses the MEMLCelium engine + MLP shape, foregrounds the Jolt + OU explore gestures), `breakor.hpp`, `elysiamorf.hpp`, `sound_analysis_midi.hpp`, `external_synth_midi.hpp` (`ExternalSynthMIDIMode` — joystick→MLP→MIDI CC for an external synth; compile-time device from `nisps/midi`; `consteval pick_cc_slots` curates which params fill the NOut slots; NoOpEngine, `kRouteOutputsToEngine=false`). `base.hpp` provides a CRTP scaffold eliminating the duplication that previously plagued firmware modes; it also owns `driver_config()` — the audio-driver setup (mic vs line, gain staging, sample rate) the platform glue reads at mode start. Defaults to `engine().driver_config()`; a mode overrides it with an optional `on_driver_config()` hook only when its engine is not what consumes the audio input (`sound_analysis_midi`, whose analyser rather than its NoOp engine owns the mic). `generated/` contains codegen output (do not edit by hand): per-mode `kSchema` ParamSchema instances, the `MLP` type aliases built from the schema's own dims, and `schema_types.hpp` which now owns the `ParamSchema` struct itself. Mode headers no longer hand-write either their schema aggregate or their net shape.
- `nisps/wasm/bindings.cpp` — flat C API exported to WASM (Emscripten target only).
- `nisps/midi/generated/midi_devices.hpp` — codegen output: no-heap `constexpr` external-MIDI-synth templates (`nisps::midi::generated`; `MidiParam`/`MidiDevice` + `kMidiDevices` registry). Source = `schemas/midi_devices/`; do not edit by hand.
- `nisps/CMakeLists.txt` + `nisps/build/` — host-target builds + ctest.
@@ -19,7 +19,8 @@ MEMLNaut-NISPS — Neural Interactive Shaping of Parameter Spaces. One C++20 cod
- `firmware/MEMLNaut-NISPS/platformio.ini` — **the variant registry**: one `[env:]` per firmware variant (16 of them), each passing `-DMEMLNAUT_MODE_TYPE=`; `selftest` passes `-DNISPS_SELFTEST=1` instead. There is no second list to keep in sync. Shared `[env]` base pins the platform wrapper + arduino-pico framework, sets `-std=gnu++20 -O3` (via `build_unflags`, because the framework appends its own `-std=gnu++17 -Os` AFTER project flags), reaches `nisps/` with `-I${PROJECT_DIR}/../..`, and carries the TFT_eSPI panel config as `-D` flags. Build: `pio run -e `, or `scripts/build-firmware.sh [--all]`.
- `firmware/MEMLNaut-NISPS/src/main.cpp` — entry point (was `MEMLNaut-NISPS.ino`). Forks on `NISPS_SELFTEST`: normal modes run the engine/ML path; the `SelfTest` variant delegates all four entry points to `glue/selftest.hpp`.
- `firmware/MEMLNaut-NISPS/glue/` — hardware bindings:
- - `audio_driver.hpp` — bridges memllib `AudioDriver` callback → `Mode::process(stereosample_t)`.
+ - `audio_driver.hpp` — bridges memllib `AudioDriver` callback → `Mode::process(stereosample_t)`, and brings the codec up on the **active mode's** `driver_config()` (`setup_audio_driver`) plus publishes its preferred sample rate before the system clock is derived from it (`apply_mode_sample_rate`). Mic vs line input is therefore a mode-level declaration, not a firmware constant.
+ - `codec_config.hpp` — pure, Arduino-free clamping of a `nisps::DriverConfig` to SGTL5000-representable values + sample-rate resolution (unsupported/"don't care" → 48 kHz, because `AudioDriver::GetSysClockSpeed()` `panic()`s otherwise). Host-tested by `tests/cpp/test_mode_driver_config.cpp`.
- `peripherals.hpp` — joystick / pots / buttons → `Mode::set_input` and ML primitives. Wires the shared `FeedbackController` ExploreAndPlace lifecycle (MomA1 = enter/exit explore, MomA2 = freeze/place, TogB2 = commit; MomB1/MomB2 = reroll/nudge while exploring **or** grab/drop *reposition* while idle) plus the adaptive-learning gestures: **TogB1** = Jolt (held weight morph), **RVX1** = exploration amount (OU output walk). Reposition relocates an existing positive example's output to a new input position (`feedback.hpp` `begin_reposition`/`commit_reposition`) — no scratchpad, no weight restore.
- `midi_io.hpp` — MIDI in → mode `note_on`/`update_bpm`/`set_playing`; drains `ControlEvent` ring → MIDI UART.
- `mode_select.hpp` — type aliases mapping firmware mode identifiers to `nisps::modes::*Mode` C++ types, selected by the `-D` from platformio.ini. Includes the six `MEMLNautModeExtSynth*` external-synth variants (one per device template in `nisps/midi`, e.g. `MEMLNautModeExtSynthSub37`). The `NISPS_ST_*`/`NISPS_ST_CAT` token-paste table and the `SelfTestRig` tag type are GONE — selftest is now just an env with its own `-D`.
@@ -47,7 +48,10 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set.
- `manifold/src/console/` — the convertible Console: `ConsoleApp`, `CompositeStage` (single-divider convertible
with snap/magnetism/minimap-demotion), `OutputStage`/`SandwichStage`/`ParticleStage`/`Manifold` (canvas,
rect↔circular + feedback markers), `Dock` (top Mode selector + 5 vertically-centred drawers), `Drawers`
- (Learning/Inputs/Outputs/Settings/Help), `VerdictCluster` (mode-aware), `OutputEditor`/`CurvePad`, `icons.tsx`
+ (Learning/Inputs/Outputs/Settings/Help), `TrainingHealth` (real per-iteration loss curve from
+ `nisps_ml_loss_history` + per-layer weight health from `nisps_ml_get_layer_stats`; rendered only at
+ the Learning drawer's `expanded` depth — that IS the advanced-surface flag), `VerdictCluster`
+ (mode-aware), `OutputEditor`/`CurvePad`, `icons.tsx`
(monochrome currentColor SVG), `model.ts` (`MF_MODES` catalogue — schema-backed modes DERIVED from
`manifold/src/modes/generated/`; carries per-mode `ml` net shape + `engineId`), `output-mode.ts`.
- `manifold/src/modes/generated/` — codegen output (`*_schema.ts`, do NOT hand-edit): `ModeSchema`
@@ -86,10 +90,14 @@ anchor + locked decisions) and the `docs/specs/*-spec.md` set.
timer-driver over the shared C++ core via the `nisps_ml_jolt_*`/`nisps_ml_ou_*` bindings (the interim
TS math and `jolt.ts`/`ou-explore.ts` were deleted when P3 landed).
- `manifold/src/debug/probe.ts` — `window.__nisps` (`?debug=1`). `manifold/tests/e2e/` — `smoke`,
- `probe-api` (15-test engine-contract port), `spine` (spine invariant + probe-survives-mode-switch).
- E2E on the VPS runs via non-snap node (see BUILD-PLAN). `manifold/tests/fixtures/` — golden parity
+ `probe-api` (engine-contract port), `spine` (spine invariant + probe-survives-mode-switch),
+ `geo-dislike`, `reshape`, `schema-modes`, `training-health` (the loss/layer-stats panel + its
+ expanded-depth gating). E2E on the VPS runs via non-snap node (see BUILD-PLAN).
+ `manifold/tests/fixtures/` — golden parity
fixtures (gesture trace, curves, input/output pipelines) captured 2026-07-13 pre-P4, guarded by
- `tests/pipeline-golden.test.ts` (in `bun run test`). `manifold/osc-bridge/` — Deno WS↔UDP-OSC bridge.
+ `tests/pipeline-golden.test.ts` (in `bun run test`). `tests/loss-history.test.ts` drives the
+ `nisps_ml_loss_history` C ABI straight at the committed WASM — the training path parity-check
+ never touches. `manifold/osc-bridge/` — Deno WS↔UDP-OSC bridge.
### `vcv/` — VCV Rack 2 plugin (MEMLNaut module, WIP)
Native C++ Rack module: ML CV-mapper with RL feedback + a browser bridge. **8 inputs × 16 outputs + per-output
@@ -102,25 +110,29 @@ includes; no `nisps-core`.
### `schemas/` — JSON parameter contracts (firmware/browser source of truth)
- `schemas/schema.json` — Draft 2020-12 meta-schema validating mode files.
-- `schemas/modes/.json` (×9) — each mode's params, ranges, defaults, curves, voice spaces, ML config. (`slp_workshop.json` reuses `engine_id: memlcelium`.)
-- `schemas/modes/params_notes.md` — provenance notes and judgement calls per mode.
+- `schemas/modes/.json` (×9) — each mode's params, ranges, defaults, curves, voice spaces, ML config. (`slp_workshop.json` reuses `engine_id: memlcelium`.) `params[].curve` is the mode-wide DEFAULT response curve; a `voice_spaces` entry may be an object `{name, curve_overrides}` declaring only the slots where THAT voice space deviates (index i == `VoiceSpace` ordinal i). Descriptive throughout: the curve is applied exactly once, inside the engine's voice space.
+- `schemas/modes/params_notes.md` — provenance notes and judgement calls per mode, plus the exact `square`/`sqrt`/`linear` predicate the drift check enforces.
- `schemas/midi_device.schema.json` — Draft 2020-12 meta-schema for external-MIDI-synth templates.
- `schemas/midi_devices/.json` (×6) — CC-controllable external synths (Moog Sub 37 / Sub Phatty, Creamware Pro-12 ASB, Elektron Analog Keys, ASM Hydrasynth, Roland JD-800). Each param: `{id, cc, label, min, max, default, group}`. Canonical source for both firmware + browser device pickers. Verified-CC provenance + sources live in `schemas/midi_devices/sources/synth-midi-cc.json` (a `sources/` subdir, because the generator ajv-validates every `*.json` directly under `midi_devices/` as a device template).
### `codegen/` — schema → C++/TS code
- `codegen/generate.ts` — Bun script: validates schemas via ajv (incl. the P5 firmware-fit check: exactly 3 hidden layers, dims ≤4096), emits per-mode C++ `nisps/modes/generated/_schema.hpp` (`constexpr`, `nisps::modes::generated`) AND TS `manifold/src/modes/generated/_schema.ts` (+ `types.ts`, `index.ts`). Idempotent; golden-tested in `run-all-tests.sh` stage 5. The TS output is the SOURCE OF TRUTH consumed by `MF_MODES` (`manifold/src/console/model.ts`).
- `codegen/generate-midi-devices.ts` — separate Bun script (isolated from the mode golden test): validates `schemas/midi_devices/` via ajv, emits `nisps/midi/generated/midi_devices.hpp` (no-heap `constexpr`) and `manifold/src/midi-devices/generated/{types,devices,index}.ts`. Idempotent.
+- `codegen/curve-audit.ts` — reads `nisps/engines/*.hpp` and derives, per voice space, which response curve the engine applies to each NN-output slot. Handles the four idioms a regex misses (`const float v = p[n]; v*v`, memlcelium's implicit-counter `sq()` lambda, loop-generated indices, `smooth_params_[n]`) and RAISES on anything it cannot reduce rather than defaulting to `linear`.
+- `codegen/tests/curve_drift_test.ts` — the gate: schema-declared curves (JSON **and** generated TS) must equal what `curve-audit.ts` extracts, and schema `voice_spaces` order must equal the engine's `kVoiceSpaceNames`. Source-level by necessity — the curve is not recoverable from engine output. Run by `run-all-tests.sh` stage 5, CI, and `bun run test` in `codegen/`.
- `codegen/lib.ts` — helpers shared by both generators. `codegen/tests/golden/` — golden snapshot for paf_synth (C++ + TS).
### `tests/cpp/` — host C++ tests
- Per-component tests: `test_dsp_*.cpp`, `test_engine_*.cpp`, `test_mlp_*.cpp`, `test_mode_*.cpp`, `test_ring_buffer.cpp`, `test_rng.cpp`, `test_math.cpp`. Helpers in `test_helpers.hpp`.
- Verification: `ml_golden_vectors.cpp`, `engine_impulse.cpp` (+ `engine_impulse_baseline.bin`), `parity_check.cpp` + `parity_wasm.mjs` + `parity_diff.mjs` — native-vs-WASM bit-equivalence within 1e-5.
+- Measurement (asserts nothing): `engine_bench.cpp` + `bench_report.mjs` — per-engine throughput (ns/sample, blocks/s, realtime factor) for the `process()` hot path. ONE source compiled twice (CMake `nisps_engine_bench` natively, emcc for WASM) so the two targets are comparable without adding a single export to `nisps/wasm/bindings.cpp`. Engines are driven into a working state (transport running + event drain for the sequencers, periodic `note_on` for paf_synth, a noise+sine input bed for the fx/analysis engines) and every row prints its own working-state evidence, so a number produced by an idle engine is visible rather than plausible. Driven by `scripts/bench-engines.sh`.
### `scripts/` — build + verify entry points
- `build-firmware.sh`, `flash-firmware.sh`, `build-and-flash-firmware.sh`, `firmware-common.sh` — Arduino-CLI wrapper for RP2350 target with C++20 flag.
- `build-wasm.sh` — Emscripten compile producing `manifold/public/nisps.{wasm,js}`.
- `build-cpp-tests.sh` — CMake configure + build + ctest (Ninja).
- `parity-check.sh` — runs native + WASM and diffs binary outputs.
+- `bench-engines.sh` — engine throughput on native + WASM; `--compare ` prints per-engine Δ%. **Reports, never asserts** (a wall-clock threshold on shared hardware is meaningless or flaky — same call as the firmware size job). Reports land in `nisps/build/bench/`.
- `lint-cpp.sh` — `.f` literal warn + heap/`Arduino.h` violation fail.
- `run-all-tests.sh` — master verification script.
@@ -145,7 +157,8 @@ includes; no `nisps-core`.
- **WASM rebuild**: `bash scripts/build-wasm.sh` (needs `emcc`).
- **Host C++ tests**: `bash scripts/build-cpp-tests.sh`.
- **Parity check**: `bash scripts/parity-check.sh`.
-- **All tests**: `bash scripts/run-all-tests.sh`.
+- **Engine benchmark**: `bash scripts/bench-engines.sh` (add `--compare nisps/build/bench/latest.json` to diff against the previous run).
+- **All tests**: `bash scripts/run-all-tests.sh` (stage 6 is a bench smoke report; it does not gate).
- **Playwright**: `cd manifold && node node_modules/.bin/playwright test` (non-snap node runner on the VPS — BUILD-PLAN gotcha; `bunx playwright test` works elsewhere).
- **Codegen**: `cd codegen && bun run generate.ts` (regenerates `nisps/modes/generated/` + `nisps/ml/generated/` C++ and `manifold/src/modes/generated/` TS).
@@ -165,7 +178,8 @@ includes; no `nisps-core`.
- `firmware/MEMLNaut-NISPS/glue/mode_select.hpp` `#undef`s Arduino macros (`sq`, `min`, `max`, `abs`, `round`) before pulling nisps headers — engines use those identifiers as method names.
- `nisps_firmware::g_active_mode_bridge` is `extern` in `glue/audio_driver.hpp` and defined in `src/main.cpp`; combining `inline` with `__not_in_flash` produces a comdat conflict at link time.
- `pio run`'s own "Flash: NN%" console line double-counts `.data` on this board (PlatformIO's generic size checker counts every PROGBITS+ALLOC section). Compare `arm-none-eabi-size -A` — flash = `.text+.rodata` — before believing a size regression.
-- `nisps_modes_tests` builds against generated schemas under `nisps/modes/generated/`; if you add a new mode, regenerate via `bun run codegen/generate.ts` before building.
+- `nisps_modes_tests` builds against generated schemas under `nisps/modes/generated/`; if you add a new mode, regenerate via `bun run codegen/generate.ts` before building. It also compiles one firmware header (`glue/codec_config.hpp`), so the repo root is on its include path.
+- `nisps::DriverConfig`'s member defaults are load-bearing: they reproduce memllib's historical hardcoded codec setup, so a mode that declares nothing gets exactly the pre-wiring behaviour. Changing them changes the codec setup of every mode that expresses no opinion (pinned by `tests/cpp/test_mode_driver_config.cpp`).
## Smells / strategic concerns
diff --git a/codegen/curve-audit.ts b/codegen/curve-audit.ts
new file mode 100644
index 0000000..17fdd8d
--- /dev/null
+++ b/codegen/curve-audit.ts
@@ -0,0 +1,885 @@
+/**
+ * curve-audit.ts — mechanically derive, from `nisps/engines/*.hpp` source,
+ * which response curve each engine applies to each NN-output slot, per voice
+ * space.
+ *
+ * WHY THIS IS A SOURCE-LEVEL CHECK
+ * --------------------------------
+ * "The curve" is not observable from engine output. A voice space maps a
+ * normalised param into engine state as `base + f(p) * scale` and then that
+ * state goes through DSP. Given only audio out you cannot separate `f` from
+ * `base`/`scale`/the DSP, and the engines expose no accessor for the mapped
+ * state. So the only place the curve exists as a fact is the arithmetic in
+ * `apply_*()` / `set_params()` / `process()` — and that is what this module
+ * reads.
+ *
+ * The contract it enforces is deliberately narrow and total:
+ *
+ * square <=> the engine multiplies the param slot by itself
+ * sqrt <=> the engine passes the param slot through std::sqrt
+ * linear <=> anything else (including quantisation, sin() combination and
+ * stepped lookup tables — those are not expressible in the
+ * `Curve` enum and are declared `linear` by definition)
+ *
+ * Everything it cannot reduce is a HARD ERROR, never a silent "linear". That
+ * is the property that makes the derived table trustworthy: a new voice space,
+ * a new idiom, or a renamed helper trips the check instead of quietly
+ * under-reporting. A regex over `p[N] * p[N]` would miss the `const float v =
+ * p[…]; v * v` form (verb_fx), the `sq()` implicit-counter lambda
+ * (memlcelium), loop-generated indices (verb_fx) and `smooth_params_[N]`
+ * (xiasri) — every one of those is live in this codebase today.
+ *
+ * Consumed by codegen/tests/curve_drift_test.ts, which asserts the schemas'
+ * declared curves (params[].curve + per-voice-space overrides) match.
+ */
+
+import { readFileSync, readdirSync } from "node:fs";
+import { join } from "node:path";
+
+export type Curve =
+ | "linear"
+ | "exp"
+ | "log"
+ | "square"
+ | "sqrt"
+ | "sigmoid"
+ | "cubic";
+
+/** Identifiers that alias the NN-output vector inside an engine. */
+const ACCESSORS = ["params", "p", "smooth_params_", "nn_outputs_"] as const;
+const ACCESSOR_RE = new RegExp(`\\b(${ACCESSORS.join("|")})\\s*\\[`);
+
+export interface EngineCurves {
+ engineId: string;
+ file: string;
+ nParams: number;
+ /** Names from the engine's `kVoiceSpaceNames`, or null when it has none. */
+ voiceSpaceNames: string[] | null;
+ /** curves[voiceSpaceIndex][paramIndex]. One row when there is no enum. */
+ curves: Curve[][];
+}
+
+export class CurveAuditError extends Error {}
+
+function fail(where: string, msg: string): never {
+ throw new CurveAuditError(`[curve-audit] ${where}: ${msg}`);
+}
+
+// ---------------------------------------------------------------------------
+// Lexical helpers
+// ---------------------------------------------------------------------------
+
+function stripComments(src: string): string {
+ // Block comments first, then line comments. No string literals in these
+ // files contain `//` or `/*` (checked: the only literals are identifiers).
+ return src.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/[^\n]*/g, " ");
+}
+
+/** Index of the `)`/`}`/`]` matching the opener at `open`. */
+function matchBracket(src: string, open: number): number {
+ const pairs: Record = { "(": ")", "{": "}", "[": "]" };
+ const close = pairs[src[open]!];
+ if (!close) fail("matchBracket", `not an opener at ${open}: ${src[open]}`);
+ let depth = 0;
+ for (let i = open; i < src.length; i++) {
+ const c = src[i]!;
+ if (c === src[open]) depth++;
+ else if (c === close) {
+ depth--;
+ if (depth === 0) return i;
+ }
+ }
+ fail("matchBracket", `unbalanced ${src[open]} at ${open}`);
+}
+
+/** Body text (between braces, exclusive) of `(...) ... { ... }`. */
+function functionBody(src: string, name: string): string | null {
+ const re = new RegExp(`\\b${name}\\s*\\(`, "g");
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(src))) {
+ const argOpen = m.index + m[0].length - 1;
+ let argClose: number;
+ try {
+ argClose = matchBracket(src, argOpen);
+ } catch {
+ continue;
+ }
+ // Between `)` and `{` only qualifiers may appear (noexcept, const, ->…).
+ const between = src.slice(argClose + 1, src.indexOf("{", argClose) + 1);
+ if (!/^[\s\w:&*<>,]*\{$/.test(between)) continue;
+ const braceOpen = src.indexOf("{", argClose);
+ if (braceOpen < 0) continue;
+ return src.slice(braceOpen + 1, matchBracket(src, braceOpen));
+ }
+ return null;
+}
+
+/** Formal parameter names of `(...)`, in order. */
+function functionParams(src: string, name: string): string[] | null {
+ const re = new RegExp(`\\b${name}\\s*\\(`, "g");
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(src))) {
+ const argOpen = m.index + m[0].length - 1;
+ let argClose: number;
+ try {
+ argClose = matchBracket(src, argOpen);
+ } catch {
+ continue;
+ }
+ const between = src.slice(argClose + 1, src.indexOf("{", argClose) + 1);
+ if (!/^[\s\w:&*<>,]*\{$/.test(between)) continue;
+ const args = src.slice(argOpen + 1, argClose).trim();
+ if (args === "") return [];
+ return args.split(",").map((a) => {
+ const t = a.trim().replace(/\[\s*\]$/, "");
+ const w = t.match(/([A-Za-z_]\w*)\s*$/);
+ return w ? w[1]! : t;
+ });
+ }
+ return null;
+}
+
+/** Split arguments of a call at top nesting level. */
+function splitArgs(text: string): string[] {
+ const out: string[] = [];
+ let depth = 0;
+ let cur = "";
+ for (const c of text) {
+ if (c === "(" || c === "[" || c === "{" || c === "<") depth++;
+ else if (c === ")" || c === "]" || c === "}" || c === ">") depth--;
+ if (c === "," && depth === 0) {
+ out.push(cur);
+ cur = "";
+ } else cur += c;
+ }
+ if (cur.trim() !== "" || out.length > 0) out.push(cur);
+ return out.map((s) => s.trim());
+}
+
+// ---------------------------------------------------------------------------
+// Integer expression evaluation (array indices, loop bounds, ternary guards)
+// ---------------------------------------------------------------------------
+
+/**
+ * Evaluate a compile-time integer expression. Constants from the engine
+ * (`static constexpr std::size_t kX = …`) are substituted first. Anything the
+ * strict character whitelist rejects is a hard error — never a guess.
+ */
+function evalInt(expr: string, consts: Map, where: string): number {
+ let e = expr;
+ for (let pass = 0; pass < 8; pass++) {
+ const before = e;
+ for (const [k, v] of consts) {
+ e = e.replace(new RegExp(`\\b${k}\\b`, "g"), `(${v})`);
+ }
+ if (e === before) break;
+ }
+ e = e.replace(/(\d)[uU]\b/g, "$1");
+ e = e.replace(/static_cast<[^>]*>/g, "");
+ if (!/^[\d\s+\-*/%()]+$/.test(e)) {
+ fail(where, `non-constant integer expression ${JSON.stringify(expr)}`);
+ }
+ // eslint-disable-next-line no-new-func
+ const v = Function(`"use strict"; return (${e});`)() as number;
+ if (!Number.isInteger(v)) fail(where, `non-integer index ${expr} -> ${v}`);
+ return v;
+}
+
+/** Evaluate a boolean guard, or null when it is not compile-time constant. */
+function evalBool(expr: string, consts: Map): boolean | null {
+ let e = expr.trim();
+ while (e.startsWith("(") && matchBracket(e, 0) === e.length - 1) {
+ e = e.slice(1, -1).trim();
+ }
+ for (let pass = 0; pass < 8; pass++) {
+ const before = e;
+ for (const [k, v] of consts) e = e.replace(new RegExp(`\\b${k}\\b`, "g"), `(${v})`);
+ if (e === before) break;
+ }
+ e = e.replace(/(\d)[uU]\b/g, "$1");
+ if (!/^[\d\s+\-*/%()<>=!&|]+$/.test(e)) return null;
+ try {
+ // eslint-disable-next-line no-new-func
+ return Boolean(Function(`"use strict"; return (${e});`)());
+ } catch {
+ return null;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Body normalisation: inline helpers -> unroll loops -> flatten braces
+// ---------------------------------------------------------------------------
+
+interface EngineFile {
+ src: string;
+ path: string;
+ consts: Map;
+ /** member name -> std::array element count, for range-for unrolling. */
+ arrayLens: Map;
+}
+
+function loadEngine(path: string): EngineFile {
+ const src = stripComments(readFileSync(path, "utf8"));
+ const consts = new Map();
+ // `static constexpr std::size_t kFoo = ;` — resolved in declaration
+ // order so later constants may refer to earlier ones.
+ const cre = /static\s+constexpr\s+std::size_t\s+(\w+)\s*=\s*([^;]+);/g;
+ let m: RegExpExecArray | null;
+ while ((m = cre.exec(src))) {
+ try {
+ consts.set(m[1]!, evalInt(m[2]!, consts, path));
+ } catch {
+ /* not an integer constant we can use; ignore */
+ }
+ }
+ const arrayLens = new Map();
+ const are = /std::array\s*<\s*[^,<>]+(?:<[^>]*>)?\s*,\s*([^>]+)>\s*(\w+)/g;
+ while ((m = are.exec(src))) {
+ try {
+ arrayLens.set(m[2]!, evalInt(m[1]!, consts, path));
+ } catch {
+ /* dynamic length; ignore */
+ }
+ }
+ return { src, path, consts, arrayLens };
+}
+
+/**
+ * Replace `name(args);` statements whose `name` is a method defined in the
+ * same file with that method's body, substituting formals for actuals.
+ */
+const KEYWORD_CALLS = new Set(["if", "for", "while", "switch", "return", "sizeof", "static_cast"]);
+
+/** Locate the next bare `name(args);` statement whose `name` is defined here. */
+function findInlinableCall(
+ body: string,
+ eng: EngineFile,
+ from: number
+): { start: number; end: number; name: string; args: string } | null {
+ const callRe = /(?:^|[;{}])\s*([a-z_]\w*)\s*\(/g;
+ callRe.lastIndex = from;
+ let m: RegExpExecArray | null;
+ while ((m = callRe.exec(body))) {
+ const name = m[1]!;
+ callRe.lastIndex = m.index + m[0].length - 1;
+ if (KEYWORD_CALLS.has(name)) continue;
+ const open = m.index + m[0].length - 1;
+ let close: number;
+ try {
+ close = matchBracket(body, open);
+ } catch {
+ continue;
+ }
+ const rest = body.slice(close + 1);
+ if (rest.trimStart()[0] !== ";") continue;
+ if (functionBody(eng.src, name) === null) continue;
+ const semi = body.indexOf(";", close);
+ return { start: m.index + (/[;{}]/.test(m[0][0]!) ? 1 : 0), end: semi + 1, name, args: body.slice(open + 1, close) };
+ }
+ return null;
+}
+
+function inlineCalls(body: string, eng: EngineFile, seen: Set, where: string): string {
+ let text = body;
+ for (let guard = 0; guard < 256; guard++) {
+ const hit = findInlinableCall(text, eng, 0);
+ if (!hit) return text;
+ if (seen.has(hit.name)) fail(where, `recursive inline of ${hit.name}()`);
+ const callee = functionBody(eng.src, hit.name)!;
+ const formals = functionParams(eng.src, hit.name) ?? [];
+ const actuals = splitArgs(hit.args);
+ let inner = inlineCalls(callee, eng, new Set([...seen, hit.name]), `${where}>${hit.name}`);
+ formals.forEach((f, i) => {
+ const a = actuals[i];
+ if (a === undefined || f === a) return;
+ inner = inner.replace(new RegExp(`\\b${f}\\b`, "g"), `(${a})`);
+ });
+ text = text.slice(0, hit.start) + ` { ${inner} } ` + text.slice(hit.end);
+ }
+ fail(where, "helper inlining did not converge");
+}
+
+/** Unroll `for` loops with compile-time trip counts, brace-stripping bodies. */
+function unrollLoops(body: string, eng: EngineFile, where: string): string {
+ let text = body;
+ for (let pass = 0; pass < 64; pass++) {
+ const idx = text.search(/\bfor\s*\(/);
+ if (idx < 0) return text;
+ const open = text.indexOf("(", idx);
+ const close = matchBracket(text, open);
+ const header = text.slice(open + 1, close);
+
+ // Body: either a braced block or a single statement.
+ let bodyStart = close + 1;
+ while (/\s/.test(text[bodyStart] ?? "")) bodyStart++;
+ let inner: string;
+ let bodyEnd: number;
+ if (text[bodyStart] === "{") {
+ const b = matchBracket(text, bodyStart);
+ inner = text.slice(bodyStart + 1, b);
+ bodyEnd = b + 1;
+ } else {
+ const semi = text.indexOf(";", bodyStart);
+ if (semi < 0) fail(where, "for-loop body has no terminator");
+ inner = text.slice(bodyStart, semi + 1);
+ bodyEnd = semi + 1;
+ }
+
+ let expansion = "";
+ const counted = header.match(
+ /^\s*(?:std::size_t|int|std::uint\d+_t|auto)\s+(\w+)\s*=\s*([^;]+);\s*\1\s*<\s*([^;]+);\s*\+\+\1\s*$/
+ );
+ const ranged = header.match(/^\s*(?:const\s+)?auto\s*[&*]?\s*\w+\s*:\s*(\w+)\s*$/);
+ if (counted) {
+ const v = counted[1]!;
+ const lo = evalInt(counted[2]!, eng.consts, where);
+ const hi = evalInt(counted[3]!, eng.consts, where);
+ for (let i = lo; i < hi; i++) {
+ expansion += ` ${inner.replace(new RegExp(`\\b${v}\\b`, "g"), `(${i})`)} `;
+ }
+ } else if (ranged) {
+ const n = eng.arrayLens.get(ranged[1]!);
+ if (n === undefined) fail(where, `range-for over ${ranged[1]} with unknown length`);
+ for (let i = 0; i < n; i++) expansion += ` ${inner} `;
+ } else {
+ fail(where, `unrecognised for-loop header ${JSON.stringify(header.trim())}`);
+ }
+ text = text.slice(0, idx) + expansion + text.slice(bodyEnd);
+ }
+ fail(where, "for-loop unrolling did not converge");
+}
+
+/**
+ * Remove `switch (voice_space_) { … }` — each voice space is analysed against
+ * its own dispatch target, so keeping the switch would merge all of them.
+ * Any OTHER switch is deliberately left in place: brace-flattening turns its
+ * arms into straight-line statements, and if two arms disagree about a param's
+ * curve that surfaces as a conflict rather than a silent pick.
+ */
+function dropVoiceSpaceSwitch(body: string, where: string): string {
+ let text = body;
+ for (;;) {
+ const m = text.match(/\bswitch\s*\(\s*voice_space_\s*\)/);
+ if (!m || m.index === undefined) return text;
+ const open = text.indexOf("(", m.index);
+ const close = matchBracket(text, open);
+ let braceOpen = close + 1;
+ while (/\s/.test(text[braceOpen] ?? "")) braceOpen++;
+ if (text[braceOpen] !== "{") fail(where, "switch (voice_space_) without a block");
+ text = text.slice(0, m.index) + " " + text.slice(matchBracket(text, braceOpen) + 1);
+ }
+}
+
+/**
+ * Extract `auto NAME = [&]() { const float X = params[i++]; return EXPR; };`
+ * lambdas (memlcelium's `sq()`), returning the curve each application yields.
+ * Any other lambda shape is a hard error.
+ */
+function extractCounterLambdas(body: string, where: string): { text: string; lambdas: Map } {
+ const lambdas = new Map();
+ let text = body;
+ for (;;) {
+ const m = text.match(/\bauto\s+(\w+)\s*=\s*\[[^\]]*\]\s*\(/);
+ if (!m || m.index === undefined) return { text, lambdas };
+ const name = m[1]!;
+ const parenOpen = text.indexOf("(", m.index + m[0].length - 1);
+ const parenClose = matchBracket(text, parenOpen);
+ let braceOpen = parenClose + 1;
+ while (/\s/.test(text[braceOpen] ?? "")) braceOpen++;
+ if (text[braceOpen] !== "{") fail(where, `lambda ${name} without a body`);
+ const braceClose = matchBracket(text, braceOpen);
+ const inner = text.slice(braceOpen + 1, braceClose).trim();
+ const shape = inner.match(
+ /^const\s+float\s+(\w+)\s*=\s*params\s*\[\s*i\+\+\s*\]\s*;\s*return\s+([^;]+);$/
+ );
+ if (!shape) {
+ fail(where, `lambda ${name} has an unrecognised body: ${JSON.stringify(inner)}`);
+ }
+ const v = shape[1]!;
+ const ret = shape[2]!.replace(/\s+/g, " ").trim();
+ let curve: Curve;
+ if (ret === `${v} * ${v}`) curve = "square";
+ else if (ret === `std::sqrt(${v})`) curve = "sqrt";
+ else if (ret === v) curve = "linear";
+ else fail(where, `lambda ${name} returns an unrecognised form: ${JSON.stringify(ret)}`);
+ lambdas.set(name, curve);
+ let end = braceClose + 1;
+ while (/[\s;]/.test(text[end] ?? "")) end++;
+ text = text.slice(0, m.index) + " " + text.slice(end);
+ }
+}
+
+/** Strip every remaining brace; loops are unrolled and switches dropped by now. */
+function flattenBraces(text: string): string {
+ return text.replace(/[{}]/g, " ");
+}
+
+function splitStatements(text: string): string[] {
+ return text
+ .split(";")
+ .map((s) => s.replace(/\s+/g, " ").trim())
+ .filter((s) => s !== "");
+}
+
+// ---------------------------------------------------------------------------
+// Ternary reduction + accessor classification
+// ---------------------------------------------------------------------------
+
+/** Collapse `cond ? a : b` where `cond` is compile-time constant. */
+function reduceTernaries(stmt: string, consts: Map): string {
+ let text = stmt;
+ for (let pass = 0; pass < 32; pass++) {
+ // Innermost `?` first: the one with no further `?` before its `:`.
+ const q = text.lastIndexOf("?");
+ if (q < 0) return text;
+ // Condition: scan left to the nearest unbalanced `(`, or `=`/`,`/start.
+ let depth = 0;
+ let condStart = 0;
+ for (let i = q - 1; i >= 0; i--) {
+ const c = text[i]!;
+ if (c === ")" || c === "]") depth++;
+ else if (c === "(" || c === "[") {
+ if (depth === 0) {
+ condStart = i + 1;
+ break;
+ }
+ depth--;
+ } else if (depth === 0 && (c === "=" || c === ",")) {
+ condStart = i + 1;
+ break;
+ }
+ }
+ // Matching `:` at the same nesting depth.
+ depth = 0;
+ let colon = -1;
+ for (let i = q + 1; i < text.length; i++) {
+ const c = text[i]!;
+ if (c === "(" || c === "[") depth++;
+ else if (c === ")" || c === "]") {
+ if (depth === 0) break;
+ depth--;
+ } else if (c === ":" && depth === 0 && text[i + 1] !== ":" && text[i - 1] !== ":") {
+ colon = i;
+ break;
+ }
+ }
+ if (colon < 0) return text;
+ // End of the false branch: unbalanced `)`/`]`/`,` or end of statement.
+ depth = 0;
+ let end = text.length;
+ for (let i = colon + 1; i < text.length; i++) {
+ const c = text[i]!;
+ if (c === "(" || c === "[") depth++;
+ else if (c === ")" || c === "]") {
+ if (depth === 0) {
+ end = i;
+ break;
+ }
+ depth--;
+ } else if (c === "," && depth === 0) {
+ end = i;
+ break;
+ }
+ }
+ const cond = text.slice(condStart, q);
+ const t = text.slice(q + 1, colon);
+ const f = text.slice(colon + 1, end);
+ const v = evalBool(cond, consts);
+ // When the guard is not constant, keep BOTH branches: an accessor that is
+ // squared in one and sqrt'd in the other then surfaces as a conflict.
+ const repl = v === null ? `( ${cond} ) * ( ${t} ) * ( ${f} )` : v ? `( ${t} )` : `( ${f} )`;
+ text = text.slice(0, condStart) + repl + text.slice(end);
+ }
+ return text;
+}
+
+/** Operand immediately to the left of `at` (balanced group, or a bare term). */
+function leftOperand(text: string, at: number): { start: number; text: string } | null {
+ let i = at - 1;
+ while (i >= 0 && /\s/.test(text[i]!)) i--;
+ if (i < 0) return null;
+ if (text[i] === ")" || text[i] === "]") {
+ // Walk back over the balanced group, then over any leading identifier
+ // (so `p[3]` and `std::sqrt(x)` come back whole).
+ let depth = 0;
+ let j = i;
+ const open = text[i] === ")" ? "(" : "[";
+ for (; j >= 0; j--) {
+ if (text[j] === text[i]) depth++;
+ else if (text[j] === open) {
+ depth--;
+ if (depth === 0) break;
+ }
+ }
+ if (j < 0) return null;
+ let k = j - 1;
+ while (k >= 0 && /[\w:]/.test(text[k]!)) k--;
+ return { start: k + 1, text: text.slice(k + 1, i + 1) };
+ }
+ let j = i;
+ while (j >= 0 && /[\w.:]/.test(text[j]!)) j--;
+ if (j === i) return null;
+ return { start: j + 1, text: text.slice(j + 1, i + 1) };
+}
+
+/** Operand immediately to the right of `at`. */
+function rightOperand(text: string, at: number): { end: number; text: string } | null {
+ let i = at + 1;
+ while (i < text.length && /\s/.test(text[i]!)) i++;
+ if (i >= text.length) return null;
+ let j = i;
+ while (j < text.length && /[\w:]/.test(text[j]!)) j++;
+ if (j < text.length && (text[j] === "(" || text[j] === "[")) {
+ const close = matchBracket(text, j);
+ return { end: close + 1, text: text.slice(i, close + 1) };
+ }
+ if (j === i) return null;
+ return { end: j, text: text.slice(i, j) };
+}
+
+/** True when `text` is exactly one param-slot read, modulo parens/whitespace. */
+function isBareAccessor(text: string): boolean {
+ let t = text.trim();
+ while (t.startsWith("(") && matchBracket(t, 0) === t.length - 1) t = t.slice(1, -1).trim();
+ const m = t.match(new RegExp(`^(?:${ACCESSORS.join("|")})\\s*\\[`));
+ if (!m) return false;
+ return matchBracket(t, t.indexOf("[")) === t.length - 1;
+}
+
+/** Every accessor index appearing in `text`. */
+function accessorIndices(text: string, consts: Map, where: string): number[] {
+ const out: number[] = [];
+ const re = new RegExp(`\\b(${ACCESSORS.join("|")})\\s*\\[`, "g");
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(text))) {
+ const open = m.index + m[0].length - 1;
+ const close = matchBracket(text, open);
+ out.push(evalInt(text.slice(open + 1, close), consts, where));
+ re.lastIndex = close;
+ }
+ return out;
+}
+
+/**
+ * Classify every accessor occurrence in one expression.
+ *
+ * `sqrt` is recognised as `std::sqrt()`;
+ * `square` as `X * X` for textually identical operands containing accessors.
+ * Recognised occurrences are blanked so they are not re-counted as linear.
+ */
+function classifyExpr(expr: string, consts: Map, where: string): Map {
+ const found = new Map();
+ const note = (idx: number, c: Curve) => {
+ const prev = found.get(idx);
+ if (prev !== undefined && prev !== c) {
+ fail(where, `param ${idx} is both ${prev} and ${c} in one expression: ${expr}`);
+ }
+ found.set(idx, c);
+ };
+ let text = expr;
+
+ // 1. std::sqrt(...)
+ for (;;) {
+ const m = text.match(/\bstd::sqrt\s*\(/);
+ if (!m || m.index === undefined) break;
+ const open = text.indexOf("(", m.index);
+ const close = matchBracket(text, open);
+ const inner = text.slice(open + 1, close);
+ const idxs = accessorIndices(inner, consts, where);
+ if (idxs.length > 0) {
+ // A sqrt over a compound expression is not a per-param sqrt curve — the
+ // same reasoning as the self-product rule below. None exists today, so
+ // this is a hard error rather than a silent demotion; if one ever
+ // appears the author must decide what the declaration should say.
+ if (!isBareAccessor(inner)) fail(where, `std::sqrt over a compound expression: ${inner}`);
+ note(idxs[0]!, "sqrt");
+ text = text.slice(0, m.index) + " __X__ " + text.slice(close + 1);
+ } else {
+ text = text.slice(0, m.index) + " __X__ " + text.slice(close + 1);
+ }
+ }
+
+ // 2. X * X, where X is one bare param slot (possibly parenthesised because
+ // it arrived via an alias). A self-product over a COMPOUND expression —
+ // e.g. paf_synth Elderstar's `factor * factor` where
+ // `factor = 1.f + (p[17] + p[27] * 0.2f)` — is deliberately NOT a
+ // per-param square: no single slot is multiplied by itself, and the
+ // `Curve` enum has no way to say "this slot is one term inside a squared
+ // sum". Those slots fall through to `linear`, which is what the schemas
+ // declare. The rule is explicit, not a silent fallback.
+ for (let from = 0; ; ) {
+ let hit = false;
+ for (let i = from; i < text.length; i++) {
+ if (text[i] !== "*") continue;
+ const l = leftOperand(text, i);
+ const r = rightOperand(text, i);
+ if (!l || !r) continue;
+ if (l.text.replace(/\s+/g, "") !== r.text.replace(/\s+/g, "")) continue;
+ const idxs = accessorIndices(l.text, consts, where);
+ if (idxs.length === 0) continue;
+ if (idxs.length !== 1 || !isBareAccessor(l.text)) {
+ from = i + 1;
+ hit = true;
+ break;
+ }
+ note(idxs[0]!, "square");
+ text = text.slice(0, l.start) + " __X__ " + text.slice(r.end);
+ from = 0;
+ hit = true;
+ break;
+ }
+ if (!hit) break;
+ }
+
+ // 3. anything left is linear
+ for (const idx of accessorIndices(text, consts, where)) note(idx, "linear");
+ return found;
+}
+
+// ---------------------------------------------------------------------------
+// Body analysis
+// ---------------------------------------------------------------------------
+
+interface Analysis {
+ /** assignment target -> curves contributed by the latest write to it. */
+ targets: Map>;
+}
+
+const ALIAS_DECL = /^(?:static\s+)?const\s+(?:float|std::size_t|int|auto)\s+(\w+)\s*=\s*(.*)$/;
+const COUNTER_DECL = /^(?:std::size_t|int)\s+(\w+)\s*=\s*(\d+)u?$/;
+
+function analyseBody(
+ rawBody: string,
+ eng: EngineFile,
+ where: string,
+ state: Analysis,
+ stmtSeq: { n: number }
+): void {
+ let text = inlineCalls(rawBody, eng, new Set(), where);
+ text = dropVoiceSpaceSwitch(text, where);
+ text = unrollLoops(text, eng, where);
+ const { text: noLambda, lambdas } = extractCounterLambdas(text, where);
+ text = flattenBraces(noLambda);
+
+ const aliases = new Map();
+ let counterName: string | null = null;
+ let counter = 0;
+
+ for (const rawStmt of splitStatements(text)) {
+ let stmt = rawStmt;
+
+ // Sequential dialect: `params[i++]` and counter lambdas consume the next
+ // slot, left to right, and are rewritten into the ordinary indexed form.
+ if (counterName !== null) {
+ const seqRe = new RegExp(
+ `params\\s*\\[\\s*${counterName}\\+\\+\\s*\\]|\\b(${[...lambdas.keys()].join("|") || "\\u0000"})\\s*\\(\\s*\\)`,
+ "g"
+ );
+ stmt = stmt.replace(seqRe, (whole, lam?: string) => {
+ const idx = counter++;
+ if (lam) {
+ const c = lambdas.get(lam)!;
+ if (c === "square") return `params[${idx}] * params[${idx}]`;
+ if (c === "sqrt") return `std::sqrt(params[${idx}])`;
+ return `params[${idx}]`;
+ }
+ return `params[${idx}]`;
+ });
+ }
+ if (/\+\+\s*\]/.test(stmt)) {
+ fail(where, `post-increment index with no active counter: ${stmt}`);
+ }
+
+ const counterM = stmt.match(COUNTER_DECL);
+ if (counterM && !ACCESSOR_RE.test(stmt)) {
+ counterName = counterM[1]!;
+ counter = Number(counterM[2]!);
+ continue;
+ }
+
+ // Substitute live aliases (longest name first so `p1v` beats `p`).
+ for (const [name, a] of [...aliases].sort((x, y) => y[0].length - x[0].length)) {
+ const re = new RegExp(`\\b${name}\\b`, "g");
+ if (re.test(stmt)) {
+ // Not a use if this statement redeclares it.
+ const decl = stmt.match(ALIAS_DECL);
+ if (decl && decl[1] === name && !new RegExp(`\\b${name}\\b`).test(decl[2]!)) continue;
+ stmt = stmt.replace(re, `(${a.rhs})`);
+ a.used = true;
+ }
+ }
+
+ const aliasM = stmt.match(ALIAS_DECL);
+ if (aliasM) {
+ const name = aliasM[1]!;
+ const prev = aliases.get(name);
+ if (prev && !prev.used && ACCESSOR_RE.test(prev.rhs)) {
+ fail(where, `alias ${name} carrying a param was shadowed before use`);
+ }
+ aliases.set(name, { rhs: aliasM[2]!, used: false });
+ continue;
+ }
+
+ if (!ACCESSOR_RE.test(stmt)) continue;
+
+ const reduced = reduceTernaries(stmt, eng.consts);
+ const eq = topLevelAssign(reduced);
+ if (eq === null) {
+ // A call statement or return: unique key, never overwritten.
+ state.targets.set(`${where}#${stmtSeq.n++}`, classifyExpr(reduced, eng.consts, where));
+ continue;
+ }
+ const lhs = reduced.slice(0, eq).trim();
+ const rhs = reduced.slice(eq + 1);
+ // Pure copy between two aliases of the NN vector carries no curve.
+ if (isPureCopy(lhs, rhs, eng.consts, where)) continue;
+ if (ACCESSOR_RE.test(lhs)) {
+ fail(where, `assignment INTO the param vector with arithmetic: ${reduced}`);
+ }
+ state.targets.set(lhs.replace(/^(?:const\s+)?(?:float|auto)\s+/, ""), classifyExpr(rhs, eng.consts, where));
+ }
+
+ for (const [name, a] of aliases) {
+ if (!a.used && ACCESSOR_RE.test(a.rhs)) {
+ fail(where, `alias ${name} carrying a param was never used`);
+ }
+ }
+}
+
+/** Index of a top-level `=` that is an assignment (not ==, +=, <=, …). */
+function topLevelAssign(stmt: string): number | null {
+ let depth = 0;
+ for (let i = 0; i < stmt.length; i++) {
+ const c = stmt[i]!;
+ if (c === "(" || c === "[") depth++;
+ else if (c === ")" || c === "]") depth--;
+ else if (c === "=" && depth === 0) {
+ if (stmt[i + 1] === "=") return null;
+ if ("=!<>+-*/&|%".includes(stmt[i - 1] ?? "")) return null;
+ return i;
+ }
+ }
+ return null;
+}
+
+function isPureCopy(lhs: string, rhs: string, consts: Map, where: string): boolean {
+ const l = lhs.trim();
+ const r = rhs.trim();
+ if (!ACCESSOR_RE.test(l) || !ACCESSOR_RE.test(r)) return false;
+ const li = accessorIndices(l, consts, where);
+ const ri = accessorIndices(r, consts, where);
+ if (li.length !== 1 || ri.length !== 1 || li[0] !== ri[0]) return false;
+ // RHS must be nothing but the accessor.
+ return new RegExp(`^(?:${ACCESSORS.join("|")})\\s*\\[[^\\]]*\\]$`).test(r);
+}
+
+// ---------------------------------------------------------------------------
+// Engine-level extraction
+// ---------------------------------------------------------------------------
+
+function parseVoiceSpaces(eng: EngineFile): { enumNames: string[]; displayNames: string[] } | null {
+ const em = eng.src.match(/enum\s+class\s+VoiceSpace\s*:[^{]*\{/);
+ if (!em || em.index === undefined) return null;
+ const open = eng.src.indexOf("{", em.index);
+ const inner = eng.src.slice(open + 1, matchBracket(eng.src, open));
+ const enumNames = inner
+ .split(",")
+ .map((s) => s.split("=")[0]!.trim())
+ .filter((s) => s !== "" && s !== "Count");
+ const nm = eng.src.match(/kVoiceSpaceNames\s*=\s*\{/);
+ if (!nm || nm.index === undefined) fail(eng.path, "VoiceSpace enum without kVoiceSpaceNames");
+ const nOpen = eng.src.indexOf("{", nm.index);
+ const nInner = eng.src.slice(nOpen + 1, matchBracket(eng.src, nOpen));
+ const displayNames = [...nInner.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]!);
+ if (displayNames.length !== enumNames.length) {
+ fail(eng.path, `VoiceSpace enum has ${enumNames.length} entries but kVoiceSpaceNames has ${displayNames.length}`);
+ }
+ return { enumNames, displayNames };
+}
+
+/** enum name -> the `apply_*` function the dispatch switch routes it to. */
+function parseDispatch(eng: EngineFile, enumNames: string[]): Map {
+ const out = new Map();
+ const re = /case\s+VoiceSpace::(\w+)\s*:\s*(?:\{\s*)?(\w+)\s*\(/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(eng.src))) {
+ if (m[1] === "Count") continue;
+ out.set(m[1]!, m[2]!);
+ }
+ for (const n of enumNames) {
+ if (!out.has(n)) fail(eng.path, `VoiceSpace::${n} has no dispatch case`);
+ }
+ return out;
+}
+
+function collectCurves(state: Analysis, nParams: number, where: string): Curve[] {
+ const merged = new Map();
+ for (const contrib of state.targets.values()) {
+ for (const [idx, c] of contrib) {
+ if (idx < 0 || idx >= nParams) fail(where, `param index ${idx} out of range (0..${nParams - 1})`);
+ const prev = merged.get(idx);
+ if (prev !== undefined && prev !== c) {
+ fail(where, `param ${idx} is mapped both ${prev} and ${c} within one voice space`);
+ }
+ merged.set(idx, c);
+ }
+ }
+ const row: Curve[] = new Array(nParams).fill("linear");
+ for (const [idx, c] of merged) row[idx] = c;
+ return row;
+}
+
+export function auditEngine(path: string): EngineCurves {
+ const eng = loadEngine(path);
+ const idm = eng.src.match(/engine_id\(\)\s*noexcept\s*\{\s*return\s*"([^"]+)"/);
+ if (!idm) fail(path, "no engine_id()");
+ const engineId = idm[1]!;
+ const nParams = eng.consts.get("kNParams") ?? 0;
+
+ const vs = parseVoiceSpaces(eng);
+ const setParams = functionBody(eng.src, "set_params");
+ const process = functionBody(eng.src, "process");
+
+ if (nParams === 0) {
+ return { engineId, file: path, nParams: 0, voiceSpaceNames: null, curves: [[]] };
+ }
+
+ if (vs === null) {
+ const state: Analysis = { targets: new Map() };
+ const seq = { n: 0 };
+ if (setParams) analyseBody(setParams, eng, `${engineId}:set_params`, state, seq);
+ if (process) analyseBody(process, eng, `${engineId}:process`, state, seq);
+ return {
+ engineId,
+ file: path,
+ nParams,
+ voiceSpaceNames: null,
+ curves: [collectCurves(state, nParams, engineId)],
+ };
+ }
+
+ const dispatch = parseDispatch(eng, vs.enumNames);
+ const curves: Curve[][] = [];
+ for (const name of vs.enumNames) {
+ const fn = dispatch.get(name)!;
+ const body = functionBody(eng.src, fn);
+ if (body === null) fail(path, `dispatch target ${fn}() has no body`);
+ const state: Analysis = { targets: new Map() };
+ const seq = { n: 0 };
+ const where = `${engineId}:${name}`;
+ if (setParams) analyseBody(setParams, eng, `${where}/set_params`, state, seq);
+ analyseBody(body, eng, where, state, seq);
+ curves.push(collectCurves(state, nParams, where));
+ }
+ return { engineId, file: path, nParams, voiceSpaceNames: vs.displayNames, curves };
+}
+
+export function auditAllEngines(enginesDir: string): Map {
+ const out = new Map();
+ for (const f of readdirSync(enginesDir).filter((f) => f.endsWith(".hpp")).sort()) {
+ const res = auditEngine(join(enginesDir, f));
+ if (out.has(res.engineId)) fail(enginesDir, `duplicate engine_id ${res.engineId}`);
+ out.set(res.engineId, res);
+ }
+ return out;
+}
diff --git a/codegen/generate.ts b/codegen/generate.ts
index ed70851..8604034 100644
--- a/codegen/generate.ts
+++ b/codegen/generate.ts
@@ -39,6 +39,57 @@ import { ensureDir, readJSON, toPascalCase, cppStringLit, tsStringLit } from "./
type Curve = "linear" | "exp" | "log" | "square" | "sqrt" | "sigmoid" | "cubic";
+/**
+ * A voice space is either a bare name (it applies every param's default
+ * `curve`) or a name plus the slots where it DEVIATES from those defaults.
+ *
+ * The curve is a property of (param x voice space), not of the mode: the same
+ * NN slot is squared by one voice space and passed through linearly by
+ * another. The mode-wide `params[].curve` remains the default; this is the
+ * delta. Application stays where it has always been — inside the engine —
+ * so declaring it here changes no emitted numeric value.
+ */
+type VoiceSpaceDecl = string | { name: string; curve_overrides: Record; _note?: string };
+
+/** Flat (voice space, param) -> curve deviation, as emitted to both languages. */
+interface CurveOverrideRow {
+ voiceSpace: number;
+ param: number;
+ curve: Curve;
+}
+
+function voiceSpaceName(v: VoiceSpaceDecl): string {
+ return typeof v === "string" ? v : v.name;
+}
+
+/**
+ * Resolve a mode's declared deltas into the flat table both targets emit,
+ * sorted by (voice space, param) so output is order-independent.
+ * Throws on an override that names an unknown param or restates the default.
+ */
+function curveOverrideRows(schema: ModeSchema, source: string): CurveOverrideRow[] {
+ const paramIndex = new Map(schema.params.map((p, i) => [p.name, i] as const));
+ const rows: CurveOverrideRow[] = [];
+ schema.voice_spaces.forEach((vs, vi) => {
+ if (typeof vs === "string") return;
+ for (const [name, curve] of Object.entries(vs.curve_overrides)) {
+ const pi = paramIndex.get(name);
+ if (pi === undefined) {
+ throw new Error(`${source}: voice space ${vs.name} overrides unknown param ${JSON.stringify(name)}`);
+ }
+ if (schema.params[pi]!.curve === curve) {
+ throw new Error(
+ `${source}: voice space ${vs.name} restates param ${name}'s default curve ` +
+ `(${curve}) — declare only deviations`
+ );
+ }
+ rows.push({ voiceSpace: vi, param: pi, curve });
+ }
+ });
+ rows.sort((a, b) => a.voiceSpace - b.voiceSpace || a.param - b.param);
+ return rows;
+}
+
interface ModeSchema {
$schema?: string;
_note?: string;
@@ -61,7 +112,7 @@ interface ModeSchema {
group: string;
_note?: string;
}>;
- voice_spaces: string[];
+ voice_spaces: VoiceSpaceDecl[];
ui: {
primary_input: "xy_pad" | "joystick" | "sliders" | "audio_in" | "midi_in" | "none";
show_voice_space_selector: boolean;
@@ -193,6 +244,17 @@ function emitSchemaTypesHpp(): string {
" std::string_view group;",
"};",
"",
+ "// One (voice space, param) slot where the engine applies a curve OTHER",
+ "// than that param's default. The curve is a property of the pair, not of",
+ "// the mode — apply_ssl4k() squares slot 11 where apply_neve66() does not.",
+ "// DESCRIPTIVE: the engine's voice space is still the only place a curve",
+ "// is applied, and it is applied exactly once.",
+ "struct CurveOverride {",
+ " std::size_t voice_space;",
+ " std::size_t param;",
+ " Curve curve;",
+ "};",
+ "",
"struct MLConfig {",
" std::size_t input_size;",
" std::size_t output_size;",
@@ -233,9 +295,21 @@ function emitSchemaTypesHpp(): string {
" float default_spread;",
" std::span params;",
" std::span voice_spaces;",
+ " std::span curve_overrides;",
" ::nisps::modes::generated::UIConfig ui;",
"};",
"",
+ "// The curve voice space `vs` applies to output slot `param`: the param's",
+ "// default unless this mode declares a deviation for that voice space.",
+ "// Linear scan — the table has tens of rows and this is not a hot path.",
+ "constexpr ::nisps::Curve effective_curve(const ParamSchema& s, std::size_t vs,",
+ " std::size_t param) noexcept {",
+ " for (const auto& o : s.curve_overrides) {",
+ " if (o.voice_space == vs && o.param == param) return o.curve;",
+ " }",
+ " return s.params[param].curve;",
+ "}",
+ "",
"} // namespace nisps",
"",
"#endif // NISPS_GENERATED_SCHEMA_TYPES_HPP",
@@ -275,6 +349,20 @@ function emitSharedTsTypes(): string {
" readonly group: string;",
"}",
"",
+ "/**",
+ " * One (voice space, param) slot where the engine applies a curve OTHER than",
+ " * that param's default. The curve is a property of the pair, not of the mode",
+ " * — apply_ssl4k() squares slot 11 where apply_neve66() does not. DESCRIPTIVE:",
+ " * the engine's voice space is still the only place a curve is applied, and it",
+ " * is applied exactly once. Indices match `ModeSchema.voice_spaces` /",
+ " * `ModeSchema.params`.",
+ " */",
+ "export interface CurveOverride {",
+ " readonly voice_space: number;",
+ " readonly param: number;",
+ " readonly curve: Curve;",
+ "}",
+ "",
"export interface MLConfig {",
" readonly input_channels: readonly string[];",
" readonly input_size: number;",
@@ -295,9 +383,25 @@ function emitSharedTsTypes(): string {
" readonly ml: MLConfig;",
" readonly params: readonly Param[];",
" readonly voice_spaces: readonly string[];",
+ " readonly curve_overrides: readonly CurveOverride[];",
" readonly ui: UIConfig;",
"}",
"",
+ "/**",
+ " * The curve voice space `voiceSpace` applies to output slot `param`: the",
+ " * param's default unless this mode declares a deviation for that voice space.",
+ " */",
+ "export function effectiveCurve(",
+ " schema: ModeSchema,",
+ " voiceSpace: number,",
+ " param: number,",
+ "): Curve {",
+ " for (const o of schema.curve_overrides) {",
+ " if (o.voice_space === voiceSpace && o.param === param) return o.curve;",
+ " }",
+ " return schema.params[param]!.curve;",
+ "}",
+ "",
].join("\n");
}
@@ -368,7 +472,7 @@ function emitModeHpp(schema: ModeSchema, sourceFile: string): string {
if (schema.voice_spaces.length > 0) {
lines.push(`inline constexpr std::array ${constName}VoiceSpaces = {{`);
for (const v of schema.voice_spaces) {
- lines.push(` ${cppStringLit(v)},`);
+ lines.push(` ${cppStringLit(voiceSpaceName(v))},`);
}
lines.push("}};");
} else {
@@ -377,6 +481,25 @@ function emitModeHpp(schema: ModeSchema, sourceFile: string): string {
}
lines.push("");
+ // Per-voice-space curve deviations (see CurveOverride in schema_types.hpp).
+ // Only the deltas from `params[].curve`; resolve with nisps::effective_curve.
+ const overrides = curveOverrideRows(schema, sourceFile);
+ if (overrides.length > 0) {
+ lines.push(
+ `inline constexpr std::array ${constName}CurveOverrides = {{`
+ );
+ for (const o of overrides) {
+ lines.push(
+ ` CurveOverride{${o.voiceSpace}u, ${o.param}u, ${cppCurveEnum(o.curve)}},` +
+ ` // ${voiceSpaceName(schema.voice_spaces[o.voiceSpace]!)}.${schema.params[o.param]!.name}`
+ );
+ }
+ lines.push("}};");
+ } else {
+ lines.push(`inline constexpr std::array ${constName}CurveOverrides = {};`);
+ }
+ lines.push("");
+
// UI
let primary: string;
switch (schema.ui.primary_input) {
@@ -417,6 +540,7 @@ function emitModeHpp(schema: ModeSchema, sourceFile: string): string {
lines.push(` ${constName}MLConfig.default_spread,`);
lines.push(` std::span(${constName}Params),`);
lines.push(` std::span(${constName}VoiceSpaces),`);
+ lines.push(` std::span(${constName}CurveOverrides),`);
lines.push(` ${constName}UI,`);
lines.push("};");
lines.push("");
@@ -485,7 +609,22 @@ function emitModeTs(schema: ModeSchema, sourceFile: string): string {
} else {
lines.push(" voice_spaces: [");
for (const v of schema.voice_spaces) {
- lines.push(` ${tsStringLit(v)},`);
+ lines.push(` ${tsStringLit(voiceSpaceName(v))},`);
+ }
+ lines.push(" ],");
+ }
+ // Per-voice-space curve deviations; resolve with effectiveCurve() from
+ // ./types. Only the deltas from params[].curve are listed.
+ const overrides = curveOverrideRows(schema, sourceFile);
+ if (overrides.length === 0) {
+ lines.push(" curve_overrides: [],");
+ } else {
+ lines.push(" curve_overrides: [");
+ for (const o of overrides) {
+ lines.push(
+ ` { voice_space: ${o.voiceSpace}, param: ${o.param}, curve: ${tsStringLit(o.curve)} },` +
+ ` // ${voiceSpaceName(schema.voice_spaces[o.voiceSpace]!)}.${schema.params[o.param]!.name}`
+ );
}
lines.push(" ],");
}
@@ -712,6 +851,24 @@ function main(): number {
continue;
}
}
+ // Per-voice-space curve deviations must name real params and must be real
+ // deviations. Resolved here so a bad table fails BEFORE anything is written.
+ try {
+ curveOverrideRows(schema, f);
+ } catch (e) {
+ console.error(`error: ${(e as Error).message}`);
+ errorCount++;
+ continue;
+ }
+ {
+ const names = schema.voice_spaces.map(voiceSpaceName);
+ const dup = names.find((n, i) => names.indexOf(n) !== i);
+ if (dup !== undefined) {
+ console.error(`error: ${f}: duplicate voice space name ${JSON.stringify(dup)}`);
+ errorCount++;
+ continue;
+ }
+ }
schemas.push({ source: f, schema });
}
if (errorCount > 0) {
diff --git a/codegen/package.json b/codegen/package.json
index a76fa9b..d6ffff6 100644
--- a/codegen/package.json
+++ b/codegen/package.json
@@ -6,7 +6,7 @@
"type": "module",
"scripts": {
"generate": "bun run generate.ts",
- "test": "bun run tests/golden_test.ts"
+ "test": "bun run tests/golden_test.ts && bun run tests/curve_drift_test.ts"
},
"dependencies": {
"ajv": "^8.17.1",
diff --git a/codegen/tests/curve_drift_test.ts b/codegen/tests/curve_drift_test.ts
new file mode 100644
index 0000000..8dcab5b
--- /dev/null
+++ b/codegen/tests/curve_drift_test.ts
@@ -0,0 +1,185 @@
+#!/usr/bin/env bun
+/**
+ * Curve drift check — the schemas' declared response curves must equal what
+ * `nisps/engines/*.hpp` actually does, per voice space.
+ *
+ * WHY THIS CHECK IS SOURCE-LEVEL, NOT BEHAVIOURAL
+ * -----------------------------------------------
+ * You cannot observe "the curve" from engine output. A voice space maps a
+ * normalised slot into engine state as `base + f(p) * scale` and the state
+ * then disappears into DSP; without independently knowing `base`/`scale` (and
+ * the DSP transfer function) there is no way to recover `f` from audio. The
+ * engines expose no accessor for the mapped state, and adding one would mean
+ * editing engine internals to make a declaration checkable — the tail wagging
+ * the dog. So the fact lives in the arithmetic, and that is what we read.
+ *
+ * The upside of being source-level: it verifies the WHOLE table (9 modes, 26
+ * voice spaces, 344 params) rather than the handful of code paths any
+ * behavioural harness would reach. The parity harness, for contrast, only
+ * exercises PAFSynth + ChannelStrip at all-params-0.5.
+ *
+ * It fails loudly on three separate classes of drift:
+ * 1. a declared curve that disagrees with the engine,
+ * 2. a schema voice-space list that disagrees with the engine's
+ * `kVoiceSpaceNames` (order matters — the schema index IS the enum
+ * ordinal that `ModeBase::set_voice_space` casts to),
+ * 3. an engine idiom the extractor cannot reduce (codegen/curve-audit.ts
+ * raises rather than guessing "linear").
+ *
+ * It checks the JSON schemas AND the generated TypeScript, so a codegen bug
+ * that drops the table cannot pass.
+ */
+
+import { readFileSync, readdirSync } from "node:fs";
+import { join, dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { auditAllEngines, CurveAuditError, type Curve } from "../curve-audit.ts";
+import { ALL_MODE_SCHEMAS, effectiveCurve } from "../../manifold/src/modes/generated/index.ts";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = resolve(__dirname, "..", "..");
+const MODES_DIR = join(REPO_ROOT, "schemas", "modes");
+const ENGINES_DIR = join(REPO_ROOT, "nisps", "engines");
+
+type VoiceSpaceDecl = string | { name: string; curve_overrides: Record };
+interface JsonModeSchema {
+ mode_id: string;
+ engine_id: string;
+ params: Array<{ name: string; curve: Curve }>;
+ voice_spaces: VoiceSpaceDecl[];
+}
+
+const vsName = (v: VoiceSpaceDecl): string => (typeof v === "string" ? v : v.name);
+
+/** Resolve the JSON declaration into curves[voiceSpace][param]. */
+function declaredCurves(s: JsonModeSchema): Curve[][] {
+ const defaults = s.params.map((p) => p.curve);
+ const byName = new Map(s.params.map((p, i) => [p.name, i] as const));
+ const rows = s.voice_spaces.length === 0 ? [null] : s.voice_spaces;
+ return rows.map((v) => {
+ const row = [...defaults];
+ if (v && typeof v !== "string") {
+ for (const [name, c] of Object.entries(v.curve_overrides)) {
+ row[byName.get(name)!] = c;
+ }
+ }
+ return row;
+ });
+}
+
+function run(): number {
+ const problems: string[] = [];
+ let engines;
+ try {
+ engines = auditAllEngines(ENGINES_DIR);
+ } catch (e) {
+ if (e instanceof CurveAuditError) {
+ console.error(`\nFAILED to read the engines' curve arithmetic:\n ${e.message}\n`);
+ console.error(
+ "The extractor refuses to guess. Either the engine grew an idiom\n" +
+ "codegen/curve-audit.ts does not model, or a voice space lost its\n" +
+ "dispatch case. Teach the extractor, do not weaken it.\n"
+ );
+ return 1;
+ }
+ throw e;
+ }
+
+ const tsByModeId = new Map(ALL_MODE_SCHEMAS.map((m) => [m.mode_id, m] as const));
+ let checkedModes = 0;
+ let checkedSlots = 0;
+
+ for (const f of readdirSync(MODES_DIR).filter((n) => n.endsWith(".json")).sort()) {
+ const s = JSON.parse(readFileSync(join(MODES_DIR, f), "utf8")) as JsonModeSchema;
+ const eng = engines.get(s.engine_id);
+ if (!eng) {
+ problems.push(`${f}: engine_id ${JSON.stringify(s.engine_id)} matches no engine in nisps/engines/`);
+ continue;
+ }
+
+ // (2) voice-space identity. Index i in the schema IS VoiceSpace ordinal i.
+ if (eng.voiceSpaceNames !== null) {
+ const declared = s.voice_spaces.map(vsName);
+ if (JSON.stringify(declared) !== JSON.stringify(eng.voiceSpaceNames)) {
+ problems.push(
+ `${f}: voice_spaces disagree with ${eng.file}'s kVoiceSpaceNames\n` +
+ ` schema: ${JSON.stringify(declared)}\n` +
+ ` engine: ${JSON.stringify(eng.voiceSpaceNames)}`
+ );
+ continue;
+ }
+ } else if (s.voice_spaces.length > 1) {
+ problems.push(
+ `${f}: declares ${s.voice_spaces.length} voice spaces but ${eng.file} has no VoiceSpace enum`
+ );
+ continue;
+ }
+
+ // An engine with no params of its own (NoOpEngine, engine_id "thru") maps
+ // nothing: the mode's outputs are MIDI CCs it emits itself, unshaped.
+ const allLinear: Curve[] = s.params.map(() => "linear");
+ const actual =
+ eng.nParams === 0
+ ? [allLinear]
+ : eng.voiceSpaceNames !== null
+ ? eng.curves
+ : [eng.curves[0]!];
+
+ if (eng.nParams !== 0 && eng.nParams !== s.params.length) {
+ problems.push(`${f}: ${s.params.length} params but ${eng.engineId} has kNParams = ${eng.nParams}`);
+ continue;
+ }
+
+ // (1) the declared table, from the JSON…
+ const declared = declaredCurves(s);
+ // …and independently from the generated TypeScript, so a codegen bug that
+ // drops or mis-indexes the table is caught too.
+ const ts = tsByModeId.get(s.mode_id);
+ if (!ts) {
+ problems.push(`${f}: no generated TypeScript schema for mode_id ${s.mode_id}`);
+ continue;
+ }
+
+ const nVs = Math.max(declared.length, 1);
+ for (let vs = 0; vs < nVs; vs++) {
+ const expect = actual.length === 1 ? actual[0]! : actual[vs]!;
+ for (let i = 0; i < s.params.length; i++) {
+ checkedSlots++;
+ const label = `${s.mode_id}[${vsName(s.voice_spaces[vs] ?? "-")}].${s.params[i]!.name} (slot ${i})`;
+ if (declared[vs]![i] !== expect[i]) {
+ problems.push(
+ `${label}: schema says ${declared[vs]![i]}, ${eng.file} applies ${expect[i]}`
+ );
+ }
+ const fromTs = effectiveCurve(ts, vs, i);
+ if (fromTs !== declared[vs]![i]) {
+ problems.push(
+ `${label}: generated TS says ${fromTs}, schemas/modes/${f} says ${declared[vs]![i]}`
+ );
+ }
+ }
+ }
+ checkedModes++;
+ }
+
+ if (problems.length > 0) {
+ console.error(`\ncurve drift: ${problems.length} problem(s)\n`);
+ for (const p of problems) console.error(` ${p}`);
+ console.error(
+ "\nThe schemas' `curve` fields are DESCRIPTIVE: they record what the\n" +
+ "engine already does. If an engine's arithmetic changed on purpose,\n" +
+ "update schemas/modes/*.json (params[].curve for the default, or the\n" +
+ "voice space's curve_overrides for a deviation) and re-run codegen.\n" +
+ "Do NOT change the engine to match the declaration.\n"
+ );
+ return 1;
+ }
+
+ console.log(
+ `curve drift: ok — ${checkedModes} modes, ${checkedSlots} (voice space x param) slots ` +
+ `cross-checked against nisps/engines/ source.`
+ );
+ return 0;
+}
+
+process.exit(run());
diff --git a/codegen/tests/golden/paf_synth_schema.hpp b/codegen/tests/golden/paf_synth_schema.hpp
index a202a12..e19a6da 100644
--- a/codegen/tests/golden/paf_synth_schema.hpp
+++ b/codegen/tests/golden/paf_synth_schema.hpp
@@ -341,6 +341,70 @@ inline constexpr std::array kPafSynt
"Ipeleiades",
}};
+inline constexpr std::array kPafSynthCurveOverrides = {{
+ CurveOverride{0u, 8u, Curve::linear}, // Ellipticacacia.paf0_vib
+ CurveOverride{0u, 9u, Curve::linear}, // Ellipticacacia.paf1_vib
+ CurveOverride{0u, 11u, Curve::linear}, // Ellipticacacia.paf0_vfr
+ CurveOverride{0u, 12u, Curve::linear}, // Ellipticacacia.paf1_vfr
+ CurveOverride{0u, 14u, Curve::square}, // Ellipticacacia.paf0_shift
+ CurveOverride{0u, 17u, Curve::linear}, // Ellipticacacia.dl1mix
+ CurveOverride{0u, 18u, Curve::square}, // Ellipticacacia.p18
+ CurveOverride{0u, 19u, Curve::square}, // Ellipticacacia.dlfb
+ CurveOverride{0u, 20u, Curve::linear}, // Ellipticacacia.env_decay
+ CurveOverride{0u, 26u, Curve::linear}, // Ellipticacacia.shape_gain
+ CurveOverride{0u, 27u, Curve::linear}, // Ellipticacacia.shape_asym
+ CurveOverride{0u, 29u, Curve::linear}, // Ellipticacacia.rm_gain
+ CurveOverride{2u, 10u, Curve::square}, // Neemeda.p10
+ CurveOverride{2u, 13u, Curve::square}, // Neemeda.p13
+ CurveOverride{2u, 23u, Curve::square}, // Neemeda.p23
+ CurveOverride{2u, 24u, Curve::square}, // Neemeda.p24
+ CurveOverride{3u, 10u, Curve::square}, // Aquillow.p10
+ CurveOverride{3u, 13u, Curve::square}, // Aquillow.p13
+ CurveOverride{3u, 23u, Curve::square}, // Aquillow.p23
+ CurveOverride{3u, 24u, Curve::square}, // Aquillow.p24
+ CurveOverride{3u, 26u, Curve::linear}, // Aquillow.shape_gain
+ CurveOverride{3u, 27u, Curve::linear}, // Aquillow.shape_asym
+ CurveOverride{3u, 29u, Curve::linear}, // Aquillow.rm_gain
+ CurveOverride{3u, 32u, Curve::square}, // Aquillow.env_release
+ CurveOverride{4u, 5u, Curve::square}, // Magnetarch.paf0_bw
+ CurveOverride{4u, 8u, Curve::linear}, // Magnetarch.paf0_vib
+ CurveOverride{4u, 9u, Curve::linear}, // Magnetarch.paf1_vib
+ CurveOverride{4u, 11u, Curve::linear}, // Magnetarch.paf0_vfr
+ CurveOverride{4u, 12u, Curve::linear}, // Magnetarch.paf1_vfr
+ CurveOverride{4u, 17u, Curve::linear}, // Magnetarch.dl1mix
+ CurveOverride{4u, 20u, Curve::linear}, // Magnetarch.env_decay
+ CurveOverride{4u, 26u, Curve::linear}, // Magnetarch.shape_gain
+ CurveOverride{4u, 27u, Curve::linear}, // Magnetarch.shape_asym
+ CurveOverride{4u, 29u, Curve::linear}, // Magnetarch.rm_gain
+ CurveOverride{5u, 8u, Curve::linear}, // Elderstar.paf0_vib
+ CurveOverride{5u, 9u, Curve::linear}, // Elderstar.paf1_vib
+ CurveOverride{5u, 11u, Curve::linear}, // Elderstar.paf0_vfr
+ CurveOverride{5u, 12u, Curve::linear}, // Elderstar.paf1_vfr
+ CurveOverride{5u, 14u, Curve::square}, // Elderstar.paf0_shift
+ CurveOverride{5u, 17u, Curve::linear}, // Elderstar.dl1mix
+ CurveOverride{5u, 18u, Curve::square}, // Elderstar.p18
+ CurveOverride{5u, 19u, Curve::square}, // Elderstar.dlfb
+ CurveOverride{5u, 21u, Curve::square}, // Elderstar.p21
+ CurveOverride{5u, 22u, Curve::square}, // Elderstar.p22
+ CurveOverride{5u, 23u, Curve::square}, // Elderstar.p23
+ CurveOverride{5u, 26u, Curve::linear}, // Elderstar.shape_gain
+ CurveOverride{5u, 27u, Curve::linear}, // Elderstar.shape_asym
+ CurveOverride{5u, 29u, Curve::linear}, // Elderstar.rm_gain
+ CurveOverride{6u, 8u, Curve::linear}, // Ipeleiades.paf0_vib
+ CurveOverride{6u, 9u, Curve::linear}, // Ipeleiades.paf1_vib
+ CurveOverride{6u, 11u, Curve::linear}, // Ipeleiades.paf0_vfr
+ CurveOverride{6u, 12u, Curve::linear}, // Ipeleiades.paf1_vfr
+ CurveOverride{6u, 14u, Curve::square}, // Ipeleiades.paf0_shift
+ CurveOverride{6u, 17u, Curve::linear}, // Ipeleiades.dl1mix
+ CurveOverride{6u, 18u, Curve::square}, // Ipeleiades.p18
+ CurveOverride{6u, 21u, Curve::square}, // Ipeleiades.p21
+ CurveOverride{6u, 22u, Curve::square}, // Ipeleiades.p22
+ CurveOverride{6u, 23u, Curve::square}, // Ipeleiades.p23
+ CurveOverride{6u, 26u, Curve::linear}, // Ipeleiades.shape_gain
+ CurveOverride{6u, 27u, Curve::linear}, // Ipeleiades.shape_asym
+ CurveOverride{6u, 29u, Curve::linear}, // Ipeleiades.rm_gain
+}};
+
inline constexpr UIConfig kPafSynthUI = {
PrimaryInput::XYPad,
true,
@@ -359,6 +423,7 @@ inline constexpr ::nisps::ParamSchema kPafSynthSchema = {
kPafSynthMLConfig.default_spread,
std::span(kPafSynthParams),
std::span(kPafSynthVoiceSpaces),
+ std::span(kPafSynthCurveOverrides),
kPafSynthUI,
};
diff --git a/codegen/tests/golden/paf_synth_schema.ts b/codegen/tests/golden/paf_synth_schema.ts
index 5890052..5a930dd 100644
--- a/codegen/tests/golden/paf_synth_schema.ts
+++ b/codegen/tests/golden/paf_synth_schema.ts
@@ -364,6 +364,69 @@ export const PafSynthSchema: ModeSchema = {
'Elderstar',
'Ipeleiades',
],
+ curve_overrides: [
+ { voice_space: 0, param: 8, curve: 'linear' }, // Ellipticacacia.paf0_vib
+ { voice_space: 0, param: 9, curve: 'linear' }, // Ellipticacacia.paf1_vib
+ { voice_space: 0, param: 11, curve: 'linear' }, // Ellipticacacia.paf0_vfr
+ { voice_space: 0, param: 12, curve: 'linear' }, // Ellipticacacia.paf1_vfr
+ { voice_space: 0, param: 14, curve: 'square' }, // Ellipticacacia.paf0_shift
+ { voice_space: 0, param: 17, curve: 'linear' }, // Ellipticacacia.dl1mix
+ { voice_space: 0, param: 18, curve: 'square' }, // Ellipticacacia.p18
+ { voice_space: 0, param: 19, curve: 'square' }, // Ellipticacacia.dlfb
+ { voice_space: 0, param: 20, curve: 'linear' }, // Ellipticacacia.env_decay
+ { voice_space: 0, param: 26, curve: 'linear' }, // Ellipticacacia.shape_gain
+ { voice_space: 0, param: 27, curve: 'linear' }, // Ellipticacacia.shape_asym
+ { voice_space: 0, param: 29, curve: 'linear' }, // Ellipticacacia.rm_gain
+ { voice_space: 2, param: 10, curve: 'square' }, // Neemeda.p10
+ { voice_space: 2, param: 13, curve: 'square' }, // Neemeda.p13
+ { voice_space: 2, param: 23, curve: 'square' }, // Neemeda.p23
+ { voice_space: 2, param: 24, curve: 'square' }, // Neemeda.p24
+ { voice_space: 3, param: 10, curve: 'square' }, // Aquillow.p10
+ { voice_space: 3, param: 13, curve: 'square' }, // Aquillow.p13
+ { voice_space: 3, param: 23, curve: 'square' }, // Aquillow.p23
+ { voice_space: 3, param: 24, curve: 'square' }, // Aquillow.p24
+ { voice_space: 3, param: 26, curve: 'linear' }, // Aquillow.shape_gain
+ { voice_space: 3, param: 27, curve: 'linear' }, // Aquillow.shape_asym
+ { voice_space: 3, param: 29, curve: 'linear' }, // Aquillow.rm_gain
+ { voice_space: 3, param: 32, curve: 'square' }, // Aquillow.env_release
+ { voice_space: 4, param: 5, curve: 'square' }, // Magnetarch.paf0_bw
+ { voice_space: 4, param: 8, curve: 'linear' }, // Magnetarch.paf0_vib
+ { voice_space: 4, param: 9, curve: 'linear' }, // Magnetarch.paf1_vib
+ { voice_space: 4, param: 11, curve: 'linear' }, // Magnetarch.paf0_vfr
+ { voice_space: 4, param: 12, curve: 'linear' }, // Magnetarch.paf1_vfr
+ { voice_space: 4, param: 17, curve: 'linear' }, // Magnetarch.dl1mix
+ { voice_space: 4, param: 20, curve: 'linear' }, // Magnetarch.env_decay
+ { voice_space: 4, param: 26, curve: 'linear' }, // Magnetarch.shape_gain
+ { voice_space: 4, param: 27, curve: 'linear' }, // Magnetarch.shape_asym
+ { voice_space: 4, param: 29, curve: 'linear' }, // Magnetarch.rm_gain
+ { voice_space: 5, param: 8, curve: 'linear' }, // Elderstar.paf0_vib
+ { voice_space: 5, param: 9, curve: 'linear' }, // Elderstar.paf1_vib
+ { voice_space: 5, param: 11, curve: 'linear' }, // Elderstar.paf0_vfr
+ { voice_space: 5, param: 12, curve: 'linear' }, // Elderstar.paf1_vfr
+ { voice_space: 5, param: 14, curve: 'square' }, // Elderstar.paf0_shift
+ { voice_space: 5, param: 17, curve: 'linear' }, // Elderstar.dl1mix
+ { voice_space: 5, param: 18, curve: 'square' }, // Elderstar.p18
+ { voice_space: 5, param: 19, curve: 'square' }, // Elderstar.dlfb
+ { voice_space: 5, param: 21, curve: 'square' }, // Elderstar.p21
+ { voice_space: 5, param: 22, curve: 'square' }, // Elderstar.p22
+ { voice_space: 5, param: 23, curve: 'square' }, // Elderstar.p23
+ { voice_space: 5, param: 26, curve: 'linear' }, // Elderstar.shape_gain
+ { voice_space: 5, param: 27, curve: 'linear' }, // Elderstar.shape_asym
+ { voice_space: 5, param: 29, curve: 'linear' }, // Elderstar.rm_gain
+ { voice_space: 6, param: 8, curve: 'linear' }, // Ipeleiades.paf0_vib
+ { voice_space: 6, param: 9, curve: 'linear' }, // Ipeleiades.paf1_vib
+ { voice_space: 6, param: 11, curve: 'linear' }, // Ipeleiades.paf0_vfr
+ { voice_space: 6, param: 12, curve: 'linear' }, // Ipeleiades.paf1_vfr
+ { voice_space: 6, param: 14, curve: 'square' }, // Ipeleiades.paf0_shift
+ { voice_space: 6, param: 17, curve: 'linear' }, // Ipeleiades.dl1mix
+ { voice_space: 6, param: 18, curve: 'square' }, // Ipeleiades.p18
+ { voice_space: 6, param: 21, curve: 'square' }, // Ipeleiades.p21
+ { voice_space: 6, param: 22, curve: 'square' }, // Ipeleiades.p22
+ { voice_space: 6, param: 23, curve: 'square' }, // Ipeleiades.p23
+ { voice_space: 6, param: 26, curve: 'linear' }, // Ipeleiades.shape_gain
+ { voice_space: 6, param: 27, curve: 'linear' }, // Ipeleiades.shape_asym
+ { voice_space: 6, param: 29, curve: 'linear' }, // Ipeleiades.rm_gain
+ ],
ui: {
primary_input: 'xy_pad',
show_voice_space_selector: true,
diff --git a/docs/specs/plans/simplification-plan.md b/docs/specs/plans/simplification-plan.md
index acb8f2b..9290bd3 100644
--- a/docs/specs/plans/simplification-plan.md
+++ b/docs/specs/plans/simplification-plan.md
@@ -39,7 +39,7 @@ All verifier-checked deletions; protected exceptions noted. Rough net effect: th
- **Repo root / hygiene**: retired-playground dist + root Playwright rig + root `package.json`/lockfile/`node_modules` (S23, S29, L55); `NISPS_CORE_EXTRACTION_PLAN.md` + `NISPS_CORE_TASKS.md` (L48/L36); `data/` (L36); committed `.claude/worktrees/` fragment (L32); prune stale `worktree-*` branches.
- **nisps core/ml**: `fixed_buffer.hpp` + its test (L27); `dislike_multiplier_` (L26); `copy_weights_to(span)` to drop `flat_` from FixedStorage and the per-gesture double copy (L28); perf-macro regime — delete the 3/5 dead SRAM macros, fix the one misshapen `NISPS_AUDIO_FUNC` use in `midi_io.hpp` (S21, L13, closes old ALIGNMENT #4). The 16 KB loss-history buffer (L25) waits on the telemetry decision (§7.3).
-- **engines/modes**: `voice_space.hpp` (L3); no-op VoiceSpace boilerplate on the five engines without real voice spaces (L9); `SawOsc`/`SquareOsc` — **keep `SineOsc`**, the selftest uses it (L4); `input_dirty_` (L5); VerbFX dead fields/setters (L6); MEMLCelium inert feedback path after checking the upstream app for a missing write (L7). `DriverConfig` (S4) waits on §7.2.
+- **engines/modes**: `voice_space.hpp` (L3); no-op VoiceSpace boilerplate on the five engines without real voice spaces (L9); `SawOsc`/`SquareOsc` — **keep `SineOsc`**, the selftest uses it (L4); `input_dirty_` (L5); VerbFX dead fields/setters (L6); MEMLCelium inert feedback path after checking the upstream app for a missing write (L7). `DriverConfig` (S4) is **wired, not deleted** — §7.2 resolved 2026-07-21; see below.
- **wasm bridge**: dead C-API entries through the full 5-layer chain — **keep** `EXPORTED_RUNTIME` `cwrap`/heaps (parity + wasm-load tests build their API via `Module.cwrap`) (S33); `publishWeights_` 200 Hz weight-copy channel (S34); worklet loader dead scaffolding + throwing import stubs (L54).
- **manifold UI**: dead focus/altitude system — SplitStage, ReadoutStrip, InputMini, AltitudeNav, CompactAxis, keep MiniMeters and **don't touch `engine.feedback.setFocus`** (S15); decorative controls — A/B, fake seed/gradient, snapshots, master volume, bpm, training-param sliders per verifier notes (S16, absorbs L1's delete-half); `BackendAdvanced.tsx` duplicate editor table (S18); ConsoleCtx prune to consumed fields (S19); 5 dead primitives (L22); dual backend catalogues (L23); inert soloMode selector + FeedbackController vestiges (L20, L21).
- **backends**: dead protocol legs — sendState/sendWeights, legacy array format, unreceivable `/nisps/state`, unused module-output listeners (L16); delete `bridge.mjs`, keep `bridge.ts` + compiled binaries as distribution (S11).
@@ -128,14 +128,66 @@ so `build_unflags` is required — appending our own flags is not enough.
- **5b Browser mode coverage honesty (A2).** Add an audio-topology notion (generator / audio-in-fx / event-only / analysis) so Manifold stops cataloguing 4 modes that structurally cannot run; wire mic input for the audio-in class (absorbs old ALIGNMENT #1); event-only modes need transport/MIDI-out UI, or explicit "hardware-only" labelling.
- **5c Curated/advanced split (A3, A7).** Product model first (§7.6): what is a "curated preset" — schema + backend preset + input map + trained net? Then: instrument picker rendered from the already-plumbed `ctx.modes`/`setModeId` (engine reshape-on-switch already works); per-drawer depth levels as the disclosure mechanism rather than one global boolean; `backends/presets.ts` + schemas seed the data model (add the missing `cv` backend to presets — small bug from A3's verification).
- **5d Hardware editor (A4, S14).** The repo already contains the right discipline: `useq-celium`'s C-header wire-protocol truth + TS mirror + parity test. Apply it to a MEMLNaut USB-serial protocol; give firmware an actual command surface + on-device persistence; settings/training payloads derive from schema codegen, not hand-defined tables. `InputChain`/`OutputChain` firmware wiring (L29) lands here or gets its comment softened now.
-- **5e Training-health telemetry (critic gap 5; §7.3) — DECIDED, unblocked.** It *is* a feature, but browser-only and behind a feature flag. L1's fabricated gradient UI was deleted in Phase 1. **L25 resolved: keep the firmware loss-history buffer** (operator) — it is the on-device record 5d's hardware editor will read, and the RAM is demonstrably there. Remaining: add a `loss_history` C-API entry across the 5-layer registration chain, replace `wasm-worker.ts:310`'s 1-element fake with it, and surface it plus the already-plumbed `get_layer_stats` behind the advanced-mode flag. No judgement calls left in this item.
-- **5f Performance measurement (critic gap 4).** A host-side blocks-per-second benchmark for `engine_process_block` (native + WASM), and `build-firmware.sh` emitting a per-variant flash/RAM size report — makes the headline constraint enforceable instead of vibes.
+- **5e Training-health telemetry (critic gap 5; §7.3) — BURNED DOWN 2026-07-21.** `int
+ nisps_ml_loss_history(ml, out, max)` landed across all five layers (KEEPALIVE →
+ `build-wasm.sh` exports → `NispsModule` decl → `WasmIML.lossHistory()` → `EngineApi
+ .lossHistory()`); it returns the TOTAL entry count and fills `min(count, max)`, so a
+ `max=0` probe sizes the JS buffer from C++ truth instead of mirroring the history cap.
+ The worker's `new Float32Array([loss])` is replaced by a readback off the worker's OWN
+ mirror handle, so async fits publish a real curve too. Display: `manifold/src/console/
+ TrainingHealth.tsx` at the Learning drawer's `expanded` depth — the flag mechanism is the
+ existing `DrawerDepth`, not a new one. The firmware buffer stays untouched (operator, L25).
+ **Two deviations.** (1) `ConsoleCtx.loss` was deleted as well: it was a synthetic series
+ (`evalLoss()` when finite, else `prev * 0.82`, else literal `0.5`) that no drawer read —
+ the §6.5e bar ("nothing may still fabricate a number") reaches it even though the audit
+ filed it as merely-unfinished plumbing under S19. (2) `` from dock-spec §1.3
+ is NOT built and should not be: the core records no per-layer gradient magnitudes, so it
+ could only be fabricated. Evidence beyond the standard gates, which do not cover this
+ path: `manifold/tests/loss-history.test.ts` (C-ABI contract driven straight at the
+ committed WASM under `bun test`) and two new `probe-api` cases + `tests/e2e/
+ training-health.spec.ts` (browser, both train paths, panel content).
+- **5f Performance measurement (critic gap 4) — BURNED DOWN 2026-07-21.** The size half landed
+ with Phase 4 (firmware CI reports per-variant flash/RAM). The time half is
+ `tests/cpp/engine_bench.cpp` + `scripts/bench-engines.sh`: per-engine ns/sample, blocks/s and
+ realtime factor for the `process()` hot path, on native and WASM.
+ **Four decisions worth recording.** (1) *One source, compiled twice.* The bench is compiled
+ natively by CMake (`nisps_engine_bench`) and by emcc for WASM, from the same file — so the two
+ targets are comparable, `nisps/wasm/bindings.cpp` gains no export, and the 5-layer WASM export
+ chain is not walked at all. Nothing under `nisps/` changed except `CMakeLists.txt`: the hot path
+ is not perturbed by measuring it. (2) *Reporting, not asserting.* No threshold anywhere. On
+ shared CI hardware a wall-clock threshold is either slack enough to be meaningless or tight
+ enough to flake — the same call the firmware size job made. A regression gets noticed three
+ ways: `--compare ` prints per-engine Δ% (noise floor ~±3% at default settings,
+ measured; the failure mode this exists to catch is 2-3x); CI prints the table per commit on
+ both targets; and `run-all-tests.sh` stage 6 runs a ~0.15 s native smoke so the bench cannot rot
+ the way the SelfTest variant did. (3) *Engines are driven.* Sequencers run with transport on and
+ their event queues drained per block (an undrained 64-slot queue makes `push` take a cheaper
+ path than production); paf_synth gets a `note_on` every 0.25 s (its envelope gates it to silence
+ otherwise, and silence in the delay line decays to denormals); the fx/analysis engines are fed a
+ noise+sine bed. Params are pseudo-random in [0.05, 0.95], not the parity harness's all-0.5 —
+ that vector is a degenerate corner and at 128 frames never reaches a sequencer tick. (4) *Each
+ row carries its own working-state evidence* (output RMS / event count / analysis feature sum,
+ chosen by engine kind, since breakor/elysiamorf/analysis emit silence by design), so a number
+ produced by an idle engine is visible in the table instead of merely plausible.
+ **Not done, and now the live half of ALIGNMENT defect 5:** these are HOST numbers. The
+ constraint is the RP2350 at 150 MHz, and no on-device timing exists.
## §7 Operator decisions needed
1. **S20 legacy feedback modes** (RandomiseOutputs/RandomiseMlp/Diffuse/on_drag): deletion reverses the explicit "keep for A/B comparison" in `docs/adr/rl-feedback-design.md` — delete (and amend the ADR) or keep?
-2. **S4 DriverConfig**: wire the firmware audio driver to read it at mode start (makes mic/line settings real) or delete the contract from the concept + all 8 engines. No dead middle.
-3. **Telemetry** (§6.5e): feature or delete.
+2. **S4 DriverConfig** — **DECIDED + LANDED 2026-07-21.** Operator: "wire it up; firmware reads active mode's
+ config at mode start; mic/line becomes real." Done: `Mode::driver_config()` (now part of the `nisps::Mode`
+ concept) defaults to `engine().driver_config()` in `ModeBase`, overridable per mode via an optional
+ `on_driver_config()` CRTP hook — SoundAnalysisMIDIMode is the one user, because its audio *engine* is a silent
+ NoOp while its `AnalysisEngine` is what owns the microphone. `src/main.cpp` publishes the mode's sample rate
+ before the system clock is derived from it, and `setup1()` calls `AudioDriver::Setup(...)` with the mode's
+ config instead of the parameterless overload. `DriverConfig`'s member defaults were changed to memllib's
+ historical hardcoded values (line_level 3, output_volume 0.8) so a mode that declares nothing is a genuine
+ no-op. Codec clamping + sample-rate resolution live in the Arduino-free `glue/codec_config.hpp` and are
+ host-tested in `tests/cpp/test_mode_driver_config.cpp`.
+3. **Telemetry** (§6.5e) — **DECIDED + LANDED 2026-07-21.** Operator: feature, browser-only,
+ behind the advanced-surface flag; fakes deleted; firmware loss-history buffer kept (L25).
+ Built as described in §6.5e above.
4. **Deploy gating** (§1.5): gate the webhook on CI, or accept ungated deploys knowingly.
5. **memllib ownership** (§5): fork-pin vs vendored subset vs upstreaming to MusicallyEmbodiedML.
6. **Curated-preset product model** (§6.5c): what a preset bundles; where curation lives.
diff --git a/firmware/MEMLNaut-NISPS/glue/audio_driver.hpp b/firmware/MEMLNaut-NISPS/glue/audio_driver.hpp
index ac7a112..8886b8f 100644
--- a/firmware/MEMLNaut-NISPS/glue/audio_driver.hpp
+++ b/firmware/MEMLNaut-NISPS/glue/audio_driver.hpp
@@ -21,8 +21,57 @@
#include "nisps/core/types.hpp"
#include "audio/AudioDriver.hpp"
+#include "codec_config.hpp"
+
namespace nisps_firmware {
+// ---------------------------------------------------------------------------
+// Driver configuration — the active mode decides how the codec is set up.
+//
+// `Mode::driver_config()` (nisps/modes/base.hpp) returns the mode's engine's
+// `nisps::DriverConfig`, or the mode's own override when the engine isn't what
+// consumes the audio input. Nothing here knows which mode is compiled in: the
+// two entry points below are the whole of the glue, and a mode that expresses
+// no opinion gets `nisps::DriverConfig{}`'s defaults, which reproduce the
+// firmware's historical hardcoded codec setup.
+// ---------------------------------------------------------------------------
+
+// Field-for-field translation into memllib's codec struct, after clamping to
+// what the SGTL5000 can represent (see codec_config.hpp — that part is
+// host-tested in tests/cpp/test_mode_driver_config.cpp).
+inline AudioDriver::codec_config_t to_codec_config(const nisps::DriverConfig& cfg) noexcept {
+ const nisps::DriverConfig c = clamp_driver_config(cfg);
+ AudioDriver::codec_config_t out{};
+ out.mic_input = c.mic_input;
+ out.line_level = static_cast(c.line_level);
+ out.mic_gain_dB = static_cast(c.mic_gain_db);
+ out.output_volume = c.output_volume;
+ return out;
+}
+
+// Publish the mode's preferred sample rate to the driver.
+//
+// MUST run before `set_sys_clock_khz(AudioDriver::GetSysClockSpeed(), ...)` —
+// the system clock is derived from the rate, and `GetSysClockSpeed()` panics on
+// a rate it has no divider for. `select_sample_rate` therefore resolves
+// "don't care" (0) and anything unsupported to 48 kHz rather than letting it
+// reach the driver. Called on core 0 in setup(), i.e. before core 1 gets past
+// its `g_serial_ready` handshake and reads `GetSampleRate()`.
+template
+inline void apply_mode_sample_rate(const Mode& mode) noexcept {
+ AudioDriver::SetSampleRate(
+ static_cast(select_sample_rate(mode.driver_config().sample_rate)));
+}
+
+// Bring the audio driver up configured for the active mode: codec input source
+// (mic vs line), input gain step, mic pre-amp gain, analog output volume.
+// Replaces the old parameterless `AudioDriver::Setup()`, which hardcoded line
+// input for every variant regardless of what the mode's engine asked for.
+template
+inline bool setup_audio_driver(const Mode& mode) {
+ return AudioDriver::Setup(to_codec_config(mode.driver_config()));
+}
+
// Pointer to the active mode. The audio block callback reads through this.
// Set during setup1() before AudioDriver::Setup() is called. Marked
// `__not_in_flash("audio")` so the audio ISR path does not pay flash latency.
diff --git a/firmware/MEMLNaut-NISPS/glue/codec_config.hpp b/firmware/MEMLNaut-NISPS/glue/codec_config.hpp
new file mode 100644
index 0000000..35e696e
--- /dev/null
+++ b/firmware/MEMLNaut-NISPS/glue/codec_config.hpp
@@ -0,0 +1,67 @@
+// firmware/glue/codec_config.hpp — Pure, host-testable translation of a
+// mode's `nisps::DriverConfig` into what the SGTL5000 codec can actually be
+// asked for.
+//
+// Deliberately Arduino-free and memllib-free so `tests/cpp/` can exercise it
+// on the host: this is the only part of the mic/line wiring that has logic in
+// it, and it is the part that can panic the device if it gets the sample rate
+// wrong. `glue/audio_driver.hpp` does the remaining (field-for-field) copy
+// into `AudioDriver::codec_config_t`.
+//
+// Codec limits are read off the vendored driver, not invented:
+// - `AudioControlSGTL5000::lineInLevel` (control_sgtl5000.cpp) clamps to 15;
+// the register is 4 bits per channel.
+// - `AudioControlSGTL5000::micGain` saturates at preamp step 3 (+40 dB) plus
+// input_gain 15, i.e. 40 + ceil(15 * 3 / 2) = 63 dB. Above that the codec
+// setting is identical, so we clamp to the point of saturation.
+// - `AudioDriver::Setup` already truncates output volume above 0.99; it does
+// NOT guard against a negative, which would run through
+// `AudioControlSGTL5000::calcVol` — so clamp both ends here.
+// - `AudioDriver::GetSysClockSpeed` `panic()`s on any sample rate outside
+// {24000, 32000, 44100, 48000}. An engine asking for anything else must
+// NOT brick the boot, so we fall back to 48 kHz.
+
+#pragma once
+
+#include
+
+#include "nisps/core/types.hpp"
+
+namespace nisps_firmware {
+
+inline constexpr std::uint8_t kMaxLineLevel = 15u;
+inline constexpr std::uint8_t kMaxMicGainDb = 63u;
+inline constexpr float kMaxOutputVolume = 0.99f;
+inline constexpr std::uint32_t kDefaultSampleRate = 48000u;
+
+// The rates `AudioDriver::GetSysClockSpeed()` knows a system-clock for.
+inline constexpr std::uint32_t kSupportedSampleRates[] = {
+ 24000u, 32000u, 44100u, 48000u,
+};
+
+// Fold a mode's requested config into the codec's representable range.
+// Everything out of range is clamped, never wrapped or ignored.
+inline constexpr nisps::DriverConfig clamp_driver_config(nisps::DriverConfig c) noexcept {
+ if (c.line_level > kMaxLineLevel) c.line_level = kMaxLineLevel;
+ if (c.mic_gain_db > kMaxMicGainDb) c.mic_gain_db = kMaxMicGainDb;
+ if (c.output_volume < 0.f) c.output_volume = 0.f;
+ else if (c.output_volume > kMaxOutputVolume) c.output_volume = kMaxOutputVolume;
+ return c;
+}
+
+// Resolve the rate the driver should actually run at.
+// requested <= 0 ⇒ "don't care" ⇒ 48 kHz
+// requested unsupported ⇒ would panic() ⇒ 48 kHz
+// otherwise ⇒ the requested rate
+// Matched with a small tolerance so a float literal like 44100.f that does not
+// round-trip exactly still resolves.
+inline constexpr std::uint32_t select_sample_rate(float requested) noexcept {
+ if (!(requested > 0.f)) return kDefaultSampleRate; // also catches NaN
+ for (const std::uint32_t rate : kSupportedSampleRates) {
+ const float diff = requested - static_cast(rate);
+ if (diff > -0.5f && diff < 0.5f) return rate;
+ }
+ return kDefaultSampleRate;
+}
+
+} // namespace nisps_firmware
diff --git a/firmware/MEMLNaut-NISPS/src/main.cpp b/firmware/MEMLNaut-NISPS/src/main.cpp
index 3fbee37..b1f831a 100644
--- a/firmware/MEMLNaut-NISPS/src/main.cpp
+++ b/firmware/MEMLNaut-NISPS/src/main.cpp
@@ -14,12 +14,13 @@
// env (`-DMEMLNAUT_MODE_TYPE=...`, or `-DNISPS_SELFTEST=1` for the
// guided hardware self-test — see platformio.ini, one [env] per variant).
// 2. setup() / loop() on core 0:
-// - boot board
+// - boot board (sample rate from the mode's driver config, then clock)
// - bind peripherals → mode.set_input
// - bind MIDI in → mode.note_on/update_bpm/...
// - run mode.tick_control() at ML cadence (5ms)
// 3. setup1() / loop1() on core 1:
// - register the audio bridge so AudioDriver streams into mode.process
+// - bring the codec up on the mode's driver config (mic vs line, gains)
// - pump engine events / drain MIDI out at sub-ms cadence
// ---- Hardware ----
@@ -104,6 +105,11 @@ static uint32_t get_rosc_entropy_seed(int bits) {
// =====================================================================
void setup() {
+ // The active mode's engine picks the sample rate (0 ⇒ don't care ⇒ 48 kHz).
+ // This has to happen before the system clock is derived from it below, and
+ // therefore before core 1 clears the g_serial_ready handshake and reads
+ // AudioDriver::GetSampleRate() in setup1().
+ nisps_firmware::apply_mode_sample_rate(g_mode);
set_sys_clock_khz(AudioDriver::GetSysClockSpeed(), true);
bus_ctrl_hw->priority = BUSCTRL_BUS_PRIORITY_DMA_W_BITS
| BUSCTRL_BUS_PRIORITY_DMA_R_BITS
@@ -190,7 +196,9 @@ void setup1() {
g_mode.setup(static_cast(AudioDriver::GetSampleRate()));
nisps_firmware::register_audio_engine(g_mode, &audio_block_callback);
- AudioDriver::Setup();
+ // Codec setup follows the ACTIVE mode: mic vs line input, input gain step,
+ // mic pre-amp gain, analog output volume (glue/audio_driver.hpp).
+ nisps_firmware::setup_audio_driver(g_mode);
WRITE_VOLATILE(g_core1_ready, true);
while (!READ_VOLATILE(g_core0_ready)) { MEMORY_BARRIER(); delay(1); }
diff --git a/manifold/ONBOARDING.md b/manifold/ONBOARDING.md
index 31ab4d3..c843d4d 100644
--- a/manifold/ONBOARDING.md
+++ b/manifold/ONBOARDING.md
@@ -127,7 +127,8 @@ double-click exits). `OutputStage.tsx` is the output columns; drag a bar to set
`Pin`, `FeedbackMarker`, `Snapshot`, and **`ConsoleCtx`** (the flat context handed to the dock).
- `model.ts`: the instrument catalogue `MF_MODES`, `MFParam`, `ParamStatus`
(`off|fixed|live`), `ParamGroup`, plus `shapeValues()` (applies min/max/curve to raw engine
- outputs) and `seededGradient()`. **Schema-backed modes are DERIVED from the codegen
+ outputs) and `modeEngineId()`. (`seededGradient()` is GONE — it was the fabricated
+ gradient-health source, deleted in the 2026-07 sweep, S16.) **Schema-backed modes are DERIVED from the codegen
schemas in `src/modes/generated/`** (one-core-engine P5.2) — real param names/groups/count,
plus each mode's `ml` net shape (`MFMode.ml`) and schema `engine_id` (`MFMode.engineId`)
come from schema truth. A thin manifold OVERLAY (`SCHEMA_MODES` in model.ts) supplies only
@@ -141,6 +142,12 @@ double-click exits). `OutputStage.tsx` is the output columns; drag a bar to set
- Five drawers (`DRAWERS` in `Drawers.tsx`, each has `.render(ctx, depth)` — condensed 360px panel
vs expanded 80vw×80vh modal):
- **learn** — feedback mode (explore-and-place / geometric-dislike) + solo mode + per-output arm.
+ At `expanded` depth ONLY it also renders `TrainingHealth.tsx`: the real per-iteration loss
+ curve (`EngineApi.lossHistory()` ← `nisps_ml_loss_history` ← `MLPCore::loss_history`) plus
+ the per-layer weight-health table (`getLayerStats`). **`depth === 'expanded'` is Manifold's
+ advanced-surface flag** — there is no separate feature-flag mechanism, so put advanced
+ surface there rather than inventing one. The panel renders "no training run yet" when the
+ core has no history; it never synthesises a curve.
- **inputs** — enable/configure input sources (XY pad / MIDI / gamepad).
- **route** (label "Outputs") — per-output control matrix + per-backend config.
- **settings** — icon style (monochrome/colour), input-map shape (xy/joystick/rect/circular), corner radius.
@@ -155,9 +162,10 @@ double-click exits). `OutputStage.tsx` is the output columns; drag a bar to set
CC#/channel, OSC path/range, VCV polarity).
### Primitives — `src/primitives/` (barrel: `index.ts`)
-`Button`, `Slider`, `PillToggle`, `Panel`, `Badge`, `Switch`, `StatusLine`, `XYPad`,
-`VirtualJoystick`, `ControlAxis`, `CurvePlot`, `Sparkline`. Dumb, reusable, no engine knowledge.
-Side-effect import of `styles/primitives.css` styles the range inputs.
+Seven: `Button`, `Slider`, `PillToggle`, `Badge`, `Switch`, `XYPad`, `VirtualJoystick`. Dumb,
+reusable, no engine knowledge. Side-effect import of `styles/primitives.css` styles the range
+inputs. `Panel`/`StatusLine`/`ControlAxis`/`CurvePlot`/`Sparkline` were **deleted** in the 2026-07
+sweep (L22, zero consumers) — don't cite them.
### Other shared UI files
- `shared-ui.tsx` — `MiniMeters` (read-only output bars). `AltitudeNav`/`CompactAxis` were deleted with the focus system.
@@ -185,15 +193,19 @@ a setting → `--r-*` tokens.
`setInputConfig`/`setOutputConfig` (state itself lives in the WASM pipeline handle since P4).
- `engine-api.ts` — **`EngineApi`, the framework-neutral facade** everything in the UI talks to:
`setInput/setInputs`, `getOutputs/routedOutput`, training (`addExample/train/trainAsync/evalLoss`),
- weights (`getWeights/setWeights/process/randomise`), `subscribe/version/on`, plus nested
- `.feedback` and `.audio` facades.
+ weights (`getWeights/setWeights/process/randomise`), telemetry
+ (`lossHistory/getLayerStats`), `subscribe/version/on`, plus nested `.feedback` and `.audio`
+ facades. **`lossHistory()` reads SPINE STATE, not the MLP handle** — an async train runs on the
+ worker's mirror net, so the main handle's own history is empty for those runs; both paths
+ publish to the spine.
- `EngineProvider.tsx` / `useEngine.ts` — the **only** React coupling. `useEngine()` returns the API
(null until WASM ready); `useEngineVersion()` = `useSyncExternalStore(subscribe, version)`.
- `engine-host.ts` — main-thread audio wiring: AudioContext (user-gesture gated), fetch `nisps.wasm`,
register + feed the worklet.
- `wasm-iml.ts` (**~750 lines**) — the ML interface to `nisps.wasm`: one MLP handle, dataset, heap
buffers, feedback C-ABI bindings (`nisps_ml_feedback_*`), lazy training worker.
-- `wasm-worker.ts` — off-thread training worker. `worklet/nisps-processor.ts` — the AudioWorklet's
+- `wasm-worker.ts` — off-thread training worker; returns weights, final loss, and the real
+ per-iteration loss curve read off its own mirror handle. `worklet/nisps-processor.ts` — the AudioWorklet's
separate WASM instance (raw `WebAssembly.instantiate`, no Emscripten glue; 128-sample blocks).
- **Input/output pipelines + curves live in the C++/WASM core (one-core-engine P4).** The input chain
(invert → deadzone → circular clamp → momentum-modulated zoom → centred power → EMA → momentum) and
diff --git a/manifold/public/nisps.js b/manifold/public/nisps.js
index f47f693..04d4f5d 100644
--- a/manifold/public/nisps.js
+++ b/manifold/public/nisps.js
@@ -6,7 +6,7 @@ var createNispsModule = (() => {
function(moduleArg = {}) {
var moduleRtn;
-var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];var wasmBinary=Module["wasmBinary"];var wasmMemory;var ABORT=false;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){var preRuns=Module["preRun"];if(preRuns){if(typeof preRuns=="function")preRuns=[preRuns];preRuns.forEach(addOnPreRun)}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;callRuntimeCallbacks(__ATINIT__)}function postRun(){var postRuns=Module["postRun"];if(postRuns){if(typeof postRuns=="function")postRuns=[postRuns];postRuns.forEach(addOnPostRun)}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";var isDataURI=filename=>filename.startsWith(dataURIPrefix);function findWasmBinary(){var f="nisps.wasm";if(!isDataURI(f)){return locateFile(f)}return f}var wasmBinaryFile;function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary){return readAsync(binaryFile).then(response=>new Uint8Array(response),()=>getBinarySync(binaryFile))}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function getWasmImports(){return{a:wasmImports}}function createWasm(){var info=getWasmImports();function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["d"];updateMemoryViews();addOnInit(wasmExports["e"]);removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);readyPromiseReject(e)}}wasmBinaryFile??=findWasmBinary();instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var callRuntimeCallbacks=callbacks=>{callbacks.forEach(f=>f(Module))};var noExitRuntime=Module["noExitRuntime"]||true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var __abort_js=()=>{abort("")};var __emscripten_memcpy_js=(dest,src,num)=>HEAPU8.copyWithin(dest,src,src+num);var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};var wasmImports={c:__abort_js,b:__emscripten_memcpy_js,a:_emscripten_resize_heap};var wasmExports=createWasm();var ___wasm_call_ctors=()=>(___wasm_call_ctors=wasmExports["e"])();var _nisps_ml_create=Module["_nisps_ml_create"]=(a0,a1,a2,a3,a4)=>(_nisps_ml_create=Module["_nisps_ml_create"]=wasmExports["f"])(a0,a1,a2,a3,a4);var _nisps_ml_reshape=Module["_nisps_ml_reshape"]=(a0,a1,a2,a3,a4,a5)=>(_nisps_ml_reshape=Module["_nisps_ml_reshape"]=wasmExports["g"])(a0,a1,a2,a3,a4,a5);var _nisps_ml_destroy=Module["_nisps_ml_destroy"]=a0=>(_nisps_ml_destroy=Module["_nisps_ml_destroy"]=wasmExports["h"])(a0);var _nisps_ml_set_input=Module["_nisps_ml_set_input"]=(a0,a1,a2)=>(_nisps_ml_set_input=Module["_nisps_ml_set_input"]=wasmExports["i"])(a0,a1,a2);var _nisps_ml_process=Module["_nisps_ml_process"]=a0=>(_nisps_ml_process=Module["_nisps_ml_process"]=wasmExports["j"])(a0);var _nisps_ml_outputs=Module["_nisps_ml_outputs"]=a0=>(_nisps_ml_outputs=Module["_nisps_ml_outputs"]=wasmExports["k"])(a0);var _nisps_ml_infer_batch=Module["_nisps_ml_infer_batch"]=(a0,a1,a2,a3)=>(_nisps_ml_infer_batch=Module["_nisps_ml_infer_batch"]=wasmExports["l"])(a0,a1,a2,a3);var _nisps_ml_add_example=Module["_nisps_ml_add_example"]=(a0,a1,a2)=>(_nisps_ml_add_example=Module["_nisps_ml_add_example"]=wasmExports["m"])(a0,a1,a2);var _nisps_ml_train=Module["_nisps_ml_train"]=(a0,a1,a2,a3,a4)=>(_nisps_ml_train=Module["_nisps_ml_train"]=wasmExports["n"])(a0,a1,a2,a3,a4);var _nisps_ml_set_train_config=Module["_nisps_ml_set_train_config"]=(a0,a1,a2,a3)=>(_nisps_ml_set_train_config=Module["_nisps_ml_set_train_config"]=wasmExports["o"])(a0,a1,a2,a3);var _nisps_ml_eval_loss=Module["_nisps_ml_eval_loss"]=a0=>(_nisps_ml_eval_loss=Module["_nisps_ml_eval_loss"]=wasmExports["p"])(a0);var _nisps_ml_weight_count=Module["_nisps_ml_weight_count"]=a0=>(_nisps_ml_weight_count=Module["_nisps_ml_weight_count"]=wasmExports["q"])(a0);var _nisps_ml_get_weights=Module["_nisps_ml_get_weights"]=(a0,a1)=>(_nisps_ml_get_weights=Module["_nisps_ml_get_weights"]=wasmExports["r"])(a0,a1);var _nisps_ml_set_weights=Module["_nisps_ml_set_weights"]=(a0,a1)=>(_nisps_ml_set_weights=Module["_nisps_ml_set_weights"]=wasmExports["s"])(a0,a1);var _nisps_ml_draw_weights=Module["_nisps_ml_draw_weights"]=(a0,a1)=>(_nisps_ml_draw_weights=Module["_nisps_ml_draw_weights"]=wasmExports["t"])(a0,a1);var _nisps_ml_feedback_set_mode=Module["_nisps_ml_feedback_set_mode"]=(a0,a1)=>(_nisps_ml_feedback_set_mode=Module["_nisps_ml_feedback_set_mode"]=wasmExports["u"])(a0,a1);var _nisps_ml_feedback_get_mode=Module["_nisps_ml_feedback_get_mode"]=a0=>(_nisps_ml_feedback_get_mode=Module["_nisps_ml_feedback_get_mode"]=wasmExports["v"])(a0);var _nisps_ml_feedback_exploring=Module["_nisps_ml_feedback_exploring"]=a0=>(_nisps_ml_feedback_exploring=Module["_nisps_ml_feedback_exploring"]=wasmExports["w"])(a0);var _nisps_ml_feedback_set_focus=Module["_nisps_ml_feedback_set_focus"]=(a0,a1,a2)=>(_nisps_ml_feedback_set_focus=Module["_nisps_ml_feedback_set_focus"]=wasmExports["x"])(a0,a1,a2);var _nisps_ml_feedback_down=Module["_nisps_ml_feedback_down"]=(a0,a1,a2,a3,a4)=>(_nisps_ml_feedback_down=Module["_nisps_ml_feedback_down"]=wasmExports["y"])(a0,a1,a2,a3,a4);var _nisps_ml_feedback_up=Module["_nisps_ml_feedback_up"]=a0=>(_nisps_ml_feedback_up=Module["_nisps_ml_feedback_up"]=wasmExports["z"])(a0);var _nisps_ml_feedback_static_output=Module["_nisps_ml_feedback_static_output"]=(a0,a1)=>(_nisps_ml_feedback_static_output=Module["_nisps_ml_feedback_static_output"]=wasmExports["A"])(a0,a1);var _nisps_ml_feedback_enter_explore=Module["_nisps_ml_feedback_enter_explore"]=(a0,a1)=>(_nisps_ml_feedback_enter_explore=Module["_nisps_ml_feedback_enter_explore"]=wasmExports["B"])(a0,a1);var _nisps_ml_feedback_exit_explore=Module["_nisps_ml_feedback_exit_explore"]=a0=>(_nisps_ml_feedback_exit_explore=Module["_nisps_ml_feedback_exit_explore"]=wasmExports["C"])(a0);var _nisps_ml_feedback_reroll=Module["_nisps_ml_feedback_reroll"]=(a0,a1)=>(_nisps_ml_feedback_reroll=Module["_nisps_ml_feedback_reroll"]=wasmExports["D"])(a0,a1);var _nisps_ml_feedback_nudge=Module["_nisps_ml_feedback_nudge"]=(a0,a1)=>(_nisps_ml_feedback_nudge=Module["_nisps_ml_feedback_nudge"]=wasmExports["E"])(a0,a1);var _nisps_ml_feedback_undo=Module["_nisps_ml_feedback_undo"]=a0=>(_nisps_ml_feedback_undo=Module["_nisps_ml_feedback_undo"]=wasmExports["F"])(a0);var _nisps_ml_feedback_like=Module["_nisps_ml_feedback_like"]=a0=>(_nisps_ml_feedback_like=Module["_nisps_ml_feedback_like"]=wasmExports["G"])(a0);var _nisps_ml_feedback_commit_place=Module["_nisps_ml_feedback_commit_place"]=a0=>(_nisps_ml_feedback_commit_place=Module["_nisps_ml_feedback_commit_place"]=wasmExports["H"])(a0);var _nisps_ml_feedback_cancel_place=Module["_nisps_ml_feedback_cancel_place"]=a0=>(_nisps_ml_feedback_cancel_place=Module["_nisps_ml_feedback_cancel_place"]=wasmExports["I"])(a0);var _nisps_ml_feedback_undo_depth=Module["_nisps_ml_feedback_undo_depth"]=a0=>(_nisps_ml_feedback_undo_depth=Module["_nisps_ml_feedback_undo_depth"]=wasmExports["J"])(a0);var _nisps_ml_feedback_placed_output=Module["_nisps_ml_feedback_placed_output"]=(a0,a1)=>(_nisps_ml_feedback_placed_output=Module["_nisps_ml_feedback_placed_output"]=wasmExports["K"])(a0,a1);var _nisps_ml_feedback_dislike_geometric=Module["_nisps_ml_feedback_dislike_geometric"]=(a0,a1,a2)=>(_nisps_ml_feedback_dislike_geometric=Module["_nisps_ml_feedback_dislike_geometric"]=wasmExports["L"])(a0,a1,a2);var _nisps_ml_feedback_store_positive=Module["_nisps_ml_feedback_store_positive"]=(a0,a1)=>(_nisps_ml_feedback_store_positive=Module["_nisps_ml_feedback_store_positive"]=wasmExports["M"])(a0,a1);var _nisps_ml_feedback_positive_count=Module["_nisps_ml_feedback_positive_count"]=a0=>(_nisps_ml_feedback_positive_count=Module["_nisps_ml_feedback_positive_count"]=wasmExports["N"])(a0);var _nisps_ml_feedback_negative_count=Module["_nisps_ml_feedback_negative_count"]=a0=>(_nisps_ml_feedback_negative_count=Module["_nisps_ml_feedback_negative_count"]=wasmExports["O"])(a0);var _nisps_ml_feedback_set_avoid_style=Module["_nisps_ml_feedback_set_avoid_style"]=(a0,a1)=>(_nisps_ml_feedback_set_avoid_style=Module["_nisps_ml_feedback_set_avoid_style"]=wasmExports["P"])(a0,a1);var _nisps_ml_jolt_press=Module["_nisps_ml_jolt_press"]=a0=>(_nisps_ml_jolt_press=Module["_nisps_ml_jolt_press"]=wasmExports["Q"])(a0);var _nisps_ml_jolt_step=Module["_nisps_ml_jolt_step"]=a0=>(_nisps_ml_jolt_step=Module["_nisps_ml_jolt_step"]=wasmExports["R"])(a0);var _nisps_ml_jolt_release=Module["_nisps_ml_jolt_release"]=a0=>(_nisps_ml_jolt_release=Module["_nisps_ml_jolt_release"]=wasmExports["S"])(a0);var _nisps_ml_jolt_active=Module["_nisps_ml_jolt_active"]=a0=>(_nisps_ml_jolt_active=Module["_nisps_ml_jolt_active"]=wasmExports["T"])(a0);var _nisps_ml_explore_intensity=Module["_nisps_ml_explore_intensity"]=(a0,a1)=>(_nisps_ml_explore_intensity=Module["_nisps_ml_explore_intensity"]=wasmExports["U"])(a0,a1);var _nisps_ml_explore_get_intensity=Module["_nisps_ml_explore_get_intensity"]=a0=>(_nisps_ml_explore_get_intensity=Module["_nisps_ml_explore_get_intensity"]=wasmExports["V"])(a0);var _nisps_ml_explore_apply=Module["_nisps_ml_explore_apply"]=(a0,a1,a2)=>(_nisps_ml_explore_apply=Module["_nisps_ml_explore_apply"]=wasmExports["W"])(a0,a1,a2);var _nisps_ml_get_layer_stats=Module["_nisps_ml_get_layer_stats"]=(a0,a1)=>(_nisps_ml_get_layer_stats=Module["_nisps_ml_get_layer_stats"]=wasmExports["X"])(a0,a1);var _nisps_ml_clear_examples=Module["_nisps_ml_clear_examples"]=a0=>(_nisps_ml_clear_examples=Module["_nisps_ml_clear_examples"]=wasmExports["Y"])(a0);var _nisps_ml_describe=Module["_nisps_ml_describe"]=(a0,a1)=>(_nisps_ml_describe=Module["_nisps_ml_describe"]=wasmExports["Z"])(a0,a1);var _nisps_pipeline_create=Module["_nisps_pipeline_create"]=()=>(_nisps_pipeline_create=Module["_nisps_pipeline_create"]=wasmExports["_"])();var _nisps_pipeline_destroy=Module["_nisps_pipeline_destroy"]=a0=>(_nisps_pipeline_destroy=Module["_nisps_pipeline_destroy"]=wasmExports["$"])(a0);var _nisps_input_set_config=Module["_nisps_input_set_config"]=(a0,a1,a2)=>(_nisps_input_set_config=Module["_nisps_input_set_config"]=wasmExports["aa"])(a0,a1,a2);var _nisps_input_process=Module["_nisps_input_process"]=(a0,a1,a2,a3,a4)=>(_nisps_input_process=Module["_nisps_input_process"]=wasmExports["ba"])(a0,a1,a2,a3,a4);var _nisps_input_reset=Module["_nisps_input_reset"]=a0=>(_nisps_input_reset=Module["_nisps_input_reset"]=wasmExports["ca"])(a0);var _nisps_output_set_config=Module["_nisps_output_set_config"]=(a0,a1,a2,a3,a4)=>(_nisps_output_set_config=Module["_nisps_output_set_config"]=wasmExports["da"])(a0,a1,a2,a3,a4);var _nisps_output_set_freeze_mask=Module["_nisps_output_set_freeze_mask"]=(a0,a1,a2)=>(_nisps_output_set_freeze_mask=Module["_nisps_output_set_freeze_mask"]=wasmExports["ea"])(a0,a1,a2);var _nisps_output_process=Module["_nisps_output_process"]=(a0,a1,a2,a3)=>(_nisps_output_process=Module["_nisps_output_process"]=wasmExports["fa"])(a0,a1,a2,a3);var _nisps_output_reset=Module["_nisps_output_reset"]=a0=>(_nisps_output_reset=Module["_nisps_output_reset"]=wasmExports["ga"])(a0);var _nisps_curve_apply=Module["_nisps_curve_apply"]=(a0,a1,a2)=>(_nisps_curve_apply=Module["_nisps_curve_apply"]=wasmExports["ha"])(a0,a1,a2);var _nisps_curve_apply_batch=Module["_nisps_curve_apply_batch"]=(a0,a1,a2,a3,a4)=>(_nisps_curve_apply_batch=Module["_nisps_curve_apply_batch"]=wasmExports["ia"])(a0,a1,a2,a3,a4);var _nisps_engine_create=Module["_nisps_engine_create"]=(a0,a1)=>(_nisps_engine_create=Module["_nisps_engine_create"]=wasmExports["ja"])(a0,a1);var _nisps_engine_destroy=Module["_nisps_engine_destroy"]=a0=>(_nisps_engine_destroy=Module["_nisps_engine_destroy"]=wasmExports["ka"])(a0);var _nisps_engine_set_params=Module["_nisps_engine_set_params"]=(a0,a1,a2)=>(_nisps_engine_set_params=Module["_nisps_engine_set_params"]=wasmExports["la"])(a0,a1,a2);var _nisps_engine_process_block=Module["_nisps_engine_process_block"]=(a0,a1,a2,a3,a4,a5)=>(_nisps_engine_process_block=Module["_nisps_engine_process_block"]=wasmExports["ma"])(a0,a1,a2,a3,a4,a5);var _malloc=Module["_malloc"]=a0=>(_malloc=Module["_malloc"]=wasmExports["oa"])(a0);var _free=Module["_free"]=a0=>(_free=Module["_free"]=wasmExports["pa"])(a0);var __emscripten_stack_restore=a0=>(__emscripten_stack_restore=wasmExports["qa"])(a0);var __emscripten_stack_alloc=a0=>(__emscripten_stack_alloc=wasmExports["ra"])(a0);var _emscripten_stack_get_current=()=>(_emscripten_stack_get_current=wasmExports["sa"])();Module["ccall"]=ccall;Module["cwrap"]=cwrap;var calledRun;var calledPrerun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}if(!calledPrerun){calledPrerun=1;preRun();if(runDependencies>0){return}}function doRun(){if(calledRun)return;calledRun=1;Module["calledRun"]=1;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();moduleRtn=readyPromise;
+var Module=moduleArg;var readyPromiseResolve,readyPromiseReject;var readyPromise=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject});var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof importScripts=="function";var ENVIRONMENT_IS_NODE=typeof process=="object"&&typeof process.versions=="object"&&typeof process.versions.node=="string"&&process.type!="renderer";var moduleOverrides=Object.assign({},Module);var arguments_=[];var thisProgram="./this.program";var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){if(ENVIRONMENT_IS_WORKER){scriptDirectory=self.location.href}else if(typeof document!="undefined"&&document.currentScript){scriptDirectory=document.currentScript.src}if(_scriptName){scriptDirectory=_scriptName}if(scriptDirectory.startsWith("blob:")){scriptDirectory=""}else{scriptDirectory=scriptDirectory.substr(0,scriptDirectory.replace(/[?#].*/,"").lastIndexOf("/")+1)}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=url=>fetch(url,{credentials:"same-origin"}).then(response=>{if(response.ok){return response.arrayBuffer()}return Promise.reject(new Error(response.status+" : "+response.url))})}}else{}var out=Module["print"]||console.log.bind(console);var err=Module["printErr"]||console.error.bind(console);Object.assign(Module,moduleOverrides);moduleOverrides=null;if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];var wasmBinary=Module["wasmBinary"];var wasmMemory;var ABORT=false;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b)}var __ATPRERUN__=[];var __ATINIT__=[];var __ATPOSTRUN__=[];var runtimeInitialized=false;function preRun(){var preRuns=Module["preRun"];if(preRuns){if(typeof preRuns=="function")preRuns=[preRuns];preRuns.forEach(addOnPreRun)}callRuntimeCallbacks(__ATPRERUN__)}function initRuntime(){runtimeInitialized=true;callRuntimeCallbacks(__ATINIT__)}function postRun(){var postRuns=Module["postRun"];if(postRuns){if(typeof postRuns=="function")postRuns=[postRuns];postRuns.forEach(addOnPostRun)}callRuntimeCallbacks(__ATPOSTRUN__)}function addOnPreRun(cb){__ATPRERUN__.unshift(cb)}function addOnInit(cb){__ATINIT__.unshift(cb)}function addOnPostRun(cb){__ATPOSTRUN__.unshift(cb)}var runDependencies=0;var runDependencyWatcher=null;var dependenciesFulfilled=null;function addRunDependency(id){runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)}function removeRunDependency(id){runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(runDependencyWatcher!==null){clearInterval(runDependencyWatcher);runDependencyWatcher=null}if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject(e);throw e}var dataURIPrefix="data:application/octet-stream;base64,";var isDataURI=filename=>filename.startsWith(dataURIPrefix);function findWasmBinary(){var f="nisps.wasm";if(!isDataURI(f)){return locateFile(f)}return f}var wasmBinaryFile;function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}function getBinaryPromise(binaryFile){if(!wasmBinary){return readAsync(binaryFile).then(response=>new Uint8Array(response),()=>getBinarySync(binaryFile))}return Promise.resolve().then(()=>getBinarySync(binaryFile))}function instantiateArrayBuffer(binaryFile,imports,receiver){return getBinaryPromise(binaryFile).then(binary=>WebAssembly.instantiate(binary,imports)).then(receiver,reason=>{err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)})}function instantiateAsync(binary,binaryFile,imports,callback){if(!binary&&typeof WebAssembly.instantiateStreaming=="function"&&!isDataURI(binaryFile)&&typeof fetch=="function"){return fetch(binaryFile,{credentials:"same-origin"}).then(response=>{var result=WebAssembly.instantiateStreaming(response,imports);return result.then(callback,function(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation");return instantiateArrayBuffer(binaryFile,imports,callback)})})}return instantiateArrayBuffer(binaryFile,imports,callback)}function getWasmImports(){return{a:wasmImports}}function createWasm(){var info=getWasmImports();function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["d"];updateMemoryViews();addOnInit(wasmExports["e"]);removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){receiveInstance(result["instance"])}if(Module["instantiateWasm"]){try{return Module["instantiateWasm"](info,receiveInstance)}catch(e){err(`Module.instantiateWasm callback failed with error: ${e}`);readyPromiseReject(e)}}wasmBinaryFile??=findWasmBinary();instantiateAsync(wasmBinary,wasmBinaryFile,info,receiveInstantiationResult).catch(readyPromiseReject);return{}}var callRuntimeCallbacks=callbacks=>{callbacks.forEach(f=>f(Module))};var noExitRuntime=Module["noExitRuntime"]||true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();var __abort_js=()=>{abort("")};var __emscripten_memcpy_js=(dest,src,num)=>HEAPU8.copyWithin(dest,src,src+num);var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var b=wasmMemory.buffer;var pages=(size-b.byteLength+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=55296&&u<=57343){var u1=str.charCodeAt(++i);u=65536+((u&1023)<<10)|u1&1023}if(u<=127){if(outIdx>=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead=NaN)=>{var endIdx=idx+maxBytesToRead;var endPtr=idx;while(heapOrArray[endPtr]&&!(endPtr>=endIdx))++endPtr;if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};var wasmImports={c:__abort_js,b:__emscripten_memcpy_js,a:_emscripten_resize_heap};var wasmExports=createWasm();var ___wasm_call_ctors=()=>(___wasm_call_ctors=wasmExports["e"])();var _nisps_ml_create=Module["_nisps_ml_create"]=(a0,a1,a2,a3,a4)=>(_nisps_ml_create=Module["_nisps_ml_create"]=wasmExports["f"])(a0,a1,a2,a3,a4);var _nisps_ml_reshape=Module["_nisps_ml_reshape"]=(a0,a1,a2,a3,a4,a5)=>(_nisps_ml_reshape=Module["_nisps_ml_reshape"]=wasmExports["g"])(a0,a1,a2,a3,a4,a5);var _nisps_ml_destroy=Module["_nisps_ml_destroy"]=a0=>(_nisps_ml_destroy=Module["_nisps_ml_destroy"]=wasmExports["h"])(a0);var _nisps_ml_set_input=Module["_nisps_ml_set_input"]=(a0,a1,a2)=>(_nisps_ml_set_input=Module["_nisps_ml_set_input"]=wasmExports["i"])(a0,a1,a2);var _nisps_ml_process=Module["_nisps_ml_process"]=a0=>(_nisps_ml_process=Module["_nisps_ml_process"]=wasmExports["j"])(a0);var _nisps_ml_outputs=Module["_nisps_ml_outputs"]=a0=>(_nisps_ml_outputs=Module["_nisps_ml_outputs"]=wasmExports["k"])(a0);var _nisps_ml_infer_batch=Module["_nisps_ml_infer_batch"]=(a0,a1,a2,a3)=>(_nisps_ml_infer_batch=Module["_nisps_ml_infer_batch"]=wasmExports["l"])(a0,a1,a2,a3);var _nisps_ml_add_example=Module["_nisps_ml_add_example"]=(a0,a1,a2)=>(_nisps_ml_add_example=Module["_nisps_ml_add_example"]=wasmExports["m"])(a0,a1,a2);var _nisps_ml_train=Module["_nisps_ml_train"]=(a0,a1,a2,a3,a4)=>(_nisps_ml_train=Module["_nisps_ml_train"]=wasmExports["n"])(a0,a1,a2,a3,a4);var _nisps_ml_set_train_config=Module["_nisps_ml_set_train_config"]=(a0,a1,a2,a3)=>(_nisps_ml_set_train_config=Module["_nisps_ml_set_train_config"]=wasmExports["o"])(a0,a1,a2,a3);var _nisps_ml_eval_loss=Module["_nisps_ml_eval_loss"]=a0=>(_nisps_ml_eval_loss=Module["_nisps_ml_eval_loss"]=wasmExports["p"])(a0);var _nisps_ml_loss_history=Module["_nisps_ml_loss_history"]=(a0,a1,a2)=>(_nisps_ml_loss_history=Module["_nisps_ml_loss_history"]=wasmExports["q"])(a0,a1,a2);var _nisps_ml_weight_count=Module["_nisps_ml_weight_count"]=a0=>(_nisps_ml_weight_count=Module["_nisps_ml_weight_count"]=wasmExports["r"])(a0);var _nisps_ml_get_weights=Module["_nisps_ml_get_weights"]=(a0,a1)=>(_nisps_ml_get_weights=Module["_nisps_ml_get_weights"]=wasmExports["s"])(a0,a1);var _nisps_ml_set_weights=Module["_nisps_ml_set_weights"]=(a0,a1)=>(_nisps_ml_set_weights=Module["_nisps_ml_set_weights"]=wasmExports["t"])(a0,a1);var _nisps_ml_draw_weights=Module["_nisps_ml_draw_weights"]=(a0,a1)=>(_nisps_ml_draw_weights=Module["_nisps_ml_draw_weights"]=wasmExports["u"])(a0,a1);var _nisps_ml_feedback_set_mode=Module["_nisps_ml_feedback_set_mode"]=(a0,a1)=>(_nisps_ml_feedback_set_mode=Module["_nisps_ml_feedback_set_mode"]=wasmExports["v"])(a0,a1);var _nisps_ml_feedback_get_mode=Module["_nisps_ml_feedback_get_mode"]=a0=>(_nisps_ml_feedback_get_mode=Module["_nisps_ml_feedback_get_mode"]=wasmExports["w"])(a0);var _nisps_ml_feedback_exploring=Module["_nisps_ml_feedback_exploring"]=a0=>(_nisps_ml_feedback_exploring=Module["_nisps_ml_feedback_exploring"]=wasmExports["x"])(a0);var _nisps_ml_feedback_set_focus=Module["_nisps_ml_feedback_set_focus"]=(a0,a1,a2)=>(_nisps_ml_feedback_set_focus=Module["_nisps_ml_feedback_set_focus"]=wasmExports["y"])(a0,a1,a2);var _nisps_ml_feedback_down=Module["_nisps_ml_feedback_down"]=(a0,a1,a2,a3,a4)=>(_nisps_ml_feedback_down=Module["_nisps_ml_feedback_down"]=wasmExports["z"])(a0,a1,a2,a3,a4);var _nisps_ml_feedback_up=Module["_nisps_ml_feedback_up"]=a0=>(_nisps_ml_feedback_up=Module["_nisps_ml_feedback_up"]=wasmExports["A"])(a0);var _nisps_ml_feedback_static_output=Module["_nisps_ml_feedback_static_output"]=(a0,a1)=>(_nisps_ml_feedback_static_output=Module["_nisps_ml_feedback_static_output"]=wasmExports["B"])(a0,a1);var _nisps_ml_feedback_enter_explore=Module["_nisps_ml_feedback_enter_explore"]=(a0,a1)=>(_nisps_ml_feedback_enter_explore=Module["_nisps_ml_feedback_enter_explore"]=wasmExports["C"])(a0,a1);var _nisps_ml_feedback_exit_explore=Module["_nisps_ml_feedback_exit_explore"]=a0=>(_nisps_ml_feedback_exit_explore=Module["_nisps_ml_feedback_exit_explore"]=wasmExports["D"])(a0);var _nisps_ml_feedback_reroll=Module["_nisps_ml_feedback_reroll"]=(a0,a1)=>(_nisps_ml_feedback_reroll=Module["_nisps_ml_feedback_reroll"]=wasmExports["E"])(a0,a1);var _nisps_ml_feedback_nudge=Module["_nisps_ml_feedback_nudge"]=(a0,a1)=>(_nisps_ml_feedback_nudge=Module["_nisps_ml_feedback_nudge"]=wasmExports["F"])(a0,a1);var _nisps_ml_feedback_undo=Module["_nisps_ml_feedback_undo"]=a0=>(_nisps_ml_feedback_undo=Module["_nisps_ml_feedback_undo"]=wasmExports["G"])(a0);var _nisps_ml_feedback_like=Module["_nisps_ml_feedback_like"]=a0=>(_nisps_ml_feedback_like=Module["_nisps_ml_feedback_like"]=wasmExports["H"])(a0);var _nisps_ml_feedback_commit_place=Module["_nisps_ml_feedback_commit_place"]=a0=>(_nisps_ml_feedback_commit_place=Module["_nisps_ml_feedback_commit_place"]=wasmExports["I"])(a0);var _nisps_ml_feedback_cancel_place=Module["_nisps_ml_feedback_cancel_place"]=a0=>(_nisps_ml_feedback_cancel_place=Module["_nisps_ml_feedback_cancel_place"]=wasmExports["J"])(a0);var _nisps_ml_feedback_undo_depth=Module["_nisps_ml_feedback_undo_depth"]=a0=>(_nisps_ml_feedback_undo_depth=Module["_nisps_ml_feedback_undo_depth"]=wasmExports["K"])(a0);var _nisps_ml_feedback_placed_output=Module["_nisps_ml_feedback_placed_output"]=(a0,a1)=>(_nisps_ml_feedback_placed_output=Module["_nisps_ml_feedback_placed_output"]=wasmExports["L"])(a0,a1);var _nisps_ml_feedback_dislike_geometric=Module["_nisps_ml_feedback_dislike_geometric"]=(a0,a1,a2)=>(_nisps_ml_feedback_dislike_geometric=Module["_nisps_ml_feedback_dislike_geometric"]=wasmExports["M"])(a0,a1,a2);var _nisps_ml_feedback_store_positive=Module["_nisps_ml_feedback_store_positive"]=(a0,a1)=>(_nisps_ml_feedback_store_positive=Module["_nisps_ml_feedback_store_positive"]=wasmExports["N"])(a0,a1);var _nisps_ml_feedback_positive_count=Module["_nisps_ml_feedback_positive_count"]=a0=>(_nisps_ml_feedback_positive_count=Module["_nisps_ml_feedback_positive_count"]=wasmExports["O"])(a0);var _nisps_ml_feedback_negative_count=Module["_nisps_ml_feedback_negative_count"]=a0=>(_nisps_ml_feedback_negative_count=Module["_nisps_ml_feedback_negative_count"]=wasmExports["P"])(a0);var _nisps_ml_feedback_set_avoid_style=Module["_nisps_ml_feedback_set_avoid_style"]=(a0,a1)=>(_nisps_ml_feedback_set_avoid_style=Module["_nisps_ml_feedback_set_avoid_style"]=wasmExports["Q"])(a0,a1);var _nisps_ml_jolt_press=Module["_nisps_ml_jolt_press"]=a0=>(_nisps_ml_jolt_press=Module["_nisps_ml_jolt_press"]=wasmExports["R"])(a0);var _nisps_ml_jolt_step=Module["_nisps_ml_jolt_step"]=a0=>(_nisps_ml_jolt_step=Module["_nisps_ml_jolt_step"]=wasmExports["S"])(a0);var _nisps_ml_jolt_release=Module["_nisps_ml_jolt_release"]=a0=>(_nisps_ml_jolt_release=Module["_nisps_ml_jolt_release"]=wasmExports["T"])(a0);var _nisps_ml_jolt_active=Module["_nisps_ml_jolt_active"]=a0=>(_nisps_ml_jolt_active=Module["_nisps_ml_jolt_active"]=wasmExports["U"])(a0);var _nisps_ml_explore_intensity=Module["_nisps_ml_explore_intensity"]=(a0,a1)=>(_nisps_ml_explore_intensity=Module["_nisps_ml_explore_intensity"]=wasmExports["V"])(a0,a1);var _nisps_ml_explore_get_intensity=Module["_nisps_ml_explore_get_intensity"]=a0=>(_nisps_ml_explore_get_intensity=Module["_nisps_ml_explore_get_intensity"]=wasmExports["W"])(a0);var _nisps_ml_explore_apply=Module["_nisps_ml_explore_apply"]=(a0,a1,a2)=>(_nisps_ml_explore_apply=Module["_nisps_ml_explore_apply"]=wasmExports["X"])(a0,a1,a2);var _nisps_ml_get_layer_stats=Module["_nisps_ml_get_layer_stats"]=(a0,a1)=>(_nisps_ml_get_layer_stats=Module["_nisps_ml_get_layer_stats"]=wasmExports["Y"])(a0,a1);var _nisps_ml_clear_examples=Module["_nisps_ml_clear_examples"]=a0=>(_nisps_ml_clear_examples=Module["_nisps_ml_clear_examples"]=wasmExports["Z"])(a0);var _nisps_ml_describe=Module["_nisps_ml_describe"]=(a0,a1)=>(_nisps_ml_describe=Module["_nisps_ml_describe"]=wasmExports["_"])(a0,a1);var _nisps_pipeline_create=Module["_nisps_pipeline_create"]=()=>(_nisps_pipeline_create=Module["_nisps_pipeline_create"]=wasmExports["$"])();var _nisps_pipeline_destroy=Module["_nisps_pipeline_destroy"]=a0=>(_nisps_pipeline_destroy=Module["_nisps_pipeline_destroy"]=wasmExports["aa"])(a0);var _nisps_input_set_config=Module["_nisps_input_set_config"]=(a0,a1,a2)=>(_nisps_input_set_config=Module["_nisps_input_set_config"]=wasmExports["ba"])(a0,a1,a2);var _nisps_input_process=Module["_nisps_input_process"]=(a0,a1,a2,a3,a4)=>(_nisps_input_process=Module["_nisps_input_process"]=wasmExports["ca"])(a0,a1,a2,a3,a4);var _nisps_input_reset=Module["_nisps_input_reset"]=a0=>(_nisps_input_reset=Module["_nisps_input_reset"]=wasmExports["da"])(a0);var _nisps_output_set_config=Module["_nisps_output_set_config"]=(a0,a1,a2,a3,a4)=>(_nisps_output_set_config=Module["_nisps_output_set_config"]=wasmExports["ea"])(a0,a1,a2,a3,a4);var _nisps_output_set_freeze_mask=Module["_nisps_output_set_freeze_mask"]=(a0,a1,a2)=>(_nisps_output_set_freeze_mask=Module["_nisps_output_set_freeze_mask"]=wasmExports["fa"])(a0,a1,a2);var _nisps_output_process=Module["_nisps_output_process"]=(a0,a1,a2,a3)=>(_nisps_output_process=Module["_nisps_output_process"]=wasmExports["ga"])(a0,a1,a2,a3);var _nisps_output_reset=Module["_nisps_output_reset"]=a0=>(_nisps_output_reset=Module["_nisps_output_reset"]=wasmExports["ha"])(a0);var _nisps_curve_apply=Module["_nisps_curve_apply"]=(a0,a1,a2)=>(_nisps_curve_apply=Module["_nisps_curve_apply"]=wasmExports["ia"])(a0,a1,a2);var _nisps_curve_apply_batch=Module["_nisps_curve_apply_batch"]=(a0,a1,a2,a3,a4)=>(_nisps_curve_apply_batch=Module["_nisps_curve_apply_batch"]=wasmExports["ja"])(a0,a1,a2,a3,a4);var _nisps_engine_create=Module["_nisps_engine_create"]=(a0,a1)=>(_nisps_engine_create=Module["_nisps_engine_create"]=wasmExports["ka"])(a0,a1);var _nisps_engine_destroy=Module["_nisps_engine_destroy"]=a0=>(_nisps_engine_destroy=Module["_nisps_engine_destroy"]=wasmExports["la"])(a0);var _nisps_engine_set_params=Module["_nisps_engine_set_params"]=(a0,a1,a2)=>(_nisps_engine_set_params=Module["_nisps_engine_set_params"]=wasmExports["ma"])(a0,a1,a2);var _nisps_engine_process_block=Module["_nisps_engine_process_block"]=(a0,a1,a2,a3,a4,a5)=>(_nisps_engine_process_block=Module["_nisps_engine_process_block"]=wasmExports["na"])(a0,a1,a2,a3,a4,a5);var _malloc=Module["_malloc"]=a0=>(_malloc=Module["_malloc"]=wasmExports["pa"])(a0);var _free=Module["_free"]=a0=>(_free=Module["_free"]=wasmExports["qa"])(a0);var __emscripten_stack_restore=a0=>(__emscripten_stack_restore=wasmExports["ra"])(a0);var __emscripten_stack_alloc=a0=>(__emscripten_stack_alloc=wasmExports["sa"])(a0);var _emscripten_stack_get_current=()=>(_emscripten_stack_get_current=wasmExports["ta"])();Module["ccall"]=ccall;Module["cwrap"]=cwrap;var calledRun;var calledPrerun;dependenciesFulfilled=function runCaller(){if(!calledRun)run();if(!calledRun)dependenciesFulfilled=runCaller};function run(){if(runDependencies>0){return}if(!calledPrerun){calledPrerun=1;preRun();if(runDependencies>0){return}}function doRun(){if(calledRun)return;calledRun=1;Module["calledRun"]=1;if(ABORT)return;initRuntime();readyPromiseResolve(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].pop()()}}run();moduleRtn=readyPromise;
return moduleRtn;
diff --git a/manifold/public/nisps.wasm b/manifold/public/nisps.wasm
index 31cacfd..82c21bf 100755
Binary files a/manifold/public/nisps.wasm and b/manifold/public/nisps.wasm differ
diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx
index 983d25c..63e4301 100644
--- a/manifold/src/console/ConsoleApp.tsx
+++ b/manifold/src/console/ConsoleApp.tsx
@@ -104,7 +104,6 @@ export function ConsoleApp() {
const [noiseCap, setNoiseCap] = useState(0.12);
const [examples, setExamples] = useState(0);
const [addingExample, setAddingExample] = useState(false);
- const [loss, setLoss] = useState([]);
const [busy, setBusy] = useState(false);
const [spread, setSpread] = useState(false);
const [active, setActive] = useState(null);
@@ -262,7 +261,6 @@ export function ConsoleApp() {
setParams(mode.params.map((p) => ({ ...p })));
setPos([0.5, 0.5]);
setExamples(0);
- setLoss([]);
setFollow(false);
setPins([]);
setMarkers([]);
@@ -453,10 +451,6 @@ export function ConsoleApp() {
forwardVcvFeedback('up');
syncController();
setNoiseCap((n) => Math.max(0.02, n * 0.7));
- const l = engine?.evalLoss();
- setLoss((prev) =>
- [...prev, Number.isFinite(l) ? (l as number) : prev.length ? prev[prev.length - 1] : 0.5].slice(-120),
- );
setBusy(false);
};
@@ -571,11 +565,8 @@ export function ConsoleApp() {
};
const train = () => {
setBusy(true);
- const l = engine?.train();
+ engine?.train();
engine?.process();
- setLoss((p) =>
- [...p, Number.isFinite(l) ? (l as number) : p.length ? p[p.length - 1] * 0.82 : 0.5].slice(-120),
- );
setBusy(false);
};
const addExample = () => {
@@ -746,7 +737,6 @@ export function ConsoleApp() {
setModeId,
mode,
datasetCount: examples,
- loss,
busy,
addingExample,
onAddExample: addExample,
@@ -757,7 +747,6 @@ export function ConsoleApp() {
// trail is ephemeral and self-decays, so it needs no explicit reset.
engine?.clearExamples();
setExamples(0);
- setLoss([]);
setMarkers([]);
setPins([]);
},
diff --git a/manifold/src/console/Drawers.tsx b/manifold/src/console/Drawers.tsx
index c91f3c2..25f1c4d 100644
--- a/manifold/src/console/Drawers.tsx
+++ b/manifold/src/console/Drawers.tsx
@@ -31,6 +31,7 @@ import { outputModeDescriptor } from './output-mode';
import { useSettings, unfocusedIconCss } from '../settings/settings-store';
import type { UnfocusedIconColour, InputMapMode } from '../settings/settings-store';
import { EditorPanel } from '../serial/EditorPanel';
+import { TrainingHealth } from './TrainingHealth';
import {
LearningIcon,
InputsIcon,
@@ -259,12 +260,9 @@ function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
anchor and restores the real net.
- {/* TODO(dock-spec §1.3): real LossPlot / WeightHealth / LayerStats / GradientFlow need
- nisps_ml_loss_history plumbed through the C API. Diagnostics suite deferred. */}
-
- Loss plot · weight-health · layer-stats · gradient-flow land here once the loss-history C API
- is plumbed (dock-spec §1.3).
-
+
+ Training health
+
>
)}
>
diff --git a/manifold/src/console/TrainingHealth.tsx b/manifold/src/console/TrainingHealth.tsx
new file mode 100644
index 0000000..38e26d5
--- /dev/null
+++ b/manifold/src/console/TrainingHealth.tsx
@@ -0,0 +1,159 @@
+/**
+ * TrainingHealth — the advanced-surface answer to "is the network learning?".
+ *
+ * Every number here is read live out of the C++ core:
+ * - the loss curve is `nisps::ml::MLPCore::loss_history` (one entry per SGD
+ * iteration of the last training run), read through `nisps_ml_loss_history`
+ * and published on the spine by both the sync and the worker train paths;
+ * - the per-layer weight health is `nisps::ml::compute_layer_stats`, read
+ * through the already-plumbed `nisps_ml_get_layer_stats`.
+ *
+ * NOTHING is synthesised. When the core has no history (nothing trained yet)
+ * this renders a plain "no training run yet" line rather than a plausible
+ * placeholder plot — that distinction is the entire point of this panel
+ * (ALIGNMENT defect 6 / simplification-plan §6.5e).
+ *
+ * It is a component (not a plain render helper like its sibling drawer
+ * sections) precisely so it can hold the engine hooks and re-read on version
+ * bumps without dragging the whole Console into a re-render.
+ *
+ * Surfaced only at the Learning drawer's `expanded` depth — Manifold's existing
+ * advanced-surface mechanism (`DrawerDepth`), not a new flag.
+ */
+import { useEngine, useEngineVersion } from '../engine';
+
+const W = 320;
+const H = 64;
+
+function fmt(v: number, dp = 4): string {
+ if (!Number.isFinite(v)) return '—';
+ return v.toFixed(dp);
+}
+
+function pct(v: number): string {
+ if (!Number.isFinite(v)) return '—';
+ return `${(v * 100).toFixed(1)}%`;
+}
+
+const mono = {
+ fontSize: 10,
+ fontFamily: 'var(--font-mono)',
+ color: 'var(--fg-mute)',
+} as const;
+
+/** Per-iteration loss curve, log-scaled on y (loss spans orders of magnitude). */
+function LossPlot({ history }: { history: ReadonlyArray }) {
+ const n = history.length;
+ // A single point has no curve to draw; the readout below still reports it.
+ if (n < 2) return null;
+
+ const logs = history.map((v) => Math.log10(Math.max(v, 1e-9)));
+ let lo = Infinity;
+ let hi = -Infinity;
+ for (const l of logs) {
+ if (l < lo) lo = l;
+ if (l > hi) hi = l;
+ }
+ const span = hi - lo < 1e-6 ? 1 : hi - lo;
+
+ const pts = logs
+ .map((l, i) => {
+ const x = (i / (n - 1)) * W;
+ const y = H - ((l - lo) / span) * H;
+ return `${x.toFixed(2)},${y.toFixed(2)}`;
+ })
+ .join(' ');
+
+ return (
+
+ );
+}
+
+export function TrainingHealth() {
+ const engine = useEngine();
+ // Re-read on every engine state change (training publishes a new history).
+ useEngineVersion(engine);
+
+ if (!engine) {
+ return