feat(slp-workshop): new MEMLCelium-based mode + port Jolt & OU-noise RL learning

New SLP-Workshop firmware variant (Synth Library Portland), built on the
MEMLCelium engine + MLP shape. Ports the two post-fork learning-algorithm
changes from upstream memllib InterfaceRL into the shared nisps/ml core,
runtime-configurable (no compile-time switch), inert by default:

- nisps/ml/jolt.hpp: Jolt — held continuous weight morph over the flat
  weight buffer + post-release LR ramp (kJolt* constants verbatim).
- nisps/ml/ou_noise.hpp: OUNoise<N> — Ornstein-Uhlenbeck exploration walk
  on the output vector (theta=0.02, dt=0.001, kMaxAmplitude=0.65).

Both wired into ModeBase so every mode gains jolt_press/jolt_release/
jolt_lr_scale + set_explore_intensity; gated so existing modes stay
bit-identical (parity + golden tests green). Firmware surfaces them on
TogB1 (Jolt) and RVX1 (explore). New SLPWorkshopMode mode + schema +
codegen; firmware alias + .ino variant; playground mode registration.

Tests: jolt + OU unit tests, ModeBase learning integration incl. an
inert-parity test proving SLP-Workshop == MEMLCelium with features off.
Verified: cpp tests, wasm build, native↔wasm parity, lint, codegen
golden, playground typecheck. Firmware compile/e2e/hardware are
environment-bound (no arduino-cli/submodules/browser here).

Refs ergo 019f0fca.
This commit is contained in:
monkey-w1n5t0n 2026-06-28 22:15:36 +02:00
parent 9ad0b8b9a0
commit 4e60d0192a
22 changed files with 2016 additions and 9 deletions

View file

@ -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)

8
MAP.md
View file

