refactor(manifold): delete the OSC bridge twin and dead protocol legs

Phase 1 group 6 (S11, L16).

- S11: deleted osc-bridge/bridge.mjs. It was not compiled output but a separate
  hand-written Node port of bridge.ts (node:dgram + ws vs Deno.listenDatagram).
  The completeness critic settled which twin survives:
  .github/workflows/osc-bridge.yml deno-compiles ONLY bridge.ts into the
  released cross-platform binaries, so bridge.ts plus those binaries are the
  distribution and the .mjs had no consumer in any workflow.
- L16: dead protocol legs left over from the retired playground —
  sendState/sendWeights in osc-client.ts, the legacy bare-array branch in the
  surviving bridge, the unreceivable /nisps/state path, and the unused
  module-output listeners.

Note for the docs phase: docs/specs/backends-spec.md still calls bridge.mjs
"already compiled" (doubly false now), and vcv/ still pushes /nisps/state via
OscServer::sendState with no manifold-side counterpart — flagged, not touched.

Gates: run-all-tests.sh ALL GREEN.
This commit is contained in:
monkey-w1n5t0n 2026-07-21 12:49:25 +02:00
parent 9b686eb312
commit 232d51039d
5 changed files with 25 additions and 381 deletions

View file

@ -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: <JSON> }
weights: { type: "weights", payload: <JSON> }
VCV -> Webapp:
/nisps/output <f...f> -> { type: "outputs", values: [...] }
/nisps/input <f...f> -> { type: "inputs", values: [...] }
Waiting for connections...
`);

View file

@ -4,7 +4,7 @@
// WebSocket server that bridges the browser webapp and OSC-capable software // WebSocket server that bridges the browser webapp and OSC-capable software
// (VCV Rack MEMLNaut module, SuperCollider, etc.). // (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) // OSC target -> Bridge -> Webapp (output values, input values)
// //
// Run with Deno: // Run with Deno:
@ -20,8 +20,6 @@
// //
// OSC address format: // OSC address format:
// /nisps/<param_name> <float> (webapp -> target) // /nisps/<param_name> <float> (webapp -> target)
// /nisps/state <string> (webapp -> target: full JSON state)
// /nisps/weights <string> (webapp -> target: weights JSON)
// /nisps/input <f...f> (webapp -> target: input vector, ONE message; // /nisps/input <f...f> (webapp -> target: input vector, ONE message;
// also target -> webapp for visualisation) // also target -> webapp for visualisation)
// /nisps/feedback <string> (webapp -> target: verdict op JSON — // /nisps/feedback <string> (webapp -> target: verdict op JSON —
@ -253,17 +251,9 @@ function handleWs(ws: WebSocket): void {
try { try {
const data = JSON.parse(e.data as string); 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) { if (data && typeof data === "object" && data.type) {
switch (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": case "input":
// Current input VECTOR as ONE multi-float message to /nisps/input // Current input VECTOR as ONE multi-float message to /nisps/input
// (browser drives the VCV module's inputs in bridged mode). // (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)); sendOSCString(`${OSC_PREFIX}/feedback`, JSON.stringify(data.payload));
return; return;
case "params": 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 (Array.isArray(data.payload)) {
if (USE_BUNDLES) { if (USE_BUNDLES) {
sendOSCBundle(data.payload); sendOSCBundle(data.payload);
@ -290,17 +280,6 @@ function handleWs(ws: WebSocket): void {
return; return;
} }
} }
// Legacy format: [[paramName, value], ...]
if (Array.isArray(data)) {
if (USE_BUNDLES) {
sendOSCBundle(data);
} else {
for (const [name, value] of data) {
sendOSC(`${OSC_PREFIX}/${name}`, value);
}
}
}
} catch (err) { } catch (err) {
console.error("[ws] Bad message:", (err as Error).message); console.error("[ws] Bad message:", (err as Error).message);
} }
@ -319,23 +298,18 @@ async function udpReceiveLoop(): Promise<void> {
const msg = parseOscMessage(data); const msg = parseOscMessage(data);
if (!msg) continue; if (!msg) continue;
// Relay parsed OSC messages to all connected WebSocket clients // Relay the module's output/input vectors to all connected WebSocket
const wsMsg: Record<string, unknown> = { type: "osc", address: msg.address }; // clients. Other addresses have no browser receiver — drop them.
let type: "outputs" | "inputs";
if (msg.address === `${OSC_PREFIX}/output` || msg.address === "/nisps/output") { if (msg.address === `${OSC_PREFIX}/output` || msg.address === "/nisps/output") {
// Float array of outputs type = "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") { } else if (msg.address === `${OSC_PREFIX}/input` || msg.address === "/nisps/input") {
// Float array of inputs type = "inputs";
wsMsg.type = "inputs";
wsMsg.values = msg.args.filter((a): a is number => typeof a === "number");
} else { } else {
// Generic OSC message continue;
wsMsg.args = msg.args;
} }
const values = msg.args.filter((a): a is number => typeof a === "number");
broadcastToWs(JSON.stringify(wsMsg)); broadcastToWs(JSON.stringify({ type, values }));
} }
} }
@ -365,9 +339,9 @@ NISPS <-> OSC Bridge (bidirectional)
Mode: ${USE_BUNDLES ? "bundles" : "individual messages"} Mode: ${USE_BUNDLES ? "bundles" : "individual messages"}
Webapp -> VCV: Webapp -> VCV:
params: [[name, value], ...] or { type: "params", payload: [...] } params: { type: "params", payload: [[name, value], ...] }
state: { type: "state", payload: <JSON> } input: { type: "input", payload: [f, ...] }
weights: { type: "weights", payload: <JSON> } feedback: { type: "feedback", payload: <op JSON> }
VCV -> Webapp: VCV -> Webapp:
/nisps/output <f...f> -> { type: "outputs", values: [...] } /nisps/output <f...f> -> { type: "outputs", values: [...] }

View file

@ -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). | | `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-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. | | `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. | | `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). | | `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. | | `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 cd manifold/osc-bridge
deno run --allow-net bridge.ts deno run --allow-net bridge.ts
# --osc-host 127.0.0.1 --osc-port 9000 --ws-port 8765 --listen-port 9001 # --osc-host 127.0.0.1 --osc-port 9000 --ws-port 8765 --listen-port 9001
# or, without Deno: # or, without Deno: use a compiled binary (compile.sh / the osc-bridge release)
node bridge.mjs
``` ```
Default bridge URL: `ws://localhost:8765` (configurable in the OSC config panel). 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 output[] }`), so the module's embedded net trains in lock-step with the
browser session. browser session.
It receives **`/nisps/output`** + **`/nisps/state`** back for status / It receives **`/nisps/output`** / **`/nisps/input`** back as proof the module is
visualisation. The verdict forwarding is gated in `BackendManager.forwardFeedback` alive. The verdict forwarding is gated in `BackendManager.forwardFeedback`
— a no-op unless VCV is the active backend (otherwise the browser engine is the — a no-op unless VCV is the active backend (otherwise the browser engine is the
learner). Both processes are required: learner). Both processes are required:

View file

@ -6,8 +6,8 @@
* *
* browser bridge: * browser bridge:
* { type: 'params', payload: [[name, value], ...] } (per-param floats) * { type: 'params', payload: [[name, value], ...] } (per-param floats)
* { type: 'state', payload: <JSON> } (full state) * { type: 'input', payload: [f, ...] } (input vector)
* { type: 'weights',payload: <JSON> } (weights only) * { type: 'feedback', payload: <op JSON> } (verdict op)
* bridge browser: * bridge browser:
* { type: 'outputs', values: [...] } * { type: 'outputs', values: [...] }
* { type: 'inputs', values: [...] } * { type: 'inputs', values: [...] }
@ -113,16 +113,6 @@ export class NispsOscClient {
this.send({ type: 'params', payload: params }); 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 * Send the current input VECTOR as ONE multi-float OSC message to
* `<prefix>/input` (the bridge `input` verb). Used in VCV bridged mode so the * `<prefix>/input` (the bridge `input` verb). Used in VCV bridged mode so the

View file

@ -18,9 +18,8 @@
* string { op, spread, input[], output[] } * string { op, spread, input[], output[] }
* *
* module browser * module browser
* /nisps/output <ff> module's live outputs (status / visualisation) * /nisps/output <ff> module's live outputs (proves the module is alive)
* /nisps/input <ff> module's live inputs (echo / status) * /nisps/input <ff> module's live inputs (echo same alive proof)
* /nisps/state <json> module status snapshot (surfaced as a message)
* *
* The bridge process AND the VCV module must both be running until the WS * 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 * 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 lastSendMs = 0;
private lastInputSent: [number, number] = [-1, -1]; 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 gotModuleReply = false;
private statusState: BackendStatus = { state: 'idle', message: 'VCV idle' }; private statusState: BackendStatus = { state: 'idle', message: 'VCV idle' };
private statusListeners = new Set<(s: BackendStatus) => void>(); private statusListeners = new Set<(s: BackendStatus) => void>();
private outputListeners = new Set<(v: number[]) => void>();
private offConn: (() => void) | null = null; private offConn: (() => void) | null = null;
private offInfo: (() => void) | null = null; private offInfo: (() => void) | null = null;
private offOutputs: (() => 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. // Module → browser: a reply on either channel proves the module is alive.
this.offOutputs = this.client.onOutputsReceived((v) => this.onModuleReply(v, true)); this.offOutputs = this.client.onOutputsReceived(() => this.onModuleReply());
this.offInputs = this.client.onInputsReceived((v) => this.onModuleReply(v, false)); this.offInputs = this.client.onInputsReceived(() => this.onModuleReply());
this.setStatus({ state: 'connecting', message: `Connecting to VCV bridge (${this.client.url})…` }); this.setStatus({ state: 'connecting', message: `Connecting to VCV bridge (${this.client.url})…` });
this.client.connect({ reconnect: true }).catch(() => { this.client.connect({ reconnect: true }).catch(() => {
@ -208,26 +204,11 @@ export class VcvBackend implements OutputBackend {
return spec?.bipolar ? v * 10 - 5 : v * 10; return spec?.bipolar ? v * 10 - 5 : v * 10;
} }
/** Latest module-reported output vector for visualisation (may be null). */ private onModuleReply(): void {
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 {
if (!this.gotModuleReply) { if (!this.gotModuleReply) {
this.gotModuleReply = true; this.gotModuleReply = true;
this.setStatus({ state: 'ready', message: `VCV module connected (${this.client.url})` }); 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<void> { async teardown(): Promise<void> {