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
This commit is contained in:
w1n5t0n 2026-03-25 11:55:58 +02:00
parent 43e066484b
commit b4d3311b32
11 changed files with 1053 additions and 1 deletions

66
.github/workflows/osc-bridge.yml vendored Normal file
View file

@ -0,0 +1,66 @@
name: Build OSC Bridge
on:
push:
tags: ['osc-bridge-v*']
workflow_dispatch:
permissions:
contents: write
jobs:
build:
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
suffix: linux-x86_64
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
suffix: linux-arm64
- os: macos-latest
target: x86_64-apple-darwin
suffix: macos-x86_64
- os: macos-latest
target: aarch64-apple-darwin
suffix: macos-arm64
- os: windows-latest
target: x86_64-pc-windows-msvc
suffix: windows-x86_64
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Compile
run: |
cd playground/osc-bridge
deno compile --allow-net --unstable-net --target ${{ matrix.target }} --output ../../dist/nisps-osc-bridge-${{ matrix.suffix }}${{ matrix.os == 'windows-latest' && '.exe' || '' }} bridge.ts
- uses: actions/upload-artifact@v4
with:
name: nisps-osc-bridge-${{ matrix.suffix }}
path: dist/nisps-osc-bridge-*
release:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: dist/*
generate_release_notes: true

4
.gitignore vendored
View file

@ -34,6 +34,10 @@
.ai
build
# OSC bridge compiled binaries (built via compile.sh or CI)
playground/osc-bridge/dist/
playground/osc-bridge/node_modules/
# Dolt database files (added by bd init)
.dolt/
*.db

View file

@ -117,6 +117,7 @@
<button class="float-btn" id="btn-randomize-bar" title="Randomize weights">Randomize</button>
<button class="float-btn" id="btn-clear-examples-bar" title="Clear examples">Clear Ex</button>
<button class="follow-pill" id="follow-pill" title="Toggle follow mode">Follow</button>
<button class="osc-pill" id="osc-pill" title="Connect OSC output">OSC</button>
<button class="chevron-btn" id="chevron-btn" title="Expand panel">&#9650;</button>
</div>
@ -189,6 +190,11 @@
<div class="synth-row" id="midi-status-row" style="display:none">
<span class="synth-status" id="midi-status"></span>
</div>
<div class="synth-row" id="osc-row">
<label>OSC Output</label>
<button class="action-btn" id="osc-toggle">Connect</button>
<span class="synth-status" id="osc-status">Off</span>
</div>
</div>
<!-- Loss plot -->
@ -303,6 +309,74 @@
</ul>
</div>
<div class="help-section">
<h3>OSC Output</h3>
<p>Send NISPS parameters to any OSC-capable software (SuperCollider, Max/MSP, Pure Data, TouchDesigner, Ableton, etc.) via a small bridge script that runs on your computer.</p>
<div class="help-osc-steps">
<p><strong>1. Download the bridge</strong></p>
<div class="help-download-row">
<button class="help-download-btn" id="osc-download-bin">Download for <span id="osc-platform-name">your OS</span></button>
<button class="help-download-btn help-download-alt" id="osc-download-src">Source (.ts)</button>
</div>
<p class="help-dim">Binary is standalone, no runtime needed. Source requires <a href="https://deno.land" target="_blank" rel="noopener">Deno</a> or <a href="https://nodejs.org" target="_blank" rel="noopener">Node.js</a>.</p>
<p><strong>2. Run</strong></p>
<pre class="help-code" id="osc-run-instructions">./nisps-osc-bridge</pre>
<p><strong>3. Connect</strong></p>
<p>In the synth panel below, click <strong>Connect</strong> next to "OSC Output". The status will show the target address once connected.</p>
</div>
<p style="margin-top:10px"><strong>Configuration</strong></p>
<table class="help-keys">
<tr><td><kbd>--osc-port 9000</kbd></td><td>Target port (default: 57120 / SuperCollider)</td></tr>
<tr><td><kbd>--osc-host 192.168.1.5</kbd></td><td>Target IP (default: 127.0.0.1)</td></tr>
<tr><td><kbd>--osc-prefix /my</kbd></td><td>Address prefix (default: /nisps)</td></tr>
<tr><td><kbd>--ws-port 8000</kbd></td><td>WebSocket listen port (default: 8765)</td></tr>
<tr><td><kbd>--bundle</kbd></td><td>Send OSC bundles instead of individual messages</td></tr>
</table>
<p style="margin-top:10px"><strong>OSC addresses</strong></p>
<p>Each parameter is sent as <code>/nisps/&lt;Param_Name&gt; &lt;float 0&ndash;1&gt;</code>. All 126 synth parameters are available. Values reflect your current preset, curves, and tame settings. Examples:</p>
<pre class="help-code">/nisps/Env_A_Att 0.35
/nisps/SVF_Flt_Cut 0.72
/nisps/Reverb_Mix 0.15</pre>
<details class="help-details">
<summary>SuperCollider example</summary>
<pre class="help-code">// Listen for all NISPS params
OSCdef(\nisps, { |msg, time|
msg.postln;
}, '/nisps/*');
// Map a specific param to a synth
OSCdef(\cutoff, { |msg|
~synth.set(\freq, msg[1].linexp(0, 1, 200, 8000));
}, '/nisps/SVF_Flt_Cut');</pre>
</details>
<details class="help-details">
<summary>Pure Data example</summary>
<pre class="help-code">[netreceive -u -b 57120]
|
[oscparse]
|
[route /nisps]
|
[route /Env_A_Att /SVF_Flt_Cut ...]</pre>
</details>
<details class="help-details">
<summary>Max/MSP example</summary>
<pre class="help-code">[udpreceive 57120]
|
[OSC-route /nisps/Env_A_Att]
|
[scale 0. 1. 200. 8000.]</pre>
</details>
</div>
<button class="help-got-it" id="help-got-it">Got it</button>
</div>
</div>

View file

@ -675,6 +675,29 @@ html, body {
color: var(--accent);
}
.osc-pill {
padding: 4px 12px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--text-dim);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.osc-pill.connected {
background: rgba(0, 200, 120, 0.15);
border-color: rgba(0, 200, 120, 0.4);
color: #00c878;
}
.osc-pill.connecting {
background: rgba(255, 200, 0, 0.1);
border-color: rgba(255, 200, 0, 0.3);
color: #ffc800;
}
.chevron-btn {
width: 32px;
height: 32px;
@ -1605,6 +1628,118 @@ html, body {
background: rgba(255, 106, 0, 0.25);
}
/* Help OSC section */
.help-osc-steps {
margin: 8px 0;
}
.help-osc-steps p {
margin: 10px 0 4px;
font-size: 12px;
}
.help-download-row {
display: flex;
gap: 8px;
margin: 4px 0;
flex-wrap: wrap;
}
.help-download-btn {
display: inline-block;
padding: 8px 20px;
border-radius: 6px;
border: 1px solid rgba(255, 106, 0, 0.4);
background: rgba(255, 106, 0, 0.12);
color: var(--accent);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.help-download-alt {
background: rgba(255, 255, 255, 0.04);
border-color: var(--glass-border);
color: var(--text-dim);
font-weight: 500;
}
.help-download-alt:hover {
background: rgba(255, 255, 255, 0.08);
border-color: var(--text-dim);
color: var(--text);
}
.help-download-btn:hover {
background: rgba(255, 106, 0, 0.25);
border-color: var(--accent);
}
.help-download-btn:active {
background: rgba(255, 106, 0, 0.35);
}
.help-dim {
font-size: 11px !important;
color: var(--text-dim) !important;
margin: 4px 0 8px !important;
}
.help-dim a {
color: var(--accent);
text-decoration: none;
}
.help-dim a:hover {
text-decoration: underline;
}
.help-code {
display: block;
padding: 8px 10px;
margin: 4px 0 8px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.06);
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 11px;
line-height: 1.5;
color: var(--text);
white-space: pre;
overflow-x: auto;
}
.help-section code {
padding: 1px 5px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.06);
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 11px;
}
.help-details {
margin: 6px 0;
}
.help-details summary {
font-size: 12px;
font-weight: 500;
color: var(--text);
cursor: pointer;
padding: 4px 0;
transition: color 0.15s;
}
.help-details summary:hover {
color: var(--accent);
}
.help-details[open] summary {
color: var(--accent);
margin-bottom: 2px;
}
/* Help modal scrollbar */
.help-modal::-webkit-scrollbar {
width: 4px;

View file

@ -30,6 +30,7 @@ let shapeSeq = null;
let stepViz = null;
let chainUI = null;
import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyCurve, applyGroupOverride } from './synth/param-map.js';
import { OSCOutput } from './synth/osc-output.js';
import { GamepadInput } from './ui/gamepad.js';
import { HandTracker } from './ui/hand-tracker.js';
import { createDevPanel } from './ui/dev-panel.js';
@ -96,6 +97,7 @@ let handTracker = null;
let visualizer;
let synthVisualizer;
let c15 = null;
let oscOutput = null;
let arpeggiator = null;
let midiInput = null;
@ -730,6 +732,22 @@ async function init() {
midiInput = new MIDIInput(c15);
initMIDIControls();
// OSC output
oscOutput = new OSCOutput();
oscOutput.onStatusChange = (msg, connected) => {
const el = document.getElementById('osc-status');
const btn = document.getElementById('osc-toggle');
const pill = document.getElementById('osc-pill');
if (el) el.textContent = msg;
if (btn) btn.textContent = connected ? 'Disconnect' : 'Connect';
if (pill) {
pill.classList.toggle('connected', connected);
pill.classList.toggle('connecting', oscOutput.enabled && !connected);
pill.title = connected ? `OSC connected: ${msg}` : 'Connect OSC output';
}
};
wireOSCControls();
// DOM refs
$heatmapCells = document.getElementById('heatmap-cells');
$heatmapTooltip = document.getElementById('heatmap-tooltip');
@ -1071,8 +1089,10 @@ let _lastParamSendTime = 0;
const PARAM_SEND_INTERVAL = 50; // max ~20fps for synth param updates
function routeOutputs(outputs) {
let overridden = null;
if (outputMode === 'synth') {
const overridden = new Array(outputs.length);
overridden = new Array(outputs.length);
for (let i = 0; i < outputs.length; i++) {
overridden[i] = applyGroupOverrides(outputs[i], i);
}
@ -1096,6 +1116,12 @@ function routeOutputs(outputs) {
} else {
visualizer.setParams(outputs.slice(0, N_VISUAL_OUTPUTS));
}
// OSC output — sends in both modes (has its own throttle + dead-zone).
// In synth mode sends post-override values; in visual mode sends raw outputs.
if (oscOutput) {
oscOutput.sendParams(overridden || outputs);
}
}
// ---- Bottom sheet ----
@ -1702,6 +1728,22 @@ function wireSynthControls() {
});
}
// ---- OSC Output ----
function toggleOSC() {
if (oscOutput.connected || oscOutput.enabled) {
oscOutput.disconnect();
} else {
oscOutput.connect();
}
}
function wireOSCControls() {
const toggle = document.getElementById('osc-toggle');
const pill = document.getElementById('osc-pill');
if (toggle) toggle.addEventListener('click', toggleOSC);
if (pill) pill.addEventListener('click', toggleOSC);
}
// ---- MIDI Input ----
async function initMIDIControls() {
const row = document.getElementById('midi-row');
@ -2226,6 +2268,81 @@ function onResize() {
}
}
// ---- OSC bridge download ----
// GitHub releases URL — update this when the repo is set up
const OSC_RELEASE_BASE = 'https://github.com/MusicallyEmbodiedML/MEMLNaut-NISPS/releases/latest/download';
function detectPlatform() {
const ua = navigator.userAgent.toLowerCase();
const platform = navigator.platform?.toLowerCase() || '';
if (ua.includes('win')) return { os: 'windows', arch: 'x86_64', label: 'Windows', ext: '.exe' };
if (ua.includes('mac') || platform.includes('mac')) {
// Check for Apple Silicon via WebGL renderer or default to ARM (most modern Macs)
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl');
const renderer = gl?.getParameter(gl.RENDERER)?.toLowerCase() || '';
const isIntel = renderer.includes('intel') || ua.includes('intel');
return isIntel
? { os: 'macos', arch: 'x86_64', label: 'macOS (Intel)', ext: '' }
: { os: 'macos', arch: 'arm64', label: 'macOS (Apple Silicon)', ext: '' };
}
if (ua.includes('linux')) {
const isArm = platform.includes('arm') || platform.includes('aarch');
return isArm
? { os: 'linux', arch: 'arm64', label: 'Linux (ARM)', ext: '' }
: { os: 'linux', arch: 'x86_64', label: 'Linux', ext: '' };
}
return { os: 'linux', arch: 'x86_64', label: 'Linux', ext: '' };
}
function downloadOSCBinary() {
const p = detectPlatform();
const filename = `nisps-osc-bridge-${p.os}-${p.arch}${p.ext}`;
const url = `${OSC_RELEASE_BASE}/${filename}`;
window.open(url, '_blank');
}
function downloadOSCSource() {
// Download bridge.ts directly — it's self-contained with Deno
fetch('osc-bridge/bridge.ts').then(r => r.blob()).then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'nisps-osc-bridge.ts';
a.click();
URL.revokeObjectURL(url);
}).catch(() => {
alert('Failed to download. Make sure the server is serving the osc-bridge/ directory.');
});
}
function initOSCDownloadUI() {
const p = detectPlatform();
// Set platform name in button
const platformName = document.getElementById('osc-platform-name');
if (platformName) platformName.textContent = p.label;
// Set run instructions based on OS
const instructions = document.getElementById('osc-run-instructions');
if (instructions) {
if (p.os === 'windows') {
instructions.textContent = 'nisps-osc-bridge-windows-x86_64.exe';
} else if (p.os === 'macos') {
instructions.textContent =
`# First time only: allow the binary to run\n` +
`chmod +x nisps-osc-bridge-macos-${p.arch}\n` +
`xattr -d com.apple.quarantine nisps-osc-bridge-macos-${p.arch}\n\n` +
`./nisps-osc-bridge-macos-${p.arch}`;
} else {
instructions.textContent =
`chmod +x nisps-osc-bridge-linux-${p.arch}\n` +
`./nisps-osc-bridge-linux-${p.arch}`;
}
}
}
// ---- Help modal ----
function wireHelp() {
const overlay = document.getElementById('help-overlay');
@ -2241,6 +2358,13 @@ function wireHelp() {
btnGotIt.addEventListener('click', hide);
overlay.addEventListener('click', (e) => { if (e.target === overlay) hide(); });
// OSC bridge download buttons
initOSCDownloadUI();
const oscDlBin = document.getElementById('osc-download-bin');
const oscDlSrc = document.getElementById('osc-download-src');
if (oscDlBin) oscDlBin.addEventListener('click', (e) => { e.stopPropagation(); downloadOSCBinary(); });
if (oscDlSrc) oscDlSrc.addEventListener('click', (e) => { e.stopPropagation(); downloadOSCSource(); });
// Show on first visit
if (!localStorage.getItem('nisps-help-seen')) show();
}

View file

@ -0,0 +1,118 @@
// OSC output via WebSocket bridge
// Sends NISPS parameter values to a local WebSocket→OSC bridge script.
// Same throttle/dead-zone pattern as C15 ring buffer output.
import { SYNTH_PARAM_MAP } from './param-map.js';
const RECONNECT_INTERVAL = 3000;
const SEND_INTERVAL = 50; // ~20fps max
const DEAD_ZONE = 0.002; // ~0.2% change threshold
export class OSCOutput {
constructor(url = 'ws://localhost:8765') {
this._url = url;
this._ws = null;
this._connected = false;
this._reconnectTimer = null;
this._lastSent = new Float32Array(SYNTH_PARAM_MAP.length);
this._lastSendTime = 0;
this._onStatusChange = null;
this._enabled = false;
}
set onStatusChange(fn) { this._onStatusChange = fn; }
get connected() { return this._connected; }
get enabled() { return this._enabled; }
_status(msg) {
console.log('[OSC]', msg);
this._onStatusChange?.(msg, this._connected);
}
connect() {
this._enabled = true;
this._tryConnect();
}
disconnect() {
this._enabled = false;
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
if (this._ws) {
this._ws.close();
this._ws = null;
}
this._connected = false;
this._status('Disconnected');
}
_tryConnect() {
if (!this._enabled) return;
if (this._ws) {
this._ws.close();
this._ws = null;
}
try {
this._ws = new WebSocket(this._url);
} catch (e) {
this._scheduleReconnect();
return;
}
this._ws.onopen = () => {
this._connected = true;
this._lastSent.fill(0);
this._status('Connected');
};
this._ws.onclose = () => {
this._connected = false;
this._status('Disconnected');
this._scheduleReconnect();
};
this._ws.onerror = () => {
// onclose will fire after this
};
this._ws.onmessage = (e) => {
// Bridge can send back info (e.g. target confirmation)
try {
const msg = JSON.parse(e.data);
if (msg.type === 'info') this._status(msg.message);
} catch {}
};
}
_scheduleReconnect() {
if (!this._enabled || this._reconnectTimer) return;
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
this._tryConnect();
}, RECONNECT_INTERVAL);
}
/** Send parameter values — same signature/timing as C15 output path */
sendParams(overriddenValues) {
if (!this._connected || !this._ws || this._ws.readyState !== WebSocket.OPEN) return;
const now = performance.now();
if (now - this._lastSendTime < SEND_INTERVAL) return;
this._lastSendTime = now;
// Build batch of changed params
const batch = [];
for (let i = 0; i < overriddenValues.length && i < SYNTH_PARAM_MAP.length; i++) {
const v = overriddenValues[i];
if (Math.abs(v - this._lastSent[i]) > DEAD_ZONE) {
batch.push([SYNTH_PARAM_MAP[i].name, v]);
this._lastSent[i] = v;
}
}
if (batch.length > 0) {
this._ws.send(JSON.stringify(batch));
}
}
}

