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>
183 lines
5.4 KiB
JavaScript
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)}`
|
|
}
|