'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: '' }. */ 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 };