memlnaut-nisps/docs/specs/vcv-module.md
monkey-w1n5t0n 9ad1f78ddd docs: the specs disposition pass (plan §8)
Roughly 20k lines were deleted from this repo in the last week and much of the
corpus still described the pre-deletion world in the present tense. Executes
the §8 table: archive the retired, reclassify the executed, prune the stale.

  aimmersive-clone-spec      -> _archive/ with a deprecated-by note
  feedback-modes-port-spec   -> plans/, kind: plan, status: executed
  manifold-parity-features   -> plans/, kind: plan, status: active
  playground-2.0-rewrite     -> status: superseded
  engine-architecture        434 -> ~120 lines; seam + spine kept, rewritten
                             present-tense against the shipped engine/
  MAIN.md                    six contradicted claims fixed; registry resynced
  vcv-module.md              pruned to the current 8->16 contract and made the
                             single .nisps format spec
  vcv/NISPS-FORMAT.md        DELETED — documented a v1 format that no longer loads
  vcv/README.md, BUILDING.md rewritten to the real contract, menu, OSC table
  inputs/backends/dock trio  grounding sections marked historical, dead cites fixed

Two rows of the §8 table were themselves wrong, corrected here: the deleted
full-state sync lives in backends-spec.md §6.3, not vcv-module.md (which has no
§6.3), and codegen/README.md was already a MAP pointer with no port-solidjs
trigger left to remove.

Beyond the table — found by sweeping every backticked path in the changed docs
against `git ls-files`, which is how these should have been caught before:

  manifold/ONBOARDING.md documented a UI that Phase 1 deleted, as if current:
  SplitStage, ReadoutStrip, InputMini, BackendAdvanced, AltitudeNav, and a
  shot.spec.ts that does not exist. The whole stage table was keyed on a `focus`
  axis that no longer exists — selection is now sandwich > particles >
  composite. This matters more than the rest: CLAUDE.md tells every agent to
  read ONBOARDING.md first for Manifold work, so it was actively teaching a
  fiction. Rewritten against ConsoleApp.tsx.

  MAP.md claimed the input layer reduces axes to the engine arity with an
  "even/odd blend". input-layer.ts says the opposite in its own header: one
  dedicated slot per axis, 1:1, into a 32-input over-provisioned head, and
  mean-blending was removed deliberately because it diluted every source.

  AGENT-REFERENCE.md still promised TS emission "returns at P5" (landed),
  per-mode dims "become schema-real at P5" (landed at P5.3), and pointed at
  nisps::FixedBuffer (deleted).

Doc-right/code-suspect, filed rather than fixed: VCV computes derivedMean/Std/
Delta and cachedNovelty behind a live context-menu toggle that nothing reads;
vcv/plugin.json points at the MusicallyEmbodiedML org rather than this repo's
origin; and the module defaults to UDP 7001+id%64 while bridge.ts defaults to
9000, so out of the box they do not meet.

Firmware-build docs are deliberately untouched — the PlatformIO migration
lands next and rewrites all of them.
2026-07-21 20:17:58 +02:00

14 KiB
Raw Blame History

kind stability layer counterpart
spec evolving binding backends-spec.md

MEMLNaut VCV Rack Module — Specification

Revision note: pruned 2026-07-21 to the current 8→16 contract. The 2→12-era design sections (phase plan, per-node cost tables, nisps-core prerequisites, dead OSC verbs) are in git history. This file is also the single .nisps/patch format spec — the separate vcv/NISPS-FORMAT.md (which documented the retired v1 nested-weights format) was deleted with this prune.


Overview

A VCV Rack module that embeds the NISPS interactive ML engine as a CV-to-CV mapper. Users explore high-dimensional parameter spaces via reinforcement-learning feedback, producing 16 raw CV outputs (each with a custom LED-ring indicator) from 8 CV inputs.

The module does not produce sound. It maps input CVs through a trained neural network to output CVs, which the user patches into other modules (VCOs, VCFs, VCAs, etc.). The result: a learned, nonlinear, high-dimensional modulation source shaped by the user's aesthetic preferences.

