feat(vcv): evolve module to 8x16 + LED rings + token palette + WS-OSC bridge

8 inputs x 16 outputs; per-output LED ring widget (drawLayer+nvgArc); palette
from frontend tokens; OSC bridge verbs for bidirectional browser training;
vendored self-contained iml.hpp (retired nisps-core); compiles against Rack
SDK 2.6.4. See SPEC.md BUILD DELTAS.
This commit is contained in:
monkey-w1n5t0n 2026-06-28 04:14:30 +02:00
parent 0d8179d6d5
commit fbc68ebfab
8 changed files with 1005 additions and 370 deletions

View file

@ -2,7 +2,9 @@ RACK_DIR ?= $(HOME)/.local/share/Rack2/Rack-SDK
FLAGS += -std=c++20
FLAGS += -I$(RACK_DIR)/include -I$(RACK_DIR)/dep/include
FLAGS += -I../nisps-core/include
# The retired nisps-core header tree is gone; the runtime IML/MLP is vendored
# self-contained in src/iml.hpp (see that file's header for the rationale).
FLAGS += -Isrc
SOURCES += src/plugin.cpp
SOURCES += src/MEMLNaut.cpp

View file

@ -1,5 +1,51 @@
# MEMLNaut VCV Rack Module — Specification
---
## ⚠️ BUILD DELTAS (2026-06-28) — AUTHORITATIVE OVERRIDES
These supersede any conflicting detail below. They reflect the Manifold mission + the locked decisions in
`docs/redesign/BUILD-PLAN.md` and `docs/redesign/backends-spec.md`. Build to THESE.
1. **I/O = 8 inputs × 16 outputs** (was 2→12). `NUM_ML_INPUTS = 8`, `NUM_ML_OUTPUTS = 16`. The IML is sized
8→16 (a runtime-shaped native MLP is fine here — the module is C++, not the fixed WASM target). The 8 CV
inputs feed the model's input dims; the 16 CV outputs are the model's inference outputs (the modular N×M
envelope). Keep the control inputs (Spread CV, Learn gate, + / triggers).
2. **LED RING around EACH of the 16 outputs** — a custom ring widget encircling each output jack whose arc
fills in proportion to that output's value (0..1 → 0..2π). Draw on `drawLayer()` layer 1 with `nvgArc` for
the proportional fill + a dim track ring. Each ring is COLOURED from a palette that MATCHES the frontend
design tokens.
3. **Palette from the frontend tokens** — generate `vcv/src/palette.hpp` from
`docs/redesign/manifold-export/tokens/colors.css`: `--accent #ff6a00` (orange), `--accent-2 #00ccff` (cyan),
and the group colours (formant→accent, pitch→accent-2, amp→`--good #6bc26b`, filter→`--warn #f5c45e`,
fx→`--info #5b9eef`, mod→`--accent-3 #ffa860`). Assign the 16 rings across these group colours (or a clean
16-step ramp between orange and cyan) so the module reads as the same instrument as the browser. A tiny
hand-written `palette.hpp` is acceptable (no build-time codegen needed).
4. **Browser ↔ VCV bridge = WS↔OSC** (locked transport). The module runs its OSC server (`src/osc_server.hpp`
already exists — evolve it). The browser's OSC backend (`manifold/src/backends/osc-backend.ts`) sends over a
WebSocket to the Deno bridge (`manifold/osc-bridge/`), which relays UDP-OSC to the module. **Bidirectional
training**: drive + train the module FROM the browser (the verdict loop + example-placing over the bridge)
AND from the module's own panel (+/ buttons, Learn gate, triggers). OSC verbs to support both directions:
`/nisps/input` (drive), `/nisps/output` (module→browser viz), `/nisps/feedback` (thumbs up/down + place),
`/nisps/weights`, `/nisps/examples`, `/nisps/state`. Pick a fixed default UDP port (e.g. 7001) + per-instance
offset; the Deno bridge maps `ws://localhost:8765` ↔ that UDP port.
5. **Core include path** — the Makefile's `-I../nisps-core/include` points at the RETIRED `nisps-core`. Repoint
to the current core (`../nisps/`) OR vendor a minimal runtime IML inside `vcv/src/`. Goal: get it COMPILING
with an 8→16 runtime MLP that shares the firmware/browser training semantics (spread-aware draw/move_weights,
deterministic RNG) as closely as the native runtime-shaped form allows. If full nisps/ml reuse is blocked by
the templated fixed-size API, keep a self-contained IML in the module and note the alignment as a follow-up.
6. **Derived outputs** (Mean/Std/Delta/Novelty/Confidence) → move to the optional EXPANDER or a context-menu
toggle; the headline is 16 raw outputs + their LED rings. Do not let them crowd the 16-jack panel.
7. **Build** needs the VCV Rack 2 SDK (`RACK_DIR`, default `$HOME/.local/share/Rack2/Rack-SDK`, NOT installed).
The build step must fetch the Linux Rack-SDK zip from vcvrack.com, set `RACK_DIR`, and `make` to verify the
plugin compiles. Panel SVGs in `vcv/res/` exist (MEMLNaut.svg / -wide / -expander) — widen/relayout for 16
outputs + rings as needed.
The rest of this document is the prior (2→12) design — useful for threading, persistence, RL workflow, and
panel/build mechanics, but the I/O counts, LED rings, palette, bridge, and core path above WIN.
---
## Overview
A VCV Rack module that embeds the NISPS interactive ML engine (nisps-core C++ library) as a CV-to-CV mapper. Users explore high-dimensional parameter spaces via reinforcement learning feedback, producing 12 raw CV outputs and 5 derived meta-signals from configurable CV inputs.

