memlnaut-nisps/playground/osc-bridge/test-receive.ts
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

112 lines
3.6 KiB
TypeScript

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