Files
companion-module-digico-s31/src/osc.js
T
michaelandClaude Fable 5.1 131cdfd81f Companion module for the DiGiCo S31 with an iPad relay (v1)
Bitfocus Companion 5 connection module (ESM, @companion-module/base ~2.1.3,
runtime node22) for the DiGiCo S21/S31. It occupies the console's single
iPad slot, controls per-channel sends/assigns to one buss, aux send
level/on/pan, mutes, faders and snapshots, and relays any number of DiGiCo
iPad apps through to the console byte-for-byte.

The S31's iPad extension was probed live: it speaks the SD/DiGiCo Pad OSC
tree (console receives on 8000, replies only to the configured iPad port
9000). tools/mock-s31.js emulates the console and test/selftest.js runs the
link + relay end to end against it. docs/design-v2.md is the brief for the
generic v2 rewrite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-15 15:55:18 -04:00

183 lines
5.4 KiB
JavaScript

/**
* 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: '<hex>' } 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) ? `<blob ${a.length}>` : a))
return `${msg.address} ${JSON.stringify(args)}`
}