feat(modes): ModeBase input-pin API for single/dual joystick

Platform-agnostic neutral-pin mechanism (set_input_pinned/pin_value) on
ModeBase, lifted from ebb953a. Consumed by the firmware Joystick Dual/Single
settings menu; the browser fixed-MLP<4> half of ebb953a is superseded by main's
MLP<32> N-D input work and intentionally dropped.
This commit is contained in:
monkey-w1n5t0n 2026-06-28 21:17:51 +02:00
parent c955e94a7a
commit 2f9cfd1de2
2 changed files with 90 additions and 3 deletions

View file

@ -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<Derived&>(*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<float, NInputs> input_channels_{};
std::array<bool, NInputs> input_pinned_{}; // false => live
float pin_value_ = 0.5f; // neutral
bool input_dirty_ = false;
std::size_t voice_space_idx_ = 0u;
RingBuffer<ControlEvent, kModeEventBufferSize> events_{};

View file

@ -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<float, 33> 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);