diff --git a/CLAUDE.md b/CLAUDE.md index 7fb1284..cab2a6d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,6 +128,7 @@ The WASM target is fixed at `MLP<2, 10, 14, 18, 126>`. Modes with smaller `outpu - Engine MLP architecture is fixed at compile time — supporting per-mode hidden-layer shapes would need either multiple WASM modules or runtime variation. - Mic input through the worklet for XIASRI / SoundAnalysisMIDI is not wired; UI scaffolds render but feature is TODO. - C15 voice space integration in C15Mode is a placeholder. +- Jolt + OU-explore learning gestures are wired in firmware (`ModeBase`, surfaced on TogB1 / RVX1) and the SLP-Workshop mode runs in the browser, but the *interactive* browser controls for Jolt/OU are not yet wired into the playground UI (the WASM weight bindings exist; the runtime/output-stage hooks are the remaining work). ## URL parameters (playground) diff --git a/MAP.md b/MAP.md index fb0b999..3c5cb40 100644 --- a/MAP.md +++ b/MAP.md @@ -6,10 +6,10 @@ MEMLNaut-NISPS — Neural Interactive Shaping of Parameter Spaces. One C++20 cod ### `nisps/` — platform-agnostic C++20 library (the only ML/DSP/engine code) - `nisps/core/` — `perf.hpp` (memory section attrs), `types.hpp`, `concepts.hpp` (`MLEngine`, `AudioEngine`, `Mode`), `fixed_buffer.hpp`, `ring_buffer.hpp` (SPSC lock-free, replaces pico/util/queue), `rng.hpp` (xoshiro256+ deterministic), `math.hpp` (fast_sigmoid, `Curve` enum + `apply_curve`). -- `nisps/ml/` — MLP class template `MLP`. Files: `mlp.hpp`, `activations.hpp`, `loss.hpp` (MSE, no double-scaling), `training.hpp` (SGD + grad clipping), `init.hpp` (spread-aware uniform↔Xavier), `rl.hpp` (`move_weights` with output pin mask + per-layer scaling + weight decay), `feedback.hpp` (`FeedbackController` — the 3-mode "Down Action" negative-feedback state machine: Avoid / RandomiseOutputs / RandomiseMlp; header-only, zero-heap, own deterministic RNG, exposed via `nisps_ml_feedback_*` C API), `stats.hpp`. +- `nisps/ml/` — MLP class template `MLP`. Files: `mlp.hpp`, `activations.hpp`, `loss.hpp` (MSE, no double-scaling), `training.hpp` (SGD + grad clipping), `init.hpp` (spread-aware uniform↔Xavier), `rl.hpp` (`move_weights` with output pin mask + per-layer scaling + weight decay), `jolt.hpp` (`Jolt` — held continuous weight-morph over the flat weight buffer + post-release LR ramp; ported from upstream InterfaceRL), `ou_noise.hpp` (`OUNoise` — Ornstein-Uhlenbeck exploration walk on the output vector; ported from upstream InterfaceRL), `feedback.hpp` (`FeedbackController` — the 3-mode "Down Action" negative-feedback state machine: Avoid / RandomiseOutputs / RandomiseMlp; header-only, zero-heap, own deterministic RNG, exposed via `nisps_ml_feedback_*` C API), `stats.hpp`. Jolt + OU are inert by default and wired into `ModeBase`, so every mode exposes `jolt_press/jolt_release`, `jolt_lr_scale`, and `set_explore_intensity`. - `nisps/dsp/` — `biquad.hpp`, `delay.hpp`, `reverb.hpp`, `filter.hpp`, `env.hpp`, `osc.hpp`, `pitch_shift.hpp`, `dc_blocker.hpp`. Lean primitives extracted from maximilian; daisysp PitchShifter replaced with custom granular impl. - `nisps/engines/` — eight audio engines, each satisfying `AudioEngine`: `paf_synth.hpp`, `channel_strip.hpp`, `xiasri.hpp`, `verb_fx.hpp`, `memlcelium.hpp`, `breakor.hpp` (sequencer, NoOp audio), `elysiamorf.hpp` (sequencer, NoOp audio), `analysis.hpp` (input-side spectral features). Plus `base.hpp` (`NoOpEngine`, engine_id "thru"). -- `nisps/modes/` — platform-agnostic modes binding `{ML config, engine, voice space lambdas, abstract I/O channels}`. Files: `paf_synth.hpp`, `channel_strip.hpp`, `xiasri.hpp`, `verb_fx.hpp`, `memlcelium.hpp`, `breakor.hpp`, `elysiamorf.hpp`, `sound_analysis_midi.hpp`, `external_synth_midi.hpp` (`ExternalSynthMIDIMode` — joystick→MLP→MIDI CC for an external synth; compile-time device from `nisps/midi`; `consteval pick_cc_slots` curates which params fill the NOut slots; NoOpEngine, `kRouteOutputsToEngine=false`). `base.hpp` provides a CRTP scaffold eliminating the duplication that previously plagued firmware modes. `voice_space.hpp` holds engine-side voice space dispatch helpers. `generated/` contains codegen output (do not edit by hand). +- `nisps/modes/` — platform-agnostic modes binding `{ML config, engine, voice space lambdas, abstract I/O channels}`. Files: `paf_synth.hpp`, `channel_strip.hpp`, `xiasri.hpp`, `verb_fx.hpp`, `memlcelium.hpp`, `slp_workshop.hpp` (`SLPWorkshopMode` — the Synth Library Portland workshop build; reuses the MEMLCelium engine + MLP shape, foregrounds the Jolt + OU explore gestures), `breakor.hpp`, `elysiamorf.hpp`, `sound_analysis_midi.hpp`, `external_synth_midi.hpp` (`ExternalSynthMIDIMode` — joystick→MLP→MIDI CC for an external synth; compile-time device from `nisps/midi`; `consteval pick_cc_slots` curates which params fill the NOut slots; NoOpEngine, `kRouteOutputsToEngine=false`). `base.hpp` provides a CRTP scaffold eliminating the duplication that previously plagued firmware modes. `voice_space.hpp` holds engine-side voice space dispatch helpers. `generated/` contains codegen output (do not edit by hand). - `nisps/wasm/bindings.cpp` — flat C API exported to WASM (Emscripten target only). - `nisps/midi/generated/midi_devices.hpp` — codegen output: no-heap `constexpr` external-MIDI-synth templates (`nisps::midi::generated`; `MidiParam`/`MidiDevice` + `kMidiDevices` registry). Source = `schemas/midi_devices/`; do not edit by hand. - `nisps/CMakeLists.txt` + `nisps/build/` — host-target builds + ctest. @@ -18,7 +18,7 @@ MEMLNaut-NISPS — Neural Interactive Shaping of Parameter Spaces. One C++20 cod - `firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino` — entry point. Selects active mode at compile time via `#define MEMLNAUT_MODE_TYPE`. Forks on `NISPS_SELFTEST`: normal modes run the engine/ML path; the `SelfTest` variant delegates all four entry points to `glue/selftest.hpp`. - `firmware/MEMLNaut-NISPS/glue/` — hardware bindings: - `audio_driver.hpp` — bridges memllib `AudioDriver` callback → `Mode::process(stereosample_t)`. - - `peripherals.hpp` — joystick / pots / buttons → `Mode::set_input` and ML primitives. + - `peripherals.hpp` — joystick / pots / buttons → `Mode::set_input` and ML primitives. Also wires the shared adaptive-learning gestures: **TogB1** = Jolt (held weight morph), **RVX1** = exploration amount (OU output walk). - `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. Build script rewrites the active line. Includes the six `MEMLNautModeExtSynth*` external-synth variants (one per device template in `nisps/midi`, e.g. `MEMLNautModeExtSynthSub37`). Also defines the `MEMLNautModeSelfTest` pseudo-variant (tag type) + the `NISPS_ST_*`/`NISPS_ST_CAT` token-paste macros the `.ino` uses to compute `NISPS_SELFTEST`. Note: `src/nisps/` exposes each referenced top-level nisps subdir as a symlink — `midi` was added alongside `core/dsp/engines/ml/modes`. - `selftest.hpp` — standalone guided hardware self-test rig (`SelfTest` variant; no engine/ML). Step-driven state machine on a `SelfTestView`: TFT prompts the operator through every control, auto-advances on detection, encoder-press skips. Ends with optional L/R/BOTH sine-sweep headphone check (core 1 block callback) + MIDI loopback-cable test. Lives firmware-side (touches TFT + raw pins) so it stays out of platform-agnostic `nisps/`. @@ -90,7 +90,7 @@ the "BUILD DELTAS" block at the top of `vcv/SPEC.md`). `src/MEMLNaut.cpp` (modul ### `schemas/` — JSON parameter contracts (firmware/browser source of truth) - `schemas/schema.json` — Draft 2020-12 meta-schema validating mode files. -- `schemas/modes/.json` (×8) — each mode's params, ranges, defaults, curves, voice spaces, ML config. +- `schemas/modes/.json` (×9) — each mode's params, ranges, defaults, curves, voice spaces, ML config. (`slp_workshop.json` reuses `engine_id: memlcelium`.) - `schemas/modes/params_notes.md` — provenance notes and judgement calls per mode. - `schemas/midi_device.schema.json` — Draft 2020-12 meta-schema for external-MIDI-synth templates. - `schemas/midi_devices/.json` (×6) — CC-controllable external synths (Moog Sub 37 / Sub Phatty, Creamware Pro-12 ASB, Elektron Analog Keys, ASM Hydrasynth, Roland JD-800). Each param: `{id, cc, label, min, max, default, group}`. Canonical source for both firmware + browser device pickers. Verified-CC provenance + sources live in `synth-midi-cc.json` (repo root). diff --git a/firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino b/firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino index 5115790..0a3a2a1 100644 --- a/firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino +++ b/firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino @@ -42,6 +42,7 @@ // #define MEMLNAUT_MODE_TYPE MEMLNautModeChannelStrip #define MEMLNAUT_MODE_TYPE MEMLNautModePAFSynth // #define MEMLNAUT_MODE_TYPE MEMLNautModeMEMLCelium +// #define MEMLNAUT_MODE_TYPE MEMLNautModeSLPWorkshop // ---- External-synth MIDI-CC variants (control an external hardware synth) ---- // #define MEMLNAUT_MODE_TYPE MEMLNautModeExtSynthSub37 // #define MEMLNAUT_MODE_TYPE MEMLNautModeExtSynthSubPhatty diff --git a/firmware/MEMLNaut-NISPS/glue/mode_select.hpp b/firmware/MEMLNaut-NISPS/glue/mode_select.hpp index b6297cd..75001c7 100644 --- a/firmware/MEMLNaut-NISPS/glue/mode_select.hpp +++ b/firmware/MEMLNaut-NISPS/glue/mode_select.hpp @@ -44,6 +44,7 @@ #include "../src/nisps/modes/external_synth_midi.hpp" #include "../src/nisps/modes/memlcelium.hpp" #include "../src/nisps/modes/paf_synth.hpp" +#include "../src/nisps/modes/slp_workshop.hpp" #include "../src/nisps/modes/sound_analysis_midi.hpp" #include "../src/nisps/modes/verb_fx.hpp" #include "../src/nisps/modes/xiasri.hpp" @@ -58,6 +59,10 @@ using MEMLNautModeBreakOr = ::nisps::modes::BreakOrMode; using MEMLNautModeVerbFX = ::nisps::modes::VerbFXMode; using MEMLNautModeElysiamorfs = ::nisps::modes::ElysiamorfMode; using MEMLNautModeMEMLCelium = ::nisps::modes::MEMLCeliumMode; +// SLP-Workshop: the Synth Library Portland workshop build. Reuses the +// MEMLCelium engine; foregrounds the Jolt (TogB1) + OU-explore (RVX1) +// adaptive-learning gestures wired in peripherals.hpp. +using MEMLNautModeSLPWorkshop = ::nisps::modes::SLPWorkshopMode; // ---- External-synth MIDI-CC variants (one flashable variant per device) ---- // Joystick -> MLP -> MIDI CC for the named external hardware synth. Device diff --git a/firmware/MEMLNaut-NISPS/glue/peripherals.hpp b/firmware/MEMLNaut-NISPS/glue/peripherals.hpp index 5e8d2c4..0d6bfb0 100644 --- a/firmware/MEMLNaut-NISPS/glue/peripherals.hpp +++ b/firmware/MEMLNaut-NISPS/glue/peripherals.hpp @@ -20,10 +20,10 @@ // MomA1 (TA up) : randomise / draw weights // MomA2 (TA down) : clear examples (reset dataset) // MomB1 (MA up) : randomise (synonym, deliberate) -// MomB2 (MA down) : jolt / move_weights -// TogB1 : add example (latched: when high, capture current -// inputs+outputs as a training pair) +// MomB2 (MA down) : nudge (small bounded perturbation) +// TogB1 : Jolt — held continuous weight morph (up=morph, down=freeze) // TogB2 : train (rising-edge → call ml.train()) +// RVX1 : exploration amount (Ornstein-Uhlenbeck output walk) // // The button block below wires the SHARED ExploreAndPlace lifecycle // (nisps/ml/feedback.hpp, the same core the browser drives via WASM) to the @@ -97,6 +97,15 @@ inline void bind_peripherals(Mode& mode) { AudioDriver::SetMasterVolume(v); }); + // RVX1 → exploration amount. Drives the Ornstein-Uhlenbeck random walk + // added to the mode's output vector (nisps/ml/ou_noise.hpp, ported from + // upstream InterfaceRL). 0 = off (inert); turning it up makes the sound + // slowly roam so likes/dislikes can steer the net. Available on every + // mode via ModeBase::set_explore_intensity. + meml->setRVX1Callback([&mode](float v) { + mode.set_explore_intensity(v); + }); + // ---- Buttons / toggles → ExploreAndPlace lifecycle (SHARED C++ core) ---- // // The same nisps::ml::FeedbackController that runs in the browser (via @@ -147,6 +156,17 @@ inline void bind_peripherals(Mode& mode) { feedback.begin_place(mode.ml()); }); + // TogB1: Jolt — held continuous weight morph (nisps/ml/jolt.hpp, ported + // from upstream InterfaceRL). Flip up to start morphing a scatter of + // weights live; flip down to freeze them (the change is permanent) and + // let the learning rate ramp gently back in. A toggle (not a momentary) + // because Jolt is a HELD gesture and momentary buttons only fire on + // press. Available on every mode via ModeBase::jolt_press/jolt_release. + meml->setTogB1Callback([&mode](bool state) { + if (state) mode.jolt_press(); + else mode.jolt_release(); + }); + // TogB2 (rising): up → commit place at the current joystick input, then // store the +1 example (input → placed output) and train. Outside Placing, // just train (legacy behaviour). diff --git a/nisps/CMakeLists.txt b/nisps/CMakeLists.txt index a6be410..6d70529 100644 --- a/nisps/CMakeLists.txt +++ b/nisps/CMakeLists.txt @@ -56,6 +56,8 @@ if(NOT EMSCRIPTEN) ${NISPS_TEST_DIR}/test_mlp_training.cpp ${NISPS_TEST_DIR}/test_mlp_loss.cpp ${NISPS_TEST_DIR}/test_mlp_rl.cpp + ${NISPS_TEST_DIR}/test_mlp_jolt.cpp + ${NISPS_TEST_DIR}/test_mlp_ou_noise.cpp ${NISPS_TEST_DIR}/test_mlp_feedback.cpp ${NISPS_TEST_DIR}/test_mlp_serialize.cpp ) @@ -115,6 +117,7 @@ if(NOT EMSCRIPTEN) ${NISPS_TEST_DIR}/test_mode_paf_synth.cpp ${NISPS_TEST_DIR}/test_mode_voice_space.cpp ${NISPS_TEST_DIR}/test_mode_breakor_events.cpp + ${NISPS_TEST_DIR}/test_mode_learning.cpp ) target_link_libraries(nisps_modes_tests PRIVATE nisps_core) diff --git a/nisps/core/perf.hpp b/nisps/core/perf.hpp index 72c4211..357d8a4 100644 --- a/nisps/core/perf.hpp +++ b/nisps/core/perf.hpp @@ -19,10 +19,16 @@ #define NISPS_APP_SRAM __not_in_flash("app") #define NISPS_FORCE_INLINE __attribute__((always_inline)) inline #define NISPS_HOT __attribute__((hot)) + #define NISPS_NOINLINE __attribute__((noinline)) #else #define NISPS_AUDIO_MEM #define NISPS_AUDIO_FUNC(decl) decl #define NISPS_APP_SRAM #define NISPS_FORCE_INLINE inline #define NISPS_HOT + #if defined(__GNUC__) || defined(__clang__) + #define NISPS_NOINLINE __attribute__((noinline)) + #else + #define NISPS_NOINLINE + #endif #endif diff --git a/nisps/ml/jolt.hpp b/nisps/ml/jolt.hpp new file mode 100644 index 0000000..8c6df30 --- /dev/null +++ b/nisps/ml/jolt.hpp @@ -0,0 +1,131 @@ +// nisps/ml/jolt.hpp — "Jolt": held-button continuous weight morph. +// +// Ported from upstream memllib InterfaceRL (commit 9fcd459 "jolts", +// e291192). While a button/pedal is held, pick a handful of random weights +// scattered across the whole network and EMA-glide each toward a bounded +// random target; on arrival, re-roll the target so the motion never stops. +// Releasing freezes the weights where they landed (the change is permanent), +// then ramps the effective learning rate back from 0 → 1 over ~5 s so any +// subsequent training eases in instead of yanking the net off the jolted +// sound. +// +// This operates on the MLP's FLAT weight buffer (mlp.hpp `get_weights()` / +// `set_weights()` / `weight_count()`), so it is architecture-agnostic — it +// does not care how many layers or how wide. It owns a per-instance +// deterministic `Rng` (seeded by the caller) so firmware ↔ browser parity +// holds. +// +// DEFAULT STATE IS INERT: a freshly constructed Jolt is inactive, `step()` +// is a no-op, and `lr_scale()` returns 1.0 (full LR). Nothing perturbs the +// network until `press()` is called. +// +// Upstream constants (InterfaceRL.hpp kJolt*) are reproduced verbatim in +// JoltParams defaults. + +#pragma once + +#include +#include +#include +#include +#include + +#include "../core/perf.hpp" +#include "../core/rng.hpp" + +namespace nisps::ml { + +// Upper bound on simultaneously-morphed weights; sizes the index/target +// arrays so the class stays heap-free. Upstream uses 40. +inline constexpr std::size_t kJoltMaxWeights = 64u; + +struct JoltParams { + std::size_t num_weights = 40u; // kJoltNumWeights + float morph_rate = 0.017f; // kJoltMorphRate (EMA per tick, ~1s @200Hz) + float target_min = -1.2f; // kJoltWeightMin (== weight-init range) + float target_max = 0.9f; // kJoltWeightMax + float target_epsilon = 0.05f; // kJoltTargetEpsilon (re-roll within this) + float lr_ramp_step = 0.001f; // kJoltLRRampStep (1/(5s*200Hz)) +}; + +class Jolt { + public: + explicit Jolt(std::uint64_t seed) noexcept : rng_(seed) {} + + void set_params(const JoltParams& p) noexcept { params_ = p; } + const JoltParams& params() const noexcept { return params_; } + + bool active() const noexcept { return active_; } + + // Effective-learning-rate multiplier for the caller's training step: + // 0 while the jolt is held, then ramps 0 → 1 after release. Callers that + // train (firmware optimise / playground continuous trainer) should + // multiply their LR by this. Pure-example modes that only train on an + // explicit gesture may ignore it. + float lr_scale() const noexcept { return active_ ? 0.f : lr_ramp_; } + + // Begin a jolt over a flat weight buffer of `weight_count` entries: pick + // `num_weights` random global indices and a bounded random target each. + void press(std::size_t weight_count) noexcept { + active_ = true; + lr_ramp_ = 0.f; + n_ = params_.num_weights; + if (n_ > kJoltMaxWeights) n_ = kJoltMaxWeights; + if (weight_count == 0u) { n_ = 0u; return; } + for (std::size_t i = 0u; i < n_; ++i) { + idx_[i] = static_cast(rng_.next_u64() % weight_count); + target_[i] = roll_target_(); + } + } + + // Per control tick while held: EMA-glide each selected weight toward its + // target, re-rolling targets that have been reached. No-op when inactive. + NISPS_HOT void step(std::span weights) noexcept { + if (!active_) return; + const std::size_t wc = weights.size(); + for (std::size_t i = 0u; i < n_; ++i) { + const std::size_t k = idx_[i]; + if (k >= wc) continue; + float w = weights[k]; + w += params_.morph_rate * (target_[i] - w); + weights[k] = w; + if (std::fabs(target_[i] - w) < params_.target_epsilon) { + target_[i] = roll_target_(); + } + } + } + + // Release: freeze weights where they are (permanent) and re-arm the LR + // ramp from 0. + void release() noexcept { + active_ = false; + lr_ramp_ = 0.f; + } + + // Advance the post-release LR ramp toward full. Call once per control + // tick; a no-op while active or already ramped. + void tick_lr_ramp() noexcept { + if (active_ || lr_ramp_ >= 1.f) return; + lr_ramp_ += params_.lr_ramp_step; + if (lr_ramp_ > 1.f) lr_ramp_ = 1.f; + } + + // Re-seed the internal RNG (parity / reset). + void seed(std::uint64_t s) noexcept { rng_.seed(s); } + + private: + NISPS_FORCE_INLINE float roll_target_() noexcept { + return params_.target_min + + rng_.next_float_uniform() * (params_.target_max - params_.target_min); + } + + JoltParams params_{}; + Rng rng_; + bool active_ = false; + float lr_ramp_ = 1.f; + std::size_t n_ = 0u; + std::array idx_{}; + std::array target_{}; +}; + +} // namespace nisps::ml diff --git a/nisps/ml/ou_noise.hpp b/nisps/ml/ou_noise.hpp new file mode 100644 index 0000000..41b0cca --- /dev/null +++ b/nisps/ml/ou_noise.hpp @@ -0,0 +1,101 @@ +// nisps/ml/ou_noise.hpp — Ornstein-Uhlenbeck exploration noise. +// +// Ported from upstream memllib InterfaceRL (commit d0d8a72 "noise", +// e291192), which adds a per-output Ornstein-Uhlenbeck random walk to the +// network's action vector. Unlike i.i.d. per-frame noise (which jitters), +// an OU process is temporally correlated: each output drifts in long, smooth +// sweeps and is gently pulled back toward the mapping output (mean reversion). +// Because learning stays active while the noise roams, "likes" registered +// during the wander steer the network toward sounds the player wants. +// +// Discrete Euler-Maruyama update, per output channel x: +// x += theta * (mu - x) * dt + noise_scale * N(0,1) +// out = clamp(out + x, 0, 1) +// where, to make the process's stationary standard deviation equal a +// requested `std`: +// sigma = std * sqrt(2 * theta) (continuous OU relation) +// noise_scale = sigma * sqrt(dt) = std * sqrt(2 * theta * dt) +// +// The exploration knob [0,1] maps to the stationary std via +// `set_intensity(level)` → std = level * kMaxAmplitude (0.65, upstream). +// +// DEFAULT STATE IS INERT: intensity defaults to 0, `enabled()` is false, and +// `apply()` neither advances the RNG nor touches the output — so a mode that +// never sets an intensity behaves bit-identically to one without OU at all +// (parity-safe). Owns a per-instance deterministic `Rng`. + +#pragma once + +#include +#include +#include +#include +#include + +#include "../core/perf.hpp" +#include "../core/rng.hpp" + +namespace nisps::ml { + +// Upstream kMaxAmplitude: the exploration knob's full-scale stationary std in +// parameter space. +inline constexpr float kOUMaxAmplitude = 0.65f; + +template +class OUNoise { + public: + explicit OUNoise(std::uint64_t seed) noexcept : rng_(seed) { + recompute_scale_(); + } + + // Exploration amount in [0,1]; 0 disables (inert). Maps to the OU + // stationary std = level * kMaxAmplitude. + void set_intensity(float level) noexcept { + if (level < 0.f) level = 0.f; + else if (level > 1.f) level = 1.f; + stationary_std_ = level * kOUMaxAmplitude; + recompute_scale_(); + } + float intensity() const noexcept { return stationary_std_ / kOUMaxAmplitude; } + bool enabled() const noexcept { return stationary_std_ > 0.f; } + + // OU smoothness controls (upstream defaults theta=0.02, dt=0.001). + void set_theta(float theta) noexcept { theta_ = theta; recompute_scale_(); } + void set_dt(float dt) noexcept { dt_ = dt; recompute_scale_(); } + + // Advance the per-channel OU state and add it (clamped) to `out`. No-op + // when disabled. `out` is the post-inference parameter vector. + NISPS_HOT void apply(std::span out) noexcept { + if (!enabled()) return; + const std::size_t n = out.size() < N ? out.size() : N; + for (std::size_t i = 0u; i < n; ++i) { + // mu = 0: the walk is an offset that mean-reverts to zero, so the + // network's own mapping output stays the anchor. + state_[i] += theta_ * (-state_[i]) * dt_ + + noise_scale_ * rng_.next_float_gaussian(1.f); + float v = out[i] + state_[i]; + if (v < 0.f) v = 0.f; + else if (v > 1.f) v = 1.f; + out[i] = v; + } + } + + void reset() noexcept { state_.fill(0.f); } + void seed(std::uint64_t s) noexcept { rng_.seed(s); } + + private: + NISPS_FORCE_INLINE void recompute_scale_() noexcept { + float k = 2.f * theta_ * dt_; + if (k < 0.f) k = 0.f; + noise_scale_ = stationary_std_ * std::sqrt(k); + } + + Rng rng_; + std::array state_{}; + float theta_ = 0.02f; + float dt_ = 0.001f; + float stationary_std_ = 0.f; + float noise_scale_ = 0.f; +}; + +} // namespace nisps::ml diff --git a/nisps/modes/base.hpp b/nisps/modes/base.hpp index 9053bfd..996e80e 100644 --- a/nisps/modes/base.hpp +++ b/nisps/modes/base.hpp @@ -37,6 +37,8 @@ #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 { @@ -131,7 +133,10 @@ class ModeBase { static constexpr std::size_t input_channel_count() noexcept { return NInputs; } - explicit ModeBase(std::uint64_t seed = 0xC0FFEEu) noexcept : ml_(seed) {} + 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 { @@ -164,13 +169,33 @@ class ModeBase { if constexpr (requires(Derived& d) { d.on_pre_inference(); }) { static_cast(*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. for (std::size_t i = 0u; i < NInputs; ++i) { ml_.set_input(i, input_channels_[i]); } ml_.process(); if constexpr (kRouteOutputsToEngine) { - engine_.set_params(ml_.outputs()); + // 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(out_buf_.data(), no)); + engine_.set_params(std::span(out_buf_.data(), no)); + } else { + engine_.set_params(ml_.outputs()); + } } input_dirty_ = false; if constexpr (requires(Derived& d) { d.on_post_inference(); }) { @@ -178,6 +203,27 @@ class ModeBase { } } + // ---- 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& ou_noise() noexcept { return ou_; } + const ml::OUNoise& ou_noise() const noexcept { return ou_; } + NISPS_HOT NISPS_FORCE_INLINE stereosample_t process(stereosample_t x) noexcept { return engine_.process(x); } @@ -230,6 +276,19 @@ class ModeBase { 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(jolt_buf_.data(), wc)); + ml_.set_weights(std::span(jolt_buf_.data(), wc)); + } + protected: float sample_rate_ = 48000.f; EngineT engine_{}; @@ -238,6 +297,17 @@ class ModeBase { bool input_dirty_ = false; std::size_t voice_space_idx_ = 0u; RingBuffer 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 ou_; + // Scratch for OU output blending and Jolt weight morphing. Sized to the + // single compiled mode; firmware only ever instantiates one mode. + std::array out_buf_{}; + std::array jolt_buf_{}; }; } // namespace nisps diff --git a/nisps/modes/generated/slp_workshop_schema.hpp b/nisps/modes/generated/slp_workshop_schema.hpp new file mode 100644 index 0000000..fddaa17 --- /dev/null +++ b/nisps/modes/generated/slp_workshop_schema.hpp @@ -0,0 +1,554 @@ +// AUTOGENERATED — do not edit. Source: schemas/modes/slp_workshop.json. Run `bun run codegen/generate.ts` to regenerate. +#ifndef NISPS_GENERATED_SLP_WORKSHOP_SCHEMA_HPP +#define NISPS_GENERATED_SLP_WORKSHOP_SCHEMA_HPP + +#include "schema_types.hpp" + +namespace nisps::modes::generated { + +inline constexpr std::string_view kSlpWorkshopModeId = "slp_workshop"; +inline constexpr std::string_view kSlpWorkshopEngineId = "memlcelium"; + +inline constexpr std::array kSlpWorkshopInputChannels = {{ + "joy_x", + "joy_y", + "joy_z", + "joy_w", +}}; + +inline constexpr std::array kSlpWorkshopHiddenLayers = {{ + 10u, + 14u, + 18u, +}}; + +inline constexpr MLConfig kSlpWorkshopMLConfig = { + 4u, + 56u, + 0.6f, + 1.0f, + 1000u, +}; + +inline constexpr std::size_t kSlpWorkshopParamCount = 56u; +inline constexpr std::array kSlpWorkshopParams = {{ + Param{ + "seq0_ratio0", + "Seq0 Ratio 0", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "sequencer", + }, + Param{ + "seq0_ratio1", + "Seq0 Ratio 1", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "sequencer", + }, + Param{ + "seq0_ratio2", + "Seq0 Ratio 2", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "sequencer", + }, + Param{ + "seq0_phasor_mul", + "Seq0 Phasor Mul", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "sequencer", + }, + Param{ + "seq0_phase_off", + "Seq0 Phase Off", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "sequencer", + }, + Param{ + "seq0_amp0", + "Seq0 Amp 0", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "sequencer", + }, + Param{ + "seq0_amp1", + "Seq0 Amp 1", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "sequencer", + }, + Param{ + "seq1_ratio0", + "Seq1 Ratio 0", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "sequencer", + }, + Param{ + "seq1_ratio1", + "Seq1 Ratio 1", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "sequencer", + }, + Param{ + "seq1_ratio2", + "Seq1 Ratio 2", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "sequencer", + }, + Param{ + "seq1_phasor_mul", + "Seq1 Phasor Mul", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "sequencer", + }, + Param{ + "seq1_phase_off", + "Seq1 Phase Off", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "sequencer", + }, + Param{ + "seq1_amp0", + "Seq1 Amp 0", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "sequencer", + }, + Param{ + "seq1_amp1", + "Seq1 Amp 1", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "sequencer", + }, + Param{ + "v0_base_freq", + "V0 Base Freq", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v0_synth", + }, + Param{ + "v0_paf0_cf", + "V0 PAF0 CF", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v0_synth", + }, + Param{ + "v0_paf1_cf", + "V0 PAF1 CF", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v0_synth", + }, + Param{ + "v0_paf2_cf", + "V0 PAF2 CF", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v0_synth", + }, + Param{ + "v0_paf0_bw", + "V0 PAF0 BW", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v0_synth", + }, + Param{ + "v0_paf1_bw", + "V0 PAF1 BW", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v0_synth", + }, + Param{ + "v0_paf2_bw", + "V0 PAF2 BW", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v0_synth", + }, + Param{ + "v0_paf_vib", + "V0 PAF Vib", + 0.0f, + 1.0f, + 0.0f, + Curve::square, + "v0_synth", + }, + Param{ + "v0_paf_vfr", + "V0 PAF Vib Rate", + 0.0f, + 1.0f, + 0.5f, + Curve::square, + "v0_synth", + }, + Param{ + "v0_paf0_shift", + "V0 PAF0 Shift", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v0_synth", + }, + Param{ + "v0_paf1_shift", + "V0 PAF1 Shift", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v0_synth", + }, + Param{ + "v0_paf2_shift", + "V0 PAF2 Shift", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v0_synth", + }, + Param{ + "v0_amp_attack", + "V0 Amp Attack", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "v0_env", + }, + Param{ + "v0_amp_decay", + "V0 Amp Decay", + 0.0f, + 1.0f, + 0.3f, + Curve::square, + "v0_env", + }, + Param{ + "v0_amp_sustain", + "V0 Amp Sustain", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v0_env", + }, + Param{ + "v0_amp_release", + "V0 Amp Release", + 0.0f, + 1.0f, + 0.3f, + Curve::square, + "v0_env", + }, + Param{ + "v0_pitch_attack", + "V0 Pitch Attack", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "v0_env", + }, + Param{ + "v0_pitch_decay", + "V0 Pitch Decay", + 0.0f, + 1.0f, + 0.3f, + Curve::square, + "v0_env", + }, + Param{ + "v0_pitch_emph", + "V0 Pitch Emph", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "v0_env", + }, + Param{ + "v0_shape_gain", + "V0 Shape Gain", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "v0_synth", + }, + Param{ + "v0_shape_asym", + "V0 Shape Asym", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "v0_synth", + }, + Param{ + "v0_shape_mix", + "V0 Shape Mix", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "v0_synth", + }, + Param{ + "v0_rm_gain", + "V0 RingMod Gain", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "v0_synth", + }, + Param{ + "v1_base_freq", + "V1 Base Freq", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v1_synth", + }, + Param{ + "v1_detune1", + "V1 Detune 1", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "v1_synth", + }, + Param{ + "v1_detune2", + "V1 Detune 2", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "v1_synth", + }, + Param{ + "v1_paf0_cf", + "V1 PAF0 CF", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v1_synth", + }, + Param{ + "v1_paf1_cf", + "V1 PAF1 CF", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v1_synth", + }, + Param{ + "v1_paf2_cf", + "V1 PAF2 CF", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v1_synth", + }, + Param{ + "v1_paf0_bw", + "V1 PAF0 BW", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v1_synth", + }, + Param{ + "v1_paf1_bw", + "V1 PAF1 BW", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v1_synth", + }, + Param{ + "v1_paf2_bw", + "V1 PAF2 BW", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v1_synth", + }, + Param{ + "v1_paf0_shift", + "V1 PAF0 Shift", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v1_synth", + }, + Param{ + "v1_paf1_shift", + "V1 PAF1 Shift", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v1_synth", + }, + Param{ + "v1_paf2_shift", + "V1 PAF2 Shift", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v1_synth", + }, + Param{ + "v1_amp_attack", + "V1 Amp Attack", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "v1_env", + }, + Param{ + "v1_amp_decay", + "V1 Amp Decay", + 0.0f, + 1.0f, + 0.3f, + Curve::square, + "v1_env", + }, + Param{ + "v1_amp_sustain", + "V1 Amp Sustain", + 0.0f, + 1.0f, + 0.5f, + Curve::linear, + "v1_env", + }, + Param{ + "v1_amp_release", + "V1 Amp Release", + 0.0f, + 1.0f, + 0.3f, + Curve::square, + "v1_env", + }, + Param{ + "v1_pitch_attack", + "V1 Pitch Attack", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "v1_env", + }, + Param{ + "v1_pitch_decay", + "V1 Pitch Decay", + 0.0f, + 1.0f, + 0.3f, + Curve::square, + "v1_env", + }, + Param{ + "v1_pitch_emph", + "V1 Pitch Emph", + 0.0f, + 1.0f, + 0.0f, + Curve::linear, + "v1_env", + }, +}}; + +inline constexpr std::size_t kSlpWorkshopVoiceSpaceCount = 1u; +inline constexpr std::array kSlpWorkshopVoiceSpaces = {{ + "Direct", +}}; + +inline constexpr UIConfig kSlpWorkshopUI = { + PrimaryInput::XYPad, + false, + true, +}; + +} // namespace nisps::modes::generated + +#endif // NISPS_GENERATED_SLP_WORKSHOP_SCHEMA_HPP diff --git a/nisps/modes/slp_workshop.hpp b/nisps/modes/slp_workshop.hpp new file mode 100644 index 0000000..d75e9fc --- /dev/null +++ b/nisps/modes/slp_workshop.hpp @@ -0,0 +1,78 @@ +// nisps/modes/slp_workshop.hpp — SLP-Workshop mode binding. +// +// The Synth Library Portland workshop instrument. It reuses the MEMLCelium +// engine and MLP shape verbatim (4 input channels → MLP[4,10,14,18,56] → +// MEMLCeliumEngine, sequencer ticked internally in process()), but exists as +// a distinct mode so the workshop firmware carries its own identity (preset +// dir, display name) and foregrounds the adaptive-learning gestures that now +// live in ModeBase for every mode: +// +// - Jolt — held gesture, continuously morphs a scatter of weights and +// freezes them on release (Base::jolt_press/jolt_release). +// - OU explore — Ornstein-Uhlenbeck random walk on the output vector +// (Base::set_explore_intensity). +// +// Both were ported from upstream memllib InterfaceRL (see ml/jolt.hpp, +// ml/ou_noise.hpp). Because they sit in the shared base, the synthesis +// mapping here is identical to MEMLCeliumMode — only the surfaced controls +// and identity differ. + +#pragma once + +#include +#include +#include +#include + +#include "../core/concepts.hpp" +#include "../core/perf.hpp" +#include "../core/types.hpp" +#include "../engines/memlcelium.hpp" +#include "../ml/mlp.hpp" +#include "base.hpp" +#include "generated/slp_workshop_schema.hpp" + +namespace nisps::modes { + +class SLPWorkshopMode : public ModeBase< + SLPWorkshopMode, + MEMLCeliumEngine, + ml::MLP<4u, 10u, 14u, 18u, 56u>, + 4u> { + public: + using Base = ModeBase, 4u>; + using Base::Base; + + static constexpr std::string_view mode_id() noexcept { + return generated::kSlpWorkshopModeId; + } + static constexpr const ParamSchema& param_schema() noexcept { return kSchema; } + + NISPS_FORCE_INLINE void set_playing(bool playing) noexcept { + engine_.set_playing(playing); + } + NISPS_FORCE_INLINE void update_bpm(float bpm) noexcept { + engine_.update_bpm(bpm); + } + + private: + static inline constexpr ParamSchema kSchema = ParamSchema{ + generated::kSlpWorkshopModeId, + generated::kSlpWorkshopEngineId, + std::span(generated::kSlpWorkshopInputChannels), + generated::kSlpWorkshopMLConfig.input_size, + std::span(generated::kSlpWorkshopHiddenLayers), + generated::kSlpWorkshopMLConfig.output_size, + generated::kSlpWorkshopMLConfig.default_spread, + generated::kSlpWorkshopMLConfig.default_learning_rate, + generated::kSlpWorkshopMLConfig.default_max_iterations, + std::span(generated::kSlpWorkshopParams), + std::span(generated::kSlpWorkshopVoiceSpaces), + generated::kSlpWorkshopUI, + }; +}; + +static_assert(Mode, "SLPWorkshopMode must satisfy nisps::Mode"); + +} // namespace nisps::modes diff --git a/playground/src/modes/SLPWorkshopMode.tsx b/playground/src/modes/SLPWorkshopMode.tsx new file mode 100644 index 0000000..cbcb6be --- /dev/null +++ b/playground/src/modes/SLPWorkshopMode.tsx @@ -0,0 +1,62 @@ +/** + * SLPWorkshopMode — the Synth Library Portland workshop instrument. + * + * Reuses the MEMLCelium engine and MLP shape verbatim (so the synth voice is + * identical), but foregrounds the adaptive-learning gestures ported from + * upstream InterfaceRL and now living in the shared core: + * - Jolt: hold to continuously morph the network's weights live, release to + * freeze (nisps/ml/jolt.hpp). + * - Explore: an Ornstein-Uhlenbeck random walk on the output that makes the + * sound slowly roam so likes/dislikes can steer it (nisps/ml/ou_noise.hpp). + */ + +import { Component, createSignal } from 'solid-js'; +import { ModeShell } from './ModeShell'; +import { useModeRuntime } from './mode-runtime'; +import { XYPad } from '../primitives/XYPad'; +import { OutputDisplay } from '../primitives/OutputDisplay'; +import { LossPlot } from '../primitives/LossPlot'; +import { SlpWorkshopSchema } from './generated/slp_workshop_schema'; + +export const SLPWorkshopMode: Component = () => { + const schema = SlpWorkshopSchema; + const runtime = useModeRuntime(schema); + const [voiceSpace, setVoiceSpace] = createSignal(0); + + return ( + ( + <> + runtime.setInput(x, y)} + position={runtime.pipedInput} + /> + + Synth Library Portland workshop instrument — MEMLCelium voice with + live Jolt + Explore learning controls. + + + )} + outputArea={() => ( + <> + + + + )} + /> + ); +}; + +export default SLPWorkshopMode; diff --git a/playground/src/modes/generated/index.ts b/playground/src/modes/generated/index.ts index cc036e2..d33e40c 100644 --- a/playground/src/modes/generated/index.ts +++ b/playground/src/modes/generated/index.ts @@ -7,6 +7,7 @@ export * from './channel_strip_schema'; export * from './elysiamorf_schema'; export * from './memlcelium_schema'; export * from './paf_synth_schema'; +export * from './slp_workshop_schema'; export * from './sound_analysis_midi_schema'; export * from './verb_fx_schema'; export * from './xiasri_schema'; diff --git a/playground/src/modes/generated/slp_workshop_schema.ts b/playground/src/modes/generated/slp_workshop_schema.ts new file mode 100644 index 0000000..4a74ad1 --- /dev/null +++ b/playground/src/modes/generated/slp_workshop_schema.ts @@ -0,0 +1,598 @@ +// AUTOGENERATED — do not edit. Source: schemas/modes/slp_workshop.json. Run `bun run codegen/generate.ts` to regenerate. +import type { ModeSchema } from './types'; + +export interface SlpWorkshopParams { + readonly seq0_ratio0: number; + readonly seq0_ratio1: number; + readonly seq0_ratio2: number; + readonly seq0_phasor_mul: number; + readonly seq0_phase_off: number; + readonly seq0_amp0: number; + readonly seq0_amp1: number; + readonly seq1_ratio0: number; + readonly seq1_ratio1: number; + readonly seq1_ratio2: number; + readonly seq1_phasor_mul: number; + readonly seq1_phase_off: number; + readonly seq1_amp0: number; + readonly seq1_amp1: number; + readonly v0_base_freq: number; + readonly v0_paf0_cf: number; + readonly v0_paf1_cf: number; + readonly v0_paf2_cf: number; + readonly v0_paf0_bw: number; + readonly v0_paf1_bw: number; + readonly v0_paf2_bw: number; + readonly v0_paf_vib: number; + readonly v0_paf_vfr: number; + readonly v0_paf0_shift: number; + readonly v0_paf1_shift: number; + readonly v0_paf2_shift: number; + readonly v0_amp_attack: number; + readonly v0_amp_decay: number; + readonly v0_amp_sustain: number; + readonly v0_amp_release: number; + readonly v0_pitch_attack: number; + readonly v0_pitch_decay: number; + readonly v0_pitch_emph: number; + readonly v0_shape_gain: number; + readonly v0_shape_asym: number; + readonly v0_shape_mix: number; + readonly v0_rm_gain: number; + readonly v1_base_freq: number; + readonly v1_detune1: number; + readonly v1_detune2: number; + readonly v1_paf0_cf: number; + readonly v1_paf1_cf: number; + readonly v1_paf2_cf: number; + readonly v1_paf0_bw: number; + readonly v1_paf1_bw: number; + readonly v1_paf2_bw: number; + readonly v1_paf0_shift: number; + readonly v1_paf1_shift: number; + readonly v1_paf2_shift: number; + readonly v1_amp_attack: number; + readonly v1_amp_decay: number; + readonly v1_amp_sustain: number; + readonly v1_amp_release: number; + readonly v1_pitch_attack: number; + readonly v1_pitch_decay: number; + readonly v1_pitch_emph: number; +} + +export const SlpWorkshopSchema: ModeSchema = { + mode_id: 'slp_workshop', + engine_id: 'memlcelium', + ml: { + input_channels: [ + 'joy_x', + 'joy_y', + 'joy_z', + 'joy_w', + ], + input_size: 4, + hidden_layers: [ + 10, + 14, + 18, + ], + output_size: 56, + default_spread: 0.6, + default_learning_rate: 1, + default_max_iterations: 1000, + }, + params: [ + { + name: 'seq0_ratio0', + label: 'Seq0 Ratio 0', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'sequencer', + }, + { + name: 'seq0_ratio1', + label: 'Seq0 Ratio 1', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'sequencer', + }, + { + name: 'seq0_ratio2', + label: 'Seq0 Ratio 2', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'sequencer', + }, + { + name: 'seq0_phasor_mul', + label: 'Seq0 Phasor Mul', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'sequencer', + }, + { + name: 'seq0_phase_off', + label: 'Seq0 Phase Off', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'sequencer', + }, + { + name: 'seq0_amp0', + label: 'Seq0 Amp 0', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'sequencer', + }, + { + name: 'seq0_amp1', + label: 'Seq0 Amp 1', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'sequencer', + }, + { + name: 'seq1_ratio0', + label: 'Seq1 Ratio 0', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'sequencer', + }, + { + name: 'seq1_ratio1', + label: 'Seq1 Ratio 1', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'sequencer', + }, + { + name: 'seq1_ratio2', + label: 'Seq1 Ratio 2', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'sequencer', + }, + { + name: 'seq1_phasor_mul', + label: 'Seq1 Phasor Mul', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'sequencer', + }, + { + name: 'seq1_phase_off', + label: 'Seq1 Phase Off', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'sequencer', + }, + { + name: 'seq1_amp0', + label: 'Seq1 Amp 0', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'sequencer', + }, + { + name: 'seq1_amp1', + label: 'Seq1 Amp 1', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'sequencer', + }, + { + name: 'v0_base_freq', + label: 'V0 Base Freq', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v0_synth', + }, + { + name: 'v0_paf0_cf', + label: 'V0 PAF0 CF', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v0_synth', + }, + { + name: 'v0_paf1_cf', + label: 'V0 PAF1 CF', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v0_synth', + }, + { + name: 'v0_paf2_cf', + label: 'V0 PAF2 CF', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v0_synth', + }, + { + name: 'v0_paf0_bw', + label: 'V0 PAF0 BW', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v0_synth', + }, + { + name: 'v0_paf1_bw', + label: 'V0 PAF1 BW', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v0_synth', + }, + { + name: 'v0_paf2_bw', + label: 'V0 PAF2 BW', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v0_synth', + }, + { + name: 'v0_paf_vib', + label: 'V0 PAF Vib', + min: 0, + max: 1, + default: 0, + curve: 'square', + group: 'v0_synth', + }, + { + name: 'v0_paf_vfr', + label: 'V0 PAF Vib Rate', + min: 0, + max: 1, + default: 0.5, + curve: 'square', + group: 'v0_synth', + }, + { + name: 'v0_paf0_shift', + label: 'V0 PAF0 Shift', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v0_synth', + }, + { + name: 'v0_paf1_shift', + label: 'V0 PAF1 Shift', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v0_synth', + }, + { + name: 'v0_paf2_shift', + label: 'V0 PAF2 Shift', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v0_synth', + }, + { + name: 'v0_amp_attack', + label: 'V0 Amp Attack', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'v0_env', + }, + { + name: 'v0_amp_decay', + label: 'V0 Amp Decay', + min: 0, + max: 1, + default: 0.3, + curve: 'square', + group: 'v0_env', + }, + { + name: 'v0_amp_sustain', + label: 'V0 Amp Sustain', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v0_env', + }, + { + name: 'v0_amp_release', + label: 'V0 Amp Release', + min: 0, + max: 1, + default: 0.3, + curve: 'square', + group: 'v0_env', + }, + { + name: 'v0_pitch_attack', + label: 'V0 Pitch Attack', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'v0_env', + }, + { + name: 'v0_pitch_decay', + label: 'V0 Pitch Decay', + min: 0, + max: 1, + default: 0.3, + curve: 'square', + group: 'v0_env', + }, + { + name: 'v0_pitch_emph', + label: 'V0 Pitch Emph', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'v0_env', + }, + { + name: 'v0_shape_gain', + label: 'V0 Shape Gain', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'v0_synth', + }, + { + name: 'v0_shape_asym', + label: 'V0 Shape Asym', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'v0_synth', + }, + { + name: 'v0_shape_mix', + label: 'V0 Shape Mix', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'v0_synth', + }, + { + name: 'v0_rm_gain', + label: 'V0 RingMod Gain', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'v0_synth', + }, + { + name: 'v1_base_freq', + label: 'V1 Base Freq', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v1_synth', + }, + { + name: 'v1_detune1', + label: 'V1 Detune 1', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'v1_synth', + }, + { + name: 'v1_detune2', + label: 'V1 Detune 2', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'v1_synth', + }, + { + name: 'v1_paf0_cf', + label: 'V1 PAF0 CF', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v1_synth', + }, + { + name: 'v1_paf1_cf', + label: 'V1 PAF1 CF', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v1_synth', + }, + { + name: 'v1_paf2_cf', + label: 'V1 PAF2 CF', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v1_synth', + }, + { + name: 'v1_paf0_bw', + label: 'V1 PAF0 BW', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v1_synth', + }, + { + name: 'v1_paf1_bw', + label: 'V1 PAF1 BW', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v1_synth', + }, + { + name: 'v1_paf2_bw', + label: 'V1 PAF2 BW', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v1_synth', + }, + { + name: 'v1_paf0_shift', + label: 'V1 PAF0 Shift', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v1_synth', + }, + { + name: 'v1_paf1_shift', + label: 'V1 PAF1 Shift', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v1_synth', + }, + { + name: 'v1_paf2_shift', + label: 'V1 PAF2 Shift', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v1_synth', + }, + { + name: 'v1_amp_attack', + label: 'V1 Amp Attack', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'v1_env', + }, + { + name: 'v1_amp_decay', + label: 'V1 Amp Decay', + min: 0, + max: 1, + default: 0.3, + curve: 'square', + group: 'v1_env', + }, + { + name: 'v1_amp_sustain', + label: 'V1 Amp Sustain', + min: 0, + max: 1, + default: 0.5, + curve: 'linear', + group: 'v1_env', + }, + { + name: 'v1_amp_release', + label: 'V1 Amp Release', + min: 0, + max: 1, + default: 0.3, + curve: 'square', + group: 'v1_env', + }, + { + name: 'v1_pitch_attack', + label: 'V1 Pitch Attack', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'v1_env', + }, + { + name: 'v1_pitch_decay', + label: 'V1 Pitch Decay', + min: 0, + max: 1, + default: 0.3, + curve: 'square', + group: 'v1_env', + }, + { + name: 'v1_pitch_emph', + label: 'V1 Pitch Emph', + min: 0, + max: 1, + default: 0, + curve: 'linear', + group: 'v1_env', + }, + ], + voice_spaces: [ + 'Direct', + ], + ui: { + primary_input: 'xy_pad', + show_voice_space_selector: false, + show_synth_visualizer: true, + }, +}; diff --git a/playground/src/modes/index.ts b/playground/src/modes/index.ts index b7a3782..006653a 100644 --- a/playground/src/modes/index.ts +++ b/playground/src/modes/index.ts @@ -12,6 +12,7 @@ import { ChannelStripMode } from './ChannelStripMode'; import { XIASRIMode } from './XIASRIMode'; import { VerbFXMode } from './VerbFXMode'; import { MEMLCeliumMode } from './MEMLCeliumMode'; +import { SLPWorkshopMode } from './SLPWorkshopMode'; import { BreakOrMode } from './BreakOrMode'; import { ElysiamorfMode } from './ElysiamorfMode'; import { SoundAnalysisMIDIMode } from './SoundAnalysisMIDIMode'; @@ -61,6 +62,12 @@ export const MODE_REGISTRY: ReadonlyArray = [ description: 'Voice + dual-MLP CV/gate via uSEQ.', Component: MEMLCeliumMode, }, + { + id: 'slp_workshop', + label: 'SLP-Workshop', + description: 'MEMLCelium voice + live Jolt / Explore learning (Synth Library Portland).', + Component: SLPWorkshopMode, + }, { id: 'breakor', label: 'Breakor', diff --git a/playground/tests/e2e/modes.spec.ts b/playground/tests/e2e/modes.spec.ts index 1d5016d..c7e2a99 100644 --- a/playground/tests/e2e/modes.spec.ts +++ b/playground/tests/e2e/modes.spec.ts @@ -21,6 +21,7 @@ const MODE_IDS = [ 'xiasri', 'verb_fx', 'memlcelium', + 'slp_workshop', 'breakor', 'elysiamorf', 'sound_analysis_midi', diff --git a/schemas/modes/slp_workshop.json b/schemas/modes/slp_workshop.json new file mode 100644 index 0000000..e1ce169 --- /dev/null +++ b/schemas/modes/slp_workshop.json @@ -0,0 +1,81 @@ +{ + "$schema": "../schema.json", + "_note": "SLP-Workshop — the Synth Library Portland workshop instrument. Reuses the MEMLCelium engine (engine_id 'memlcelium', 56-param dual-voice PAF synth + 2-track ratio sequencer) and MLP shape verbatim; it exists as a distinct mode so the workshop build carries its own identity (preset dir, display name) and surfaces the adaptive-learning gestures ported from upstream InterfaceRL: Jolt (held continuous weight morph) and OU exploration noise. Both live in the shared ModeBase, so the mapping/params are identical to MEMLCelium — only the surfaced controls differ.", + "mode_id": "slp_workshop", + "engine_id": "memlcelium", + "ml": { + "input_channels": ["joy_x", "joy_y", "joy_z", "joy_w"], + "input_size": 4, + "hidden_layers": [10, 14, 18], + "output_size": 56, + "default_spread": 0.6, + "default_learning_rate": 1.0, + "default_max_iterations": 1000 + }, + "params": [ + { "name": "seq0_ratio0", "label": "Seq0 Ratio 0", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "sequencer" }, + { "name": "seq0_ratio1", "label": "Seq0 Ratio 1", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "sequencer" }, + { "name": "seq0_ratio2", "label": "Seq0 Ratio 2", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "sequencer" }, + { "name": "seq0_phasor_mul","label": "Seq0 Phasor Mul", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "sequencer" }, + { "name": "seq0_phase_off", "label": "Seq0 Phase Off", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "sequencer" }, + { "name": "seq0_amp0", "label": "Seq0 Amp 0", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "sequencer" }, + { "name": "seq0_amp1", "label": "Seq0 Amp 1", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "sequencer" }, + { "name": "seq1_ratio0", "label": "Seq1 Ratio 0", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "sequencer" }, + { "name": "seq1_ratio1", "label": "Seq1 Ratio 1", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "sequencer" }, + { "name": "seq1_ratio2", "label": "Seq1 Ratio 2", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "sequencer" }, + { "name": "seq1_phasor_mul","label": "Seq1 Phasor Mul", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "sequencer" }, + { "name": "seq1_phase_off", "label": "Seq1 Phase Off", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "sequencer" }, + { "name": "seq1_amp0", "label": "Seq1 Amp 0", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "sequencer" }, + { "name": "seq1_amp1", "label": "Seq1 Amp 1", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "sequencer" }, + { "name": "v0_base_freq", "label": "V0 Base Freq", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v0_synth" }, + { "name": "v0_paf0_cf", "label": "V0 PAF0 CF", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v0_synth" }, + { "name": "v0_paf1_cf", "label": "V0 PAF1 CF", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v0_synth" }, + { "name": "v0_paf2_cf", "label": "V0 PAF2 CF", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v0_synth" }, + { "name": "v0_paf0_bw", "label": "V0 PAF0 BW", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v0_synth" }, + { "name": "v0_paf1_bw", "label": "V0 PAF1 BW", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v0_synth" }, + { "name": "v0_paf2_bw", "label": "V0 PAF2 BW", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v0_synth" }, + { "name": "v0_paf_vib", "label": "V0 PAF Vib", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "square", "group": "v0_synth" }, + { "name": "v0_paf_vfr", "label": "V0 PAF Vib Rate", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "square", "group": "v0_synth" }, + { "name": "v0_paf0_shift", "label": "V0 PAF0 Shift", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v0_synth" }, + { "name": "v0_paf1_shift", "label": "V0 PAF1 Shift", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v0_synth" }, + { "name": "v0_paf2_shift", "label": "V0 PAF2 Shift", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v0_synth" }, + { "name": "v0_amp_attack", "label": "V0 Amp Attack", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "v0_env" }, + { "name": "v0_amp_decay", "label": "V0 Amp Decay", "min": 0.0, "max": 1.0, "default": 0.3, "curve": "square", "group": "v0_env" }, + { "name": "v0_amp_sustain", "label": "V0 Amp Sustain", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v0_env" }, + { "name": "v0_amp_release", "label": "V0 Amp Release", "min": 0.0, "max": 1.0, "default": 0.3, "curve": "square", "group": "v0_env" }, + { "name": "v0_pitch_attack","label": "V0 Pitch Attack", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "v0_env" }, + { "name": "v0_pitch_decay", "label": "V0 Pitch Decay", "min": 0.0, "max": 1.0, "default": 0.3, "curve": "square", "group": "v0_env" }, + { "name": "v0_pitch_emph", "label": "V0 Pitch Emph", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "v0_env" }, + { "name": "v0_shape_gain", "label": "V0 Shape Gain", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "v0_synth" }, + { "name": "v0_shape_asym", "label": "V0 Shape Asym", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "v0_synth" }, + { "name": "v0_shape_mix", "label": "V0 Shape Mix", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "v0_synth" }, + { "name": "v0_rm_gain", "label": "V0 RingMod Gain", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "v0_synth" }, + { "name": "v1_base_freq", "label": "V1 Base Freq", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v1_synth" }, + { "name": "v1_detune1", "label": "V1 Detune 1", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "v1_synth" }, + { "name": "v1_detune2", "label": "V1 Detune 2", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "v1_synth" }, + { "name": "v1_paf0_cf", "label": "V1 PAF0 CF", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v1_synth" }, + { "name": "v1_paf1_cf", "label": "V1 PAF1 CF", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v1_synth" }, + { "name": "v1_paf2_cf", "label": "V1 PAF2 CF", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v1_synth" }, + { "name": "v1_paf0_bw", "label": "V1 PAF0 BW", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v1_synth" }, + { "name": "v1_paf1_bw", "label": "V1 PAF1 BW", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v1_synth" }, + { "name": "v1_paf2_bw", "label": "V1 PAF2 BW", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v1_synth" }, + { "name": "v1_paf0_shift", "label": "V1 PAF0 Shift", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v1_synth" }, + { "name": "v1_paf1_shift", "label": "V1 PAF1 Shift", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v1_synth" }, + { "name": "v1_paf2_shift", "label": "V1 PAF2 Shift", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v1_synth" }, + { "name": "v1_amp_attack", "label": "V1 Amp Attack", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "v1_env" }, + { "name": "v1_amp_decay", "label": "V1 Amp Decay", "min": 0.0, "max": 1.0, "default": 0.3, "curve": "square", "group": "v1_env" }, + { "name": "v1_amp_sustain", "label": "V1 Amp Sustain", "min": 0.0, "max": 1.0, "default": 0.5, "curve": "linear", "group": "v1_env" }, + { "name": "v1_amp_release", "label": "V1 Amp Release", "min": 0.0, "max": 1.0, "default": 0.3, "curve": "square", "group": "v1_env" }, + { "name": "v1_pitch_attack","label": "V1 Pitch Attack", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "v1_env" }, + { "name": "v1_pitch_decay", "label": "V1 Pitch Decay", "min": 0.0, "max": 1.0, "default": 0.3, "curve": "square", "group": "v1_env" }, + { "name": "v1_pitch_emph", "label": "V1 Pitch Emph", "min": 0.0, "max": 1.0, "default": 0.0, "curve": "linear", "group": "v1_env" } + ], + "voice_spaces": [ + "Direct" + ], + "ui": { + "primary_input": "xy_pad", + "show_voice_space_selector": false, + "show_synth_visualizer": true + } +} diff --git a/tests/cpp/test_mlp_jolt.cpp b/tests/cpp/test_mlp_jolt.cpp new file mode 100644 index 0000000..54d06f2 --- /dev/null +++ b/tests/cpp/test_mlp_jolt.cpp @@ -0,0 +1,94 @@ +// tests/cpp/test_mlp_jolt.cpp — Jolt (held continuous weight morph) behavior. +// +// - default state is inert: inactive, step() is a no-op, lr_scale()==1. +// - press() activates and pauses learning (lr_scale()==0). +// - step() morphs selected weights toward bounded targets, staying within +// the [target_min, target_max] envelope. +// - release() freezes and re-arms the LR ramp (0 → 1 via tick_lr_ramp). +// - same seed ⇒ identical morph (determinism / parity). + +#include +#include +#include +#include + +#include "test_helpers.hpp" + +#include "../../nisps/ml/jolt.hpp" + +namespace { + +using nisps::ml::Jolt; +using nisps::ml::JoltParams; + +NISPS_TEST(jolt_inert_by_default) { + Jolt j(0ull); + NISPS_EXPECT(!j.active()); + NISPS_EXPECT(j.lr_scale() == 1.f); + std::array w{}; + w.fill(0.5f); + j.step(std::span(w)); // no-op while inactive + for (float v : w) NISPS_EXPECT(v == 0.5f); +} + +NISPS_TEST(jolt_press_activates_and_pauses_learning) { + Jolt j(1ull); + j.press(100u); + NISPS_EXPECT(j.active()); + NISPS_EXPECT(j.lr_scale() == 0.f); +} + +NISPS_TEST(jolt_step_morphs_weights_within_envelope) { + Jolt j(7ull); + JoltParams p; + p.num_weights = 16u; + j.set_params(p); + + std::array w{}; + w.fill(0.f); + j.press(w.size()); + for (int i = 0; i < 300; ++i) j.step(std::span(w)); + + int changed = 0; + for (float v : w) { + if (std::fabs(v) > 1e-4f) ++changed; + // Targets are bounded to [target_min, target_max] = [-1.2, 0.9]; + // EMA interpolation keeps weights inside that envelope. + NISPS_EXPECT(v >= p.target_min - 0.01f); + NISPS_EXPECT(v <= p.target_max + 0.01f); + } + NISPS_EXPECT(changed > 0); + NISPS_EXPECT(changed <= 16); // at most num_weights distinct indices +} + +NISPS_TEST(jolt_release_ramps_lr_back_to_full) { + Jolt j(3ull); + j.press(50u); + NISPS_EXPECT(j.lr_scale() == 0.f); + + j.release(); + NISPS_EXPECT(!j.active()); + NISPS_EXPECT(j.lr_scale() == 0.f); // ramp re-armed at 0 + + for (int i = 0; i < 10; ++i) j.tick_lr_ramp(); + NISPS_EXPECT(j.lr_scale() > 0.f && j.lr_scale() <= 1.f); + + for (int i = 0; i < 2000; ++i) j.tick_lr_ramp(); // default step 0.001 + NISPS_EXPECT_NEAR(j.lr_scale(), 1.f, 1e-6); +} + +NISPS_TEST(jolt_deterministic_same_seed) { + Jolt a(99ull), b(99ull); + std::array wa{}, wb{}; + wa.fill(0.25f); + wb.fill(0.25f); + a.press(wa.size()); + b.press(wb.size()); + for (int i = 0; i < 150; ++i) { + a.step(std::span(wa)); + b.step(std::span(wb)); + } + for (std::size_t i = 0; i < wa.size(); ++i) NISPS_EXPECT(wa[i] == wb[i]); +} + +} // namespace diff --git a/tests/cpp/test_mlp_ou_noise.cpp b/tests/cpp/test_mlp_ou_noise.cpp new file mode 100644 index 0000000..b16b69a --- /dev/null +++ b/tests/cpp/test_mlp_ou_noise.cpp @@ -0,0 +1,77 @@ +// tests/cpp/test_mlp_ou_noise.cpp — Ornstein-Uhlenbeck exploration noise. +// +// - default state is inert: intensity 0, disabled, apply() leaves the +// output untouched (parity-safe). +// - set_intensity enables it; apply() drifts the output but keeps it in +// [0, 1]. +// - same seed ⇒ identical walk (determinism / parity). +// - intensity back to 0 disables it again (inert). + +#include +#include +#include +#include + +#include "test_helpers.hpp" + +#include "../../nisps/ml/ou_noise.hpp" + +namespace { + +using nisps::ml::OUNoise; + +NISPS_TEST(ou_inert_by_default) { + OUNoise<8> ou(0ull); + NISPS_EXPECT(!ou.enabled()); + NISPS_EXPECT(ou.intensity() == 0.f); + std::array o{}; + o.fill(0.5f); + ou.apply(std::span(o)); // no-op while disabled + for (float v : o) NISPS_EXPECT(v == 0.5f); +} + +NISPS_TEST(ou_intensity_enables_and_perturbs_bounded) { + OUNoise<8> ou(5ull); + ou.set_intensity(0.6f); + NISPS_EXPECT(ou.enabled()); + + bool moved = false; + for (int t = 0; t < 500; ++t) { + std::array o{}; + o.fill(0.5f); + ou.apply(std::span(o)); + for (float v : o) { + NISPS_EXPECT(v >= 0.f && v <= 1.f); // clamped to param space + if (std::fabs(v - 0.5f) > 1e-4f) moved = true; + } + } + NISPS_EXPECT(moved); +} + +NISPS_TEST(ou_deterministic_same_seed) { + OUNoise<4> a(42ull), b(42ull); + a.set_intensity(0.5f); + b.set_intensity(0.5f); + for (int t = 0; t < 100; ++t) { + std::array oa{}, ob{}; + oa.fill(0.3f); + ob.fill(0.3f); + a.apply(std::span(oa)); + b.apply(std::span(ob)); + for (int i = 0; i < 4; ++i) NISPS_EXPECT(oa[i] == ob[i]); + } +} + +NISPS_TEST(ou_intensity_zero_disables_again) { + OUNoise<4> ou(1ull); + ou.set_intensity(0.5f); + NISPS_EXPECT(ou.enabled()); + ou.set_intensity(0.f); + NISPS_EXPECT(!ou.enabled()); + std::array o{}; + o.fill(0.2f); + ou.apply(std::span(o)); + for (float v : o) NISPS_EXPECT(v == 0.2f); +} + +} // namespace diff --git a/tests/cpp/test_mode_concepts.cpp b/tests/cpp/test_mode_concepts.cpp index 9e55d6b..543bdbc 100644 --- a/tests/cpp/test_mode_concepts.cpp +++ b/tests/cpp/test_mode_concepts.cpp @@ -12,6 +12,7 @@ #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" @@ -25,6 +26,7 @@ static_assert(Mode); static_assert(Mode); static_assert(Mode); static_assert(Mode); +static_assert(Mode); static_assert(Mode); static_assert(Mode); static_assert(Mode); @@ -70,6 +72,16 @@ NISPS_TEST(mode_memlcelium_metadata) { NISPS_EXPECT(s.engine_id == "memlcelium"); } +NISPS_TEST(mode_slp_workshop_metadata) { + const auto& s = modes::SLPWorkshopMode::param_schema(); + NISPS_EXPECT(modes::SLPWorkshopMode::mode_id() == "slp_workshop"); + NISPS_EXPECT(modes::SLPWorkshopMode::input_channel_count() == 4u); + NISPS_EXPECT(s.output_size == 56u); + // Reuses the MEMLCelium engine verbatim. + NISPS_EXPECT(s.engine_id == "memlcelium"); + NISPS_EXPECT(s.voice_spaces.size() == 1u); +} + NISPS_TEST(mode_breakor_metadata) { const auto& s = modes::BreakOrMode::param_schema(); NISPS_EXPECT(modes::BreakOrMode::mode_id() == "breakor"); diff --git a/tests/cpp/test_mode_learning.cpp b/tests/cpp/test_mode_learning.cpp new file mode 100644 index 0000000..e350e3c --- /dev/null +++ b/tests/cpp/test_mode_learning.cpp @@ -0,0 +1,104 @@ +// tests/cpp/test_mode_learning.cpp — ModeBase adaptive-learning wiring, +// exercised through SLPWorkshopMode. +// +// - inert parity: with Jolt inactive and OU disabled (the defaults), +// SLPWorkshopMode is bit-identical to MEMLCeliumMode (same engine, same +// MLP shape, same seed) — proving the new gestures don't perturb the +// baseline mapping. +// - Jolt: pressing it morphs the network weights over successive control +// ticks and gates the LR (0 while held, ramps back after release). +// - OU explore: intensity round-trips, toggles enabled state, and keeps +// audio finite. + +#include +#include +#include + +#include "test_helpers.hpp" + +#include "../../nisps/core/types.hpp" +#include "../../nisps/modes/memlcelium.hpp" +#include "../../nisps/modes/slp_workshop.hpp" + +using namespace nisps; + +NISPS_TEST(slp_workshop_inert_equals_memlcelium) { + modes::MEMLCeliumMode a(123ull); + modes::SLPWorkshopMode b(123ull); + a.setup(48000.f); + b.setup(48000.f); + + for (int t = 0; t < 8; ++t) { + const float x = 0.3f + 0.05f * static_cast(t); + a.set_input(0, x); + a.set_input(1, 0.6f); + b.set_input(0, x); + b.set_input(1, 0.6f); + a.tick_control(); + b.tick_control(); + for (int s = 0; s < 32; ++s) { + const stereosample_t za{0.f, 0.f}; + const stereosample_t zb{0.f, 0.f}; + const auto oa = a.process(za); + const auto ob = b.process(zb); + NISPS_EXPECT(oa.L == ob.L); + NISPS_EXPECT(oa.R == ob.R); + } + } +} + +NISPS_TEST(slp_workshop_jolt_morphs_weights_and_gates_lr) { + using ML = modes::SLPWorkshopMode::ML; + modes::SLPWorkshopMode m(5ull); + m.setup(48000.f); + + std::array before{}; + { + auto w = m.ml().get_weights(); + for (std::size_t i = 0; i < w.size(); ++i) before[i] = w[i]; + } + + NISPS_EXPECT(!m.jolt_active()); + NISPS_EXPECT(m.jolt_lr_scale() == 1.f); + + m.jolt_press(); + NISPS_EXPECT(m.jolt_active()); + NISPS_EXPECT(m.jolt_lr_scale() == 0.f); + + for (int t = 0; t < 100; ++t) m.tick_control(); + + int changed = 0; + { + auto w = m.ml().get_weights(); + for (std::size_t i = 0; i < w.size(); ++i) { + if (before[i] != w[i]) ++changed; + } + } + NISPS_EXPECT(changed > 0); + + m.jolt_release(); + NISPS_EXPECT(!m.jolt_active()); + for (int t = 0; t < 2000; ++t) m.tick_control(); // ramps LR back to full + NISPS_EXPECT_NEAR(m.jolt_lr_scale(), 1.f, 1e-6); +} + +NISPS_TEST(slp_workshop_explore_intensity_roundtrip_finite) { + modes::SLPWorkshopMode m(9ull); + m.setup(48000.f); + + NISPS_EXPECT(!m.ou_noise().enabled()); + m.set_explore_intensity(0.5f); + NISPS_EXPECT(m.ou_noise().enabled()); + NISPS_EXPECT_NEAR(m.explore_intensity(), 0.5f, 1e-6); + + for (int t = 0; t < 8; ++t) { + m.tick_control(); + for (int s = 0; s < 16; ++s) { + const auto o = m.process(stereosample_t{0.f, 0.f}); + NISPS_EXPECT(std::isfinite(o.L) && std::isfinite(o.R)); + } + } + + m.set_explore_intensity(0.f); + NISPS_EXPECT(!m.ou_noise().enabled()); +}