memlnaut-nisps/firmware/MEMLNaut-NISPS/glue/peripherals.hpp
monkey-w1n5t0n ffac04090c refactor(firmware): delete vendored daisysp and the input_router layer
Phase 1 group 8 (S8, ST2, L14).

- S8: the vendored src/daisysp tree (96 files, 41 .cpp compiled into every
  firmware build) and its sketch-tree symlink. Zero consumers — nisps replaced
  daisysp's PitchShifter with a custom granular implementation. Corrected
  firmware/README.md and setup-firmware-toolchain.sh, which called it a live
  submodule. The attribution comment in nisps/dsp/pitch_shift.hpp stays: it is
  an honest provenance note about a port, not a dependency.
  Noted while verifying: src/memllib/examples/KassiaAudioApp includes
  ../../daisysp/..., but examples/ is not symlinked into the sketch tree and is
  never compiled by the firmware build, so the deletion stands.
- ST2: input_router.hpp was a zero-logic speculative layer with one consumer;
  the .ino now calls bind_peripherals directly.
- L14: peripherals.hpp — deleted kAnalogInputCount, PeripheralBindings and the
  unused spread local, and extracted the duplicated commit-and-train block into
  one helper. The audit's suggested commit_and_train(mode, feedback) signature
  could NOT be behaviour-identical: repositioning() implies placing() (both
  live in ExploreState::Placing), so dispatching on feedback state inside the
  helper would silently reroute a TogB2 press mid-reposition from commit_place
  to commit_reposition, which differs (no snapshot restore, clears
  reposition_). Implemented as commit_and_train(mode, feedback, bool
  reposition) with the branch decided at the call site, preserving behaviour.

Firmware is not compiled by any gate yet (that arrives with the PlatformIO
migration, plan §5/S9), so this group is verified by reading and grep only.
Gates: run-all-tests.sh ALL GREEN.
2026-07-21 12:49:25 +02:00

230 lines
11 KiB
C++

