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

engine not ready

; + } + + const history = engine.lossHistory(); + const stats = engine.getLayerStats(); + const first = history.length ? history[0] : null; + const last = history.length ? history[history.length - 1] : null; + + return ( +
+ {history.length === 0 ? ( +

+ no training run yet — the loss curve appears after the first fit +

+ ) : ( + <> + +
+ {history.length} iter + start {fmt(first ?? 0)} + end {fmt(last ?? 0)} + + {first !== null && last !== null && last < first + ? 'converging' + : 'not improving'} + +
+ + )} + + + + + + + + + + + + + {stats.map((s, i) => ( + + + + + + + + ))} + +
layermean|w|max|w|deadsat
L{i}{fmt(s.meanAbs, 3)}{fmt(s.maxAbs, 3)}{pct(s.deadFrac)}{pct(s.saturatingFrac)}
+

+ dead = |w| < 0.001, sat = |w| > 3 (nisps/ml/stats.hpp). A layer that is + mostly dead or mostly saturating is not learning usefully. +

+
+ ); +} diff --git a/manifold/src/console/types.ts b/manifold/src/console/types.ts index c7e84e4..fe19b66 100644 --- a/manifold/src/console/types.ts +++ b/manifold/src/console/types.ts @@ -49,12 +49,16 @@ export type DrawerDepth = 'condensed' | 'expanded'; * * `modes` / `setModeId` are KEPT despite having no renderer today — this is * the exact plumbing the Phase-5 instrument-mode picker is built on (A7/A3), - * not dead code. `busy` / `addingExample` / `onAddExample` / `onTrain` / `loss` - * are ALSO kept even though no drawer reads them either: unlike the fields - * above they drive real engine calls (engine.addExample / engine.train / - * engine.evalLoss), so — pending confirmation either way — they read as - * unfinished plumbing rather than confirmed-dead decoration; deleting them - * was out of this pass's authorized scope. + * not dead code. `busy` / `addingExample` / `onAddExample` / `onTrain` are ALSO + * kept even though no drawer reads them either: unlike the fields above they + * drive real engine calls (engine.addExample / engine.train), so — pending + * confirmation either way — they read as unfinished plumbing rather than + * confirmed-dead decoration. + * + * `loss` was DELETED with §6.5e (2026-07-21): it was a synthetic series (an + * `evalLoss` sample when finite, otherwise `prev * 0.82` or a literal 0.5) that + * no drawer read. The real per-iteration curve now comes straight from the core + * — see `TrainingHealth.tsx` / `EngineApi.lossHistory()`. */ export interface ConsoleCtx { modes: MFMode[]; @@ -63,7 +67,6 @@ export interface ConsoleCtx { mode: MFMode; datasetCount: number; - loss: number[]; busy: boolean; addingExample: boolean; onAddExample: () => void; diff --git a/manifold/src/engine/engine-api.ts b/manifold/src/engine/engine-api.ts index ed33070..8809195 100644 --- a/manifold/src/engine/engine-api.ts +++ b/manifold/src/engine/engine-api.ts @@ -359,6 +359,20 @@ export class EngineApi { return this.iml.getLayerStatsFlat(); } + /** + * Per-iteration loss of the most recent training run — the real curve the + * core recorded (`nisps::ml::MLPCore::loss_history`, read out through + * `nisps_ml_loss_history`), not a synthesised one. + * + * Deliberately sourced from spine state rather than 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 here, so this is the one honest + * answer to "how did the last fit go?". + */ + lossHistory(): ReadonlyArray { + return this.spine.getState().lossHistory; + } + // ---- Reactive contract -------------------------------------------- /** Subscribe to state changes (useSyncExternalStore). Returns an unsubscribe. */ diff --git a/manifold/src/engine/types.ts b/manifold/src/engine/types.ts index 6a3b34e..00452d6 100644 --- a/manifold/src/engine/types.ts +++ b/manifold/src/engine/types.ts @@ -49,6 +49,11 @@ export interface NispsModule { // nisps::ml::MLPCore::set_train_config. Does not train. _nisps_ml_set_train_config(ml: number, lr: number, max_iter: number, min_err: number): void; _nisps_ml_eval_loss(ml: number): number; + // Per-iteration loss recorded by the LAST _nisps_ml_train call on this + // handle. Returns the TOTAL entry count and writes min(count, max) floats + // into out_ptr; out_ptr=0 / max=0 queries the count without copying (that + // is how JS sizes its heap buffer instead of mirroring the C++ history cap). + _nisps_ml_loss_history(ml: number, out_ptr: number, max: number): number; // ML examples. _nisps_ml_clear_examples(ml: number): void; @@ -270,8 +275,9 @@ export type WorkerResponse = requestId: number; loss: number; weights: Float32Array; - // Loss curve (per-iteration). Currently always single-element — the C++ - // MLP exposes loss_history but the WASM bridge does not yet plumb it. + // Loss curve: one entry per SGD iteration the worker's net actually ran, + // read out of the core via `_nisps_ml_loss_history`. Length <= maxIter + // (training stops early once epoch loss < minErr). lossHistory: Float32Array; } | { diff --git a/manifold/src/engine/wasm-iml.ts b/manifold/src/engine/wasm-iml.ts index 887b364..153de86 100644 --- a/manifold/src/engine/wasm-iml.ts +++ b/manifold/src/engine/wasm-iml.ts @@ -161,6 +161,12 @@ export class WasmIML { private pinMaskBuf!: HeapU8; private feedbackBuf!: HeapBuffer; // kDefaultOutputs scratch for feedback static/down private describePtr = 0; + // Loss-history readback scratch: grown on demand to the count the core + // reports, so nothing here mirrors the C++ history cap. Raw ptr rather than a + // HeapBuffer because the view has to be rebuilt per read anyway (the length + // varies with each training run). + private lossHistPtr_ = 0; + private lossHistCap_ = 0; // Pipeline (one-core-engine P4): the input/output processing chains live // C++-side per handle. These wrappers own the handle + bridge buffers. @@ -311,6 +317,11 @@ export class WasmIML { if (this.pipeMaskBuf) this.pipeMaskBuf.free(); if (this.curveBuf) this.curveBuf.free(); if (this.describePtr) this.module._free(this.describePtr); + if (this.lossHistPtr_) { + this.module._free(this.lossHistPtr_); + this.lossHistPtr_ = 0; + this.lossHistCap_ = 0; + } this.sink.setState({ ready: false }); } @@ -668,9 +679,7 @@ export class WasmIML { } this.lastLoss_ = loss; - // The C++ MLP stores per-iter history but it isn't exposed via the WASM - // bindings yet, so this is a single-element array. - this.sink.setState({ lastLoss: loss, lossHistory: [loss] }); + this.sink.setState({ lastLoss: loss, lossHistory: this.lossHistory() }); this.sink.emit('ml.trained', { loss }); this.scheduleSave_(); return loss; @@ -1020,6 +1029,34 @@ export class WasmIML { return new Float32Array(this.statsBuf.view); } + /** + * The REAL per-iteration loss curve of the LAST synchronous `train()` on this + * handle, straight out of `nisps::ml::MLPCore::loss_history`. Empty when the + * handle has not trained (or trained on an empty dataset). + * + * `trainAsync()` runs on the worker's own MLP handle, so ITS curve comes back + * over the worker protocol instead — this handle's history is untouched by it. + * + * Two C calls: count first (out=0), then copy. The count query is what sizes + * the heap buffer, so no TS constant mirrors the C++ history cap. + */ + lossHistory(): number[] { + const count = this.module._nisps_ml_loss_history(this.mlHandle, 0, 0); + if (count <= 0) return []; + if (count > this.lossHistCap_) { + if (this.lossHistPtr_) this.module._free(this.lossHistPtr_); + this.lossHistPtr_ = this.module._malloc(count * 4); + if (!this.lossHistPtr_) { + this.lossHistCap_ = 0; + return []; + } + this.lossHistCap_ = count; + } + this.module._nisps_ml_loss_history(this.mlHandle, this.lossHistPtr_, count); + // View built per call: ALLOW_MEMORY_GROWTH can detach a cached one. + return Array.from(new Float32Array(this.module.HEAPF32.buffer, this.lossHistPtr_, count)); + } + // ------------------------------------------------------------------- // Persistence // ------------------------------------------------------------------- diff --git a/manifold/src/engine/wasm-worker.ts b/manifold/src/engine/wasm-worker.ts index 0a66faa..988b4fc 100644 --- a/manifold/src/engine/wasm-worker.ts +++ b/manifold/src/engine/wasm-worker.ts @@ -189,6 +189,8 @@ if (isWorker) { let labelsLen = 0; let sampleWeightsPtr = 0; let sampleWeightsLen = 0; + let lossHistPtr = 0; + let lossHistLen = 0; async function loadModule(seed: number): Promise { // nisps.js is non-ES-module Emscripten glue; fetch + indirect-eval to @@ -280,6 +282,26 @@ if (isWorker) { } } + /** + * The REAL per-iteration loss curve of the run that just finished, copied out + * of the core's own history buffer. Two calls: the first queries the entry + * count (out=0), the second copies — so the heap buffer is sized from C++ + * truth rather than a TS mirror of the history cap. Empty when nothing was + * recorded (e.g. an empty dataset). + */ + function readLossHistory(): Float32Array { + if (!mod) return new Float32Array(0); + const count = mod._nisps_ml_loss_history(mlHandle, 0, 0); + if (count <= 0) return new Float32Array(0); + if (count > lossHistLen) { + if (lossHistPtr) mod._free(lossHistPtr); + lossHistPtr = mod._malloc(count * 4); + lossHistLen = count; + } + mod._nisps_ml_loss_history(mlHandle, lossHistPtr, count); + return new Float32Array(new Float32Array(mod.HEAPF32.buffer, lossHistPtr, count)); + } + function trainOnce(req: Extract): WorkerResponse { if (!mod) { return { kind: 'error', requestId: req.requestId, message: 'worker not initialised' }; @@ -307,7 +329,7 @@ if (isWorker) { const view = new Float32Array(mod.HEAPF32.buffer, weightsPtr, weightCount); const outWeights = new Float32Array(view); // copy - const lossHistory = new Float32Array([loss]); + const lossHistory = readLossHistory(); return { kind: 'result', @@ -335,6 +357,7 @@ if (isWorker) { if (featuresPtr) { mod._free(featuresPtr); featuresPtr = 0; } if (labelsPtr) { mod._free(labelsPtr); labelsPtr = 0; } if (sampleWeightsPtr) { mod._free(sampleWeightsPtr); sampleWeightsPtr = 0; } + if (lossHistPtr) { mod._free(lossHistPtr); lossHistPtr = 0; lossHistLen = 0; } mod = null; } diff --git a/manifold/src/modes/generated/breakor_schema.ts b/manifold/src/modes/generated/breakor_schema.ts index e719246..092cc08 100644 --- a/manifold/src/modes/generated/breakor_schema.ts +++ b/manifold/src/modes/generated/breakor_schema.ts @@ -586,6 +586,7 @@ export const BreakorSchema: ModeSchema = { }, ], voice_spaces: [], + curve_overrides: [], ui: { primary_input: 'xy_pad', show_voice_space_selector: false, diff --git a/manifold/src/modes/generated/channel_strip_schema.ts b/manifold/src/modes/generated/channel_strip_schema.ts index e2791a3..56cb8c8 100644 --- a/manifold/src/modes/generated/channel_strip_schema.ts +++ b/manifold/src/modes/generated/channel_strip_schema.ts @@ -273,6 +273,19 @@ export const ChannelStripSchema: ModeSchema = { 'FemaleVox', 'Neve 80', ], + curve_overrides: [ + { voice_space: 1, param: 11, curve: 'square' }, // SSL 4K G-ist.comp_ratio + { voice_space: 2, param: 11, curve: 'square' }, // SSL 9K-inda.comp_ratio + { voice_space: 3, param: 13, curve: 'linear' }, // MaleVox.comp_release + { voice_space: 4, param: 13, curve: 'linear' }, // FemaleVox.comp_release + { voice_space: 5, param: 1, curve: 'linear' }, // Neve 80.peak0_freq + { voice_space: 5, param: 4, curve: 'linear' }, // Neve 80.peak1_freq + { voice_space: 5, param: 7, curve: 'linear' }, // Neve 80.in_lpf_cutoff + { voice_space: 5, param: 8, curve: 'linear' }, // Neve 80.in_hpf_cutoff + { voice_space: 5, param: 13, curve: 'linear' }, // Neve 80.comp_release + { voice_space: 5, param: 14, curve: 'linear' }, // Neve 80.lowshelf_freq + { voice_space: 5, param: 17, curve: 'linear' }, // Neve 80.highshelf_freq + ], ui: { primary_input: 'joystick', show_voice_space_selector: true, diff --git a/manifold/src/modes/generated/elysiamorf_schema.ts b/manifold/src/modes/generated/elysiamorf_schema.ts index a66ebb9..3cd657b 100644 --- a/manifold/src/modes/generated/elysiamorf_schema.ts +++ b/manifold/src/modes/generated/elysiamorf_schema.ts @@ -426,6 +426,7 @@ export const ElysiamorfSchema: ModeSchema = { }, ], voice_spaces: [], + curve_overrides: [], ui: { primary_input: 'xy_pad', show_voice_space_selector: false, diff --git a/manifold/src/modes/generated/memlcelium_schema.ts b/manifold/src/modes/generated/memlcelium_schema.ts index 39c573f..13559ee 100644 --- a/manifold/src/modes/generated/memlcelium_schema.ts +++ b/manifold/src/modes/generated/memlcelium_schema.ts @@ -588,6 +588,7 @@ export const MemlceliumSchema: ModeSchema = { voice_spaces: [ 'Direct', ], + curve_overrides: [], ui: { primary_input: 'xy_pad', show_voice_space_selector: false, diff --git a/manifold/src/modes/generated/paf_synth_schema.ts b/manifold/src/modes/generated/paf_synth_schema.ts index 5890052..5a930dd 100644 --- a/manifold/src/modes/generated/paf_synth_schema.ts +++ b/manifold/src/modes/generated/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/manifold/src/modes/generated/slp_workshop_schema.ts b/manifold/src/modes/generated/slp_workshop_schema.ts index 9ced1e1..85e07d1 100644 --- a/manifold/src/modes/generated/slp_workshop_schema.ts +++ b/manifold/src/modes/generated/slp_workshop_schema.ts @@ -588,6 +588,7 @@ export const SlpWorkshopSchema: ModeSchema = { voice_spaces: [ 'Direct', ], + curve_overrides: [], ui: { primary_input: 'xy_pad', show_voice_space_selector: false, diff --git a/manifold/src/modes/generated/sound_analysis_midi_schema.ts b/manifold/src/modes/generated/sound_analysis_midi_schema.ts index d40ea06..ba62c05 100644 --- a/manifold/src/modes/generated/sound_analysis_midi_schema.ts +++ b/manifold/src/modes/generated/sound_analysis_midi_schema.ts @@ -112,6 +112,7 @@ export const SoundAnalysisMidiSchema: ModeSchema = { }, ], voice_spaces: [], + curve_overrides: [], ui: { primary_input: 'audio_in', show_voice_space_selector: false, diff --git a/manifold/src/modes/generated/types.ts b/manifold/src/modes/generated/types.ts index e095a7e..a72e211 100644 --- a/manifold/src/modes/generated/types.ts +++ b/manifold/src/modes/generated/types.ts @@ -28,6 +28,20 @@ export interface Param { 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; @@ -48,5 +62,21 @@ export interface ModeSchema { 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; +} diff --git a/manifold/src/modes/generated/verb_fx_schema.ts b/manifold/src/modes/generated/verb_fx_schema.ts index fae4bd3..cd55f9d 100644 --- a/manifold/src/modes/generated/verb_fx_schema.ts +++ b/manifold/src/modes/generated/verb_fx_schema.ts @@ -509,6 +509,202 @@ export const VerbFxSchema: ModeSchema = { 'Bright', 'Harmonic', ], + curve_overrides: [ + { voice_space: 1, param: 29, curve: 'sqrt' }, // Resonant.fbank_res0 + { voice_space: 1, param: 30, curve: 'sqrt' }, // Resonant.fbank_res1 + { voice_space: 1, param: 31, curve: 'sqrt' }, // Resonant.fbank_res2 + { voice_space: 1, param: 32, curve: 'sqrt' }, // Resonant.fbank_res3 + { voice_space: 1, param: 33, curve: 'sqrt' }, // Resonant.fbank_res4 + { voice_space: 1, param: 34, curve: 'sqrt' }, // Resonant.fbank_res5 + { voice_space: 1, param: 35, curve: 'sqrt' }, // Resonant.fbank_res6 + { voice_space: 1, param: 36, curve: 'sqrt' }, // Resonant.fbank_res7 + { voice_space: 2, param: 1, curve: 'square' }, // Soft.lp0_fb + { voice_space: 2, param: 3, curve: 'square' }, // Soft.lp1_fb + { voice_space: 2, param: 5, curve: 'square' }, // Soft.lp2_fb + { voice_space: 2, param: 7, curve: 'square' }, // Soft.lp3_fb + { voice_space: 2, param: 9, curve: 'square' }, // Soft.lp4_fb + { voice_space: 2, param: 11, curve: 'square' }, // Soft.lp5_fb + { voice_space: 2, param: 13, curve: 'square' }, // Soft.lp6_fb + { voice_space: 2, param: 15, curve: 'square' }, // Soft.lp7_fb + { voice_space: 2, param: 17, curve: 'square' }, // Soft.allp0_fb + { voice_space: 2, param: 18, curve: 'square' }, // Soft.allp1_fb + { voice_space: 2, param: 19, curve: 'square' }, // Soft.allp2_fb + { voice_space: 2, param: 20, curve: 'square' }, // Soft.allp3_fb + { voice_space: 2, param: 29, curve: 'square' }, // Soft.fbank_res0 + { voice_space: 2, param: 30, curve: 'square' }, // Soft.fbank_res1 + { voice_space: 2, param: 31, curve: 'square' }, // Soft.fbank_res2 + { voice_space: 2, param: 32, curve: 'square' }, // Soft.fbank_res3 + { voice_space: 2, param: 33, curve: 'square' }, // Soft.fbank_res4 + { voice_space: 2, param: 34, curve: 'square' }, // Soft.fbank_res5 + { voice_space: 2, param: 35, curve: 'square' }, // Soft.fbank_res6 + { voice_space: 2, param: 36, curve: 'square' }, // Soft.fbank_res7 + { voice_space: 2, param: 38, curve: 'square' }, // Soft.delay0_fb + { voice_space: 2, param: 40, curve: 'square' }, // Soft.delay1_fb + { voice_space: 2, param: 42, curve: 'square' }, // Soft.delay2_fb + { voice_space: 3, param: 1, curve: 'sqrt' }, // Cathedral.lp0_fb + { voice_space: 3, param: 3, curve: 'sqrt' }, // Cathedral.lp1_fb + { voice_space: 3, param: 5, curve: 'sqrt' }, // Cathedral.lp2_fb + { voice_space: 3, param: 7, curve: 'sqrt' }, // Cathedral.lp3_fb + { voice_space: 3, param: 9, curve: 'sqrt' }, // Cathedral.lp4_fb + { voice_space: 3, param: 11, curve: 'sqrt' }, // Cathedral.lp5_fb + { voice_space: 3, param: 13, curve: 'sqrt' }, // Cathedral.lp6_fb + { voice_space: 3, param: 15, curve: 'sqrt' }, // Cathedral.lp7_fb + { voice_space: 3, param: 17, curve: 'sqrt' }, // Cathedral.allp0_fb + { voice_space: 3, param: 18, curve: 'sqrt' }, // Cathedral.allp1_fb + { voice_space: 3, param: 19, curve: 'sqrt' }, // Cathedral.allp2_fb + { voice_space: 3, param: 20, curve: 'sqrt' }, // Cathedral.allp3_fb + { voice_space: 3, param: 37, curve: 'sqrt' }, // Cathedral.delay0_time + { voice_space: 3, param: 38, curve: 'sqrt' }, // Cathedral.delay0_fb + { voice_space: 3, param: 39, curve: 'sqrt' }, // Cathedral.delay1_time + { voice_space: 3, param: 40, curve: 'sqrt' }, // Cathedral.delay1_fb + { voice_space: 3, param: 41, curve: 'sqrt' }, // Cathedral.delay2_time + { voice_space: 3, param: 42, curve: 'sqrt' }, // Cathedral.delay2_fb + { voice_space: 3, param: 43, curve: 'square' }, // Cathedral.verb_vs_delay + { voice_space: 4, param: 1, curve: 'sqrt' }, // Shimmer.lp0_fb + { voice_space: 4, param: 3, curve: 'sqrt' }, // Shimmer.lp1_fb + { voice_space: 4, param: 5, curve: 'sqrt' }, // Shimmer.lp2_fb + { voice_space: 4, param: 7, curve: 'sqrt' }, // Shimmer.lp3_fb + { voice_space: 4, param: 9, curve: 'sqrt' }, // Shimmer.lp4_fb + { voice_space: 4, param: 11, curve: 'sqrt' }, // Shimmer.lp5_fb + { voice_space: 4, param: 13, curve: 'sqrt' }, // Shimmer.lp6_fb + { voice_space: 4, param: 15, curve: 'sqrt' }, // Shimmer.lp7_fb + { voice_space: 4, param: 17, curve: 'sqrt' }, // Shimmer.allp0_fb + { voice_space: 4, param: 18, curve: 'sqrt' }, // Shimmer.allp1_fb + { voice_space: 4, param: 19, curve: 'sqrt' }, // Shimmer.allp2_fb + { voice_space: 4, param: 20, curve: 'sqrt' }, // Shimmer.allp3_fb + { voice_space: 4, param: 29, curve: 'sqrt' }, // Shimmer.fbank_res0 + { voice_space: 4, param: 30, curve: 'sqrt' }, // Shimmer.fbank_res1 + { voice_space: 4, param: 31, curve: 'sqrt' }, // Shimmer.fbank_res2 + { voice_space: 4, param: 32, curve: 'sqrt' }, // Shimmer.fbank_res3 + { voice_space: 4, param: 33, curve: 'sqrt' }, // Shimmer.fbank_res4 + { voice_space: 4, param: 34, curve: 'sqrt' }, // Shimmer.fbank_res5 + { voice_space: 4, param: 35, curve: 'sqrt' }, // Shimmer.fbank_res6 + { voice_space: 4, param: 36, curve: 'sqrt' }, // Shimmer.fbank_res7 + { voice_space: 4, param: 38, curve: 'sqrt' }, // Shimmer.delay0_fb + { voice_space: 4, param: 40, curve: 'sqrt' }, // Shimmer.delay1_fb + { voice_space: 4, param: 42, curve: 'sqrt' }, // Shimmer.delay2_fb + { voice_space: 4, param: 43, curve: 'square' }, // Shimmer.verb_vs_delay + { voice_space: 5, param: 1, curve: 'square' }, // Chamber.lp0_fb + { voice_space: 5, param: 3, curve: 'square' }, // Chamber.lp1_fb + { voice_space: 5, param: 5, curve: 'square' }, // Chamber.lp2_fb + { voice_space: 5, param: 7, curve: 'square' }, // Chamber.lp3_fb + { voice_space: 5, param: 9, curve: 'square' }, // Chamber.lp4_fb + { voice_space: 5, param: 11, curve: 'square' }, // Chamber.lp5_fb + { voice_space: 5, param: 13, curve: 'square' }, // Chamber.lp6_fb + { voice_space: 5, param: 15, curve: 'square' }, // Chamber.lp7_fb + { voice_space: 5, param: 17, curve: 'square' }, // Chamber.allp0_fb + { voice_space: 5, param: 18, curve: 'square' }, // Chamber.allp1_fb + { voice_space: 5, param: 19, curve: 'square' }, // Chamber.allp2_fb + { voice_space: 5, param: 20, curve: 'square' }, // Chamber.allp3_fb + { voice_space: 5, param: 29, curve: 'square' }, // Chamber.fbank_res0 + { voice_space: 5, param: 30, curve: 'square' }, // Chamber.fbank_res1 + { voice_space: 5, param: 31, curve: 'square' }, // Chamber.fbank_res2 + { voice_space: 5, param: 32, curve: 'square' }, // Chamber.fbank_res3 + { voice_space: 5, param: 33, curve: 'square' }, // Chamber.fbank_res4 + { voice_space: 5, param: 34, curve: 'square' }, // Chamber.fbank_res5 + { voice_space: 5, param: 35, curve: 'square' }, // Chamber.fbank_res6 + { voice_space: 5, param: 36, curve: 'square' }, // Chamber.fbank_res7 + { voice_space: 5, param: 37, curve: 'square' }, // Chamber.delay0_time + { voice_space: 5, param: 38, curve: 'square' }, // Chamber.delay0_fb + { voice_space: 5, param: 39, curve: 'square' }, // Chamber.delay1_time + { voice_space: 5, param: 40, curve: 'square' }, // Chamber.delay1_fb + { voice_space: 5, param: 41, curve: 'square' }, // Chamber.delay2_time + { voice_space: 5, param: 42, curve: 'square' }, // Chamber.delay2_fb + { voice_space: 6, param: 17, curve: 'sqrt' }, // Metallic.allp0_fb + { voice_space: 6, param: 18, curve: 'sqrt' }, // Metallic.allp1_fb + { voice_space: 6, param: 19, curve: 'sqrt' }, // Metallic.allp2_fb + { voice_space: 6, param: 20, curve: 'sqrt' }, // Metallic.allp3_fb + { voice_space: 6, param: 29, curve: 'sqrt' }, // Metallic.fbank_res0 + { voice_space: 6, param: 30, curve: 'square' }, // Metallic.fbank_res1 + { voice_space: 6, param: 31, curve: 'sqrt' }, // Metallic.fbank_res2 + { voice_space: 6, param: 32, curve: 'square' }, // Metallic.fbank_res3 + { voice_space: 6, param: 33, curve: 'sqrt' }, // Metallic.fbank_res4 + { voice_space: 6, param: 34, curve: 'square' }, // Metallic.fbank_res5 + { voice_space: 6, param: 35, curve: 'sqrt' }, // Metallic.fbank_res6 + { voice_space: 6, param: 36, curve: 'square' }, // Metallic.fbank_res7 + { voice_space: 7, param: 1, curve: 'square' }, // Granular.lp0_fb + { voice_space: 7, param: 3, curve: 'square' }, // Granular.lp1_fb + { voice_space: 7, param: 5, curve: 'square' }, // Granular.lp2_fb + { voice_space: 7, param: 7, curve: 'square' }, // Granular.lp3_fb + { voice_space: 7, param: 9, curve: 'square' }, // Granular.lp4_fb + { voice_space: 7, param: 11, curve: 'square' }, // Granular.lp5_fb + { voice_space: 7, param: 13, curve: 'square' }, // Granular.lp6_fb + { voice_space: 7, param: 15, curve: 'square' }, // Granular.lp7_fb + { voice_space: 7, param: 17, curve: 'square' }, // Granular.allp0_fb + { voice_space: 7, param: 18, curve: 'square' }, // Granular.allp1_fb + { voice_space: 7, param: 19, curve: 'square' }, // Granular.allp2_fb + { voice_space: 7, param: 20, curve: 'square' }, // Granular.allp3_fb + { voice_space: 7, param: 29, curve: 'square' }, // Granular.fbank_res0 + { voice_space: 7, param: 30, curve: 'square' }, // Granular.fbank_res1 + { voice_space: 7, param: 31, curve: 'square' }, // Granular.fbank_res2 + { voice_space: 7, param: 32, curve: 'square' }, // Granular.fbank_res3 + { voice_space: 7, param: 33, curve: 'square' }, // Granular.fbank_res4 + { voice_space: 7, param: 34, curve: 'square' }, // Granular.fbank_res5 + { voice_space: 7, param: 35, curve: 'square' }, // Granular.fbank_res6 + { voice_space: 7, param: 36, curve: 'square' }, // Granular.fbank_res7 + { voice_space: 7, param: 38, curve: 'square' }, // Granular.delay0_fb + { voice_space: 7, param: 40, curve: 'square' }, // Granular.delay1_fb + { voice_space: 7, param: 42, curve: 'sqrt' }, // Granular.delay2_fb + { voice_space: 7, param: 43, curve: 'sqrt' }, // Granular.verb_vs_delay + { voice_space: 7, param: 45, curve: 'square' }, // Granular.delay_morph + { voice_space: 7, param: 46, curve: 'sqrt' }, // Granular.delay_blend + { voice_space: 8, param: 0, curve: 'sqrt' }, // Diffuse.fb_delay_xfade + { voice_space: 8, param: 17, curve: 'sqrt' }, // Diffuse.allp0_fb + { voice_space: 8, param: 18, curve: 'sqrt' }, // Diffuse.allp1_fb + { voice_space: 8, param: 19, curve: 'sqrt' }, // Diffuse.allp2_fb + { voice_space: 8, param: 20, curve: 'sqrt' }, // Diffuse.allp3_fb + { voice_space: 8, param: 29, curve: 'square' }, // Diffuse.fbank_res0 + { voice_space: 8, param: 30, curve: 'square' }, // Diffuse.fbank_res1 + { voice_space: 8, param: 31, curve: 'square' }, // Diffuse.fbank_res2 + { voice_space: 8, param: 32, curve: 'square' }, // Diffuse.fbank_res3 + { voice_space: 8, param: 33, curve: 'square' }, // Diffuse.fbank_res4 + { voice_space: 8, param: 34, curve: 'square' }, // Diffuse.fbank_res5 + { voice_space: 8, param: 35, curve: 'square' }, // Diffuse.fbank_res6 + { voice_space: 8, param: 36, curve: 'square' }, // Diffuse.fbank_res7 + { voice_space: 8, param: 38, curve: 'sqrt' }, // Diffuse.delay0_fb + { voice_space: 8, param: 40, curve: 'sqrt' }, // Diffuse.delay1_fb + { voice_space: 8, param: 42, curve: 'sqrt' }, // Diffuse.delay2_fb + { voice_space: 9, param: 21, curve: 'square' }, // Dark.fbank_f0 + { voice_space: 9, param: 22, curve: 'square' }, // Dark.fbank_f1 + { voice_space: 9, param: 23, curve: 'square' }, // Dark.fbank_f2 + { voice_space: 9, param: 24, curve: 'square' }, // Dark.fbank_f3 + { voice_space: 9, param: 25, curve: 'square' }, // Dark.fbank_f4 + { voice_space: 9, param: 26, curve: 'square' }, // Dark.fbank_f5 + { voice_space: 9, param: 27, curve: 'square' }, // Dark.fbank_f6 + { voice_space: 9, param: 28, curve: 'square' }, // Dark.fbank_f7 + { voice_space: 9, param: 29, curve: 'sqrt' }, // Dark.fbank_res0 + { voice_space: 9, param: 30, curve: 'sqrt' }, // Dark.fbank_res1 + { voice_space: 9, param: 31, curve: 'sqrt' }, // Dark.fbank_res2 + { voice_space: 9, param: 32, curve: 'sqrt' }, // Dark.fbank_res3 + { voice_space: 9, param: 33, curve: 'square' }, // Dark.fbank_res4 + { voice_space: 9, param: 34, curve: 'square' }, // Dark.fbank_res5 + { voice_space: 9, param: 35, curve: 'square' }, // Dark.fbank_res6 + { voice_space: 9, param: 36, curve: 'square' }, // Dark.fbank_res7 + { voice_space: 10, param: 21, curve: 'sqrt' }, // Bright.fbank_f0 + { voice_space: 10, param: 22, curve: 'sqrt' }, // Bright.fbank_f1 + { voice_space: 10, param: 23, curve: 'sqrt' }, // Bright.fbank_f2 + { voice_space: 10, param: 24, curve: 'sqrt' }, // Bright.fbank_f3 + { voice_space: 10, param: 25, curve: 'sqrt' }, // Bright.fbank_f4 + { voice_space: 10, param: 26, curve: 'sqrt' }, // Bright.fbank_f5 + { voice_space: 10, param: 27, curve: 'sqrt' }, // Bright.fbank_f6 + { voice_space: 10, param: 28, curve: 'sqrt' }, // Bright.fbank_f7 + { voice_space: 10, param: 29, curve: 'square' }, // Bright.fbank_res0 + { voice_space: 10, param: 30, curve: 'square' }, // Bright.fbank_res1 + { voice_space: 10, param: 31, curve: 'square' }, // Bright.fbank_res2 + { voice_space: 10, param: 32, curve: 'square' }, // Bright.fbank_res3 + { voice_space: 10, param: 33, curve: 'sqrt' }, // Bright.fbank_res4 + { voice_space: 10, param: 34, curve: 'sqrt' }, // Bright.fbank_res5 + { voice_space: 10, param: 35, curve: 'sqrt' }, // Bright.fbank_res6 + { voice_space: 10, param: 36, curve: 'sqrt' }, // Bright.fbank_res7 + { voice_space: 11, param: 29, curve: 'sqrt' }, // Harmonic.fbank_res0 + { voice_space: 11, param: 30, curve: 'sqrt' }, // Harmonic.fbank_res1 + { voice_space: 11, param: 31, curve: 'sqrt' }, // Harmonic.fbank_res2 + { voice_space: 11, param: 32, curve: 'sqrt' }, // Harmonic.fbank_res3 + { voice_space: 11, param: 33, curve: 'sqrt' }, // Harmonic.fbank_res4 + { voice_space: 11, param: 34, curve: 'sqrt' }, // Harmonic.fbank_res5 + { voice_space: 11, param: 35, curve: 'sqrt' }, // Harmonic.fbank_res6 + { voice_space: 11, param: 36, curve: 'sqrt' }, // Harmonic.fbank_res7 + ], ui: { primary_input: 'joystick', show_voice_space_selector: true, diff --git a/manifold/src/modes/generated/xiasri_schema.ts b/manifold/src/modes/generated/xiasri_schema.ts index 1ebd04e..88ccead 100644 --- a/manifold/src/modes/generated/xiasri_schema.ts +++ b/manifold/src/modes/generated/xiasri_schema.ts @@ -268,6 +268,7 @@ export const XiasriSchema: ModeSchema = { voice_spaces: [ 'Direct', ], + curve_overrides: [], ui: { primary_input: 'joystick', show_voice_space_selector: false, diff --git a/manifold/src/primitives/index.ts b/manifold/src/primitives/index.ts index 593ea23..ae32d5b 100644 --- a/manifold/src/primitives/index.ts +++ b/manifold/src/primitives/index.ts @@ -8,8 +8,10 @@ * register those rules. * * Panel / StatusLine / ControlAxis / CurvePlot / Sparkline were deleted - * 2026-07 (simplification audit L22) — zero consumers repo-wide. Sparkline/ - * CurvePlot may return with the deferred training-health diagnostics suite. + * 2026-07 (simplification audit L22) — zero consumers repo-wide. They stay + * deleted: the training-health diagnostics suite landed (§6.5e) and its loss + * plot is ~30 lines of inline SVG in `console/TrainingHealth.tsx`, its only + * consumer. Resurrect a primitive when a SECOND caller exists, not before. */ import '../styles/primitives.css'; diff --git a/manifold/tests/e2e/probe-api.spec.ts b/manifold/tests/e2e/probe-api.spec.ts index fc00b8a..a70edf2 100644 --- a/manifold/tests/e2e/probe-api.spec.ts +++ b/manifold/tests/e2e/probe-api.spec.ts @@ -141,6 +141,43 @@ test.describe('ML engine — debug probe contract', () => { expect(loss2).toBeLessThanOrEqual(loss1 + 1e-6); }); + test('getLossHistory returns the REAL per-iteration curve after a sync train', async ({ page }) => { + // Empty until something has actually trained — never a placeholder. + expect(await page.evaluate(() => window.__nisps!.getLossHistory().length)).toBe(0); + + const hist = await page.evaluate( + ([low, high]) => { + window.__nisps!.addExample(low.input, low.output); + window.__nisps!.addExample(high.input, high.output); + window.__nisps!.train(); + return Array.from(window.__nisps!.getLossHistory()); + }, + [EXAMPLE_LOW, EXAMPLE_HIGH], + ); + // The pre-§6.5e worker fabricated a 1-element "history" from the final loss. + expect(hist.length).toBeGreaterThan(1); + for (const v of hist) { + expect(Number.isFinite(v)).toBe(true); + expect(v).toBeGreaterThanOrEqual(0); + } + expect(hist[hist.length - 1]!).toBeLessThan(hist[0]!); + }); + + test('async training publishes the worker net\'s real loss curve too', async ({ page }) => { + const hist = await page.evaluate( + async ([low, high]) => { + window.__nisps!.addExample(low.input, low.output); + window.__nisps!.addExample(high.input, high.output); + await window.__nisps!.trainAsync(); + return Array.from(window.__nisps!.getLossHistory()); + }, + [EXAMPLE_LOW, EXAMPLE_HIGH], + ); + expect(hist.length).toBeGreaterThan(1); + for (const v of hist) expect(Number.isFinite(v)).toBe(true); + expect(hist[hist.length - 1]!).toBeLessThan(hist[0]!); + }); + test('async training resolves to a finite non-negative loss', async ({ page }) => { await page.evaluate( ([low, high]) => { diff --git a/manifold/tests/e2e/training-health.spec.ts b/manifold/tests/e2e/training-health.spec.ts new file mode 100644 index 0000000..f5d7268 --- /dev/null +++ b/manifold/tests/e2e/training-health.spec.ts @@ -0,0 +1,77 @@ +/** + * Training-health panel (simplification-plan §6.5e / ALIGNMENT defect 6). + * + * The point of the panel is that "is the network learning?" becomes GENUINELY + * answerable, so the test asserts two things a placeholder could not satisfy: + * + * 1. Before any training it says so plainly — no plot, no numbers. + * 2. After a real fit it reports the iteration count and the endpoints of the + * core's own loss curve, and draws a polyline with one vertex per + * iteration. + * + * It also pins the disclosure rule: the panel is advanced surface, so it lives + * at the Learning drawer's `expanded` depth (Manifold's existing DrawerDepth + * mechanism) and must NOT appear in the condensed panel. + */ +import { test, expect } from '@playwright/test'; +import { loadProbe } from './helpers'; +import { PafSynthSchema } from '../../src/modes/generated'; + +const N_OUTPUTS = PafSynthSchema.ml.output_size; +const LOW = { input: [0.1, 0.9], output: new Array(N_OUTPUTS).fill(0.1) }; +const HIGH = { input: [0.9, 0.1], output: new Array(N_OUTPUTS).fill(0.9) }; + +/** Open the Learning drawer and expand it to the advanced depth. */ +async function openLearningExpanded(page: import('@playwright/test').Page) { + await page.getByTitle('Learning', { exact: true }).click(); + await page.getByTitle('Expand', { exact: true }).click(); +} + +test.beforeEach(async ({ page }) => { + await loadProbe(page); +}); + +test('training health is advanced surface — absent from the condensed drawer', async ({ page }) => { + await page.getByTitle('Learning', { exact: true }).click(); + await expect(page.getByText('Training health')).toHaveCount(0); +}); + +test('with no training run the panel says so instead of drawing a curve', async ({ page }) => { + await openLearningExpanded(page); + await expect(page.getByText('Training health')).toBeVisible(); + await expect(page.getByText(/no training run yet/)).toBeVisible(); + await expect(page.locator('svg polyline')).toHaveCount(0); +}); + +test('after a real fit the panel reports the core loss curve', async ({ page }) => { + const hist = await page.evaluate( + ([low, high]) => { + window.__nisps!.addExample(low.input, low.output); + window.__nisps!.addExample(high.input, high.output); + window.__nisps!.train(); + return Array.from(window.__nisps!.getLossHistory()); + }, + [LOW, HIGH], + ); + expect(hist.length).toBeGreaterThan(1); + + await openLearningExpanded(page); + await expect(page.getByText(/no training run yet/)).toHaveCount(0); + await expect(page.getByText(`${hist.length} iter`)).toBeVisible(); + await expect(page.getByText(`start ${hist[0]!.toFixed(4)}`)).toBeVisible(); + await expect(page.getByText(`end ${hist[hist.length - 1]!.toFixed(4)}`)).toBeVisible(); + + // One polyline vertex per recorded iteration — the plot is the data, not decor. + const points = await page.locator('svg polyline').first().getAttribute('points'); + expect(points!.trim().split(/\s+/)).toHaveLength(hist.length); +}); + +test('layer stats show one row per layer with real weight-health numbers', async ({ page }) => { + const layers = await page.evaluate(() => window.__nisps!.describe().numLayers); + await openLearningExpanded(page); + const rows = page.locator('table tbody tr'); + await expect(rows).toHaveCount(layers); + // mean|w| of a freshly-drawn net is non-zero — the table is reading the net. + const meanAbs = await rows.first().locator('td').nth(1).innerText(); + expect(Number(meanAbs)).toBeGreaterThan(0); +}); diff --git a/manifold/tests/loss-history.test.ts b/manifold/tests/loss-history.test.ts new file mode 100644 index 0000000..8f42497 --- /dev/null +++ b/manifold/tests/loss-history.test.ts @@ -0,0 +1,134 @@ +/** + * Loss-history C-ABI contract (`bun test`). + * + * `nisps_ml_loss_history` is the browser's only honest answer to "is the + * network learning?" — it hands back the per-iteration curve the C++ core + * already records (`nisps::ml::MLPCore::loss_history`). Before §6.5e the + * worker fabricated a ONE-element "history" from the final loss, so the first + * assertion here is deliberately that the curve is longer than one entry. + * + * This test drives the committed `manifold/public/nisps.{js,wasm}` directly, + * which matters: `scripts/parity-check.sh` only exercises PAFSynth and + * ChannelStrip from an all-params-0.5 baseline and never touches the training + * path, so a parity PASS is no evidence for anything asserted below. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { beforeAll, expect, test } from 'bun:test'; + +interface EmModule { + HEAPF32: Float32Array; + _malloc(bytes: number): number; + _free(ptr: number): void; + cwrap(name: string, ret: string | null, args: string[]): (...a: number[]) => number; +} +type Factory = (opts: { wasmBinary: Uint8Array }) => Promise; + +let M: EmModule; +let mlCreate: (i: number, o: number, h: number, nh: number, seed: number) => number; +let mlDestroy: (ml: number) => number; +let mlAddExample: (ml: number, f: number, l: number) => number; +let mlTrain: (ml: number, lr: number, maxIter: number, minErr: number, sw: number) => number; +let mlLossHistory: (ml: number, out: number, max: number) => number; + +beforeAll(async () => { + // Same glue-loading dance as tests/wasm-load.ts: MODULARIZE output with no ES + // exports, in a `type:module` sub-package. + const dir = dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(join(dir, '..', 'public', 'nisps.js'), 'utf8'); + const factory = new Function( + 'module', 'exports', + `${source}\n;return typeof createNispsModule === 'function' ? createNispsModule : null;`, + )({ exports: {} }, {}) as Factory | null; + if (typeof factory !== 'function') throw new Error('createNispsModule not found in glue'); + M = await factory({ wasmBinary: readFileSync(join(dir, '..', 'public', 'nisps.wasm')) }); + + mlCreate = M.cwrap('nisps_ml_create', 'number', ['number', 'number', 'number', 'number', 'number']) as typeof mlCreate; + mlDestroy = M.cwrap('nisps_ml_destroy', null, ['number']) as typeof mlDestroy; + mlAddExample = M.cwrap('nisps_ml_add_example', null, ['number', 'number', 'number']) as typeof mlAddExample; + mlTrain = M.cwrap('nisps_ml_train', 'number', ['number', 'number', 'number', 'number', 'number']) as typeof mlTrain; + mlLossHistory = M.cwrap('nisps_ml_loss_history', 'number', ['number', 'number', 'number']) as typeof mlLossHistory; +}); + +/** A 2→1 net fed the XOR table; returns the handle (caller destroys). */ +function trainedNet(maxIter: number, minErr = 0): { ml: number; loss: number } { + const ml = mlCreate(2, 1, 0, 0, 7); + const f = M._malloc(2 * 4); + const l = M._malloc(1 * 4); + for (const [x, y, t] of [[0, 0, 0], [0, 1, 1], [1, 0, 1], [1, 1, 0]]) { + new Float32Array(M.HEAPF32.buffer, f, 2).set([x, y]); + new Float32Array(M.HEAPF32.buffer, l, 1).set([t]); + mlAddExample(ml, f, l); + } + M._free(f); + M._free(l); + return { ml, loss: mlTrain(ml, 0.5, maxIter, minErr, 0) }; +} + +function readHistory(ml: number, cap?: number): number[] { + const total = mlLossHistory(ml, 0, 0); + const n = cap ?? total; + if (n <= 0) return []; + const ptr = M._malloc(n * 4); + mlLossHistory(ml, ptr, n); + const out = Array.from(new Float32Array(M.HEAPF32.buffer, ptr, n)); + M._free(ptr); + return out; +} + +test('an untrained handle reports an empty history', () => { + const ml = mlCreate(2, 1, 0, 0, 7); + expect(mlLossHistory(ml, 0, 0)).toBe(0); + mlDestroy(ml); +}); + +test('a training run records ONE entry per iteration, not a 1-element fake', () => { + const { ml, loss } = trainedNet(40); + const count = mlLossHistory(ml, 0, 0); + expect(count).toBe(40); + // The pre-§6.5e worker synthesised `new Float32Array([loss])`. + expect(count).toBeGreaterThan(1); + + const hist = readHistory(ml); + expect(hist).toHaveLength(40); + for (const v of hist) expect(Number.isFinite(v)).toBe(true); + // The last recorded epoch loss IS what train() returned. + expect(Math.abs(hist[39]! - loss)).toBeLessThan(1e-6); + // A real fit descends. + expect(hist[39]!).toBeLessThan(hist[0]!); + mlDestroy(ml); +}); + +test('a truncated read returns the TOTAL count and fills the prefix', () => { + const { ml } = trainedNet(40); + const full = readHistory(ml); + const ptr = M._malloc(5 * 4); + new Float32Array(M.HEAPF32.buffer, ptr, 5).fill(-1); + const total = mlLossHistory(ml, ptr, 5); + const partial = Array.from(new Float32Array(M.HEAPF32.buffer, ptr, 5)); + M._free(ptr); + + expect(total).toBe(40); // total available, not the number written + expect(partial).toEqual(full.slice(0, 5)); + mlDestroy(ml); +}); + +test('early convergence truncates the curve to the iterations actually run', () => { + // An absurd min_err makes train() break after the first iteration. + const { ml } = trainedNet(50, 1e9); + expect(mlLossHistory(ml, 0, 0)).toBe(1); + mlDestroy(ml); +}); + +test('a fresh run REPLACES the curve rather than appending to it', () => { + const { ml } = trainedNet(40); + expect(mlLossHistory(ml, 0, 0)).toBe(40); + mlTrain(ml, 0.5, 3, 0, 0); + expect(mlLossHistory(ml, 0, 0)).toBe(3); + mlDestroy(ml); +}); + +test('a null handle is safe and reports nothing', () => { + expect(mlLossHistory(0, 0, 0)).toBe(0); +}); diff --git a/nisps/CMakeLists.txt b/nisps/CMakeLists.txt index 766b9d7..7d34d41 100644 --- a/nisps/CMakeLists.txt +++ b/nisps/CMakeLists.txt @@ -123,8 +123,15 @@ if(NOT EMSCRIPTEN) ${NISPS_TEST_DIR}/test_mode_voice_space.cpp ${NISPS_TEST_DIR}/test_mode_breakor_events.cpp ${NISPS_TEST_DIR}/test_mode_learning.cpp + ${NISPS_TEST_DIR}/test_mode_curve_overrides.cpp + ${NISPS_TEST_DIR}/test_mode_driver_config.cpp ) target_link_libraries(nisps_modes_tests PRIVATE nisps_core) + # test_mode_driver_config.cpp also covers the firmware-side codec clamping + # (firmware/MEMLNaut-NISPS/glue/codec_config.hpp — deliberately Arduino-free + # so it is host-testable). That header includes "nisps/core/types.hpp" the + # way the firmware does, so the repo root has to be on the include path. + target_include_directories(nisps_modes_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/..) if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") target_compile_options(nisps_modes_tests PRIVATE @@ -163,6 +170,25 @@ if(NOT EMSCRIPTEN) WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/.. ) + # --------------------------------------------------------------------- + # Standalone engine throughput benchmark. NOT registered with ctest: it + # measures time and asserts nothing, so it has no pass/fail to report and + # a per-block timing loop has no place in a gate every change pays for. + # Driven by scripts/bench-engines.sh, which also compiles the SAME source + # to WASM via emcc for the cross-target comparison. + # --------------------------------------------------------------------- + add_executable(nisps_engine_bench + ${NISPS_TEST_DIR}/engine_bench.cpp + ) + target_link_libraries(nisps_engine_bench PRIVATE nisps_core) + if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang") + target_compile_options(nisps_engine_bench PRIVATE + -Wall -Wextra -Werror -Wpedantic -O3 + ) + elseif(MSVC) + target_compile_options(nisps_engine_bench PRIVATE /W4 /WX) + endif() + # Standalone parity-check runner. NOT registered with ctest — it's # invoked from scripts/parity-check.sh which orchestrates native+WASM # together. diff --git a/nisps/core/concepts.hpp b/nisps/core/concepts.hpp index e42a74d..2b6dbea 100644 --- a/nisps/core/concepts.hpp +++ b/nisps/core/concepts.hpp @@ -73,6 +73,11 @@ concept Mode = requires(T m, std::size_t idx, float v, stereosample_t s, float s { T::input_channel_count() } -> std::convertible_to; // constexpr { T::param_schema() } -> std::same_as; // constexpr ref { m.setup(sr) } -> std::same_as; + // Audio-driver setup the mode wants (codec input source / gain staging / + // sample rate). Defaults to the mode's audio engine's config; a mode whose + // audio INPUT is consumed by something other than that engine overrides it + // (see ModeBase::driver_config). Platform glue reads this at mode start. + { m.driver_config() } -> std::convertible_to; { m.set_input(idx, v) } -> std::same_as; { m.tick_control() } -> std::same_as; // non-RT { m.process(s) } -> std::same_as; // RT diff --git a/nisps/core/types.hpp b/nisps/core/types.hpp index 5dacc3b..aa33cc9 100644 --- a/nisps/core/types.hpp +++ b/nisps/core/types.hpp @@ -53,14 +53,20 @@ struct stereosample_t { // // `mic_input` true ⇒ codec is configured for mic-level input // `mic_gain_db` dB of pre-amp gain when `mic_input` is true -// `line_level` 1..15 ish — codec line-input gain step +// `line_level` 0..15 — codec line-input full-scale step (0 = 3.12 Vp-p / +// least sensitive, 15 = 0.24 Vp-p / most sensitive) // `output_volume` 0..1 — analog out master // `sample_rate` preferred rate (Hz); 0 means "don't care" +// +// The member defaults ARE the "engine expresses no opinion" configuration: +// they reproduce the firmware's historical hardcoded codec setup +// (`AudioDriver::Setup()` in memllib), so wiring a mode's config through the +// driver is a no-op for an engine that declares nothing. struct DriverConfig { bool mic_input = false; std::uint8_t mic_gain_db = 0; - std::uint8_t line_level = 0; - float output_volume = 1.f; + std::uint8_t line_level = 3; + float output_volume = 0.8f; float sample_rate = 0.f; }; diff --git a/nisps/modes/base.hpp b/nisps/modes/base.hpp index 2f6e430..49d6f47 100644 --- a/nisps/modes/base.hpp +++ b/nisps/modes/base.hpp @@ -82,6 +82,8 @@ struct ModeRoutesOutputsToEngine : std::true_type {}; // void on_setup(float sample_rate) noexcept // optional hook // void on_pre_inference() noexcept // optional, before ml_.process() // void on_post_inference() noexcept // optional, after engine.set_params() +// DriverConfig on_driver_config() const noexcept // optional, overrides the +// // engine's driver config // // Derived classes may choose the engine type (`EngineT`) and ML type // (`MLPType`) freely; both must satisfy `MLEngine` and `AudioEngine` @@ -139,6 +141,27 @@ class ModeBase { } } + // ---- Audio-driver configuration ---- + // + // What the platform's audio driver should be set up as for this mode: + // codec input source (mic vs line), gain staging, preferred sample rate. + // Firmware glue reads this at mode start; see + // firmware/MEMLNaut-NISPS/glue/audio_driver.hpp. + // + // Default = whatever the mode's audio engine advertises, so a mode that + // does not care says NOTHING and inherits the engine's (or, for + // NoOpEngine, `DriverConfig{}`'s) values. A mode whose audio INPUT is + // consumed by something other than `engine_` — e.g. SoundAnalysisMIDIMode, + // whose engine is a silent NoOp while a separately-composed AnalysisEngine + // owns the microphone — declares `on_driver_config()` and that wins. + DriverConfig driver_config() const noexcept { + if constexpr (requires(const Derived& d) { d.on_driver_config(); }) { + return static_cast(*this).on_driver_config(); + } else { + return engine_.driver_config(); + } + } + NISPS_FORCE_INLINE void set_input(std::size_t idx, float value) noexcept { if (idx >= NInputs) return; if (value < 0.f) value = 0.f; diff --git a/nisps/modes/external_synth_midi.hpp b/nisps/modes/external_synth_midi.hpp index 4fcdd7a..c855560 100644 --- a/nisps/modes/external_synth_midi.hpp +++ b/nisps/modes/external_synth_midi.hpp @@ -177,6 +177,7 @@ class ExternalSynthMIDIMode : public ModeBase< static constexpr std::array kNoParams{}; static constexpr std::array kNoVoiceSpaces{}; + static constexpr std::array kNoCurveOverrides{}; static constexpr generated::UIConfig kUI{generated::PrimaryInput::Joystick, false, false}; static inline constexpr ParamSchema kSchema = ParamSchema{ @@ -189,6 +190,7 @@ class ExternalSynthMIDIMode : public ModeBase< ext_synth_defaults::kDefaultSpread, std::span(kNoParams), std::span(kNoVoiceSpaces), + std::span(kNoCurveOverrides), kUI, }; }; diff --git a/nisps/modes/generated/breakor_schema.hpp b/nisps/modes/generated/breakor_schema.hpp index 444dea1..0e0abae 100644 --- a/nisps/modes/generated/breakor_schema.hpp +++ b/nisps/modes/generated/breakor_schema.hpp @@ -540,6 +540,8 @@ inline constexpr std::array kBreakorParams = {{ inline constexpr std::size_t kBreakorVoiceSpaceCount = 0u; inline constexpr std::array kBreakorVoiceSpaces = {}; +inline constexpr std::array kBreakorCurveOverrides = {}; + inline constexpr UIConfig kBreakorUI = { PrimaryInput::XYPad, false, @@ -558,6 +560,7 @@ inline constexpr ::nisps::ParamSchema kBreakorSchema = { kBreakorMLConfig.default_spread, std::span(kBreakorParams), std::span(kBreakorVoiceSpaces), + std::span(kBreakorCurveOverrides), kBreakorUI, }; diff --git a/nisps/modes/generated/channel_strip_schema.hpp b/nisps/modes/generated/channel_strip_schema.hpp index 57ca4c2..f97569d 100644 --- a/nisps/modes/generated/channel_strip_schema.hpp +++ b/nisps/modes/generated/channel_strip_schema.hpp @@ -259,6 +259,20 @@ inline constexpr std::array kCha "Neve 80", }}; +inline constexpr std::array kChannelStripCurveOverrides = {{ + CurveOverride{1u, 11u, Curve::square}, // SSL 4K G-ist.comp_ratio + CurveOverride{2u, 11u, Curve::square}, // SSL 9K-inda.comp_ratio + CurveOverride{3u, 13u, Curve::linear}, // MaleVox.comp_release + CurveOverride{4u, 13u, Curve::linear}, // FemaleVox.comp_release + CurveOverride{5u, 1u, Curve::linear}, // Neve 80.peak0_freq + CurveOverride{5u, 4u, Curve::linear}, // Neve 80.peak1_freq + CurveOverride{5u, 7u, Curve::linear}, // Neve 80.in_lpf_cutoff + CurveOverride{5u, 8u, Curve::linear}, // Neve 80.in_hpf_cutoff + CurveOverride{5u, 13u, Curve::linear}, // Neve 80.comp_release + CurveOverride{5u, 14u, Curve::linear}, // Neve 80.lowshelf_freq + CurveOverride{5u, 17u, Curve::linear}, // Neve 80.highshelf_freq +}}; + inline constexpr UIConfig kChannelStripUI = { PrimaryInput::Joystick, true, @@ -277,6 +291,7 @@ inline constexpr ::nisps::ParamSchema kChannelStripSchema = { kChannelStripMLConfig.default_spread, std::span(kChannelStripParams), std::span(kChannelStripVoiceSpaces), + std::span(kChannelStripCurveOverrides), kChannelStripUI, }; diff --git a/nisps/modes/generated/elysiamorf_schema.hpp b/nisps/modes/generated/elysiamorf_schema.hpp index 1e1a1e9..fd8148b 100644 --- a/nisps/modes/generated/elysiamorf_schema.hpp +++ b/nisps/modes/generated/elysiamorf_schema.hpp @@ -396,6 +396,8 @@ inline constexpr std::array kElysiamorfParams = {{ inline constexpr std::size_t kElysiamorfVoiceSpaceCount = 0u; inline constexpr std::array kElysiamorfVoiceSpaces = {}; +inline constexpr std::array kElysiamorfCurveOverrides = {}; + inline constexpr UIConfig kElysiamorfUI = { PrimaryInput::XYPad, false, @@ -414,6 +416,7 @@ inline constexpr ::nisps::ParamSchema kElysiamorfSchema = { kElysiamorfMLConfig.default_spread, std::span(kElysiamorfParams), std::span(kElysiamorfVoiceSpaces), + std::span(kElysiamorfCurveOverrides), kElysiamorfUI, }; diff --git a/nisps/modes/generated/memlcelium_schema.hpp b/nisps/modes/generated/memlcelium_schema.hpp index c232a15..4abe183 100644 --- a/nisps/modes/generated/memlcelium_schema.hpp +++ b/nisps/modes/generated/memlcelium_schema.hpp @@ -542,6 +542,8 @@ inline constexpr std::array kMemlc "Direct", }}; +inline constexpr std::array kMemlceliumCurveOverrides = {}; + inline constexpr UIConfig kMemlceliumUI = { PrimaryInput::XYPad, false, @@ -560,6 +562,7 @@ inline constexpr ::nisps::ParamSchema kMemlceliumSchema = { kMemlceliumMLConfig.default_spread, std::span(kMemlceliumParams), std::span(kMemlceliumVoiceSpaces), + std::span(kMemlceliumCurveOverrides), kMemlceliumUI, }; diff --git a/nisps/modes/generated/paf_synth_schema.hpp b/nisps/modes/generated/paf_synth_schema.hpp index a202a12..e19a6da 100644 --- a/nisps/modes/generated/paf_synth_schema.hpp +++ b/nisps/modes/generated/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/nisps/modes/generated/schema_types.hpp b/nisps/modes/generated/schema_types.hpp index 1dbe2c0..ad50ee5 100644 --- a/nisps/modes/generated/schema_types.hpp +++ b/nisps/modes/generated/schema_types.hpp @@ -33,6 +33,17 @@ struct Param { 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; @@ -73,9 +84,21 @@ struct ParamSchema { 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 diff --git a/nisps/modes/generated/slp_workshop_schema.hpp b/nisps/modes/generated/slp_workshop_schema.hpp index 941c9c3..fc068d2 100644 --- a/nisps/modes/generated/slp_workshop_schema.hpp +++ b/nisps/modes/generated/slp_workshop_schema.hpp @@ -542,6 +542,8 @@ inline constexpr std::array kSlpW "Direct", }}; +inline constexpr std::array kSlpWorkshopCurveOverrides = {}; + inline constexpr UIConfig kSlpWorkshopUI = { PrimaryInput::XYPad, false, @@ -560,6 +562,7 @@ inline constexpr ::nisps::ParamSchema kSlpWorkshopSchema = { kSlpWorkshopMLConfig.default_spread, std::span(kSlpWorkshopParams), std::span(kSlpWorkshopVoiceSpaces), + std::span(kSlpWorkshopCurveOverrides), kSlpWorkshopUI, }; diff --git a/nisps/modes/generated/sound_analysis_midi_schema.hpp b/nisps/modes/generated/sound_analysis_midi_schema.hpp index 44b62dc..f83bdff 100644 --- a/nisps/modes/generated/sound_analysis_midi_schema.hpp +++ b/nisps/modes/generated/sound_analysis_midi_schema.hpp @@ -114,6 +114,8 @@ inline constexpr std::array kSoundAnalysisM inline constexpr std::size_t kSoundAnalysisMidiVoiceSpaceCount = 0u; inline constexpr std::array kSoundAnalysisMidiVoiceSpaces = {}; +inline constexpr std::array kSoundAnalysisMidiCurveOverrides = {}; + inline constexpr UIConfig kSoundAnalysisMidiUI = { PrimaryInput::AudioIn, false, @@ -132,6 +134,7 @@ inline constexpr ::nisps::ParamSchema kSoundAnalysisMidiSchema = { kSoundAnalysisMidiMLConfig.default_spread, std::span(kSoundAnalysisMidiParams), std::span(kSoundAnalysisMidiVoiceSpaces), + std::span(kSoundAnalysisMidiCurveOverrides), kSoundAnalysisMidiUI, }; diff --git a/nisps/modes/generated/verb_fx_schema.hpp b/nisps/modes/generated/verb_fx_schema.hpp index 6604ea6..1fc6607 100644 --- a/nisps/modes/generated/verb_fx_schema.hpp +++ b/nisps/modes/generated/verb_fx_schema.hpp @@ -472,6 +472,203 @@ inline constexpr std::array kVerbFxVoi "Harmonic", }}; +inline constexpr std::array kVerbFxCurveOverrides = {{ + CurveOverride{1u, 29u, Curve::sqrt}, // Resonant.fbank_res0 + CurveOverride{1u, 30u, Curve::sqrt}, // Resonant.fbank_res1 + CurveOverride{1u, 31u, Curve::sqrt}, // Resonant.fbank_res2 + CurveOverride{1u, 32u, Curve::sqrt}, // Resonant.fbank_res3 + CurveOverride{1u, 33u, Curve::sqrt}, // Resonant.fbank_res4 + CurveOverride{1u, 34u, Curve::sqrt}, // Resonant.fbank_res5 + CurveOverride{1u, 35u, Curve::sqrt}, // Resonant.fbank_res6 + CurveOverride{1u, 36u, Curve::sqrt}, // Resonant.fbank_res7 + CurveOverride{2u, 1u, Curve::square}, // Soft.lp0_fb + CurveOverride{2u, 3u, Curve::square}, // Soft.lp1_fb + CurveOverride{2u, 5u, Curve::square}, // Soft.lp2_fb + CurveOverride{2u, 7u, Curve::square}, // Soft.lp3_fb + CurveOverride{2u, 9u, Curve::square}, // Soft.lp4_fb + CurveOverride{2u, 11u, Curve::square}, // Soft.lp5_fb + CurveOverride{2u, 13u, Curve::square}, // Soft.lp6_fb + CurveOverride{2u, 15u, Curve::square}, // Soft.lp7_fb + CurveOverride{2u, 17u, Curve::square}, // Soft.allp0_fb + CurveOverride{2u, 18u, Curve::square}, // Soft.allp1_fb + CurveOverride{2u, 19u, Curve::square}, // Soft.allp2_fb + CurveOverride{2u, 20u, Curve::square}, // Soft.allp3_fb + CurveOverride{2u, 29u, Curve::square}, // Soft.fbank_res0 + CurveOverride{2u, 30u, Curve::square}, // Soft.fbank_res1 + CurveOverride{2u, 31u, Curve::square}, // Soft.fbank_res2 + CurveOverride{2u, 32u, Curve::square}, // Soft.fbank_res3 + CurveOverride{2u, 33u, Curve::square}, // Soft.fbank_res4 + CurveOverride{2u, 34u, Curve::square}, // Soft.fbank_res5 + CurveOverride{2u, 35u, Curve::square}, // Soft.fbank_res6 + CurveOverride{2u, 36u, Curve::square}, // Soft.fbank_res7 + CurveOverride{2u, 38u, Curve::square}, // Soft.delay0_fb + CurveOverride{2u, 40u, Curve::square}, // Soft.delay1_fb + CurveOverride{2u, 42u, Curve::square}, // Soft.delay2_fb + CurveOverride{3u, 1u, Curve::sqrt}, // Cathedral.lp0_fb + CurveOverride{3u, 3u, Curve::sqrt}, // Cathedral.lp1_fb + CurveOverride{3u, 5u, Curve::sqrt}, // Cathedral.lp2_fb + CurveOverride{3u, 7u, Curve::sqrt}, // Cathedral.lp3_fb + CurveOverride{3u, 9u, Curve::sqrt}, // Cathedral.lp4_fb + CurveOverride{3u, 11u, Curve::sqrt}, // Cathedral.lp5_fb + CurveOverride{3u, 13u, Curve::sqrt}, // Cathedral.lp6_fb + CurveOverride{3u, 15u, Curve::sqrt}, // Cathedral.lp7_fb + CurveOverride{3u, 17u, Curve::sqrt}, // Cathedral.allp0_fb + CurveOverride{3u, 18u, Curve::sqrt}, // Cathedral.allp1_fb + CurveOverride{3u, 19u, Curve::sqrt}, // Cathedral.allp2_fb + CurveOverride{3u, 20u, Curve::sqrt}, // Cathedral.allp3_fb + CurveOverride{3u, 37u, Curve::sqrt}, // Cathedral.delay0_time + CurveOverride{3u, 38u, Curve::sqrt}, // Cathedral.delay0_fb + CurveOverride{3u, 39u, Curve::sqrt}, // Cathedral.delay1_time + CurveOverride{3u, 40u, Curve::sqrt}, // Cathedral.delay1_fb + CurveOverride{3u, 41u, Curve::sqrt}, // Cathedral.delay2_time + CurveOverride{3u, 42u, Curve::sqrt}, // Cathedral.delay2_fb + CurveOverride{3u, 43u, Curve::square}, // Cathedral.verb_vs_delay + CurveOverride{4u, 1u, Curve::sqrt}, // Shimmer.lp0_fb + CurveOverride{4u, 3u, Curve::sqrt}, // Shimmer.lp1_fb + CurveOverride{4u, 5u, Curve::sqrt}, // Shimmer.lp2_fb + CurveOverride{4u, 7u, Curve::sqrt}, // Shimmer.lp3_fb + CurveOverride{4u, 9u, Curve::sqrt}, // Shimmer.lp4_fb + CurveOverride{4u, 11u, Curve::sqrt}, // Shimmer.lp5_fb + CurveOverride{4u, 13u, Curve::sqrt}, // Shimmer.lp6_fb + CurveOverride{4u, 15u, Curve::sqrt}, // Shimmer.lp7_fb + CurveOverride{4u, 17u, Curve::sqrt}, // Shimmer.allp0_fb + CurveOverride{4u, 18u, Curve::sqrt}, // Shimmer.allp1_fb + CurveOverride{4u, 19u, Curve::sqrt}, // Shimmer.allp2_fb + CurveOverride{4u, 20u, Curve::sqrt}, // Shimmer.allp3_fb + CurveOverride{4u, 29u, Curve::sqrt}, // Shimmer.fbank_res0 + CurveOverride{4u, 30u, Curve::sqrt}, // Shimmer.fbank_res1 + CurveOverride{4u, 31u, Curve::sqrt}, // Shimmer.fbank_res2 + CurveOverride{4u, 32u, Curve::sqrt}, // Shimmer.fbank_res3 + CurveOverride{4u, 33u, Curve::sqrt}, // Shimmer.fbank_res4 + CurveOverride{4u, 34u, Curve::sqrt}, // Shimmer.fbank_res5 + CurveOverride{4u, 35u, Curve::sqrt}, // Shimmer.fbank_res6 + CurveOverride{4u, 36u, Curve::sqrt}, // Shimmer.fbank_res7 + CurveOverride{4u, 38u, Curve::sqrt}, // Shimmer.delay0_fb + CurveOverride{4u, 40u, Curve::sqrt}, // Shimmer.delay1_fb + CurveOverride{4u, 42u, Curve::sqrt}, // Shimmer.delay2_fb + CurveOverride{4u, 43u, Curve::square}, // Shimmer.verb_vs_delay + CurveOverride{5u, 1u, Curve::square}, // Chamber.lp0_fb + CurveOverride{5u, 3u, Curve::square}, // Chamber.lp1_fb + CurveOverride{5u, 5u, Curve::square}, // Chamber.lp2_fb + CurveOverride{5u, 7u, Curve::square}, // Chamber.lp3_fb + CurveOverride{5u, 9u, Curve::square}, // Chamber.lp4_fb + CurveOverride{5u, 11u, Curve::square}, // Chamber.lp5_fb + CurveOverride{5u, 13u, Curve::square}, // Chamber.lp6_fb + CurveOverride{5u, 15u, Curve::square}, // Chamber.lp7_fb + CurveOverride{5u, 17u, Curve::square}, // Chamber.allp0_fb + CurveOverride{5u, 18u, Curve::square}, // Chamber.allp1_fb + CurveOverride{5u, 19u, Curve::square}, // Chamber.allp2_fb + CurveOverride{5u, 20u, Curve::square}, // Chamber.allp3_fb + CurveOverride{5u, 29u, Curve::square}, // Chamber.fbank_res0 + CurveOverride{5u, 30u, Curve::square}, // Chamber.fbank_res1 + CurveOverride{5u, 31u, Curve::square}, // Chamber.fbank_res2 + CurveOverride{5u, 32u, Curve::square}, // Chamber.fbank_res3 + CurveOverride{5u, 33u, Curve::square}, // Chamber.fbank_res4 + CurveOverride{5u, 34u, Curve::square}, // Chamber.fbank_res5 + CurveOverride{5u, 35u, Curve::square}, // Chamber.fbank_res6 + CurveOverride{5u, 36u, Curve::square}, // Chamber.fbank_res7 + CurveOverride{5u, 37u, Curve::square}, // Chamber.delay0_time + CurveOverride{5u, 38u, Curve::square}, // Chamber.delay0_fb + CurveOverride{5u, 39u, Curve::square}, // Chamber.delay1_time + CurveOverride{5u, 40u, Curve::square}, // Chamber.delay1_fb + CurveOverride{5u, 41u, Curve::square}, // Chamber.delay2_time + CurveOverride{5u, 42u, Curve::square}, // Chamber.delay2_fb + CurveOverride{6u, 17u, Curve::sqrt}, // Metallic.allp0_fb + CurveOverride{6u, 18u, Curve::sqrt}, // Metallic.allp1_fb + CurveOverride{6u, 19u, Curve::sqrt}, // Metallic.allp2_fb + CurveOverride{6u, 20u, Curve::sqrt}, // Metallic.allp3_fb + CurveOverride{6u, 29u, Curve::sqrt}, // Metallic.fbank_res0 + CurveOverride{6u, 30u, Curve::square}, // Metallic.fbank_res1 + CurveOverride{6u, 31u, Curve::sqrt}, // Metallic.fbank_res2 + CurveOverride{6u, 32u, Curve::square}, // Metallic.fbank_res3 + CurveOverride{6u, 33u, Curve::sqrt}, // Metallic.fbank_res4 + CurveOverride{6u, 34u, Curve::square}, // Metallic.fbank_res5 + CurveOverride{6u, 35u, Curve::sqrt}, // Metallic.fbank_res6 + CurveOverride{6u, 36u, Curve::square}, // Metallic.fbank_res7 + CurveOverride{7u, 1u, Curve::square}, // Granular.lp0_fb + CurveOverride{7u, 3u, Curve::square}, // Granular.lp1_fb + CurveOverride{7u, 5u, Curve::square}, // Granular.lp2_fb + CurveOverride{7u, 7u, Curve::square}, // Granular.lp3_fb + CurveOverride{7u, 9u, Curve::square}, // Granular.lp4_fb + CurveOverride{7u, 11u, Curve::square}, // Granular.lp5_fb + CurveOverride{7u, 13u, Curve::square}, // Granular.lp6_fb + CurveOverride{7u, 15u, Curve::square}, // Granular.lp7_fb + CurveOverride{7u, 17u, Curve::square}, // Granular.allp0_fb + CurveOverride{7u, 18u, Curve::square}, // Granular.allp1_fb + CurveOverride{7u, 19u, Curve::square}, // Granular.allp2_fb + CurveOverride{7u, 20u, Curve::square}, // Granular.allp3_fb + CurveOverride{7u, 29u, Curve::square}, // Granular.fbank_res0 + CurveOverride{7u, 30u, Curve::square}, // Granular.fbank_res1 + CurveOverride{7u, 31u, Curve::square}, // Granular.fbank_res2 + CurveOverride{7u, 32u, Curve::square}, // Granular.fbank_res3 + CurveOverride{7u, 33u, Curve::square}, // Granular.fbank_res4 + CurveOverride{7u, 34u, Curve::square}, // Granular.fbank_res5 + CurveOverride{7u, 35u, Curve::square}, // Granular.fbank_res6 + CurveOverride{7u, 36u, Curve::square}, // Granular.fbank_res7 + CurveOverride{7u, 38u, Curve::square}, // Granular.delay0_fb + CurveOverride{7u, 40u, Curve::square}, // Granular.delay1_fb + CurveOverride{7u, 42u, Curve::sqrt}, // Granular.delay2_fb + CurveOverride{7u, 43u, Curve::sqrt}, // Granular.verb_vs_delay + CurveOverride{7u, 45u, Curve::square}, // Granular.delay_morph + CurveOverride{7u, 46u, Curve::sqrt}, // Granular.delay_blend + CurveOverride{8u, 0u, Curve::sqrt}, // Diffuse.fb_delay_xfade + CurveOverride{8u, 17u, Curve::sqrt}, // Diffuse.allp0_fb + CurveOverride{8u, 18u, Curve::sqrt}, // Diffuse.allp1_fb + CurveOverride{8u, 19u, Curve::sqrt}, // Diffuse.allp2_fb + CurveOverride{8u, 20u, Curve::sqrt}, // Diffuse.allp3_fb + CurveOverride{8u, 29u, Curve::square}, // Diffuse.fbank_res0 + CurveOverride{8u, 30u, Curve::square}, // Diffuse.fbank_res1 + CurveOverride{8u, 31u, Curve::square}, // Diffuse.fbank_res2 + CurveOverride{8u, 32u, Curve::square}, // Diffuse.fbank_res3 + CurveOverride{8u, 33u, Curve::square}, // Diffuse.fbank_res4 + CurveOverride{8u, 34u, Curve::square}, // Diffuse.fbank_res5 + CurveOverride{8u, 35u, Curve::square}, // Diffuse.fbank_res6 + CurveOverride{8u, 36u, Curve::square}, // Diffuse.fbank_res7 + CurveOverride{8u, 38u, Curve::sqrt}, // Diffuse.delay0_fb + CurveOverride{8u, 40u, Curve::sqrt}, // Diffuse.delay1_fb + CurveOverride{8u, 42u, Curve::sqrt}, // Diffuse.delay2_fb + CurveOverride{9u, 21u, Curve::square}, // Dark.fbank_f0 + CurveOverride{9u, 22u, Curve::square}, // Dark.fbank_f1 + CurveOverride{9u, 23u, Curve::square}, // Dark.fbank_f2 + CurveOverride{9u, 24u, Curve::square}, // Dark.fbank_f3 + CurveOverride{9u, 25u, Curve::square}, // Dark.fbank_f4 + CurveOverride{9u, 26u, Curve::square}, // Dark.fbank_f5 + CurveOverride{9u, 27u, Curve::square}, // Dark.fbank_f6 + CurveOverride{9u, 28u, Curve::square}, // Dark.fbank_f7 + CurveOverride{9u, 29u, Curve::sqrt}, // Dark.fbank_res0 + CurveOverride{9u, 30u, Curve::sqrt}, // Dark.fbank_res1 + CurveOverride{9u, 31u, Curve::sqrt}, // Dark.fbank_res2 + CurveOverride{9u, 32u, Curve::sqrt}, // Dark.fbank_res3 + CurveOverride{9u, 33u, Curve::square}, // Dark.fbank_res4 + CurveOverride{9u, 34u, Curve::square}, // Dark.fbank_res5 + CurveOverride{9u, 35u, Curve::square}, // Dark.fbank_res6 + CurveOverride{9u, 36u, Curve::square}, // Dark.fbank_res7 + CurveOverride{10u, 21u, Curve::sqrt}, // Bright.fbank_f0 + CurveOverride{10u, 22u, Curve::sqrt}, // Bright.fbank_f1 + CurveOverride{10u, 23u, Curve::sqrt}, // Bright.fbank_f2 + CurveOverride{10u, 24u, Curve::sqrt}, // Bright.fbank_f3 + CurveOverride{10u, 25u, Curve::sqrt}, // Bright.fbank_f4 + CurveOverride{10u, 26u, Curve::sqrt}, // Bright.fbank_f5 + CurveOverride{10u, 27u, Curve::sqrt}, // Bright.fbank_f6 + CurveOverride{10u, 28u, Curve::sqrt}, // Bright.fbank_f7 + CurveOverride{10u, 29u, Curve::square}, // Bright.fbank_res0 + CurveOverride{10u, 30u, Curve::square}, // Bright.fbank_res1 + CurveOverride{10u, 31u, Curve::square}, // Bright.fbank_res2 + CurveOverride{10u, 32u, Curve::square}, // Bright.fbank_res3 + CurveOverride{10u, 33u, Curve::sqrt}, // Bright.fbank_res4 + CurveOverride{10u, 34u, Curve::sqrt}, // Bright.fbank_res5 + CurveOverride{10u, 35u, Curve::sqrt}, // Bright.fbank_res6 + CurveOverride{10u, 36u, Curve::sqrt}, // Bright.fbank_res7 + CurveOverride{11u, 29u, Curve::sqrt}, // Harmonic.fbank_res0 + CurveOverride{11u, 30u, Curve::sqrt}, // Harmonic.fbank_res1 + CurveOverride{11u, 31u, Curve::sqrt}, // Harmonic.fbank_res2 + CurveOverride{11u, 32u, Curve::sqrt}, // Harmonic.fbank_res3 + CurveOverride{11u, 33u, Curve::sqrt}, // Harmonic.fbank_res4 + CurveOverride{11u, 34u, Curve::sqrt}, // Harmonic.fbank_res5 + CurveOverride{11u, 35u, Curve::sqrt}, // Harmonic.fbank_res6 + CurveOverride{11u, 36u, Curve::sqrt}, // Harmonic.fbank_res7 +}}; + inline constexpr UIConfig kVerbFxUI = { PrimaryInput::Joystick, true, @@ -490,6 +687,7 @@ inline constexpr ::nisps::ParamSchema kVerbFxSchema = { kVerbFxMLConfig.default_spread, std::span(kVerbFxParams), std::span(kVerbFxVoiceSpaces), + std::span(kVerbFxCurveOverrides), kVerbFxUI, }; diff --git a/nisps/modes/generated/xiasri_schema.hpp b/nisps/modes/generated/xiasri_schema.hpp index d300045..e90535f 100644 --- a/nisps/modes/generated/xiasri_schema.hpp +++ b/nisps/modes/generated/xiasri_schema.hpp @@ -254,6 +254,8 @@ inline constexpr std::array kXiasriVoi "Direct", }}; +inline constexpr std::array kXiasriCurveOverrides = {}; + inline constexpr UIConfig kXiasriUI = { PrimaryInput::Joystick, false, @@ -272,6 +274,7 @@ inline constexpr ::nisps::ParamSchema kXiasriSchema = { kXiasriMLConfig.default_spread, std::span(kXiasriParams), std::span(kXiasriVoiceSpaces), + std::span(kXiasriCurveOverrides), kXiasriUI, }; diff --git a/nisps/modes/sound_analysis_midi.hpp b/nisps/modes/sound_analysis_midi.hpp index 37dd6ce..e3a40d2 100644 --- a/nisps/modes/sound_analysis_midi.hpp +++ b/nisps/modes/sound_analysis_midi.hpp @@ -65,6 +65,15 @@ class SoundAnalysisMIDIMode : public ModeBase< analysis_.setup(sample_rate); } + // The audio ENGINE here is a silent NoOp; the analyser is what actually + // consumes the incoming audio, so IT decides how the codec input is set + // up (mic-level input + pre-amp gain). Overrides ModeBase's default of + // `engine().driver_config()` — which would have asked for line input and + // silently defeated this mode's whole purpose. + DriverConfig on_driver_config() const noexcept { + return analysis_.driver_config(); + } + // Audio path tap — modes call this from the audio thread (or the // platform glue does after process()) to feed the analyser. Distinct // from the audio engine's process() which is the silent passthrough. diff --git a/nisps/wasm/README.md b/nisps/wasm/README.md index 5ddfc88..3fc26a2 100644 --- a/nisps/wasm/README.md +++ b/nisps/wasm/README.md @@ -56,7 +56,7 @@ See `bindings.cpp` for the full list. Summary: | ML I/O | `nisps_ml_set_input`, `nisps_ml_process`, `nisps_ml_outputs`, `nisps_ml_infer_batch` | | Training | `nisps_ml_add_example`, `nisps_ml_train`, `nisps_ml_eval_loss`, `nisps_ml_clear_examples` | | Weights | `nisps_ml_weight_count`, `nisps_ml_get_weights`, `nisps_ml_set_weights`, `nisps_ml_draw_weights` | -| Diag | `nisps_ml_get_layer_stats`, `nisps_ml_describe` | +| Diag | `nisps_ml_get_layer_stats`, `nisps_ml_loss_history`, `nisps_ml_describe` | | Engines | `nisps_engine_create`, `nisps_engine_destroy`, `nisps_engine_set_params`, `nisps_engine_process_block` | Engine-id strings follow the C++ `engine_id()` constexpr accessors: diff --git a/nisps/wasm/bindings.cpp b/nisps/wasm/bindings.cpp index 379293a..772c663 100644 --- a/nisps/wasm/bindings.cpp +++ b/nisps/wasm/bindings.cpp @@ -523,6 +523,29 @@ float nisps_ml_eval_loss(void* ml) { return h->mlp.eval_loss(); } +// Per-iteration training loss recorded by the LAST nisps_ml_train() call on +// this handle (nisps::ml::MLPCore::loss_history — the same buffer the firmware +// MLP fills). Returns the TOTAL number of recorded entries and writes +// min(count, max) of them into `out`. Pass out=null / max<=0 to query the +// count without copying, which is how JS sizes its heap buffer instead of +// mirroring the C++ history cap. +// +// The history is reset at the START of every train() call, so it always +// describes exactly one training run. train_targets() (the geometric-dislike +// single-step path) does NOT record — a dislike leaves the previous run's +// curve intact rather than replacing it with a 1-point curve. +EMSCRIPTEN_KEEPALIVE +int nisps_ml_loss_history(void* ml, float* out, int max) { + if (!ml) return 0; + auto* h = static_cast(ml); + const auto hist = h->mlp.loss_history(); + const int count = static_cast(hist.size()); + if (!out || max <= 0) return count; + const int n = max < count ? max : count; + for (int i = 0; i < n; ++i) out[i] = hist[static_cast(i)]; + return count; +} + // --------------------------------------------------------------------------- // ML weights // --------------------------------------------------------------------------- diff --git a/schemas/modes/channel_strip.json b/schemas/modes/channel_strip.json index fb87164..3c78414 100644 --- a/schemas/modes/channel_strip.json +++ b/schemas/modes/channel_strip.json @@ -38,11 +38,22 @@ ], "voice_spaces": [ "WannabeNeve66", - "SSL 4K G-ist", - "SSL 9K-inda", - "MaleVox", - "FemaleVox", - "Neve 80" + { "name": "SSL 4K G-ist", "curve_overrides": { + "comp_ratio": "square" + } }, + { "name": "SSL 9K-inda", "curve_overrides": { + "comp_ratio": "square" + } }, + { "name": "MaleVox", "curve_overrides": { + "comp_release": "linear" + } }, + { "name": "FemaleVox", "curve_overrides": { + "comp_release": "linear" + } }, + { "name": "Neve 80", "curve_overrides": { + "peak0_freq": "linear", "peak1_freq": "linear", "in_lpf_cutoff": "linear", "in_hpf_cutoff": "linear", + "comp_release": "linear", "lowshelf_freq": "linear", "highshelf_freq": "linear" + } } ], "ui": { "primary_input": "joystick", diff --git a/schemas/modes/paf_synth.json b/schemas/modes/paf_synth.json index 57f33a4..f685db4 100644 --- a/schemas/modes/paf_synth.json +++ b/schemas/modes/paf_synth.json @@ -46,13 +46,36 @@ { "name": "env_release","label": "Env Release", "min": 0.0, "max": 1.0, "default": 0.3, "curve": "linear", "group": "envelope" } ], "voice_spaces": [ - "Ellipticacacia", + { "name": "Ellipticacacia", "curve_overrides": { + "paf0_vib": "linear", "paf1_vib": "linear", "paf0_vfr": "linear", "paf1_vfr": "linear", + "paf0_shift": "square", "dl1mix": "linear", "p18": "square", "dlfb": "square", + "env_decay": "linear", "shape_gain": "linear", "shape_asym": "linear", "rm_gain": "linear" + } }, "Rowantares", - "Neemeda", - "Aquillow", - "Magnetarch", - "Elderstar", - "Ipeleiades" + { "name": "Neemeda", "curve_overrides": { + "p10": "square", "p13": "square", "p23": "square", "p24": "square" + } }, + { "name": "Aquillow", "curve_overrides": { + "p10": "square", "p13": "square", "p23": "square", "p24": "square", + "shape_gain": "linear", "shape_asym": "linear", "rm_gain": "linear", "env_release": "square" + } }, + { "name": "Magnetarch", "curve_overrides": { + "paf0_bw": "square", "paf0_vib": "linear", "paf1_vib": "linear", "paf0_vfr": "linear", + "paf1_vfr": "linear", "dl1mix": "linear", "env_decay": "linear", "shape_gain": "linear", + "shape_asym": "linear", "rm_gain": "linear" + } }, + { "name": "Elderstar", "curve_overrides": { + "paf0_vib": "linear", "paf1_vib": "linear", "paf0_vfr": "linear", "paf1_vfr": "linear", + "paf0_shift": "square", "dl1mix": "linear", "p18": "square", "dlfb": "square", + "p21": "square", "p22": "square", "p23": "square", "shape_gain": "linear", + "shape_asym": "linear", "rm_gain": "linear" + } }, + { "name": "Ipeleiades", "curve_overrides": { + "paf0_vib": "linear", "paf1_vib": "linear", "paf0_vfr": "linear", "paf1_vfr": "linear", + "paf0_shift": "square", "dl1mix": "linear", "p18": "square", "p21": "square", + "p22": "square", "p23": "square", "shape_gain": "linear", "shape_asym": "linear", + "rm_gain": "linear" + } } ], "ui": { "primary_input": "xy_pad", diff --git a/schemas/modes/params_notes.md b/schemas/modes/params_notes.md index eb6f109..b1078bd 100644 --- a/schemas/modes/params_notes.md +++ b/schemas/modes/params_notes.md @@ -5,7 +5,27 @@ This document captures provenance and judgement calls for each mode schema. Read ## Conventions - All `params` ranges are **normalised `[0,1]`**. The firmware voice spaces apply per-mode scaling (e.g. `peak0Freq = 200.f + (params[1] * params[1] * 1800.f)`); we expose the NN-output-space here, not the engine-state-space, because the same NN slot drives different engine values across voice spaces. -- `curve` is a hint about the dominant scaling pattern, not a hard contract: `square` indicates the dominant voice space squares the value (`p * p`), `linear` indicates direct passthrough. +- `curve` is **descriptive, and verified**. It records what the engine already does; the curve is applied exactly once, inside the voice space. Nothing downstream re-applies it. `params[].curve` is the mode-wide DEFAULT; a voice space that deviates declares the delta in `voice_spaces[].curve_overrides` (name → curve), and only the delta. `codegen/tests/curve_drift_test.ts` cross-checks every (voice space × param) slot against `nisps/engines/*.hpp` source on every run — see below. + +### What `square` / `sqrt` / `linear` mean, exactly + +The drift check needs a total, decidable predicate, so: + +- `square` — the engine multiplies **that slot by itself** (`p[n] * p[n]`, or via a `const float v = p[n]` alias, or memlcelium's `sq()` lambda). +- `sqrt` — the engine passes **that slot alone** through `std::sqrt`. +- `linear` — everything else. + +"Everything else" deliberately swallows three shapes the `Curve` enum cannot express, and they are declared `linear` by definition rather than by oversight: + +1. **Quantisation.** `muls[static_cast(p[n] * 3.999999f) & 3]`, `idx_clamp(p[7] * 3.999999f, 5)` — the underlying response is linear, then stepped. Every `Neve 80` frequency and every sequencer ratio is this. +2. **Compound self-products.** paf_synth's Elderstar/Ipeleiades compute `factor = 1.f + (p[17] + p[27] * 0.6f)` and then use `factor * factor`. No single slot is squared; two slots are terms inside a squared sum. `linear`. +3. **Trigonometric combination.** paf_synth Magnetarch folds `p[0] + p[7] + p[8]` through `sin()`. `linear`. + +Anything the extractor cannot place in one of these buckets is a **hard error**, not a silent `linear`. That is the whole point: a regex over `p[N] * p[N]` would have missed the alias form, the `sq()` lambda, loop-generated indices and `smooth_params_[N]` — all four are live in this codebase. + +### Voice-space ordering is load-bearing + +`voice_spaces[i]` **is** `VoiceSpace` ordinal `i` — `ModeBase::set_voice_space(idx)` casts the index straight to the enum. The drift check asserts the schema's names equal the engine's `kVoiceSpaceNames`, in order. Note that paf_synth's enum order (`Ellipticacacia`=QuadDetune, `Rowantares`=VS1, `Neemeda`=VS2, `Aquillow`=Perc, `Magnetarch`=Single1, `Elderstar`=QuadOct, `Ipeleiades`=QuadDist) is **not** the order of the `apply_*` functions in the source file. - `output_size` matches the actual number of params consumed in `ProcessParams()`. Where the firmware's templated NPARAMS is larger than what's consumed, we follow consumption (see `elysiamorf`). ## Per-mode notes @@ -13,11 +33,12 @@ This document captures provenance and judgement calls for each mode schema. Read ### paf_synth (33 params, 7 voice spaces) - Source of truth: `PAFSynthAudioApp.hpp` + `voicespaces/VoiceSpace*.hpp`. - Voice space 1 (Rowantares) uses param indices 2,3,5,6,8,9,11,12,14,15,17,19,20,26,27,28,29,30,31,32 — 20 of 33 slots have a clear meaning. Other voice spaces use overlapping but not identical subsets. We named the meaningful slots after the dominant Rowantares mapping; unused-by-VS1 slots get generic `pXX` names. A future cleanup could canonicalise these names per-voice-space, but the schema is mode-wide so a single canonical name set is correct. -- `curve` of `square` indicates voice spaces consistently apply `params[i] * params[i]` to that slot. +- The mode-wide `curve` default is **Rowantares** (`voice_spaces[1]`), matching the naming convention above — not `voice_spaces[0]`. All six other voice spaces carry `curve_overrides`. This reads oddly in the override tables (`Ellipticacacia.paf0_shift: square` means "QuadDetune squares slot 14, which VS1 calls paf0_shift and uses as a formant shift"); that is the pre-existing per-mode-naming wart above surfacing, not a bug in the table. ### channel_strip (24 params, 6 voice spaces) - Source of truth: `ChannelStripAudioApp.hpp` + `voicespaces/ChannelStrip/basic.hpp`. - All 6 voice spaces touch the same param indices (0,1,4,5,6,7,8,10,11,12,13,14..19,23). Indices 2,3,9,20,21,22 are NN-output slots with no engine effect — exposed as raw `pXX` for future voice-space designers. +- The mode-wide `curve` default is **WannabeNeve66** (`voice_spaces[0]`). Deviations: SSL 4K/9K additionally square `comp_ratio`; MaleVox/FemaleVox do not square `comp_release`; Neve 80 replaces every frequency/ratio with a stepped lookup, so only the two gains stay squared. ### xiasri (24 params, 0 voice spaces — direct mapping) - Source of truth: `XIASRIAudioApp.hpp::Process()`. @@ -28,12 +49,14 @@ This document captures provenance and judgement calls for each mode schema. Read - Source of truth: `modes/AudioApps/VerbFXAudioApp.hpp` + `voicespaces/VerbFX/*.hpp`. - The "Default" voice space is fully exposed; other voice spaces remap the same 47 slots with different scalings. - Hidden layers tweaked to `[10, 14, 18]` for the larger output size. +- The mode-wide `curve` default is **Default** (`voice_spaces[0]`), the only all-linear voice space. The other **eleven** all deviate — Soft/Chamber/Granular square the comb and allpass feedbacks and the filterbank resonances; Cathedral/Shimmer/Diffuse/Metallic `sqrt` them; Dark and Bright split the filterbank by index (`i < 4` one way, the rest the other); Granular is Soft with four late slots re-mapped. This is by far the biggest gap the 2026-07 audit's "declaration is lossy" finding was pointing at: the schema previously said "nothing is curved" for all twelve. +- Slot 44 (`delay_to_verb`) is read by no voice space at all. Left declared for layout stability; flagged here rather than in `ALIGNMENT.md` because it is one dead slot, not a strategic defect. ### memlcelium (56 params, 0 effective voice spaces) - Source of truth: `modes/AudioApps/MEMLCeliumAudioApp.hpp::ProcessParams()`. - Voice spaces are commented out in firmware; we expose a "Direct" placeholder. - Param 0-13 = sequencer (2 RatioSeq tracks × 7), 14-55 = synth (matches `kFocusSeq`/`kFocusSyn` mask). -- Some env params are scaled with `sqParam()` (squared) — flagged with `curve: "square"`. +- Some env params are scaled with an `sq()` lambda over an **implicit** index counter that starts at `i = 14` and advances through `params[i++]` (`nisps/engines/memlcelium.hpp`). Slots 21, 22, 27, 29, 31, 50, 52, 54 come out squared. No index literal appears in the source, so this is exactly the case a `p[N] * p[N]` regex misses; the drift check models the counter instead. ### breakor (56 params, 0 voice spaces) - Source of truth: `modes/AudioApps/BreakOrAudioApp.hpp` + `RatioSeqEngine::updateParams()`. diff --git a/schemas/modes/verb_fx.json b/schemas/modes/verb_fx.json index 07a1f3a..be1d850 100644 --- a/schemas/modes/verb_fx.json +++ b/schemas/modes/verb_fx.json @@ -61,17 +61,78 @@ ], "voice_spaces": [ "Default", - "Resonant", - "Soft", - "Cathedral", - "Shimmer", - "Chamber", - "Metallic", - "Granular", - "Diffuse", - "Dark", - "Bright", - "Harmonic" + { "name": "Resonant", "curve_overrides": { + "fbank_res0": "sqrt", "fbank_res1": "sqrt", "fbank_res2": "sqrt", "fbank_res3": "sqrt", + "fbank_res4": "sqrt", "fbank_res5": "sqrt", "fbank_res6": "sqrt", "fbank_res7": "sqrt" + } }, + { "name": "Soft", "curve_overrides": { + "lp0_fb": "square", "lp1_fb": "square", "lp2_fb": "square", "lp3_fb": "square", + "lp4_fb": "square", "lp5_fb": "square", "lp6_fb": "square", "lp7_fb": "square", + "allp0_fb": "square", "allp1_fb": "square", "allp2_fb": "square", "allp3_fb": "square", + "fbank_res0": "square", "fbank_res1": "square", "fbank_res2": "square", "fbank_res3": "square", + "fbank_res4": "square", "fbank_res5": "square", "fbank_res6": "square", "fbank_res7": "square", + "delay0_fb": "square", "delay1_fb": "square", "delay2_fb": "square" + } }, + { "name": "Cathedral", "curve_overrides": { + "lp0_fb": "sqrt", "lp1_fb": "sqrt", "lp2_fb": "sqrt", "lp3_fb": "sqrt", + "lp4_fb": "sqrt", "lp5_fb": "sqrt", "lp6_fb": "sqrt", "lp7_fb": "sqrt", + "allp0_fb": "sqrt", "allp1_fb": "sqrt", "allp2_fb": "sqrt", "allp3_fb": "sqrt", + "delay0_time": "sqrt", "delay0_fb": "sqrt", "delay1_time": "sqrt", "delay1_fb": "sqrt", + "delay2_time": "sqrt", "delay2_fb": "sqrt", "verb_vs_delay": "square" + } }, + { "name": "Shimmer", "curve_overrides": { + "lp0_fb": "sqrt", "lp1_fb": "sqrt", "lp2_fb": "sqrt", "lp3_fb": "sqrt", + "lp4_fb": "sqrt", "lp5_fb": "sqrt", "lp6_fb": "sqrt", "lp7_fb": "sqrt", + "allp0_fb": "sqrt", "allp1_fb": "sqrt", "allp2_fb": "sqrt", "allp3_fb": "sqrt", + "fbank_res0": "sqrt", "fbank_res1": "sqrt", "fbank_res2": "sqrt", "fbank_res3": "sqrt", + "fbank_res4": "sqrt", "fbank_res5": "sqrt", "fbank_res6": "sqrt", "fbank_res7": "sqrt", + "delay0_fb": "sqrt", "delay1_fb": "sqrt", "delay2_fb": "sqrt", "verb_vs_delay": "square" + } }, + { "name": "Chamber", "curve_overrides": { + "lp0_fb": "square", "lp1_fb": "square", "lp2_fb": "square", "lp3_fb": "square", + "lp4_fb": "square", "lp5_fb": "square", "lp6_fb": "square", "lp7_fb": "square", + "allp0_fb": "square", "allp1_fb": "square", "allp2_fb": "square", "allp3_fb": "square", + "fbank_res0": "square", "fbank_res1": "square", "fbank_res2": "square", "fbank_res3": "square", + "fbank_res4": "square", "fbank_res5": "square", "fbank_res6": "square", "fbank_res7": "square", + "delay0_time": "square", "delay0_fb": "square", "delay1_time": "square", "delay1_fb": "square", + "delay2_time": "square", "delay2_fb": "square" + } }, + { "name": "Metallic", "curve_overrides": { + "allp0_fb": "sqrt", "allp1_fb": "sqrt", "allp2_fb": "sqrt", "allp3_fb": "sqrt", + "fbank_res0": "sqrt", "fbank_res1": "square", "fbank_res2": "sqrt", "fbank_res3": "square", + "fbank_res4": "sqrt", "fbank_res5": "square", "fbank_res6": "sqrt", "fbank_res7": "square" + } }, + { "name": "Granular", "curve_overrides": { + "lp0_fb": "square", "lp1_fb": "square", "lp2_fb": "square", "lp3_fb": "square", + "lp4_fb": "square", "lp5_fb": "square", "lp6_fb": "square", "lp7_fb": "square", + "allp0_fb": "square", "allp1_fb": "square", "allp2_fb": "square", "allp3_fb": "square", + "fbank_res0": "square", "fbank_res1": "square", "fbank_res2": "square", "fbank_res3": "square", + "fbank_res4": "square", "fbank_res5": "square", "fbank_res6": "square", "fbank_res7": "square", + "delay0_fb": "square", "delay1_fb": "square", "delay2_fb": "sqrt", "verb_vs_delay": "sqrt", + "delay_morph": "square", "delay_blend": "sqrt" + } }, + { "name": "Diffuse", "curve_overrides": { + "fb_delay_xfade": "sqrt", "allp0_fb": "sqrt", "allp1_fb": "sqrt", "allp2_fb": "sqrt", + "allp3_fb": "sqrt", "fbank_res0": "square", "fbank_res1": "square", "fbank_res2": "square", + "fbank_res3": "square", "fbank_res4": "square", "fbank_res5": "square", "fbank_res6": "square", + "fbank_res7": "square", "delay0_fb": "sqrt", "delay1_fb": "sqrt", "delay2_fb": "sqrt" + } }, + { "name": "Dark", "curve_overrides": { + "fbank_f0": "square", "fbank_f1": "square", "fbank_f2": "square", "fbank_f3": "square", + "fbank_f4": "square", "fbank_f5": "square", "fbank_f6": "square", "fbank_f7": "square", + "fbank_res0": "sqrt", "fbank_res1": "sqrt", "fbank_res2": "sqrt", "fbank_res3": "sqrt", + "fbank_res4": "square", "fbank_res5": "square", "fbank_res6": "square", "fbank_res7": "square" + } }, + { "name": "Bright", "curve_overrides": { + "fbank_f0": "sqrt", "fbank_f1": "sqrt", "fbank_f2": "sqrt", "fbank_f3": "sqrt", + "fbank_f4": "sqrt", "fbank_f5": "sqrt", "fbank_f6": "sqrt", "fbank_f7": "sqrt", + "fbank_res0": "square", "fbank_res1": "square", "fbank_res2": "square", "fbank_res3": "square", + "fbank_res4": "sqrt", "fbank_res5": "sqrt", "fbank_res6": "sqrt", "fbank_res7": "sqrt" + } }, + { "name": "Harmonic", "curve_overrides": { + "fbank_res0": "sqrt", "fbank_res1": "sqrt", "fbank_res2": "sqrt", "fbank_res3": "sqrt", + "fbank_res4": "sqrt", "fbank_res5": "sqrt", "fbank_res6": "sqrt", "fbank_res7": "sqrt" + } } ], "ui": { "primary_input": "joystick", diff --git a/schemas/schema.json b/schemas/schema.json index f5ed170..5d05373 100644 --- a/schemas/schema.json +++ b/schemas/schema.json @@ -78,7 +78,8 @@ "default": { "type": "number" }, "curve": { "type": "string", - "enum": ["linear", "exp", "log", "square", "sqrt", "sigmoid", "cubic"] + "enum": ["linear", "exp", "log", "square", "sqrt", "sigmoid", "cubic"], + "description": "DEFAULT response curve the engine applies to this slot. Descriptive, not prescriptive: the curve is applied exactly once, inside the engine's voice space. Voice spaces that deviate declare the delta in voice_spaces[].curve_overrides. Verified against engine source by codegen/tests/curve_drift_test.ts." }, "group": { "type": "string" }, "_note": { "type": "string" } @@ -87,8 +88,30 @@ }, "voice_spaces": { "type": "array", - "items": { "type": "string" }, - "description": "Names of C++ voice space lambdas the mode supports. May be empty for engines with no voice spaces (e.g. sequencers)." + "description": "Voice spaces the mode supports, in the engine's VoiceSpace enum order (index i here == VoiceSpace ordinal i). A bare string is a voice space that applies every param's default `curve`; the object form additionally declares the slots where THIS voice space deviates. Only deltas are listed — the resolved per-voice-space table is codegen output.", + "items": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "required": ["name", "curve_overrides"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "curve_overrides": { + "type": "object", + "minProperties": 1, + "description": "param name -> the curve THIS voice space applies, where it differs from that param's default `curve`. Redundant entries (same as the default) are a codegen error.", + "additionalProperties": { + "type": "string", + "enum": ["linear", "exp", "log", "square", "sqrt", "sigmoid", "cubic"] + } + }, + "_note": { "type": "string" } + } + } + ] + } }, "ui": { "type": "object", diff --git a/scripts/bench-engines.sh b/scripts/bench-engines.sh new file mode 100755 index 0000000..8503242 --- /dev/null +++ b/scripts/bench-engines.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# scripts/bench-engines.sh — measure engine throughput on BOTH host targets. +# +# The repo's performance constraint ("super performance-sensitive", ALIGNMENT +# defect 5) was enforced entirely by static discipline: a no-heap lint, section +# attributes, and — since Phase 4 — a firmware flash/RAM report. Nothing +# measured time. This does. +# +# It builds tests/cpp/engine_bench.cpp twice from ONE source: +# native — CMake target nisps_engine_bench (Release/-O3) +# wasm — emcc, with the same flags scripts/build-wasm.sh uses for the +# shipped module, run under node +# then prints a side-by-side table of ns/sample, blocks/s and realtime factor +# per engine, plus a wasm/native ratio. +# +# NOTHING HERE FAILS. A wall-clock threshold on shared CI hardware is either +# slack enough to be meaningless or tight enough to fail on an unrelated noisy +# runner — the same call the firmware size job made. A regression is noticed by +# running this with --compare against a previous report, which prints per-engine +# Δ% (positive = slower). Reports are plain JSON; keep one around to diff. +# +# Usage: +# scripts/bench-engines.sh # native + wasm, full run +# scripts/bench-engines.sh --native-only # skip emcc +# scripts/bench-engines.sh --smoke # ~1 s, proves it still runs +# scripts/bench-engines.sh --engine verb_fx # one engine +# scripts/bench-engines.sh --compare old.json # diff vs a previous report +# scripts/bench-engines.sh --out bench-2026-07-21.json +# +# Env: +# NISPS_BUILD_DIR default nisps/build +# NISPS_BENCH_NO_BUILD 1 = never invoke a build; fail if artifacts missing +# EMCC emcc path (same convention as build-wasm.sh) +# +# Exit codes: 0 on a completed run, 2 on missing artifacts/args, 3 on build +# failure. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BUILD_DIR="${NISPS_BUILD_DIR:-$ROOT/nisps/build}" +BENCH_DIR="$BUILD_DIR/bench" +NATIVE_BIN="$BUILD_DIR/nisps_engine_bench" +SRC="$ROOT/tests/cpp/engine_bench.cpp" +REPORT="$ROOT/tests/cpp/bench_report.mjs" +NO_BUILD="${NISPS_BENCH_NO_BUILD:-0}" + +run_native=1 +run_wasm=1 +out_path="" +compare_path="" +bench_args=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --native-only) run_wasm=0; shift ;; + --wasm-only) run_native=0; shift ;; + --out) out_path="${2:?--out needs a path}"; shift 2 ;; + --compare) compare_path="${2:?--compare needs a path}"; shift 2 ;; + --smoke) bench_args+=("--smoke"); shift ;; + --engine|--repeats|--target-ms|--block-size|--sample-rate|--seed) + bench_args+=("$1" "${2:?$1 needs a value}"); shift 2 ;; + -h|--help) + sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//' + exit 0 ;; + *) + echo "[bench-engines] unknown argument: $1" >&2 + exit 2 ;; + esac +done + +if [[ $run_native -eq 0 && $run_wasm -eq 0 ]]; then + echo "[bench-engines] --native-only and --wasm-only are mutually exclusive" >&2 + exit 2 +fi + +mkdir -p "$BENCH_DIR" +runs=() + +# --------------------------------------------------------------------------- +# Native +# --------------------------------------------------------------------------- +if [[ $run_native -eq 1 ]]; then + if [[ ! -x "$NATIVE_BIN" ]]; then + if [[ "$NO_BUILD" == "1" ]]; then + echo "[bench-engines] missing $NATIVE_BIN and NISPS_BENCH_NO_BUILD=1" >&2 + exit 2 + fi + echo "[bench-engines] native binary missing — running build-cpp-tests.sh" + NISPS_RUN_TESTS=0 "$ROOT/scripts/build-cpp-tests.sh" >/dev/null || { + echo "[bench-engines] C++ build failed" >&2 + exit 3 + } + fi + echo "[bench-engines] running native..." + "$NATIVE_BIN" --json --label native "${bench_args[@]}" > "$BENCH_DIR/native.json" + runs+=("$BENCH_DIR/native.json") +fi + +# --------------------------------------------------------------------------- +# WASM — same source, same optimisation/exception/RTTI flags as the shipped +# module (scripts/build-wasm.sh), plus a node-shaped runtime. STACK_SIZE is +# raised for the same reason build-wasm.sh raises it: the DSP objects are big. +# --------------------------------------------------------------------------- +if [[ $run_wasm -eq 1 ]]; then + EMCC="${EMCC:-$(command -v emcc || echo /usr/lib/emscripten/emcc)}" + if [[ "$EMCC" != */* ]]; then EMCC="$(command -v "$EMCC" || echo "$EMCC")"; fi + if [[ ! -x "$EMCC" && ! -f "$EMCC" ]]; then + echo "[bench-engines] emcc not found at $EMCC — skipping the WASM leg" >&2 + echo "[bench-engines] (set EMCC=/path/to/emcc, or pass --native-only)" >&2 + run_wasm=0 + elif ! command -v node >/dev/null 2>&1; then + echo "[bench-engines] node not on PATH — skipping the WASM leg" >&2 + run_wasm=0 + fi +fi + +if [[ $run_wasm -eq 1 ]]; then + if [[ "$NO_BUILD" == "1" && ! -f "$BENCH_DIR/engine_bench.js" ]]; then + echo "[bench-engines] missing $BENCH_DIR/engine_bench.js and NISPS_BENCH_NO_BUILD=1" >&2 + exit 2 + fi + if [[ "$NO_BUILD" != "1" ]]; then + echo "[bench-engines] compiling WASM bench..." + "$EMCC" "$SRC" \ + -std=c++20 -O3 \ + -fno-exceptions \ + -fno-rtti \ + -s ENVIRONMENT=node \ + -s ALLOW_MEMORY_GROWTH=1 \ + -s INITIAL_MEMORY=16777216 \ + -s STACK_SIZE=1048576 \ + -s ASSERTIONS=0 \ + -s EXIT_RUNTIME=1 \ + -o "$BENCH_DIR/engine_bench.js" || { + echo "[bench-engines] WASM build failed" >&2 + exit 3 + } + fi + echo "[bench-engines] running wasm..." + node "$BENCH_DIR/engine_bench.js" --json --label wasm "${bench_args[@]}" > "$BENCH_DIR/wasm.json" + runs+=("$BENCH_DIR/wasm.json") +fi + +# --------------------------------------------------------------------------- +# Report +# --------------------------------------------------------------------------- +if [[ ${#runs[@]} -eq 0 ]]; then + echo "[bench-engines] no target ran" >&2 + exit 2 +fi + +report_args=("${runs[@]}") +report_args+=(--out "${out_path:-$BENCH_DIR/latest.json}") +if [[ -n "$compare_path" ]]; then + if [[ ! -f "$compare_path" ]]; then + echo "[bench-engines] --compare file not found: $compare_path" >&2 + exit 2 + fi + report_args+=(--compare "$compare_path") +fi + +echo "" +node "$REPORT" "${report_args[@]}" diff --git a/scripts/build-cpp-tests.sh b/scripts/build-cpp-tests.sh index 557e6da..0ed612f 100755 --- a/scripts/build-cpp-tests.sh +++ b/scripts/build-cpp-tests.sh @@ -2,11 +2,13 @@ # scripts/build-cpp-tests.sh — configure + build the host C++ test suite. # # Output: nisps/build/{nisps_core_tests,nisps_dsp_engine_tests, -# nisps_modes_tests,nisps_golden_tests,nisps_parity_check} +# nisps_modes_tests,nisps_golden_tests,nisps_parity_check, +# nisps_engine_bench} # -# Adds CTest registration for the first four. parity_check is invoked by -# scripts/parity-check.sh (orchestrates native+WASM together) and is NOT -# part of the ctest pipeline. +# Adds CTest registration for the first four. The last two are standalone: +# parity_check is invoked by scripts/parity-check.sh (orchestrates native+WASM +# together), and engine_bench by scripts/bench-engines.sh (measures time and +# asserts nothing, so it has no pass/fail for ctest to report). # # Honours these env vars: # CMAKE_BUILD_TYPE default Release; pass Debug for stepping diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index 7472bdb..a4adf8a 100755 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -42,6 +42,7 @@ EXPORTED_FUNCS='[ "_nisps_ml_create","_nisps_ml_destroy","_nisps_ml_reshape", "_nisps_ml_set_input","_nisps_ml_process","_nisps_ml_outputs","_nisps_ml_infer_batch", "_nisps_ml_add_example","_nisps_ml_train","_nisps_ml_set_train_config","_nisps_ml_eval_loss", + "_nisps_ml_loss_history", "_nisps_ml_clear_examples", "_nisps_ml_weight_count","_nisps_ml_get_weights","_nisps_ml_set_weights", "_nisps_ml_draw_weights", diff --git a/scripts/run-all-tests.sh b/scripts/run-all-tests.sh index d7bc5af..39ce524 100755 --- a/scripts/run-all-tests.sh +++ b/scripts/run-all-tests.sh @@ -9,11 +9,23 @@ # 2. WASM build → scripts/build-wasm.sh # 3. Parity check → scripts/parity-check.sh # 4. Lint → scripts/lint-cpp.sh -# 5. Manifold tests → cd manifold && typecheck + build + bun test + playwright +# 5. Manifold tests → codegen golden + curve drift, then +# cd manifold && typecheck + build + bun test + playwright +# 6. Engine bench SMOKE → scripts/bench-engines.sh --smoke --native-only +# +# Stage 6 is REPORTING ONLY and deliberately tiny (~0.15 s, native only, no +# emcc). It exists so the benchmark cannot rot unnoticed the way an unbuilt +# firmware variant did, and so every full local run leaves a rough throughput +# table in the log. It does NOT gate: a smoke-sized run has a ±30% noise floor +# and a wall-clock threshold on shared hardware is either meaningless or flaky +# (same reasoning as the firmware flash/RAM CI job). For numbers you can +# actually compare, run scripts/bench-engines.sh on its own — it defaults to +# best-of-3 × 150 ms per engine on both targets and takes --compare. # # Flags via env: # NISPS_SKIP_PLAYWRIGHT=1 skip the Playwright leg (useful in C++-only loops) # NISPS_SKIP_WASM=1 skip WASM build + parity (no emcc available) +# NISPS_SKIP_BENCH=1 skip the engine-bench smoke report # NISPS_LINT_STRICT=1 treat lint warnings as failures # PLAYWRIGHT_BROWSERS_PATH respected (on the VPS point it at the snap-bun # browser cache — see docs/specs/plans/BUILD-PLAN.md) @@ -27,33 +39,36 @@ cd "$ROOT" stage() { printf '\n=== %s ===\n' "$1"; } -stage "1/5 C++ build + ctest" +stage "1/6 C++ build + ctest" "$ROOT/scripts/build-cpp-tests.sh" if [[ "${NISPS_SKIP_WASM:-0}" != "1" ]]; then - stage "2/5 WASM build" + stage "2/6 WASM build" "$ROOT/scripts/build-wasm.sh" - stage "3/5 parity check" + stage "3/6 parity check" "$ROOT/scripts/parity-check.sh" else - stage "2/5 WASM build (skipped: NISPS_SKIP_WASM=1)" - stage "3/5 parity check (skipped: NISPS_SKIP_WASM=1)" + stage "2/6 WASM build (skipped: NISPS_SKIP_WASM=1)" + stage "3/6 parity check (skipped: NISPS_SKIP_WASM=1)" fi -stage "4/5 lint" +stage "4/6 lint" "$ROOT/scripts/lint-cpp.sh" if [[ "${NISPS_SKIP_PLAYWRIGHT:-0}" != "1" ]]; then - stage "5/5 manifold tests" + stage "5/6 manifold tests" if ! command -v bun >/dev/null 2>&1; then echo "[run-all-tests] bun not on PATH; skipping manifold stage" else ( - # Codegen idempotence golden (C++ + manifold TS outputs). + # Codegen idempotence golden (C++ + manifold TS outputs), then the + # curve drift check: the schemas' declared per-voice-space response + # curves vs what nisps/engines/*.hpp actually computes. cd "$ROOT/codegen" bun install --frozen-lockfile 2>/dev/null || bun install bun run tests/golden_test.ts + bun run tests/curve_drift_test.ts ) ( cd "$ROOT/manifold" @@ -69,7 +84,16 @@ if [[ "${NISPS_SKIP_PLAYWRIGHT:-0}" != "1" ]]; then ) fi else - stage "5/5 manifold tests (skipped: NISPS_SKIP_PLAYWRIGHT=1)" + stage "5/6 manifold tests (skipped: NISPS_SKIP_PLAYWRIGHT=1)" +fi + +# Report-only. See the stage list at the top of this file for why it does not +# gate and why it is smoke-sized here. +if [[ "${NISPS_SKIP_BENCH:-0}" != "1" ]]; then + stage "6/6 engine bench (report only, does not gate)" + NISPS_BENCH_NO_BUILD=1 "$ROOT/scripts/bench-engines.sh" --smoke --native-only +else + stage "6/6 engine bench (skipped: NISPS_SKIP_BENCH=1)" fi stage "ALL GREEN" diff --git a/tests/cpp/bench_report.mjs b/tests/cpp/bench_report.mjs new file mode 100644 index 0000000..7fec4bb --- /dev/null +++ b/tests/cpp/bench_report.mjs @@ -0,0 +1,179 @@ +#!/usr/bin/env node +/** + * tests/cpp/bench_report.mjs — merge one or more `engine_bench --json` runs + * into a single report, print a side-by-side table, and (with --compare) + * diff against a previous report. + * + * Sibling of parity_diff.mjs: same place, same job shape — the C++ produces + * the numbers, node does the presentation. + * + * node bench_report.mjs [ ...] + * [--out combined.json] [--compare previous.json] + * + * Each input is the JSON object `engine_bench --json` writes; its "target" + * field ("native" / "wasm") names the column. The combined document is + * { "generated": ISO8601, "runs": [ , ... ] } + * and that is also what --compare expects to read. + * + * Exit codes: 0 always on a readable report (this tool asserts nothing — + * see the REPORTING, NOT ASSERTING note in engine_bench.cpp), 2 on bad input. + */ + +import { readFileSync, writeFileSync } from 'node:fs'; + +const argv = process.argv.slice(2); +const inputs = []; +let outPath = null; +let comparePath = null; + +for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--out') outPath = argv[++i]; + else if (a === '--compare') comparePath = argv[++i]; + else if (a.startsWith('--')) { + console.error(`[bench_report] unknown flag ${a}`); + process.exit(2); + } else inputs.push(a); +} + +if (inputs.length === 0) { + console.error('[bench_report] usage: bench_report.mjs ... [--out FILE] [--compare FILE]'); + process.exit(2); +} + +function readJson(p) { + try { + return JSON.parse(readFileSync(p, 'utf8')); + } catch (e) { + console.error(`[bench_report] cannot read ${p}: ${e.message}`); + process.exit(2); + } +} + +// An input is normally a bare `engine_bench --json` object, but accepting a +// previously combined report too costs one line and removes the obvious trap +// of feeding this tool its own output. +const runs = []; +for (const p of inputs) { + const doc = readJson(p); + if (Array.isArray(doc.runs)) runs.push(...doc.runs); + else runs.push(doc); +} +if (runs.some((r) => !Array.isArray(r.engines))) { + console.error('[bench_report] input is not an engine_bench report (no "engines" array)'); + process.exit(2); +} +const combined = { generated: new Date().toISOString(), runs }; + +if (outPath) { + writeFileSync(outPath, JSON.stringify(combined, null, 2) + '\n'); +} + +// --- previous report, indexed [target][engine] -> ns_per_sample ------------- +let prev = null; +if (comparePath) { + const doc = readJson(comparePath); + const byTarget = new Map(); + for (const r of doc.runs ?? [doc]) { + const m = new Map(); + for (const e of r.engines ?? []) m.set(e.engine, e); + byTarget.set(r.target, m); + } + prev = { byTarget, generated: doc.generated ?? '(unknown date)' }; +} + +// --- table ----------------------------------------------------------------- +const engines = []; +for (const r of runs) for (const e of r.engines ?? []) { + if (!engines.includes(e.engine)) engines.push(e.engine); +} + +const pad = (s, n) => String(s).padStart(n); +const padr = (s, n) => String(s).padEnd(n); + +console.log('nisps engine benchmark — combined report'); +for (const r of runs) { + console.log(` ${padr(r.target, 8)} block=${r.block_size} sr=${r.sample_rate} ` + + `repeats=${r.repeats} target_ms=${r.target_ms} seed=${r.seed} ` + + `ref=${Number(r.ref_ns_per_op).toFixed(3)} ns/op`); +} +if (prev) console.log(` compared against ${comparePath} (${prev.generated})`); +console.log(''); + +let header = padr('engine', 14); +for (const r of runs) { + header += ' | ' + padr(`${r.target} ns/smp`, 14) + pad('xRT', 8); + if (prev) header += pad('Δ%', 8); +} +if (runs.length === 2) header += ' | ' + pad(`${runs[1].target}/${runs[0].target}`, 12); +console.log(header); +console.log('-'.repeat(header.length)); + +for (const id of engines) { + let line = padr(id, 14); + const nsByTarget = []; + for (const r of runs) { + const e = (r.engines ?? []).find((x) => x.engine === id); + if (!e) { + line += ' | ' + padr('-', 14) + pad('-', 8) + (prev ? pad('-', 8) : ''); + nsByTarget.push(null); + continue; + } + nsByTarget.push(e.ns_per_sample); + line += ' | ' + padr(e.ns_per_sample.toFixed(2), 14) + pad(e.realtime_x.toFixed(1), 8); + if (prev) { + const p = prev.byTarget.get(r.target)?.get(id); + if (!p || !p.ns_per_sample) line += pad('-', 8); + else { + const d = ((e.ns_per_sample - p.ns_per_sample) / p.ns_per_sample) * 100; + line += pad((d >= 0 ? '+' : '') + d.toFixed(1), 8); + } + } + } + if (runs.length === 2 && nsByTarget[0] && nsByTarget[1]) { + line += ' | ' + pad((nsByTarget[1] / nsByTarget[0]).toFixed(2) + 'x', 12); + } else if (runs.length === 2) { + line += ' | ' + pad('-', 12); + } + console.log(line); +} + +console.log(''); +console.log(' ns/smp = nanoseconds per sample (lower is faster).'); +console.log(' xRT = seconds of audio produced per second of CPU (higher is faster).'); +if (prev) console.log(' Δ% = change in ns/sample vs the compared report; POSITIVE means SLOWER.'); +console.log(' Nothing here fails a build. See engine_bench.cpp "REPORTING, NOT ASSERTING".'); + +// A short/unrepeated run is fine as a "does it still work" smoke, and useless +// as a comparison. Say which one you just did rather than letting a ±30% swing +// be read as a regression. +const lowConfidence = runs.filter((r) => (r.repeats ?? 1) < 2 || (r.target_ms ?? 0) < 50); +if (lowConfidence.length) { + console.log(''); + console.log(` NOTE: smoke-sized run (${lowConfidence.map((r) => r.target).join(', ')}) — ` + + 'noise floor is tens of percent. Not comparison-grade.'); +} else if (prev) { + console.log(''); + console.log(' Noise floor at these settings is roughly ±3% on an idle machine, ' + + 'occasionally ±8%.'); + console.log(' Treat |Δ%| under ~10% as noise; a real regression of the kind this ' + + 'exists to catch is 2-3x.'); +} + +// Working-state warnings: a timing number from an idle engine is worthless, +// so say so loudly rather than letting it sit in the table looking fine. +const idle = []; +for (const r of runs) for (const e of r.engines ?? []) { + const ev = String(e.evidence ?? ''); + const value = Number(ev.split('=')[1]); + if (e.engine !== 'thru' && Number.isFinite(value) && value === 0) { + idle.push(`${r.target}/${e.engine} (${ev})`); + } +} +if (idle.length) { + console.log(''); + console.log(` WARNING: engine(s) showed no working state: ${idle.join(', ')}`); + console.log(' Their timings measure an idle engine and must not be compared.'); +} + +if (outPath) console.log(`\n wrote ${outPath}`); diff --git a/tests/cpp/engine_bench.cpp b/tests/cpp/engine_bench.cpp new file mode 100644 index 0000000..208c54c --- /dev/null +++ b/tests/cpp/engine_bench.cpp @@ -0,0 +1,565 @@ +// tests/cpp/engine_bench.cpp — host-side throughput benchmark for the audio +// engines' per-block hot path. Compiles TWICE from this one source: +// +// native : CMake target `nisps_engine_bench` (Release/-O3, see nisps/CMakeLists.txt) +// wasm : emcc, driven by scripts/bench-engines.sh with the SAME flags +// scripts/build-wasm.sh uses for the shipped module +// +// One source compiled two ways is the point: it makes the native and WASM +// numbers comparable without adding a single export to +// nisps/wasm/bindings.cpp. The production C API is untouched, and nothing in +// nisps/ changes — the hot path being measured is not perturbed by measuring it. +// +// WHAT IS MEASURED +// ---------------- +// The inner loop is written to mirror `process_typed()` in +// nisps/wasm/bindings.cpp — the function `nisps_engine_process_block` +// dispatches to, and the one the AudioWorklet calls once per 128-sample +// render quantum. Per block: read interleaved-by-channel input arrays, call +// `engine.process(stereosample_t)` per sample, store to output arrays. The +// only deliberate difference is the missing `switch` on EngineKind (one +// branch per block, unmeasurable at this scale) because the benchmark knows +// the type statically. +// +// Engines are created with `setup(sample_rate)` and a parameter vector, and +// otherwise left in their DEFAULT configuration (default voice space, default +// enable flags) — exactly what `nisps_engine_create()` hands the browser. +// +// DRIVING ENGINES INTO A WORKING STATE +// ------------------------------------ +// Benchmarking an idle engine measures nothing. Three problems, three fixes: +// +// 1. Sequencer engines (breakor, elysiamorf, memlcelium) do almost nothing +// per sample and only spend real time on the sub-sampled sequencer tick +// (every 400/500 samples) and on the events that tick emits. So they run +// with `set_playing(true)` + `update_bpm(120)`, for long enough that +// hundreds of ticks land inside the window, and their event queues are +// drained once per block the way the mode layer drains them. Without the +// drain the 64-slot queue saturates and `push` silently takes a cheaper +// path than production. +// +// 2. paf_synth is envelope-gated: with no note its output is silence, and +// silence through its delay line eventually decays into denormals, whose +// cost is wildly unrepresentative. It gets a `note_on` every ~0.25 s. +// +// 3. Input-consuming engines (channel_strip, xiasri, verb_fx, analysis) +// given silence measure filters and compressors that never work, and hit +// the same denormal cliff. They are fed a deterministic pseudo-noise + +// sine bed, generated once BEFORE the timed region. +// +// Every row carries its own WORKING-STATE EVIDENCE column so a number produced +// by an idle engine is visible rather than silently plausible. Which quantity +// is evidence depends on the engine's kind: output RMS for the audio engines, +// event count for breakor/elysiamorf, feature sum for analysis — the latter +// three emit silence BY DESIGN, so an RMS column would read as broken for +// half the table. +// +// ANTI-ELISION +// ------------ +// The per-block sum-of-squares over the output buffers is what stops -O3 from +// deleting the whole benchmark. It is vectorizable and costs a fraction of a +// nanosecond per sample; the `thru` (NoOpEngine) row is the floor that shows +// how much of the number is harness rather than engine. +// +// REPORTING, NOT ASSERTING +// ------------------------ +// There is no threshold and no failure mode. A time threshold on shared CI +// hardware is either slack enough to be meaningless or tight enough to fail on +// somebody else's noisy runner — the same reasoning that made the firmware +// flash/RAM CI job reporting-only (.github/workflows/ci.yml). Regressions get +// noticed by RUNNING it: `scripts/bench-engines.sh --compare ` +// prints per-engine deltas against a previous run. +// +// The `rel` column exists for the cross-machine case: each engine's ns/sample +// divided by a serial FP multiply-add latency chain measured in the same +// process. That cancels clock speed (not microarchitecture), so `rel` compares +// far better across machines than raw nanoseconds do. +// +// WHAT THIS DOES NOT MEASURE +// -------------------------- +// * The RP2350. These are HOST numbers on a host FPU/cache. A 200x realtime +// factor here implies nothing about the MCU's per-block budget. On-device +// timing is the open half of ALIGNMENT defect 5. +// * Event TRANSPORT. breakor/elysiamorf look nearly free because their cost +// is the tick, not the sample — but the MIDI/WebMIDI forwarding of the +// events they emit lives in platform glue and is outside this loop. +// * The ML path. `nisps_ml_process`/`train` are not benchmarked here; this +// is the audio hot path only. +// +// USAGE +// engine_bench [--json] [--engine ID] [--repeats N] [--target-ms MS] +// [--block-size N] [--sample-rate HZ] [--seed N] [--smoke] +// [--label NAME] +// +// Exit codes: 0 always on a completed run; 2 on bad arguments. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../nisps/engines/analysis.hpp" +#include "../../nisps/engines/base.hpp" +#include "../../nisps/engines/breakor.hpp" +#include "../../nisps/engines/channel_strip.hpp" +#include "../../nisps/engines/elysiamorf.hpp" +#include "../../nisps/engines/memlcelium.hpp" +#include "../../nisps/engines/paf_synth.hpp" +#include "../../nisps/engines/verb_fx.hpp" +#include "../../nisps/engines/xiasri.hpp" + +namespace { + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +struct BenchConfig { + float sample_rate = 48000.f; + std::size_t block_size = 128u; // WebAudio render quantum + std::size_t repeats = 3u; // best-of; benchmark noise is additive + double target_ms = 150.0; // per timed run, auto-sized block count + std::uint64_t seed = 20260721u; + std::string engine_filter; // empty = all + std::string label = "native"; + bool json = false; +}; + +// --------------------------------------------------------------------------- +// Deterministic scalar helpers. Not nisps::Rng — that lives in the core and +// this file must stay a pure consumer of the engine headers. +// --------------------------------------------------------------------------- + +class Lcg { + public: + explicit Lcg(std::uint64_t seed) noexcept : s_(seed * 6364136223846793005ull + 1442695040888963407ull) {} + std::uint32_t next_u32() noexcept { + s_ = s_ * 6364136223846793005ull + 1442695040888963407ull; + return static_cast(s_ >> 33); + } + // Uniform in [0,1). + float next_unit() noexcept { + return static_cast(next_u32()) * (1.f / 4294967296.f); + } + + private: + std::uint64_t s_; +}; + +using Clock = std::chrono::steady_clock; +using Seconds = std::chrono::duration; + +// --------------------------------------------------------------------------- +// Calibration kernel — a serially dependent FP multiply-add chain. Latency +// bound, so it does not vectorize and does not depend on SIMD width; it tracks +// clock speed and FP latency and little else. Used to normalise engine cost +// into the machine-independent-ish `rel` column. +// --------------------------------------------------------------------------- + +double bench_ref_ns_per_op(std::size_t iters) noexcept { + volatile float sink = 0.f; + float x = 1.000001f; + const auto t0 = Clock::now(); + for (std::size_t i = 0u; i < iters; ++i) { + x = x * 0.9999999f + 1e-7f; + x = x * 0.9999998f + 1e-7f; + x = x * 0.9999997f + 1e-7f; + x = x * 0.9999996f + 1e-7f; + } + const auto t1 = Clock::now(); + sink = x; + (void)sink; + const double elapsed = std::chrono::duration_cast(t1 - t0).count(); + return (elapsed * 1e9) / static_cast(iters * 4u); +} + +// --------------------------------------------------------------------------- +// Result record +// --------------------------------------------------------------------------- + +struct Result { + std::string engine; + std::size_t param_count = 0u; + std::size_t blocks = 0u; + double ns_per_sample = 0.0; + double blocks_per_s = 0.0; + double realtime_x = 0.0; + double rel_ref = 0.0; + double out_rms = 0.0; + long long events = -1; // -1 = engine has no event surface + double feature_sum = -1.0; // -1 = engine computes no features + // Short human string proving the engine was actually working. WHICH + // quantity proves that differs by engine kind: an audio engine that fell + // silent is broken, but breakor/elysiamorf/analysis output silence BY + // DESIGN and are evidenced by their event stream / feature vector instead. + std::string evidence; +}; + +// --------------------------------------------------------------------------- +// Input bed. Pseudo-noise + two sines: broadband enough to keep filters, +// followers and compressors doing real work, and far enough from zero that no +// feedback path decays into denormals. Generated once, outside every timed +// region, and cycled. +// --------------------------------------------------------------------------- + +std::vector make_input_bed(std::size_t n, std::uint64_t seed, float sample_rate) { + std::vector buf(n); + Lcg rng(seed); + const double two_pi = 6.283185307179586; + for (std::size_t i = 0u; i < n; ++i) { + const double t = static_cast(i) / static_cast(sample_rate); + const double s = 0.30 * std::sin(two_pi * 220.0 * t) + + 0.15 * std::sin(two_pi * 1310.0 * t); + const double noise = (static_cast(rng.next_unit()) - 0.5) * 0.20; + buf[i] = static_cast(s + noise); + } + return buf; +} + +std::vector make_params(std::size_t n, std::uint64_t seed) { + // Uniform in [0.05, 0.95]. These stand in for MLP outputs, which is what + // the engines actually receive; the parity harness's all-0.5 vector is a + // deliberately degenerate corner (every knob identical) and, at 128 + // frames, never reaches a sequencer tick at all. + std::vector p(n); + Lcg rng(seed ^ 0x9e3779b97f4a7c15ull); + for (std::size_t i = 0u; i < n; ++i) p[i] = 0.05f + rng.next_unit() * 0.90f; + return p; +} + +// --------------------------------------------------------------------------- +// The measured loop. `control` runs once per block INSIDE the timed region — +// it is the mode layer's per-block work (event drain, periodic note_on) and is +// deliberately included, being real cost. It is kept rare/cheap enough that it +// cannot dominate; the reported event counts say how often it did anything. +// --------------------------------------------------------------------------- + +struct RunStats { + double seconds = 0.0; + double sumsq = 0.0; + long long events = 0; +}; + +template +RunStats time_blocks(EngineT& engine, + std::size_t blocks, + std::size_t block_size, + const std::vector& bed, + std::vector& in_l, + std::vector& in_r, + std::vector& out_l, + std::vector& out_r, + std::size_t& bed_pos, + ControlFn&& control) { + RunStats st; + const auto t0 = Clock::now(); + for (std::size_t b = 0u; b < blocks; ++b) { + // Refill the block from the bed (cheap copy, part of what a real host + // does when handing the worklet its input). + for (std::size_t i = 0u; i < block_size; ++i) { + const float v = bed[bed_pos]; + bed_pos = (bed_pos + 1u == bed.size()) ? 0u : bed_pos + 1u; + in_l[i] = v; + in_r[i] = v * 0.87f; + } + + // Mirrors process_typed() in nisps/wasm/bindings.cpp. + for (std::size_t i = 0u; i < block_size; ++i) { + const nisps::stereosample_t s{in_l[i], in_r[i]}; + const auto y = engine.process(s); + out_l[i] = y.L; + out_r[i] = y.R; + } + + st.events += control(engine, b); + + // Anti-elision + signal evidence. Vectorizable; see file header. + double acc = 0.0; + for (std::size_t i = 0u; i < block_size; ++i) { + acc += static_cast(out_l[i]) * out_l[i] + + static_cast(out_r[i]) * out_r[i]; + } + st.sumsq += acc; + } + const auto t1 = Clock::now(); + st.seconds = std::chrono::duration_cast(t1 - t0).count(); + return st; +} + +// --------------------------------------------------------------------------- +// Per-engine harness: prepare → warm up → auto-size → best-of-N timed runs. +// --------------------------------------------------------------------------- + +template +Result run_engine(const char* id, + const BenchConfig& cfg, + double ref_ns_per_op, + PrepareFn prepare, + ControlFn control) { + Result r; + r.engine = id; + r.param_count = EngineT::param_count(); + + const std::size_t bs = cfg.block_size; + const auto bed = make_input_bed(bs * 64u, cfg.seed, cfg.sample_rate); + const auto params = make_params(EngineT::param_count(), cfg.seed); + + std::vector in_l(bs), in_r(bs), out_l(bs), out_r(bs); + + // ONE instance across pilot + repeats: stateful DSP has no meaningful + // "cold" measurement, and a long-running audio session is the state we + // care about. Keeping it alive also lets the evidence probe below read + // the engine after the timed runs. + // + // Heap, not stack: verb_fx/memlcelium carry multi-hundred-kB delay lines + // and blow Emscripten's default stack. `make_handle()` in + // nisps/wasm/bindings.cpp heap-allocates engines for the same reason, so + // this also matches how the browser holds them. + auto owned = std::make_unique(); + EngineT& engine = *owned; + engine.setup(cfg.sample_rate); + if (EngineT::param_count() > 0u) engine.set_params(std::span(params)); + prepare(engine); + + std::size_t pos = 0u; + + // ---- Pilot: settles engine state AND sizes the run so every engine gets + // roughly target_ms of measurement regardless of its cost. + std::size_t blocks = 32u; + { + const auto pilot = time_blocks(engine, blocks, bs, bed, in_l, in_r, out_l, out_r, pos, control); + const double per_block = (pilot.seconds > 0.0) + ? pilot.seconds / static_cast(blocks) + : 1e-9; + const double want = (cfg.target_ms / 1000.0) / per_block; + blocks = static_cast(std::clamp(want, 8.0, 4.0e7)); + } + + double best_seconds = 0.0; + double best_sumsq = 0.0; + long long best_events = 0; + for (std::size_t rep = 0u; rep < cfg.repeats; ++rep) { + const auto st = time_blocks(engine, blocks, bs, bed, in_l, in_r, out_l, out_r, pos, control); + if (rep == 0u || st.seconds < best_seconds) { + best_seconds = st.seconds; + best_sumsq = st.sumsq; + best_events = st.events; + } + } + + const double samples = static_cast(blocks) * static_cast(bs); + r.blocks = blocks; + r.ns_per_sample = (best_seconds * 1e9) / samples; + r.blocks_per_s = (best_seconds > 0.0) ? static_cast(blocks) / best_seconds : 0.0; + r.realtime_x = (best_seconds > 0.0) + ? samples / (best_seconds * static_cast(cfg.sample_rate)) + : 0.0; + r.rel_ref = (ref_ns_per_op > 0.0) ? r.ns_per_sample / ref_ns_per_op : 0.0; + r.out_rms = std::sqrt(best_sumsq / (samples * 2.0)); + r.events = best_events; + + // ---- Evidence that the engine was actually doing work. Which quantity + // proves that depends on the engine's kind, so pick per kind rather than + // printing a column that is legitimately zero for half the table. + char buf[32]; + if constexpr (requires(EngineT& e) { e.features(); }) { + // Analysis engine: emits no audio; its output is the feature vector. + const auto& f = engine.features(); + r.feature_sum = static_cast(f.pitch + f.aperiodicity + f.energy + + f.attack + f.brightness + f.energy_crude); + std::snprintf(buf, sizeof(buf), "feat=%.3f", r.feature_sum); + } else if constexpr (requires(EngineT& e) { e.pop_events(std::span{}); }) { + // Sequencer engine: emits no audio; its output is the event stream. + std::snprintf(buf, sizeof(buf), "ev=%lld", r.events); + } else { + r.events = -1; + std::snprintf(buf, sizeof(buf), "rms=%.4f", r.out_rms); + } + r.evidence = buf; + return r; +} + +// --------------------------------------------------------------------------- +// Control hooks — the per-block work the mode layer does around the engine. +// --------------------------------------------------------------------------- + +struct NoControl { + template + long long operator()(E&, std::size_t) const noexcept { return 0; } +}; + +// paf_synth: retrigger the envelope so the engine is never sitting in silence. +struct RetriggerNotes { + std::size_t every_blocks; + std::array notes{{48u, 55u, 60u, 67u}}; + long long operator()(nisps::PAFSynthEngine& e, std::size_t b) noexcept { + if (b % every_blocks == 0u) { + e.note_on(notes[(b / every_blocks) & 3u], 100u); + return 1; + } + return 0; + } +}; + +// Sequencer engines: drain the event queue the way the mode's control tick +// does. Skipping this saturates the 64-slot queue and pushes take a cheaper +// path than production ever would. +template +struct DrainEvents { + std::array buf{}; + long long operator()(EngineT& e, std::size_t) noexcept { + return static_cast(e.pop_events(std::span(buf))); + } +}; + +// --------------------------------------------------------------------------- +// Reporting +// --------------------------------------------------------------------------- + +void print_table(const BenchConfig& cfg, double ref_ns, const std::vector& rs) { + std::printf("nisps engine benchmark — target=%s\n", cfg.label.c_str()); + std::printf(" sample rate %.0f Hz block %zu samples params pseudo-random(seed=%llu)\n", + static_cast(cfg.sample_rate), cfg.block_size, + static_cast(cfg.seed)); + std::printf(" best of %zu runs, each auto-sized to ~%.0f ms\n", cfg.repeats, cfg.target_ms); + std::printf(" ref kernel %.3f ns/op (serial FP mul-add latency; `rel` is ns/sample ÷ this)\n\n", + ref_ns); + + std::printf("%-14s %7s %11s %12s %11s %8s %s\n", + "engine", "params", "ns/sample", "blocks/s", "xRT", "rel", "working-state evidence"); + std::printf("%-14s %7s %11s %12s %11s %8s %s\n", + "--------------", "------", "---------", "----------", "---------", + "------", "----------------------"); + for (const auto& r : rs) { + std::printf("%-14s %7zu %11.2f %12.1f %11.1f %8.1f %s\n", + r.engine.c_str(), r.param_count, r.ns_per_sample, + r.blocks_per_s, r.realtime_x, r.rel_ref, r.evidence.c_str()); + } + std::printf("\n xRT = seconds of audio per second of CPU (higher is faster).\n"); + std::printf(" evidence: rms = output RMS | ev = events emitted | feat = analysis feature sum.\n"); + std::printf(" A zero there means the engine was NOT driven and its timing means nothing.\n"); + std::printf(" `thru` is NoOpEngine: it measures the harness floor, not an engine (rms=0 expected).\n"); + std::printf(" Reported, never asserted — see scripts/bench-engines.sh --compare.\n"); +} + +void print_json(const BenchConfig& cfg, double ref_ns, const std::vector& rs) { + std::printf("{\n"); + std::printf(" \"target\": \"%s\",\n", cfg.label.c_str()); + std::printf(" \"sample_rate\": %.0f,\n", static_cast(cfg.sample_rate)); + std::printf(" \"block_size\": %zu,\n", cfg.block_size); + std::printf(" \"repeats\": %zu,\n", cfg.repeats); + std::printf(" \"target_ms\": %.1f,\n", cfg.target_ms); + std::printf(" \"seed\": %llu,\n", static_cast(cfg.seed)); + std::printf(" \"ref_ns_per_op\": %.6f,\n", ref_ns); + std::printf(" \"engines\": [\n"); + for (std::size_t i = 0u; i < rs.size(); ++i) { + const auto& r = rs[i]; + std::printf(" {\"engine\": \"%s\", \"params\": %zu, \"blocks\": %zu, " + "\"ns_per_sample\": %.6f, \"blocks_per_s\": %.3f, \"realtime_x\": %.3f, " + "\"rel_ref\": %.4f, \"out_rms\": %.6f, \"events\": %lld, " + "\"feature_sum\": %.6f, \"evidence\": \"%s\"}%s\n", + r.engine.c_str(), r.param_count, r.blocks, r.ns_per_sample, + r.blocks_per_s, r.realtime_x, r.rel_ref, r.out_rms, r.events, + r.feature_sum, r.evidence.c_str(), + (i + 1u == rs.size()) ? "" : ","); + } + std::printf(" ]\n}\n"); +} + +bool wanted(const BenchConfig& cfg, std::string_view id) { + return cfg.engine_filter.empty() || cfg.engine_filter == id; +} + +} // namespace + +int main(int argc, char** argv) { + BenchConfig cfg; + + for (int i = 1; i < argc; ++i) { + const std::string_view a = argv[i]; + auto need = [&](const char* what) -> const char* { + if (i + 1 >= argc) { + std::fprintf(stderr, "[engine_bench] %s needs a value\n", what); + std::exit(2); + } + return argv[++i]; + }; + if (a == "--json") cfg.json = true; + else if (a == "--engine") cfg.engine_filter = need("--engine"); + else if (a == "--repeats") cfg.repeats = static_cast(std::atoi(need("--repeats"))); + else if (a == "--target-ms") cfg.target_ms = std::atof(need("--target-ms")); + else if (a == "--block-size") cfg.block_size = static_cast(std::atoi(need("--block-size"))); + else if (a == "--sample-rate") cfg.sample_rate = static_cast(std::atof(need("--sample-rate"))); + else if (a == "--seed") cfg.seed = static_cast(std::strtoull(need("--seed"), nullptr, 10)); + else if (a == "--label") cfg.label = need("--label"); + else if (a == "--smoke") { cfg.repeats = 1u; cfg.target_ms = 8.0; } + else if (a == "--help" || a == "-h") { + std::printf("usage: engine_bench [--json] [--engine ID] [--repeats N] " + "[--target-ms MS] [--block-size N] [--sample-rate HZ] " + "[--seed N] [--label NAME] [--smoke]\n"); + return 0; + } else { + std::fprintf(stderr, "[engine_bench] unknown argument: %.*s\n", + static_cast(a.size()), a.data()); + return 2; + } + } + if (cfg.repeats == 0u) cfg.repeats = 1u; + if (cfg.block_size == 0u) cfg.block_size = 128u; + + // ~15 ms of calibration: stable enough, short enough not to matter. + const double ref_ns = bench_ref_ns_per_op(2000000u); + + // Retrigger paf_synth roughly every 0.25 s of audio. + const std::size_t retrigger_blocks = + std::max(1u, static_cast( + (static_cast(cfg.sample_rate) * 0.25) / static_cast(cfg.block_size))); + + // The sequencer engines need transport running; everything else is left in + // the exact state nisps_engine_create() leaves it. + auto start_transport = [](auto& e) { e.update_bpm(120.f); e.set_playing(true); }; + auto no_prepare = [](auto&) {}; + + std::vector rs; + + if (wanted(cfg, "thru")) + rs.push_back(run_engine("thru", cfg, ref_ns, no_prepare, NoControl{})); + if (wanted(cfg, "paf_synth")) + rs.push_back(run_engine("paf_synth", cfg, ref_ns, no_prepare, + RetriggerNotes{retrigger_blocks})); + if (wanted(cfg, "channel_strip")) + rs.push_back(run_engine("channel_strip", cfg, ref_ns, no_prepare, NoControl{})); + if (wanted(cfg, "xiasri")) + rs.push_back(run_engine("xiasri", cfg, ref_ns, no_prepare, NoControl{})); + if (wanted(cfg, "verb_fx")) + rs.push_back(run_engine("verb_fx", cfg, ref_ns, no_prepare, NoControl{})); + if (wanted(cfg, "memlcelium")) + rs.push_back(run_engine("memlcelium", cfg, ref_ns, start_transport, NoControl{})); + if (wanted(cfg, "breakor")) + rs.push_back(run_engine("breakor", cfg, ref_ns, start_transport, + DrainEvents{})); + if (wanted(cfg, "elysiamorf")) + rs.push_back(run_engine("elysiamorf", cfg, ref_ns, start_transport, + DrainEvents{})); + if (wanted(cfg, "analysis")) + rs.push_back(run_engine("analysis", cfg, ref_ns, no_prepare, NoControl{})); + + if (rs.empty()) { + std::fprintf(stderr, "[engine_bench] no engine matched --engine %s\n", + cfg.engine_filter.c_str()); + return 2; + } + + if (cfg.json) print_json(cfg, ref_ns, rs); + else print_table(cfg, ref_ns, rs); + return 0; +} diff --git a/tests/cpp/test_mlp_training.cpp b/tests/cpp/test_mlp_training.cpp index ede52c5..61c5fee 100644 --- a/tests/cpp/test_mlp_training.cpp +++ b/tests/cpp/test_mlp_training.cpp @@ -59,6 +59,48 @@ NISPS_TEST(mlp_xor_converges) { NISPS_EXPECT(m.loss_history().size() >= 1u); } +// The loss history is the ONLY on-device record of how a fit went (firmware) +// and the source the browser's training-health panel reads through +// `nisps_ml_loss_history` (simplification-plan §6.5e). Pin its contract: +// one entry per iteration actually run, last entry == the value train() +// returned, and a fresh run replaces rather than appends. +NISPS_TEST(mlp_loss_history_records_every_iteration) { + using M = nisps::ml::MLP<2, 4, 4, 4, 1, 4, 64>; + M m(11ull); + m.draw_weights(1.f); + + std::array x{0.25f, 0.75f}; + std::array y{0.9f}; + m.add_example(std::span(x), std::span(y)); + + // min_err = 0 ⇒ the early-out never fires, so we run exactly max_iter. + const float loss = m.train(/*lr=*/0.2f, /*max_iter=*/12u, /*min_err=*/0.f); + NISPS_EXPECT(m.loss_history().size() == 12u); + NISPS_EXPECT_NEAR(m.loss_history()[11], loss, 1e-6); + // A real fit descends. + NISPS_EXPECT(m.loss_history()[11] < m.loss_history()[0]); + + // A second run REPLACES the curve (it describes exactly one training run). + m.train(/*lr=*/0.2f, /*max_iter=*/3u, /*min_err=*/0.f); + NISPS_EXPECT(m.loss_history().size() == 3u); + + // The single-step geometric-dislike path does NOT record — a dislike must + // not overwrite the last fit's curve with a 1-point one. + std::array target{0.1f}; + m.train_targets(std::span(x), std::span(target), 0.05f); + NISPS_EXPECT(m.loss_history().size() == 3u); + + // Early convergence truncates: an absurd min_err stops after iteration 1. + m.train(/*lr=*/0.2f, /*max_iter=*/50u, /*min_err=*/1e9f); + NISPS_EXPECT(m.loss_history().size() == 1u); + + // Bounded by the storage cap, never past it. + M capped(11ull); + capped.add_example(std::span(x), std::span(y)); + capped.train(/*lr=*/0.2f, /*max_iter=*/200u, /*min_err=*/0.f); + NISPS_EXPECT(capped.loss_history().size() == 64u); +} + NISPS_TEST(mlp_train_with_no_examples_returns_zero) { using M = nisps::ml::MLP<2, 4, 4, 4, 1, 4, 8>; M m(0ull); diff --git a/tests/cpp/test_mode_curve_overrides.cpp b/tests/cpp/test_mode_curve_overrides.cpp new file mode 100644 index 0000000..5ae9c70 --- /dev/null +++ b/tests/cpp/test_mode_curve_overrides.cpp @@ -0,0 +1,122 @@ +// tests/cpp/test_mode_curve_overrides.cpp — the C++ side of the per-voice-space +// curve declaration. +// +// The authority on WHAT the table should contain is codegen/tests/ +// curve_drift_test.ts, which derives it from nisps/engines/*.hpp source (the +// curve is not observable from engine output — see that file's header). This +// test covers what TypeScript cannot see: that the generated C++ table is +// well-formed, reachable through `nisps::ParamSchema`, and that +// `nisps::effective_curve()` resolves it correctly. A handful of spot checks +// pin the wiring so a mis-indexed span cannot pass as "all linear". + +#include "test_helpers.hpp" + +#include "../../nisps/modes/breakor.hpp" +#include "../../nisps/modes/channel_strip.hpp" +#include "../../nisps/modes/elysiamorf.hpp" +#include "../../nisps/modes/memlcelium.hpp" +#include "../../nisps/modes/paf_synth.hpp" +#include "../../nisps/modes/slp_workshop.hpp" +#include "../../nisps/modes/sound_analysis_midi.hpp" +#include "../../nisps/modes/verb_fx.hpp" +#include "../../nisps/modes/xiasri.hpp" + +using namespace nisps; + +namespace { + +// Every override must address a real (voice space, param) pair and must be a +// real deviation — a row restating the default is dead weight that would make +// the table's size a lie about how much the voice spaces actually differ. +void check_well_formed(const ParamSchema& s) { + for (const auto& o : s.curve_overrides) { + NISPS_EXPECT(o.voice_space < s.voice_spaces.size()); + NISPS_EXPECT(o.param < s.params.size()); + if (o.param < s.params.size()) { + NISPS_EXPECT(o.curve != s.params[o.param].curve); + } + } + // No duplicate (voice_space, param) rows: effective_curve() returns the + // first match, so a duplicate would silently shadow. + for (std::size_t i = 0u; i < s.curve_overrides.size(); ++i) { + for (std::size_t j = i + 1u; j < s.curve_overrides.size(); ++j) { + const bool same = s.curve_overrides[i].voice_space == s.curve_overrides[j].voice_space && + s.curve_overrides[i].param == s.curve_overrides[j].param; + NISPS_EXPECT(!same); + } + } +} + +} // namespace + +NISPS_TEST(curve_overrides_well_formed) { + check_well_formed(modes::PAFSynthMode::param_schema()); + check_well_formed(modes::ChannelStripMode::param_schema()); + check_well_formed(modes::VerbFXMode::param_schema()); + check_well_formed(modes::XIASRIMode::param_schema()); + check_well_formed(modes::MEMLCeliumMode::param_schema()); + check_well_formed(modes::SLPWorkshopMode::param_schema()); + check_well_formed(modes::BreakOrMode::param_schema()); + check_well_formed(modes::ElysiamorfMode::param_schema()); + check_well_formed(modes::SoundAnalysisMIDIMode::param_schema()); +} + +// Single-voice-space and voice-space-less modes deviate from nothing: their +// mode-wide `curve` already is the whole truth. +NISPS_TEST(curve_overrides_empty_where_one_voice_space) { + NISPS_EXPECT(modes::XIASRIMode::param_schema().curve_overrides.empty()); + NISPS_EXPECT(modes::MEMLCeliumMode::param_schema().curve_overrides.empty()); + NISPS_EXPECT(modes::SLPWorkshopMode::param_schema().curve_overrides.empty()); + NISPS_EXPECT(modes::BreakOrMode::param_schema().curve_overrides.empty()); + NISPS_EXPECT(modes::ElysiamorfMode::param_schema().curve_overrides.empty()); + NISPS_EXPECT(modes::SoundAnalysisMIDIMode::param_schema().curve_overrides.empty()); +} + +// channel_strip: the mode-wide default IS WannabeNeve66 (voice space 0). +// SSL 4K/9K additionally square comp_ratio (slot 11); the vox strips do not +// square comp_release (13); Neve 80 quantises everything but the two gains. +NISPS_TEST(curve_overrides_channel_strip) { + const auto& s = modes::ChannelStripMode::param_schema(); + NISPS_EXPECT(effective_curve(s, 0u, 11u) == Curve::linear); // WannabeNeve66 + NISPS_EXPECT(effective_curve(s, 1u, 11u) == Curve::square); // SSL 4K G-ist + NISPS_EXPECT(effective_curve(s, 2u, 11u) == Curve::square); // SSL 9K-inda + NISPS_EXPECT(effective_curve(s, 0u, 13u) == Curve::square); // comp_release + NISPS_EXPECT(effective_curve(s, 3u, 13u) == Curve::linear); // MaleVox + NISPS_EXPECT(effective_curve(s, 4u, 13u) == Curve::linear); // FemaleVox + NISPS_EXPECT(effective_curve(s, 5u, 1u) == Curve::linear); // Neve 80 stepped + NISPS_EXPECT(effective_curve(s, 5u, 0u) == Curve::square); // …but pre_gain + NISPS_EXPECT(effective_curve(s, 5u, 23u) == Curve::square); // …and post_gain +} + +// paf_synth: the mode-wide default is Rowantares (voice space 1), NOT voice +// space 0 — the enum order is QuadDetune, VS1, VS2, Perc, Single1, QuadOct, +// QuadDist while the param NAMES came from VS1's mapping. +NISPS_TEST(curve_overrides_paf_synth) { + const auto& s = modes::PAFSynthMode::param_schema(); + NISPS_EXPECT(effective_curve(s, 1u, 8u) == Curve::square); // Rowantares == default + NISPS_EXPECT(effective_curve(s, 0u, 8u) == Curve::linear); // Ellipticacacia + NISPS_EXPECT(effective_curve(s, 4u, 5u) == Curve::square); // Magnetarch shape gain + NISPS_EXPECT(effective_curve(s, 3u, 32u) == Curve::square); // Aquillow env release + NISPS_EXPECT(effective_curve(s, 6u, 19u) == Curve::linear); // Ipeleiades vfr is linear + NISPS_EXPECT(effective_curve(s, 5u, 19u) == Curve::square); // …but Elderstar squares it +} + +// verb_fx: the mode-wide default is Default (voice space 0), which is the ONLY +// all-linear voice space. Every other one deviates. +NISPS_TEST(curve_overrides_verb_fx) { + const auto& s = modes::VerbFXMode::param_schema(); + NISPS_EXPECT(!s.curve_overrides.empty()); + for (std::size_t i = 0u; i < s.params.size(); ++i) { + NISPS_EXPECT(effective_curve(s, 0u, i) == Curve::linear); + } + NISPS_EXPECT(effective_curve(s, 1u, 29u) == Curve::sqrt); // Resonant fbank res + NISPS_EXPECT(effective_curve(s, 2u, 1u) == Curve::square); // Soft lp0_fb + NISPS_EXPECT(effective_curve(s, 3u, 37u) == Curve::sqrt); // Cathedral delay time + NISPS_EXPECT(effective_curve(s, 5u, 39u) == Curve::square); // Chamber delay1 time + NISPS_EXPECT(effective_curve(s, 6u, 29u) == Curve::sqrt); // Metallic alternates… + NISPS_EXPECT(effective_curve(s, 6u, 30u) == Curve::square); // …by index parity + NISPS_EXPECT(effective_curve(s, 7u, 42u) == Curve::sqrt); // Granular overrides Soft + NISPS_EXPECT(effective_curve(s, 8u, 0u) == Curve::sqrt); // Diffuse xfade + NISPS_EXPECT(effective_curve(s, 9u, 21u) == Curve::square); // Dark fbank freqs + NISPS_EXPECT(effective_curve(s, 10u, 21u) == Curve::sqrt); // Bright fbank freqs +} diff --git a/tests/cpp/test_mode_driver_config.cpp b/tests/cpp/test_mode_driver_config.cpp new file mode 100644 index 0000000..6de9f60 --- /dev/null +++ b/tests/cpp/test_mode_driver_config.cpp @@ -0,0 +1,191 @@ +// tests/cpp/test_mode_driver_config.cpp — the mic/line wiring, minus the +// hardware. +// +// Two halves, both of which are the parts that can actually be wrong: +// +// 1. SELECTION — what `Mode::driver_config()` resolves to per mode. This is +// the logic the firmware reads at mode start (src/main.cpp → +// glue/audio_driver.hpp). The interesting case is SoundAnalysisMIDIMode, +// whose audio ENGINE is a silent NoOp while its AnalysisEngine is what +// actually consumes the microphone; taking the engine's config there +// would silently leave the codec on line input and defeat the mode. +// +// 2. CLAMPING — `glue/codec_config.hpp`, the only firmware-side logic in the +// chain. Included directly (it is deliberately Arduino-free) so the +// sample-rate resolution, which can `panic()` the device if it hands +// `AudioDriver::GetSysClockSpeed()` a rate it has no divider for, is +// pinned on the host. +// +// What is NOT proven here: that the SGTL5000 actually switches its input mux. +// That needs hardware. + +#include "test_helpers.hpp" + +#include "../../nisps/modes/breakor.hpp" +#include "../../nisps/modes/channel_strip.hpp" +#include "../../nisps/modes/elysiamorf.hpp" +#include "../../nisps/modes/external_synth_midi.hpp" +#include "../../nisps/modes/memlcelium.hpp" +#include "../../nisps/modes/paf_synth.hpp" +#include "../../nisps/modes/slp_workshop.hpp" +#include "../../nisps/modes/sound_analysis_midi.hpp" +#include "../../nisps/modes/verb_fx.hpp" +#include "../../nisps/modes/xiasri.hpp" + +#include "../../firmware/MEMLNaut-NISPS/glue/codec_config.hpp" + +using namespace nisps; + +// --------------------------------------------------------------------------- +// 1. Per-mode selection +// --------------------------------------------------------------------------- + +// The mode that exists to listen: mic input, with the analyser's pre-amp gain. +NISPS_TEST(driver_config_sound_analysis_midi_selects_mic) { + const modes::SoundAnalysisMIDIMode mode{}; + const DriverConfig c = mode.driver_config(); + NISPS_EXPECT(c.mic_input == true); + NISPS_EXPECT(c.mic_gain_db == 20u); + // ...and it must NOT be the (NoOp) engine's config, which is line input. + NISPS_EXPECT(mode.engine().driver_config().mic_input == false); + // Same object the mode delegates to. + NISPS_EXPECT(c.mic_input == mode.analysis().driver_config().mic_input); + NISPS_EXPECT(c.mic_gain_db == mode.analysis().driver_config().mic_gain_db); +} + +// The two input-processing modes: line input, at the level their engines ask +// for (louder than the default step, i.e. a real instrument/line source). +NISPS_TEST(driver_config_channel_strip_selects_line) { + const modes::ChannelStripMode mode{}; + const DriverConfig c = mode.driver_config(); + NISPS_EXPECT(c.mic_input == false); + NISPS_EXPECT(c.line_level == 6u); + NISPS_EXPECT_NEAR(c.output_volume, 0.9f, 1e-6f); +} + +NISPS_TEST(driver_config_verb_fx_selects_line) { + const modes::VerbFXMode mode{}; + const DriverConfig c = mode.driver_config(); + NISPS_EXPECT(c.mic_input == false); + NISPS_EXPECT(c.line_level == 6u); + NISPS_EXPECT_NEAR(c.output_volume, 0.9f, 1e-6f); +} + +NISPS_TEST(driver_config_xiasri_selects_line) { + const DriverConfig c = modes::XIASRIMode{}.driver_config(); + NISPS_EXPECT(c.mic_input == false); + NISPS_EXPECT(c.line_level == 6u); +} + +// Synth modes: no input opinion beyond the default, own output level. +NISPS_TEST(driver_config_synth_modes_default_line) { + for (const DriverConfig c : {modes::PAFSynthMode{}.driver_config(), + modes::MEMLCeliumMode{}.driver_config(), + modes::SLPWorkshopMode{}.driver_config()}) { + NISPS_EXPECT(c.mic_input == false); + NISPS_EXPECT(c.line_level == DriverConfig{}.line_level); + NISPS_EXPECT_NEAR(c.output_volume, 0.9f, 1e-6f); + } +} + +// Modes that say nothing get exactly the struct defaults — which are, by +// construction, the codec setup the firmware used before any of this was +// wired. Wiring them up is a no-op, not a silent behaviour change. +NISPS_TEST(driver_config_silent_modes_get_defaults) { + const DriverConfig def{}; + const modes::BreakOrMode breakor{}; + const modes::ElysiamorfMode elysiamorf{}; + const modes::ExternalSynthMIDIMode ext{}; + for (const DriverConfig c : {breakor.driver_config(), + elysiamorf.driver_config(), + ext.driver_config()}) { + NISPS_EXPECT(c.mic_input == def.mic_input); + NISPS_EXPECT(c.line_level == def.line_level); + NISPS_EXPECT(c.mic_gain_db == def.mic_gain_db); + NISPS_EXPECT_NEAR(c.output_volume, def.output_volume, 1e-6f); + NISPS_EXPECT_NEAR(c.sample_rate, 0.f, 1e-6f); + } +} + +// The defaults are load-bearing (see above): pin them to memllib's historical +// `AudioDriver::Setup()` values so a future edit has to be deliberate. +NISPS_TEST(driver_config_defaults_match_legacy_firmware_setup) { + const DriverConfig def{}; + NISPS_EXPECT(def.mic_input == false); + NISPS_EXPECT(def.line_level == 3u); + NISPS_EXPECT(def.mic_gain_db == 0u); + NISPS_EXPECT_NEAR(def.output_volume, 0.8f, 1e-6f); + NISPS_EXPECT_NEAR(def.sample_rate, 0.f, 1e-6f); +} + +// No mode may request a rate the driver cannot clock (that would panic() at +// boot). Every mode currently says "don't care"; this holds the line. +NISPS_TEST(driver_config_every_mode_requests_a_clockable_rate) { + const float rates[] = { + modes::PAFSynthMode{}.driver_config().sample_rate, + modes::ChannelStripMode{}.driver_config().sample_rate, + modes::XIASRIMode{}.driver_config().sample_rate, + modes::VerbFXMode{}.driver_config().sample_rate, + modes::MEMLCeliumMode{}.driver_config().sample_rate, + modes::SLPWorkshopMode{}.driver_config().sample_rate, + modes::BreakOrMode{}.driver_config().sample_rate, + modes::ElysiamorfMode{}.driver_config().sample_rate, + modes::SoundAnalysisMIDIMode{}.driver_config().sample_rate, + }; + for (const float r : rates) { + const std::uint32_t resolved = nisps_firmware::select_sample_rate(r); + NISPS_EXPECT(r <= 0.f || resolved == static_cast(r + 0.5f)); + } +} + +// --------------------------------------------------------------------------- +// 2. Firmware-side clamping (glue/codec_config.hpp) +// --------------------------------------------------------------------------- + +NISPS_TEST(codec_config_clamp_passes_through_valid_values) { + DriverConfig in{}; + in.mic_input = true; + in.mic_gain_db = 20u; + in.line_level = 6u; + in.output_volume = 0.9f; + const DriverConfig c = nisps_firmware::clamp_driver_config(in); + NISPS_EXPECT(c.mic_input == true); + NISPS_EXPECT(c.mic_gain_db == 20u); + NISPS_EXPECT(c.line_level == 6u); + NISPS_EXPECT_NEAR(c.output_volume, 0.9f, 1e-6f); +} + +NISPS_TEST(codec_config_clamp_bounds_out_of_range_values) { + DriverConfig hi{}; + hi.mic_gain_db = 200u; + hi.line_level = 99u; + hi.output_volume = 4.f; + const DriverConfig c = nisps_firmware::clamp_driver_config(hi); + NISPS_EXPECT(c.mic_gain_db == nisps_firmware::kMaxMicGainDb); + NISPS_EXPECT(c.line_level == nisps_firmware::kMaxLineLevel); + NISPS_EXPECT_NEAR(c.output_volume, nisps_firmware::kMaxOutputVolume, 1e-6f); + + DriverConfig lo{}; + lo.output_volume = -1.f; + NISPS_EXPECT_NEAR(nisps_firmware::clamp_driver_config(lo).output_volume, 0.f, 1e-6f); +} + +NISPS_TEST(codec_config_sample_rate_dont_care_is_48k) { + NISPS_EXPECT(nisps_firmware::select_sample_rate(0.f) == 48000u); + NISPS_EXPECT(nisps_firmware::select_sample_rate(-1.f) == 48000u); +} + +NISPS_TEST(codec_config_sample_rate_supported_rates_pass_through) { + NISPS_EXPECT(nisps_firmware::select_sample_rate(24000.f) == 24000u); + NISPS_EXPECT(nisps_firmware::select_sample_rate(32000.f) == 32000u); + NISPS_EXPECT(nisps_firmware::select_sample_rate(44100.f) == 44100u); + NISPS_EXPECT(nisps_firmware::select_sample_rate(48000.f) == 48000u); +} + +// The one that keeps the device bootable: GetSysClockSpeed() panics on these. +NISPS_TEST(codec_config_sample_rate_unsupported_falls_back_to_48k) { + NISPS_EXPECT(nisps_firmware::select_sample_rate(96000.f) == 48000u); + NISPS_EXPECT(nisps_firmware::select_sample_rate(22050.f) == 48000u); + NISPS_EXPECT(nisps_firmware::select_sample_rate(1.f) == 48000u); + NISPS_EXPECT(nisps_firmware::select_sample_rate(48001.f) == 48000u); +}