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:
parent
74c52fadc7
commit
ba0fcab2a2
17 changed files with 2389 additions and 98 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
241
playground/js/nisps/osc-client.js
Normal file
241
playground/js/nisps/osc-client.js
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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/<param_name> <float>
|
||||
// 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,59 +150,126 @@ 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);
|
||||
|
||||
// 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(batch);
|
||||
sendOSCBundle(data.payload);
|
||||
} else {
|
||||
for (const [name, value] of batch) {
|
||||
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) {
|
||||
console.error('[ws] Bad message:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
──────────────────
|
||||
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}/<param_name> <float>
|
||||
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: <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...
|
||||
`);
|
||||
|
|
|
|||
|
|
@ -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/<param_name> <float>
|
||||
// e.g. /nisps/Env_A_Att 0.35
|
||||
// /nisps/SVF_Flt_Cut 0.72
|
||||
// /nisps/<param_name> <float> (webapp -> target)
|
||||
// /nisps/state <string> (webapp -> target: full JSON state)
|
||||
// /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";
|
||||
|
||||
// ---- 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,15 +44,16 @@ 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 <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)
|
||||
--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
|
||||
--help Show this help
|
||||
`);
|
||||
|
|
@ -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,48 +202,123 @@ 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<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 {
|
||||
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);
|
||||
|
||||
// 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(batch);
|
||||
sendOSCBundle(data.payload);
|
||||
} else {
|
||||
for (const [name, value] of batch) {
|
||||
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) {
|
||||
console.error("[ws] Bad message:", (err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
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<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) => {
|
||||
// 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
|
||||
──────────────────
|
||||
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}/<param_name> <float>
|
||||
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: <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
36
playground/osc-bridge/package-lock.json
generated
Normal 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
68
vcv/BUILDING.md
Normal 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
35
vcv/Makefile.dist
Normal 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
206
vcv/NISPS-FORMAT.md
Normal 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
267
vcv/README.md
Normal 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.
|
||||
100
vcv/SPEC.md
100
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<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)
|
||||
|
||||
1. **MLP hidden layer sizing**: [16, 24, 16] is a guess. May need tuning based on real-world training performance with 12 outputs.
|
||||
|
|
|
|||
74
vcv/res/MEMLNaut-expander.svg
Normal file
74
vcv/res/MEMLNaut-expander.svg
Normal 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
217
vcv/res/MEMLNaut-wide.svg
Normal 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">−</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">−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">Δ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 |
|
|
@ -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">
|
||||
<rect width="203.2" height="128.5" fill="#1a1a2e" />
|
||||
<text x="101.6" y="12" text-anchor="middle" fill="#e0e0e0" font-family="monospace" font-size="6">MEMLNaut</text>
|
||||
<text x="101.6" y="120" text-anchor="middle" fill="#808080" font-family="monospace" font-size="3">NISPS v0.1</text>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="152.4mm" height="128.5mm" viewBox="0 0 152.4 128.5">
|
||||
<defs>
|
||||
<style>
|
||||
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">−</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">−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">Δ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>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 395 B After Width: | Height: | Size: 12 KiB |
|
|
@ -1,4 +1,5 @@
|
|||
#include "plugin.hpp"
|
||||
#include "osc_server.hpp"
|
||||
#include <nisps/nisps.hpp>
|
||||
#include <osdialog.h>
|
||||
#include <thread>
|
||||
|
|
@ -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<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 ──────────────────────────────────────────────────────
|
||||
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();
|
||||
}
|
||||
));
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
356
vcv/src/osc_server.hpp
Normal file
356
vcv/src/osc_server.hpp
Normal 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
BIN
vcv/test/smoke_test
Executable file
Binary file not shown.
Loading…
Reference in a new issue