From a77770f95d2b71d839701aacd380a0a9aa1cef95 Mon Sep 17 00:00:00 2001
From: monkey-w1n5t0n
Date: Tue, 21 Jul 2026 22:02:23 +0200
Subject: [PATCH] feat: curve truth, DriverConfig, real telemetry, engine
benchmark
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Four items from one workflow, committed together because their build and CI
wiring genuinely interleaves — nisps/CMakeLists.txt, run-all-tests.sh and
ci.yml each carry hunks from two of them, and the stage renumbering (1/5 ->
1/6) touches every line. Splitting would produce commits that do not build,
which is worse than a commit that does four things and says so.
S26 part 2 — the curve declaration now matches reality. params[].curve stays
the mode-wide DEFAULT; a voice_spaces entry may now be {name, curve_overrides}
declaring only the slots where THAT voice space deviates. The 6 modes with one
voice space are byte-identical. The values were derived MECHANICALLY by a new
codegen/curve-audit.ts that models the four idioms a p[N]*p[N] regex misses
(alias form, memlcelium's implicit-counter sq() lambda, loop-generated indices,
smooth_params_), inlines helpers, and RAISES rather than guessing when it
cannot reduce an expression. A drift gate cross-checks 1179 (voice space x
param) slots against engine source on every run and was proved to fail loudly
on three drift classes. Application stays in the engine: nisps/engines,
nisps/pipeline and nisps/core are untouched, generated output is pure insertion
(755 insertions, 0 deletions), and the rebuilt nisps.wasm was byte-identical.
S4 / 7.2 — firmware reads the active mode's driver config at mode start, and
mic/line is real. My brief assumed the engine owns this; the code disagreed and
the code was right. sound_analysis_midi's EngineT is NoOpEngine — the mic lives
on a separately-composed AnalysisEngine member — so engine-level wiring would
have compiled, passed every gate, and left the one mic mode on line input.
Hence a mode-level seam defaulting to engine().driver_config(). Separately,
DriverConfig's defaults (line_level 0, output_volume 1.0) had drifted from
memllib's actual 3/0.8 because nothing had ever read them; wiring them as-is
would have made every silent mode louder and its line input maximally
insensitive — a behaviour change disguised as plumbing. Now pinned by a test.
Also: GetSysClockSpeed() panic()s on unsupported sample rates and runs on the
first line of setup(), so sample_rate needed a fallback ahead of clock setup.
CI's firmware env list gains soundanalysismidi — it is the only mic variant and
nothing else compiles that path.
Plan 5e — telemetry is real. A loss_history C-API entry across the full 5-layer
chain lets the browser read the per-iteration loss the core already records.
The audit named one fabrication site; there were two — wasm-iml.ts's
synchronous train() published lossHistory: [loss] as well. A third, ctx.loss,
was not merely dead but actively synthetic (fallbacks of prev * 0.82 and a
literal 0.5, rendered by nothing) and is deleted. The firmware buffer stays
untouched, per the L25 call. EngineApi.lossHistory() reads spine state rather
than the MLP handle, because trainAsync() fits on the worker's mirror net and
the handle would give a subtly-wrong second answer.
Plan 5f — engine throughput is measurable. One source compiled twice (CMake
natively, emcc for WASM) so the targets compare directly and no WASM export is
added. Sequencers are driven into a working state, and every row prints its own
working-state evidence so a number produced by an idle engine is visible rather
than plausible. Reports, never asserts: a wall-clock threshold on shared
hardware is meaningless or flaky, same call as the firmware size job.
ALIGNMENT: the telemetry defect is deleted (built, not deferred); the
performance defect is rewritten to what is actually left — these are HOST
numbers, and nothing measures the RP2350 at 150 MHz, which is the target the
mission's constraint is about. Q4 (memllib ownership) and Q5 (legacy feedback
modes) are closed.
Corrections to my own earlier claims, both found by agents contradicting the
brief: manifold/ONBOARDING.md was NOT "now accurate" — its primitives list
still named five deleted primitives and cited a seededGradient() that does not
exist. And the parity harness misses the sequencer engines because it runs 128
frames while their sequencers evaluate every 400-500 samples, NOT because
all-params-0.5 fails to trigger them (it does trigger: 0.5 maps to ratio 2,
firing three times per bar). The fix is a longer window, not different params.
Gates: run-all-tests.sh ALL GREEN — 4/4 ctest, parity PASS, lint clean, curve
drift 1179 slots ok, 39 e2e (was 33). Firmware: 5 envs built including the mic
variant.
---
.github/workflows/ci.yml | 36 +-
ALIGNMENT.md | 77 +-
MAP.md | 34 +-
codegen/curve-audit.ts | 885 ++++++++++++++++++
codegen/generate.ts | 163 +++-
codegen/package.json | 2 +-
codegen/tests/curve_drift_test.ts | 185 ++++
codegen/tests/golden/paf_synth_schema.hpp | 65 ++
codegen/tests/golden/paf_synth_schema.ts | 63 ++
docs/specs/plans/simplification-plan.md | 62 +-
firmware/MEMLNaut-NISPS/glue/audio_driver.hpp | 49 +
firmware/MEMLNaut-NISPS/glue/codec_config.hpp | 67 ++
firmware/MEMLNaut-NISPS/src/main.cpp | 12 +-
manifold/ONBOARDING.md | 26 +-
manifold/public/nisps.js | 2 +-
manifold/public/nisps.wasm | Bin 130169 -> 130437 bytes
manifold/src/console/ConsoleApp.tsx | 13 +-
manifold/src/console/Drawers.tsx | 10 +-
manifold/src/console/TrainingHealth.tsx | 159 ++++
manifold/src/console/types.ts | 17 +-
manifold/src/engine/engine-api.ts | 14 +
manifold/src/engine/types.ts | 10 +-
manifold/src/engine/wasm-iml.ts | 43 +-
manifold/src/engine/wasm-worker.ts | 25 +-
.../src/modes/generated/breakor_schema.ts | 1 +
.../modes/generated/channel_strip_schema.ts | 13 +
.../src/modes/generated/elysiamorf_schema.ts | 1 +
.../src/modes/generated/memlcelium_schema.ts | 1 +
.../src/modes/generated/paf_synth_schema.ts | 63 ++
.../modes/generated/slp_workshop_schema.ts | 1 +
.../generated/sound_analysis_midi_schema.ts | 1 +
manifold/src/modes/generated/types.ts | 30 +
.../src/modes/generated/verb_fx_schema.ts | 196 ++++
manifold/src/modes/generated/xiasri_schema.ts | 1 +
manifold/src/primitives/index.ts | 6 +-
manifold/tests/e2e/probe-api.spec.ts | 37 +
manifold/tests/e2e/training-health.spec.ts | 77 ++
manifold/tests/loss-history.test.ts | 134 +++
nisps/CMakeLists.txt | 26 +
nisps/core/concepts.hpp | 5 +
nisps/core/types.hpp | 12 +-
nisps/modes/base.hpp | 23 +
nisps/modes/external_synth_midi.hpp | 2 +
nisps/modes/generated/breakor_schema.hpp | 3 +
.../modes/generated/channel_strip_schema.hpp | 15 +
nisps/modes/generated/elysiamorf_schema.hpp | 3 +
nisps/modes/generated/memlcelium_schema.hpp | 3 +
nisps/modes/generated/paf_synth_schema.hpp | 65 ++
nisps/modes/generated/schema_types.hpp | 23 +
nisps/modes/generated/slp_workshop_schema.hpp | 3 +
.../generated/sound_analysis_midi_schema.hpp | 3 +
nisps/modes/generated/verb_fx_schema.hpp | 198 ++++
nisps/modes/generated/xiasri_schema.hpp | 3 +
nisps/modes/sound_analysis_midi.hpp | 9 +
nisps/wasm/README.md | 2 +-
nisps/wasm/bindings.cpp | 23 +
schemas/modes/channel_strip.json | 21 +-
schemas/modes/paf_synth.json | 35 +-
schemas/modes/params_notes.md | 29 +-
schemas/modes/verb_fx.json | 83 +-
schemas/schema.json | 29 +-
scripts/bench-engines.sh | 164 ++++
scripts/build-cpp-tests.sh | 10 +-
scripts/build-wasm.sh | 1 +
scripts/run-all-tests.sh | 44 +-
tests/cpp/bench_report.mjs | 179 ++++
tests/cpp/engine_bench.cpp | 565 +++++++++++
tests/cpp/test_mlp_training.cpp | 42 +
tests/cpp/test_mode_curve_overrides.cpp | 122 +++
tests/cpp/test_mode_driver_config.cpp | 191 ++++
70 files changed, 4361 insertions(+), 156 deletions(-)
create mode 100644 codegen/curve-audit.ts
create mode 100644 codegen/tests/curve_drift_test.ts
create mode 100644 firmware/MEMLNaut-NISPS/glue/codec_config.hpp
create mode 100644 manifold/src/console/TrainingHealth.tsx
create mode 100644 manifold/tests/e2e/training-health.spec.ts
create mode 100644 manifold/tests/loss-history.test.ts
create mode 100755 scripts/bench-engines.sh
create mode 100644 tests/cpp/bench_report.mjs
create mode 100644 tests/cpp/engine_bench.cpp
create mode 100644 tests/cpp/test_mode_curve_overrides.cpp
create mode 100644 tests/cpp/test_mode_driver_config.cpp
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 31cacfd21f76c716082204bbc88229aecde0d098..82c21bfef56fff035c65cdbe7b60104f2670cc9d 100755
GIT binary patch
delta 1058
zcmZ{h&2N-d6vm(D-aGF*pKVpFt$@5UR#RG_7FsM+a9(ITZ7FS`w1tY+3;mqwcZWi&
zaTc~L&^7tB&C!T1%be0kIN^d&%{Fcl;S3dCxZ-z
zr0}s&;He^s;-@zwB)0%tCBg({#1a6EgU{F-5v*+%EL%9zNERvlk!VbY1)dcp%EyW2
z4_TD?TqM{hz**XE=lg4x^Sy>}Lvd9KkF{agJk1a~#v0z!WDj$tg^58ski3
zjOQ@Q8I15eQoMj9XEDq<4AI3P4`G0Z(a$>cu^wmH0L=tmXCrzuY{F}7#;ZJn(`>;j
zJc=H+;uPC(lI`f`F?8`bI@y5}Jb?~&;yAl-jNNGGNwo14TG@l6>_rPpa71X0W^qQN
zNu1Sa6nz>A(XUZ21~lr#pvGY_q;W_LYq%o0Un4G38ZV0xje}xTZQa!c2AzX-r7Tdf*=e+VhyTQ2d4tzpQ?)I*I%6I&H!7KlQ3(EWD
zOE%lw>D7M2xMW54%~k#y-sQ@u3agM~73}ify2a-dE3+TpW>02b(9SrhS8*5lAqS=6
zJ4Tg=JFJhu5iVK`)FYV7uegzNgRV#?qta21Fh@0SjtN&zMlBc74HHwYY*TR){kgur
zZcj!7(iC*e;?gmZG!B_86Q~hzGBinQD&|W%(((PHZixQ>62nvf^21e0)6#Dc^E-h=
z1R?WD^Ys5~2$vfM+aP8;mi@?~yuWU&EBmcXE=mI7=U$|2{aYWos2hwu;4K
zjIE74h%L-JNV3FYvbDvgMMY96MM?4dw|=3%RK7$O%O{q_RE4wv13@J|rXwR$VcYhv
z5LDQKoeCj>Dr^gY#Dcq=&k0$!4cn1ym8fh|!a2KDNcKQ-Egz>Czr~E$9jRrXfc@CZ
z0W5M5nnO6wVN`Gg-*FTR9K%(P<1!~O%Sp^|3e%j%6lXBWSxoRU#(4!}oWm%uVubS;
z<^qPeh(Sg$z$)~!8hxxmFOTCAYjKfv=-~-mi18%OvmV_%g>yWOC>zklMs%_X9c)HB
z&!CNG(aILIuoY+7hBItOGds}4PBgL$4UFP6dvJ=ms28nqQZ8wnkY0^C>C>o{evKL#
z(5RL{jVc+^h{&);rHqto9FtLvqcWy(M8-9~mkCYCq{cTgrD4gm#=OjE%*m|AmEhgy
z&b;?da{SDTr-h1{=hcPY2oJ{#lcPN3Ehj$?@UYjE`QoT52nXW2H`S}?$Vy>>cW$s|
zFZ1KSudFfOD_Q-u%q`7io%he(6ecbHA7O>aRe8cW><-{MP+Hz-WTDARBqe
z+zN6f$t-4aXFCs?UnY3NNtiXqXQ|w0-Y&C;2^)UqL!Vo|&Sf{a&V2J|o!gRFV*dS&
zF&_`OT^sCYk$JSi>z4WP4mDY7Ht+H$Ka0)ueJ)w1`Zg=Yynn!YhX+jL4^}D`xj+2L
z_u;~b%CW*$DBDgcG}oW-Yl|iB?Y|i{!Kcg!v&`&%$?mYb^orM5?mC;SO)d{*Tan7q
aT!d_7!?bQDPO;Rz_nOTtzW;_x>7M{gE4MTN
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