285 lines
9.3 KiB
JavaScript
285 lines
9.3 KiB
JavaScript
'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 };
|