feat(vcv,playground): complete Phases 8, 9, 10 — all phases done

Phase 8 — Companion webapp bridge:
- NISPS-FORMAT.md: full .nisps JSON schema with validation rules
- Webapp iml.js: exportState() / importState() with bias handling
- osc_server.hpp: minimal UDP OSC server (cross-platform, no deps)
- VCV module: OSC toggle + port selection in right-click menu
- osc-client.js: WebSocket client with auto-reconnect
- Bridge scripts updated for bidirectional VCV↔webapp relay

Phase 9 — Panel layout variants:
- MEMLNaut.svg: 30HP standard panel (matches widget positions)
- MEMLNaut-wide.svg: 44HP with expanded display and 8 input slots
- MEMLNaut-expander.svg: 8HP with 6 extra inputs and LINK LED

Phase 10 — Polish & distribution:
- README.md: 267-line user guide (install, quick start, RL workflow,
  presets, OSC, technical details)
- BUILDING.md: build prerequisites, SDK setup, local install
- Makefile.dist: platform-stamped zip packaging
- SPEC.md: performance characteristics (1060 MADs/pass, ~46KB/instance)
- SPEC.md: v1 compatibility assessment (v2-only recommended)
This commit is contained in:
w1n5t0n 2026-03-28 01:48:44 +02:00
parent 74c52fadc7
commit ba0fcab2a2
17 changed files with 2389 additions and 98 deletions

View file

@ -215,4 +215,101 @@ export class IML {
get exampleCount() { get exampleCount() {
return this.dataset.size; return this.dataset.size;
} }
/**
* Export state as a .nisps-compatible JSON object.
* The weight format matches the VCV Rack serialization: a 3D array of
* connection weights [layer][node][weight], without bias values.
* Bias is stored in a separate parallel structure for lossless round-trip
* within the webapp, but VCV Rack will ignore it.
*
* @returns {object} .nisps JSON object
*/
exportState() {
const internalWeights = this.mlp.getWeights();
// Convert from JS format [{weights, bias}, ...] per layer
// to .nisps format: float[][][] (connection weights only)
const weights = internalWeights.map(layer =>
layer.map(node => node.weights)
);
// Also capture bias values for lossless webapp round-trip
const biases = internalWeights.map(layer =>
layer.map(node => node.bias)
);
// Features stored without bias term
const features = this.dataset.features.map(f => [...f]);
const labels = this.dataset.labels.map(l => [...l]);
const activations = this.mlp.layers.map(layer => layer.activationName);
return {
version: 1,
weights: weights,
biases: biases,
examples: { features, labels },
mlpConfig: {
layers: [...this.mlp.layersNodes],
activations: activations,
},
};
}
/**
* Import state from a .nisps JSON object.
* Accepts both the VCV Rack format (3D weight array without bias) and
* the webapp extended format (with separate biases array).
*
* @param {object} state - Parsed .nisps JSON
* @throws {Error} If version is invalid or architecture mismatches
*/
importState(state) {
if (!state || !state.version || state.version < 1) {
throw new Error('Invalid .nisps format: missing or unsupported version');
}
// Validate architecture compatibility if mlpConfig is present
if (state.mlpConfig && state.mlpConfig.layers) {
const expected = this.mlp.layersNodes;
const actual = state.mlpConfig.layers;
if (expected.length !== actual.length ||
!expected.every((v, i) => v === actual[i])) {
throw new Error(
`Architecture mismatch: expected [${expected}], got [${actual}]`
);
}
}
// Load weights
if (state.weights) {
// Convert from .nisps 3D format to JS internal format
const internalWeights = state.weights.map((layer, li) =>
layer.map((nodeWeights, ni) => ({
weights: Array.isArray(nodeWeights) ? [...nodeWeights] : nodeWeights,
bias: (state.biases && state.biases[li] && state.biases[li][ni] !== undefined)
? state.biases[li][ni]
: 0,
}))
);
this.mlp.setWeights(internalWeights);
}
// Load examples
if (state.examples) {
this.dataset.clear();
const { features, labels } = state.examples;
if (features && labels) {
const count = Math.min(features.length, labels.length);
for (let i = 0; i < count; i++) {
this.dataset.add(features[i], labels[i]);
}
}
}
// Re-run inference with current inputs
this.inputUpdated = true;
this.process();
}
} }

View file

@ -34,6 +34,7 @@ export class Layer {
this.numNodes = numNodes; this.numNodes = numNodes;
this.nodes = []; this.nodes = [];
this.activationName = activationName;
const pair = activationPairs[activationName]; const pair = activationPairs[activationName];
this.activationFn = pair[0]; this.activationFn = pair[0];
this.derivActivationFn = pair[1]; this.derivActivationFn = pair[1];

View file

@ -0,0 +1,241 @@
// osc-client.js — WebSocket client for NISPS <-> OSC bridge
// Connects the webapp to VCV Rack MEMLNaut module (or any OSC target)
// via the bridge server (bridge.ts / bridge.mjs).
//
// Usage:
// import { NispsOscClient } from './osc-client.js';
// const osc = new NispsOscClient('ws://localhost:8765');
// osc.onOutputsReceived(values => console.log('outputs:', values));
// osc.onInputsReceived(values => console.log('inputs:', values));
// await osc.connect();
// osc.sendState(stateJson);
export class NispsOscClient extends EventTarget {
/**
* @param {string} wsUrl WebSocket URL of the bridge server
*/
constructor(wsUrl = 'ws://localhost:8765') {
super();
this._wsUrl = wsUrl;
this._ws = null;
this._connected = false;
this._reconnect = false;
this._reconnectDelay = 1000;
this._reconnectTimer = null;
// Registered callbacks
this._outputsCallbacks = [];
this._inputsCallbacks = [];
this._infoCallbacks = [];
}
/** Current connection state */
get connected() { return this._connected; }
/** The WebSocket URL */
get url() { return this._wsUrl; }
set url(val) {
if (this._connected) {
this.disconnect();
}
this._wsUrl = val;
}
/**
* Connect to the bridge server.
* @param {object} [opts]
* @param {boolean} [opts.reconnect=true] Auto-reconnect on disconnect
* @returns {Promise<void>} Resolves when connected
*/
connect({ reconnect = true } = {}) {
this._reconnect = reconnect;
return new Promise((resolve, reject) => {
if (this._connected && this._ws) {
resolve();
return;
}
try {
this._ws = new WebSocket(this._wsUrl);
} catch (err) {
reject(err);
return;
}
this._ws.onopen = () => {
this._connected = true;
this._reconnectDelay = 1000; // reset backoff
this.dispatchEvent(new CustomEvent('connected'));
resolve();
};
this._ws.onclose = () => {
const wasConnected = this._connected;
this._connected = false;
this._ws = null;
this.dispatchEvent(new CustomEvent('disconnected'));
if (this._reconnect) {
this._scheduleReconnect();
}
if (!wasConnected) {
reject(new Error('WebSocket closed before connecting'));
}
};
this._ws.onerror = (e) => {
this.dispatchEvent(new CustomEvent('error', { detail: e }));
};
this._ws.onmessage = (e) => {
this._handleMessage(e.data);
};
});
}
/** Disconnect from the bridge. */
disconnect() {
this._reconnect = false;
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
if (this._ws) {
this._ws.onclose = null; // prevent reconnect trigger
this._ws.close();
this._ws = null;
}
this._connected = false;
this.dispatchEvent(new CustomEvent('disconnected'));
}
// ── Send methods ─────────────────────────────────────────────────
/**
* Send full .nisps state JSON to the VCV module.
* The module will call dataFromJson to apply it.
* @param {object|string} stateJson
*/
sendState(stateJson) {
const payload = typeof stateJson === 'string' ? JSON.parse(stateJson) : stateJson;
this._send({ type: 'state', payload });
}
/**
* Send just weights to the VCV module.
* @param {object} weightsObj { weights: [[[...]]] }
*/
sendWeights(weightsObj) {
const payload = typeof weightsObj === 'string' ? JSON.parse(weightsObj) : weightsObj;
this._send({ type: 'weights', payload });
}
/**
* Send individual parameter updates (legacy format, for synth params).
* @param {Array<[string, number]>} params e.g. [["Env_A_Att", 0.35], ...]
*/
sendParams(params) {
this._send({ type: 'params', payload: params });
}
/**
* Send a raw param batch in legacy format (backwards compatible).
* @param {Array<[string, number]>} batch
*/
sendParamBatch(batch) {
this._send(batch);
}
// ── Receive handlers ─────────────────────────────────────────────
/**
* Register a callback for output values from VCV.
* @param {function(number[]): void} callback
* @returns {function} unsubscribe function
*/
onOutputsReceived(callback) {
this._outputsCallbacks.push(callback);
return () => {
const idx = this._outputsCallbacks.indexOf(callback);
if (idx >= 0) this._outputsCallbacks.splice(idx, 1);
};
}
/**
* Register a callback for input values from VCV.
* @param {function(number[]): void} callback
* @returns {function} unsubscribe function
*/
onInputsReceived(callback) {
this._inputsCallbacks.push(callback);
return () => {
const idx = this._inputsCallbacks.indexOf(callback);
if (idx >= 0) this._inputsCallbacks.splice(idx, 1);
};
}
/**
* Register a callback for bridge info messages.
* @param {function(string): void} callback
* @returns {function} unsubscribe function
*/
onInfo(callback) {
this._infoCallbacks.push(callback);
return () => {
const idx = this._infoCallbacks.indexOf(callback);
if (idx >= 0) this._infoCallbacks.splice(idx, 1);
};
}
// ── Internal ─────────────────────────────────────────────────────
_send(data) {
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) return;
this._ws.send(JSON.stringify(data));
}
_handleMessage(raw) {
try {
const msg = JSON.parse(raw);
switch (msg.type) {
case 'outputs':
for (const cb of this._outputsCallbacks) cb(msg.values);
this.dispatchEvent(new CustomEvent('outputs', { detail: msg.values }));
break;
case 'inputs':
for (const cb of this._inputsCallbacks) cb(msg.values);
this.dispatchEvent(new CustomEvent('inputs', { detail: msg.values }));
break;
case 'info':
for (const cb of this._infoCallbacks) cb(msg.message);
this.dispatchEvent(new CustomEvent('info', { detail: msg.message }));
break;
case 'osc':
// Generic OSC message passthrough
this.dispatchEvent(new CustomEvent('osc', { detail: msg }));
break;
}
} catch {
// ignore parse errors
}
}
_scheduleReconnect() {
if (this._reconnectTimer) return;
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
if (!this._connected && this._reconnect) {
this.connect({ reconnect: true }).catch(() => {
// increase backoff, max 30s
this._reconnectDelay = Math.min(this._reconnectDelay * 1.5, 30000);
});
}
}, this._reconnectDelay);
}
}

View file

