diff --git a/.github/workflows/osc-bridge.yml b/.github/workflows/osc-bridge.yml new file mode 100644 index 0000000..f22937f --- /dev/null +++ b/.github/workflows/osc-bridge.yml @@ -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 diff --git a/.gitignore b/.gitignore index 629b1e8..ce0f284 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/playground/a-immersive.html b/playground/a-immersive.html index ba70a7d..5f7056a 100644 --- a/playground/a-immersive.html +++ b/playground/a-immersive.html @@ -117,6 +117,7 @@ + @@ -189,6 +190,11 @@ +
+ + + Off +
@@ -303,6 +309,74 @@ +
+

OSC Output

+

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.

+ +
+

1. Download the bridge

+
+ + +
+

Binary is standalone, no runtime needed. Source requires Deno or Node.js.

+ +

2. Run

+
./nisps-osc-bridge
+ +

3. Connect

+

In the synth panel below, click Connect next to "OSC Output". The status will show the target address once connected.

+
+ +

Configuration

+ + + + + + +
--osc-port 9000Target port (default: 57120 / SuperCollider)
--osc-host 192.168.1.5Target IP (default: 127.0.0.1)
--osc-prefix /myAddress prefix (default: /nisps)
--ws-port 8000WebSocket listen port (default: 8765)
--bundleSend OSC bundles instead of individual messages
+ +

OSC addresses

+

Each parameter is sent as /nisps/<Param_Name> <float 0–1>. All 126 synth parameters are available. Values reflect your current preset, curves, and tame settings. Examples:

+
/nisps/Env_A_Att 0.35
+/nisps/SVF_Flt_Cut 0.72
+/nisps/Reverb_Mix 0.15
+ +
+ SuperCollider example +
// 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');
+
+ +
+ Pure Data example +
[netreceive -u -b 57120]
+|
+[oscparse]
+|
+[route /nisps]
+|
+[route /Env_A_Att /SVF_Flt_Cut ...]
+
+ +
+ Max/MSP example +
[udpreceive 57120]
+|
+[OSC-route /nisps/Env_A_Att]
+|
+[scale 0. 1. 200. 8000.]
+
+
+ diff --git a/playground/css/a-immersive.css b/playground/css/a-immersive.css index fa6d47e..83434ac 100644 --- a/playground/css/a-immersive.css +++ b/playground/css/a-immersive.css @@ -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; diff --git a/playground/js/a-app.js b/playground/js/a-app.js index ba399d4..7b4e2ca 100644 --- a/playground/js/a-app.js +++ b/playground/js/a-app.js @@ -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(); } diff --git a/playground/js/synth/osc-output.js b/playground/js/synth/osc-output.js new file mode 100644 index 0000000..42f6a19 --- /dev/null +++ b/playground/js/synth/osc-output.js @@ -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)); + } + } +} diff --git a/playground/osc-bridge/bridge.mjs b/playground/osc-bridge/bridge.mjs new file mode 100644 index 0000000..0e9904a --- /dev/null +++ b/playground/osc-bridge/bridge.mjs @@ -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/ +// 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}/ + e.g. ${OSC_PREFIX}/Env_A_Att 0.35 + ${OSC_PREFIX}/SVF_Cut 0.72 + + Waiting for browser connection... +`); diff --git a/playground/osc-bridge/bridge.ts b/playground/osc-bridge/bridge.ts new file mode 100644 index 0000000..3ef803e --- /dev/null +++ b/playground/osc-bridge/bridge.ts @@ -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/ +// 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 Target IP address (default: 127.0.0.1) + --osc-port Target UDP port (default: 57120) + --osc-prefix OSC address prefix (default: /nisps) + --ws-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}/ + e.g. ${OSC_PREFIX}/Env_A_Att 0.35 + ${OSC_PREFIX}/SVF_Flt_Cut 0.72 + + Waiting for browser connection... +`); diff --git a/playground/osc-bridge/compile.sh b/playground/osc-bridge/compile.sh new file mode 100755 index 0000000..3c37b97 --- /dev/null +++ b/playground/osc-bridge/compile.sh @@ -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 diff --git a/playground/osc-bridge/deno.lock b/playground/osc-bridge/deno.lock new file mode 100644 index 0000000..5275890 --- /dev/null +++ b/playground/osc-bridge/deno.lock @@ -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" + ] + } + } +} diff --git a/playground/osc-bridge/test-receive.ts b/playground/osc-bridge/test-receive.ts new file mode 100644 index 0000000..7d47cb7 --- /dev/null +++ b/playground/osc-bridge/test-receive.ts @@ -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(); + +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")); +}