diff --git a/nisps/modes/base.hpp b/nisps/modes/base.hpp index 9053bfd..d5702a1 100644 --- a/nisps/modes/base.hpp +++ b/nisps/modes/base.hpp @@ -141,7 +141,7 @@ class ModeBase { // Run an inference at default inputs so engine has params on first // process() call, even if no input has been touched. for (std::size_t i = 0u; i < NInputs; ++i) { - ml_.set_input(i, input_channels_[i]); + ml_.set_input(i, effective_input(i)); } ml_.process(); if constexpr (kRouteOutputsToEngine) { @@ -160,13 +160,42 @@ class ModeBase { input_dirty_ = true; } + // ---- Input neutralization (single/double controller toggle) ---- + // + // A pinned channel feeds `pin_value_` (neutral, default 0.5) to the MLP + // instead of its live value, without rebuilding/resizing the network. + // Glue toggles which channels are pinned (e.g. single-joystick mode pins + // the second 2D controller's two channels). The stored live value is left + // untouched, so unpinning resumes from the controller's current position. + NISPS_FORCE_INLINE void set_input_pinned(std::size_t idx, bool pinned) noexcept { + if (idx >= NInputs) return; + input_pinned_[idx] = pinned; + input_dirty_ = true; + } + NISPS_FORCE_INLINE bool is_input_pinned(std::size_t idx) const noexcept { + return idx < NInputs && input_pinned_[idx]; + } + NISPS_FORCE_INLINE void set_pin_value(float v) noexcept { + if (v < 0.f) v = 0.f; + else if (v > 1.f) v = 1.f; + pin_value_ = v; + input_dirty_ = true; + } + float pin_value() const noexcept { return pin_value_; } + + // Effective value fed to the MLP for channel i (pin override applied). + NISPS_FORCE_INLINE float effective_input(std::size_t i) const noexcept { + return input_pinned_[i] ? pin_value_ : input_channels_[i]; + } + NISPS_HOT void tick_control() noexcept { if constexpr (requires(Derived& d) { d.on_pre_inference(); }) { static_cast(*this).on_pre_inference(); } - // Forward (possibly Derived-mutated) channels into the MLP. + // Forward (possibly Derived-mutated) channels into the MLP, applying + // the per-channel pin override. for (std::size_t i = 0u; i < NInputs; ++i) { - ml_.set_input(i, input_channels_[i]); + ml_.set_input(i, effective_input(i)); } ml_.process(); if constexpr (kRouteOutputsToEngine) { @@ -235,6 +264,8 @@ class ModeBase { EngineT engine_{}; MLPType ml_; std::array input_channels_{}; + std::array input_pinned_{}; // false => live + float pin_value_ = 0.5f; // neutral bool input_dirty_ = false; std::size_t voice_space_idx_ = 0u; RingBuffer events_{}; diff --git a/tests/cpp/test_mode_paf_synth.cpp b/tests/cpp/test_mode_paf_synth.cpp index 737ba2f..9d18662 100644 --- a/tests/cpp/test_mode_paf_synth.cpp +++ b/tests/cpp/test_mode_paf_synth.cpp @@ -88,6 +88,62 @@ NISPS_TEST(paf_synth_mode_input_clamping) { m.set_input(99u, 0.5f); } +NISPS_TEST(paf_synth_mode_pinned_inputs_neutralized) { + // Single-joystick mode pins the second 2D controller's channels (2,3). + // A pinned channel must feed the neutral pin value (default 0.5) to the + // MLP regardless of its live value, and pinning must not resize the model. + PAFSynthMode m; + m.setup(48000.f); + NISPS_EXPECT(m.pin_value() == 0.5f); + + // Reference: channels 2,3 driven live to 0.5 (the neutral value). + m.set_input(0, 0.2f); + m.set_input(1, 0.8f); + m.set_input(2, 0.5f); + m.set_input(3, 0.5f); + m.tick_control(); + std::array ref{}; + { + const auto outs = m.ml().outputs(); + for (std::size_t i = 0u; i < outs.size(); ++i) ref[i] = outs[i]; + } + + // Pin channels 2,3, then drive them to arbitrary values. Output must match + // the reference (they are neutralized to 0.5). + m.set_input_pinned(2, true); + m.set_input_pinned(3, true); + NISPS_EXPECT(m.is_input_pinned(2)); + m.set_input(2, 0.0f); + m.set_input(3, 1.0f); + m.tick_control(); + for (std::size_t i = 0u; i < 33u; ++i) { + NISPS_EXPECT(std::fabs(m.ml().outputs()[i] - ref[i]) < 1e-6f); + } + + // The active controller (channels 0,1) still affects the output. + m.set_input(0, 0.9f); + m.tick_control(); + bool changed = false; + for (std::size_t i = 0u; i < 33u; ++i) { + if (std::fabs(m.ml().outputs()[i] - ref[i]) > 1e-6f) { changed = true; break; } + } + NISPS_EXPECT(changed); + + // Unpinning restores sensitivity on channels 2,3. + m.set_input_pinned(2, false); + m.set_input_pinned(3, false); + m.set_input(0, 0.2f); // restore active channels to the reference pose + m.set_input(1, 0.8f); + m.set_input(2, 0.0f); + m.set_input(3, 1.0f); + m.tick_control(); + bool differs = false; + for (std::size_t i = 0u; i < 33u; ++i) { + if (std::fabs(m.ml().outputs()[i] - ref[i]) > 1e-6f) { differs = true; break; } + } + NISPS_EXPECT(differs); +} + NISPS_TEST(paf_synth_mode_engine_and_ml_accessors) { PAFSynthMode m; m.setup(48000.f);