@ -1,21 +1,20 @@
#!/usr/bin/env node #!/usr/bin/env node
// NISPS → OSC Bridge // NISPS <-> OSC Bridge (bidirectional)
// WebSocket server that receives parameter updates from the browser // WebSocket server that bridges the browser webapp and OSC-capable software
// and forwards them as OSC messages to any OSC-capable software. // (VCV Rack MEMLNaut module, SuperCollider, etc.).
// //
// Usage: // Usage:
// node bridge.mjs # defaults: ws:8765, osc:127.0.0.1:57120 // node bridge.mjs # defaults
// node bridge.mjs --osc-host 192.168.1.5 # send to another machine // node bridge.mjs --osc-host 192.168.1.5 # send to another machine
// node bridge.mjs --osc-port 9000 # SuperCollider on custom port // node bridge.mjs --osc-port 9000 # target port
// node bridge.mjs --ws-port 8765 # WebSocket listen port // node bridge.mjs --ws-port 8765 # WebSocket listen port
// node bridge.mjs --osc-prefix /nisps # OSC address prefix (default: /nisps) // node bridge.mjs --listen-port 9001 # UDP listen port for incoming OSC
// node bridge.mjs --bundle # send OSC bundles instead of individual messages // node bridge.mjs --osc-prefix /nisps # OSC address prefix
// node bridge.mjs --bundle # send OSC bundles
// //
// OSC address format: // Webapp -> Bridge -> OSC target (param updates, state, weights)
// /nisps/<param_name> <float> // OSC target -> Bridge -> Webapp (output values, input values)
// e.g. /nisps/Env_A_Att 0.35
// /nisps/SVF_Cutoff 0.72
import { createSocket } from 'node:dgram'; import { createSocket } from 'node:dgram';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
@ -31,15 +30,20 @@ const hasFlag = (name) => args.includes(`--${name}`);
const WS_PORT = parseInt(flag('ws-port', '8765'), 10); const WS_PORT = parseInt(flag('ws-port', '8765'), 10);
const OSC_HOST = flag('osc-host', '127.0.0.1'); const OSC_HOST = flag('osc-host', '127.0.0.1');
const OSC_PORT = parseInt(flag('osc-port', '57120'), 10); const OSC_PORT = parseInt(flag('osc-port', '9000'), 10);
const OSC_PREFIX = flag('osc-prefix', '/nisps'); const OSC_PREFIX = flag('osc-prefix', '/nisps');
const LISTEN_PORT = parseInt(flag('listen-port', '9001'), 10);
const USE_BUNDLES = hasFlag('bundle'); const USE_BUNDLES = hasFlag('bundle');
// ---- OSC encoding (minimal, no dependencies) ---- // ---- OSC encoding (minimal, no dependencies) ----
function oscPadded(len) {
return len + (4 - (len % 4)) % 4;
}
function oscString(str) { function oscString(str) {
const len = str.length + 1; // null terminator const len = str.length + 1; // null terminator
const padded = len + (4 - (len % 4)) % 4; const padded = oscPadded(len);
const buf = Buffer.alloc(padded); const buf = Buffer.alloc(padded);
buf.write(str, 'ascii'); buf.write(str, 'ascii');
return buf; return buf;
@ -59,9 +63,16 @@ function oscMessage(address, value) {
]); ]);
} }
function oscMessageString(address, value) {
return Buffer.concat([
oscString(address),
oscString(',s'),
oscString(value),
]);
}
function oscBundle(messages) { function oscBundle(messages) {
const header = oscString('#bundle'); const header = oscString('#bundle');
// NTP timestamp: immediately (1 in upper 32 bits)
const timetag = Buffer.alloc(8); const timetag = Buffer.alloc(8);
timetag.writeUInt32BE(1, 0); timetag.writeUInt32BE(1, 0);
@ -74,12 +85,64 @@ function oscBundle(messages) {
return Buffer.concat(parts); return Buffer.concat(parts);
} }
// ---- UDP socket ---- // ---- OSC decoding ----
const udp = createSocket('udp4');
function readOscString(buf, offset) {
let end = offset;
while (end < buf.length && buf[end] !== 0) end++;
const str = buf.toString('ascii', offset, end);
const nextOffset = offset + oscPadded(end - offset + 1);
return [str, nextOffset];
}
function readOscFloat(buf, offset) {
if (offset + 4 > buf.length) return [0, offset + 4];
return [buf.readFloatBE(offset), offset + 4];
}
function parseOscMessage(buf) {
if (buf.length < 4) return null;
let offset = 0;
const [address, off1] = readOscString(buf, offset);
if (!address.startsWith('/')) return null;
offset = off1;
const [tags, off2] = readOscString(buf, offset);
if (!tags.startsWith(',')) return null;
offset = off2;
const types = tags.slice(1);
const args = [];
for (const t of types) {
if (t === 'f') {
const [val, off] = readOscFloat(buf, offset);
args.push(val);
offset = off;
} else if (t === 's') {
const [val, off] = readOscString(buf, offset);
args.push(val);
offset = off;
}
}
return { address, types, args };
}
// ---- UDP sockets ----
// Outgoing: sends OSC to target
const udpSend = createSocket('udp4');
function sendOSC(address, value) { function sendOSC(address, value) {
const msg = oscMessage(address, value); const msg = oscMessage(address, value);
udp.send(msg, OSC_PORT, OSC_HOST); udpSend.send(msg, OSC_PORT, OSC_HOST);
}
function sendOSCString(address, value) {
const msg = oscMessageString(address, value);
udpSend.send(msg, OSC_PORT, OSC_HOST);
} }
function sendOSCBundle(params) { function sendOSCBundle(params) {
@ -87,59 +150,126 @@ function sendOSCBundle(params) {
oscMessage(`${OSC_PREFIX}/${name}`, value) oscMessage(`${OSC_PREFIX}/${name}`, value)
); );
const bundle = oscBundle(messages); const bundle = oscBundle(messages);
udp.send(bundle, OSC_PORT, OSC_HOST); udpSend.send(bundle, OSC_PORT, OSC_HOST);
} }
// Incoming: listens for OSC from target
const udpRecv = createSocket('udp4');
udpRecv.bind(LISTEN_PORT, '0.0.0.0');
udpRecv.on('message', (buf, _rinfo) => {
const msg = parseOscMessage(buf);
if (!msg) return;
const wsMsg = { type: 'osc', address: msg.address };
if (msg.address === `${OSC_PREFIX}/output` || msg.address === '/nisps/output') {
wsMsg.type = 'outputs';
wsMsg.values = msg.args.filter(a => typeof a === 'number');
} else if (msg.address === `${OSC_PREFIX}/input` || msg.address === '/nisps/input') {
wsMsg.type = 'inputs';
wsMsg.values = msg.args.filter(a => typeof a === 'number');
} else {
wsMsg.args = msg.args;
}
broadcastToWs(JSON.stringify(wsMsg));
});
udpRecv.on('error', (err) => {
console.error('[udp] Listen error:', err.message);
});
// ---- WebSocket server ---- // ---- WebSocket server ----
const wss = new WebSocketServer({ port: WS_PORT }); const wss = new WebSocketServer({ port: WS_PORT });
const wsClients = new Set();
let clientCount = 0; function broadcastToWs(data) {
for (const ws of wsClients) {
try {
if (ws.readyState === 1) { // OPEN
ws.send(data);
}
} catch {
// ignore
}
}
}
wss.on('connection', (ws) => { wss.on('connection', (ws) => {
clientCount++; wsClients.add(ws);
console.log(`[ws] Client connected (${clientCount} total)`); console.log(`[ws] Client connected (${wsClients.size} total)`);
// Tell the browser what we're targeting
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: 'info', type: 'info',
message: `OSC → ${OSC_HOST}:${OSC_PORT} (prefix: ${OSC_PREFIX})`, message: `OSC <-> ${OSC_HOST}:${OSC_PORT} (prefix: ${OSC_PREFIX}, listen: ${LISTEN_PORT})`,
})); }));
ws.on('message', (data) => { ws.on('message', (raw) => {
try { try {
// Expects: [[paramName, value], ...] const data = JSON.parse(raw);
const batch = JSON.parse(data);
if (!Array.isArray(batch)) return;
// Structured message format: { type, payload }
if (data && typeof data === 'object' && data.type) {
switch (data.type) {
case 'state':
sendOSCString(`${OSC_PREFIX}/state`, JSON.stringify(data.payload));
return;
case 'weights':
sendOSCString(`${OSC_PREFIX}/weights`, JSON.stringify(data.payload));
return;
case 'params':
if (Array.isArray(data.payload)) {
if (USE_BUNDLES) { if (USE_BUNDLES) {
sendOSCBundle(batch); sendOSCBundle(data.payload);
} else { } else {
for (const [name, value] of batch) { for (const [name, value] of data.payload) {
sendOSC(`${OSC_PREFIX}/${name}`, value); sendOSC(`${OSC_PREFIX}/${name}`, value);
} }
} }
}
return;
}
}
// Legacy format: [[paramName, value], ...]
if (Array.isArray(data)) {
if (USE_BUNDLES) {
sendOSCBundle(data);
} else {
for (const [name, value] of data) {
sendOSC(`${OSC_PREFIX}/${name}`, value);
}
}
}
} catch (e) { } catch (e) {
console.error('[ws] Bad message:', e.message); console.error('[ws] Bad message:', e.message);
} }
}); });
ws.on('close', () => { ws.on('close', () => {
clientCount--; wsClients.delete(ws);
console.log(`[ws] Client disconnected (${clientCount} remaining)`); console.log(`[ws] Client disconnected (${wsClients.size} remaining)`);
}); });
}); });
console.log(` console.log(`
NISPS OSC Bridge NISPS <-> OSC Bridge (bidirectional)
WebSocket: ws://localhost:${WS_PORT} WebSocket: ws://localhost:${WS_PORT}
OSC target: ${OSC_HOST}:${OSC_PORT} OSC target: ${OSC_HOST}:${OSC_PORT}
OSC listen: 0.0.0.0:${LISTEN_PORT}
Prefix: ${OSC_PREFIX} Prefix: ${OSC_PREFIX}
Mode: ${USE_BUNDLES ? 'bundles' : 'individual messages'} Mode: ${USE_BUNDLES ? 'bundles' : 'individual messages'}
OSC addresses: ${OSC_PREFIX}/<param_name> <float> Webapp -> VCV:
e.g. ${OSC_PREFIX}/Env_A_Att 0.35 params: [[name, value], ...] or { type: "params", payload: [...] }
${OSC_PREFIX}/SVF_Cut 0.72 state: { type: "state", payload: <JSON> }
weights: { type: "weights", payload: <JSON> }
Waiting for browser connection... VCV -> Webapp:
/nisps/output <f...f> -> { type: "outputs", values: [...] }
/nisps/input <f...f> -> { type: "inputs", values: [...] }
Waiting for connections...
`); `);

View file

