memlnaut-nisps/nisps/modes/base.hpp

345 lines
15 KiB
C++
Raw Permalink Normal View History

// nisps/modes/base.hpp — Common scaffolding for every concrete mode.
//
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by construction: the diff on the generated directories is PURELY ADDITIVE (217 insertions, 0 deletions), so no emitted constant changed value. This moves where truth lives; it does not change what truth says. - S5: nine mode headers each carried a mechanically identical 12-field positional ParamSchema aggregate. codegen now emits one `inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself moved into generated/schema_types.hpp. Each param_schema() is a one-line return. - S6 + S25: every mode hand-typed its net shape a second time as MLP template args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias built from the already-emitted constants (not re-literalled), and all nine modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2). - S1: model.ts hand-imported all nine schemas by name and hand-paired each with its overlay — so the SET of modes was hand-maintained and could silently drift from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is purely display truth (label/glyph/css/order), which stays hand-curated. - L37: deleted the hand-written modeEngineId switch, which duplicated schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on MFMode.engineId with exactly one documented exception: sound_analysis_midi declares engine_id 'thru' because its ModeBase audio slot really is NoOpEngine, while it separately drives the real AnalysisEngine. - L11: ExternalSynthMIDIMode's shape was literal in two places; now named once in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding into the JSON pipeline is NOT done and the reason is recorded in-file: it is a template family over an externally-supplied Device and variable NOut, with no single (device, NOut) schema to author. - ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and corrected the comment that claimed they had to be separate. Proof the extraction was behaviour-free: regenerating the MIDI-device outputs produces a byte-identical tree. - ST11: deleted codegen/templates/ — dead "reference" files no generator reads, already drifted from the real emitters. Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
// Provides `ModeBase<Derived, EngineT, MLPType, NInputs>` — a CRTP base
// that absorbs the per-mode boilerplate (input forwarding, ML inference
// driving engine params, voice space selection, control event ring
// buffer). Concrete modes derive from this and only specialise:
// - the schema reference (static; `nisps::ParamSchema` — the aggregate
// type the `Mode` concept's `param_schema()` returns a const-reference
// to — is now DEFINED in the codegen output, `generated/schema_types.hpp`,
// included below; codegen also emits one `inline constexpr ParamSchema
// k<Mode>Schema` per mode, so `param_schema()` is a one-line return),
// - the "extra" pre-mapping done before set_params (e.g. analysis
// features stitched into ML inputs in SoundAnalysisMIDI),
// - any engine-specific control glue (note_on/note_off, sequencer
// play/stop, BPM updates).
//
// Modes are platform-agnostic. Hardware/browser glue maps abstract input
// channels (float [0, 1]) into `set_input(idx, value)` and drains
// `pop_control_events()` for MIDI/I2C dispatch.
//
// No heap, no virtuals, no pico/Arduino headers.
#pragma once
#include <array>
#include <cstddef>
#include <cstdint>
#include <span>
#include <string_view>
#include <type_traits>
#include "../core/concepts.hpp"
#include "../core/perf.hpp"
#include "../core/ring_buffer.hpp"
#include "../core/types.hpp"
#include "../ml/jolt.hpp"
#include "../ml/ou_noise.hpp"
#include "generated/schema_types.hpp"
namespace nisps {
refactor(codegen): codegen owns mode identity, per-mode schemas and net dims Phase 3 (S1, S5, S6, S25, L11, L37, ST11, ST12). Behaviour-preserving by construction: the diff on the generated directories is PURELY ADDITIVE (217 insertions, 0 deletions), so no emitted constant changed value. This moves where truth lives; it does not change what truth says. - S5: nine mode headers each carried a mechanically identical 12-field positional ParamSchema aggregate. codegen now emits one `inline constexpr ParamSchema k<Mode>Schema` per mode, and the struct itself moved into generated/schema_types.hpp. Each param_schema() is a one-line return. - S6 + S25: every mode hand-typed its net shape a second time as MLP template args, duplicating the schema's own dims. codegen emits a `<Mode>MLP` alias built from the already-emitted constants (not re-literalled), and all nine modes use it. NMaxExamples still defaults from kDefaultMaxExamples (Phase 2). - S1: model.ts hand-imported all nine schemas by name and hand-paired each with its overlay — so the SET of modes was hand-maintained and could silently drift from codegen. codegen now emits ALL_MODE_SCHEMAS; SCHEMA_MODE_OVERLAYS is purely display truth (label/glyph/css/order), which stays hand-curated. - L37: deleted the hand-written modeEngineId switch, which duplicated schema.engine_id and silently defaulted unknown modes to 'thru'. Routes on MFMode.engineId with exactly one documented exception: sound_analysis_midi declares engine_id 'thru' because its ModeBase audio slot really is NoOpEngine, while it separately drives the real AnalysisEngine. - L11: ExternalSynthMIDIMode's shape was literal in two places; now named once in an ext_synth_defaults namespace with an ExtSynthMIDIMLP alias. Full folding into the JSON pipeline is NOT done and the reason is recorded in-file: it is a template family over an externally-supplied Device and variable NOut, with no single (device, NOut) schema to author. - ST12: extracted codegen/lib.ts for the helpers both generators duplicated, and corrected the comment that claimed they had to be separate. Proof the extraction was behaviour-free: regenerating the MIDI-device outputs produces a byte-identical tree. - ST11: deleted codegen/templates/ — dead "reference" files no generator reads, already drifted from the real emitters. Gates: run-all-tests.sh ALL GREEN; codegen idempotent (re-running both generators yields no further diff), which is what CI's dirty-diff gate checks.
2026-07-21 14:02:23 +02:00
// `nisps::ParamSchema` is defined in generated/schema_types.hpp (included
// above) — codegen owns it (S5, one-core simplification 2026-07) since its
// shape must match the `nisps::Mode` concept's forward declaration
// (nisps/core/concepts.hpp) exactly, and every mode's actual schema VALUE is
// itself codegen output (`generated::k<Mode>Schema`).
// ---------------------------------------------------------------------------
// Abstract control event — emitted by modes for the platform glue to drain.
// Sequencer modes (BreakOr, Elysiamorf) push real events; synth modes push
// none unless they want to relay MIDI thru.
// ---------------------------------------------------------------------------
struct ControlEvent {
enum class Kind : std::uint8_t {
None,
NoteOn,
NoteOff,
ControlChange,
Clock,
};
Kind kind = Kind::None;
std::uint8_t channel = 0u;
std::uint8_t data1 = 0u;
std::uint8_t data2 = 0u;
};
// Sized at the larger of the engines' event buffers (BreakOr/Elysiamorf
// publish 64 entries; we mirror that for consistency).
inline constexpr std::size_t kModeEventBufferSize = 64u;
// Trait controlling whether ModeBase routes ML outputs into engine.set_params().
// Default: true (every synth/effect mode). Specialise to `false` for modes
// that don't (e.g. SoundAnalysisMIDIMode where outputs become MIDI CC).
template <typename Derived>
struct ModeRoutesOutputsToEngine : std::true_type {};
// ---------------------------------------------------------------------------
// ModeBase — CRTP scaffold.
//
// Derived classes provide:
// static constexpr const ParamSchema& schema() // their generated schema
// void on_setup(float sample_rate) noexcept // optional hook
// void on_pre_inference() noexcept // optional, before ml_.process()
// void on_post_inference() noexcept // optional, after engine.set_params()
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.
2026-07-21 22:02:23 +02:00
// DriverConfig on_driver_config() const noexcept // optional, overrides the
// // engine's driver config
//
// Derived classes may choose the engine type (`EngineT`) and ML type
// (`MLPType`) freely; both must satisfy `MLEngine` and `AudioEngine`
// respectively, except for sequencer modes whose engine still satisfies
// `AudioEngine` (process() returns silence).
// ---------------------------------------------------------------------------
template <typename Derived,
typename EngineT,
typename MLPType,
std::size_t NInputs>
class ModeBase {
public:
using Engine = EngineT;
using ML = MLPType;
static_assert(AudioEngine<EngineT>,
"ModeBase: EngineT must satisfy nisps::AudioEngine concept");
static_assert(MLEngine<MLPType>,
"ModeBase: MLPType must satisfy nisps::MLEngine concept");
static_assert(MLPType::kInput == NInputs,
"ModeBase: NInputs must equal MLP::kInput");
// Most modes route ML outputs directly into engine params; require the
// sizes to match. SoundAnalysisMIDI opts out by specialising
// ModeRoutesOutputsToEngine<Derived> to std::false_type.
static constexpr bool kRouteOutputsToEngine =
ModeRoutesOutputsToEngine<Derived>::value;
static_assert(!kRouteOutputsToEngine ||
MLPType::kOutput == EngineT::param_count(),
"ModeBase: MLP output_size must equal engine param_count() "
"unless ModeRoutesOutputsToEngine<Derived> is false");
static constexpr std::size_t input_channel_count() noexcept { return NInputs; }
explicit ModeBase(std::uint64_t seed = 0xC0FFEEu) noexcept
: ml_(seed),
jolt_(seed ^ 0x91E10C5Eull),
ou_(seed ^ 0x0CEA0FF5ull) {}
// ---- Mode concept surface ----
void setup(float sample_rate) noexcept {
sample_rate_ = sample_rate;
engine_.setup(sample_rate);
for (auto& v : input_channels_) v = 0.5f;
// Run an inference at default inputs so engine has params on first
// process() call, even if no input has been touched.
for (std::size_t i = 0u; i < NInputs; ++i) {
ml_.set_input(i, effective_input(i));
}
ml_.process();
if constexpr (kRouteOutputsToEngine) {
engine_.set_params(ml_.outputs());
}
if constexpr (requires(Derived& d, float s) { d.on_setup(s); }) {
static_cast<Derived&>(*this).on_setup(sample_rate);
}
}
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.
2026-07-21 22:02:23 +02:00
// ---- 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 {
if (idx >= NInputs) return;
if (value < 0.f) value = 0.f;
else if (value > 1.f) value = 1.f;
input_channels_[idx] = value;
}
// ---- Input neutralization (single/double controller toggle) ----
//
// A pinned channel feeds `pin_value_` (neutral, default 0.5) to the MLP
// instead of its live value, without rebuilding/resizing the network.
// Glue toggles which channels are pinned (e.g. single-joystick mode pins
// the second 2D controller's two channels). The stored live value is left
// untouched, so unpinning resumes from the controller's current position.
NISPS_FORCE_INLINE void set_input_pinned(std::size_t idx, bool pinned) noexcept {
if (idx >= NInputs) return;
input_pinned_[idx] = pinned;
}
NISPS_FORCE_INLINE bool is_input_pinned(std::size_t idx) const noexcept {
return idx < NInputs && input_pinned_[idx];
}
NISPS_FORCE_INLINE void set_pin_value(float v) noexcept {
if (v < 0.f) v = 0.f;
else if (v > 1.f) v = 1.f;
pin_value_ = v;
}
float pin_value() const noexcept { return pin_value_; }
// Effective value fed to the MLP for channel i (pin override applied).
NISPS_FORCE_INLINE float effective_input(std::size_t i) const noexcept {
return input_pinned_[i] ? pin_value_ : input_channels_[i];
}
NISPS_HOT void tick_control() noexcept {
if constexpr (requires(Derived& d) { d.on_pre_inference(); }) {
static_cast<Derived&>(*this).on_pre_inference();
}
// Jolt: continuous weight morph while a gesture is held. Inert (and
// free) when inactive — no copy, no RNG advance, weights untouched.
// Kept out-of-line so the heavy get/set-weights copy doesn't bloat
// the control path or perturb the optimizer's analysis of the
// control-event ring buffer below.
if (jolt_.active()) apply_jolt_();
jolt_.tick_lr_ramp();
// Forward (possibly Derived-mutated) channels into the MLP, applying
// the per-channel pin override.
for (std::size_t i = 0u; i < NInputs; ++i) {
ml_.set_input(i, effective_input(i));
}
ml_.process();
if constexpr (kRouteOutputsToEngine) {
// Exploration OU walk on the output vector (inert when intensity
// is 0 → falls through to the original direct-route path).
if (ou_.enabled()) {
const auto o = ml_.outputs();
const std::size_t no = o.size();
for (std::size_t i = 0u; i < no && i < out_buf_.size(); ++i) {
out_buf_[i] = o[i];
}
ou_.apply(std::span<float>(out_buf_.data(), no));
engine_.set_params(std::span<const float>(out_buf_.data(), no));
} else {
engine_.set_params(ml_.outputs());
}
}
if constexpr (requires(Derived& d) { d.on_post_inference(); }) {
static_cast<Derived&>(*this).on_post_inference();
}
}
// ---- Adaptive-learning gestures (shared by every mode) ----
//
// Jolt — held gesture that continuously morphs a scatter of weights,
// then freezes them on release (see ml/jolt.hpp). Wire a momentary
// button / MIDI pedal: press on down-edge, release on up-edge.
void jolt_press() noexcept { jolt_.press(MLPType::weight_count()); }
void jolt_release() noexcept { jolt_.release(); }
bool jolt_active() const noexcept { return jolt_.active(); }
// Effective-LR multiplier for the caller's training step (0 while held,
// ramps to 1 after release). Multiply your training LR by this.
float jolt_lr_scale() const noexcept { return jolt_.lr_scale(); }
ml::Jolt& jolt() noexcept { return jolt_; }
const ml::Jolt& jolt() const noexcept { return jolt_; }
// Exploration — Ornstein-Uhlenbeck random walk added to the output
// vector (see ml/ou_noise.hpp). `level` in [0,1]; 0 disables.
void set_explore_intensity(float level) noexcept { ou_.set_intensity(level); }
float explore_intensity() const noexcept { return ou_.intensity(); }
ml::OUNoise<MLPType::kOutput>& ou_noise() noexcept { return ou_; }
const ml::OUNoise<MLPType::kOutput>& ou_noise() const noexcept { return ou_; }
NISPS_HOT NISPS_FORCE_INLINE stereosample_t process(stereosample_t x) noexcept {
return engine_.process(x);
}
Engine& engine() noexcept { return engine_; }
const Engine& engine() const noexcept { return engine_; }
ML& ml() noexcept { return ml_; }
const ML& ml() const noexcept { return ml_; }
// ---- Common helpers ----
// Read-only view of latest input-channel values [0, 1].
std::span<const float> input_channels() const noexcept {
return std::span<const float>(input_channels_.data(), NInputs);
}
// Mutable accessor for derived classes (e.g. SoundAnalysisMIDI splices
// analysis features into the channel array before forwarding to ML).
std::span<float> mutable_input_channels() noexcept {
return std::span<float>(input_channels_.data(), NInputs);
}
// Voice space selection (engines that support it expose set_voice_space).
void set_voice_space(std::size_t idx) noexcept {
if constexpr (requires(EngineT& e) { e.set_voice_space(typename EngineT::VoiceSpace{}); }) {
using VS = typename EngineT::VoiceSpace;
if (idx >= EngineT::kVoiceSpaceCount) return;
engine_.set_voice_space(static_cast<VS>(idx));
voice_space_idx_ = idx;
// Re-apply current params under the new voice space mapping.
engine_.set_params(ml_.outputs());
} else {
(void)idx;
}
}
std::size_t voice_space_index() const noexcept { return voice_space_idx_; }
// Control event ring — modes/derived classes push, hardware glue pops.
NISPS_FORCE_INLINE bool push_control_event(const ControlEvent& e) noexcept {
return events_.try_push(e);
}
std::size_t pop_control_events(std::span<ControlEvent> out) noexcept {
std::size_t n = 0u;
while (n < out.size()) {
ControlEvent e;
if (!events_.try_pop(e)) break;
out[n++] = e;
}
return n;
}
float sample_rate() const noexcept { return sample_rate_; }
private:
// Copy the flat weights out of the MLP, morph the jolt-selected few, and
// write them back. Out-of-line on purpose (see tick_control).
NISPS_NOINLINE void apply_jolt_() noexcept {
const auto w = ml_.get_weights(); // span into ml_'s flat scratch
const std::size_t wc = w.size();
for (std::size_t i = 0u; i < wc && i < jolt_buf_.size(); ++i) {
jolt_buf_[i] = w[i];
}
jolt_.step(std::span<float>(jolt_buf_.data(), wc));
ml_.set_weights(std::span<const float>(jolt_buf_.data(), wc));
}
protected:
float sample_rate_ = 48000.f;
EngineT engine_{};
MLPType ml_;
std::array<float, NInputs> input_channels_{};
std::array<bool, NInputs> input_pinned_{}; // false => live
float pin_value_ = 0.5f; // neutral
std::size_t voice_space_idx_ = 0u;
RingBuffer<ControlEvent, kModeEventBufferSize> events_{};
// Adaptive-learning state (Jolt + OU). Declared AFTER events_ so the
// lock-free ring buffer keeps its original object offset — inserting the
// large jolt_buf_ before it provokes a spurious GCC -Wstringop-overflow
// on the ring's atomic index under -O3. Order matters only to that
// false positive; behaviour is identical either way.
ml::Jolt jolt_;
ml::OUNoise<MLPType::kOutput> ou_;
// Scratch for OU output blending and Jolt weight morphing. Sized to the
// single compiled mode; firmware only ever instantiates one mode.
std::array<float, MLPType::kOutput> out_buf_{};
std::array<float, MLPType::weight_count()> jolt_buf_{};
};
} // namespace nisps