memlnaut-nisps/playground/js/synth/osc-output.js
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

118 lines
3 KiB
JavaScript

// 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));
}
}
}