View file

@ -1,7 +1,7 @@
{
"slug": "MEMLNaut",
"name": "MEMLNaut",
"version": "0.1.0",
"version": "0.2.0",
"license": "proprietary",
"brand": "MEMLNaut",
"author": "MEML",

75
vcv/src/LedRing.hpp Normal file
View file

@ -0,0 +1,75 @@
// LedRing.hpp — Custom LED-ring widget encircling each output jack.
//
// The arc fills clockwise from 12 o'clock in proportion to the output's value
// (0..1 → 0..2π) and is drawn on drawLayer() layer 1 (so it stays bright when
// room brightness is lowered, per the VCV custom-light convention). A dim track
// ring sits underneath. Colour comes from the frontend design tokens
// (palette.hpp) so the module matches the browser.
//
// Templated on the module type so it can read the per-output value without a
// hard include cycle; MEMLNaut.cpp instantiates LedRingWidget<MEMLNaut>.
#pragma once
#include <rack.hpp>
#include "palette.hpp"
using namespace rack;
template<typename TModule>
struct LedRingWidget : Widget {
TModule* module = nullptr;
int outIdx = 0;
NVGcolor ringColor = memlnaut::palette::accent();
float radius = 9.f; // px around a PJ301M jack
LedRingWidget() {
// Box large enough to host the ring around a ~22px jack.
box.size = Vec(radius * 2.f + 6.f, radius * 2.f + 6.f);
}
float valueOf() const {
if (!module) return 0.f;
return clamp(module->ringValue(outIdx), 0.f, 1.f);
}
void drawLayer(const DrawArgs& args, int layer) override {
if (layer != 1) {
Widget::drawLayer(args, layer);
return;
}
float v = valueOf();
Vec c = box.size.div(2.f);
const float start = -M_PI / 2.f; // 12 o'clock
const float end = start + 2.f * M_PI; // full circle
// Dim track ring (full circle).
nvgBeginPath(args.vg);
nvgArc(args.vg, c.x, c.y, radius, start, end, NVG_CW);
nvgStrokeColor(args.vg, nvgRGBA((unsigned char)(ringColor.r * 255),
(unsigned char)(ringColor.g * 255),
(unsigned char)(ringColor.b * 255), 40));
nvgStrokeWidth(args.vg, 1.4f);
nvgStroke(args.vg);
// Proportional value arc.
if (v > 0.001f) {
nvgBeginPath(args.vg);
nvgArc(args.vg, c.x, c.y, radius, start, start + v * 2.f * M_PI, NVG_CW);
nvgStrokeColor(args.vg, ringColor);
nvgStrokeWidth(args.vg, 1.9f);
nvgLineCap(args.vg, NVG_ROUND);
nvgStroke(args.vg);
// Soft glow halo — the frontend "glow not shadow" signature.
nvgGlobalCompositeOperation(args.vg, NVG_LIGHTER);
nvgBeginPath(args.vg);
nvgArc(args.vg, c.x, c.y, radius, start, start + v * 2.f * M_PI, NVG_CW);
nvgStrokeColor(args.vg, nvgRGBAf(ringColor.r, ringColor.g, ringColor.b, 0.25f * v));
nvgStrokeWidth(args.vg, 4.0f);
nvgStroke(args.vg);
nvgGlobalCompositeOperation(args.vg, NVG_SOURCE_OVER);
}
Widget::drawLayer(args, layer);
}
};

File diff suppressed because it is too large Load diff

409
vcv/src/iml.hpp Normal file
View file

@ -0,0 +1,409 @@
// 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<float>` / `nisps::MLP<float>` 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 vcv/SPEC.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 <vector>
#include <cstddef>
#include <cmath>
#include <cstdint>
#include <limits>
#include <algorithm>
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<typename T = float>
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<std::vector<std::vector<T>>>;
// layers_nodes: full sizes including input (with bias) and output, e.g.
// {n_in + 1, h0, h1, h2, n_out}
MLP(const std::vector<size_t>& layers_nodes, uint32_t seed)
: layers_nodes_(layers_nodes), rng_(seed) {
build_();
draw_weights_spread_(static_cast<T>(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<T>& input_with_bias, std::vector<T>& out) const {
std::vector<T> 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<T> 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<T>(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<T>(1) - static_cast<T>(0.1) * spread;
for (size_t l = 0; l < weights_.size(); ++l) {
const T fan_in = static_cast<T>(input_size_of_layer_(l));
const T xavier = (fan_in > 0) ? static_cast<T>(1) / std::sqrt(fan_in) : static_cast<T>(1);
// spread=0 → flat noise (scale 1); spread=1 → per-layer Xavier scale
const T noiseScale = (static_cast<T>(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<std::vector<T>>& features,
const std::vector<std::vector<T>>& 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<T> in = features[s];
in.push_back(static_cast<T>(1)); // bias
epoch_loss += backprop_(in, labels[s], learning_rate);
}
epoch_loss /= static_cast<T>(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<T>(1) / (static_cast<T>(1) + std::exp(-x)); }
static T dsigmoid_from_out_(T y) { return y * (static_cast<T>(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<std::vector<T>> layer(out_sz, std::vector<T>(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<T>(layers_nodes_[l]); // incl. bias
const T xavier = (fan_in > 0) ? static_cast<T>(1) / std::sqrt(fan_in) : static_cast<T>(1);
const T scale = (static_cast<T>(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<T>(0) : rng_.uniform_pm1() * scale;
}
}
}
}
// One SGD step on a single example; returns the MSE for this example.
T backprop_(const std::vector<T>& in_with_bias, const std::vector<T>& target,
T lr) {
// Forward, caching activations per layer.
std::vector<std::vector<T>> acts;
acts.reserve(weights_.size() + 1);
acts.push_back(in_with_bias);
std::vector<T> 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<T> 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<T>(1));
acts.push_back(next);
act = next;
}
// Output error.
const size_t L = weights_.size();
std::vector<T>& out = acts[L];
T loss = 0;
std::vector<T> delta(out.size());
for (size_t o = 0; o < out.size(); ++o) {
const T t = (o < target.size()) ? target[o] : static_cast<T>(0);
const T e = out[o] - t;
loss += e * e;
delta[o] = e * dsigmoid_from_out_(out[o]); // MSE × sigmoid'
}
loss /= static_cast<T>(out.size() ? out.size() : 1);
// Backprop through layers L-1 .. 0.
std::vector<T> 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<T> 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<T>(1) : static_cast<T>(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<size_t> layers_nodes_;
mlp_weights weights_;
mutable DetRng rng_;
};
// ── Dataset (FIFO ring, max 100 examples) ─────────────────────────────
template<typename T = float>
class Dataset {
public:
static constexpr size_t kMax_examples = 100;
void add(const std::vector<T>& feat, const std::vector<T>& 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<std::vector<T>>& features() const { return features_; }
const std::vector<std::vector<T>>& labels() const { return labels_; }
void load(const std::vector<std::vector<T>>& f, const std::vector<std::vector<T>>& 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<std::vector<T>> features_;
std::vector<std::vector<T>> labels_;
};
// ── IML ───────────────────────────────────────────────────────────────
template<typename Float = float>
class IML {
public:
enum class Mode { Inference, Training };
IML(size_t n_inputs, size_t n_outputs,
std::vector<size_t> hidden_layers = {16, 24, 16},
size_t max_iterations = 200,
Float learning_rate = static_cast<Float>(0.1),
Float convergence_threshold = static_cast<Float>(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<size_t> 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<MLP<Float>>(sizes, seed);
input_state_.assign(n_inputs_, static_cast<Float>(0.5));
output_state_.assign(n_outputs_, static_cast<Float>(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<Float>(0), static_cast<Float>(1));
input_updated_ = true;
}
const Float* get_outputs() const { return output_state_.data(); }
void process() {
if (!input_updated_) return;
std::vector<Float> in = input_state_;
in.push_back(static_cast<Float>(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<Float> in(inputs, inputs + std::min(n_in, n_inputs_));
in.resize(n_inputs_, static_cast<Float>(0));
std::vector<Float> out(outputs, outputs + std::min(n_out, n_outputs_));
out.resize(n_outputs_, static_cast<Float>(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<Float>::mlp_weights get_weights() const { return mlp_->get_weights(); }
void set_weights(typename MLP<Float>::mlp_weights& w) { mlp_->set_weights(w); }
size_t get_example_count() const { return dataset_.count(); }
size_t get_max_examples() const { return Dataset<Float>::kMax_examples; }
std::vector<std::vector<Float>> get_example_features() const { return dataset_.features(); }
std::vector<std::vector<Float>> get_example_labels() const { return dataset_.labels(); }
void load_examples(const std::vector<std::vector<Float>>& f,
const std::vector<std::vector<Float>>& 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<Float>(-1);
Float best = std::numeric_limits<Float>::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<int>(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<Float> input_state_, output_state_;
Dataset<Float> dataset_;
std::unique_ptr<MLP<Float>> mlp_;
};
} // namespace nisps

View file

@ -136,6 +136,7 @@ inline std::vector<uint8_t> messageString(const std::string& address,
class OscServer {
public:
using StringCallback = std::function<void(const std::string&)>;
using FloatVecCallback = std::function<void(const std::vector<float>&)>;
OscServer() = default;
~OscServer() { stop(); }
@ -144,9 +145,15 @@ public:
OscServer(const OscServer&) = delete;
OscServer& operator=(const OscServer&) = delete;
// Register handlers before starting
// Register handlers before starting.
// onState — full JSON state snapshot (/nisps/state <s>)
// onWeights — weights-only JSON (/nisps/weights <s>)
// onInput — live input vector (browser drives the model) (/nisps/input <f…f>)
// onFeedback— verdict op JSON (thumbs/place/rand/clear) (/nisps/feedback <s>)
void onState(StringCallback cb) { stateCallback_ = std::move(cb); }
void onWeights(StringCallback cb) { weightsCallback_ = std::move(cb); }
void onInput(FloatVecCallback cb) { inputCallback_ = std::move(cb); }
void onFeedback(StringCallback cb) { feedbackCallback_ = std::move(cb); }
// Set the target address for sending (where the webapp bridge listens).
// Default: 127.0.0.1:9001
@ -240,12 +247,24 @@ public:
sendPacket(msg);
}
// Send current input values (2 floats)
// Send current input values (N floats)
void sendInputs(const float* values, size_t count) {
auto msg = osc::messageFloats("/nisps/input", values, count);
sendPacket(msg);
}
// Send a full JSON state snapshot (module → browser).
void sendState(const std::string& json) {
auto msg = osc::messageString("/nisps/state", json);
sendPacket(msg);
}
// Send weights-only JSON (module → browser).
void sendWeights(const std::string& json) {
auto msg = osc::messageString("/nisps/weights", json);
sendPacket(msg);
}
private:
void recvLoop() {
uint8_t buf[65536];
@ -291,6 +310,21 @@ private:
std::string payload = osc::readString(buf, len, offset);
if (weightsCallback_) weightsCallback_(payload);
}
} else if (address == "/nisps/feedback") {
// Verdict op as a JSON string:
// {"op":"up|down|rand|clear","spread":f,"input":[…],"output":[…]}
if (tags.size() >= 2 && tags[1] == 's') {
std::string payload = osc::readString(buf, len, offset);
if (feedbackCallback_) feedbackCallback_(payload);
}
} else if (address == "/nisps/input") {
// Live input vector from the browser → drive the model inputs.
std::vector<float> values;
for (size_t i = 1; i < tags.size(); ++i) {
if (tags[i] == 'f') values.push_back(osc::readFloat(buf, len, offset));
else break;
}
if (!values.empty() && inputCallback_) inputCallback_(values);
}
// Unknown addresses are silently ignored
}
@ -337,6 +371,8 @@ private:
// Callbacks
StringCallback stateCallback_;
StringCallback weightsCallback_;
StringCallback feedbackCallback_;
FloatVecCallback inputCallback_;
// Send target
std::mutex sendMutex_;

52
vcv/src/palette.hpp Normal file
View file

@ -0,0 +1,52 @@
// palette.hpp — MEMLNaut ring colours, hand-synced from the frontend tokens.
//
// Source of truth: docs/redesign/manifold-export/tokens/colors.css. These are
// the exact hex values from that file, so the VCV module's LED rings read as the
// same instrument as the Manifold browser front-end. If a token changes there,
// update the matching constant here (small hand-sync; see SPEC delta #3).
//
// The 16 output rings are assigned across the design-token accents + group/pin
// colours as a clean orange→cyan-anchored ramp, so outputs in the same mode
// group glow the same colour as the browser heatmap/Console grouping.
#pragma once
#include <rack.hpp>
namespace memlnaut {
namespace palette {
// ── Design tokens (colors.css) ────────────────────────────────────────
inline NVGcolor accent() { return nvgRGB(0xff, 0x6a, 0x00); } // --accent warm primary
inline NVGcolor accent2() { return nvgRGB(0x00, 0xcc, 0xff); } // --accent-2 cool secondary
inline NVGcolor accent3() { return nvgRGB(0xff, 0xa8, 0x60); } // --accent-3 warm hover/tint
inline NVGcolor good() { return nvgRGB(0x6b, 0xc2, 0x6b); } // --good
inline NVGcolor warn() { return nvgRGB(0xf5, 0xc4, 0x5e); } // --warn
inline NVGcolor info() { return nvgRGB(0x5b, 0x9e, 0xef); } // --info
inline NVGcolor pin3() { return nvgRGB(0xb4, 0x64, 0xff); } // --pin-3 base (violet)
inline NVGcolor danger() { return nvgRGB(0xff, 0x44, 0x66); } // --danger (bipolar / perturbed)
inline NVGcolor bgTrack() { return nvgRGB(0x24, 0x24, 0x24); } // --bg-3 (ring track)
// ── 16-ring palette ───────────────────────────────────────────────────
// Groups cycle through the token accents/group colours. Outputs 0..15 read as a
// coherent orange→cyan family with the semantic accents woven in.
inline NVGcolor ring(int outIdx) {
static const NVGcolor kRing[16] = {
// group 0 — formant/primary (orange family)
nvgRGB(0xff, 0x6a, 0x00), nvgRGB(0xff, 0x82, 0x2a), nvgRGB(0xff, 0xa8, 0x60), nvgRGB(0xff, 0xc4, 0x90),
// group 1 — amp (green)
nvgRGB(0x6b, 0xc2, 0x6b), nvgRGB(0x86, 0xcf, 0x86),
// group 2 — filter (amber/warn)
nvgRGB(0xf5, 0xc4, 0x5e), nvgRGB(0xf8, 0xd4, 0x84),
// group 3 — mod (violet/pin-3)
nvgRGB(0xb4, 0x64, 0xff), nvgRGB(0xc6, 0x86, 0xff),
// group 4 — fx (blue/info)
nvgRGB(0x5b, 0x9e, 0xef), nvgRGB(0x82, 0xb6, 0xf3),
// group 5 — pitch/data (cyan family → accent-2)
nvgRGB(0x3a, 0xd0, 0xf0), nvgRGB(0x1d, 0xce, 0xf7), nvgRGB(0x00, 0xcc, 0xff), nvgRGB(0x55, 0xdd, 0xff),
};
if (outIdx < 0) outIdx = 0;
return kRing[outIdx % 16];
}
} // namespace palette
} // namespace memlnaut