View file

@ -0,0 +1,145 @@
#!/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.
//
// Usage:
// node bridge.mjs # defaults: ws:8765, osc:127.0.0.1:57120
// 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 --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
//
// OSC address format:
// /nisps/<param_name> <float>
// e.g. /nisps/Env_A_Att 0.35
// /nisps/SVF_Cutoff 0.72
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', '57120'), 10);
const OSC_PREFIX = flag('osc-prefix', '/nisps');
const USE_BUNDLES = hasFlag('bundle');
// ---- OSC encoding (minimal, no dependencies) ----
function oscString(str) {
const len = str.length + 1; // null terminator
const padded = len + (4 - (len % 4)) % 4;
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),
]);
}
function oscBundle(messages) {
const header = oscString('#bundle');
// NTP timestamp: immediately (1 in upper 32 bits)
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);
}
// ---- UDP socket ----
const udp = createSocket('udp4');
function sendOSC(address, value) {
const msg = oscMessage(address, value);
udp.send(msg, OSC_PORT, OSC_HOST);
}
function sendOSCBundle(params) {
const messages = params.map(([name, value]) =>
oscMessage(`${OSC_PREFIX}/${name}`, value)
);
const bundle = oscBundle(messages);
udp.send(bundle, OSC_PORT, OSC_HOST);
}
// ---- WebSocket server ----
const wss = new WebSocketServer({ port: WS_PORT });
let clientCount = 0;
wss.on('connection', (ws) => {
clientCount++;
console.log(`[ws] Client connected (${clientCount} total)`);
// Tell the browser what we're targeting
ws.send(JSON.stringify({
type: 'info',
message: `OSC → ${OSC_HOST}:${OSC_PORT} (prefix: ${OSC_PREFIX})`,
}));
ws.on('message', (data) => {
try {
// Expects: [[paramName, value], ...]
const batch = JSON.parse(data);
if (!Array.isArray(batch)) return;
if (USE_BUNDLES) {
sendOSCBundle(batch);
} else {
for (const [name, value] of batch) {
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)`);
});
});
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_Cut 0.72
Waiting for browser connection...
`);

View file

@ -0,0 +1,194 @@
#!/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...
`);

View file

@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
# Compile NISPS OSC Bridge for all platforms
# Requires: deno 2.x
# Outputs go to dist/
#
# Note: macOS cross-compilation from Linux has a known Deno bug.
# macOS binaries must be built on macOS (or via GitHub Actions CI).
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DIST="$SCRIPT_DIR/dist"
SRC="$SCRIPT_DIR/bridge.ts"
NAME="nisps-osc-bridge"
mkdir -p "$DIST"
# Detect host OS for cross-compile compatibility
HOST_OS="$(uname -s)"
TARGETS=(
"x86_64-unknown-linux-gnu:linux-x86_64"
"aarch64-unknown-linux-gnu:linux-arm64"
"x86_64-apple-darwin:macos-x86_64"
"aarch64-apple-darwin:macos-arm64"
"x86_64-pc-windows-msvc:windows-x86_64"
)
FAILED=()
for entry in "${TARGETS[@]}"; do
target="${entry%%:*}"
suffix="${entry##*:}"
outname="$NAME-$suffix"
if [[ "$target" == *windows* ]]; then
outname="$outname.exe"
fi
echo "Compiling $outname ($target)..."
if deno compile \
--allow-net \
--unstable-net \
--target "$target" \
--output "$DIST/$outname" \
"$SRC" 2>&1; then
echo " OK"
else
echo " FAILED (skipping — cross-compile to this target may not work on $HOST_OS)"
FAILED+=("$outname")
fi
echo ""
done
echo "Binaries in $DIST/:"
ls -lh "$DIST/" 2>/dev/null || echo " (none)"
if [[ ${#FAILED[@]} -gt 0 ]]; then
echo ""
echo "Failed targets: ${FAILED[*]}"
echo "These may need to be built natively or via CI."
fi

View file

@ -0,0 +1,18 @@
{
"version": "5",
"specifiers": {
"jsr:@std/cli@1": "1.0.28"
},
"jsr": {
"@std/cli@1.0.28": {
"integrity": "74ef9b976db59ca6b23a5283469c9072be6276853807a83ec6c7ce412135c70a"
}
},
"workspace": {
"packageJson": {
"dependencies": [
"npm:ws@^8.18.0"
]
}
}
}

View file

@ -0,0 +1,112 @@
#!/usr/bin/env -S deno run --allow-net --unstable-net
// Quick OSC receiver — prints incoming NISPS parameters to the terminal.
// Usage: deno run --allow-net test-receive.ts [--port 57120]
import { parseArgs } from "jsr:@std/cli@1/parse-args";
const args = parseArgs(Deno.args, {
string: ["port"],
default: { port: "57120" },
});
const port = parseInt(args.port);
const udp = Deno.listenDatagram({ port, transport: "udp", hostname: "0.0.0.0" });
console.log(`Listening for OSC on UDP port ${port}...\n`);
// Minimal OSC parser
function readOscString(buf: Uint8Array, offset: number): [string, number] {
let end = offset;
while (end < buf.length && buf[end] !== 0) end++;
const str = new TextDecoder().decode(buf.slice(offset, end));
// Advance past null + padding to 4-byte boundary
const padded = end + 1;
return [str, padded + (4 - (padded % 4)) % 4];
}
function parseOscMessage(buf: Uint8Array): { address: string; args: number[] } | null {
if (buf.length < 8) return null;
const [address, typeOffset] = readOscString(buf, 0);
if (buf[typeOffset] !== 0x2C) return null; // ','
const [typeTag, dataOffset] = readOscString(buf, typeOffset);
const oscArgs: number[] = [];
let pos = dataOffset;
for (let i = 1; i < typeTag.length; i++) {
if (typeTag[i] === "f") {
const view = new DataView(buf.buffer, buf.byteOffset + pos, 4);
oscArgs.push(view.getFloat32(0, false));
pos += 4;
} else if (typeTag[i] === "i") {
const view = new DataView(buf.buffer, buf.byteOffset + pos, 4);
oscArgs.push(view.getInt32(0, false));
pos += 4;
}
}
return { address, args: oscArgs };
}
function parseOscBundle(buf: Uint8Array): { address: string; args: number[] }[] {
// Check for #bundle header
const header = new TextDecoder().decode(buf.slice(0, 7));
if (header !== "#bundle") return [];
const messages: { address: string; args: number[] }[] = [];
let pos = 16; // skip header (8) + timetag (8)
while (pos + 4 < buf.length) {
const size = new DataView(buf.buffer, buf.byteOffset + pos, 4).getUint32(0, false);
pos += 4;
if (pos + size > buf.length) break;
const msg = parseOscMessage(buf.slice(pos, pos + size));
if (msg) messages.push(msg);
pos += size;
}
return messages;
}
// Bar chart helper
function bar(value: number, width = 30): string {
const filled = Math.round(value * width);
return "\x1b[36m" + "█".repeat(filled) + "\x1b[90m" + "░".repeat(width - filled) + "\x1b[0m";
}
let msgCount = 0;
let lastPrint = 0;
const latest = new Map<string, number>();
for await (const [data] of udp) {
const buf = new Uint8Array(data);
let messages: { address: string; args: number[] }[];
// Try bundle first, fall back to single message
messages = parseOscBundle(buf);
if (messages.length === 0) {
const single = parseOscMessage(buf);
if (single) messages = [single];
}
for (const msg of messages) {
msgCount++;
const name = msg.address.replace(/^\/nisps\//, "");
latest.set(name, msg.args[0]);
}
// Throttle display to ~10fps
const now = Date.now();
if (now - lastPrint < 100) continue;
lastPrint = now;
// Clear and redraw
const lines: string[] = [];
lines.push(`\x1b[2J\x1b[H\x1b[1mNISPS OSC Receiver\x1b[0m (${msgCount} messages received)\n`);
const sorted = [...latest.entries()].sort((a, b) => a[0].localeCompare(b[0]));
for (const [name, value] of sorted) {
const v = value.toFixed(3).padStart(5);
lines.push(` ${name.padEnd(24)} ${v} ${bar(value)}`);
}
lines.push(`\n\x1b[90mCtrl+C to quit\x1b[0m`);
console.log(lines.join("\n"));
}