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:
parent
43e066484b
commit
b4d3311b32
11 changed files with 1053 additions and 1 deletions
66
.github/workflows/osc-bridge.yml
vendored
Normal file
66
.github/workflows/osc-bridge.yml
vendored
Normal 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
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">▲</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/<Param_Name> <float 0–1></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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
118
playground/js/synth/osc-output.js
Normal file
118
playground/js/synth/osc-output.js
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
145
playground/osc-bridge/bridge.mjs
Normal file
145
playground/osc-bridge/bridge.mjs
Normal 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...
|
||||
`);
|
||||
194
playground/osc-bridge/bridge.ts
Normal file
194
playground/osc-bridge/bridge.ts
Normal 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...
|
||||
`);
|
||||
62
playground/osc-bridge/compile.sh
Executable file
62
playground/osc-bridge/compile.sh
Executable 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
|
||||
18
playground/osc-bridge/deno.lock
Normal file
18
playground/osc-bridge/deno.lock
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
112
playground/osc-bridge/test-receive.ts
Normal file
112
playground/osc-bridge/test-receive.ts
Normal 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"));
|
||||
}
|
||||
Loading…
Reference in a new issue