Files
companion-module-digico-s31/tools/osc-lite.js
T

285 lines
9.3 KiB
JavaScript
Raw Normal View History

'use strict';
/**
* osc-lite.js - a deliberately tiny OSC 1.0 encoder/decoder (no dependencies).
*
* Supported type tags: i (int32), f (float32), s (string), T, F, N.
* T/F are represented as JS booleans, N as null.
*
* Exports:
* encode(address, args) -> Buffer
* decode(buffer) -> { address, args, types, unpadded } | { raw }
* formatArgs(args, types)-> printable "[a, b]" string for log lines
* typeTags(args) -> type-tag string (no comma) encode() would use, e.g. "ff"
* int(n), float(n) -> explicitly typed argument wrappers for encode()
*
* Encoding arguments
* A plain JS value is typed automatically:
* boolean -> T/F, string -> s, null/undefined -> N,
* integer number (int32 range) -> i, any other number -> f.
* Because JS cannot tell 1 from 1.0, use float(1) when a float must go on
* the wire even though its value happens to be whole (e.g. a fader at -10 dB).
*
* Decoding is tolerant and never throws:
* - A well-formed (4-byte padded) message decodes with unpadded:false.
* - A message whose strings are NUL-terminated but NOT padded to 4 bytes, or
* whose last string is missing its final padding, still decodes, with
* unpadded:true.
* - Anything else (not an OSC message, truncated numbers, bundles, tags we do
* not support) comes back as { raw: '<hex>' }.
*/
const INT32_MIN = -2147483648;
const INT32_MAX = 2147483647;
/** Round n up to the next multiple of 4. */
const pad4 = (n) => (n + 3) & ~3;
/** Explicitly typed argument wrappers for encode(). */
const int = (value) => ({ type: 'i', value });
const float = (value) => ({ type: 'f', value });
const isTyped = (a) => a !== null && typeof a === 'object' && typeof a.type === 'string';
// ---------------------------------------------------------------------------
// Encoding
// ---------------------------------------------------------------------------
/** NUL-terminated string padded with NULs to a multiple of 4 bytes. */
function oscString(text) {
const body = Buffer.from(String(text), 'utf8');
const out = Buffer.alloc(pad4(body.length + 1)); // zero-filled: terminator + padding
body.copy(out);
return out;
}
/** Turn any accepted argument form into { type, value? }. */
function normalize(arg) {
if (isTyped(arg)) {
switch (arg.type) {
case 'i':
case 'f':
case 's':
return { type: arg.type, value: arg.value };
case 'T':
case 'F':
case 'N':
return { type: arg.type };
default:
throw new TypeError(`unsupported OSC type tag '${arg.type}'`);
}
}
if (arg === null || arg === undefined) return { type: 'N' };
switch (typeof arg) {
case 'boolean':
return { type: arg ? 'T' : 'F' };
case 'string':
return { type: 's', value: arg };
case 'number':
if (Number.isInteger(arg) && arg >= INT32_MIN && arg <= INT32_MAX) return { type: 'i', value: arg };
return { type: 'f', value: arg };
default:
throw new TypeError(`cannot encode a ${typeof arg} as an OSC argument`);
}
}
/** Payload bytes for one normalised argument (T/F/N carry none). */
function argData(arg) {
switch (arg.type) {
case 'i': {
const v = arg.value;
if (!Number.isInteger(v) || v < INT32_MIN || v > INT32_MAX) {
throw new RangeError(`${v} does not fit an OSC int32`);
}
const b = Buffer.alloc(4);
b.writeInt32BE(v, 0);
return b;
}
case 'f': {
const b = Buffer.alloc(4);
b.writeFloatBE(Number(arg.value), 0);
return b;
}
case 's':
return oscString(arg.value);
default:
return null;
}
}
/**
* Encode one OSC message.
* @param {string} address must start with '/'
* @param {Array} [args] plain values and/or int()/float() wrappers
* @returns {Buffer}
*/
function encode(address, args = []) {
if (typeof address !== 'string' || address[0] !== '/') {
throw new TypeError(`OSC address must be a string starting with '/' (got ${JSON.stringify(address)})`);
}
const list = (Array.isArray(args) ? args : [args]).map(normalize);
const parts = [oscString(address), oscString(',' + list.map((a) => a.type).join(''))];
for (const arg of list) {
const data = argData(arg);
if (data) parts.push(data);
}
return Buffer.concat(parts);
}
// ---------------------------------------------------------------------------
// Decoding
// ---------------------------------------------------------------------------
/**
* One parsing attempt.
* @param {Buffer} buf
* @param {boolean} padded strings occupy a multiple of 4 bytes (standard OSC)
* @param {boolean} strict require well-formed padding and exact consumption
* @returns {object|null} null when this interpretation does not fit
*/
function parseMessage(buf, padded, strict) {
let off = 0;
let short = false; // a string's terminator/padding ran past the end of the buffer
// Read one NUL-terminated string, or null when it is malformed.
const readString = () => {
if (off >= buf.length) return null;
const nul = buf.indexOf(0, off);
if (nul === -1) {
if (strict) return null;
const rest = buf.toString('utf8', off);
off = buf.length;
short = true;
return rest;
}
const text = buf.toString('utf8', off, nul);
const next = padded ? off + pad4(nul - off + 1) : nul + 1;
if (next > buf.length) {
if (strict) return null;
short = true;
off = buf.length;
} else {
if (strict) {
for (let k = nul + 1; k < next; k++) if (buf[k] !== 0) return null;
}
off = next;
}
return text;
};
const address = readString();
if (address === null || address[0] !== '/') return null;
const args = [];
let tags = '';
// A packet may legitimately end after the address (pre-1.0 style, no type tags).
if (off < buf.length) {
const tagString = readString();
if (tagString === null || tagString[0] !== ',') return null;
tags = tagString.slice(1);
for (const t of tags) {
switch (t) {
case 'i':
if (off + 4 > buf.length) return null;
args.push(buf.readInt32BE(off));
off += 4;
break;
case 'f':
if (off + 4 > buf.length) return null;
args.push(buf.readFloatBE(off));
off += 4;
break;
case 's': {
const s = readString();
if (s === null) return null;
args.push(s);
break;
}
case 'T':
args.push(true);
break;
case 'F':
args.push(false);
break;
case 'N':
args.push(null);
break;
default:
return null; // tag we do not support
}
}
}
if (strict && off !== buf.length) return null;
return { address, args, types: tags, unpadded: short };
}
/**
* Decode one OSC message. Never throws.
* @param {Buffer|Uint8Array} input
* @returns {{address:string,args:Array,types:string,unpadded:boolean}|{raw:string}}
* `types` is the type-tag string without the leading comma (e.g. "if").
*/
function decode(input) {
let buf;
try {
buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
} catch {
return { raw: '' };
}
try {
// 1) standard, correctly padded
const standard = parseMessage(buf, true, true);
if (standard) return standard;
// 2) strings NUL-terminated but not padded to 4 bytes
const compact = parseMessage(buf, false, true);
if (compact) return { ...compact, unpadded: true };
// 3) padded, but last string short of its padding / trailing junk
const lenient = parseMessage(buf, true, false);
if (lenient) return lenient;
} catch {
// fall through to raw
}
return { raw: buf.toString('hex') };
}
// ---------------------------------------------------------------------------
// Logging helper
// ---------------------------------------------------------------------------
/** Format one number: floats trimmed of float32 noise, whole floats shown as "-10.0". */
function formatNumber(v, type) {
if (type === 'i') return String(v);
if (!Number.isFinite(v)) return String(v);
const text = String(Number(v.toPrecision(7)));
return type === 'f' && /^-?\d+$/.test(text) ? `${text}.0` : text;
}
/**
* Printable form of an argument list, e.g. `[-12.5, true, "Kick"]`.
* @param {Array} args plain (decoded) values or int()/float() wrappers
* @param {string} [types] type-tag string from decode(), so whole floats print as -10.0
*/
function formatArgs(args, types) {
const parts = (args || []).map((a, idx) => {
const typed = isTyped(a);
const type = typed ? a.type : types ? types[idx] : undefined;
const value = typed ? a.value : a;
if (type === 'T') return 'true';
if (type === 'F') return 'false';
if (type === 'N' || value === null || value === undefined) return 'nil';
if (typeof value === 'string') return JSON.stringify(value);
if (typeof value === 'boolean') return String(value);
if (typeof value === 'number') return formatNumber(value, type);
return String(value);
});
return `[${parts.join(', ')}]`;
}
/** The type-tag string (without the leading comma) that encode() would produce for args. */
function typeTags(args) {
const list = args === undefined ? [] : Array.isArray(args) ? args : [args];
return list.map(normalize).map((a) => a.type).join('');
}
module.exports = { encode, decode, formatArgs, typeTags, int, float };