Plugin name: MEMLNaut (vcv/plugin.json, currently v0.2.0, license field "proprietary") Target: VCV Rack 2 only (see the v2-only decision at the end) Distribution: not submitted to the VCV Library; .vcvplugin bundles published at meml.lnfinitemonkeys.org/next/vcv/ (see vcv/DISTRIBUTION.md; local builds land in the git-ignored vcv/dist/). Note: VCV SDK is GPLv3 — linking against it effectively makes the combined binary GPL.


Architecture

Core Stack

┌─────────────────────────────────────┐
│          VCV Module (UI + I/O)      │
│  Panel, knobs, ports, display       │
├─────────────────────────────────────┤
│        VCV process() callback       │
│  Reads CV inputs, writes CV outputs │
│  Decimated inference trigger        │
├─────────────────────────────────────┤
│  src/iml.hpp — thin adapter over    │
│  nisps::ml::MLPCore<DynamicStorage> │
│  (the shared C++20 core in nisps/)  │
├─────────────────────────────────────┤
│          State Manager              │
│  Serialize/deserialize weights,     │
│  examples, config to JSON           │
└─────────────────────────────────────┘

Core Library Integration

Delta #5 CLOSED (2026-07-18, one-core-engine-refactor P6). The module no longer vendors its own MLP. vcv/src/iml.hpp is a THIN, Rack-free adapter over the shared core: nisps::ml::MLPCore<nisps::ml::DynamicStorage> (the runtime-shaped branch of the one core MLP — fixed 4-layer ReLU×3 + Sigmoid topology, three runtime hidden sizes) with the module's real [16, 24, 16] shape, nisps::Rng (nisps/core/rng.hpp) replacing the vendored DetRng, and the core MLP's own FIFO dataset replacing the vendored Dataset. The adapter includes only nisps headers + the standard library (no Rack includes) so the host ctest can compile it. Behaviour is core-exact: weight init, RL move_weights, SGD training, activations and RNG are bit-identical to the firmware/WASM engine — pinned by tests/cpp/test_vcv_iml_parity.cpp (adapter == bare MLPCore<DynamicStorage>, memcmp-equal).

Threading Model

  • Audio thread (process()): reads input CVs, runs MLP inference (decimated), writes output CVs. Never blocks.
  • Worker thread: handles training (thumbs-up) and weight perturbation (thumbs-down). On completion, signals the audio thread to swap in the new weights and crossfade.
  • Widget thread: draws UI, reads output values for display.

Threading invariant (documented at vcv/src/MEMLNaut.cpp iml/imlShadow): only the audio thread touches iml; the worker thread operates exclusively on imlShadow. Hand-off is through atomic-flagged staging buffers (pendingWeights, staged example copies) — the worker deep-copies example vectors into staging before flagging, and the audio thread never reads imlShadow directly. Feedback taps arriving mid-job are coalesced (latest pending state wins).

Post-swap output crossfade: when weights are swapped, outputs may jump discontinuously. The context-menu slew setting (default 10 ms) crossfades old→new output vectors to prevent clicks.

Multiple instances: each module instance is fully independent (own adapter instances, own worker thread, own OSC port = 7001 + instance-id % 64).

Input Signal Handling

  • Polyphonic inputs: channel 0 only (monophonic).
  • Input clipping: CV inputs are hard-clamped to their configured range before normalisation to [0, 1]. Unipolar (default): clamp [0, 10 V], ÷10. Bipolar: clamp [5, +5 V], +5, ÷10.

I/O Specification

Inputs (8 CV inputs + control ports)

