// iml.hpp — Self-contained runtime IML/MLP for the MEMLNaut VCV module. // // This is a VENDORED, runtime-shaped re-implementation of the nisps core // `nisps::IML` / `nisps::MLP` surface the VCV module relies on. // The retired `nisps-core` header tree (`-I../nisps-core/include`) is gone, and // the templated firmware/WASM `nisps/ml` core is fixed-size — neither is a clean // fit for a runtime 8→16 module. So we ship a small native MLP here that matches // the firmware/browser TRAINING SEMANTICS as closely as a runtime form allows: // // • ReLU hidden layers, sigmoid output (sigmoid maps to [0,1]). // • A trailing bias node (1.0) appended to the input vector. // • spread-aware weight init: uniform [-1,1] (spread=0) → Xavier 1/√fan_in // (spread=1), interpolated per layer. // • spread-aware perturbation (RL "move weights"): flat noise (spread=0) → // per-layer Xavier-scaled noise + 10%·spread weight decay (spread=1). // • Plain SGD / MSE training over the example dataset. // • DETERMINISTIC per-instance RNG (seeded), so behaviour is reproducible and // the threading double-buffer stays race-free (each MLP owns its own RNG). // // It is NOT bit-identical to the firmware core (different optimiser internals), // and that divergence is an accepted follow-up (see docs/specs/vcv-module.md delta #5). The // public method names mirror the core so `MEMLNaut.cpp` is unchanged in spirit. // // MPL-2.0 in spirit with the rest of nisps; wrapper code under the VCV module's // licence. British spelling in comments where it reads naturally. #pragma once #include #include #include #include #include #include namespace nisps { // ── Tiny deterministic RNG (xorshift128) ────────────────────────────── // Per-instance state; seeded in the constructor. No std::random_device, no // shared global generator — this is what keeps the audio/worker MLP pair free // of data races and makes parity reproducible. class DetRng { public: explicit DetRng(uint32_t seed = 0x1234567u) { reseed(seed); } void reseed(uint32_t seed) { s_[0] = seed ? seed : 0xA5A5A5A5u; s_[1] = s_[0] ^ 0x9E3779B9u; s_[2] = s_[0] * 0x85EBCA6Bu + 1u; s_[3] = s_[0] * 0xC2B2AE35u + 0x27D4EB2Fu; } uint32_t next_u32() { uint32_t t = s_[3]; uint32_t const u = s_[0]; s_[3] = s_[2]; s_[2] = s_[1]; s_[1] = u; t ^= t << 11; t ^= t >> 8; s_[0] = t ^ u ^ (u >> 19); return s_[0]; } // Uniform in [0,1) float uniform01() { return (next_u32() >> 8) * (1.0f / 16777216.0f); } // Uniform in [-1,1) float uniform_pm1() { return uniform01() * 2.0f - 1.0f; } // Approx standard-normal: sum of 3 uniforms (matches the core's gen_randn shape) float gaussian() { return (uniform_pm1() + uniform_pm1() + uniform_pm1()) * 0.5773502692f; // /√3 → unit-ish variance } private: uint32_t s_[4]; }; // ── MLP ─────────────────────────────────────────────────────────────── template class MLP { public: // 3D weight store: [layer][node][weight] where the final weight per node is // the bias (the previous layer's activations get a trailing 1.0). using mlp_weights = std::vector>>; // layers_nodes: full sizes including input (with bias) and output, e.g. // {n_in + 1, h0, h1, h2, n_out} MLP(const std::vector& layers_nodes, uint32_t seed) : layers_nodes_(layers_nodes), rng_(seed) { build_(); draw_weights_spread_(static_cast(0)); } size_t num_layers() const { return weights_.size(); } // Forward pass. `input_with_bias` has the trailing 1.0 already appended. void forward(const std::vector& input_with_bias, std::vector& out) const { std::vector act = input_with_bias; for (size_t l = 0; l < weights_.size(); ++l) { const auto& layer = weights_[l]; const bool is_output = (l + 1 == weights_.size()); std::vector next(layer.size()); for (size_t n = 0; n < layer.size(); ++n) { const auto& w = layer[n]; T sum = 0; // w has act.size()+1 entries? No: w spans the *current* input // size (which already includes the bias slot of `act`). const size_t lim = std::min(w.size(), act.size()); for (size_t k = 0; k < lim; ++k) sum += w[k] * act[k]; next[n] = is_output ? sigmoid_(sum) : relu_(sum); } // For hidden layers, append a bias term for the next layer's input. if (!is_output) next.push_back(static_cast(1)); act = std::move(next); } out = std::move(act); } // ── spread-aware weight init ────────────────────────────────────── void draw_weights_spread(T spread) { draw_weights_spread_(spread); } // ── spread-aware perturbation (RL move_weights) ─────────────────── void move_weights_spread(T speed, T spread) { const T decay = static_cast(1) - static_cast(0.1) * spread; for (size_t l = 0; l < weights_.size(); ++l) { const T fan_in = static_cast(input_size_of_layer_(l)); const T xavier = (fan_in > 0) ? static_cast(1) / std::sqrt(fan_in) : static_cast(1); // spread=0 → flat noise (scale 1); spread=1 → per-layer Xavier scale const T noiseScale = (static_cast(1) - spread) + spread * xavier; for (auto& node : weights_[l]) { for (auto& w : node) { if (spread > 0) w *= decay; // weight decay only when spread>0 w += rng_.gaussian() * speed * noiseScale; } } } } // ── plain SGD / MSE training ────────────────────────────────────── // features: each row is input WITHOUT bias; labels: target outputs in [0,1]. void train(const std::vector>& features, const std::vector>& labels, int max_iterations, T learning_rate, T convergence) { const size_t n = std::min(features.size(), labels.size()); if (n == 0) return; for (int iter = 0; iter < max_iterations; ++iter) { T epoch_loss = 0; for (size_t s = 0; s < n; ++s) { std::vector in = features[s]; in.push_back(static_cast(1)); // bias epoch_loss += backprop_(in, labels[s], learning_rate); } epoch_loss /= static_cast(n); if (epoch_loss < convergence) break; } } mlp_weights get_weights() const { return weights_; } void set_weights(const mlp_weights& w) { // Only adopt if the topology matches; otherwise ignore (keeps the audio // path safe against malformed snapshots from the bridge / patch files). if (w.size() != weights_.size()) return; for (size_t l = 0; l < w.size(); ++l) { if (w[l].size() != weights_[l].size()) return; } weights_ = w; } private: static T relu_(T x) { return x > 0 ? x : 0; } static T sigmoid_(T x) { return static_cast(1) / (static_cast(1) + std::exp(-x)); } static T dsigmoid_from_out_(T y) { return y * (static_cast(1) - y); } size_t input_size_of_layer_(size_t l) const { // The number of weights per node in layer l (incl. bias slot). return weights_[l].empty() ? 0 : weights_[l][0].size(); } void build_() { weights_.clear(); // layers_nodes_[0] is the input layer WITH bias already counted. for (size_t l = 1; l < layers_nodes_.size(); ++l) { const size_t in_sz = layers_nodes_[l - 1]; // includes bias slot const size_t out_sz = layers_nodes_[l]; std::vector> layer(out_sz, std::vector(in_sz, 0)); weights_.push_back(std::move(layer)); } } void draw_weights_spread_(T spread) { for (size_t l = 0; l < weights_.size(); ++l) { const T fan_in = static_cast(layers_nodes_[l]); // incl. bias const T xavier = (fan_in > 0) ? static_cast(1) / std::sqrt(fan_in) : static_cast(1); const T scale = (static_cast(1) - spread) + spread * xavier; for (auto& node : weights_[l]) { for (size_t k = 0; k < node.size(); ++k) { // bias (last weight) initialised to 0, like the core const bool is_bias = (k + 1 == node.size()); node[k] = is_bias ? static_cast(0) : rng_.uniform_pm1() * scale; } } } } // One SGD step on a single example; returns the MSE for this example. T backprop_(const std::vector& in_with_bias, const std::vector& target, T lr) { // Forward, caching activations per layer. std::vector> acts; acts.reserve(weights_.size() + 1); acts.push_back(in_with_bias); std::vector act = in_with_bias; for (size_t l = 0; l < weights_.size(); ++l) { const auto& layer = weights_[l]; const bool is_output = (l + 1 == weights_.size()); std::vector next(layer.size()); for (size_t nidx = 0; nidx < layer.size(); ++nidx) { const auto& w = layer[nidx]; T sum = 0; const size_t lim = std::min(w.size(), act.size()); for (size_t k = 0; k < lim; ++k) sum += w[k] * act[k]; next[nidx] = is_output ? sigmoid_(sum) : relu_(sum); } if (!is_output) next.push_back(static_cast(1)); acts.push_back(next); act = next; } // Output error. const size_t L = weights_.size(); std::vector& out = acts[L]; T loss = 0; std::vector delta(out.size()); for (size_t o = 0; o < out.size(); ++o) { const T t = (o < target.size()) ? target[o] : static_cast(0); const T e = out[o] - t; loss += e * e; delta[o] = e * dsigmoid_from_out_(out[o]); // MSE × sigmoid' } loss /= static_cast(out.size() ? out.size() : 1); // Backprop through layers L-1 .. 0. std::vector nextDelta; for (size_t li = L; li-- > 0;) { const auto& prevAct = acts[li]; // input activations to layer li auto& layer = weights_[li]; const bool is_output = (li + 1 == L); // Compute delta to propagate to the previous layer (excludes bias node). const size_t prevSize = prevAct.size(); // includes bias slot std::vector propagate(prevSize, 0); for (size_t nidx = 0; nidx < layer.size(); ++nidx) { const T d = delta[nidx]; auto& w = layer[nidx]; const size_t lim = std::min(w.size(), prevSize); for (size_t k = 0; k < lim; ++k) { propagate[k] += d * w[k]; w[k] -= lr * d * prevAct[k]; // gradient step } } // Turn `propagate` into next-layer delta via ReLU' (skip for input). if (li > 0) { const auto& actPrev = acts[li]; // activations of layer li-1's output nextDelta.assign(actPrev.size(), 0); for (size_t k = 0; k < actPrev.size(); ++k) { const T relud = actPrev[k] > 0 ? static_cast(1) : static_cast(0); nextDelta[k] = propagate[k] * relud; } // Drop the trailing bias slot's delta (it has no upstream weights). if (!nextDelta.empty()) nextDelta.pop_back(); delta = nextDelta; } (void)is_output; } return loss; } std::vector layers_nodes_; mlp_weights weights_; mutable DetRng rng_; }; // ── Dataset (FIFO ring, max 100 examples) ───────────────────────────── template class Dataset { public: static constexpr size_t kMax_examples = 100; void add(const std::vector& feat, const std::vector& label) { if (features_.size() >= kMax_examples) { features_.erase(features_.begin()); labels_.erase(labels_.begin()); } features_.push_back(feat); labels_.push_back(label); } void clear() { features_.clear(); labels_.clear(); } size_t count() const { return features_.size(); } const std::vector>& features() const { return features_; } const std::vector>& labels() const { return labels_; } void load(const std::vector>& f, const std::vector>& l) { clear(); const size_t n = std::min(f.size(), l.size()); for (size_t i = 0; i < n; ++i) add(f[i], l[i]); } private: std::vector> features_; std::vector> labels_; }; // ── IML ─────────────────────────────────────────────────────────────── template class IML { public: enum class Mode { Inference, Training }; IML(size_t n_inputs, size_t n_outputs, std::vector hidden_layers = {16, 24, 16}, size_t max_iterations = 200, Float learning_rate = static_cast(0.1), Float convergence_threshold = static_cast(0.00001), uint32_t seed = 0xC0FFEEu) : n_inputs_(n_inputs), n_outputs_(n_outputs), max_iterations_(max_iterations), learning_rate_(learning_rate), convergence_threshold_(convergence_threshold) { std::vector sizes; sizes.push_back(n_inputs_ + 1); // + bias for (size_t h : hidden_layers) sizes.push_back(h); sizes.push_back(n_outputs_); mlp_ = std::make_unique>(sizes, seed); input_state_.assign(n_inputs_, static_cast(0.5)); output_state_.assign(n_outputs_, static_cast(0)); } size_t num_inputs() const { return n_inputs_; } size_t num_outputs() const { return n_outputs_; } void set_input(size_t i, Float v) { if (i >= n_inputs_) return; input_state_[i] = std::clamp(v, static_cast(0), static_cast(1)); input_updated_ = true; } const Float* get_outputs() const { return output_state_.data(); } void process() { if (!input_updated_) return; std::vector in = input_state_; in.push_back(static_cast(1)); mlp_->forward(in, output_state_); if (output_state_.size() < n_outputs_) output_state_.resize(n_outputs_, 0); input_updated_ = false; } void set_mode(Mode m) { if (m == Mode::Inference && mode_ == Mode::Training) train_(); mode_ = m; } Mode get_mode() const { return mode_; } void add_example(const Float* inputs, size_t n_in, const Float* outputs, size_t n_out) { std::vector in(inputs, inputs + std::min(n_in, n_inputs_)); in.resize(n_inputs_, static_cast(0)); std::vector out(outputs, outputs + std::min(n_out, n_outputs_)); out.resize(n_outputs_, static_cast(0)); dataset_.add(in, out); } void clear_dataset() { dataset_.clear(); } void randomise_weights(Float spread) { mlp_->draw_weights_spread(spread); refresh_(); } void move_weights(Float speed, Float spread) { mlp_->move_weights_spread(speed, spread); refresh_(); } typename MLP::mlp_weights get_weights() const { return mlp_->get_weights(); } void set_weights(typename MLP::mlp_weights& w) { mlp_->set_weights(w); } size_t get_example_count() const { return dataset_.count(); } size_t get_max_examples() const { return Dataset::kMax_examples; } std::vector> get_example_features() const { return dataset_.features(); } std::vector> get_example_labels() const { return dataset_.labels(); } void load_examples(const std::vector>& f, const std::vector>& l) { dataset_.load(f, l); } Float nearest_example_distance(const Float* input, size_t n_in) const { const auto& feats = dataset_.features(); if (feats.empty()) return static_cast(-1); Float best = std::numeric_limits::max(); const size_t dims = std::min(n_in, n_inputs_); for (const auto& f : feats) { Float d = 0; for (size_t k = 0; k < dims && k < f.size(); ++k) { const Float diff = f[k] - input[k]; d += diff * diff; } best = std::min(best, std::sqrt(d)); } return best; } private: void refresh_() { input_updated_ = true; process(); } void train_() { if (dataset_.count() == 0) return; mlp_->train(dataset_.features(), dataset_.labels(), static_cast(max_iterations_), learning_rate_, convergence_threshold_); refresh_(); } size_t n_inputs_, n_outputs_, max_iterations_; Float learning_rate_, convergence_threshold_; Mode mode_ = Mode::Inference; bool input_updated_ = false; std::vector input_state_, output_state_; Dataset dataset_; std::unique_ptr> mlp_; }; } // namespace nisps