@ -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<NIn, NH1, NH2, NH3, NOut>`. 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<MLP_T>` — 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<NIn, NH1, NH2, NH3, NOut>`. 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<N>` — Ornstein-Uhlenbeck exploration walk on the output vector; ported from upstream InterfaceRL), `feedback.hpp` (`FeedbackController<MLP_T>` — 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<const MidiDevice&, NOut>` — joystick→MLP→MIDI CC for an external synth; compile-time device from `nisps/midi`; `consteval pick_cc_slots` curates which params fill the NOut slots; NoOpEngine, `kRouteOutputsToEngine=false`). `base.hpp` provides a CRTP scaffold eliminating the duplication that previously plagued firmware modes. `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<const MidiDevice&, NOut>` — joystick→MLP→MIDI CC for an external synth; compile-time device from `nisps/midi`; `consteval pick_cc_slots` curates which params fill the NOut slots; NoOpEngine, `kRouteOutputsToEngine=false`). `base.hpp` provides a CRTP scaffold eliminating the duplication that previously plagued firmware modes. `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/<mode>.json` (×8) — each mode's params, ranges, defaults, curves, voice spaces, ML config.
- `schemas/modes/<mode>.json` (×9) — each mode's params, ranges, defaults, curves, voice spaces, ML config. (`slp_workshop.json` reuses `engine_id: memlcelium`.)
- `schemas/modes/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/<device>.json` (×6) — CC-controllable external synths (Moog Sub 37 / Sub Phatty, Creamware Pro-12 ASB, Elektron Analog Keys, ASM Hydrasynth, Roland JD-800). Each param: `{id, cc, label, min, max, default, group}`. Canonical source for both firmware + browser device pickers. Verified-CC provenance + sources live in `synth-midi-cc.json` (repo root).

View file

@ -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

View file

@ -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

View file

@ -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).

View file

@ -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)

View file

@ -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

131
nisps/ml/jolt.hpp Normal file
View file

@ -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 <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <span>
#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<std::size_t>(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<float> 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<std::size_t, kJoltMaxWeights> idx_{};
std::array<float, kJoltMaxWeights> target_{};
};
} // namespace nisps::ml

101
nisps/ml/ou_noise.hpp Normal file
View file

@ -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 <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <span>
#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 <std::size_t N>
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<float> 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<float, N> state_{};
float theta_ = 0.02f;
float dt_ = 0.001f;
float stationary_std_ = 0.f;
float noise_scale_ = 0.f;
};
} // namespace nisps::ml

View file

@ -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<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.
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<float>(out_buf_.data(), no));
engine_.set_params(std::span<const float>(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<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);
}
@ -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<float>(jolt_buf_.data(), wc));
ml_.set_weights(std::span<const float>(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<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

View file

@ -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<std::string_view, 4> kSlpWorkshopInputChannels = {{
"joy_x",
"joy_y",
"joy_z",
"joy_w",
}};
inline constexpr std::array<std::size_t, 3> 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<Param, kSlpWorkshopParamCount> 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<std::string_view, kSlpWorkshopVoiceSpaceCount> kSlpWorkshopVoiceSpaces = {{
"Direct",
}};
inline constexpr UIConfig kSlpWorkshopUI = {
PrimaryInput::XYPad,
false,
true,
};
} // namespace nisps::modes::generated
#endif // NISPS_GENERATED_SLP_WORKSHOP_SCHEMA_HPP

View file

@ -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 <cstddef>
#include <cstdint>
#include <span>
#include <string_view>
#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<SLPWorkshopMode, MEMLCeliumEngine,
ml::MLP<4u, 10u, 14u, 18u, 56u>, 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<const std::string_view>(generated::kSlpWorkshopInputChannels),
generated::kSlpWorkshopMLConfig.input_size,
std::span<const std::size_t>(generated::kSlpWorkshopHiddenLayers),
generated::kSlpWorkshopMLConfig.output_size,
generated::kSlpWorkshopMLConfig.default_spread,
generated::kSlpWorkshopMLConfig.default_learning_rate,
generated::kSlpWorkshopMLConfig.default_max_iterations,
std::span<const generated::Param>(generated::kSlpWorkshopParams),
std::span<const std::string_view>(generated::kSlpWorkshopVoiceSpaces),
generated::kSlpWorkshopUI,
};
};
static_assert(Mode<SLPWorkshopMode>, "SLPWorkshopMode must satisfy nisps::Mode");
} // namespace nisps::modes

View file

@ -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 (
<ModeShell
schema={schema}
runtime={runtime}
activeVoiceSpace={voiceSpace}
onVoiceSpaceChange={setVoiceSpace}
drawerTitle="SLP-Workshop settings"
primaryInput={() => (
<>
<XYPad
size={280}
ariaLabel="SLP-Workshop pad"
onMove={(x, y) => runtime.setInput(x, y)}
position={runtime.pipedInput}
/>
<span style={{ 'font-size': 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
Synth Library Portland workshop instrument MEMLCelium voice with
live Jolt + Explore learning controls.
</span>
</>
)}
outputArea={() => (
<>
<OutputDisplay
values={runtime.paramOutputs}
width={360}
height={120}
color="#ef9e5b"
/>
<LossPlot history={runtime.training.lossHistory} width={360} height={70} />
</>
)}
/>
);
};
export default SLPWorkshopMode;

View file

@ -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';

View file

@ -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,
},
};

View file

@ -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<ModeRegistration> = [
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',

View file

@ -21,6 +21,7 @@ const MODE_IDS = [
'xiasri',
'verb_fx',
'memlcelium',
'slp_workshop',
'breakor',
'elysiamorf',
'sound_analysis_midi',

View file

@ -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
}
}

View file

@ -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 <array>
#include <cmath>
#include <cstdint>
#include <span>
#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<float, 32> w{};
w.fill(0.5f);
j.step(std::span<float>(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<float, 64> w{};
w.fill(0.f);
j.press(w.size());
for (int i = 0; i < 300; ++i) j.step(std::span<float>(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<float, 48> 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<float>(wa));
b.step(std::span<float>(wb));
}
for (std::size_t i = 0; i < wa.size(); ++i) NISPS_EXPECT(wa[i] == wb[i]);
}
} // namespace

View file

@ -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 <array>
#include <cmath>
#include <cstdint>
#include <span>
#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<float, 8> o{};
o.fill(0.5f);
ou.apply(std::span<float>(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<float, 8> o{};
o.fill(0.5f);
ou.apply(std::span<float>(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<float, 4> oa{}, ob{};
oa.fill(0.3f);
ob.fill(0.3f);
a.apply(std::span<float>(oa));
b.apply(std::span<float>(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<float, 4> o{};
o.fill(0.2f);
ou.apply(std::span<float>(o));
for (float v : o) NISPS_EXPECT(v == 0.2f);
}
} // namespace

View file

@ -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<modes::ChannelStripMode>);
static_assert(Mode<modes::XIASRIMode>);
static_assert(Mode<modes::VerbFXMode>);
static_assert(Mode<modes::MEMLCeliumMode>);
static_assert(Mode<modes::SLPWorkshopMode>);
static_assert(Mode<modes::BreakOrMode>);
static_assert(Mode<modes::ElysiamorfMode>);
static_assert(Mode<modes::SoundAnalysisMIDIMode>);
@ -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");

View file

@ -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 <array>
#include <cmath>
#include <cstdint>
#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<float>(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<float, ML::weight_count()> 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());
}