Port Description
IN 18 CV inputs feeding the 8-input MLP
SPREAD CV CV modulation of SPREAD knob (added to knob value, /10 V)
LEARN Gate input: high accepts RL feedback (OR'd with the LEARN toggle)
+ TRIG Trigger input: thumbs-up
TRIG Trigger input: thumbs-down

Outputs

Port Description
OUT 116 Raw MLP outputs scaled to the configured CV range; each jack has an LED ring and an attenuverter trimpot (100%..+100%, default +100%)

There are no derived-output jacks in the 8→16 module (the 2→12-era MEAN/STD/DELTA/NOVELTY/ CONFIDENCE jacks were dropped in the redesign). A context-menu toggle "Compute derived stats (Mean/Std/Delta)" computes the stats on the audio thread, and a novelty distance is cached after training — but as of 2026-07-21 no jack, display, or OSC verb consumes these values (see Open Questions).

Panel Controls

Control Type Description
SPREAD Knob (default 60%) Weight-init scale, RL noise cap, weight decay
RATE Knob Inference rate: block-rate (~170 Hz) … audio-rate (44.1 kHz)
+ Momentary Thumbs up (requires LEARN)
Momentary Thumbs down (requires LEARN)
LEARN Toggle + green LED Enable/disable learning; inference always runs
RAND Momentary Randomise network weights (uses SPREAD)
CLEAR Momentary (long-press ~1 s) Clear all examples and reset network

A yellow TRAIN LED flashes during worker-thread training. The display strip draws the 16 output bars (palette-coloured), the noise level (N:), the example count (n/100), and TRAIN while training.

Context Menu (the real, current one)

Setting Description
Output ranges Per-output Unipolar (010 V, default) / Bipolar (±5 V)
Input ranges Per-input Unipolar (010 V, default) / Bipolar (±5 V)
Compute derived stats (Mean/Std/Delta) Toggle; see note above — currently unconsumed
Output slew 0 / 5 / 10 (default) / 20 / 50 / 100 ms
Presets (.nisps) Save / Load .nisps preset files (format below)
Browser bridge (WS↔OSC) Enable OSC server; listen port ∈ {7001, 7002, 7003, 9000, 9001}

Visual Feedback — Per-Output LED Rings

Each of the 16 output jacks is surrounded by a custom LED ring (vcv/src/LedRing.hpp, NanoVG on drawLayer() layer 1). The ring arc fills proportionally to the output's current value; the track ring is dimly visible always. Colours come from vcv/src/palette.hpp (hand-written from the frontend design tokens — --accent #ff6a00 etc.): the 16 outputs read as a clean orange→cyan-anchored ramp across the jacks.


MLP Configuration

Inputs:  8
Hidden:  [16, 24, 16]  (3 hidden layers, ReLU)
Output:  16 (sigmoid, values in [0, 1])

vcv/src/MEMLNaut.cpp: NUM_ML_INPUTS = 8, NUM_ML_OUTPUTS = 16, nisps::IML<float> iml{NUM_ML_INPUTS, NUM_ML_OUTPUTS, {16, 24, 16}}. The topology is fixed; the core stores biases explicitly (no phantom "+1 bias node" in the layer counts).

Spread Parameter

Identical to the browser/firmware engine (the core methods):

  1. Weight initialisation: draw_weights(spread) — uniform [1,1] (spread=0) → Xavier (spread=1)
  2. RL perturbation: move_weights(speed, spread) — per-layer scaling + weight decay
  3. Noise cap: 0.3·(1spread) + 0.05·spread

Dataset Capacity

100 examples (vcv/src/iml.hpp kMaxExamples = 100, kept equal to the old vendored capacity so patch round-trips preserve counts). FIFO: oldest example dropped when full. Note the nisps core's own default is 128 (kDefaultMaxExamples); the VCV adapter pins 100 explicitly.


Inference Rate

RATE knob, exponential mapping period = 256 · (1/256)^rate samples:

Position Rate Behavior
Full CCW ~170 Hz Once per 256 samples. Cheapest.
12 o'clock ~2.8 kHz Every 16 samples. Good for CV-rate modulation.
Full CW 44.1 kHz Every sample. Audio-rate CV.

Between inference steps, outputs are linearly interpolated; the post-training crossfade (slew) composes on top.


RL Feedback Workflow

Thumbs up (+): capture current input/output pair as a training example → enqueue training on the worker → noiseLevel *= 0.97.

Thumbs down (): noiseLevel = min(noiseLevel · 1.5, noiseCap) → enqueue perturbation (move_weights(noiseLevel, spread)) on the worker → audio thread crossfades to the new weights.

Learn gate: when LEARN is off (gate low AND toggle off), +/ are ignored; inference always runs ("learn off = play mode").


State Persistence — the ONE .nisps/patch format (version 3)

Both the VCV patch state and menu-saved .nisps files use the same JSON, produced by MEMLNaut::dataToJson():

{
  "version": 3,
  "inputCount": 8,
  "outputCount": 16,
  "noiseLevel": 0.1,
  "slewMs": 10.0,
  "computeDerived": false,
  "oscEnabled": false,
  "oscPort": 7001,
  "outputRangeUnipolar": [true, "… ×16"],
  "inputRangeUnipolar": [true, "… ×8"],
  "weights": ["… flat float array"],
  "examples": { "features": [["… ×8"]], "labels": [["… ×16"]] },
  "mlpConfig": { "layers": [8, 16, 24, 16, 16] },
  "params": ["only in menu-saved .nisps files: all param values incl. attenuverters"]
}
  • weights is the core's FLAT vector: [layer0_w … layer3_w][layer0_b … layer3_b] — the exact MLPCore weight layout, biases included. The old v1 nested 3-D weights[layer][node][weight] blobs (bias not serialised) do not load: set_weights rejects them on a size guard.
  • mlpConfig.layers are true node counts [8, 16, 24, 16, 16] — no "+1 bias" first entry.
  • examples: parallel features (×8 floats) / labels (×16 floats) arrays, ≤100 entries.
  • The menu "Load .nisps preset…" rejects files with version < 1; unknown extra fields are ignored. params is appended only by the menu save (patch save round-trips params natively).

Browser Bridge (WS ↔ UDP OSC)

The module runs a UDP OSC server (vcv/src/osc_server.hpp, transport-only). The browser (manifold/src/backends/vcv-backend.ts) connects via the Deno WS↔UDP bridge (manifold/osc-bridge/bridge.ts; WS port default 8765 — pass --osc-port to match the module's port, the bridge's own default is 9000).

Live OSC verbs (the complete set)

Verb Direction Payload
/nisps/input Browser → VCV float array — input vector; while streaming, the module runs in bridged mode (browser drives the model inputs instead of the jacks)
/nisps/output VCV → Browser (~100 ms throttle) 16 floats — live outputs (alive-proof + visualisation)
/nisps/input VCV → Browser (~100 ms throttle) 8 floats — live inputs (echo)
/nisps/feedback Browser → VCV JSON string op (up / down / rand / clear, optional spread, input/output vectors), routed through the same paths as the panel buttons

The 2→12-era /nisps/weights, /nisps/examples, and /nisps/state full-state sync verbs were deleted (both directions had zero consumers; Rack patch save/load owns persistence). Bidirectional training remains: both the browser verdict loop and the module panel drive the same MLP.

Port assignment: default UDP 7001 + per-instance offset (7001 + id % 64); the context menu offers {7001, 7002, 7003, 9000, 9001}.


Panel Layout

The module is 44HP (res/MEMLNaut-wide.svg, 223.52 mm — the SVG the widget actually loads). res/MEMLNaut.svg is an unused 30HP variant and res/MEMLNaut-expander.svg an unused 8HP expander panel; no expander module is implemented.

  • Top: display strip (16 output bars, noise level, example count, TRAIN)
  • Upper: SPREAD + RATE knobs, LEARN toggle + LED, RAND, CLEAR, +/ buttons
  • Middle: 8 input jacks, SPREAD CV, LEARN gate, +/ trigger inputs
  • Lower: 16 output jacks (2 rows of 8) with LED rings and per-output attenuverter trimpots

Build

cd vcv
export RACK_DIR=/path/to/Rack-SDK   # default: ~/.local/share/Rack2/Rack-SDK
make
make install

Files: src/plugin.{hpp,cpp}, src/MEMLNaut.cpp (module + widget + serialization + OSC wiring), src/iml.hpp (core adapter), src/osc_server.hpp, src/LedRing.hpp, src/palette.hpp, res/*.svg, Makefile (adds -std=c++20; the core is reached via relative ../../nisps/… includes from src/, no extra -I). Cross-platform bundles are produced by build-mac.sh / build-win.sh into dist/ (see vcv/BUILDING.md, vcv/DISTRIBUTION.md).

Rack version decision: ship v2-only. The v1 SDK lacks the menu/tooltip APIs used throughout and may not support the C++20 the core requires; the port (~8 h) can be done later if demand materialises.


Open Questions

  1. Derived stats are computed but unconsumed — the context-menu toggle computes Mean/Std/ Delta (and novelty is cached after training) with no jack, display, or OSC consumer. Either wire a consumer or delete the compute path.
  2. Hidden-layer sizing: [16, 24, 16] carried over from the 2→12 era; untuned for 8→16.
  3. Expander: an 8HP expander SVG exists with no module behind it — build or delete.