feat: curve truth, DriverConfig, real telemetry, engine benchmark
Four items from one workflow, committed together because their build and CI
wiring genuinely interleaves — nisps/CMakeLists.txt, run-all-tests.sh and
ci.yml each carry hunks from two of them, and the stage renumbering (1/5 ->
1/6) touches every line. Splitting would produce commits that do not build,
which is worse than a commit that does four things and says so.
S26 part 2 — the curve declaration now matches reality. params[].curve stays
the mode-wide DEFAULT; a voice_spaces entry may now be {name, curve_overrides}
declaring only the slots where THAT voice space deviates. The 6 modes with one
voice space are byte-identical. The values were derived MECHANICALLY by a new
codegen/curve-audit.ts that models the four idioms a p[N]*p[N] regex misses
(alias form, memlcelium's implicit-counter sq() lambda, loop-generated indices,
smooth_params_), inlines helpers, and RAISES rather than guessing when it
cannot reduce an expression. A drift gate cross-checks 1179 (voice space x
param) slots against engine source on every run and was proved to fail loudly
on three drift classes. Application stays in the engine: nisps/engines,
nisps/pipeline and nisps/core are untouched, generated output is pure insertion
(755 insertions, 0 deletions), and the rebuilt nisps.wasm was byte-identical.
S4 / 7.2 — firmware reads the active mode's driver config at mode start, and
mic/line is real. My brief assumed the engine owns this; the code disagreed and
the code was right. sound_analysis_midi's EngineT is NoOpEngine — the mic lives
on a separately-composed AnalysisEngine member — so engine-level wiring would
have compiled, passed every gate, and left the one mic mode on line input.
Hence a mode-level seam defaulting to engine().driver_config(). Separately,
DriverConfig's defaults (line_level 0, output_volume 1.0) had drifted from
memllib's actual 3/0.8 because nothing had ever read them; wiring them as-is
would have made every silent mode louder and its line input maximally
insensitive — a behaviour change disguised as plumbing. Now pinned by a test.
Also: GetSysClockSpeed() panic()s on unsupported sample rates and runs on the
first line of setup(), so sample_rate needed a fallback ahead of clock setup.
CI's firmware env list gains soundanalysismidi — it is the only mic variant and
nothing else compiles that path.
Plan 5e — telemetry is real. A loss_history C-API entry across the full 5-layer
chain lets the browser read the per-iteration loss the core already records.
The audit named one fabrication site; there were two — wasm-iml.ts's
synchronous train() published lossHistory: [loss] as well. A third, ctx.loss,
was not merely dead but actively synthetic (fallbacks of prev * 0.82 and a
literal 0.5, rendered by nothing) and is deleted. The firmware buffer stays
untouched, per the L25 call. EngineApi.lossHistory() reads spine state rather
than the MLP handle, because trainAsync() fits on the worker's mirror net and
the handle would give a subtly-wrong second answer.
Plan 5f — engine throughput is measurable. One source compiled twice (CMake
natively, emcc for WASM) so the targets compare directly and no WASM export is
added. Sequencers are driven into a working state, and every row prints its own
working-state evidence so a number produced by an idle engine is visible rather
than plausible. Reports, never asserts: a wall-clock threshold on shared
hardware is meaningless or flaky, same call as the firmware size job.
ALIGNMENT: the telemetry defect is deleted (built, not deferred); the
performance defect is rewritten to what is actually left — these are HOST
numbers, and nothing measures the RP2350 at 150 MHz, which is the target the
mission's constraint is about. Q4 (memllib ownership) and Q5 (legacy feedback
modes) are closed.
Corrections to my own earlier claims, both found by agents contradicting the
brief: manifold/ONBOARDING.md was NOT "now accurate" — its primitives list
still named five deleted primitives and cited a seededGradient() that does not
exist. And the parity harness misses the sequencer engines because it runs 128
frames while their sequencers evaluate every 400-500 samples, NOT because
all-params-0.5 fails to trigger them (it does trigger: 0.5 maps to ratio 2,
firing three times per bar). The fix is a longer window, not different params.
Gates: run-all-tests.sh ALL GREEN — 4/4 ctest, parity PASS, lint clean, curve
drift 1179 slots ok, 39 e2e (was 33). Firmware: 5 envs built including the mic
variant.
This commit is contained in:
parent
68d4cc4017
commit
a77770f95d
70 changed files with 4361 additions and 156 deletions
36
.github/workflows/ci.yml
vendored
36
.github/workflows/ci.yml
vendored
|
|
@ -4,7 +4,8 @@ name: CI
|
||||||
#
|
#
|
||||||
# Three parallel jobs:
|
# Three parallel jobs:
|
||||||
# * cpp-tests — builds nisps host C++ tests, builds nisps.wasm, runs
|
# * 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
|
# * manifold-tests — typechecks the React manifold app, runs bun unit
|
||||||
# tests, builds the production bundle, runs Playwright
|
# tests, builds the production bundle, runs Playwright
|
||||||
# e2e tests.
|
# e2e tests.
|
||||||
|
|
@ -86,6 +87,19 @@ jobs:
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: bash scripts/lint-cpp.sh
|
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
|
- name: Upload parity blobs on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|
@ -119,6 +133,9 @@ jobs:
|
||||||
bun run generate.ts
|
bun run generate.ts
|
||||||
bun run generate-midi-devices.ts
|
bun run generate-midi-devices.ts
|
||||||
bun run tests/golden_test.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 ..
|
cd ..
|
||||||
GEN_DIRS="nisps/modes/generated nisps/midi/generated nisps/ml/generated \
|
GEN_DIRS="nisps/modes/generated nisps/midi/generated nisps/ml/generated \
|
||||||
manifold/src/modes/generated manifold/src/midi-devices/generated"
|
manifold/src/modes/generated manifold/src/midi-devices/generated"
|
||||||
|
|
@ -186,14 +203,17 @@ jobs:
|
||||||
- name: Install PlatformIO
|
- name: Install PlatformIO
|
||||||
run: pip install --upgrade platformio
|
run: pip install --upgrade platformio
|
||||||
|
|
||||||
# Three representative envs rather than all sixteen: they differ only in
|
# Four representative envs rather than all sixteen: they differ mostly in
|
||||||
# which mode type is instantiated, so a compile break is almost always
|
# which mode type is instantiated, so a compile break is usually common to
|
||||||
# common to all. slpworkshop is the shipped default, pafsynth is the
|
# all. slpworkshop is the shipped default, pafsynth is the heaviest RAM
|
||||||
# heaviest RAM user, and selftest takes the separate NISPS_SELFTEST fork
|
# user, selftest takes the separate NISPS_SELFTEST fork through main.cpp
|
||||||
# through main.cpp — the one that broke unnoticed before.
|
# (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
|
- name: Build representative firmware variants
|
||||||
working-directory: firmware/MEMLNaut-NISPS
|
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
|
# Sizes are reported, not asserted. A threshold would either be slack
|
||||||
# enough to be meaningless or tight enough to fail on unrelated work;
|
# enough to be meaningless or tight enough to fail on unrelated work;
|
||||||
|
|
@ -205,7 +225,7 @@ jobs:
|
||||||
working-directory: firmware/MEMLNaut-NISPS
|
working-directory: firmware/MEMLNaut-NISPS
|
||||||
run: |
|
run: |
|
||||||
SIZE=$(find ~/.platformio/packages -name 'arm-none-eabi-size' | head -1)
|
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" '
|
"$SIZE" -A ".pio/build/$e/firmware.elf" | awk -v e="$e" '
|
||||||
/^\.text/{t=$2} /^\.rodata/{r=$2} /^\.data/{d=$2}
|
/^\.text/{t=$2} /^\.rodata/{r=$2} /^\.data/{d=$2}
|
||||||
/^\.bss/{b=$2} /^\.ram_vector_table/{v=$2}
|
/^\.bss/{b=$2} /^\.ram_vector_table/{v=$2}
|
||||||
|
|
|
||||||
77
ALIGNMENT.md
77
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.
|
**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)
|
### 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.
|
**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
|
### 6. RMSProp still deferred from `nisps/ml/` (2026-04-29; reaffirmed 2026-07-21)
|
||||||
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)
|
|
||||||
|
|
||||||
**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).
|
**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.
|
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
|
## 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.
|
- **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)
|
## 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
|
- 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
|
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
|
Python/sed machinery and the `NISPS_ST_*` token-paste table and the sketch symlink forest and the
|
||||||
|
|
|
||||||
34
MAP.md
34
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<NMax>` — curve→EMA→slew→freeze(+mask), capacity-templated). Behaviour contract = the retired manifold TS pipelines, pinned by `manifold/tests/fixtures/` and parity stage 7.
|
- `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<NMax>` — 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/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/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<const MidiDevice&, NOut>` — 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 `k<Mode>Schema` ParamSchema instances, the `<Mode>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<const MidiDevice&, NOut>` — 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 `k<Mode>Schema` ParamSchema instances, the `<Mode>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/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/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.
|
- `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:<alias>]` per firmware variant (16 of them), each passing `-DMEMLNAUT_MODE_TYPE=<alias>`; `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 <alias>`, or `scripts/build-firmware.sh [--all]`.
|
- `firmware/MEMLNaut-NISPS/platformio.ini` — **the variant registry**: one `[env:<alias>]` per firmware variant (16 of them), each passing `-DMEMLNAUT_MODE_TYPE=<alias>`; `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 <alias>`, 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/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:
|
- `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.
|
- `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.
|
- `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`.
|
- `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
|
- `manifold/src/console/` — the convertible Console: `ConsoleApp`, `CompositeStage` (single-divider convertible
|
||||||
with snap/magnetism/minimap-demotion), `OutputStage`/`SandwichStage`/`ParticleStage`/`Manifold` (canvas,
|
with snap/magnetism/minimap-demotion), `OutputStage`/`SandwichStage`/`ParticleStage`/`Manifold` (canvas,
|
||||||
rect↔circular + feedback markers), `Dock` (top Mode selector + 5 vertically-centred drawers), `Drawers`
|
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
|
(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/`; carries per-mode `ml` net shape + `engineId`), `output-mode.ts`.
|
||||||
- `manifold/src/modes/generated/` — codegen output (`*_schema.ts`, do NOT hand-edit): `ModeSchema`
|
- `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
|
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).
|
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`,
|
- `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).
|
`probe-api` (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
|
`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
|
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)
|
### `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
|
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/` — JSON parameter contracts (firmware/browser source of truth)
|
||||||
- `schemas/schema.json` — Draft 2020-12 meta-schema validating mode files.
|
- `schemas/schema.json` — Draft 2020-12 meta-schema validating mode files.
|
||||||
- `schemas/modes/<mode>.json` (×9) — each mode's params, ranges, defaults, curves, voice spaces, ML config. (`slp_workshop.json` reuses `engine_id: memlcelium`.)
|
- `schemas/modes/<mode>.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.
|
- `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_device.schema.json` — Draft 2020-12 meta-schema for external-MIDI-synth templates.
|
||||||
- `schemas/midi_devices/<device>.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).
|
- `schemas/midi_devices/<device>.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/` — 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/<mode>_schema.hpp` (`constexpr`, `nisps::modes::generated`) AND TS `manifold/src/modes/generated/<mode>_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.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/<mode>_schema.hpp` (`constexpr`, `nisps::modes::generated`) AND TS `manifold/src/modes/generated/<mode>_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/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).
|
- `codegen/lib.ts` — helpers shared by both generators. `codegen/tests/golden/` — golden snapshot for paf_synth (C++ + TS).
|
||||||
|
|
||||||
### `tests/cpp/` — host C++ tests
|
### `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`.
|
- 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.
|
- 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
|
### `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-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-wasm.sh` — Emscripten compile producing `manifold/public/nisps.{wasm,js}`.
|
||||||
- `build-cpp-tests.sh` — CMake configure + build + ctest (Ninja).
|
- `build-cpp-tests.sh` — CMake configure + build + ctest (Ninja).
|
||||||
- `parity-check.sh` — runs native + WASM and diffs binary outputs.
|
- `parity-check.sh` — runs native + WASM and diffs binary outputs.
|
||||||
|
- `bench-engines.sh` — engine throughput on native + WASM; `--compare <report.json>` 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.
|
- `lint-cpp.sh` — `.f` literal warn + heap/`Arduino.h` violation fail.
|
||||||
- `run-all-tests.sh` — master verification script.
|
- `run-all-tests.sh` — master verification script.
|
||||||
|
|
||||||
|
|
@ -145,7 +157,8 @@ includes; no `nisps-core`.
|
||||||
- **WASM rebuild**: `bash scripts/build-wasm.sh` (needs `emcc`).
|
- **WASM rebuild**: `bash scripts/build-wasm.sh` (needs `emcc`).
|
||||||
- **Host C++ tests**: `bash scripts/build-cpp-tests.sh`.
|
- **Host C++ tests**: `bash scripts/build-cpp-tests.sh`.
|
||||||
- **Parity check**: `bash scripts/parity-check.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).
|
- **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).
|
- **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.
|
- `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.
|
- `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.
|
- `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
|
## Smells / strategic concerns
|
||||||
|
|
||||||
|
|
|
||||||
885
codegen/curve-audit.ts
Normal file
885
codegen/curve-audit.ts
Normal file
|
|
@ -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<string, string> = { "(": ")", "{": "}", "[": "]" };
|
||||||
|
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 `<name>(...) ... { ... }`. */
|
||||||
|
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 `<name>(...)`, 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<string, number>, 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<string, number>): 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<string, number>;
|
||||||
|
/** member name -> std::array element count, for range-for unrolling. */
|
||||||
|
arrayLens: Map<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadEngine(path: string): EngineFile {
|
||||||
|
const src = stripComments(readFileSync(path, "utf8"));
|
||||||
|
const consts = new Map<string, number>();
|
||||||
|
// `static constexpr std::size_t kFoo = <expr>;` — 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<string, number>();
|
||||||
|
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<string>, 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<string, Curve> } {
|
||||||
|
const lambdas = new Map<string, Curve>();
|
||||||
|
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, number>): 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<string, number>, 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(<expr containing exactly the accessors>)`;
|
||||||
|
* `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<string, number>, where: string): Map<number, Curve> {
|
||||||
|
const found = new Map<number, Curve>();
|
||||||
|
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<string, Map<number, Curve>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, { rhs: string; used: boolean }>();
|
||||||
|
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<string, number>, 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<string, string> {
|
||||||
|
const out = new Map<string, string>();
|
||||||
|
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<number, Curve>();
|
||||||
|
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<string, EngineCurves> {
|
||||||
|
const out = new Map<string, EngineCurves>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,57 @@ import { ensureDir, readJSON, toPascalCase, cppStringLit, tsStringLit } from "./
|
||||||
|
|
||||||
type Curve = "linear" | "exp" | "log" | "square" | "sqrt" | "sigmoid" | "cubic";
|
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<string, Curve>; _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 {
|
interface ModeSchema {
|
||||||
$schema?: string;
|
$schema?: string;
|
||||||
_note?: string;
|
_note?: string;
|
||||||
|
|
@ -61,7 +112,7 @@ interface ModeSchema {
|
||||||
group: string;
|
group: string;
|
||||||
_note?: string;
|
_note?: string;
|
||||||
}>;
|
}>;
|
||||||
voice_spaces: string[];
|
voice_spaces: VoiceSpaceDecl[];
|
||||||
ui: {
|
ui: {
|
||||||
primary_input: "xy_pad" | "joystick" | "sliders" | "audio_in" | "midi_in" | "none";
|
primary_input: "xy_pad" | "joystick" | "sliders" | "audio_in" | "midi_in" | "none";
|
||||||
show_voice_space_selector: boolean;
|
show_voice_space_selector: boolean;
|
||||||
|
|
@ -193,6 +244,17 @@ function emitSchemaTypesHpp(): string {
|
||||||
" std::string_view group;",
|
" 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 {",
|
"struct MLConfig {",
|
||||||
" std::size_t input_size;",
|
" std::size_t input_size;",
|
||||||
" std::size_t output_size;",
|
" std::size_t output_size;",
|
||||||
|
|
@ -233,9 +295,21 @@ function emitSchemaTypesHpp(): string {
|
||||||
" float default_spread;",
|
" float default_spread;",
|
||||||
" std::span<const ::nisps::modes::generated::Param> params;",
|
" std::span<const ::nisps::modes::generated::Param> params;",
|
||||||
" std::span<const std::string_view> voice_spaces;",
|
" std::span<const std::string_view> voice_spaces;",
|
||||||
|
" std::span<const ::nisps::modes::generated::CurveOverride> curve_overrides;",
|
||||||
" ::nisps::modes::generated::UIConfig ui;",
|
" ::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",
|
"} // namespace nisps",
|
||||||
"",
|
"",
|
||||||
"#endif // NISPS_GENERATED_SCHEMA_TYPES_HPP",
|
"#endif // NISPS_GENERATED_SCHEMA_TYPES_HPP",
|
||||||
|
|
@ -275,6 +349,20 @@ function emitSharedTsTypes(): string {
|
||||||
" readonly group: 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 {",
|
"export interface MLConfig {",
|
||||||
" readonly input_channels: readonly string[];",
|
" readonly input_channels: readonly string[];",
|
||||||
" readonly input_size: number;",
|
" readonly input_size: number;",
|
||||||
|
|
@ -295,9 +383,25 @@ function emitSharedTsTypes(): string {
|
||||||
" readonly ml: MLConfig;",
|
" readonly ml: MLConfig;",
|
||||||
" readonly params: readonly Param[];",
|
" readonly params: readonly Param[];",
|
||||||
" readonly voice_spaces: readonly string[];",
|
" readonly voice_spaces: readonly string[];",
|
||||||
|
" readonly curve_overrides: readonly CurveOverride[];",
|
||||||
" readonly ui: UIConfig;",
|
" 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");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -368,7 +472,7 @@ function emitModeHpp(schema: ModeSchema, sourceFile: string): string {
|
||||||
if (schema.voice_spaces.length > 0) {
|
if (schema.voice_spaces.length > 0) {
|
||||||
lines.push(`inline constexpr std::array<std::string_view, ${constName}VoiceSpaceCount> ${constName}VoiceSpaces = {{`);
|
lines.push(`inline constexpr std::array<std::string_view, ${constName}VoiceSpaceCount> ${constName}VoiceSpaces = {{`);
|
||||||
for (const v of schema.voice_spaces) {
|
for (const v of schema.voice_spaces) {
|
||||||
lines.push(` ${cppStringLit(v)},`);
|
lines.push(` ${cppStringLit(voiceSpaceName(v))},`);
|
||||||
}
|
}
|
||||||
lines.push("}};");
|
lines.push("}};");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -377,6 +481,25 @@ function emitModeHpp(schema: ModeSchema, sourceFile: string): string {
|
||||||
}
|
}
|
||||||
lines.push("");
|
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<CurveOverride, ${overrides.length}u> ${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<CurveOverride, 0> ${constName}CurveOverrides = {};`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
// UI
|
// UI
|
||||||
let primary: string;
|
let primary: string;
|
||||||
switch (schema.ui.primary_input) {
|
switch (schema.ui.primary_input) {
|
||||||
|
|
@ -417,6 +540,7 @@ function emitModeHpp(schema: ModeSchema, sourceFile: string): string {
|
||||||
lines.push(` ${constName}MLConfig.default_spread,`);
|
lines.push(` ${constName}MLConfig.default_spread,`);
|
||||||
lines.push(` std::span<const Param>(${constName}Params),`);
|
lines.push(` std::span<const Param>(${constName}Params),`);
|
||||||
lines.push(` std::span<const std::string_view>(${constName}VoiceSpaces),`);
|
lines.push(` std::span<const std::string_view>(${constName}VoiceSpaces),`);
|
||||||
|
lines.push(` std::span<const CurveOverride>(${constName}CurveOverrides),`);
|
||||||
lines.push(` ${constName}UI,`);
|
lines.push(` ${constName}UI,`);
|
||||||
lines.push("};");
|
lines.push("};");
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
@ -485,7 +609,22 @@ function emitModeTs(schema: ModeSchema, sourceFile: string): string {
|
||||||
} else {
|
} else {
|
||||||
lines.push(" voice_spaces: [");
|
lines.push(" voice_spaces: [");
|
||||||
for (const v of schema.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(" ],");
|
lines.push(" ],");
|
||||||
}
|
}
|
||||||
|
|
@ -712,6 +851,24 @@ function main(): number {
|
||||||
continue;
|
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 });
|
schemas.push({ source: f, schema });
|
||||||
}
|
}
|
||||||
if (errorCount > 0) {
|
if (errorCount > 0) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"generate": "bun run generate.ts",
|
"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": {
|
"dependencies": {
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
|
|
|
||||||
185
codegen/tests/curve_drift_test.ts
Normal file
185
codegen/tests/curve_drift_test.ts
Normal file
|
|
@ -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<string, Curve> };
|
||||||
|
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());
|
||||||
|
|
@ -341,6 +341,70 @@ inline constexpr std::array<std::string_view, kPafSynthVoiceSpaceCount> kPafSynt
|
||||||
"Ipeleiades",
|
"Ipeleiades",
|
||||||
}};
|
}};
|
||||||
|
|
||||||
|
inline constexpr std::array<CurveOverride, 61u> 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 = {
|
inline constexpr UIConfig kPafSynthUI = {
|
||||||
PrimaryInput::XYPad,
|
PrimaryInput::XYPad,
|
||||||
true,
|
true,
|
||||||
|
|
@ -359,6 +423,7 @@ inline constexpr ::nisps::ParamSchema kPafSynthSchema = {
|
||||||
kPafSynthMLConfig.default_spread,
|
kPafSynthMLConfig.default_spread,
|
||||||
std::span<const Param>(kPafSynthParams),
|
std::span<const Param>(kPafSynthParams),
|
||||||
std::span<const std::string_view>(kPafSynthVoiceSpaces),
|
std::span<const std::string_view>(kPafSynthVoiceSpaces),
|
||||||
|
std::span<const CurveOverride>(kPafSynthCurveOverrides),
|
||||||
kPafSynthUI,
|
kPafSynthUI,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,69 @@ export const PafSynthSchema: ModeSchema = {
|
||||||
'Elderstar',
|
'Elderstar',
|
||||||
'Ipeleiades',
|
'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: {
|
ui: {
|
||||||
primary_input: 'xy_pad',
|
primary_input: 'xy_pad',
|
||||||
show_voice_space_selector: true,
|
show_voice_space_selector: true,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **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).
|
- **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).
|
- **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).
|
- **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).
|
- **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.
|
- **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).
|
- **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.
|
- **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.
|
- **5e Training-health telemetry (critic gap 5; §7.3) — BURNED DOWN 2026-07-21.** `int
|
||||||
- **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.
|
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) `<GradientFlow>` 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 <report.json>` 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
|
## §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?
|
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.
|
2. **S4 DriverConfig** — **DECIDED + LANDED 2026-07-21.** Operator: "wire it up; firmware reads active mode's
|
||||||
3. **Telemetry** (§6.5e): feature or delete.
|
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.
|
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.
|
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.
|
6. **Curated-preset product model** (§6.5c): what a preset bundles; where curation lives.
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,57 @@
|
||||||
#include "nisps/core/types.hpp"
|
#include "nisps/core/types.hpp"
|
||||||
#include "audio/AudioDriver.hpp"
|
#include "audio/AudioDriver.hpp"
|
||||||
|
|
||||||
|
#include "codec_config.hpp"
|
||||||
|
|
||||||
namespace nisps_firmware {
|
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<size_t>(c.line_level);
|
||||||
|
out.mic_gain_dB = static_cast<size_t>(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 <typename Mode>
|
||||||
|
inline void apply_mode_sample_rate(const Mode& mode) noexcept {
|
||||||
|
AudioDriver::SetSampleRate(
|
||||||
|
static_cast<size_t>(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 <typename Mode>
|
||||||
|
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.
|
// Pointer to the active mode. The audio block callback reads through this.
|
||||||
// Set during setup1() before AudioDriver::Setup() is called. Marked
|
// Set during setup1() before AudioDriver::Setup() is called. Marked
|
||||||
// `__not_in_flash("audio")` so the audio ISR path does not pay flash latency.
|
// `__not_in_flash("audio")` so the audio ISR path does not pay flash latency.
|
||||||
|
|
|
||||||
67
firmware/MEMLNaut-NISPS/glue/codec_config.hpp
Normal file
67
firmware/MEMLNaut-NISPS/glue/codec_config.hpp
Normal file
|
|
@ -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 <cstdint>
|
||||||
|
|
||||||
|
#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<float>(rate);
|
||||||
|
if (diff > -0.5f && diff < 0.5f) return rate;
|
||||||
|
}
|
||||||
|
return kDefaultSampleRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace nisps_firmware
|
||||||
|
|
@ -14,12 +14,13 @@
|
||||||
// env (`-DMEMLNAUT_MODE_TYPE=...`, or `-DNISPS_SELFTEST=1` for the
|
// env (`-DMEMLNAUT_MODE_TYPE=...`, or `-DNISPS_SELFTEST=1` for the
|
||||||
// guided hardware self-test — see platformio.ini, one [env] per variant).
|
// guided hardware self-test — see platformio.ini, one [env] per variant).
|
||||||
// 2. setup() / loop() on core 0:
|
// 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 peripherals → mode.set_input
|
||||||
// - bind MIDI in → mode.note_on/update_bpm/...
|
// - bind MIDI in → mode.note_on/update_bpm/...
|
||||||
// - run mode.tick_control() at ML cadence (5ms)
|
// - run mode.tick_control() at ML cadence (5ms)
|
||||||
// 3. setup1() / loop1() on core 1:
|
// 3. setup1() / loop1() on core 1:
|
||||||
// - register the audio bridge so AudioDriver streams into mode.process
|
// - 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
|
// - pump engine events / drain MIDI out at sub-ms cadence
|
||||||
|
|
||||||
// ---- Hardware ----
|
// ---- Hardware ----
|
||||||
|
|
@ -104,6 +105,11 @@ static uint32_t get_rosc_entropy_seed(int bits) {
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|
||||||
void setup() {
|
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);
|
set_sys_clock_khz(AudioDriver::GetSysClockSpeed(), true);
|
||||||
bus_ctrl_hw->priority = BUSCTRL_BUS_PRIORITY_DMA_W_BITS
|
bus_ctrl_hw->priority = BUSCTRL_BUS_PRIORITY_DMA_W_BITS
|
||||||
| BUSCTRL_BUS_PRIORITY_DMA_R_BITS
|
| BUSCTRL_BUS_PRIORITY_DMA_R_BITS
|
||||||
|
|
@ -190,7 +196,9 @@ void setup1() {
|
||||||
g_mode.setup(static_cast<float>(AudioDriver::GetSampleRate()));
|
g_mode.setup(static_cast<float>(AudioDriver::GetSampleRate()));
|
||||||
|
|
||||||
nisps_firmware::register_audio_engine(g_mode, &audio_block_callback);
|
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);
|
WRITE_VOLATILE(g_core1_ready, true);
|
||||||
while (!READ_VOLATILE(g_core0_ready)) { MEMORY_BARRIER(); delay(1); }
|
while (!READ_VOLATILE(g_core0_ready)) { MEMORY_BARRIER(); delay(1); }
|
||||||
|
|
|
||||||
|
|
@ -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).
|
`Pin`, `FeedbackMarker`, `Snapshot`, and **`ConsoleCtx`** (the flat context handed to the dock).
|
||||||
- `model.ts`: the instrument catalogue `MF_MODES`, `MFParam`, `ParamStatus`
|
- `model.ts`: the instrument catalogue `MF_MODES`, `MFParam`, `ParamStatus`
|
||||||
(`off|fixed|live`), `ParamGroup`, plus `shapeValues()` (applies min/max/curve to raw engine
|
(`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,
|
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`)
|
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
|
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
|
- Five drawers (`DRAWERS` in `Drawers.tsx`, each has `.render(ctx, depth)` — condensed 360px panel
|
||||||
vs expanded 80vw×80vh modal):
|
vs expanded 80vw×80vh modal):
|
||||||
- **learn** — feedback mode (explore-and-place / geometric-dislike) + solo mode + per-output arm.
|
- **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).
|
- **inputs** — enable/configure input sources (XY pad / MIDI / gamepad).
|
||||||
- **route** (label "Outputs") — per-output control matrix + per-backend config.
|
- **route** (label "Outputs") — per-output control matrix + per-backend config.
|
||||||
- **settings** — icon style (monochrome/colour), input-map shape (xy/joystick/rect/circular), corner radius.
|
- **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).
|
CC#/channel, OSC path/range, VCV polarity).
|
||||||
|
|
||||||
### Primitives — `src/primitives/` (barrel: `index.ts`)
|
### Primitives — `src/primitives/` (barrel: `index.ts`)
|
||||||
`Button`, `Slider`, `PillToggle`, `Panel`, `Badge`, `Switch`, `StatusLine`, `XYPad`,
|
Seven: `Button`, `Slider`, `PillToggle`, `Badge`, `Switch`, `XYPad`, `VirtualJoystick`. Dumb,
|
||||||
`VirtualJoystick`, `ControlAxis`, `CurvePlot`, `Sparkline`. Dumb, reusable, no engine knowledge.
|
reusable, no engine knowledge. Side-effect import of `styles/primitives.css` styles the range
|
||||||
Side-effect import of `styles/primitives.css` styles the range inputs.
|
inputs. `Panel`/`StatusLine`/`ControlAxis`/`CurvePlot`/`Sparkline` were **deleted** in the 2026-07
|
||||||
|
sweep (L22, zero consumers) — don't cite them.
|
||||||
|
|
||||||
### Other shared UI files
|
### Other shared UI files
|
||||||
- `shared-ui.tsx` — `MiniMeters` (read-only output bars). `AltitudeNav`/`CompactAxis` were deleted with the focus system.
|
- `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).
|
`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:
|
- `engine-api.ts` — **`EngineApi`, the framework-neutral facade** everything in the UI talks to:
|
||||||
`setInput/setInputs`, `getOutputs/routedOutput`, training (`addExample/train/trainAsync/evalLoss`),
|
`setInput/setInputs`, `getOutputs/routedOutput`, training (`addExample/train/trainAsync/evalLoss`),
|
||||||
weights (`getWeights/setWeights/process/randomise`), `subscribe/version/on`, plus nested
|
weights (`getWeights/setWeights/process/randomise`), telemetry
|
||||||
`.feedback` and `.audio` facades.
|
(`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
|
- `EngineProvider.tsx` / `useEngine.ts` — the **only** React coupling. `useEngine()` returns the API
|
||||||
(null until WASM ready); `useEngineVersion()` = `useSyncExternalStore(subscribe, version)`.
|
(null until WASM ready); `useEngineVersion()` = `useSyncExternalStore(subscribe, version)`.
|
||||||
- `engine-host.ts` — main-thread audio wiring: AudioContext (user-gesture gated), fetch `nisps.wasm`,
|
- `engine-host.ts` — main-thread audio wiring: AudioContext (user-gesture gated), fetch `nisps.wasm`,
|
||||||
register + feed the worklet.
|
register + feed the worklet.
|
||||||
- `wasm-iml.ts` (**~750 lines**) — the ML interface to `nisps.wasm`: one MLP handle, dataset, heap
|
- `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.
|
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).
|
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
|
- **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
|
(invert → deadzone → circular clamp → momentum-modulated zoom → centred power → EMA → momentum) and
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -104,7 +104,6 @@ export function ConsoleApp() {
|
||||||
const [noiseCap, setNoiseCap] = useState(0.12);
|
const [noiseCap, setNoiseCap] = useState(0.12);
|
||||||
const [examples, setExamples] = useState(0);
|
const [examples, setExamples] = useState(0);
|
||||||
const [addingExample, setAddingExample] = useState(false);
|
const [addingExample, setAddingExample] = useState(false);
|
||||||
const [loss, setLoss] = useState<number[]>([]);
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [spread, setSpread] = useState(false);
|
const [spread, setSpread] = useState(false);
|
||||||
const [active, setActive] = useState<DrawerKey | null>(null);
|
const [active, setActive] = useState<DrawerKey | null>(null);
|
||||||
|
|
@ -262,7 +261,6 @@ export function ConsoleApp() {
|
||||||
setParams(mode.params.map((p) => ({ ...p })));
|
setParams(mode.params.map((p) => ({ ...p })));
|
||||||
setPos([0.5, 0.5]);
|
setPos([0.5, 0.5]);
|
||||||
setExamples(0);
|
setExamples(0);
|
||||||
setLoss([]);
|
|
||||||
setFollow(false);
|
setFollow(false);
|
||||||
setPins([]);
|
setPins([]);
|
||||||
setMarkers([]);
|
setMarkers([]);
|
||||||
|
|
@ -453,10 +451,6 @@ export function ConsoleApp() {
|
||||||
forwardVcvFeedback('up');
|
forwardVcvFeedback('up');
|
||||||
syncController();
|
syncController();
|
||||||
setNoiseCap((n) => Math.max(0.02, n * 0.7));
|
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);
|
setBusy(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -571,11 +565,8 @@ export function ConsoleApp() {
|
||||||
};
|
};
|
||||||
const train = () => {
|
const train = () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
const l = engine?.train();
|
engine?.train();
|
||||||
engine?.process();
|
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);
|
setBusy(false);
|
||||||
};
|
};
|
||||||
const addExample = () => {
|
const addExample = () => {
|
||||||
|
|
@ -746,7 +737,6 @@ export function ConsoleApp() {
|
||||||
setModeId,
|
setModeId,
|
||||||
mode,
|
mode,
|
||||||
datasetCount: examples,
|
datasetCount: examples,
|
||||||
loss,
|
|
||||||
busy,
|
busy,
|
||||||
addingExample,
|
addingExample,
|
||||||
onAddExample: addExample,
|
onAddExample: addExample,
|
||||||
|
|
@ -757,7 +747,6 @@ export function ConsoleApp() {
|
||||||
// trail is ephemeral and self-decays, so it needs no explicit reset.
|
// trail is ephemeral and self-decays, so it needs no explicit reset.
|
||||||
engine?.clearExamples();
|
engine?.clearExamples();
|
||||||
setExamples(0);
|
setExamples(0);
|
||||||
setLoss([]);
|
|
||||||
setMarkers([]);
|
setMarkers([]);
|
||||||
setPins([]);
|
setPins([]);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import { outputModeDescriptor } from './output-mode';
|
||||||
import { useSettings, unfocusedIconCss } from '../settings/settings-store';
|
import { useSettings, unfocusedIconCss } from '../settings/settings-store';
|
||||||
import type { UnfocusedIconColour, InputMapMode } from '../settings/settings-store';
|
import type { UnfocusedIconColour, InputMapMode } from '../settings/settings-store';
|
||||||
import { EditorPanel } from '../serial/EditorPanel';
|
import { EditorPanel } from '../serial/EditorPanel';
|
||||||
|
import { TrainingHealth } from './TrainingHealth';
|
||||||
import {
|
import {
|
||||||
LearningIcon,
|
LearningIcon,
|
||||||
InputsIcon,
|
InputsIcon,
|
||||||
|
|
@ -259,12 +260,9 @@ function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
|
||||||
anchor and restores the real net.
|
anchor and restores the real net.
|
||||||
</p>
|
</p>
|
||||||
<Switch checked={ctx.spread} onChange={ctx.setSpread} label="Xavier (centered) weight regime" />
|
<Switch checked={ctx.spread} onChange={ctx.setSpread} label="Xavier (centered) weight regime" />
|
||||||
{/* TODO(dock-spec §1.3): real LossPlot / WeightHealth / LayerStats / GradientFlow need
|
|
||||||
nisps_ml_loss_history plumbed through the C API. Diagnostics suite deferred. */}
|
<SectionLabel>Training health</SectionLabel>
|
||||||
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0 }}>
|
<TrainingHealth />
|
||||||
Loss plot · weight-health · layer-stats · gradient-flow land here once the loss-history C API
|
|
||||||
is plumbed (dock-spec §1.3).
|
|
||||||
</p>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
159
manifold/src/console/TrainingHealth.tsx
Normal file
159
manifold/src/console/TrainingHealth.tsx
Normal file
|
|
@ -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<number> }) {
|
||||||
|
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 (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
role="img"
|
||||||
|
aria-label={`Training loss over ${n} iterations, ${fmt(history[0])} down to ${fmt(history[n - 1])}`}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: H,
|
||||||
|
display: 'block',
|
||||||
|
background: 'var(--bg-2)',
|
||||||
|
border: '1px solid var(--line)',
|
||||||
|
borderRadius: 'var(--r-sm, 4px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
points={pts}
|
||||||
|
fill="none"
|
||||||
|
stroke="var(--accent)"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrainingHealth() {
|
||||||
|
const engine = useEngine();
|
||||||
|
// Re-read on every engine state change (training publishes a new history).
|
||||||
|
useEngineVersion(engine);
|
||||||
|
|
||||||
|
if (!engine) {
|
||||||
|
return <p style={{ ...mono, margin: 0 }}>engine not ready</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<p style={{ ...mono, margin: 0 }}>
|
||||||
|
no training run yet — the loss curve appears after the first fit
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LossPlot history={history} />
|
||||||
|
<div style={{ ...mono, display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<span>{history.length} iter</span>
|
||||||
|
<span>start {fmt(first ?? 0)}</span>
|
||||||
|
<span style={{ color: 'var(--accent)' }}>end {fmt(last ?? 0)}</span>
|
||||||
|
<span>
|
||||||
|
{first !== null && last !== null && last < first
|
||||||
|
? 'converging'
|
||||||
|
: 'not improving'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<table
|
||||||
|
style={{ ...mono, width: '100%', borderCollapse: 'collapse', textAlign: 'right' }}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ color: 'var(--fg-dim)' }}>
|
||||||
|
<th style={{ textAlign: 'left', fontWeight: 400 }}>layer</th>
|
||||||
|
<th style={{ fontWeight: 400 }}>mean|w|</th>
|
||||||
|
<th style={{ fontWeight: 400 }}>max|w|</th>
|
||||||
|
<th style={{ fontWeight: 400 }}>dead</th>
|
||||||
|
<th style={{ fontWeight: 400 }}>sat</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stats.map((s, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td style={{ textAlign: 'left' }}>L{i}</td>
|
||||||
|
<td>{fmt(s.meanAbs, 3)}</td>
|
||||||
|
<td>{fmt(s.maxAbs, 3)}</td>
|
||||||
|
<td>{pct(s.deadFrac)}</td>
|
||||||
|
<td>{pct(s.saturatingFrac)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p style={{ ...mono, margin: 0, color: 'var(--fg-dim)', lineHeight: 1.5 }}>
|
||||||
|
dead = |w| < 0.001, sat = |w| > 3 (nisps/ml/stats.hpp). A layer that is
|
||||||
|
mostly dead or mostly saturating is not learning usefully.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -49,12 +49,16 @@ export type DrawerDepth = 'condensed' | 'expanded';
|
||||||
*
|
*
|
||||||
* `modes` / `setModeId` are KEPT despite having no renderer today — this is
|
* `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),
|
* the exact plumbing the Phase-5 instrument-mode picker is built on (A7/A3),
|
||||||
* not dead code. `busy` / `addingExample` / `onAddExample` / `onTrain` / `loss`
|
* not dead code. `busy` / `addingExample` / `onAddExample` / `onTrain` are ALSO
|
||||||
* are ALSO kept even though no drawer reads them either: unlike the fields
|
* kept even though no drawer reads them either: unlike the fields above they
|
||||||
* above they drive real engine calls (engine.addExample / engine.train /
|
* drive real engine calls (engine.addExample / engine.train), so — pending
|
||||||
* engine.evalLoss), so — pending confirmation either way — they read as
|
* confirmation either way — they read as unfinished plumbing rather than
|
||||||
* unfinished plumbing rather than confirmed-dead decoration; deleting them
|
* confirmed-dead decoration.
|
||||||
* was out of this pass's authorized scope.
|
*
|
||||||
|
* `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 {
|
export interface ConsoleCtx {
|
||||||
modes: MFMode[];
|
modes: MFMode[];
|
||||||
|
|
@ -63,7 +67,6 @@ export interface ConsoleCtx {
|
||||||
mode: MFMode;
|
mode: MFMode;
|
||||||
|
|
||||||
datasetCount: number;
|
datasetCount: number;
|
||||||
loss: number[];
|
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
addingExample: boolean;
|
addingExample: boolean;
|
||||||
onAddExample: () => void;
|
onAddExample: () => void;
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,20 @@ export class EngineApi {
|
||||||
return this.iml.getLayerStatsFlat();
|
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<number> {
|
||||||
|
return this.spine.getState().lossHistory;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Reactive contract --------------------------------------------
|
// ---- Reactive contract --------------------------------------------
|
||||||
|
|
||||||
/** Subscribe to state changes (useSyncExternalStore). Returns an unsubscribe. */
|
/** Subscribe to state changes (useSyncExternalStore). Returns an unsubscribe. */
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,11 @@ export interface NispsModule {
|
||||||
// nisps::ml::MLPCore::set_train_config. Does not train.
|
// 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_set_train_config(ml: number, lr: number, max_iter: number, min_err: number): void;
|
||||||
_nisps_ml_eval_loss(ml: number): number;
|
_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.
|
// ML examples.
|
||||||
_nisps_ml_clear_examples(ml: number): void;
|
_nisps_ml_clear_examples(ml: number): void;
|
||||||
|
|
@ -270,8 +275,9 @@ export type WorkerResponse =
|
||||||
requestId: number;
|
requestId: number;
|
||||||
loss: number;
|
loss: number;
|
||||||
weights: Float32Array;
|
weights: Float32Array;
|
||||||
// Loss curve (per-iteration). Currently always single-element — the C++
|
// Loss curve: one entry per SGD iteration the worker's net actually ran,
|
||||||
// MLP exposes loss_history but the WASM bridge does not yet plumb it.
|
// read out of the core via `_nisps_ml_loss_history`. Length <= maxIter
|
||||||
|
// (training stops early once epoch loss < minErr).
|
||||||
lossHistory: Float32Array;
|
lossHistory: Float32Array;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,12 @@ export class WasmIML {
|
||||||
private pinMaskBuf!: HeapU8;
|
private pinMaskBuf!: HeapU8;
|
||||||
private feedbackBuf!: HeapBuffer; // kDefaultOutputs scratch for feedback static/down
|
private feedbackBuf!: HeapBuffer; // kDefaultOutputs scratch for feedback static/down
|
||||||
private describePtr = 0;
|
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
|
// Pipeline (one-core-engine P4): the input/output processing chains live
|
||||||
// C++-side per handle. These wrappers own the handle + bridge buffers.
|
// 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.pipeMaskBuf) this.pipeMaskBuf.free();
|
||||||
if (this.curveBuf) this.curveBuf.free();
|
if (this.curveBuf) this.curveBuf.free();
|
||||||
if (this.describePtr) this.module._free(this.describePtr);
|
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 });
|
this.sink.setState({ ready: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -668,9 +679,7 @@ export class WasmIML {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastLoss_ = loss;
|
this.lastLoss_ = loss;
|
||||||
// The C++ MLP stores per-iter history but it isn't exposed via the WASM
|
this.sink.setState({ lastLoss: loss, lossHistory: this.lossHistory() });
|
||||||
// bindings yet, so this is a single-element array.
|
|
||||||
this.sink.setState({ lastLoss: loss, lossHistory: [loss] });
|
|
||||||
this.sink.emit('ml.trained', { loss });
|
this.sink.emit('ml.trained', { loss });
|
||||||
this.scheduleSave_();
|
this.scheduleSave_();
|
||||||
return loss;
|
return loss;
|
||||||
|
|
@ -1020,6 +1029,34 @@ export class WasmIML {
|
||||||
return new Float32Array(this.statsBuf.view);
|
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
|
// Persistence
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,8 @@ if (isWorker) {
|
||||||
let labelsLen = 0;
|
let labelsLen = 0;
|
||||||
let sampleWeightsPtr = 0;
|
let sampleWeightsPtr = 0;
|
||||||
let sampleWeightsLen = 0;
|
let sampleWeightsLen = 0;
|
||||||
|
let lossHistPtr = 0;
|
||||||
|
let lossHistLen = 0;
|
||||||
|
|
||||||
async function loadModule(seed: number): Promise<void> {
|
async function loadModule(seed: number): Promise<void> {
|
||||||
// nisps.js is non-ES-module Emscripten glue; fetch + indirect-eval to
|
// 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<WorkerRequest, { kind: 'train' }>): WorkerResponse {
|
function trainOnce(req: Extract<WorkerRequest, { kind: 'train' }>): WorkerResponse {
|
||||||
if (!mod) {
|
if (!mod) {
|
||||||
return { kind: 'error', requestId: req.requestId, message: 'worker not initialised' };
|
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 view = new Float32Array(mod.HEAPF32.buffer, weightsPtr, weightCount);
|
||||||
const outWeights = new Float32Array(view); // copy
|
const outWeights = new Float32Array(view); // copy
|
||||||
|
|
||||||
const lossHistory = new Float32Array([loss]);
|
const lossHistory = readLossHistory();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: 'result',
|
kind: 'result',
|
||||||
|
|
@ -335,6 +357,7 @@ if (isWorker) {
|
||||||
if (featuresPtr) { mod._free(featuresPtr); featuresPtr = 0; }
|
if (featuresPtr) { mod._free(featuresPtr); featuresPtr = 0; }
|
||||||
if (labelsPtr) { mod._free(labelsPtr); labelsPtr = 0; }
|
if (labelsPtr) { mod._free(labelsPtr); labelsPtr = 0; }
|
||||||
if (sampleWeightsPtr) { mod._free(sampleWeightsPtr); sampleWeightsPtr = 0; }
|
if (sampleWeightsPtr) { mod._free(sampleWeightsPtr); sampleWeightsPtr = 0; }
|
||||||
|
if (lossHistPtr) { mod._free(lossHistPtr); lossHistPtr = 0; lossHistLen = 0; }
|
||||||
mod = null;
|
mod = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -586,6 +586,7 @@ export const BreakorSchema: ModeSchema = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
voice_spaces: [],
|
voice_spaces: [],
|
||||||
|
curve_overrides: [],
|
||||||
ui: {
|
ui: {
|
||||||
primary_input: 'xy_pad',
|
primary_input: 'xy_pad',
|
||||||
show_voice_space_selector: false,
|
show_voice_space_selector: false,
|
||||||
|
|
|
||||||
|
|
@ -273,6 +273,19 @@ export const ChannelStripSchema: ModeSchema = {
|
||||||
'FemaleVox',
|
'FemaleVox',
|
||||||
'Neve 80',
|
'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: {
|
ui: {
|
||||||
primary_input: 'joystick',
|
primary_input: 'joystick',
|
||||||
show_voice_space_selector: true,
|
show_voice_space_selector: true,
|
||||||
|
|
|
||||||
|
|
@ -426,6 +426,7 @@ export const ElysiamorfSchema: ModeSchema = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
voice_spaces: [],
|
voice_spaces: [],
|
||||||
|
curve_overrides: [],
|
||||||
ui: {
|
ui: {
|
||||||
primary_input: 'xy_pad',
|
primary_input: 'xy_pad',
|
||||||
show_voice_space_selector: false,
|
show_voice_space_selector: false,
|
||||||
|
|
|
||||||
|
|
@ -588,6 +588,7 @@ export const MemlceliumSchema: ModeSchema = {
|
||||||
voice_spaces: [
|
voice_spaces: [
|
||||||
'Direct',
|
'Direct',
|
||||||
],
|
],
|
||||||
|
curve_overrides: [],
|
||||||
ui: {
|
ui: {
|
||||||
primary_input: 'xy_pad',
|
primary_input: 'xy_pad',
|
||||||
show_voice_space_selector: false,
|
show_voice_space_selector: false,
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,69 @@ export const PafSynthSchema: ModeSchema = {
|
||||||
'Elderstar',
|
'Elderstar',
|
||||||
'Ipeleiades',
|
'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: {
|
ui: {
|
||||||
primary_input: 'xy_pad',
|
primary_input: 'xy_pad',
|
||||||
show_voice_space_selector: true,
|
show_voice_space_selector: true,
|
||||||
|
|
|
||||||
|
|
@ -588,6 +588,7 @@ export const SlpWorkshopSchema: ModeSchema = {
|
||||||
voice_spaces: [
|
voice_spaces: [
|
||||||
'Direct',
|
'Direct',
|
||||||
],
|
],
|
||||||
|
curve_overrides: [],
|
||||||
ui: {
|
ui: {
|
||||||
primary_input: 'xy_pad',
|
primary_input: 'xy_pad',
|
||||||
show_voice_space_selector: false,
|
show_voice_space_selector: false,
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,7 @@ export const SoundAnalysisMidiSchema: ModeSchema = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
voice_spaces: [],
|
voice_spaces: [],
|
||||||
|
curve_overrides: [],
|
||||||
ui: {
|
ui: {
|
||||||
primary_input: 'audio_in',
|
primary_input: 'audio_in',
|
||||||
show_voice_space_selector: false,
|
show_voice_space_selector: false,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,20 @@ export interface Param {
|
||||||
readonly group: 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 {
|
export interface MLConfig {
|
||||||
readonly input_channels: readonly string[];
|
readonly input_channels: readonly string[];
|
||||||
readonly input_size: number;
|
readonly input_size: number;
|
||||||
|
|
@ -48,5 +62,21 @@ export interface ModeSchema {
|
||||||
readonly ml: MLConfig;
|
readonly ml: MLConfig;
|
||||||
readonly params: readonly Param[];
|
readonly params: readonly Param[];
|
||||||
readonly voice_spaces: readonly string[];
|
readonly voice_spaces: readonly string[];
|
||||||
|
readonly curve_overrides: readonly CurveOverride[];
|
||||||
readonly ui: UIConfig;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -509,6 +509,202 @@ export const VerbFxSchema: ModeSchema = {
|
||||||
'Bright',
|
'Bright',
|
||||||
'Harmonic',
|
'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: {
|
ui: {
|
||||||
primary_input: 'joystick',
|
primary_input: 'joystick',
|
||||||
show_voice_space_selector: true,
|
show_voice_space_selector: true,
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,7 @@ export const XiasriSchema: ModeSchema = {
|
||||||
voice_spaces: [
|
voice_spaces: [
|
||||||
'Direct',
|
'Direct',
|
||||||
],
|
],
|
||||||
|
curve_overrides: [],
|
||||||
ui: {
|
ui: {
|
||||||
primary_input: 'joystick',
|
primary_input: 'joystick',
|
||||||
show_voice_space_selector: false,
|
show_voice_space_selector: false,
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@
|
||||||
* register those rules.
|
* register those rules.
|
||||||
*
|
*
|
||||||
* Panel / StatusLine / ControlAxis / CurvePlot / Sparkline were deleted
|
* Panel / StatusLine / ControlAxis / CurvePlot / Sparkline were deleted
|
||||||
* 2026-07 (simplification audit L22) — zero consumers repo-wide. Sparkline/
|
* 2026-07 (simplification audit L22) — zero consumers repo-wide. They stay
|
||||||
* CurvePlot may return with the deferred training-health diagnostics suite.
|
* 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';
|
import '../styles/primitives.css';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,43 @@ test.describe('ML engine — debug probe contract', () => {
|
||||||
expect(loss2).toBeLessThanOrEqual(loss1 + 1e-6);
|
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 }) => {
|
test('async training resolves to a finite non-negative loss', async ({ page }) => {
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
([low, high]) => {
|
([low, high]) => {
|
||||||
|
|
|
||||||
77
manifold/tests/e2e/training-health.spec.ts
Normal file
77
manifold/tests/e2e/training-health.spec.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
134
manifold/tests/loss-history.test.ts
Normal file
134
manifold/tests/loss-history.test.ts
Normal file
|
|
@ -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<EmModule>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
@ -123,8 +123,15 @@ if(NOT EMSCRIPTEN)
|
||||||
${NISPS_TEST_DIR}/test_mode_voice_space.cpp
|
${NISPS_TEST_DIR}/test_mode_voice_space.cpp
|
||||||
${NISPS_TEST_DIR}/test_mode_breakor_events.cpp
|
${NISPS_TEST_DIR}/test_mode_breakor_events.cpp
|
||||||
${NISPS_TEST_DIR}/test_mode_learning.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)
|
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")
|
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang|AppleClang")
|
||||||
target_compile_options(nisps_modes_tests PRIVATE
|
target_compile_options(nisps_modes_tests PRIVATE
|
||||||
|
|
@ -163,6 +170,25 @@ if(NOT EMSCRIPTEN)
|
||||||
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/..
|
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
|
# Standalone parity-check runner. NOT registered with ctest — it's
|
||||||
# invoked from scripts/parity-check.sh which orchestrates native+WASM
|
# invoked from scripts/parity-check.sh which orchestrates native+WASM
|
||||||
# together.
|
# together.
|
||||||
|
|
|
||||||
|
|
@ -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<std::size_t>; // constexpr
|
{ T::input_channel_count() } -> std::convertible_to<std::size_t>; // constexpr
|
||||||
{ T::param_schema() } -> std::same_as<const ParamSchema&>; // constexpr ref
|
{ T::param_schema() } -> std::same_as<const ParamSchema&>; // constexpr ref
|
||||||
{ m.setup(sr) } -> std::same_as<void>;
|
{ m.setup(sr) } -> std::same_as<void>;
|
||||||
|
// 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<DriverConfig>;
|
||||||
{ m.set_input(idx, v) } -> std::same_as<void>;
|
{ m.set_input(idx, v) } -> std::same_as<void>;
|
||||||
{ m.tick_control() } -> std::same_as<void>; // non-RT
|
{ m.tick_control() } -> std::same_as<void>; // non-RT
|
||||||
{ m.process(s) } -> std::same_as<stereosample_t>; // RT
|
{ m.process(s) } -> std::same_as<stereosample_t>; // RT
|
||||||
|
|
|
||||||
|
|
@ -53,14 +53,20 @@ struct stereosample_t {
|
||||||
//
|
//
|
||||||
// `mic_input` true ⇒ codec is configured for mic-level input
|
// `mic_input` true ⇒ codec is configured for mic-level input
|
||||||
// `mic_gain_db` dB of pre-amp gain when `mic_input` is true
|
// `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
|
// `output_volume` 0..1 — analog out master
|
||||||
// `sample_rate` preferred rate (Hz); 0 means "don't care"
|
// `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 {
|
struct DriverConfig {
|
||||||
bool mic_input = false;
|
bool mic_input = false;
|
||||||
std::uint8_t mic_gain_db = 0;
|
std::uint8_t mic_gain_db = 0;
|
||||||
std::uint8_t line_level = 0;
|
std::uint8_t line_level = 3;
|
||||||
float output_volume = 1.f;
|
float output_volume = 0.8f;
|
||||||
float sample_rate = 0.f;
|
float sample_rate = 0.f;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ struct ModeRoutesOutputsToEngine : std::true_type {};
|
||||||
// void on_setup(float sample_rate) noexcept // optional hook
|
// void on_setup(float sample_rate) noexcept // optional hook
|
||||||
// void on_pre_inference() noexcept // optional, before ml_.process()
|
// void on_pre_inference() noexcept // optional, before ml_.process()
|
||||||
// void on_post_inference() noexcept // optional, after engine.set_params()
|
// 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
|
// Derived classes may choose the engine type (`EngineT`) and ML type
|
||||||
// (`MLPType`) freely; both must satisfy `MLEngine` and `AudioEngine`
|
// (`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<const Derived&>(*this).on_driver_config();
|
||||||
|
} else {
|
||||||
|
return engine_.driver_config();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
NISPS_FORCE_INLINE void set_input(std::size_t idx, float value) noexcept {
|
NISPS_FORCE_INLINE void set_input(std::size_t idx, float value) noexcept {
|
||||||
if (idx >= NInputs) return;
|
if (idx >= NInputs) return;
|
||||||
if (value < 0.f) value = 0.f;
|
if (value < 0.f) value = 0.f;
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ class ExternalSynthMIDIMode : public ModeBase<
|
||||||
|
|
||||||
static constexpr std::array<generated::Param, 0> kNoParams{};
|
static constexpr std::array<generated::Param, 0> kNoParams{};
|
||||||
static constexpr std::array<std::string_view, 0> kNoVoiceSpaces{};
|
static constexpr std::array<std::string_view, 0> kNoVoiceSpaces{};
|
||||||
|
static constexpr std::array<generated::CurveOverride, 0> kNoCurveOverrides{};
|
||||||
static constexpr generated::UIConfig kUI{generated::PrimaryInput::Joystick, false, false};
|
static constexpr generated::UIConfig kUI{generated::PrimaryInput::Joystick, false, false};
|
||||||
|
|
||||||
static inline constexpr ParamSchema kSchema = ParamSchema{
|
static inline constexpr ParamSchema kSchema = ParamSchema{
|
||||||
|
|
@ -189,6 +190,7 @@ class ExternalSynthMIDIMode : public ModeBase<
|
||||||
ext_synth_defaults::kDefaultSpread,
|
ext_synth_defaults::kDefaultSpread,
|
||||||
std::span<const generated::Param>(kNoParams),
|
std::span<const generated::Param>(kNoParams),
|
||||||
std::span<const std::string_view>(kNoVoiceSpaces),
|
std::span<const std::string_view>(kNoVoiceSpaces),
|
||||||
|
std::span<const generated::CurveOverride>(kNoCurveOverrides),
|
||||||
kUI,
|
kUI,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -540,6 +540,8 @@ inline constexpr std::array<Param, kBreakorParamCount> kBreakorParams = {{
|
||||||
inline constexpr std::size_t kBreakorVoiceSpaceCount = 0u;
|
inline constexpr std::size_t kBreakorVoiceSpaceCount = 0u;
|
||||||
inline constexpr std::array<std::string_view, 0> kBreakorVoiceSpaces = {};
|
inline constexpr std::array<std::string_view, 0> kBreakorVoiceSpaces = {};
|
||||||
|
|
||||||
|
inline constexpr std::array<CurveOverride, 0> kBreakorCurveOverrides = {};
|
||||||
|
|
||||||
inline constexpr UIConfig kBreakorUI = {
|
inline constexpr UIConfig kBreakorUI = {
|
||||||
PrimaryInput::XYPad,
|
PrimaryInput::XYPad,
|
||||||
false,
|
false,
|
||||||
|
|
@ -558,6 +560,7 @@ inline constexpr ::nisps::ParamSchema kBreakorSchema = {
|
||||||
kBreakorMLConfig.default_spread,
|
kBreakorMLConfig.default_spread,
|
||||||
std::span<const Param>(kBreakorParams),
|
std::span<const Param>(kBreakorParams),
|
||||||
std::span<const std::string_view>(kBreakorVoiceSpaces),
|
std::span<const std::string_view>(kBreakorVoiceSpaces),
|
||||||
|
std::span<const CurveOverride>(kBreakorCurveOverrides),
|
||||||
kBreakorUI,
|
kBreakorUI,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,20 @@ inline constexpr std::array<std::string_view, kChannelStripVoiceSpaceCount> kCha
|
||||||
"Neve 80",
|
"Neve 80",
|
||||||
}};
|
}};
|
||||||
|
|
||||||
|
inline constexpr std::array<CurveOverride, 11u> 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 = {
|
inline constexpr UIConfig kChannelStripUI = {
|
||||||
PrimaryInput::Joystick,
|
PrimaryInput::Joystick,
|
||||||
true,
|
true,
|
||||||
|
|
@ -277,6 +291,7 @@ inline constexpr ::nisps::ParamSchema kChannelStripSchema = {
|
||||||
kChannelStripMLConfig.default_spread,
|
kChannelStripMLConfig.default_spread,
|
||||||
std::span<const Param>(kChannelStripParams),
|
std::span<const Param>(kChannelStripParams),
|
||||||
std::span<const std::string_view>(kChannelStripVoiceSpaces),
|
std::span<const std::string_view>(kChannelStripVoiceSpaces),
|
||||||
|
std::span<const CurveOverride>(kChannelStripCurveOverrides),
|
||||||
kChannelStripUI,
|
kChannelStripUI,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -396,6 +396,8 @@ inline constexpr std::array<Param, kElysiamorfParamCount> kElysiamorfParams = {{
|
||||||
inline constexpr std::size_t kElysiamorfVoiceSpaceCount = 0u;
|
inline constexpr std::size_t kElysiamorfVoiceSpaceCount = 0u;
|
||||||
inline constexpr std::array<std::string_view, 0> kElysiamorfVoiceSpaces = {};
|
inline constexpr std::array<std::string_view, 0> kElysiamorfVoiceSpaces = {};
|
||||||
|
|
||||||
|
inline constexpr std::array<CurveOverride, 0> kElysiamorfCurveOverrides = {};
|
||||||
|
|
||||||
inline constexpr UIConfig kElysiamorfUI = {
|
inline constexpr UIConfig kElysiamorfUI = {
|
||||||
PrimaryInput::XYPad,
|
PrimaryInput::XYPad,
|
||||||
false,
|
false,
|
||||||
|
|
@ -414,6 +416,7 @@ inline constexpr ::nisps::ParamSchema kElysiamorfSchema = {
|
||||||
kElysiamorfMLConfig.default_spread,
|
kElysiamorfMLConfig.default_spread,
|
||||||
std::span<const Param>(kElysiamorfParams),
|
std::span<const Param>(kElysiamorfParams),
|
||||||
std::span<const std::string_view>(kElysiamorfVoiceSpaces),
|
std::span<const std::string_view>(kElysiamorfVoiceSpaces),
|
||||||
|
std::span<const CurveOverride>(kElysiamorfCurveOverrides),
|
||||||
kElysiamorfUI,
|
kElysiamorfUI,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -542,6 +542,8 @@ inline constexpr std::array<std::string_view, kMemlceliumVoiceSpaceCount> kMemlc
|
||||||
"Direct",
|
"Direct",
|
||||||
}};
|
}};
|
||||||
|
|
||||||
|
inline constexpr std::array<CurveOverride, 0> kMemlceliumCurveOverrides = {};
|
||||||
|
|
||||||
inline constexpr UIConfig kMemlceliumUI = {
|
inline constexpr UIConfig kMemlceliumUI = {
|
||||||
PrimaryInput::XYPad,
|
PrimaryInput::XYPad,
|
||||||
false,
|
false,
|
||||||
|
|
@ -560,6 +562,7 @@ inline constexpr ::nisps::ParamSchema kMemlceliumSchema = {
|
||||||
kMemlceliumMLConfig.default_spread,
|
kMemlceliumMLConfig.default_spread,
|
||||||
std::span<const Param>(kMemlceliumParams),
|
std::span<const Param>(kMemlceliumParams),
|
||||||
std::span<const std::string_view>(kMemlceliumVoiceSpaces),
|
std::span<const std::string_view>(kMemlceliumVoiceSpaces),
|
||||||
|
std::span<const CurveOverride>(kMemlceliumCurveOverrides),
|
||||||
kMemlceliumUI,
|
kMemlceliumUI,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,70 @@ inline constexpr std::array<std::string_view, kPafSynthVoiceSpaceCount> kPafSynt
|
||||||
"Ipeleiades",
|
"Ipeleiades",
|
||||||
}};
|
}};
|
||||||
|
|
||||||
|
inline constexpr std::array<CurveOverride, 61u> 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 = {
|
inline constexpr UIConfig kPafSynthUI = {
|
||||||
PrimaryInput::XYPad,
|
PrimaryInput::XYPad,
|
||||||
true,
|
true,
|
||||||
|
|
@ -359,6 +423,7 @@ inline constexpr ::nisps::ParamSchema kPafSynthSchema = {
|
||||||
kPafSynthMLConfig.default_spread,
|
kPafSynthMLConfig.default_spread,
|
||||||
std::span<const Param>(kPafSynthParams),
|
std::span<const Param>(kPafSynthParams),
|
||||||
std::span<const std::string_view>(kPafSynthVoiceSpaces),
|
std::span<const std::string_view>(kPafSynthVoiceSpaces),
|
||||||
|
std::span<const CurveOverride>(kPafSynthCurveOverrides),
|
||||||
kPafSynthUI,
|
kPafSynthUI,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,17 @@ struct Param {
|
||||||
std::string_view group;
|
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 {
|
struct MLConfig {
|
||||||
std::size_t input_size;
|
std::size_t input_size;
|
||||||
std::size_t output_size;
|
std::size_t output_size;
|
||||||
|
|
@ -73,9 +84,21 @@ struct ParamSchema {
|
||||||
float default_spread;
|
float default_spread;
|
||||||
std::span<const ::nisps::modes::generated::Param> params;
|
std::span<const ::nisps::modes::generated::Param> params;
|
||||||
std::span<const std::string_view> voice_spaces;
|
std::span<const std::string_view> voice_spaces;
|
||||||
|
std::span<const ::nisps::modes::generated::CurveOverride> curve_overrides;
|
||||||
::nisps::modes::generated::UIConfig ui;
|
::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
|
} // namespace nisps
|
||||||
|
|
||||||
#endif // NISPS_GENERATED_SCHEMA_TYPES_HPP
|
#endif // NISPS_GENERATED_SCHEMA_TYPES_HPP
|
||||||
|
|
|
||||||
|
|
@ -542,6 +542,8 @@ inline constexpr std::array<std::string_view, kSlpWorkshopVoiceSpaceCount> kSlpW
|
||||||
"Direct",
|
"Direct",
|
||||||
}};
|
}};
|
||||||
|
|
||||||
|
inline constexpr std::array<CurveOverride, 0> kSlpWorkshopCurveOverrides = {};
|
||||||
|
|
||||||
inline constexpr UIConfig kSlpWorkshopUI = {
|
inline constexpr UIConfig kSlpWorkshopUI = {
|
||||||
PrimaryInput::XYPad,
|
PrimaryInput::XYPad,
|
||||||
false,
|
false,
|
||||||
|
|
@ -560,6 +562,7 @@ inline constexpr ::nisps::ParamSchema kSlpWorkshopSchema = {
|
||||||
kSlpWorkshopMLConfig.default_spread,
|
kSlpWorkshopMLConfig.default_spread,
|
||||||
std::span<const Param>(kSlpWorkshopParams),
|
std::span<const Param>(kSlpWorkshopParams),
|
||||||
std::span<const std::string_view>(kSlpWorkshopVoiceSpaces),
|
std::span<const std::string_view>(kSlpWorkshopVoiceSpaces),
|
||||||
|
std::span<const CurveOverride>(kSlpWorkshopCurveOverrides),
|
||||||
kSlpWorkshopUI,
|
kSlpWorkshopUI,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,8 @@ inline constexpr std::array<Param, kSoundAnalysisMidiParamCount> kSoundAnalysisM
|
||||||
inline constexpr std::size_t kSoundAnalysisMidiVoiceSpaceCount = 0u;
|
inline constexpr std::size_t kSoundAnalysisMidiVoiceSpaceCount = 0u;
|
||||||
inline constexpr std::array<std::string_view, 0> kSoundAnalysisMidiVoiceSpaces = {};
|
inline constexpr std::array<std::string_view, 0> kSoundAnalysisMidiVoiceSpaces = {};
|
||||||
|
|
||||||
|
inline constexpr std::array<CurveOverride, 0> kSoundAnalysisMidiCurveOverrides = {};
|
||||||
|
|
||||||
inline constexpr UIConfig kSoundAnalysisMidiUI = {
|
inline constexpr UIConfig kSoundAnalysisMidiUI = {
|
||||||
PrimaryInput::AudioIn,
|
PrimaryInput::AudioIn,
|
||||||
false,
|
false,
|
||||||
|
|
@ -132,6 +134,7 @@ inline constexpr ::nisps::ParamSchema kSoundAnalysisMidiSchema = {
|
||||||
kSoundAnalysisMidiMLConfig.default_spread,
|
kSoundAnalysisMidiMLConfig.default_spread,
|
||||||
std::span<const Param>(kSoundAnalysisMidiParams),
|
std::span<const Param>(kSoundAnalysisMidiParams),
|
||||||
std::span<const std::string_view>(kSoundAnalysisMidiVoiceSpaces),
|
std::span<const std::string_view>(kSoundAnalysisMidiVoiceSpaces),
|
||||||
|
std::span<const CurveOverride>(kSoundAnalysisMidiCurveOverrides),
|
||||||
kSoundAnalysisMidiUI,
|
kSoundAnalysisMidiUI,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -472,6 +472,203 @@ inline constexpr std::array<std::string_view, kVerbFxVoiceSpaceCount> kVerbFxVoi
|
||||||
"Harmonic",
|
"Harmonic",
|
||||||
}};
|
}};
|
||||||
|
|
||||||
|
inline constexpr std::array<CurveOverride, 194u> 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 = {
|
inline constexpr UIConfig kVerbFxUI = {
|
||||||
PrimaryInput::Joystick,
|
PrimaryInput::Joystick,
|
||||||
true,
|
true,
|
||||||
|
|
@ -490,6 +687,7 @@ inline constexpr ::nisps::ParamSchema kVerbFxSchema = {
|
||||||
kVerbFxMLConfig.default_spread,
|
kVerbFxMLConfig.default_spread,
|
||||||
std::span<const Param>(kVerbFxParams),
|
std::span<const Param>(kVerbFxParams),
|
||||||
std::span<const std::string_view>(kVerbFxVoiceSpaces),
|
std::span<const std::string_view>(kVerbFxVoiceSpaces),
|
||||||
|
std::span<const CurveOverride>(kVerbFxCurveOverrides),
|
||||||
kVerbFxUI,
|
kVerbFxUI,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,8 @@ inline constexpr std::array<std::string_view, kXiasriVoiceSpaceCount> kXiasriVoi
|
||||||
"Direct",
|
"Direct",
|
||||||
}};
|
}};
|
||||||
|
|
||||||
|
inline constexpr std::array<CurveOverride, 0> kXiasriCurveOverrides = {};
|
||||||
|
|
||||||
inline constexpr UIConfig kXiasriUI = {
|
inline constexpr UIConfig kXiasriUI = {
|
||||||
PrimaryInput::Joystick,
|
PrimaryInput::Joystick,
|
||||||
false,
|
false,
|
||||||
|
|
@ -272,6 +274,7 @@ inline constexpr ::nisps::ParamSchema kXiasriSchema = {
|
||||||
kXiasriMLConfig.default_spread,
|
kXiasriMLConfig.default_spread,
|
||||||
std::span<const Param>(kXiasriParams),
|
std::span<const Param>(kXiasriParams),
|
||||||
std::span<const std::string_view>(kXiasriVoiceSpaces),
|
std::span<const std::string_view>(kXiasriVoiceSpaces),
|
||||||
|
std::span<const CurveOverride>(kXiasriCurveOverrides),
|
||||||
kXiasriUI,
|
kXiasriUI,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,15 @@ class SoundAnalysisMIDIMode : public ModeBase<
|
||||||
analysis_.setup(sample_rate);
|
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
|
// Audio path tap — modes call this from the audio thread (or the
|
||||||
// platform glue does after process()) to feed the analyser. Distinct
|
// platform glue does after process()) to feed the analyser. Distinct
|
||||||
// from the audio engine's process() which is the silent passthrough.
|
// from the audio engine's process() which is the silent passthrough.
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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:
|
Engine-id strings follow the C++ `engine_id()` constexpr accessors:
|
||||||
|
|
|
||||||
|
|
@ -523,6 +523,29 @@ float nisps_ml_eval_loss(void* ml) {
|
||||||
return h->mlp.eval_loss();
|
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<MLHandle*>(ml);
|
||||||
|
const auto hist = h->mlp.loss_history();
|
||||||
|
const int count = static_cast<int>(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<std::size_t>(i)];
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ML weights
|
// ML weights
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,22 @@
|
||||||
],
|
],
|
||||||
"voice_spaces": [
|
"voice_spaces": [
|
||||||
"WannabeNeve66",
|
"WannabeNeve66",
|
||||||
"SSL 4K G-ist",
|
{ "name": "SSL 4K G-ist", "curve_overrides": {
|
||||||
"SSL 9K-inda",
|
"comp_ratio": "square"
|
||||||
"MaleVox",
|
} },
|
||||||
"FemaleVox",
|
{ "name": "SSL 9K-inda", "curve_overrides": {
|
||||||
"Neve 80"
|
"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": {
|
"ui": {
|
||||||
"primary_input": "joystick",
|
"primary_input": "joystick",
|
||||||
|
|
|
||||||
|
|
@ -46,13 +46,36 @@
|
||||||
{ "name": "env_release","label": "Env Release", "min": 0.0, "max": 1.0, "default": 0.3, "curve": "linear", "group": "envelope" }
|
{ "name": "env_release","label": "Env Release", "min": 0.0, "max": 1.0, "default": 0.3, "curve": "linear", "group": "envelope" }
|
||||||
],
|
],
|
||||||
"voice_spaces": [
|
"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",
|
"Rowantares",
|
||||||
"Neemeda",
|
{ "name": "Neemeda", "curve_overrides": {
|
||||||
"Aquillow",
|
"p10": "square", "p13": "square", "p23": "square", "p24": "square"
|
||||||
"Magnetarch",
|
} },
|
||||||
"Elderstar",
|
{ "name": "Aquillow", "curve_overrides": {
|
||||||
"Ipeleiades"
|
"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": {
|
"ui": {
|
||||||
"primary_input": "xy_pad",
|
"primary_input": "xy_pad",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,27 @@ This document captures provenance and judgement calls for each mode schema. Read
|
||||||
## Conventions
|
## 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.
|
- 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<int>(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`).
|
- `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
|
## 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)
|
### paf_synth (33 params, 7 voice spaces)
|
||||||
- Source of truth: `PAFSynthAudioApp.hpp` + `voicespaces/VoiceSpace*.hpp`.
|
- 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.
|
- 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)
|
### channel_strip (24 params, 6 voice spaces)
|
||||||
- Source of truth: `ChannelStripAudioApp.hpp` + `voicespaces/ChannelStrip/basic.hpp`.
|
- 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.
|
- 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)
|
### xiasri (24 params, 0 voice spaces — direct mapping)
|
||||||
- Source of truth: `XIASRIAudioApp.hpp::Process()`.
|
- 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`.
|
- 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.
|
- 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.
|
- 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)
|
### memlcelium (56 params, 0 effective voice spaces)
|
||||||
- Source of truth: `modes/AudioApps/MEMLCeliumAudioApp.hpp::ProcessParams()`.
|
- Source of truth: `modes/AudioApps/MEMLCeliumAudioApp.hpp::ProcessParams()`.
|
||||||
- Voice spaces are commented out in firmware; we expose a "Direct" placeholder.
|
- 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).
|
- 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)
|
### breakor (56 params, 0 voice spaces)
|
||||||
- Source of truth: `modes/AudioApps/BreakOrAudioApp.hpp` + `RatioSeqEngine::updateParams()`.
|
- Source of truth: `modes/AudioApps/BreakOrAudioApp.hpp` + `RatioSeqEngine::updateParams()`.
|
||||||
|
|
|
||||||
|
|
@ -61,17 +61,78 @@
|
||||||
],
|
],
|
||||||
"voice_spaces": [
|
"voice_spaces": [
|
||||||
"Default",
|
"Default",
|
||||||
"Resonant",
|
{ "name": "Resonant", "curve_overrides": {
|
||||||
"Soft",
|
"fbank_res0": "sqrt", "fbank_res1": "sqrt", "fbank_res2": "sqrt", "fbank_res3": "sqrt",
|
||||||
"Cathedral",
|
"fbank_res4": "sqrt", "fbank_res5": "sqrt", "fbank_res6": "sqrt", "fbank_res7": "sqrt"
|
||||||
"Shimmer",
|
} },
|
||||||
"Chamber",
|
{ "name": "Soft", "curve_overrides": {
|
||||||
"Metallic",
|
"lp0_fb": "square", "lp1_fb": "square", "lp2_fb": "square", "lp3_fb": "square",
|
||||||
"Granular",
|
"lp4_fb": "square", "lp5_fb": "square", "lp6_fb": "square", "lp7_fb": "square",
|
||||||
"Diffuse",
|
"allp0_fb": "square", "allp1_fb": "square", "allp2_fb": "square", "allp3_fb": "square",
|
||||||
"Dark",
|
"fbank_res0": "square", "fbank_res1": "square", "fbank_res2": "square", "fbank_res3": "square",
|
||||||
"Bright",
|
"fbank_res4": "square", "fbank_res5": "square", "fbank_res6": "square", "fbank_res7": "square",
|
||||||
"Harmonic"
|
"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": {
|
"ui": {
|
||||||
"primary_input": "joystick",
|
"primary_input": "joystick",
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,8 @@
|
||||||
"default": { "type": "number" },
|
"default": { "type": "number" },
|
||||||
"curve": {
|
"curve": {
|
||||||
"type": "string",
|
"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" },
|
"group": { "type": "string" },
|
||||||
"_note": { "type": "string" }
|
"_note": { "type": "string" }
|
||||||
|
|
@ -87,8 +88,30 @@
|
||||||
},
|
},
|
||||||
"voice_spaces": {
|
"voice_spaces": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "type": "string" },
|
"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.",
|
||||||
"description": "Names of C++ voice space lambdas the mode supports. May be empty for engines with no voice spaces (e.g. sequencers)."
|
"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": {
|
"ui": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
|
||||||
164
scripts/bench-engines.sh
Executable file
164
scripts/bench-engines.sh
Executable file
|
|
@ -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[@]}"
|
||||||
|
|
@ -2,11 +2,13 @@
|
||||||
# scripts/build-cpp-tests.sh — configure + build the host C++ test suite.
|
# scripts/build-cpp-tests.sh — configure + build the host C++ test suite.
|
||||||
#
|
#
|
||||||
# Output: nisps/build/{nisps_core_tests,nisps_dsp_engine_tests,
|
# 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
|
# Adds CTest registration for the first four. The last two are standalone:
|
||||||
# scripts/parity-check.sh (orchestrates native+WASM together) and is NOT
|
# parity_check is invoked by scripts/parity-check.sh (orchestrates native+WASM
|
||||||
# part of the ctest pipeline.
|
# 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:
|
# Honours these env vars:
|
||||||
# CMAKE_BUILD_TYPE default Release; pass Debug for stepping
|
# CMAKE_BUILD_TYPE default Release; pass Debug for stepping
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ EXPORTED_FUNCS='[
|
||||||
"_nisps_ml_create","_nisps_ml_destroy","_nisps_ml_reshape",
|
"_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_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_add_example","_nisps_ml_train","_nisps_ml_set_train_config","_nisps_ml_eval_loss",
|
||||||
|
"_nisps_ml_loss_history",
|
||||||
"_nisps_ml_clear_examples",
|
"_nisps_ml_clear_examples",
|
||||||
"_nisps_ml_weight_count","_nisps_ml_get_weights","_nisps_ml_set_weights",
|
"_nisps_ml_weight_count","_nisps_ml_get_weights","_nisps_ml_set_weights",
|
||||||
"_nisps_ml_draw_weights",
|
"_nisps_ml_draw_weights",
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,23 @@
|
||||||
# 2. WASM build → scripts/build-wasm.sh
|
# 2. WASM build → scripts/build-wasm.sh
|
||||||
# 3. Parity check → scripts/parity-check.sh
|
# 3. Parity check → scripts/parity-check.sh
|
||||||
# 4. Lint → scripts/lint-cpp.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:
|
# Flags via env:
|
||||||
# NISPS_SKIP_PLAYWRIGHT=1 skip the Playwright leg (useful in C++-only loops)
|
# 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_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
|
# NISPS_LINT_STRICT=1 treat lint warnings as failures
|
||||||
# PLAYWRIGHT_BROWSERS_PATH respected (on the VPS point it at the snap-bun
|
# PLAYWRIGHT_BROWSERS_PATH respected (on the VPS point it at the snap-bun
|
||||||
# browser cache — see docs/specs/plans/BUILD-PLAN.md)
|
# browser cache — see docs/specs/plans/BUILD-PLAN.md)
|
||||||
|
|
@ -27,33 +39,36 @@ cd "$ROOT"
|
||||||
|
|
||||||
stage() { printf '\n=== %s ===\n' "$1"; }
|
stage() { printf '\n=== %s ===\n' "$1"; }
|
||||||
|
|
||||||
stage "1/5 C++ build + ctest"
|
stage "1/6 C++ build + ctest"
|
||||||
"$ROOT/scripts/build-cpp-tests.sh"
|
"$ROOT/scripts/build-cpp-tests.sh"
|
||||||
|
|
||||||
if [[ "${NISPS_SKIP_WASM:-0}" != "1" ]]; then
|
if [[ "${NISPS_SKIP_WASM:-0}" != "1" ]]; then
|
||||||
stage "2/5 WASM build"
|
stage "2/6 WASM build"
|
||||||
"$ROOT/scripts/build-wasm.sh"
|
"$ROOT/scripts/build-wasm.sh"
|
||||||
|
|
||||||
stage "3/5 parity check"
|
stage "3/6 parity check"
|
||||||
"$ROOT/scripts/parity-check.sh"
|
"$ROOT/scripts/parity-check.sh"
|
||||||
else
|
else
|
||||||
stage "2/5 WASM build (skipped: NISPS_SKIP_WASM=1)"
|
stage "2/6 WASM build (skipped: NISPS_SKIP_WASM=1)"
|
||||||
stage "3/5 parity check (skipped: NISPS_SKIP_WASM=1)"
|
stage "3/6 parity check (skipped: NISPS_SKIP_WASM=1)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
stage "4/5 lint"
|
stage "4/6 lint"
|
||||||
"$ROOT/scripts/lint-cpp.sh"
|
"$ROOT/scripts/lint-cpp.sh"
|
||||||
|
|
||||||
if [[ "${NISPS_SKIP_PLAYWRIGHT:-0}" != "1" ]]; then
|
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
|
if ! command -v bun >/dev/null 2>&1; then
|
||||||
echo "[run-all-tests] bun not on PATH; skipping manifold stage"
|
echo "[run-all-tests] bun not on PATH; skipping manifold stage"
|
||||||
else
|
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"
|
cd "$ROOT/codegen"
|
||||||
bun install --frozen-lockfile 2>/dev/null || bun install
|
bun install --frozen-lockfile 2>/dev/null || bun install
|
||||||
bun run tests/golden_test.ts
|
bun run tests/golden_test.ts
|
||||||
|
bun run tests/curve_drift_test.ts
|
||||||
)
|
)
|
||||||
(
|
(
|
||||||
cd "$ROOT/manifold"
|
cd "$ROOT/manifold"
|
||||||
|
|
@ -69,7 +84,16 @@ if [[ "${NISPS_SKIP_PLAYWRIGHT:-0}" != "1" ]]; then
|
||||||
)
|
)
|
||||||
fi
|
fi
|
||||||
else
|
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
|
fi
|
||||||
|
|
||||||
stage "ALL GREEN"
|
stage "ALL GREEN"
|
||||||
|
|
|
||||||
179
tests/cpp/bench_report.mjs
Normal file
179
tests/cpp/bench_report.mjs
Normal file
|
|
@ -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 <run.json> [<run.json> ...]
|
||||||
|
* [--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": [ <run>, ... ] }
|
||||||
|
* 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 <run.json>... [--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}`);
|
||||||
565
tests/cpp/engine_bench.cpp
Normal file
565
tests/cpp/engine_bench.cpp
Normal file
|
|
@ -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 <old.json>`
|
||||||
|
// 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 <algorithm>
|
||||||
|
#include <array>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
|
#include <span>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<std::uint32_t>(s_ >> 33);
|
||||||
|
}
|
||||||
|
// Uniform in [0,1).
|
||||||
|
float next_unit() noexcept {
|
||||||
|
return static_cast<float>(next_u32()) * (1.f / 4294967296.f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::uint64_t s_;
|
||||||
|
};
|
||||||
|
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
using Seconds = std::chrono::duration<double>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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<Seconds>(t1 - t0).count();
|
||||||
|
return (elapsed * 1e9) / static_cast<double>(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<float> make_input_bed(std::size_t n, std::uint64_t seed, float sample_rate) {
|
||||||
|
std::vector<float> 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<double>(i) / static_cast<double>(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<double>(rng.next_unit()) - 0.5) * 0.20;
|
||||||
|
buf[i] = static_cast<float>(s + noise);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<float> 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<float> 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 <typename EngineT, typename ControlFn>
|
||||||
|
RunStats time_blocks(EngineT& engine,
|
||||||
|
std::size_t blocks,
|
||||||
|
std::size_t block_size,
|
||||||
|
const std::vector<float>& bed,
|
||||||
|
std::vector<float>& in_l,
|
||||||
|
std::vector<float>& in_r,
|
||||||
|
std::vector<float>& out_l,
|
||||||
|
std::vector<float>& 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<double>(out_l[i]) * out_l[i]
|
||||||
|
+ static_cast<double>(out_r[i]) * out_r[i];
|
||||||
|
}
|
||||||
|
st.sumsq += acc;
|
||||||
|
}
|
||||||
|
const auto t1 = Clock::now();
|
||||||
|
st.seconds = std::chrono::duration_cast<Seconds>(t1 - t0).count();
|
||||||
|
return st;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Per-engine harness: prepare → warm up → auto-size → best-of-N timed runs.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
template <typename EngineT, typename PrepareFn, typename ControlFn>
|
||||||
|
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<float> 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>();
|
||||||
|
EngineT& engine = *owned;
|
||||||
|
engine.setup(cfg.sample_rate);
|
||||||
|
if (EngineT::param_count() > 0u) engine.set_params(std::span<const float>(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<double>(blocks)
|
||||||
|
: 1e-9;
|
||||||
|
const double want = (cfg.target_ms / 1000.0) / per_block;
|
||||||
|
blocks = static_cast<std::size_t>(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<double>(blocks) * static_cast<double>(bs);
|
||||||
|
r.blocks = blocks;
|
||||||
|
r.ns_per_sample = (best_seconds * 1e9) / samples;
|
||||||
|
r.blocks_per_s = (best_seconds > 0.0) ? static_cast<double>(blocks) / best_seconds : 0.0;
|
||||||
|
r.realtime_x = (best_seconds > 0.0)
|
||||||
|
? samples / (best_seconds * static_cast<double>(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<double>(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<typename EngineT::Event>{}); }) {
|
||||||
|
// 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 <typename E>
|
||||||
|
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<std::uint8_t, 4> 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 <typename EngineT>
|
||||||
|
struct DrainEvents {
|
||||||
|
std::array<typename EngineT::Event, 64> buf{};
|
||||||
|
long long operator()(EngineT& e, std::size_t) noexcept {
|
||||||
|
return static_cast<long long>(e.pop_events(std::span<typename EngineT::Event>(buf)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reporting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void print_table(const BenchConfig& cfg, double ref_ns, const std::vector<Result>& 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<double>(cfg.sample_rate), cfg.block_size,
|
||||||
|
static_cast<unsigned long long>(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<Result>& rs) {
|
||||||
|
std::printf("{\n");
|
||||||
|
std::printf(" \"target\": \"%s\",\n", cfg.label.c_str());
|
||||||
|
std::printf(" \"sample_rate\": %.0f,\n", static_cast<double>(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<unsigned long long>(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::size_t>(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::size_t>(std::atoi(need("--block-size")));
|
||||||
|
else if (a == "--sample-rate") cfg.sample_rate = static_cast<float>(std::atof(need("--sample-rate")));
|
||||||
|
else if (a == "--seed") cfg.seed = static_cast<std::uint64_t>(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<int>(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<std::size_t>(1u, static_cast<std::size_t>(
|
||||||
|
(static_cast<double>(cfg.sample_rate) * 0.25) / static_cast<double>(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<Result> rs;
|
||||||
|
|
||||||
|
if (wanted(cfg, "thru"))
|
||||||
|
rs.push_back(run_engine<nisps::NoOpEngine>("thru", cfg, ref_ns, no_prepare, NoControl{}));
|
||||||
|
if (wanted(cfg, "paf_synth"))
|
||||||
|
rs.push_back(run_engine<nisps::PAFSynthEngine>("paf_synth", cfg, ref_ns, no_prepare,
|
||||||
|
RetriggerNotes{retrigger_blocks}));
|
||||||
|
if (wanted(cfg, "channel_strip"))
|
||||||
|
rs.push_back(run_engine<nisps::ChannelStripEngine>("channel_strip", cfg, ref_ns, no_prepare, NoControl{}));
|
||||||
|
if (wanted(cfg, "xiasri"))
|
||||||
|
rs.push_back(run_engine<nisps::XIASRIEngine>("xiasri", cfg, ref_ns, no_prepare, NoControl{}));
|
||||||
|
if (wanted(cfg, "verb_fx"))
|
||||||
|
rs.push_back(run_engine<nisps::VerbFXEngine>("verb_fx", cfg, ref_ns, no_prepare, NoControl{}));
|
||||||
|
if (wanted(cfg, "memlcelium"))
|
||||||
|
rs.push_back(run_engine<nisps::MEMLCeliumEngine>("memlcelium", cfg, ref_ns, start_transport, NoControl{}));
|
||||||
|
if (wanted(cfg, "breakor"))
|
||||||
|
rs.push_back(run_engine<nisps::BreakOrEngine>("breakor", cfg, ref_ns, start_transport,
|
||||||
|
DrainEvents<nisps::BreakOrEngine>{}));
|
||||||
|
if (wanted(cfg, "elysiamorf"))
|
||||||
|
rs.push_back(run_engine<nisps::ElysiamorfEngine>("elysiamorf", cfg, ref_ns, start_transport,
|
||||||
|
DrainEvents<nisps::ElysiamorfEngine>{}));
|
||||||
|
if (wanted(cfg, "analysis"))
|
||||||
|
rs.push_back(run_engine<nisps::AnalysisEngine>("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;
|
||||||
|
}
|
||||||
|
|
@ -59,6 +59,48 @@ NISPS_TEST(mlp_xor_converges) {
|
||||||
NISPS_EXPECT(m.loss_history().size() >= 1u);
|
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<float, 2> x{0.25f, 0.75f};
|
||||||
|
std::array<float, 1> y{0.9f};
|
||||||
|
m.add_example(std::span<const float>(x), std::span<const float>(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<float, 1> target{0.1f};
|
||||||
|
m.train_targets(std::span<const float>(x), std::span<const float>(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<const float>(x), std::span<const float>(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) {
|
NISPS_TEST(mlp_train_with_no_examples_returns_zero) {
|
||||||
using M = nisps::ml::MLP<2, 4, 4, 4, 1, 4, 8>;
|
using M = nisps::ml::MLP<2, 4, 4, 4, 1, 4, 8>;
|
||||||
M m(0ull);
|
M m(0ull);
|
||||||
|
|
|
||||||
122
tests/cpp/test_mode_curve_overrides.cpp
Normal file
122
tests/cpp/test_mode_curve_overrides.cpp
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
191
tests/cpp/test_mode_driver_config.cpp
Normal file
191
tests/cpp/test_mode_driver_config.cpp
Normal file
|
|
@ -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<midi::generated::kMoogSub37, 8u> 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<std::uint32_t>(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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue