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
|
.ai
|
||||||
build
|
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 database files (added by bd init)
|
||||||
.dolt/
|
.dolt/
|
||||||
*.db
|
*.db
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@
|
||||||
<button class="float-btn" id="btn-randomize-bar" title="Randomize weights">Randomize</button>
|
<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="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="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>
|
<button class="chevron-btn" id="chevron-btn" title="Expand panel">▲</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -189,6 +190,11 @@
|
||||||
<div class="synth-row" id="midi-status-row" style="display:none">
|
<div class="synth-row" id="midi-status-row" style="display:none">
|
||||||
<span class="synth-status" id="midi-status"></span>
|
<span class="synth-status" id="midi-status"></span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Loss plot -->
|
<!-- Loss plot -->
|
||||||
|
|
@ -303,6 +309,74 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
<button class="help-got-it" id="help-got-it">Got it</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -675,6 +675,29 @@ html, body {
|
||||||
color: var(--accent);
|
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 {
|
.chevron-btn {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
|
@ -1605,6 +1628,118 @@ html, body {
|
||||||
background: rgba(255, 106, 0, 0.25);
|
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 scrollbar */
|
||||||
.help-modal::-webkit-scrollbar {
|
.help-modal::-webkit-scrollbar {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ let shapeSeq = null;
|
||||||
let stepViz = null;
|
let stepViz = null;
|
||||||
let chainUI = null;
|
let chainUI = null;
|
||||||
import { SYNTH_PARAM_MAP, SYNTH_PARAM_NAMES, SYNTH_PARAM_COLORS, applyCurve, applyGroupOverride } from './synth/param-map.js';
|
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 { GamepadInput } from './ui/gamepad.js';
|
||||||
import { HandTracker } from './ui/hand-tracker.js';
|
import { HandTracker } from './ui/hand-tracker.js';
|
||||||
import { createDevPanel } from './ui/dev-panel.js';
|
import { createDevPanel } from './ui/dev-panel.js';
|
||||||
|
|
@ -96,6 +97,7 @@ let handTracker = null;
|
||||||
let visualizer;
|
let visualizer;
|
||||||
let synthVisualizer;
|
let synthVisualizer;
|
||||||
let c15 = null;
|
let c15 = null;
|
||||||
|
let oscOutput = null;
|
||||||
let arpeggiator = null;
|
let arpeggiator = null;
|
||||||
let midiInput = null;
|
let midiInput = null;
|
||||||
|
|
||||||
|
|
@ -730,6 +732,22 @@ async function init() {
|
||||||
midiInput = new MIDIInput(c15);
|
midiInput = new MIDIInput(c15);
|
||||||
initMIDIControls();
|
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
|
// DOM refs
|
||||||
$heatmapCells = document.getElementById('heatmap-cells');
|
$heatmapCells = document.getElementById('heatmap-cells');
|
||||||
$heatmapTooltip = document.getElementById('heatmap-tooltip');
|
$heatmapTooltip = document.getElementById('heatmap-tooltip');
|
||||||
|
|
@ -1071,8 +1089,10 @@ let _lastParamSendTime = 0;
|
||||||
const PARAM_SEND_INTERVAL = 50; // max ~20fps for synth param updates
|
const PARAM_SEND_INTERVAL = 50; // max ~20fps for synth param updates
|
||||||
|
|
||||||
function routeOutputs(outputs) {
|
function routeOutputs(outputs) {
|
||||||
|
let overridden = null;
|
||||||
|
|
||||||
if (outputMode === 'synth') {
|
if (outputMode === 'synth') {
|
||||||
const overridden = new Array(outputs.length);
|
overridden = new Array(outputs.length);
|
||||||
for (let i = 0; i < outputs.length; i++) {
|
for (let i = 0; i < outputs.length; i++) {
|
||||||
overridden[i] = applyGroupOverrides(outputs[i], i);
|
overridden[i] = applyGroupOverrides(outputs[i], i);
|
||||||
}
|
}
|
||||||
|
|
@ -1096,6 +1116,12 @@ function routeOutputs(outputs) {
|
||||||
} else {
|
} else {
|
||||||
visualizer.setParams(outputs.slice(0, N_VISUAL_OUTPUTS));
|
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 ----
|
// ---- 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 ----
|
// ---- MIDI Input ----
|
||||||
async function initMIDIControls() {
|
async function initMIDIControls() {
|
||||||
const row = document.getElementById('midi-row');
|
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 ----
|
// ---- Help modal ----
|
||||||
function wireHelp() {
|
function wireHelp() {
|
||||||
const overlay = document.getElementById('help-overlay');
|
const overlay = document.getElementById('help-overlay');
|
||||||
|
|
@ -2241,6 +2358,13 @@ function wireHelp() {
|
||||||
btnGotIt.addEventListener('click', hide);
|
btnGotIt.addEventListener('click', hide);
|
||||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) 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
|
// Show on first visit
|
||||||
if (!localStorage.getItem('nisps-help-seen')) show();
|
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