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

- {/* TODO(dock-spec §1.3): real LossPlot / WeightHealth / LayerStats / GradientFlow need - nisps_ml_loss_history plumbed through the C API. Diagnostics suite deferred. */} -

- Loss plot · weight-health · layer-stats · gradient-flow land here once the loss-history C API - is plumbed (dock-spec §1.3). -

+ + Training health + )} diff --git a/manifold/src/console/TrainingHealth.tsx b/manifold/src/console/TrainingHealth.tsx new file mode 100644 index 0000000..38e26d5 --- /dev/null +++ b/manifold/src/console/TrainingHealth.tsx @@ -0,0 +1,159 @@ +/** + * TrainingHealth — the advanced-surface answer to "is the network learning?". + * + * Every number here is read live out of the C++ core: + * - the loss curve is `nisps::ml::MLPCore::loss_history` (one entry per SGD + * iteration of the last training run), read through `nisps_ml_loss_history` + * and published on the spine by both the sync and the worker train paths; + * - the per-layer weight health is `nisps::ml::compute_layer_stats`, read + * through the already-plumbed `nisps_ml_get_layer_stats`. + * + * NOTHING is synthesised. When the core has no history (nothing trained yet) + * this renders a plain "no training run yet" line rather than a plausible + * placeholder plot — that distinction is the entire point of this panel + * (ALIGNMENT defect 6 / simplification-plan §6.5e). + * + * It is a component (not a plain render helper like its sibling drawer + * sections) precisely so it can hold the engine hooks and re-read on version + * bumps without dragging the whole Console into a re-render. + * + * Surfaced only at the Learning drawer's `expanded` depth — Manifold's existing + * advanced-surface mechanism (`DrawerDepth`), not a new flag. + */ +import { useEngine, useEngineVersion } from '../engine'; + +const W = 320; +const H = 64; + +function fmt(v: number, dp = 4): string { + if (!Number.isFinite(v)) return '—'; + return v.toFixed(dp); +} + +function pct(v: number): string { + if (!Number.isFinite(v)) return '—'; + return `${(v * 100).toFixed(1)}%`; +} + +const mono = { + fontSize: 10, + fontFamily: 'var(--font-mono)', + color: 'var(--fg-mute)', +} as const; + +/** Per-iteration loss curve, log-scaled on y (loss spans orders of magnitude). */ +function LossPlot({ history }: { history: ReadonlyArray }) { + const n = history.length; + // A single point has no curve to draw; the readout below still reports it. + if (n < 2) return null; + + const logs = history.map((v) => Math.log10(Math.max(v, 1e-9))); + let lo = Infinity; + let hi = -Infinity; + for (const l of logs) { + if (l < lo) lo = l; + if (l > hi) hi = l; + } + const span = hi - lo < 1e-6 ? 1 : hi - lo; + + const pts = logs + .map((l, i) => { + const x = (i / (n - 1)) * W; + const y = H - ((l - lo) / span) * H; + return `${x.toFixed(2)},${y.toFixed(2)}`; + }) + .join(' '); + + return ( + + + + ); +} + +export function TrainingHealth() { + const engine = useEngine(); + // Re-read on every engine state change (training publishes a new history). + useEngineVersion(engine); + + if (!engine) { + return

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); +}