/** * Minimal, tolerant OSC 1.0 codec. * * The DiGiCo iPad app is known to emit datagrams that are not padded to a * 4-byte multiple, so the decoder never throws: anything it cannot read comes * back as `{ raw }` and the relay still forwards the original bytes verbatim. * * Argument encoding rules (encode): * boolean -> T / F * null -> N * number -> f (float32) use { i: n } for an int32 * string -> s * { i: n } -> i { f: n } -> f { s: x } -> s { b: Buffer } -> blob */ function pad4(len) { return (4 - (len % 4)) % 4 } function encodeString(value) { const bytes = Buffer.from(String(value), 'utf8') const out = Buffer.alloc(bytes.length + 1 + pad4(bytes.length + 1)) bytes.copy(out) return out } function encodeBlob(buf) { const out = Buffer.alloc(4 + buf.length + pad4(buf.length)) out.writeInt32BE(buf.length, 0) buf.copy(out, 4) return out } function int32(n) { const b = Buffer.alloc(4) b.writeInt32BE(Math.trunc(n), 0) return b } function float32(n) { const b = Buffer.alloc(4) b.writeFloatBE(Number(n), 0) return b } export function encode(address, args = []) { let tags = ',' const parts = [] for (const arg of args) { if (arg === true) tags += 'T' else if (arg === false) tags += 'F' else if (arg === null || arg === undefined) tags += 'N' else if (typeof arg === 'number') { tags += 'f' parts.push(float32(arg)) } else if (typeof arg === 'string') { tags += 's' parts.push(encodeString(arg)) } else if (Buffer.isBuffer(arg)) { tags += 'b' parts.push(encodeBlob(arg)) } else if (typeof arg === 'object') { if ('i' in arg) { tags += 'i' parts.push(int32(arg.i)) } else if ('f' in arg) { tags += 'f' parts.push(float32(arg.f)) } else if ('s' in arg) { tags += 's' parts.push(encodeString(arg.s)) } else if ('b' in arg) { tags += 'b' parts.push(encodeBlob(arg.b)) } else throw new Error('Unsupported OSC argument object') } else throw new Error(`Unsupported OSC argument type ${typeof arg}`) } return Buffer.concat([encodeString(address), encodeString(tags), ...parts]) } function readString(buf, offset) { let end = buf.indexOf(0, offset) if (end < 0) end = buf.length const value = buf.toString('utf8', offset, end) let next = end + 1 next += pad4(next) return [value, next] } /** * Decode one datagram. Returns * { address, args, tags, unpadded } for a message * { bundle: true, elements: [...] } for a bundle * { raw: '' } for anything unreadable */ export function decode(buf) { try { if (!Buffer.isBuffer(buf) || buf.length === 0) return { raw: '' } if (buf.length >= 8 && buf.toString('ascii', 0, 7) === '#bundle') { const elements = [] let off = 16 while (off + 4 <= buf.length) { const size = buf.readInt32BE(off) off += 4 if (size <= 0 || off + size > buf.length) break elements.push(decode(buf.subarray(off, off + size))) off += size } return { bundle: true, elements } } if (buf[0] !== 0x2f) return { raw: buf.toString('hex') } let [address, off] = readString(buf, 0) let tags = '' if (off < buf.length && buf[off] === 0x2c) [tags, off] = readString(buf, off) const args = [] for (const t of tags.slice(1)) { if (t === 'i') { if (off + 4 > buf.length) break args.push(buf.readInt32BE(off)) off += 4 } else if (t === 'f') { if (off + 4 > buf.length) break args.push(buf.readFloatBE(off)) off += 4 } else if (t === 'd') { if (off + 8 > buf.length) break args.push(buf.readDoubleBE(off)) off += 8 } else if (t === 'h') { if (off + 8 > buf.length) break args.push(Number(buf.readBigInt64BE(off))) off += 8 } else if (t === 's' || t === 'S') { let s ;[s, off] = readString(buf, off) args.push(s) } else if (t === 'b') { if (off + 4 > buf.length) break const size = buf.readInt32BE(off) off += 4 args.push(buf.subarray(off, Math.min(buf.length, off + size))) off += size + pad4(size) } else if (t === 'T') args.push(true) else if (t === 'F') args.push(false) else if (t === 'N') args.push(null) else if (t === 'I') args.push(Infinity) else break } return { address, args, tags, unpadded: buf.length % 4 !== 0 } } catch (e) { return { raw: buf.toString('hex'), error: e.message } } } /** "/channel/12/send/3/level" -> "/channel/N/send/N/level" */ export function foldPattern(address) { return String(address).replace(/\/-?\d+(?:\.\d+)?(?=\/|$)/g, '/N') } /** Coerce an OSC argument to boolean (T/F, 0/1, 0.0/1.0, "true"). */ export function toBool(value) { if (typeof value === 'boolean') return value if (typeof value === 'number') return value > 0.5 if (typeof value === 'string') return /^(1|true|on|yes)$/i.test(value.trim()) return false } export function toNumber(value) { if (typeof value === 'number') return value if (typeof value === 'boolean') return value ? 1 : 0 const n = Number.parseFloat(value) return Number.isFinite(n) ? n : NaN } /** Short human form of a decoded message for logs. */ export function describe(msg) { if (!msg) return '(empty)' if (msg.raw !== undefined) return `raw ${msg.raw.slice(0, 48)}${msg.raw.length > 48 ? '…' : ''}` if (msg.bundle) return `#bundle(${msg.elements.length})` const args = msg.args.map((a) => (typeof a === 'number' ? Number(a.toFixed(3)) : Buffer.isBuffer(a) ? `` : a)) return `${msg.address} ${JSON.stringify(args)}` }