memlnaut-nisps/playground/osc-bridge/bridge.ts
w1n5t0n b4d3311b32 feat(playground): add OSC output bridge for external synth control
Browser-side OSCOutput module sends parameter values over WebSocket to
a companion Deno bridge script that converts them to OSC/UDP messages.
Enables controlling SuperCollider, Max/MSP, Pure Data, TouchDesigner,
or any OSC-capable software from the NISPS playground.

- Browser module (js/synth/osc-output.js): WebSocket client with
  auto-reconnect, throttle (~20fps), and dead-zone filtering
- Deno bridge (osc-bridge/bridge.ts): zero-dependency, compiles to
  standalone binaries via deno compile for Linux/macOS/Windows
- Test receiver (osc-bridge/test-receive.ts): terminal dashboard
  showing live OSC parameter values with bar charts
- OSC pill button in floating bar for quick connect/disconnect
- Help modal section with platform-aware download, setup guide,
  and examples for SuperCollider/PD/Max
- GitHub Actions workflow for cross-platform binary builds
- OSC sends in both visual and synth modes
2026-03-25 11:55:58 +02:00

194 lines
5.5 KiB
TypeScript

#!/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.
//
// 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-prefix /my Address prefix (default: /nisps)
// --ws-port 8000 WebSocket listen port (default: 8765)
// --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
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"],
boolean: ["bundle", "help"],
default: {
"osc-host": "127.0.0.1",
"osc-port": "57120",
"osc-prefix": "/nisps",
"ws-port": "8765",
"bundle": false,
"help": false,
},
});
if (args.help) {
console.log(`
NISPS → OSC Bridge
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-prefix <pfx> OSC address prefix (default: /nisps)
--ws-port <port> WebSocket listen port (default: 8765)
--bundle Send OSC bundles instead of individual messages
--help Show this help
`);
Deno.exit(0);
}
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 USE_BUNDLES = args.bundle;
// ---- OSC encoding (zero dependencies) ----
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 buf = new Uint8Array(padded);
buf.set(strBytes);
return buf;
}
function oscFloat(val: number): Uint8Array {
const buf = new ArrayBuffer(4);
new DataView(buf).setFloat32(0, val, false); // big-endian
return new Uint8Array(buf);
}
function concat(...arrays: Uint8Array[]): Uint8Array {
const total = arrays.reduce((s, a) => s + a.length, 0);
const result = new Uint8Array(total);
let offset = 0;
for (const a of arrays) {
result.set(a, offset);
offset += a.length;
}
return result;
}
function oscMessage(address: string, value: number): Uint8Array {
return concat(oscString(address), oscString(",f"), oscFloat(value));
}
function u32be(val: number): Uint8Array {
const buf = new ArrayBuffer(4);
new DataView(buf).setUint32(0, val, false);
return new Uint8Array(buf);
}
function oscBundle(messages: Uint8Array[]): Uint8Array {
const header = oscString("#bundle");
// NTP timestamp: immediately (1 in upper 32 bits)
const timetag = new Uint8Array(8);
new DataView(timetag.buffer).setUint32(0, 1, false);
const parts: Uint8Array[] = [header, timetag];
for (const msg of messages) {
parts.push(u32be(msg.length), msg);
}
return concat(...parts);
}
// ---- UDP socket ----
const udp = 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);
}
function sendOSCBundle(params: [string, number][]): void {
const messages = params.map(([name, value]) =>
oscMessage(`${OSC_PREFIX}/${name}`, value)
);
const bundle = oscBundle(messages);
udp.send(bundle, oscAddr);
}
// ---- WebSocket server (Deno built-in) ----
let clientCount = 0;
function handleWs(ws: WebSocket): void {
clientCount++;
console.log(`[ws] Client connected (${clientCount} total)`);
ws.onopen = () => {
ws.send(JSON.stringify({
type: "info",
message: `OSC → ${OSC_HOST}:${OSC_PORT} (prefix: ${OSC_PREFIX})`,
}));
};
ws.onmessage = (e) => {
try {
const batch = JSON.parse(e.data as string);
if (!Array.isArray(batch)) return;
if (USE_BUNDLES) {
sendOSCBundle(batch);
} else {
for (const [name, value] of batch) {
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)`);
};
}
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 });
}
const { socket, response } = Deno.upgradeWebSocket(req);
handleWs(socket);
return response;
});
console.log(`
NISPS → OSC Bridge
──────────────────
WebSocket: ws://localhost:${WS_PORT}
OSC target: ${OSC_HOST}:${OSC_PORT}
Prefix: ${OSC_PREFIX}
Mode: ${USE_BUNDLES ? "bundles" : "individual messages"}
OSC addresses: ${OSC_PREFIX}/<param_name> <float>
e.g. ${OSC_PREFIX}/Env_A_Att 0.35
${OSC_PREFIX}/SVF_Flt_Cut 0.72
Waiting for browser connection...
`);