// firmware/glue/peripherals.hpp — Joystick, buttons, toggles → mode input
// channels.
//
// The MEMLNaut hardware exposes:
// - 3 analog joystick axes (X, Y, Z) via `setJoyXCallback` / `Y` / `Z`,
// each delivering a float in [0, 1].
// - Up to 5 rotary pots (RVGain1, RVZ1, RVY1, RVX1, ADC3) — same callback
// shape.
// - 4 momentary buttons (MomA1/2, MomB1/2) and 5 toggles (TogA1/2, TogB1/2,
// JoySW). Buttons fire `void()` on press; toggles fire `void(bool)` on
// edge.
//
// The `Mode` concept's input channels are abstract floats in [0, 1]. Each
// concrete mode's schema names its channels (e.g. paf_synth: joy_x, joy_y,
// joy_z, joy_w). The glue maps the *first N* analog inputs onto channels
// `[0, N)` — that's the trivial mapping that matches every concrete mode's
// schema today (joystick first, optional 4D extra pot for joy_w).
//
// Buttons/toggles drive the SHARED ExploreAndPlace lifecycle. The authoritative
// per-control mapping is documented at the binding site below; in summary:
// MomA1 (TA up) : enter / exit explore (toggle)
// MomA2 (TA down) : like — freeze current scratchpad output (begin place)
// MomB1 (MA up) : Exploring → reroll scratchpad; Idle → grab (reposition)
// MomB2 (MA down) : Exploring → nudge scratchpad; Repositioning → drop+train
// TogB1 : Jolt — held continuous weight morph (up=morph, down=freeze)
// TogB2 : commit place (rising-edge) — store +1 example + 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
// hardware buttons. See the per-button mapping at the binding site.
//
// FOLLOW-UP (needs a firmware build to verify — no arduino-cli here): while the
// controller is PLACING, the audition should hold feedback.static_output()
// (the frozen vector) instead of running fresh inference. That requires a hook
// in the control path (tick_control / audio_driver) to consult
// feedback.static_output() before ml().process() — not yet wired here because
// the controller lives in this translation unit. Wiring the buttons is the
// deliverable; the audition-hold is a small follow-up.
#pragma once
#include <Arduino.h>
#include <array>
#include <cstddef>
#include <cstdint>
#include "../src/nisps/core/perf.hpp"
#include "../src/nisps/ml/feedback.hpp"
#include "../src/memllib/hardware/memlnaut/MEMLNaut.hpp"
namespace nisps_firmware {
// Salt matching nisps/wasm/bindings.cpp so the firmware FeedbackController's
// per-instance Rng stream is independent of (and reproducible against) the
// MLP's inference/move RNG — same discipline as the browser.
inline constexpr std::uint64_t kFeedbackSalt = 0xFEEDBACC0DEull;
// Firmware undo-ring depth for ExploreAndPlace (rl-feedback-design §2.2: WASM
// D=4, firmware D=2 — SRAM budget).
inline constexpr std::size_t kFirmwareUndoDepth = 2u;
// Shared commit path for the two "commit the held output at the CURRENT
// joystick input" gestures (MomB2 drop-reposition, TogB2 commit-place).
// Captures the joystick input BEFORE the commit (commit_place restores the
// real net), runs the caller-named commit, then stores the +1 example
// (input → committed output) and warm-start trains — the CALLER owns
// training. The commit is picked by the call site, not by feedback state:
// dispatching on repositioning() here would change what TogB2 does when
// pressed mid-reposition (today it takes the commit_place path).
template <typename Mode, typename FB>
inline void commit_and_train(Mode& mode, FB& feedback, bool reposition) {
// Capture the current joystick input — the DESTINATION position.
const auto in = mode.input_channels();
std::array<float, Mode::ML::kInput> features{};
for (std::size_t i = 0; i < Mode::ML::kInput; ++i) {
features[i] = (i < in.size()) ? in[i] : 0.f;
}
if (reposition) {
feedback.commit_reposition(); // no weight restore (net never set aside)
} else {
feedback.commit_place(mode.ml()); // restores the real net
}
const auto label = feedback.committed_output(); // valid post-commit
if (!label.empty()) {
mode.ml().add_example(
std::span<const float>(features.data(), Mode::ML::kInput), label);
(void)mode.ml().train();
}
}
// Wire all hardware → mode bindings. Must be called *after*
// MEMLNaut::Initialize() (so MEMLNaut::Instance() is valid). The Mode
// reference must outlive the program (it's a static in the .ino).
template <typename Mode>
inline void bind_peripherals(Mode& mode) {
auto* meml = MEMLNaut::Instance();
if (!meml) return;
// ---- Analog inputs → mode input channels ----
meml->setJoyXCallback([&mode](float v) { mode.set_input(0u, v); });
meml->setJoyYCallback([&mode](float v) { mode.set_input(1u, v); });
meml->setJoyZCallback([&mode](float v) { mode.set_input(2u, v); });
// Fourth analog input: use ADC3 (the spare). Modes that don't expose a
// joy_w channel just don't see it (set_input drops out-of-range idx).
meml->setADC3Callback([&mode](float v) { mode.set_input(3u, v); });
// ---- Rotary pots ----
// Reuse RVGain1 as a "tempo" knob for sequencer modes. The mode is free
// to ignore via the same channel-bounds drop. RVGain1 is also used by
// InterfaceRL legacy as an audio output volume knob — for now we hand
// it to the ML interface for non-sequencer modes by setting the output
// master volume directly via AudioDriver.
meml->setRVGain1Callback([](float v) {
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
// WASM) drives the Idle → Exploring → Placing → Idle state machine here.
// The HARDWARE button mapping differs from the browser's on_down/on_up
// default policy — firmware maps its buttons to the GRANULAR core methods:
//
// MomA1 (TA up) : down — enter explore (Idle→Exploring) /
// exit explore (Exploring→Idle) TOGGLE
// MomB1 (MA up) : STATE-GATED — Exploring: reroll the scratchpad;
// Idle: GRAB (begin reposition — freeze the
// current trained-net output to carry it).
// MomB2 (MA down) : STATE-GATED — Exploring: nudge (small perturbation);
// Repositioning: DROP (commit the carried output
// at the current joystick input + train).
// MomA2 (TA down) : like — begin place (Exploring→Placing, freeze output)
// TogB2 (rising) : up — commit place (Placing→Idle) at the CURRENT
// joystick input, then store the +1 example and
// train (caller owns training). Outside Placing,
// a plain train().
//
// The controller is a function-local static so it lives for the whole
// program (like the mode). It is seeded off the same salt as the browser.
using FB = nisps::ml::FeedbackController<typename Mode::ML, kFirmwareUndoDepth>;
static FB feedback(static_cast<std::uint64_t>(0xC0FFEEu) ^ kFeedbackSalt);
feedback.set_mode(nisps::ml::FeedbackMode::ExploreAndPlace, mode.ml());
// MomA1: enter/exit explore toggle.
meml->setMomA1Callback([&mode]() {
if (feedback.explore_state() == nisps::ml::ExploreState::Idle) {
feedback.enter_explore(mode.ml(), mode.param_schema().default_spread);
} else {
feedback.exit_explore(mode.ml());
}
});
// MomB1: STATE-GATED.
// Exploring → reroll the scratchpad (a fresh random sound).
// Idle → GRAB: begin a reposition. Freezes the output currently heard
// from the trained net so the user can carry it to a new input
// location (the 4D variant has no joystick button, so the
// grab/drop gesture lives on this momentary toggle). The real
// net is NOT set aside — only the heard output is frozen.
meml->setMomB1Callback([&mode]() {
switch (feedback.explore_state()) {
case nisps::ml::ExploreState::Exploring:
feedback.reroll(mode.ml(), mode.param_schema().default_spread);
break;
case nisps::ml::ExploreState::Idle:
feedback.begin_reposition(mode.ml()); // process + capture + hold
break;
case nisps::ml::ExploreState::Placing:
break; // already holding — ignore
}
});
// MomB2: STATE-GATED.
// Exploring → nudge the scratchpad (small bounded perturbation).
// Repositioning → DROP: commit the carried output at the CURRENT joystick
// input, store the +1 example (new input → carried output)
// and warm-start train. This is the "move an existing
// positive example to a new position" gesture.
meml->setMomB2Callback([&mode]() {
if (feedback.explore_state() == nisps::ml::ExploreState::Exploring) {
feedback.nudge(mode.ml(), 0.05f);
return;
}
if (feedback.repositioning()) {
commit_and_train(mode, feedback, /*reposition=*/true);
}
});
// MomA2: like → begin place. Freezes the current scratchpad output so the
// user can move the joystick to choose WHERE to place it.
meml->setMomA2Callback([&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).
meml->setTogB2Callback([&mode](bool state) {
if (!state) return;
if (feedback.placing()) {
commit_and_train(mode, feedback, /*reposition=*/false);
} else {
(void)mode.ml().train();
}
});
}
} // namespace nisps_firmware