From ba0fcab2a22c371804d18f7f5a92497e7442dc30 Mon Sep 17 00:00:00 2001 From: w1n5t0n Date: Sat, 28 Mar 2026 01:48:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(vcv,playground):=20complete=20Phases=208,?= =?UTF-8?q?=209,=2010=20=E2=80=94=20all=20phases=20done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- playground/js/nisps/iml.js | 97 +++++++ playground/js/nisps/layer.js | 1 + playground/js/nisps/osc-client.js | 241 ++++++++++++++++ playground/osc-bridge/bridge.mjs | 218 ++++++++++++--- playground/osc-bridge/bridge.ts | 256 +++++++++++++---- playground/osc-bridge/package-lock.json | 36 +++ vcv/BUILDING.md | 68 +++++ vcv/Makefile.dist | 35 +++ vcv/NISPS-FORMAT.md | 206 ++++++++++++++ vcv/README.md | 267 ++++++++++++++++++ vcv/SPEC.md | 100 +++++++ vcv/res/MEMLNaut-expander.svg | 74 +++++ vcv/res/MEMLNaut-wide.svg | 217 +++++++++++++++ vcv/res/MEMLNaut.svg | 198 ++++++++++++- vcv/src/MEMLNaut.cpp | 117 ++++++++ vcv/src/osc_server.hpp | 356 ++++++++++++++++++++++++ vcv/test/smoke_test | Bin 0 -> 172128 bytes 17 files changed, 2389 insertions(+), 98 deletions(-) create mode 100644 playground/js/nisps/osc-client.js create mode 100644 playground/osc-bridge/package-lock.json create mode 100644 vcv/BUILDING.md create mode 100644 vcv/Makefile.dist create mode 100644 vcv/NISPS-FORMAT.md create mode 100644 vcv/README.md create mode 100644 vcv/res/MEMLNaut-expander.svg create mode 100644 vcv/res/MEMLNaut-wide.svg create mode 100644 vcv/src/osc_server.hpp create mode 100755 vcv/test/smoke_test diff --git a/playground/js/nisps/iml.js b/playground/js/nisps/iml.js index eea2005..4e095b0 100644 --- a/playground/js/nisps/iml.js +++ b/playground/js/nisps/iml.js @@ -215,4 +215,101 @@ export class IML { get exampleCount() { 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(); + } } diff --git a/playground/js/nisps/layer.js b/playground/js/nisps/layer.js index 4f9e165..b39eaa9 100644 --- a/playground/js/nisps/layer.js +++ b/playground/js/nisps/layer.js @@ -34,6 +34,7 @@ export class Layer { this.numNodes = numNodes; this.nodes = []; + this.activationName = activationName; const pair = activationPairs[activationName]; this.activationFn = pair[0]; this.derivActivationFn = pair[1]; diff --git a/playground/js/nisps/osc-client.js b/playground/js/nisps/osc-client.js new file mode 100644 index 0000000..9351ab0 --- /dev/null +++ b/playground/js/nisps/osc-client.js @@ -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} 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); + } +} diff --git a/playground/osc-bridge/bridge.mjs b/playground/osc-bridge/bridge.mjs index 0e9904a..2439f8e 100644 --- a/playground/osc-bridge/bridge.mjs +++ b/playground/osc-bridge/bridge.mjs @@ -1,21 +1,20 @@ #!/usr/bin/env node -// NISPS → OSC Bridge -// WebSocket server that receives parameter updates from the browser -// and forwards them as OSC messages to any OSC-capable software. +// NISPS <-> OSC Bridge (bidirectional) +// WebSocket server that bridges the browser webapp and OSC-capable software +// (VCV Rack MEMLNaut module, SuperCollider, etc.). // // 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-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 --osc-prefix /nisps # OSC address prefix (default: /nisps) -// node bridge.mjs --bundle # send OSC bundles instead of individual messages +// node bridge.mjs --listen-port 9001 # UDP listen port for incoming OSC +// node bridge.mjs --osc-prefix /nisps # OSC address prefix +// node bridge.mjs --bundle # send OSC bundles // -// OSC address format: -// /nisps/ -// e.g. /nisps/Env_A_Att 0.35 -// /nisps/SVF_Cutoff 0.72 +// Webapp -> Bridge -> OSC target (param updates, state, weights) +// OSC target -> Bridge -> Webapp (output values, input values) import { createSocket } from 'node:dgram'; import { WebSocketServer } from 'ws'; @@ -31,15 +30,20 @@ const hasFlag = (name) => args.includes(`--${name}`); const WS_PORT = parseInt(flag('ws-port', '8765'), 10); 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 LISTEN_PORT = parseInt(flag('listen-port', '9001'), 10); const USE_BUNDLES = hasFlag('bundle'); // ---- OSC encoding (minimal, no dependencies) ---- +function oscPadded(len) { + return len + (4 - (len % 4)) % 4; +} + function oscString(str) { const len = str.length + 1; // null terminator - const padded = len + (4 - (len % 4)) % 4; + const padded = oscPadded(len); const buf = Buffer.alloc(padded); buf.write(str, 'ascii'); 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) { const header = oscString('#bundle'); - // NTP timestamp: immediately (1 in upper 32 bits) const timetag = Buffer.alloc(8); timetag.writeUInt32BE(1, 0); @@ -74,12 +85,64 @@ function oscBundle(messages) { return Buffer.concat(parts); } -// ---- UDP socket ---- -const udp = createSocket('udp4'); +// ---- OSC decoding ---- + +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) { 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) { @@ -87,35 +150,96 @@ function sendOSCBundle(params) { oscMessage(`${OSC_PREFIX}/${name}`, value) ); 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 ---- 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) => { - clientCount++; - console.log(`[ws] Client connected (${clientCount} total)`); + wsClients.add(ws); + console.log(`[ws] Client connected (${wsClients.size} total)`); - // Tell the browser what we're targeting ws.send(JSON.stringify({ 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 { - // Expects: [[paramName, value], ...] - const batch = JSON.parse(data); - if (!Array.isArray(batch)) return; + const data = JSON.parse(raw); - if (USE_BUNDLES) { - sendOSCBundle(batch); - } else { - for (const [name, value] of batch) { - sendOSC(`${OSC_PREFIX}/${name}`, value); + // 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) { + sendOSCBundle(data.payload); + } else { + for (const [name, value] of data.payload) { + 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) { @@ -124,22 +248,28 @@ wss.on('connection', (ws) => { }); ws.on('close', () => { - clientCount--; - console.log(`[ws] Client disconnected (${clientCount} remaining)`); + wsClients.delete(ws); + console.log(`[ws] Client disconnected (${wsClients.size} remaining)`); }); }); console.log(` -NISPS → OSC Bridge -────────────────── - WebSocket: ws://localhost:${WS_PORT} - OSC target: ${OSC_HOST}:${OSC_PORT} - Prefix: ${OSC_PREFIX} - Mode: ${USE_BUNDLES ? 'bundles' : 'individual messages'} +NISPS <-> OSC Bridge (bidirectional) +──────────────────────────────────── + WebSocket: ws://localhost:${WS_PORT} + OSC target: ${OSC_HOST}:${OSC_PORT} + OSC listen: 0.0.0.0:${LISTEN_PORT} + Prefix: ${OSC_PREFIX} + Mode: ${USE_BUNDLES ? 'bundles' : 'individual messages'} - OSC addresses: ${OSC_PREFIX}/ - e.g. ${OSC_PREFIX}/Env_A_Att 0.35 - ${OSC_PREFIX}/SVF_Cut 0.72 + Webapp -> VCV: + params: [[name, value], ...] or { type: "params", payload: [...] } + state: { type: "state", payload: } + weights: { type: "weights", payload: } - Waiting for browser connection... + VCV -> Webapp: + /nisps/output -> { type: "outputs", values: [...] } + /nisps/input -> { type: "inputs", values: [...] } + + Waiting for connections... `); diff --git a/playground/osc-bridge/bridge.ts b/playground/osc-bridge/bridge.ts index 3ef803e..a7cf565 100644 --- a/playground/osc-bridge/bridge.ts +++ b/playground/osc-bridge/bridge.ts @@ -1,38 +1,42 @@ #!/usr/bin/env -S deno run --allow-net --unstable-net -// NISPS → OSC Bridge -// Zero-dependency bridge: WebSocket server receives parameter updates from the -// browser and forwards them as OSC messages to any OSC-capable software. +// NISPS <-> OSC Bridge (bidirectional) +// WebSocket server that bridges the browser webapp and 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: // deno run --allow-net bridge.ts // -// Or use the compiled binary: -// ./nisps-osc-bridge -// // Options: // --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) -// --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 // // OSC address format: -// /nisps/ -// e.g. /nisps/Env_A_Att 0.35 -// /nisps/SVF_Flt_Cut 0.72 +// /nisps/ (webapp -> target) +// /nisps/state (webapp -> target: full JSON state) +// /nisps/weights (webapp -> target: weights JSON) +// /nisps/output (target -> webapp: output float array) +// /nisps/input (target -> webapp: input float array) import { parseArgs } from "jsr:@std/cli@1/parse-args"; // ---- CLI 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"], default: { "osc-host": "127.0.0.1", - "osc-port": "57120", + "osc-port": "9000", "osc-prefix": "/nisps", "ws-port": "8765", + "listen-port": "9001", "bundle": false, "help": false, }, @@ -40,17 +44,18 @@ const args = parseArgs(Deno.args, { if (args.help) { console.log(` -NISPS → OSC Bridge +NISPS <-> OSC Bridge (bidirectional) Usage: nisps-osc-bridge [options] Options: - --osc-host Target IP address (default: 127.0.0.1) - --osc-port Target UDP port (default: 57120) - --osc-prefix OSC address prefix (default: /nisps) - --ws-port WebSocket listen port (default: 8765) - --bundle Send OSC bundles instead of individual messages - --help Show this help + --osc-host Target IP address (default: 127.0.0.1) + --osc-port Target UDP port (default: 9000) + --osc-prefix OSC address prefix (default: /nisps) + --ws-port WebSocket listen port (default: 8765) + --listen-port UDP listen port for incoming OSC (default: 9001) + --bundle Send OSC bundles instead of individual messages + --help Show this help `); Deno.exit(0); } @@ -59,15 +64,20 @@ const WS_PORT = parseInt(args["ws-port"]); const OSC_HOST = args["osc-host"]; const OSC_PORT = parseInt(args["osc-port"]); const OSC_PREFIX = args["osc-prefix"]; +const LISTEN_PORT = parseInt(args["listen-port"]); const USE_BUNDLES = args.bundle; // ---- OSC encoding (zero dependencies) ---- +function oscPadded(len: number): number { + return len + (4 - (len % 4)) % 4; +} + function oscString(str: string): Uint8Array { const encoder = new TextEncoder(); const strBytes = encoder.encode(str); const len = strBytes.length + 1; // null terminator - const padded = len + (4 - (len % 4)) % 4; + const padded = oscPadded(len); const buf = new Uint8Array(padded); buf.set(strBytes); return buf; @@ -94,6 +104,10 @@ function oscMessage(address: string, value: number): Uint8Array { 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 { const buf = new ArrayBuffer(4); new DataView(buf).setUint32(0, val, false); @@ -113,13 +127,74 @@ function oscBundle(messages: Uint8Array[]): Uint8Array { return concat(...parts); } -// ---- UDP socket ---- -const udp = Deno.listenDatagram({ port: 0, transport: "udp", hostname: "0.0.0.0" }); +// ---- OSC decoding ---- + +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 }; function sendOSC(address: string, value: number): void { 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 { @@ -127,33 +202,76 @@ function sendOSCBundle(params: [string, number][]): void { oscMessage(`${OSC_PREFIX}/${name}`, value) ); const bundle = oscBundle(messages); - udp.send(bundle, oscAddr); + udpSend.send(bundle, oscAddr); } -// ---- WebSocket server (Deno built-in) ---- -let clientCount = 0; +// Incoming: listens for OSC from the target (VCV module) +const udpRecv = Deno.listenDatagram({ port: LISTEN_PORT, transport: "udp", hostname: "0.0.0.0" }); + +// ---- WebSocket server ---- +const wsClients: Set = 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 { - clientCount++; - console.log(`[ws] Client connected (${clientCount} total)`); + wsClients.add(ws); + console.log(`[ws] Client connected (${wsClients.size} total)`); ws.onopen = () => { ws.send(JSON.stringify({ 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) => { try { - const batch = JSON.parse(e.data as string); - if (!Array.isArray(batch)) return; + const data = JSON.parse(e.data as string); - if (USE_BUNDLES) { - sendOSCBundle(batch); - } else { - for (const [name, value] of batch) { - sendOSC(`${OSC_PREFIX}/${name}`, value); + // 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) { + sendOSCBundle(data.payload); + } else { + for (const [name, value] of data.payload) { + 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) { @@ -162,13 +280,45 @@ function handleWs(ws: WebSocket): void { }; ws.onclose = () => { - clientCount--; - console.log(`[ws] Client disconnected (${clientCount} remaining)`); + wsClients.delete(ws); + console.log(`[ws] Client disconnected (${wsClients.size} remaining)`); }; } +// ---- UDP receive loop (OSC from VCV -> relay to WebSocket clients) ---- + +async function udpReceiveLoop(): Promise { + 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 = { 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) => { - // Only accept WebSocket upgrades const upgrade = req.headers.get("upgrade") || ""; if (upgrade.toLowerCase() !== "websocket") { return new Response("NISPS OSC Bridge — connect via WebSocket", { status: 200 }); @@ -179,16 +329,22 @@ Deno.serve({ port: WS_PORT }, (req) => { }); console.log(` -NISPS → OSC Bridge -────────────────── - WebSocket: ws://localhost:${WS_PORT} - OSC target: ${OSC_HOST}:${OSC_PORT} - Prefix: ${OSC_PREFIX} - Mode: ${USE_BUNDLES ? "bundles" : "individual messages"} +NISPS <-> OSC Bridge (bidirectional) +──────────────────────────────────── + WebSocket: ws://localhost:${WS_PORT} + OSC target: ${OSC_HOST}:${OSC_PORT} + OSC listen: 0.0.0.0:${LISTEN_PORT} + Prefix: ${OSC_PREFIX} + Mode: ${USE_BUNDLES ? "bundles" : "individual messages"} - OSC addresses: ${OSC_PREFIX}/ - e.g. ${OSC_PREFIX}/Env_A_Att 0.35 - ${OSC_PREFIX}/SVF_Flt_Cut 0.72 + Webapp -> VCV: + params: [[name, value], ...] or { type: "params", payload: [...] } + state: { type: "state", payload: } + weights: { type: "weights", payload: } - Waiting for browser connection... + VCV -> Webapp: + /nisps/output -> { type: "outputs", values: [...] } + /nisps/input -> { type: "inputs", values: [...] } + + Waiting for connections... `); diff --git a/playground/osc-bridge/package-lock.json b/playground/osc-bridge/package-lock.json new file mode 100644 index 0000000..34e917e --- /dev/null +++ b/playground/osc-bridge/package-lock.json @@ -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 + } + } + } + } +} diff --git a/vcv/BUILDING.md b/vcv/BUILDING.md new file mode 100644 index 0000000..fb05a97 --- /dev/null +++ b/vcv/BUILDING.md @@ -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. diff --git a/vcv/Makefile.dist b/vcv/Makefile.dist new file mode 100644 index 0000000..8e01378 --- /dev/null +++ b/vcv/Makefile.dist @@ -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 diff --git a/vcv/NISPS-FORMAT.md b/vcv/NISPS-FORMAT.md new file mode 100644 index 0000000..e3987c4 --- /dev/null +++ b/vcv/NISPS-FORMAT.md @@ -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"] + } +} +``` diff --git a/vcv/README.md b/vcv/README.md new file mode 100644 index 0000000..aa397f7 --- /dev/null +++ b/vcv/README.md @@ -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. diff --git a/vcv/SPEC.md b/vcv/SPEC.md index b2b3864..a33b1bd 100644 --- a/vcv/SPEC.md +++ b/vcv/SPEC.md @@ -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()` | 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) 1. **MLP hidden layer sizing**: [16, 24, 16] is a guess. May need tuning based on real-world training performance with 12 outputs. diff --git a/vcv/res/MEMLNaut-expander.svg b/vcv/res/MEMLNaut-expander.svg new file mode 100644 index 0000000..0bf6259 --- /dev/null +++ b/vcv/res/MEMLNaut-expander.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + MEMLNaut + EXP + + + + + + LINK + + + + + + + + + + + + + IN 3 + + + IN 4 + + + + IN 5 + + + IN 6 + + + + IN 7 + + + IN 8 + + + + + + reserved + + + + NISPS v0.1 + + + + + + + diff --git a/vcv/res/MEMLNaut-wide.svg b/vcv/res/MEMLNaut-wide.svg new file mode 100644 index 0000000..b095c9c --- /dev/null +++ b/vcv/res/MEMLNaut-wide.svg @@ -0,0 +1,217 @@ + + + + + + + + + + + + + MEMLNaut + Neural Interactive Shaping of Parameter Spaces + + + + + + + DISPLAY + input space / output map + + + + + + + + + + + CV + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + X + + + Y + + + IN 3 + + + IN 4 + + + IN 5 + + + IN 6 + + + + IN 7 + + + IN 8 + + + LEARN + + + +TRIG + + + −TRIG + + + + + + + + + + + + + + + + + 1 + + + + + 2 + + + + + 3 + + + + + 4 + + + + + + 5 + + + + + 6 + + + + + 7 + + + + + 8 + + + + + + 9 + + + + + 10 + + + + + 11 + + + + + 12 + + + + + + + + + + MN + + + SD + + + ΔT + + + NV + + + CF + + + + + NISPS v0.1 + + + + + + + diff --git a/vcv/res/MEMLNaut.svg b/vcv/res/MEMLNaut.svg index 2eaf451..86cf8eb 100644 --- a/vcv/res/MEMLNaut.svg +++ b/vcv/res/MEMLNaut.svg @@ -1,5 +1,195 @@ - - - MEMLNaut - NISPS v0.1 + + + + + + + + + + + + + MEMLNaut + + + + + + + DISPLAY + + + + + + + + + + + CV + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + X + + + Y + + + LEARN + + + +TRIG + + + −TRIG + + + + + + + + + + + + 1 + + + + + 2 + + + + + 3 + + + + + + 4 + + + + + 5 + + + + + 6 + + + + + + 7 + + + + + 8 + + + + + 9 + + + + + + 10 + + + + + 11 + + + + + 12 + + + + + + + + + + + MN + + + SD + + + ΔT + + + NV + + + CF + + + + + NISPS v0.1 + + + + + + diff --git a/vcv/src/MEMLNaut.cpp b/vcv/src/MEMLNaut.cpp index 0fe0139..4ab6e12 100644 --- a/vcv/src/MEMLNaut.cpp +++ b/vcv/src/MEMLNaut.cpp @@ -1,4 +1,5 @@ #include "plugin.hpp" +#include "osc_server.hpp" #include #include #include @@ -81,6 +82,68 @@ struct MEMLNaut : Module { float cachedConfidence = 0.f; // default: no confidence (0V) float lastInputs[MAX_ML_INPUTS] = {}; + // ── OSC bridge ──────────────────────────────────────────────────── + std::unique_ptr 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(); + + // 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::mlp_weights weights; + for (size_t li = 0; li < json_array_size(jWeights); li++) { + json_t* jLayer = json_array_get(jWeights, li); + std::vector> layer; + for (size_t ni = 0; ni < json_array_size(jLayer); ni++) { + json_t* jNode = json_array_get(jLayer, ni); + std::vector 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 ────────────────────────────────────────────────────── dsp::BooleanTrigger randTrigger; dsp::BooleanTrigger thumbsUpTrigger; @@ -154,6 +217,7 @@ struct MEMLNaut : Module { } ~MEMLNaut() { + stopOsc(); shouldStop.store(true); jobCv.notify_one(); if (workerThread.joinable()) { @@ -432,6 +496,16 @@ struct MEMLNaut : Module { // Novelty + Confidence (computed on background thread, cached) outputs[OUTPUT_NOVELTY].setVoltage(cachedNovelty); 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 ───────────────────────────────────────────────── @@ -440,6 +514,8 @@ struct MEMLNaut : Module { json_object_set_new(root, "version", json_integer(1)); json_object_set_new(root, "noiseLevel", json_real(noiseLevel)); 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 json_t* outRanges = json_array(); @@ -511,6 +587,16 @@ struct MEMLNaut : Module { if ((j = json_object_get(root, "slewMs"))) 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 json_t* outRanges = json_object_get(root, "outputRangeUnipolar"); if (outRanges) { @@ -800,6 +886,37 @@ struct MEMLNautWidget : ModuleWidget { 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(); + } + )); + } + })); } }; diff --git a/vcv/src/osc_server.hpp b/vcv/src/osc_server.hpp new file mode 100644 index 0000000..6112494 --- /dev/null +++ b/vcv/src/osc_server.hpp @@ -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 +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 + #ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN + #endif + #include + #include + #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 + #include + #include + #include + #include + #include + 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(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& 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& 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 messageFloats(const std::string& address, + const float* values, size_t count) { + std::vector 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 messageString(const std::string& address, + const std::string& value) { + std::vector msg; + writeString(msg, address); + writeString(msg, ",s"); + writeString(msg, value); + return msg; +} + +} // namespace osc + +// ── OscServer ──────────────────────────────────────────────────────── + +class OscServer { +public: + using StringCallback = std::function; + + 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 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(&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(listenPort)); + + if (bind(recvSock_, reinterpret_cast(&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(&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(buf), sizeof(buf), 0, + reinterpret_cast(&from), &fromLen); + if (n <= 0) continue; // timeout or error + + // Remember sender for replies + { + std::lock_guard lock(sendMutex_); + lastSender_ = from; + hasLastSender_ = true; + } + + parseMessage(buf, static_cast(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& packet) { + if (sendSock_ == INVALID_SOCK) return; + + std::lock_guard 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(sendPort_)); + hasExplicitTarget_ = true; + sendTargetDirty_ = false; + sendAddr_ = target; + } else if (hasLastSender_) { + target = lastSender_; + target.sin_port = htons(static_cast(sendPort_)); + sendAddr_ = target; + } else { + return; // nobody to send to + } + } + + sendto(sendSock_, reinterpret_cast(packet.data()), packet.size(), 0, + reinterpret_cast(&sendAddr_), sizeof(sendAddr_)); + } + + // Sockets + socket_t recvSock_ = INVALID_SOCK; + socket_t sendSock_ = INVALID_SOCK; + int listenPort_ = 9000; + + // Thread control + std::thread recvThread_; + std::atomic shouldStop_{false}; + std::atomic 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 diff --git a/vcv/test/smoke_test b/vcv/test/smoke_test new file mode 100755 index 0000000000000000000000000000000000000000..2244f041e75207af525b174d845bf0d51f6b761e GIT binary patch literal 172128 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 literal 0 HcmV?d00001