diff --git a/manifold/osc-bridge/bridge.mjs b/manifold/osc-bridge/bridge.mjs deleted file mode 100644 index a87ee0f..0000000 --- a/manifold/osc-bridge/bridge.mjs +++ /dev/null @@ -1,300 +0,0 @@ -#!/usr/bin/env node - -// 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 -// node bridge.mjs --osc-host 192.168.1.5 # send to another machine -// node bridge.mjs --osc-port 9000 # target port -// node bridge.mjs --ws-port 8765 # WebSocket listen port -// 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 -// -// 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'; - -// ---- CLI args ---- -const args = process.argv.slice(2); -function flag(name, fallback) { - const idx = args.indexOf(`--${name}`); - if (idx === -1) return fallback; - return args[idx + 1] ?? fallback; -} -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', '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 = oscPadded(len); - const buf = Buffer.alloc(padded); - buf.write(str, 'ascii'); - return buf; -} - -function oscFloat(val) { - const buf = Buffer.alloc(4); - buf.writeFloatBE(val, 0); - return buf; -} - -function oscMessage(address, value) { - return Buffer.concat([ - oscString(address), - oscString(',f'), - oscFloat(value), - ]); -} - -/** A single OSC message carrying N floats (one ",fff…" message, not N messages). */ -function oscMessageFloats(address, values) { - const tags = ',' + 'f'.repeat(values.length); - return Buffer.concat([ - oscString(address), - oscString(tags), - ...values.map((v) => oscFloat(v)), - ]); -} - -function oscMessageString(address, value) { - return Buffer.concat([ - oscString(address), - oscString(',s'), - oscString(value), - ]); -} - -function oscBundle(messages) { - const header = oscString('#bundle'); - const timetag = Buffer.alloc(8); - timetag.writeUInt32BE(1, 0); - - const parts = [header, timetag]; - for (const msg of messages) { - const size = Buffer.alloc(4); - size.writeUInt32BE(msg.length, 0); - parts.push(size, msg); - } - return Buffer.concat(parts); -} - -// ---- 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); - udpSend.send(msg, OSC_PORT, OSC_HOST); -} - -function sendOSCString(address, value) { - const msg = oscMessageString(address, value); - udpSend.send(msg, OSC_PORT, OSC_HOST); -} - -function sendOSCFloats(address, values) { - const msg = oscMessageFloats(address, values); - udpSend.send(msg, OSC_PORT, OSC_HOST); -} - -function sendOSCBundle(params) { - const messages = params.map(([name, value]) => - oscMessage(`${OSC_PREFIX}/${name}`, value) - ); - const bundle = oscBundle(messages); - 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(); - -function broadcastToWs(data) { - for (const ws of wsClients) { - try { - if (ws.readyState === 1) { // OPEN - ws.send(data); - } - } catch { - // ignore - } - } -} - -wss.on('connection', (ws) => { - wsClients.add(ws); - console.log(`[ws] Client connected (${wsClients.size} total)`); - - ws.send(JSON.stringify({ - type: 'info', - message: `OSC <-> ${OSC_HOST}:${OSC_PORT} (prefix: ${OSC_PREFIX}, listen: ${LISTEN_PORT})`, - })); - - ws.on('message', (raw) => { - try { - 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 'input': - // Current input VECTOR as ONE multi-float message to /nisps/input. - if (Array.isArray(data.payload)) { - sendOSCFloats(`${OSC_PREFIX}/input`, data.payload); - } - return; - case 'feedback': - // Verdict op as OSC string to /nisps/feedback (trains the module). - sendOSCString(`${OSC_PREFIX}/feedback`, JSON.stringify(data.payload)); - return; - case 'params': - if (Array.isArray(data.payload)) { - if (USE_BUNDLES) { - sendOSCBundle(data.payload); - } else { - for (const [name, value] of data.payload) { - sendOSC(`${OSC_PREFIX}/${name}`, value); - } - } - } - return; - } - } - - // Legacy format: [[paramName, value], ...] - if (Array.isArray(data)) { - if (USE_BUNDLES) { - sendOSCBundle(data); - } else { - for (const [name, value] of data) { - sendOSC(`${OSC_PREFIX}/${name}`, value); - } - } - } - } catch (e) { - console.error('[ws] Bad message:', e.message); - } - }); - - ws.on('close', () => { - wsClients.delete(ws); - console.log(`[ws] Client disconnected (${wsClients.size} remaining)`); - }); -}); - -console.log(` -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'} - - Webapp -> VCV: - params: [[name, value], ...] or { type: "params", payload: [...] } - state: { type: "state", payload: } - weights: { type: "weights", payload: } - - VCV -> Webapp: - /nisps/output -> { type: "outputs", values: [...] } - /nisps/input -> { type: "inputs", values: [...] } - - Waiting for connections... -`); diff --git a/manifold/osc-bridge/bridge.ts b/manifold/osc-bridge/bridge.ts index 0d9e926..a7cd780 100644 --- a/manifold/osc-bridge/bridge.ts +++ b/manifold/osc-bridge/bridge.ts @@ -4,7 +4,7 @@ // 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) +// Webapp -> Bridge -> OSC target (param updates, input vector, feedback) // OSC target -> Bridge -> Webapp (output values, input values) // // Run with Deno: @@ -20,8 +20,6 @@ // // OSC address format: // /nisps/ (webapp -> target) -// /nisps/state (webapp -> target: full JSON state) -// /nisps/weights (webapp -> target: weights JSON) // /nisps/input (webapp -> target: input vector, ONE message; // also target -> webapp for visualisation) // /nisps/feedback (webapp -> target: verdict op JSON — @@ -253,17 +251,9 @@ function handleWs(ws: WebSocket): void { try { const data = JSON.parse(e.data as string); - // New structured message format: { type, payload } + // 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 "input": // Current input VECTOR as ONE multi-float message to /nisps/input // (browser drives the VCV module's inputs in bridged mode). @@ -277,7 +267,7 @@ function handleWs(ws: WebSocket): void { sendOSCString(`${OSC_PREFIX}/feedback`, JSON.stringify(data.payload)); return; case "params": - // Legacy batch format embedded in structured message + // Per-param batch: one single-float message per [name, value] entry if (Array.isArray(data.payload)) { if (USE_BUNDLES) { sendOSCBundle(data.payload); @@ -290,17 +280,6 @@ function handleWs(ws: WebSocket): void { 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); } @@ -319,23 +298,18 @@ async function udpReceiveLoop(): Promise { const msg = parseOscMessage(data); if (!msg) continue; - // Relay parsed OSC messages to all connected WebSocket clients - const wsMsg: Record = { type: "osc", address: msg.address }; - + // Relay the module's output/input vectors to all connected WebSocket + // clients. Other addresses have no browser receiver — drop them. + let type: "outputs" | "inputs"; 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"); + type = "outputs"; } 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"); + type = "inputs"; } else { - // Generic OSC message - wsMsg.args = msg.args; + continue; } - - broadcastToWs(JSON.stringify(wsMsg)); + const values = msg.args.filter((a): a is number => typeof a === "number"); + broadcastToWs(JSON.stringify({ type, values })); } } @@ -365,9 +339,9 @@ NISPS <-> OSC Bridge (bidirectional) Mode: ${USE_BUNDLES ? "bundles" : "individual messages"} Webapp -> VCV: - params: [[name, value], ...] or { type: "params", payload: [...] } - state: { type: "state", payload: } - weights: { type: "weights", payload: } + params: { type: "params", payload: [[name, value], ...] } + input: { type: "input", payload: [f, ...] } + feedback: { type: "feedback", payload: } VCV -> Webapp: /nisps/output -> { type: "outputs", values: [...] } diff --git a/manifold/src/backends/README.md b/manifold/src/backends/README.md index d46dd0f..b41adde 100644 --- a/manifold/src/backends/README.md +++ b/manifold/src/backends/README.md @@ -17,7 +17,7 @@ See `docs/specs/backends-spec.md` for the authoritative design. | `midi-backend.ts` | `WebMidiBackend` — real Web MIDI CC out (per-output CC#/channel/range/name, throttled + dead-zone). | | `osc-client.ts` | `NispsOscClient` — WS transport to the Deno OSC bridge (JSON protocol, auto-reconnect). | | `osc-backend.ts` | `OscBridgeBackend` — OSC out over WS; per-output address path + physical range. | -| `vcv-backend.ts` | `VcvBackend` — drives + **trains** the VCV Rack NISPS module over the OSC↔WS bridge (streams `/nisps/input`, forwards the verdict loop to `/nisps/feedback`, receives `/nisps/output` + `/nisps/state`). | +| `vcv-backend.ts` | `VcvBackend` — drives + **trains** the VCV Rack NISPS module over the OSC↔WS bridge (streams `/nisps/input`, forwards the verdict loop to `/nisps/feedback`, receives `/nisps/output` / `/nisps/input` as module-alive proof). | | `passthrough-backend.ts` | No-op sink for synth (plays in-engine) / particles (rAF consumer) / editor. | | `presets.ts` | Named per-backend output-config presets (localStorage, per-backend namespace). | | `useBackendManager.ts` | Thin React binding: builds `BackendContext` from the store, switches Mode, surfaces status. | @@ -40,8 +40,7 @@ the OSC backend shows "bridge not running" (it auto-reconnects): cd manifold/osc-bridge deno run --allow-net bridge.ts # --osc-host 127.0.0.1 --osc-port 9000 --ws-port 8765 --listen-port 9001 -# or, without Deno: -node bridge.mjs +# or, without Deno: use a compiled binary (compile.sh / the osc-bridge release) ``` Default bridge URL: `ws://localhost:8765` (configurable in the OSC config panel). @@ -60,8 +59,8 @@ When Mode = **VCV**, the browser is authoritative in *bridged mode*: the output[] }`), so the module's embedded net trains in lock-step with the browser session. -It receives **`/nisps/output`** + **`/nisps/state`** back for status / -visualisation. The verdict forwarding is gated in `BackendManager.forwardFeedback` +It receives **`/nisps/output`** / **`/nisps/input`** back as proof the module is +alive. The verdict forwarding is gated in `BackendManager.forwardFeedback` — a no-op unless VCV is the active backend (otherwise the browser engine is the learner). Both processes are required: diff --git a/manifold/src/backends/osc-client.ts b/manifold/src/backends/osc-client.ts index 1a4b66d..962e5d0 100644 --- a/manifold/src/backends/osc-client.ts +++ b/manifold/src/backends/osc-client.ts @@ -6,8 +6,8 @@ * * browser → bridge: * { type: 'params', payload: [[name, value], ...] } (per-param floats) - * { type: 'state', payload: } (full state) - * { type: 'weights',payload: } (weights only) + * { type: 'input', payload: [f, ...] } (input vector) + * { type: 'feedback', payload: } (verdict op) * bridge → browser: * { type: 'outputs', values: [...] } * { type: 'inputs', values: [...] } @@ -113,16 +113,6 @@ export class NispsOscClient { this.send({ type: 'params', payload: params }); } - sendState(stateJson: object | string): void { - const payload = typeof stateJson === 'string' ? JSON.parse(stateJson) : stateJson; - this.send({ type: 'state', payload }); - } - - sendWeights(weightsObj: object | string): void { - const payload = typeof weightsObj === 'string' ? JSON.parse(weightsObj) : weightsObj; - this.send({ type: 'weights', payload }); - } - /** * Send the current input VECTOR as ONE multi-float OSC message to * `/input` (the bridge `input` verb). Used in VCV bridged mode so the diff --git a/manifold/src/backends/vcv-backend.ts b/manifold/src/backends/vcv-backend.ts index 2a7218a..98aa023 100644 --- a/manifold/src/backends/vcv-backend.ts +++ b/manifold/src/backends/vcv-backend.ts @@ -18,9 +18,8 @@ * string — { op, spread, input[], output[] } * * module → browser - * /nisps/output module's live outputs (status / visualisation) - * /nisps/input module's live inputs (echo / status) - * /nisps/state module status snapshot (surfaced as a message) + * /nisps/output module's live outputs (proves the module is alive) + * /nisps/input module's live inputs (echo — same alive proof) * * The bridge process AND the VCV module must both be running — until the WS * connects we surface "bridge not running"; until the module replies we stay @@ -71,13 +70,10 @@ export class VcvBackend implements OutputBackend { private lastSendMs = 0; private lastInputSent: [number, number] = [-1, -1]; - /** Latest module-reported outputs (for visualisation), null until first echo. */ - private moduleOutputs: number[] | null = null; private gotModuleReply = false; private statusState: BackendStatus = { state: 'idle', message: 'VCV idle' }; private statusListeners = new Set<(s: BackendStatus) => void>(); - private outputListeners = new Set<(v: number[]) => void>(); private offConn: (() => void) | null = null; private offInfo: (() => void) | null = null; private offOutputs: (() => void) | null = null; @@ -113,8 +109,8 @@ export class VcvBackend implements OutputBackend { } }); // Module → browser: a reply on either channel proves the module is alive. - this.offOutputs = this.client.onOutputsReceived((v) => this.onModuleReply(v, true)); - this.offInputs = this.client.onInputsReceived((v) => this.onModuleReply(v, false)); + this.offOutputs = this.client.onOutputsReceived(() => this.onModuleReply()); + this.offInputs = this.client.onInputsReceived(() => this.onModuleReply()); this.setStatus({ state: 'connecting', message: `Connecting to VCV bridge (${this.client.url})…` }); this.client.connect({ reconnect: true }).catch(() => { @@ -208,26 +204,11 @@ export class VcvBackend implements OutputBackend { return spec?.bipolar ? v * 10 - 5 : v * 10; } - /** Latest module-reported output vector for visualisation (may be null). */ - moduleStatusOutputs(): number[] | null { - return this.moduleOutputs; - } - - /** Subscribe to module-reported outputs (visualisation feed). */ - onModuleOutputs(cb: (v: number[]) => void): () => void { - this.outputListeners.add(cb); - return () => this.outputListeners.delete(cb); - } - - private onModuleReply(v: number[], isOutput: boolean): void { + private onModuleReply(): void { if (!this.gotModuleReply) { this.gotModuleReply = true; this.setStatus({ state: 'ready', message: `VCV module connected (${this.client.url})` }); } - if (isOutput) { - this.moduleOutputs = v; - for (const cb of this.outputListeners) cb(v); - } } async teardown(): Promise {