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.
274 lines
12 KiB
C++
274 lines
12 KiB
C++
// nisps/modes/base.hpp — Common scaffolding for every concrete mode.
|
|
//
|
|
// Two responsibilities:
|
|
// 1. Define `nisps::ParamSchema` (the aggregate type that the `Mode`
|
|
// concept's `param_schema()` returns a const-reference to). The codegen
|
|
// output in `nisps/modes/generated/` lives in a different namespace and
|
|
// provides typed constants per mode; we wrap those in a uniform view-
|
|
// style `ParamSchema` here so the concept is satisfied without touching
|
|
// generated code.
|
|
//
|
|
// 2. Provide `ModeBase<Derived, EngineT, MLPType, NInputs>` — a CRTP base
|
|
// that absorbs the per-mode boilerplate (input forwarding, ML inference
|
|
// driving engine params, voice space selection, control event ring
|
|
// buffer). Concrete modes derive from this and only specialise:
|
|
// - the schema reference (static),
|
|
// - the "extra" pre-mapping done before set_params (e.g. analysis
|
|
// features stitched into ML inputs in SoundAnalysisMIDI),
|
|
// - any engine-specific control glue (note_on/note_off, sequencer
|
|
// play/stop, BPM updates).
|
|
//
|
|
// Modes are platform-agnostic. Hardware/browser glue maps abstract input
|
|
// channels (float [0, 1]) into `set_input(idx, value)` and drains
|
|
// `pop_control_events()` for MIDI/I2C dispatch.
|
|
//
|
|
// No heap, no virtuals, no pico/Arduino headers.
|
|
|
|
#pragma once
|
|
|
|
#include <array>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
#include <span>
|
|
#include <string_view>
|
|
#include <type_traits>
|
|
|
|
#include "../core/concepts.hpp"
|
|
#include "../core/perf.hpp"
|
|
#include "../core/ring_buffer.hpp"
|
|
#include "../core/types.hpp"
|
|
#include "generated/schema_types.hpp"
|
|
|
|
namespace nisps {
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ParamSchema — view-style aggregate matching the concept's forward decl.
|
|
// All members are spans/views into compile-time generated arrays; the
|
|
// schema itself can be `inline constexpr` per mode.
|
|
// ---------------------------------------------------------------------------
|
|
struct ParamSchema {
|
|
std::string_view mode_id;
|
|
std::string_view engine_id;
|
|
std::span<const std::string_view> input_channels;
|
|
std::size_t input_size;
|
|
std::span<const std::size_t> hidden_layers;
|
|
std::size_t output_size;
|
|
float default_spread;
|
|
float default_learning_rate;
|
|
std::size_t default_max_iterations;
|
|
std::span<const ::nisps::modes::generated::Param> params;
|
|
std::span<const std::string_view> voice_spaces;
|
|
::nisps::modes::generated::UIConfig ui;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Abstract control event — emitted by modes for the platform glue to drain.
|
|
// Sequencer modes (BreakOr, Elysiamorf) push real events; synth modes push
|
|
// none unless they want to relay MIDI thru.
|
|
// ---------------------------------------------------------------------------
|
|
struct ControlEvent {
|
|
enum class Kind : std::uint8_t {
|
|
None,
|
|
NoteOn,
|
|
NoteOff,
|
|
ControlChange,
|
|
Clock,
|
|
};
|
|
Kind kind = Kind::None;
|
|
std::uint8_t channel = 0u;
|
|
std::uint8_t data1 = 0u;
|
|
std::uint8_t data2 = 0u;
|
|
};
|
|
|
|
// Sized at the larger of the engines' event buffers (BreakOr/Elysiamorf
|
|
// publish 64 entries; we mirror that for consistency).
|
|
inline constexpr std::size_t kModeEventBufferSize = 64u;
|
|
|
|
// Trait controlling whether ModeBase routes ML outputs into engine.set_params().
|
|
// Default: true (every synth/effect mode). Specialise to `false` for modes
|
|
// that don't (e.g. SoundAnalysisMIDIMode where outputs become MIDI CC).
|
|
template <typename Derived>
|
|
struct ModeRoutesOutputsToEngine : std::true_type {};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// ModeBase — CRTP scaffold.
|
|
//
|
|
// Derived classes provide:
|
|
// static constexpr const ParamSchema& schema() // their generated schema
|
|
// void on_setup(float sample_rate) noexcept // optional hook
|
|
// void on_pre_inference() noexcept // optional, before ml_.process()
|
|
// void on_post_inference() noexcept // optional, after engine.set_params()
|
|
//
|
|
// Derived classes may choose the engine type (`EngineT`) and ML type
|
|
// (`MLPType`) freely; both must satisfy `MLEngine` and `AudioEngine`
|
|
// respectively, except for sequencer modes whose engine still satisfies
|
|
// `AudioEngine` (process() returns silence).
|
|
// ---------------------------------------------------------------------------
|
|
template <typename Derived,
|
|
typename EngineT,
|
|
typename MLPType,
|
|
std::size_t NInputs>
|
|
class ModeBase {
|
|
public:
|
|
using Engine = EngineT;
|
|
using ML = MLPType;
|
|
|
|
static_assert(AudioEngine<EngineT>,
|
|
"ModeBase: EngineT must satisfy nisps::AudioEngine concept");
|
|
static_assert(MLEngine<MLPType>,
|
|
"ModeBase: MLPType must satisfy nisps::MLEngine concept");
|
|
static_assert(MLPType::kInput == NInputs,
|
|
"ModeBase: NInputs must equal MLP::kInput");
|
|
// Most modes route ML outputs directly into engine params; require the
|
|
// sizes to match. SoundAnalysisMIDI opts out by specialising
|
|
// ModeRoutesOutputsToEngine<Derived> to std::false_type.
|
|
static constexpr bool kRouteOutputsToEngine =
|
|
ModeRoutesOutputsToEngine<Derived>::value;
|
|
static_assert(!kRouteOutputsToEngine ||
|
|
MLPType::kOutput == EngineT::param_count(),
|
|
"ModeBase: MLP output_size must equal engine param_count() "
|
|
"unless ModeRoutesOutputsToEngine<Derived> is false");
|
|
|
|
static constexpr std::size_t input_channel_count() noexcept { return NInputs; }
|
|
|
|
explicit ModeBase(std::uint64_t seed = 0xC0FFEEu) noexcept : ml_(seed) {}
|
|
|
|
// ---- Mode concept surface ----
|
|
void setup(float sample_rate) noexcept {
|
|
sample_rate_ = sample_rate;
|
|
engine_.setup(sample_rate);
|
|
for (auto& v : input_channels_) v = 0.5f;
|
|
// Run an inference at default inputs so engine has params on first
|
|
// process() call, even if no input has been touched.
|
|
for (std::size_t i = 0u; i < NInputs; ++i) {
|
|
ml_.set_input(i, effective_input(i));
|
|
}
|
|
ml_.process();
|
|
if constexpr (kRouteOutputsToEngine) {
|
|
engine_.set_params(ml_.outputs());
|
|
}
|
|
if constexpr (requires(Derived& d, float s) { d.on_setup(s); }) {
|
|
static_cast<Derived&>(*this).on_setup(sample_rate);
|
|
}
|
|
}
|
|
|
|
NISPS_FORCE_INLINE void set_input(std::size_t idx, float value) noexcept {
|
|
if (idx >= NInputs) return;
|
|
if (value < 0.f) value = 0.f;
|
|
else if (value > 1.f) value = 1.f;
|
|
input_channels_[idx] = value;
|
|
input_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, applying
|
|
// the per-channel pin override.
|
|
for (std::size_t i = 0u; i < NInputs; ++i) {
|
|
ml_.set_input(i, effective_input(i));
|
|
}
|
|
ml_.process();
|
|
if constexpr (kRouteOutputsToEngine) {
|
|
engine_.set_params(ml_.outputs());
|
|
}
|
|
input_dirty_ = false;
|
|
if constexpr (requires(Derived& d) { d.on_post_inference(); }) {
|
|
static_cast<Derived&>(*this).on_post_inference();
|
|
}
|
|
}
|
|
|
|
NISPS_HOT NISPS_FORCE_INLINE stereosample_t process(stereosample_t x) noexcept {
|
|
return engine_.process(x);
|
|
}
|
|
|
|
Engine& engine() noexcept { return engine_; }
|
|
const Engine& engine() const noexcept { return engine_; }
|
|
ML& ml() noexcept { return ml_; }
|
|
const ML& ml() const noexcept { return ml_; }
|
|
|
|
// ---- Common helpers ----
|
|
|
|
// Read-only view of latest input-channel values [0, 1].
|
|
std::span<const float> input_channels() const noexcept {
|
|
return std::span<const float>(input_channels_.data(), NInputs);
|
|
}
|
|
// Mutable accessor for derived classes (e.g. SoundAnalysisMIDI splices
|
|
// analysis features into the channel array before forwarding to ML).
|
|
std::span<float> mutable_input_channels() noexcept {
|
|
return std::span<float>(input_channels_.data(), NInputs);
|
|
}
|
|
|
|
// Voice space selection (engines that support it expose set_voice_space).
|
|
void set_voice_space(std::size_t idx) noexcept {
|
|
if constexpr (requires(EngineT& e) { e.set_voice_space(typename EngineT::VoiceSpace{}); }) {
|
|
using VS = typename EngineT::VoiceSpace;
|
|
if (idx >= EngineT::kVoiceSpaceCount) return;
|
|
engine_.set_voice_space(static_cast<VS>(idx));
|
|
voice_space_idx_ = idx;
|
|
// Re-apply current params under the new voice space mapping.
|
|
engine_.set_params(ml_.outputs());
|
|
} else {
|
|
(void)idx;
|
|
}
|
|
}
|
|
std::size_t voice_space_index() const noexcept { return voice_space_idx_; }
|
|
|
|
// Control event ring — modes/derived classes push, hardware glue pops.
|
|
NISPS_FORCE_INLINE bool push_control_event(const ControlEvent& e) noexcept {
|
|
return events_.try_push(e);
|
|
}
|
|
std::size_t pop_control_events(std::span<ControlEvent> out) noexcept {
|
|
std::size_t n = 0u;
|
|
while (n < out.size()) {
|
|
ControlEvent e;
|
|
if (!events_.try_pop(e)) break;
|
|
out[n++] = e;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
float sample_rate() const noexcept { return sample_rate_; }
|
|
|
|
protected:
|
|
float sample_rate_ = 48000.f;
|
|
EngineT engine_{};
|
|
MLPType ml_;
|
|
std::array<float, NInputs> input_channels_{};
|
|
std::array<bool, NInputs> input_pinned_{}; // false => live
|
|
float pin_value_ = 0.5f; // neutral
|
|
bool input_dirty_ = false;
|
|
std::size_t voice_space_idx_ = 0u;
|
|
RingBuffer<ControlEvent, kModeEventBufferSize> events_{};
|
|
};
|
|
|
|
} // namespace nisps
|