@ -1,38 +1,42 @@
#!/usr/bin/env -S deno run --allow-net --unstable-net #!/usr/bin/env -S deno run --allow-net --unstable-net
// NISPS → OSC Bridge // NISPS <-> OSC Bridge (bidirectional)
// Zero-dependency bridge: WebSocket server receives parameter updates from the // WebSocket server that bridges the browser webapp and OSC-capable software
// browser and forwards them as OSC messages to any OSC-capable software. // (VCV Rack MEMLNaut module, SuperCollider, etc.).
//
// Webapp -> Bridge -> OSC target (param updates, state, weights)
// OSC target -> Bridge -> Webapp (output values, input values)
// //
// Run with Deno: // Run with Deno:
// deno run --allow-net bridge.ts // deno run --allow-net bridge.ts
// //
// Or use the compiled binary:
// ./nisps-osc-bridge
//
// Options: // Options:
// --osc-host 192.168.1.5 Target IP (default: 127.0.0.1) // --osc-host 192.168.1.5 Target IP (default: 127.0.0.1)
// --osc-port 9000 Target port (default: 57120 / SuperCollider) // --osc-port 9000 Target port (default: 9000 / VCV MEMLNaut)
// --osc-prefix /my Address prefix (default: /nisps) // --osc-prefix /my Address prefix (default: /nisps)
// --ws-port 8000 WebSocket listen port (default: 8765) // --ws-port 8765 WebSocket listen port (default: 8765)
// --listen-port 9001 UDP port for incoming OSC (default: 9001)
// --bundle Send OSC bundles instead of individual messages // --bundle Send OSC bundles instead of individual messages
// //
// OSC address format: // OSC address format:
// /nisps/<param_name> <float> // /nisps/<param_name> <float> (webapp -> target)
// e.g. /nisps/Env_A_Att 0.35 // /nisps/state <string> (webapp -> target: full JSON state)
// /nisps/SVF_Flt_Cut 0.72 // /nisps/weights <string> (webapp -> target: weights JSON)
// /nisps/output <f...f> (target -> webapp: output float array)
// /nisps/input <f...f> (target -> webapp: input float array)
import { parseArgs } from "jsr:@std/cli@1/parse-args"; import { parseArgs } from "jsr:@std/cli@1/parse-args";
// ---- CLI args ---- // ---- CLI args ----
const args = parseArgs(Deno.args, { const args = parseArgs(Deno.args, {
string: ["osc-host", "osc-port", "osc-prefix", "ws-port"], string: ["osc-host", "osc-port", "osc-prefix", "ws-port", "listen-port"],
boolean: ["bundle", "help"], boolean: ["bundle", "help"],
default: { default: {
"osc-host": "127.0.0.1", "osc-host": "127.0.0.1",
"osc-port": "57120", "osc-port": "9000",
"osc-prefix": "/nisps", "osc-prefix": "/nisps",
"ws-port": "8765", "ws-port": "8765",
"listen-port": "9001",
"bundle": false, "bundle": false,
"help": false, "help": false,
}, },
@ -40,15 +44,16 @@ const args = parseArgs(Deno.args, {
if (args.help) { if (args.help) {
console.log(` console.log(`
NISPS OSC Bridge NISPS <-> OSC Bridge (bidirectional)
Usage: nisps-osc-bridge [options] Usage: nisps-osc-bridge [options]
Options: Options:
--osc-host <ip> Target IP address (default: 127.0.0.1) --osc-host <ip> Target IP address (default: 127.0.0.1)
--osc-port <port> Target UDP port (default: 57120) --osc-port <port> Target UDP port (default: 9000)
--osc-prefix <pfx> OSC address prefix (default: /nisps) --osc-prefix <pfx> OSC address prefix (default: /nisps)
--ws-port <port> WebSocket listen port (default: 8765) --ws-port <port> WebSocket listen port (default: 8765)
--listen-port <port> UDP listen port for incoming OSC (default: 9001)
--bundle Send OSC bundles instead of individual messages --bundle Send OSC bundles instead of individual messages
--help Show this help --help Show this help
`); `);
@ -59,15 +64,20 @@ const WS_PORT = parseInt(args["ws-port"]);
const OSC_HOST = args["osc-host"]; const OSC_HOST = args["osc-host"];
const OSC_PORT = parseInt(args["osc-port"]); const OSC_PORT = parseInt(args["osc-port"]);
const OSC_PREFIX = args["osc-prefix"]; const OSC_PREFIX = args["osc-prefix"];
const LISTEN_PORT = parseInt(args["listen-port"]);
const USE_BUNDLES = args.bundle; const USE_BUNDLES = args.bundle;
// ---- OSC encoding (zero dependencies) ---- // ---- OSC encoding (zero dependencies) ----
function oscPadded(len: number): number {
return len + (4 - (len % 4)) % 4;
}
function oscString(str: string): Uint8Array { function oscString(str: string): Uint8Array {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const strBytes = encoder.encode(str); const strBytes = encoder.encode(str);
const len = strBytes.length + 1; // null terminator const len = strBytes.length + 1; // null terminator
const padded = len + (4 - (len % 4)) % 4; const padded = oscPadded(len);
const buf = new Uint8Array(padded); const buf = new Uint8Array(padded);
buf.set(strBytes); buf.set(strBytes);
return buf; return buf;
@ -94,6 +104,10 @@ function oscMessage(address: string, value: number): Uint8Array {
return concat(oscString(address), oscString(",f"), oscFloat(value)); return concat(oscString(address), oscString(",f"), oscFloat(value));
} }
function oscMessageString(address: string, value: string): Uint8Array {
return concat(oscString(address), oscString(",s"), oscString(value));
}
function u32be(val: number): Uint8Array { function u32be(val: number): Uint8Array {
const buf = new ArrayBuffer(4); const buf = new ArrayBuffer(4);
new DataView(buf).setUint32(0, val, false); new DataView(buf).setUint32(0, val, false);
@ -113,13 +127,74 @@ function oscBundle(messages: Uint8Array[]): Uint8Array {
return concat(...parts); return concat(...parts);
} }
// ---- UDP socket ---- // ---- OSC decoding ----
const udp = Deno.listenDatagram({ port: 0, transport: "udp", hostname: "0.0.0.0" });
function readOscString(buf: Uint8Array, offset: number): [string, number] {
let end = offset;
while (end < buf.length && buf[end] !== 0) end++;
const decoder = new TextDecoder();
const str = decoder.decode(buf.slice(offset, end));
const nextOffset = offset + oscPadded(end - offset + 1);
return [str, nextOffset];
}
function readOscFloat(buf: Uint8Array, offset: number): [number, number] {
if (offset + 4 > buf.length) return [0, offset + 4];
const view = new DataView(buf.buffer, buf.byteOffset + offset, 4);
return [view.getFloat32(0, false), offset + 4];
}
interface ParsedOscMessage {
address: string;
types: string;
args: (number | string)[];
}
function parseOscMessage(buf: Uint8Array): ParsedOscMessage | null {
if (buf.length < 4) return null;
let offset = 0;
const [address, off1] = readOscString(buf, offset);
if (!address.startsWith("/")) return null;
offset = off1;
const [tags, off2] = readOscString(buf, offset);
if (!tags.startsWith(",")) return null;
offset = off2;
const types = tags.slice(1);
const args: (number | string)[] = [];
for (const t of types) {
if (t === "f") {
const [val, off] = readOscFloat(buf, offset);
args.push(val);
offset = off;
} else if (t === "s") {
const [val, off] = readOscString(buf, offset);
args.push(val);
offset = off;
}
// skip unknown types
}
return { address, types, args };
}
// ---- UDP sockets ----
// Outgoing: sends OSC to the target (VCV module)
const udpSend = Deno.listenDatagram({ port: 0, transport: "udp", hostname: "0.0.0.0" });
const oscAddr: Deno.NetAddr = { transport: "udp", hostname: OSC_HOST, port: OSC_PORT }; const oscAddr: Deno.NetAddr = { transport: "udp", hostname: OSC_HOST, port: OSC_PORT };
function sendOSC(address: string, value: number): void { function sendOSC(address: string, value: number): void {
const msg = oscMessage(address, value); const msg = oscMessage(address, value);
udp.send(msg, oscAddr); udpSend.send(msg, oscAddr);
}
function sendOSCString(address: string, value: string): void {
const msg = oscMessageString(address, value);
udpSend.send(msg, oscAddr);
} }
function sendOSCBundle(params: [string, number][]): void { function sendOSCBundle(params: [string, number][]): void {
@ -127,48 +202,123 @@ function sendOSCBundle(params: [string, number][]): void {
oscMessage(`${OSC_PREFIX}/${name}`, value) oscMessage(`${OSC_PREFIX}/${name}`, value)
); );
const bundle = oscBundle(messages); const bundle = oscBundle(messages);
udp.send(bundle, oscAddr); udpSend.send(bundle, oscAddr);
} }
// ---- WebSocket server (Deno built-in) ---- // Incoming: listens for OSC from the target (VCV module)
let clientCount = 0; const udpRecv = Deno.listenDatagram({ port: LISTEN_PORT, transport: "udp", hostname: "0.0.0.0" });
// ---- WebSocket server ----
const wsClients: Set<WebSocket> = new Set();
function broadcastToWs(data: string): void {
for (const ws of wsClients) {
try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
} catch {
// ignore send errors
}
}
}
function handleWs(ws: WebSocket): void { function handleWs(ws: WebSocket): void {
clientCount++; wsClients.add(ws);
console.log(`[ws] Client connected (${clientCount} total)`); console.log(`[ws] Client connected (${wsClients.size} total)`);
ws.onopen = () => { ws.onopen = () => {
ws.send(JSON.stringify({ ws.send(JSON.stringify({
type: "info", type: "info",
message: `OSC → ${OSC_HOST}:${OSC_PORT} (prefix: ${OSC_PREFIX})`, message: `OSC <-> ${OSC_HOST}:${OSC_PORT} (prefix: ${OSC_PREFIX}, listen: ${LISTEN_PORT})`,
})); }));
}; };
ws.onmessage = (e) => { ws.onmessage = (e) => {
try { try {
const batch = JSON.parse(e.data as string); const data = JSON.parse(e.data as string);
if (!Array.isArray(batch)) return;
// New structured message format: { type, payload }
if (data && typeof data === "object" && data.type) {
switch (data.type) {
case "state":
// Send full state JSON as OSC string to /nisps/state
sendOSCString(`${OSC_PREFIX}/state`, JSON.stringify(data.payload));
return;
case "weights":
// Send weights JSON as OSC string to /nisps/weights
sendOSCString(`${OSC_PREFIX}/weights`, JSON.stringify(data.payload));
return;
case "params":
// Legacy batch format embedded in structured message
if (Array.isArray(data.payload)) {
if (USE_BUNDLES) { if (USE_BUNDLES) {
sendOSCBundle(batch); sendOSCBundle(data.payload);
} else { } else {
for (const [name, value] of batch) { for (const [name, value] of data.payload) {
sendOSC(`${OSC_PREFIX}/${name}`, value); sendOSC(`${OSC_PREFIX}/${name}`, value);
} }
} }
}
return;
}
}
// Legacy format: [[paramName, value], ...]
if (Array.isArray(data)) {
if (USE_BUNDLES) {
sendOSCBundle(data);
} else {
for (const [name, value] of data) {
sendOSC(`${OSC_PREFIX}/${name}`, value);
}
}
}
} catch (err) { } catch (err) {
console.error("[ws] Bad message:", (err as Error).message); console.error("[ws] Bad message:", (err as Error).message);
} }
}; };
ws.onclose = () => { ws.onclose = () => {
clientCount--; wsClients.delete(ws);
console.log(`[ws] Client disconnected (${clientCount} remaining)`); console.log(`[ws] Client disconnected (${wsClients.size} remaining)`);
}; };
} }
// ---- UDP receive loop (OSC from VCV -> relay to WebSocket clients) ----
async function udpReceiveLoop(): Promise<void> {
for await (const [data, _addr] of udpRecv) {
const msg = parseOscMessage(data);
if (!msg) continue;
// Relay parsed OSC messages to all connected WebSocket clients
const wsMsg: Record<string, unknown> = { type: "osc", address: msg.address };
if (msg.address === `${OSC_PREFIX}/output` || msg.address === "/nisps/output") {
// Float array of outputs
wsMsg.type = "outputs";
wsMsg.values = msg.args.filter((a): a is number => typeof a === "number");
} else if (msg.address === `${OSC_PREFIX}/input` || msg.address === "/nisps/input") {
// Float array of inputs
wsMsg.type = "inputs";
wsMsg.values = msg.args.filter((a): a is number => typeof a === "number");
} else {
// Generic OSC message
wsMsg.args = msg.args;
}
broadcastToWs(JSON.stringify(wsMsg));
}
}
// Start UDP receive loop
udpReceiveLoop().catch((err) => {
console.error("[udp] Receive loop error:", err);
});
// Start WebSocket server
Deno.serve({ port: WS_PORT }, (req) => { Deno.serve({ port: WS_PORT }, (req) => {
// Only accept WebSocket upgrades
const upgrade = req.headers.get("upgrade") || ""; const upgrade = req.headers.get("upgrade") || "";
if (upgrade.toLowerCase() !== "websocket") { if (upgrade.toLowerCase() !== "websocket") {
return new Response("NISPS OSC Bridge — connect via WebSocket", { status: 200 }); return new Response("NISPS OSC Bridge — connect via WebSocket", { status: 200 });
@ -179,16 +329,22 @@ Deno.serve({ port: WS_PORT }, (req) => {
}); });
console.log(` console.log(`
NISPS OSC Bridge NISPS <-> OSC Bridge (bidirectional)
WebSocket: ws://localhost:${WS_PORT} WebSocket: ws://localhost:${WS_PORT}
OSC target: ${OSC_HOST}:${OSC_PORT} OSC target: ${OSC_HOST}:${OSC_PORT}
OSC listen: 0.0.0.0:${LISTEN_PORT}
Prefix: ${OSC_PREFIX} Prefix: ${OSC_PREFIX}
Mode: ${USE_BUNDLES ? "bundles" : "individual messages"} Mode: ${USE_BUNDLES ? "bundles" : "individual messages"}
OSC addresses: ${OSC_PREFIX}/<param_name> <float> Webapp -> VCV:
e.g. ${OSC_PREFIX}/Env_A_Att 0.35 params: [[name, value], ...] or { type: "params", payload: [...] }
${OSC_PREFIX}/SVF_Flt_Cut 0.72 state: { type: "state", payload: <JSON> }
weights: { type: "weights", payload: <JSON> }
Waiting for browser connection... VCV -> Webapp:
/nisps/output <f...f> -> { type: "outputs", values: [...] }
/nisps/input <f...f> -> { type: "inputs", values: [...] }
Waiting for connections...
`); `);

36
playground/osc-bridge/package-lock.json generated Normal file
View file

@ -0,0 +1,36 @@
{
"name": "nisps-osc-bridge",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nisps-osc-bridge",
"version": "0.1.0",
"dependencies": {
"ws": "^8.18.0"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

68
vcv/BUILDING.md Normal file
View file

@ -0,0 +1,68 @@
# Building MEMLNaut VCV Plugin
## Prerequisites
- **VCV Rack 2 SDK** — download from https://vcvrack.com/manual/PluginDevelopmentTutorial or build from source
- **C++20 compiler** — GCC 10+, Clang 11+, or MSVC 19.29+ (required by nisps-core for `std::span` and concepts)
- **GNU Make**
- **zip** (for distribution packaging only)
## Build Steps
```bash
# 1. Set the SDK path (adjust to your installation)
export RACK_DIR=/path/to/Rack-SDK
# 2. Build the plugin
cd vcv
make
# This produces plugin.so (Linux), plugin.dylib (macOS), or plugin.dll (Windows)
```
The Makefile adds `-std=c++20` and includes nisps-core headers from `../nisps-core/include`. The VCV SDK's default `-std=c++11` flag is filtered out to avoid conflicts.
## Local Installation
```bash
# Option A: use the SDK's install target
make install
# Copies the plugin to ~/.local/share/Rack2/plugins-lin/ (or platform equivalent)
# Option B: manual symlink (useful during development)
ln -s $(pwd) ~/.local/share/Rack2/plugins-lin/MEMLNaut
```
After installing, restart VCV Rack (or use the module browser refresh if available). The MEMLNaut module appears in the module browser.
## Distribution Packaging
```bash
# Build and package in one step
make -f Makefile.dist dist
# Or package an already-built plugin
make -f Makefile.dist package-only
# Output: dist/MEMLNaut-0.1.0-Linux-x86_64.zip (platform name varies)
```
The zip contains a `MEMLNaut/` directory with `plugin.so` (or `.dylib`/`.dll`), `plugin.json`, and `res/`. Users extract this into their VCV Rack plugins directory.
## Cross-Compilation
Cross-compilation is **not currently supported**. Building for each platform requires the native VCV Rack SDK and a matching C++20 toolchain.
Options for multi-platform releases:
- **GitHub Actions CI** — build on Linux, macOS, and Windows runners. The VCV SDK provides Docker images for consistent builds.
- **Docker** — VCV provides `ghcr.io/vcvrack/rack-plugin-toolchain` images for cross-platform builds from a Linux host.
- **Manual** — build natively on each target platform.
The VCV Library submission process handles multi-platform builds automatically via their CI pipeline, but we are not submitting to the Library initially.
## Troubleshooting
- **`-std=c++11` conflicts**: The Makefile filters this out, but if you see C++20 errors, verify your `RACK_DIR` points to a v2 SDK and that your compiler supports C++20.
- **nisps-core not found**: The include path assumes nisps-core is at `../nisps-core/include` relative to the `vcv/` directory. Verify the path or adjust `-I` in the Makefile.
- **Plugin not appearing**: Check that the built `.so`/`.dylib`/`.dll` is in the correct plugins directory and that `plugin.json` is alongside it.

35
vcv/Makefile.dist Normal file
View file

@ -0,0 +1,35 @@
# Distribution packaging for MEMLNaut VCV plugin
#
# Usage:
# make -f Makefile.dist dist # Build plugin then package
# make -f Makefile.dist package-only # Package without rebuilding
# make -f Makefile.dist clean-dist # Remove dist/ directory
PLUGIN_NAME = MEMLNaut
VERSION = 0.1.0
PLATFORM = $(shell uname -s)-$(shell uname -m)
# Delegate build to the main Makefile (which includes the VCV SDK plugin.mk)
all:
$(MAKE) -f Makefile
# Build then package
dist: all package-only
# Package the already-built plugin (no rebuild)
package-only:
@echo "Packaging $(PLUGIN_NAME) v$(VERSION) for $(PLATFORM)..."
mkdir -p dist/$(PLUGIN_NAME)
cp plugin.so dist/$(PLUGIN_NAME)/ 2>/dev/null || true
cp plugin.dylib dist/$(PLUGIN_NAME)/ 2>/dev/null || true
cp plugin.dll dist/$(PLUGIN_NAME)/ 2>/dev/null || true
cp plugin.json dist/$(PLUGIN_NAME)/
cp -r res dist/$(PLUGIN_NAME)/
cd dist && zip -r $(PLUGIN_NAME)-$(VERSION)-$(PLATFORM).zip $(PLUGIN_NAME)
rm -rf dist/$(PLUGIN_NAME)
@echo "Created dist/$(PLUGIN_NAME)-$(VERSION)-$(PLATFORM).zip"
clean-dist:
rm -rf dist
.PHONY: all dist package-only clean-dist

206
vcv/NISPS-FORMAT.md Normal file
View file

@ -0,0 +1,206 @@
# .nisps File Format Specification
Version: 1
## Overview
The `.nisps` format is a JSON file that captures the complete state of a NISPS interactive ML engine: network weights, training examples, I/O configuration, and MLP architecture metadata. It enables preset sharing between the VCV Rack module and the web playground.
## Top-Level Structure
```json
{
"version": 1,
"noiseLevel": 0.1,
"slewMs": 10.0,
"outputRangeUnipolar": [true, false, ...],
"inputRangeUnipolar": [true, true, ...],
"weights": [[[...], ...], ...],
"examples": {
"features": [[...], ...],
"labels": [[...], ...]
},
"mlpConfig": {
"layers": [3, 16, 24, 16, 12],
"activations": ["relu", "relu", "relu", "sigmoid"]
},
"params": [0.5, 0.8, ...]
}
```
## Field Reference
### `version` (integer, required)
Format version number. Currently `1`. Loaders must reject files where `version < 1`. Future versions will increment this value; loaders should accept any version they understand and reject higher versions gracefully.
### `noiseLevel` (float, optional)
RL exploration noise amplitude. Range: 0.0-1.0. Default: `0.1`. Controls how much random perturbation is applied to weights during reinforcement learning exploration.
### `slewMs` (float, optional)
Output slew rate in milliseconds. Default: `10.0`. Smooths transitions between output values to prevent clicks/artifacts. In VCV Rack this is the time constant for exponential smoothing on output voltages.
### `outputRangeUnipolar` (boolean[], optional)
Per-output voltage range flag. Length must equal the number of MLP outputs. When `true`, the output maps to 0-10V (unipolar); when `false`, it maps to +/-5V (bipolar). Default: all `false`.
This field is VCV Rack-specific. The web playground ignores it but should preserve it on round-trip.
### `inputRangeUnipolar` (boolean[], optional)
Per-input voltage range flag. Length must equal the maximum number of inputs (8 in VCV Rack). When `true`, the input expects 0-10V; when `false`, +/-5V. Default: all `false`.
This field is VCV Rack-specific. The web playground ignores it but should preserve it on round-trip.
### `weights` (float[][][], required for state restore)
MLP weights as a 3D array: `weights[layer][node][weight]`.
**Serialization order:**
- Outer dimension: layers, from first hidden layer to output layer. Length = number of layers - 1 (i.e., number of weight matrices).
- Middle dimension: nodes within that layer. Length = `mlpConfig.layers[i+1]` (the number of nodes in the destination layer).
- Inner dimension: connection weights from source nodes. Length = `mlpConfig.layers[i]` (the number of nodes in the source layer, including bias for the first layer).
**Bias handling:** In the VCV Rack C++ implementation, bias is stored separately from connection weights and is **not** included in this array. The bias values are not serialized. On load, biases remain at their current values (typically 0).
In the web playground, bias is also stored separately per node. The webapp's export/import methods handle the structural difference by serializing weights without bias (matching the VCV format) and preserving bias in a separate optional field.
**Example for a `[3, 4, 2]` network** (3 inputs, 4 hidden, 2 outputs):
```json
"weights": [
[ // layer 0: input -> hidden
[0.1, -0.3, 0.5], // hidden node 0: 3 weights from 3 inputs
[0.2, 0.4, -0.1], // hidden node 1
[-0.6, 0.3, 0.2], // hidden node 2
[0.1, -0.5, 0.7] // hidden node 3
],
[ // layer 1: hidden -> output
[0.3, -0.2, 0.4, 0.1], // output node 0: 4 weights from 4 hidden nodes
[-0.1, 0.5, -0.3, 0.2] // output node 1
]
]
```
### `examples` (object, optional)
Training examples as parallel arrays.
#### `examples.features` (float[][], required if examples present)
Input feature vectors. Each inner array has length equal to `mlpConfig.layers[0]` minus the bias term. In the VCV module, features are stored **without** the bias term appended.
#### `examples.labels` (float[][], required if examples present)
Output label vectors. Each inner array has length equal to `mlpConfig.layers[last]`. Must have the same outer length as `features`.
**Example:**
```json
"examples": {
"features": [
[0.3, 0.7],
[0.8, 0.2]
],
"labels": [
[0.1, 0.9, 0.5, 0.3, 0.7, 0.2, 0.8, 0.4, 0.6, 0.1, 0.5, 0.3],
[0.9, 0.1, 0.4, 0.7, 0.3, 0.8, 0.2, 0.6, 0.4, 0.9, 0.5, 0.7]
]
}
```
### `mlpConfig` (object, required)
Architecture metadata for validation on load.
#### `mlpConfig.layers` (integer[], required)
Layer sizes including input (with bias) and output. For the default VCV configuration with 2 inputs and 12 outputs: `[3, 16, 24, 16, 12]`. The first element includes the +1 bias node.
#### `mlpConfig.activations` (string[], optional)
Activation function names per weight layer. Length = `layers.length - 1`. Valid values: `"relu"`, `"sigmoid"`, `"tanh"`, `"linear"`. Default: all hidden layers use `"relu"`, output layer uses `"sigmoid"`.
The VCV Rack module currently does not serialize this field (the architecture is fixed). The web playground includes it for forward compatibility.
### `params` (float[], optional)
VCV Rack module parameter values (knob positions, attenuators, etc.). Array indexed by the module's parameter enum. Present only in files saved from the VCV Rack "Save .nisps preset" menu.
The web playground may use this field to store synth parameter values for preset portability. Consumers that don't understand the parameter layout should ignore this field.
## Validation Rules
Loaders should check the following on import:
1. **Version gate**: `version` must be present and >= 1. Reject unknown future versions.
2. **Weight dimensions**: If `weights` and `mlpConfig.layers` are both present, verify:
- `weights.length === mlpConfig.layers.length - 1`
- `weights[i].length === mlpConfig.layers[i + 1]`
- `weights[i][j].length === mlpConfig.layers[i]`
3. **Example dimensions**: If `examples` is present:
- `features.length === labels.length`
- Each feature vector length should equal `mlpConfig.layers[0]` (or `mlpConfig.layers[0] - 1` if bias is excluded)
- Each label vector length should equal `mlpConfig.layers[last]`
4. **Architecture compatibility**: If the loader's MLP has a different architecture than `mlpConfig.layers`, the file cannot be loaded directly. The loader should reject or warn.
5. **Numeric validity**: All weight and example values must be finite (not NaN or Infinity).
## Compatibility Notes
### VCV Rack -> Web Playground
- The VCV module uses a `[3, 16, 24, 16, 12]` architecture (2 inputs + bias, 12 outputs).
- The web playground uses a `[3, 32, 48, 64, 126]` architecture (2 inputs + bias, 126 outputs).
- Direct weight transfer between these architectures is not possible. The `mlpConfig.layers` field enables loaders to detect this mismatch and report it.
- Training examples (features/labels) are also architecture-dependent due to different output counts.
### Bias Values
The VCV C++ serialization does not include node bias values in the `weights` array. Bias is stored separately in `Node::m_bias` but is not written to JSON. This means bias values are reset to their pre-load state when restoring from a `.nisps` file. In practice this has minimal impact because:
- The MLP uses leaky ReLU (hidden) and sigmoid (output) activations
- Training quickly adjusts bias values
- Initial bias is typically 0
### Future Extensions
New fields may be added to the top level without incrementing the version number, as long as they are optional and backward-compatible. The version number increments only for breaking changes to existing field semantics.
## Complete Example
A minimal but complete `.nisps` file for a `[3, 4, 2]` network (2 inputs, 4 hidden nodes, 2 outputs):
```json
{
"version": 1,
"noiseLevel": 0.15,
"slewMs": 10.0,
"outputRangeUnipolar": [true, false],
"inputRangeUnipolar": [false, false],
"weights": [
[
[0.123, -0.456, 0.789],
[-0.321, 0.654, -0.987],
[0.111, -0.222, 0.333],
[-0.444, 0.555, -0.666]
],
[
[0.12, -0.34, 0.56, -0.78],
[0.91, -0.23, 0.45, -0.67]
]
],
"examples": {
"features": [
[0.3, 0.7],
[0.8, 0.2]
],
"labels": [
[0.9, 0.1],
[0.2, 0.8]
]
},
"mlpConfig": {
"layers": [3, 4, 2],
"activations": ["relu", "sigmoid"]
}
}
```

267
vcv/README.md Normal file
View file

@ -0,0 +1,267 @@
# MEMLNaut for VCV Rack
MEMLNaut is a CV-to-CV mapper powered by a neural network that you train in real time using reinforcement learning. Patch any CV sources into its 2 inputs, connect its 12 outputs to your synth parameters, and shape the mapping by giving thumbs-up/thumbs-down feedback. The module learns your preferences, producing complex, nonlinear modulation that evolves with your taste. It does not generate sound itself -- it generates control voltages.
## Installation
MEMLNaut is built from source against the VCV Rack 2 SDK.
### Requirements
- VCV Rack 2 (Community Edition or Pro)
- VCV Rack SDK (v2.x)
- C++20 compiler (GCC 10+, Clang 12+, or MSVC 2019+)
- GNU Make
### Build
```bash
git clone --recursive https://github.com/MusicallyEmbodiedML/MEMLNaut-NISPS.git
cd MEMLNaut-NISPS/vcv
# Point to your Rack SDK (or set in environment)
export RACK_DIR=/path/to/Rack-SDK
make
make install # copies plugin to your VCV Rack plugins directory
```
The default `RACK_DIR` is `~/.local/share/Rack2/Rack-SDK`. If your SDK lives there, you can skip the export.
The `nisps-core` headers (the ML engine) are included in the parent repository and referenced automatically via the Makefile.
## Quick Start
1. Add **MEMLNaut** from the module browser (under Controller / Utility).
2. Patch two LFOs (or any CV source) into the **X** and **Y** inputs.
3. Connect several of the 12 outputs to parameters on your synth voice -- filter cutoff, oscillator pitch, waveshape, VCA level, etc.
4. Press **RAND** to randomize the network. You should hear your synth respond as the LFOs sweep.
5. Flip the **LEARN** switch on.
6. When you hear something you like, press **+** (thumbs up). When you hear something you dislike, press **-** (thumbs down).
7. Keep exploring. The mapping will converge toward sounds you prefer.
## Panel Controls
### Knobs
| Control | Description |
|---------|-------------|
| **SPREAD** | Controls weight initialization scale, RL noise amplitude, and weight decay. Low values produce extreme, polarized mappings. High values produce balanced, subtle mappings. Default: 60%. |
| **RATE** | Inference rate. Full CCW = ~170 Hz (block rate, cheapest). Noon = ~2 kHz (good for CV). Full CW = 44.1 kHz (audio rate, most expensive). |
### Buttons
| Control | Description |
|---------|-------------|
| **+** | Thumbs up. Captures the current input/output pair as a training example and trains the network. Requires LEARN to be enabled. |
| **-** | Thumbs down. Increases exploration noise and perturbs the network weights to try something different. Requires LEARN to be enabled. |
| **LEARN** | Toggle switch. Enables/disables RL feedback. When off, the module still runs inference -- it just ignores +/- presses. |
| **RAND** | Randomize all network weights (using current SPREAD setting). |
| **CLEAR** | Long-press (~1 second) to clear all training examples and reset the network. |
### Inputs
| Port | Description |
|------|-------------|
| **X** | Primary CV input. Default range: 0-10V (unipolar). |
| **Y** | Secondary CV input. Default range: 0-10V (unipolar). |
| **SPREAD CV** | CV modulation of the SPREAD knob (added to knob value, 0-10V). |
| **LEARN** | Gate input. High = enable learning. Works alongside the LEARN toggle (either enables it). |
| **+ TRIG** | Trigger input for thumbs-up. Alternative to pressing the + button. |
| **- TRIG** | Trigger input for thumbs-down. Alternative to pressing the - button. |
### Outputs
| Port | Description |
|------|-------------|
| **OUT 1-12** | Raw MLP outputs, each with its own attenuverter trimpot and LED. Default: 0-10V unipolar. |
| **MEAN** | Mean of the 12 raw outputs (0-10V). |
| **STD** | Standard deviation of the 12 raw outputs (0-10V). |
| **DELTA** | Rate of change across all outputs (L2 norm of frame-to-frame difference). |
| **NOVELTY** | How far the current input is from any training example. 10V when untrained, drops as you add examples near the current position. |
| **CONFIDENCE** | Inverse of novelty. 0V when untrained, rises as examples accumulate near the current input. |
Each of the 12 raw outputs has a **trimpot attenuverter** (-100% to +100%) for scaling and inverting individual outputs without external modules.
### LEDs
| LED | Meaning |
|-----|---------|
| **LEARN** (green) | Lit when learning is enabled. |
| **TRAIN** (yellow) | Flashes during background training. |
| **Output LEDs** (white) | Brightness tracks each output's current level. |
### Display
The built-in bar graph shows all 12 output levels in real time, color-coded by output index. The top-left corner shows the current noise level (N:). "TRAIN" appears in the top-right during active training.
## RL Workflow
The reinforcement learning loop works like this:
1. **Start exploring.** Patch LFOs or sequencers into X and Y. Connect outputs to interesting synth parameters. Press RAND a few times to hear different random mappings.
2. **Enable learning.** Flip the LEARN switch on (or send a gate to the LEARN input).
3. **Thumbs up (+)** when you like what you hear. This:
- Saves the current input position and output values as a training example
- Trains the network to reproduce this mapping
- Slightly reduces exploration noise (the network becomes more "settled")
4. **Thumbs down (-)** when you dislike what you hear. This:
- Increases exploration noise
- Perturbs the network weights to try a different mapping
- Does NOT save any training example
5. **Repeat.** Over time, the network learns to produce outputs you tend to like across the input space. Regions near your thumbs-up examples will be stable; distant regions remain exploratory.
6. **Disable learning** when you are happy with the mapping. The module continues running inference with the trained network. You now have a complex, personalized CV source.
### Tips
- Give thumbs-up at several different input positions to teach the network about different regions of the input space.
- The network holds up to 100 examples. Oldest examples are dropped when full (FIFO).
- Use the SPREAD knob to control how wild the exploration is. Low spread = dramatic changes. High spread = subtle refinements.
- The NOVELTY and CONFIDENCE outputs are useful for self-patching: route NOVELTY to control something that signals "unexplored territory."
## Context Menu
Right-click the module to access these settings:
### Output Ranges
Toggle each output between **unipolar (0-10V)** and **bipolar (+/-5V)**. Default is unipolar. Use bipolar for parameters that expect centered modulation (e.g., FM depth, panning).
### Input Ranges
Toggle each input between **unipolar (0-10V)** and **bipolar (+/-5V)**. Default is unipolar. Set to bipolar if your input source produces +/-5V signals (e.g., standard LFOs).
### Output Slew
Smoothing time applied when network weights change (after training or perturbation). Prevents audible clicks from sudden output jumps. Options: 0, 5, 10, 20, 50, 100 ms. Default: 10 ms.
### Presets (.nisps)
- **Save .nisps preset...** -- Export the full module state (weights, training examples, knob positions, ranges) to a `.nisps` JSON file.
- **Load .nisps preset...** -- Import a `.nisps` file, restoring the network and all settings.
### OSC Bridge
- **Enable OSC server** -- Start a UDP/WebSocket OSC server for live communication with the companion web app.
- **OSC listen port** -- Choose the port (default 9000). Change this if running multiple MEMLNaut instances.
## Presets
MEMLNaut uses `.nisps` files for saving and sharing trained networks.
### What gets saved
- All network weights (the learned mapping)
- All training examples (input/output pairs from thumbs-up)
- Knob positions (SPREAD, RATE, attenuverters)
- Input/output range settings
- Noise level and slew time
### Saving and loading
1. Right-click the module.
2. Under "Presets (.nisps)", choose **Save** or **Load**.
3. Pick a location and filename.
### Sharing between VCV and the web playground
The `.nisps` format is shared with the [NISPS web playground](https://musicallyembodiedml.github.io/memlnaut/). However, the VCV module and web app use different network architectures (VCV: 12 outputs, web: 126 outputs), so weights are not directly transferable between them. Training examples and configuration metadata are preserved for reference. The `mlpConfig.layers` field in the file lets each loader detect architecture mismatches.
### Patch save/load
Full module state is also saved automatically with your VCV Rack patch file. You do not need to manually export `.nisps` files to preserve your work between sessions.
## OSC Integration
MEMLNaut can communicate with the companion web app over OSC for live, bidirectional state sync.
### Setup
1. Right-click the module and enable **OSC server** (default port 9000).
2. In the web playground, open the OSC connection panel and connect to `localhost:9000`.
3. The web app connects via a WebSocket-to-OSC bridge.
### What syncs
| OSC Address | Direction | Content |
|-------------|-----------|---------|
| `/nisps/outputs` | VCV -> Web | Current output values (~10 times/sec) |
| `/nisps/inputs` | VCV -> Web | Current input values (~10 times/sec) |
| `/nisps/weights` | Both | Full weight transfer |
| `/nisps/state` | Both | Complete state sync (weights + examples + config) |
### Multiple instances
Each MEMLNaut instance needs its own OSC port. Use the port selector in the context menu (9000, 9001, 9002, 8000, 7000) to avoid conflicts.
## Technical Details
### Network architecture
The module uses a multi-layer perceptron (MLP) with the following default architecture:
```
Inputs: 2 (+ 1 bias = 3 input nodes)
Hidden: 16 -> 24 -> 16 (3 hidden layers, ReLU activation)
Output: 12 (sigmoid activation, producing values in [0, 1])
```
### Inference
The MLP runs in the VCV `process()` callback. The RATE knob controls how often inference runs, from once per audio block (~170 Hz) to every sample (44.1 kHz). Between inference steps, outputs are linearly interpolated to avoid staircase artifacts.
### Threading
- **Audio thread**: Reads CV inputs, runs MLP inference, writes CV outputs. Never blocks.
- **Background thread**: Handles training (thumbs-up) and weight perturbation (thumbs-down). When complete, signals the audio thread to crossfade to the new outputs.
- Each module instance has its own independent background thread and ML engine.
### Performance
The default network is small (~20 KB of weights). At block-rate inference, CPU usage is negligible. At audio-rate inference with all 12 outputs patched, expect moderate CPU usage comparable to a complex oscillator module. Multiple instances scale linearly.
## Building
### Full build commands
```bash
cd vcv
export RACK_DIR=/path/to/Rack-SDK
make # build the plugin
make install # install to VCV plugins directory
make clean # remove build artifacts
```
### SDK setup
1. Download the VCV Rack SDK from https://vcvrack.com/manual/PluginDevelopmentTutorial
2. Extract it somewhere (e.g., `~/Rack-SDK`)
3. Set `RACK_DIR` to that path, or place it at `~/.local/share/Rack2/Rack-SDK`
### Project structure
```
vcv/
Makefile # Build configuration
plugin.json # Plugin manifest (name, version, tags)
src/
plugin.cpp # Plugin initialization
plugin.hpp # Plugin globals
MEMLNaut.cpp # Module logic, UI, serialization
osc_server.hpp # OSC bridge server
res/
MEMLNaut.svg # Panel artwork
dep/ # Build dependencies
```
### Dependencies
- **VCV Rack SDK** (v2.x) -- provides the module framework
- **nisps-core** -- header-only C++20 ML library (included in the parent repo at `../nisps-core/`)
No external package manager dependencies are required. The nisps-core headers are referenced directly from the Makefile.

View file

@ -442,6 +442,106 @@ make install # Copies to VCV plugin directory
--- ---
## Performance Characteristics
### Inference Cost
The MLP has architecture `[3, 16, 24, 16, 12]` (3 = 2 inputs + 1 bias node). Each layer's nodes compute a weighted sum of all inputs from the previous layer (including bias weight), then apply an activation function.
| Layer transition | Nodes | Weights per node | Multiply-adds | Activations |
|-----------------|-------|-----------------|---------------|-------------|
| Input (3) -> Hidden 1 (16) | 16 | 3 | 48 | 16 (ReLU) |
| Hidden 1 (16) -> Hidden 2 (24) | 24 | 17 (16 + bias) | 408 | 24 (ReLU) |
| Hidden 2 (24) -> Hidden 3 (16) | 16 | 25 (24 + bias) | 400 | 16 (ReLU) |
| Hidden 3 (16) -> Output (12) | 12 | 17 (16 + bias) | 204 | 12 (sigmoid) |
| **Total** | | | **1,060** | **68** |
One forward pass: ~1,060 multiply-adds + 68 activation evaluations (56 ReLU, 12 sigmoid). This is trivially cheap for any modern CPU.
### Memory
- ~20KB per MLP instance (weights + node state for [3, 16, 24, 16, 12])
- 2 MLP instances per module (double-buffering for thread safety): ~40KB
- Dataset: up to 100 examples, each with 2 floats (inputs) + 12 floats (labels) = 5.6KB max
- Total per module instance: ~46KB — negligible
### Threading
- 1 background thread per module instance for training/perturbation
- No shared thread pool (simplicity over efficiency)
- 4 module instances = 4 threads + ~184KB total memory
### Rate Knob Range
| Knob position | Inference rate | Period (samples at 44.1kHz) | Behavior |
|--------------|---------------|----------------------------|----------|
| Full CCW (0.0) | ~170 Hz | 256 | Once per process block. Cheapest. |
| 12 o'clock (0.5) | ~2,756 Hz | 16 | Good for CV-rate modulation. |
| Full CW (1.0) | 44,100 Hz | 1 | Every sample. Audio-rate CV. |
Mapping is exponential: `period = 256 * (1/256)^rate`, giving perceptually linear response.
### CPU Estimate
At default rate (~2kHz with knob at 0.5): ~2,000 forward passes/sec. Each pass is ~1,060 multiply-adds. Total: ~2.1M multiply-adds/sec. For context, a single modern CPU core can sustain billions of multiply-adds per second. Even at audio rate (44.1kHz = ~46M multiply-adds/sec), the MLP inference is a small fraction of available compute.
The dominant CPU cost at audio rate is not the MLP math but the per-sample overhead in `process()` (derived output computation, slew interpolation, voltage scaling). At default rate this overhead is amortized across ~16 samples.
---
## VCV Rack v1 Compatibility
### v2-Specific APIs Used
The following VCV Rack v2 APIs are used throughout `src/MEMLNaut.cpp`:
| API | Usage | v1 Equivalent |
|-----|-------|--------------|
| `createPanel()` | `setPanel(createPanel(asset::plugin(...)))` — loads SVG panel | `SVGPanel` + `setPanel()` manual setup |
| `configButton()` | Configures momentary button params (RAND, +, -, CLEAR) | `configParam()` with min=0, max=1, default=0 |
| `configSwitch()` | Configures LEARN toggle with string labels | `configParam()` (no label strings) |
| `configParam()` | All knob/attenuverter configuration | Same name, but v2 added display formatting args |
| `configInput()` / `configOutput()` | Port labels for tooltips | Not available in v1 (no port tooltips) |
| `createCheckMenuItem()` | Context menu toggle items (output ranges, input ranges, OSC, slew) | Manual `MenuItem` subclass with `rightText` checkmark |
| `createSubmenuItem()` | Nested submenus (slew time, OSC port) | Manual `MenuItem` subclass overriding `createChildMenu()` |
| `createMenuItem()` | Simple menu actions (save/load preset) | Manual `MenuItem` subclass overriding `onAction()` |
| `createMenuLabel()` | Section headers in context menu | `MenuLabel` direct construction |
| `LedDisplay` | Base class for NanoVG bar graph display widget | `LedDisplayWidget` (similar but slightly different API) |
| `drawLayer()` | Layer-based drawing (layer 1 = foreground) | `draw()` only (no layer separation) |
| `createModel<M, W>()` | Template model registration | Same syntax (available since late v1) |
| `string::f()` | Printf-style string formatting | `string::f()` (available in v1) |
| `dsp::BooleanTrigger` | Edge detection on boolean params | Available in v1 |
| `dsp::SchmittTrigger` | Edge detection on CV triggers | Available in v1 |
| `json_*` (jansson) | State serialization | Same (jansson is used in both v1 and v2) |
| `osdialog_*` | Native file dialogs for preset save/load | Same (osdialog bundled in both) |
| Standard widgets: `RoundBlackKnob`, `VCVButton`, `CKSS`, `Trimpot`, `PJ301MPort`, `SmallLight` | Panel components | All available in v1 (VCVButton may need renaming to `BefacoButton` or similar) |
### APIs Without Direct v1 Equivalents
- **`configInput()` / `configOutput()`**: v1 has no port tooltip system. These calls would simply be removed — ports would work but lack hover labels.
- **`configButton()` / `configSwitch()`**: Would revert to `configParam()` calls. Lose the semantic distinction and string labels.
- **`createCheckMenuItem()` / `createSubmenuItem()`**: The most labor-intensive change. Each menu item in v1 requires a dedicated `struct` subclass of `MenuItem` with `onAction()` and `rightText` overrides. Our context menu has ~20+ items. A v1 port would need ~20 small structs or a templated helper.
### Estimated Effort
- **Mechanical changes** (configButton -> configParam, remove configInput/configOutput labels): ~1 hour
- **Context menu rewrite** (createCheckMenuItem/createSubmenuItem -> manual MenuItem subclasses): ~3-4 hours. This is the bulk of the work — approximately 20 menu items each needing a small struct.
- **Display widget** (LedDisplay/drawLayer -> LedDisplayWidget/draw): ~30 minutes
- **Widget naming** (VCVButton and similar may have different names): ~30 minutes of research + find/replace
- **Testing**: ~2 hours (v1 has different SDK build, need separate build environment)
- **Total estimate**: ~8 hours of focused work
### Recommendation
**Ship v2-only.** Rationale:
1. VCV Rack v1 userbase is declining — v2 Community Edition is free, removing the cost barrier that kept some users on v1.
2. The 8-hour port effort is not large, but maintaining two codepaths adds ongoing cost for every future feature.
3. nisps-core requires C++20 (`std::span`, concepts). The v1 SDK toolchain may not support C++20 on all platforms, which could require additional workarounds or feature-gating.
4. If v1 demand materializes, the port is straightforward and can be done as a one-time effort.
---
## Open Questions (to resolve during implementation) ## Open Questions (to resolve during implementation)
1. **MLP hidden layer sizing**: [16, 24, 16] is a guess. May need tuning based on real-world training performance with 12 outputs. 1. **MLP hidden layer sizing**: [16, 24, 16] is a guess. May need tuning based on real-world training performance with 12 outputs.

View file

@ -0,0 +1,74 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40.64mm" height="128.5mm" viewBox="0 0 40.64 128.5">
<defs>
<style>
text { font-family: 'Courier New', monospace; }
.title { font-size: 3.5; fill: #e0e0e0; }
.subtitle { font-size: 2.5; fill: #4a9eff; }
.section-label { font-size: 2.5; fill: #808080; }
.component-label { font-size: 2.2; fill: #808080; }
.footer { font-size: 2; fill: #808080; }
</style>
</defs>
<!-- Background -->
<rect width="40.64" height="128.5" fill="#1a1a2e"/>
<!-- Panel border -->
<rect x="0.5" y="0.5" width="39.64" height="127.5" fill="none" stroke="#2a2a3e" stroke-width="0.5"/>
<!-- Title -->
<text x="20.32" y="8" text-anchor="middle" class="title">MEMLNaut</text>
<text x="20.32" y="12" text-anchor="middle" class="subtitle">EXP</text>
<line x1="5" y1="14" x2="35.64" y2="14" stroke="#2a2a3e" stroke-width="0.3"/>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- CONNECTION INDICATOR LED -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<text x="20.32" y="20" text-anchor="middle" class="component-label">LINK</text>
<circle cx="20.32" cy="23" r="1.2" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<circle cx="20.32" cy="23" r="0.4" fill="#333355"/>
<line x1="5" y1="28" x2="35.64" y2="28" stroke="#2a2a3e" stroke-width="0.3"/>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- EXTRA INPUTS: IN 3-8 -->
<!-- 2 columns, 3 rows -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<text x="20.32" y="32" text-anchor="middle" class="section-label" fill="#e0e0e0">IN</text>
<!-- Row 0: IN 3, IN 4 -->
<text x="12" y="37" text-anchor="middle" class="component-label">IN 3</text>
<circle cx="12" cy="40" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="28.64" y="37" text-anchor="middle" class="component-label">IN 4</text>
<circle cx="28.64" cy="40" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<!-- Row 1: IN 5, IN 6 -->
<text x="12" y="50" text-anchor="middle" class="component-label">IN 5</text>
<circle cx="12" cy="53" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="28.64" y="50" text-anchor="middle" class="component-label">IN 6</text>
<circle cx="28.64" cy="53" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<!-- Row 2: IN 7, IN 8 -->
<text x="12" y="63" text-anchor="middle" class="component-label">IN 7</text>
<circle cx="12" cy="66" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="28.64" y="63" text-anchor="middle" class="component-label">IN 8</text>
<circle cx="28.64" cy="66" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<line x1="5" y1="74" x2="35.64" y2="74" stroke="#2a2a3e" stroke-width="0.3"/>
<!-- Empty space for future expansion -->
<text x="20.32" y="90" text-anchor="middle" class="component-label" fill="#333355">reserved</text>
<!-- Footer -->
<line x1="5" y1="122" x2="35.64" y2="122" stroke="#2a2a3e" stroke-width="0.3"/>
<text x="20.32" y="126" text-anchor="middle" class="footer">NISPS v0.1</text>
<!-- Mounting holes -->
<circle cx="5.08" cy="3" r="1.2" fill="none" stroke="#2a2a3e" stroke-width="0.3"/>
<circle cx="35.56" cy="3" r="1.2" fill="none" stroke="#2a2a3e" stroke-width="0.3"/>
<circle cx="5.08" cy="125.5" r="1.2" fill="none" stroke="#2a2a3e" stroke-width="0.3"/>
<circle cx="35.56" cy="125.5" r="1.2" fill="none" stroke="#2a2a3e" stroke-width="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 4 KiB

217
vcv/res/MEMLNaut-wide.svg Normal file
View file

@ -0,0 +1,217 @@
<svg xmlns="http://www.w3.org/2000/svg" width="223.52mm" height="128.5mm" viewBox="0 0 223.52 128.5">
<defs>
<style>
text { font-family: 'Courier New', monospace; }
.title { font-size: 6; fill: #e0e0e0; }
.subtitle { font-size: 3; fill: #4a9eff; }
.section-label { font-size: 3; fill: #808080; }
.component-label { font-size: 2.5; fill: #808080; }
.output-num { font-size: 2.5; fill: #4a9eff; }
.footer { font-size: 2.5; fill: #808080; }
</style>
</defs>
<!-- Background -->
<rect width="223.52" height="128.5" fill="#1a1a2e"/>
<!-- Panel border -->
<rect x="0.5" y="0.5" width="222.52" height="127.5" fill="none" stroke="#2a2a3e" stroke-width="0.5"/>
<!-- Title -->
<text x="111.76" y="8" text-anchor="middle" class="title">MEMLNaut</text>
<text x="111.76" y="12" text-anchor="middle" class="subtitle">Neural Interactive Shaping of Parameter Spaces</text>
<line x1="20" y1="13.5" x2="203" y2="13.5" stroke="#2a2a3e" stroke-width="0.3"/>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- DISPLAY AREA: wider — (2, 16) to (60, 34) -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<rect x="2" y="16" width="56" height="18" rx="1" fill="#0a0a18" stroke="#2a2a3e" stroke-width="0.4"/>
<text x="30" y="26" text-anchor="middle" class="component-label" fill="#4a9eff">DISPLAY</text>
<text x="30" y="30" text-anchor="middle" class="component-label" fill="#333355">input space / output map</text>
<line x1="0" y1="36" x2="223.52" y2="36" stroke="#2a2a3e" stroke-width="0.3"/>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- KNOBS ROW: y=40 -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<text x="14" y="38" text-anchor="middle" class="section-label">SPREAD</text>
<circle cx="14" cy="41" r="4" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<circle cx="14" cy="41" r="0.7" fill="#4a9eff"/>
<text x="28" y="38" text-anchor="middle" class="component-label">CV</text>
<circle cx="28" cy="41" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="42" y="38" text-anchor="middle" class="section-label">RATE</text>
<circle cx="42" cy="41" r="4" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<circle cx="42" cy="41" r="0.7" fill="#4a9eff"/>
<line x1="0" y1="48" x2="223.52" y2="48" stroke="#2a2a3e" stroke-width="0.3"/>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- BUTTONS ROW: y=53 -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<text x="10" y="50.5" text-anchor="middle" class="section-label" fill="#4a9eff">+</text>
<rect x="7" y="51" width="6" height="4.5" rx="0.8" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="20" y="50.5" text-anchor="middle" class="section-label" fill="#4a9eff">&minus;</text>
<rect x="17" y="51" width="6" height="4.5" rx="0.8" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="32" y="50.5" text-anchor="middle" class="section-label">LEARN</text>
<rect x="30" y="51" width="4" height="5.5" rx="0.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<circle cx="32" cy="49" r="0.8" fill="none" stroke="#00cc00" stroke-width="0.3"/>
<text x="44" y="50.5" text-anchor="middle" class="section-label">RAND</text>
<rect x="41" y="51" width="6" height="4.5" rx="0.8" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="56" y="50.5" text-anchor="middle" class="section-label">CLR</text>
<rect x="53" y="51" width="6" height="4.5" rx="0.8" fill="none" stroke="#808080" stroke-width="0.4"/>
<circle cx="56" cy="49" r="0.8" fill="none" stroke="#cccc00" stroke-width="0.3"/>
<line x1="0" y1="59" x2="223.52" y2="59" stroke="#2a2a3e" stroke-width="0.3"/>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- INPUTS: 8 jacks (future configurable) -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<text x="50" y="61.5" text-anchor="middle" class="section-label" fill="#e0e0e0">IN</text>
<!-- Row 1: X, Y, IN3-IN6 -->
<text x="10" y="63.5" text-anchor="middle" class="component-label">X</text>
<circle cx="10" cy="66" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="22" y="63.5" text-anchor="middle" class="component-label">Y</text>
<circle cx="22" cy="66" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="34" y="63.5" text-anchor="middle" class="component-label">IN 3</text>
<circle cx="34" cy="66" r="2.5" fill="none" stroke="#555555" stroke-width="0.4" stroke-dasharray="1,0.5"/>
<text x="46" y="63.5" text-anchor="middle" class="component-label">IN 4</text>
<circle cx="46" cy="66" r="2.5" fill="none" stroke="#555555" stroke-width="0.4" stroke-dasharray="1,0.5"/>
<text x="58" y="63.5" text-anchor="middle" class="component-label">IN 5</text>
<circle cx="58" cy="66" r="2.5" fill="none" stroke="#555555" stroke-width="0.4" stroke-dasharray="1,0.5"/>
<text x="70" y="63.5" text-anchor="middle" class="component-label">IN 6</text>
<circle cx="70" cy="66" r="2.5" fill="none" stroke="#555555" stroke-width="0.4" stroke-dasharray="1,0.5"/>
<!-- Row 2: IN7, IN8, LEARN gate, +TRIG, -TRIG -->
<text x="10" y="72" text-anchor="middle" class="component-label">IN 7</text>
<circle cx="10" cy="74.5" r="2.5" fill="none" stroke="#555555" stroke-width="0.4" stroke-dasharray="1,0.5"/>
<text x="22" y="72" text-anchor="middle" class="component-label">IN 8</text>
<circle cx="22" cy="74.5" r="2.5" fill="none" stroke="#555555" stroke-width="0.4" stroke-dasharray="1,0.5"/>
<text x="46" y="72" text-anchor="middle" class="component-label">LEARN</text>
<circle cx="46" cy="74.5" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="58" y="72" text-anchor="middle" class="component-label">+TRIG</text>
<circle cx="58" cy="74.5" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="70" y="72" text-anchor="middle" class="component-label">&minus;TRIG</text>
<circle cx="70" cy="74.5" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<!-- Vertical divider between inputs and outputs -->
<line x1="85" y1="59" x2="85" y2="118" stroke="#2a2a3e" stroke-width="0.4"/>
<line x1="0" y1="80" x2="85" y2="80" stroke="#2a2a3e" stroke-width="0.3"/>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- OUTPUTS: right side, 4 cols x 3 rows for generous spacing -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<text x="154" y="61.5" text-anchor="middle" class="section-label" fill="#e0e0e0">OUT</text>
<!-- Output grid: 4 columns x 3 rows, generous spacing -->
<!-- Col centers: 100, 120, 140, 160 Row centers: 68, 80, 92 -->
<!-- Row 0: 1-4 -->
<text x="100" y="65" text-anchor="middle" class="output-num">1</text>
<circle cx="96" cy="68" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="100.5" cy="68" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="105" cy="68" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="120" y="65" text-anchor="middle" class="output-num">2</text>
<circle cx="116" cy="68" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="120.5" cy="68" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="125" cy="68" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="140" y="65" text-anchor="middle" class="output-num">3</text>
<circle cx="136" cy="68" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="140.5" cy="68" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="145" cy="68" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="160" y="65" text-anchor="middle" class="output-num">4</text>
<circle cx="156" cy="68" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="160.5" cy="68" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="165" cy="68" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<!-- Row 1: 5-8 -->
<text x="100" y="77" text-anchor="middle" class="output-num">5</text>
<circle cx="96" cy="80" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="100.5" cy="80" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="105" cy="80" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="120" y="77" text-anchor="middle" class="output-num">6</text>
<circle cx="116" cy="80" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="120.5" cy="80" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="125" cy="80" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="140" y="77" text-anchor="middle" class="output-num">7</text>
<circle cx="136" cy="80" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="140.5" cy="80" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="145" cy="80" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="160" y="77" text-anchor="middle" class="output-num">8</text>
<circle cx="156" cy="80" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="160.5" cy="80" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="165" cy="80" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<!-- Row 2: 9-12 -->
<text x="100" y="89" text-anchor="middle" class="output-num">9</text>
<circle cx="96" cy="92" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="100.5" cy="92" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="105" cy="92" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="120" y="89" text-anchor="middle" class="output-num">10</text>
<circle cx="116" cy="92" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="120.5" cy="92" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="125" cy="92" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="140" y="89" text-anchor="middle" class="output-num">11</text>
<circle cx="136" cy="92" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="140.5" cy="92" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="145" cy="92" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="160" y="89" text-anchor="middle" class="output-num">12</text>
<circle cx="156" cy="92" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="160.5" cy="92" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="165" cy="92" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<line x1="85" y1="99" x2="223.52" y2="99" stroke="#2a2a3e" stroke-width="0.3"/>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- DERIVED OUTPUTS: wide spacing -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<text x="100" y="103" text-anchor="middle" class="component-label">MN</text>
<circle cx="100" cy="106" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="117" y="103" text-anchor="middle" class="component-label">SD</text>
<circle cx="117" cy="106" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="134" y="103" text-anchor="middle" class="component-label">&#x0394;T</text>
<circle cx="134" cy="106" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="151" y="103" text-anchor="middle" class="component-label">NV</text>
<circle cx="151" cy="106" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="168" y="103" text-anchor="middle" class="component-label">CF</text>
<circle cx="168" cy="106" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<!-- Footer -->
<line x1="20" y1="122" x2="203" y2="122" stroke="#2a2a3e" stroke-width="0.3"/>
<text x="111.76" y="126" text-anchor="middle" class="footer">NISPS v0.1</text>
<!-- Mounting holes -->
<circle cx="5.08" cy="3" r="1.2" fill="none" stroke="#2a2a3e" stroke-width="0.3"/>
<circle cx="218.44" cy="3" r="1.2" fill="none" stroke="#2a2a3e" stroke-width="0.3"/>
<circle cx="5.08" cy="125.5" r="1.2" fill="none" stroke="#2a2a3e" stroke-width="0.3"/>
<circle cx="218.44" cy="125.5" r="1.2" fill="none" stroke="#2a2a3e" stroke-width="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,5 +1,195 @@
<svg xmlns="http://www.w3.org/2000/svg" width="203.2mm" height="128.5mm" viewBox="0 0 203.2 128.5"> <svg xmlns="http://www.w3.org/2000/svg" width="152.4mm" height="128.5mm" viewBox="0 0 152.4 128.5">
<rect width="203.2" height="128.5" fill="#1a1a2e" /> <defs>
<text x="101.6" y="12" text-anchor="middle" fill="#e0e0e0" font-family="monospace" font-size="6">MEMLNaut</text> <style>
<text x="101.6" y="120" text-anchor="middle" fill="#808080" font-family="monospace" font-size="3">NISPS v0.1</text> text { font-family: 'Courier New', monospace; }
.title { font-size: 5; fill: #e0e0e0; }
.section-label { font-size: 3; fill: #808080; }
.component-label { font-size: 2.5; fill: #808080; }
.output-num { font-size: 2.2; fill: #4a9eff; }
.footer { font-size: 2.5; fill: #808080; }
.accent { fill: #4a9eff; }
</style>
</defs>
<!-- Background -->
<rect width="152.4" height="128.5" fill="#1a1a2e"/>
<!-- Panel border -->
<rect x="0.5" y="0.5" width="151.4" height="127.5" fill="none" stroke="#2a2a3e" stroke-width="0.5"/>
<!-- Title -->
<text x="76.2" y="8" text-anchor="middle" class="title">MEMLNaut</text>
<line x1="20" y1="10" x2="132" y2="10" stroke="#2a2a3e" stroke-width="0.3"/>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- DISPLAY AREA: (2, 14) to (38, 32) -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<rect x="2" y="14" width="36" height="18" rx="1" fill="#0a0a18" stroke="#2a2a3e" stroke-width="0.4"/>
<text x="20" y="24" text-anchor="middle" class="component-label" fill="#4a9eff">DISPLAY</text>
<line x1="0" y1="34" x2="152.4" y2="34" stroke="#2a2a3e" stroke-width="0.3"/>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- KNOBS ROW: y=36 SPREAD(8,36) CV(20,36) RATE(32,36) -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<text x="8" y="33.5" text-anchor="middle" class="section-label">SPREAD</text>
<circle cx="8" cy="36" r="3.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<circle cx="8" cy="36" r="0.6" fill="#4a9eff"/>
<text x="20" y="33.5" text-anchor="middle" class="component-label">CV</text>
<circle cx="20" cy="36" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="32" y="33.5" text-anchor="middle" class="section-label">RATE</text>
<circle cx="32" cy="36" r="3.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<circle cx="32" cy="36" r="0.6" fill="#4a9eff"/>
<line x1="0" y1="42" x2="152.4" y2="42" stroke="#2a2a3e" stroke-width="0.3"/>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- BUTTONS ROW: y=46 +(6) -(14) LEARN(22) RAND(32) CLR(40) -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<text x="6" y="43.5" text-anchor="middle" class="section-label" fill="#4a9eff">+</text>
<rect x="3.5" y="44" width="5" height="4" rx="0.8" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="14" y="43.5" text-anchor="middle" class="section-label" fill="#4a9eff">&minus;</text>
<rect x="11.5" y="44" width="5" height="4" rx="0.8" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="22" y="43.5" text-anchor="middle" class="section-label">LEARN</text>
<rect x="20.5" y="44.5" width="3" height="5" rx="0.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<!-- Learn LED -->
<circle cx="22" cy="42" r="0.8" fill="none" stroke="#00cc00" stroke-width="0.3"/>
<text x="32" y="43.5" text-anchor="middle" class="section-label">RAND</text>
<rect x="29.5" y="44" width="5" height="4" rx="0.8" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="40" y="43.5" text-anchor="middle" class="section-label">CLR</text>
<rect x="37.5" y="44" width="5" height="4" rx="0.8" fill="none" stroke="#808080" stroke-width="0.4"/>
<!-- Training LED -->
<circle cx="40" cy="42" r="0.8" fill="none" stroke="#cccc00" stroke-width="0.3"/>
<line x1="0" y1="52" x2="152.4" y2="52" stroke="#2a2a3e" stroke-width="0.3"/>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- INPUTS: y=56 X(8) Y(20) LEARN_GATE(32) -->
<!-- y=64 +TRIG(8) -TRIG(20) -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<text x="20" y="54" text-anchor="middle" class="section-label" fill="#e0e0e0">IN</text>
<text x="8" y="53.5" text-anchor="middle" class="component-label">X</text>
<circle cx="8" cy="56" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="20" y="53.5" text-anchor="middle" class="component-label">Y</text>
<circle cx="20" cy="56" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="32" y="53.5" text-anchor="middle" class="component-label">LEARN</text>
<circle cx="32" cy="56" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="8" y="61.5" text-anchor="middle" class="component-label">+TRIG</text>
<circle cx="8" cy="64" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<text x="20" y="61.5" text-anchor="middle" class="component-label">&minus;TRIG</text>
<circle cx="20" cy="64" r="2.5" fill="none" stroke="#808080" stroke-width="0.4"/>
<line x1="0" y1="70" x2="152.4" y2="70" stroke="#2a2a3e" stroke-width="0.3"/>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- OUTPUTS: 3 cols x 4 rows starting y=74, col spacing 13mm, row 9mm -->
<!-- ox = 6 + col*13, jack at ox+9 -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<text x="20" y="72.5" text-anchor="middle" class="section-label" fill="#e0e0e0">OUT</text>
<!-- Row 0: y=74 -->
<text x="6" y="72" text-anchor="middle" class="output-num">1</text>
<circle cx="6" cy="74" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="10.5" cy="74" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="15" cy="74" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="19" y="72" text-anchor="middle" class="output-num">2</text>
<circle cx="19" cy="74" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="23.5" cy="74" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="28" cy="74" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="32" y="72" text-anchor="middle" class="output-num">3</text>
<circle cx="32" cy="74" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="36.5" cy="74" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="41" cy="74" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<!-- Row 1: y=83 -->
<text x="6" y="81" text-anchor="middle" class="output-num">4</text>
<circle cx="6" cy="83" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="10.5" cy="83" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="15" cy="83" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="19" y="81" text-anchor="middle" class="output-num">5</text>
<circle cx="19" cy="83" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="23.5" cy="83" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="28" cy="83" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="32" y="81" text-anchor="middle" class="output-num">6</text>
<circle cx="32" cy="83" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="36.5" cy="83" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="41" cy="83" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<!-- Row 2: y=92 -->
<text x="6" y="90" text-anchor="middle" class="output-num">7</text>
<circle cx="6" cy="92" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="10.5" cy="92" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="15" cy="92" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="19" y="90" text-anchor="middle" class="output-num">8</text>
<circle cx="19" cy="92" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="23.5" cy="92" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="28" cy="92" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="32" y="90" text-anchor="middle" class="output-num">9</text>
<circle cx="32" cy="92" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="36.5" cy="92" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="41" cy="92" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<!-- Row 3: y=101 -->
<text x="6" y="99" text-anchor="middle" class="output-num">10</text>
<circle cx="6" cy="101" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="10.5" cy="101" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="15" cy="101" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="19" y="99" text-anchor="middle" class="output-num">11</text>
<circle cx="19" cy="101" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="23.5" cy="101" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="28" cy="101" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="32" y="99" text-anchor="middle" class="output-num">12</text>
<circle cx="32" cy="101" r="1.8" fill="none" stroke="#808080" stroke-width="0.3"/>
<circle cx="36.5" cy="101" r="0.6" fill="none" stroke="#ffffff" stroke-width="0.2"/>
<circle cx="41" cy="101" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<line x1="0" y1="106" x2="152.4" y2="106" stroke="#2a2a3e" stroke-width="0.3"/>
<!-- ══════════════════════════════════════════════════════════════════ -->
<!-- DERIVED OUTPUTS: y=112, starting x=4, spacing 8mm -->
<!-- MN(4) SD(12) DT(20) NV(28) CF(36) -->
<!-- ══════════════════════════════════════════════════════════════════ -->
<text x="4" y="109" text-anchor="middle" class="component-label">MN</text>
<circle cx="4" cy="112" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="12" y="109" text-anchor="middle" class="component-label">SD</text>
<circle cx="12" cy="112" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="20" y="109" text-anchor="middle" class="component-label">&#x0394;T</text>
<circle cx="20" cy="112" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="28" y="109" text-anchor="middle" class="component-label">NV</text>
<circle cx="28" cy="112" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<text x="36" y="109" text-anchor="middle" class="component-label">CF</text>
<circle cx="36" cy="112" r="2.5" fill="none" stroke="#4a9eff" stroke-width="0.4"/>
<!-- Footer -->
<line x1="20" y1="122" x2="132" y2="122" stroke="#2a2a3e" stroke-width="0.3"/>
<text x="76.2" y="126" text-anchor="middle" class="footer">NISPS v0.1</text>
<!-- Mounting holes (decorative) -->
<circle cx="5.08" cy="3" r="1.2" fill="none" stroke="#2a2a3e" stroke-width="0.3"/>
<circle cx="147.32" cy="3" r="1.2" fill="none" stroke="#2a2a3e" stroke-width="0.3"/>
<circle cx="5.08" cy="125.5" r="1.2" fill="none" stroke="#2a2a3e" stroke-width="0.3"/>
<circle cx="147.32" cy="125.5" r="1.2" fill="none" stroke="#2a2a3e" stroke-width="0.3"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 395 B

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -1,4 +1,5 @@
#include "plugin.hpp" #include "plugin.hpp"
#include "osc_server.hpp"
#include <nisps/nisps.hpp> #include <nisps/nisps.hpp>
#include <osdialog.h> #include <osdialog.h>
#include <thread> #include <thread>
@ -81,6 +82,68 @@ struct MEMLNaut : Module {
float cachedConfidence = 0.f; // default: no confidence (0V) float cachedConfidence = 0.f; // default: no confidence (0V)
float lastInputs[MAX_ML_INPUTS] = {}; float lastInputs[MAX_ML_INPUTS] = {};
// ── OSC bridge ────────────────────────────────────────────────────
std::unique_ptr<memlnaut::OscServer> oscServer;
bool oscEnabled = false;
int oscPort = 9000;
int oscSendCounter = 0;
static constexpr int OSC_SEND_INTERVAL_SAMPLES = 4410; // ~100ms at 44.1kHz
void startOsc() {
if (oscServer && oscServer->isRunning()) return;
oscServer = std::make_unique<memlnaut::OscServer>();
// When we receive full state JSON, apply it
oscServer->onState([this](const std::string& json) {
json_error_t error;
json_t* root = json_loads(json.c_str(), 0, &error);
if (!root) return;
dataFromJson(root);
json_decref(root);
});
// When we receive weights JSON, apply just the weights
oscServer->onWeights([this](const std::string& json) {
json_error_t error;
json_t* root = json_loads(json.c_str(), 0, &error);
if (!root) return;
json_t* jWeights = json_object_get(root, "weights");
if (jWeights && json_is_array(jWeights)) {
nisps::MLP<float>::mlp_weights weights;
for (size_t li = 0; li < json_array_size(jWeights); li++) {
json_t* jLayer = json_array_get(jWeights, li);
std::vector<std::vector<float>> layer;
for (size_t ni = 0; ni < json_array_size(jLayer); ni++) {
json_t* jNode = json_array_get(jLayer, ni);
std::vector<float> node;
for (size_t wi = 0; wi < json_array_size(jNode); wi++) {
node.push_back(json_real_value(json_array_get(jNode, wi)));
}
layer.push_back(node);
}
weights.push_back(layer);
}
iml.set_weights(weights);
}
json_decref(root);
});
if (!oscServer->start(oscPort)) {
oscServer.reset();
oscEnabled = false;
} else {
oscEnabled = true;
}
}
void stopOsc() {
if (oscServer) {
oscServer->stop();
oscServer.reset();
}
oscEnabled = false;
}
// ── Triggers ────────────────────────────────────────────────────── // ── Triggers ──────────────────────────────────────────────────────
dsp::BooleanTrigger randTrigger; dsp::BooleanTrigger randTrigger;
dsp::BooleanTrigger thumbsUpTrigger; dsp::BooleanTrigger thumbsUpTrigger;
@ -154,6 +217,7 @@ struct MEMLNaut : Module {
} }
~MEMLNaut() { ~MEMLNaut() {
stopOsc();
shouldStop.store(true); shouldStop.store(true);
jobCv.notify_one(); jobCv.notify_one();
if (workerThread.joinable()) { if (workerThread.joinable()) {
@ -432,6 +496,16 @@ struct MEMLNaut : Module {
// Novelty + Confidence (computed on background thread, cached) // Novelty + Confidence (computed on background thread, cached)
outputs[OUTPUT_NOVELTY].setVoltage(cachedNovelty); outputs[OUTPUT_NOVELTY].setVoltage(cachedNovelty);
outputs[OUTPUT_CONFIDENCE].setVoltage(cachedConfidence); outputs[OUTPUT_CONFIDENCE].setVoltage(cachedConfidence);
// ── OSC send (throttled to ~100ms) ───────────────────────────
if (oscServer && oscServer->isRunning()) {
oscSendCounter++;
if (oscSendCounter >= OSC_SEND_INTERVAL_SAMPLES) {
oscSendCounter = 0;
oscServer->sendOutputs(slewOutputs, NUM_ML_OUTPUTS);
oscServer->sendInputs(lastInputs, NUM_ML_INPUTS);
}
}
} }
// ── Serialization ───────────────────────────────────────────────── // ── Serialization ─────────────────────────────────────────────────
@ -440,6 +514,8 @@ struct MEMLNaut : Module {
json_object_set_new(root, "version", json_integer(1)); json_object_set_new(root, "version", json_integer(1));
json_object_set_new(root, "noiseLevel", json_real(noiseLevel)); json_object_set_new(root, "noiseLevel", json_real(noiseLevel));
json_object_set_new(root, "slewMs", json_real(slewMs)); json_object_set_new(root, "slewMs", json_real(slewMs));
json_object_set_new(root, "oscEnabled", json_boolean(oscEnabled));
json_object_set_new(root, "oscPort", json_integer(oscPort));
// Output ranges // Output ranges
json_t* outRanges = json_array(); json_t* outRanges = json_array();
@ -511,6 +587,16 @@ struct MEMLNaut : Module {
if ((j = json_object_get(root, "slewMs"))) if ((j = json_object_get(root, "slewMs")))
slewMs = json_real_value(j); slewMs = json_real_value(j);
// OSC
if ((j = json_object_get(root, "oscPort")))
oscPort = json_integer_value(j);
if ((j = json_object_get(root, "oscEnabled"))) {
if (json_boolean_value(j))
startOsc();
else
stopOsc();
}
// Output ranges // Output ranges
json_t* outRanges = json_object_get(root, "outputRangeUnipolar"); json_t* outRanges = json_object_get(root, "outputRangeUnipolar");
if (outRanges) { if (outRanges) {
@ -800,6 +886,37 @@ struct MEMLNautWidget : ModuleWidget {
json_decref(root); json_decref(root);
})); }));
// ── OSC bridge ───────────────────────────────────────────────
menu->addChild(new MenuSeparator);
menu->addChild(createMenuLabel("OSC Bridge"));
menu->addChild(createCheckMenuItem(
string::f("Enable OSC server (port %d)", module->oscPort), "",
[=]() { return module->oscEnabled; },
[=]() {
if (module->oscEnabled) {
module->stopOsc();
} else {
module->startOsc();
}
}
));
menu->addChild(createSubmenuItem("OSC listen port", string::f("%d", module->oscPort), [=](Menu* childMenu) {
for (int port : {9000, 9001, 9002, 8000, 7000}) {
childMenu->addChild(createCheckMenuItem(
string::f("%d", port), "",
[=]() { return module->oscPort == port; },
[=]() {
bool wasRunning = module->oscEnabled;
if (wasRunning) module->stopOsc();
module->oscPort = port;
if (wasRunning) module->startOsc();
}
));
}
}));
} }
}; };

356
vcv/src/osc_server.hpp Normal file
View file

@ -0,0 +1,356 @@
// osc_server.hpp — Minimal OSC server for MEMLNaut VCV module
// Lightweight header-only implementation using raw UDP sockets.
// Supports a tiny subset of OSC: float and string arguments only.
// No bundles, no timetags, no pattern matching.
#pragma once
#include <string>
#include <functional>
#include <thread>
#include <atomic>
#include <vector>
#include <cstring>
#include <cstdint>
#include <mutex>
#include <chrono>
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")
using socket_t = SOCKET;
static constexpr socket_t INVALID_SOCK = INVALID_SOCKET;
#define CLOSE_SOCKET(s) closesocket(s)
#else
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <cerrno>
using socket_t = int;
static constexpr socket_t INVALID_SOCK = -1;
#define CLOSE_SOCKET(s) ::close(s)
#endif
namespace memlnaut {
// ── OSC encoding/decoding helpers ────────────────────────────────────
namespace osc {
// Pad length to next multiple of 4
inline size_t padded(size_t len) {
return (len + 3) & ~size_t(3);
}
// Read a null-terminated, 4-byte-padded OSC string from buf at offset.
// Returns the string and advances offset past the padded data.
inline std::string readString(const uint8_t* buf, size_t bufLen, size_t& offset) {
if (offset >= bufLen) return "";
const char* start = reinterpret_cast<const char*>(buf + offset);
size_t maxLen = bufLen - offset;
size_t slen = strnlen(start, maxLen);
std::string s(start, slen);
offset += padded(slen + 1); // +1 for null terminator
return s;
}
// Read a big-endian float32 from buf at offset.
inline float readFloat(const uint8_t* buf, size_t bufLen, size_t& offset) {
if (offset + 4 > bufLen) return 0.f;
uint32_t raw = (uint32_t(buf[offset]) << 24)
| (uint32_t(buf[offset + 1]) << 16)
| (uint32_t(buf[offset + 2]) << 8)
| uint32_t(buf[offset + 3]);
offset += 4;
float f;
std::memcpy(&f, &raw, 4);
return f;
}
// Read a big-endian int32 from buf at offset.
inline int32_t readInt32(const uint8_t* buf, size_t bufLen, size_t& offset) {
if (offset + 4 > bufLen) return 0;
int32_t val = (int32_t(buf[offset]) << 24)
| (int32_t(buf[offset + 1]) << 16)
| (int32_t(buf[offset + 2]) << 8)
| int32_t(buf[offset + 3]);
offset += 4;
return val;
}
// Write a null-terminated, 4-byte-padded string into out.
inline void writeString(std::vector<uint8_t>& out, const std::string& s) {
size_t start = out.size();
size_t total = padded(s.size() + 1);
out.resize(start + total, 0);
std::memcpy(out.data() + start, s.c_str(), s.size());
// Remaining bytes are already zero (null terminator + padding)
}
// Write a big-endian float32 into out.
inline void writeFloat(std::vector<uint8_t>& out, float f) {
uint32_t raw;
std::memcpy(&raw, &f, 4);
out.push_back(uint8_t(raw >> 24));
out.push_back(uint8_t(raw >> 16));
out.push_back(uint8_t(raw >> 8));
out.push_back(uint8_t(raw));
}
// Build an OSC message with a float array payload.
// Address: e.g. "/nisps/output"
// Type tag string: ",fff..." (one 'f' per float)
inline std::vector<uint8_t> messageFloats(const std::string& address,
const float* values, size_t count) {
std::vector<uint8_t> msg;
writeString(msg, address);
// Type tag string: "," + count 'f' chars
std::string tags = ",";
for (size_t i = 0; i < count; i++) tags += 'f';
writeString(msg, tags);
for (size_t i = 0; i < count; i++) {
writeFloat(msg, values[i]);
}
return msg;
}
// Build an OSC message with a single string payload.
inline std::vector<uint8_t> messageString(const std::string& address,
const std::string& value) {
std::vector<uint8_t> msg;
writeString(msg, address);
writeString(msg, ",s");
writeString(msg, value);
return msg;
}
} // namespace osc
// ── OscServer ────────────────────────────────────────────────────────
class OscServer {
public:
using StringCallback = std::function<void(const std::string&)>;
OscServer() = default;
~OscServer() { stop(); }
// Non-copyable
OscServer(const OscServer&) = delete;
OscServer& operator=(const OscServer&) = delete;
// Register handlers before starting
void onState(StringCallback cb) { stateCallback_ = std::move(cb); }
void onWeights(StringCallback cb) { weightsCallback_ = std::move(cb); }
// Set the target address for sending (where the webapp bridge listens).
// Default: 127.0.0.1:9001
void setSendTarget(const std::string& host, int port) {
std::lock_guard<std::mutex> lock(sendMutex_);
sendHost_ = host;
sendPort_ = port;
sendTargetDirty_ = true;
}
bool start(int listenPort = 9000) {
if (running_.load()) return true;
#ifdef _WIN32
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) return false;
wsaInit_ = true;
#endif
recvSock_ = socket(AF_INET, SOCK_DGRAM, 0);
if (recvSock_ == INVALID_SOCK) return false;
// Allow address reuse
int opt = 1;
#ifdef _WIN32
setsockopt(recvSock_, SOL_SOCKET, SO_REUSEADDR,
reinterpret_cast<const char*>(&opt), sizeof(opt));
#else
setsockopt(recvSock_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
#endif
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(static_cast<uint16_t>(listenPort));
if (bind(recvSock_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
CLOSE_SOCKET(recvSock_);
recvSock_ = INVALID_SOCK;
return false;
}
// Set receive timeout so the thread can check shouldStop
#ifdef _WIN32
DWORD timeout = 200; // ms
setsockopt(recvSock_, SOL_SOCKET, SO_RCVTIMEO,
reinterpret_cast<const char*>(&timeout), sizeof(timeout));
#else
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 200000; // 200ms
setsockopt(recvSock_, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
#endif
// Create send socket
sendSock_ = socket(AF_INET, SOCK_DGRAM, 0);
if (sendSock_ == INVALID_SOCK) {
CLOSE_SOCKET(recvSock_);
recvSock_ = INVALID_SOCK;
return false;
}
listenPort_ = listenPort;
shouldStop_.store(false);
running_.store(true);
recvThread_ = std::thread(&OscServer::recvLoop, this);
return true;
}
void stop() {
if (!running_.load()) return;
shouldStop_.store(true);
if (recvThread_.joinable()) recvThread_.join();
if (recvSock_ != INVALID_SOCK) { CLOSE_SOCKET(recvSock_); recvSock_ = INVALID_SOCK; }
if (sendSock_ != INVALID_SOCK) { CLOSE_SOCKET(sendSock_); sendSock_ = INVALID_SOCK; }
running_.store(false);
#ifdef _WIN32
if (wsaInit_) { WSACleanup(); wsaInit_ = false; }
#endif
}
bool isRunning() const { return running_.load(); }
int getPort() const { return listenPort_; }
// ── Send methods ─────────────────────────────────────────────────
// Send current output values (12 floats) to the webapp bridge
void sendOutputs(const float* values, size_t count) {
auto msg = osc::messageFloats("/nisps/output", values, count);
sendPacket(msg);
}
// Send current input values (2 floats)
void sendInputs(const float* values, size_t count) {
auto msg = osc::messageFloats("/nisps/input", values, count);
sendPacket(msg);
}
private:
void recvLoop() {
uint8_t buf[65536];
while (!shouldStop_.load()) {
sockaddr_in from{};
socklen_t fromLen = sizeof(from);
ssize_t n = recvfrom(recvSock_, reinterpret_cast<char*>(buf), sizeof(buf), 0,
reinterpret_cast<sockaddr*>(&from), &fromLen);
if (n <= 0) continue; // timeout or error
// Remember sender for replies
{
std::lock_guard<std::mutex> lock(sendMutex_);
lastSender_ = from;
hasLastSender_ = true;
}
parseMessage(buf, static_cast<size_t>(n));
}
}
void parseMessage(const uint8_t* buf, size_t len) {
size_t offset = 0;
// Read address
std::string address = osc::readString(buf, len, offset);
if (address.empty() || address[0] != '/') return;
// Read type tag string
std::string tags = osc::readString(buf, len, offset);
if (tags.empty() || tags[0] != ',') return;
// Dispatch based on address
if (address == "/nisps/state") {
// Expect a single string argument
if (tags.size() >= 2 && tags[1] == 's') {
std::string payload = osc::readString(buf, len, offset);
if (stateCallback_) stateCallback_(payload);
}
} else if (address == "/nisps/weights") {
// Expect a single string argument (JSON)
if (tags.size() >= 2 && tags[1] == 's') {
std::string payload = osc::readString(buf, len, offset);
if (weightsCallback_) weightsCallback_(payload);
}
}
// Unknown addresses are silently ignored
}
void sendPacket(const std::vector<uint8_t>& packet) {
if (sendSock_ == INVALID_SOCK) return;
std::lock_guard<std::mutex> lock(sendMutex_);
sockaddr_in target{};
target.sin_family = AF_INET;
if (sendTargetDirty_ || !hasExplicitTarget_) {
// Use explicit target if set, otherwise reply to last sender
if (!sendHost_.empty()) {
inet_pton(AF_INET, sendHost_.c_str(), &target.sin_addr);
target.sin_port = htons(static_cast<uint16_t>(sendPort_));
hasExplicitTarget_ = true;
sendTargetDirty_ = false;
sendAddr_ = target;
} else if (hasLastSender_) {
target = lastSender_;
target.sin_port = htons(static_cast<uint16_t>(sendPort_));
sendAddr_ = target;
} else {
return; // nobody to send to
}
}
sendto(sendSock_, reinterpret_cast<const char*>(packet.data()), packet.size(), 0,
reinterpret_cast<sockaddr*>(&sendAddr_), sizeof(sendAddr_));
}
// Sockets
socket_t recvSock_ = INVALID_SOCK;
socket_t sendSock_ = INVALID_SOCK;
int listenPort_ = 9000;
// Thread control
std::thread recvThread_;
std::atomic<bool> shouldStop_{false};
std::atomic<bool> running_{false};
// Callbacks
StringCallback stateCallback_;
StringCallback weightsCallback_;
// Send target
std::mutex sendMutex_;
std::string sendHost_ = "127.0.0.1";
int sendPort_ = 9001;
bool sendTargetDirty_ = false;
bool hasExplicitTarget_ = false;
bool hasLastSender_ = false;
sockaddr_in lastSender_{};
sockaddr_in sendAddr_{};
#ifdef _WIN32
bool wsaInit_ = false;
#endif
};
} // namespace memlnaut

BIN
vcv/test/smoke_test Executable file

Binary file not shown.