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>
This commit is contained in:
Executable
+792
@@ -0,0 +1,792 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* mock-s31.js - emulates the DiGiCo S31 iPad "OSC Control" extension for
|
||||
* developing and testing the Companion module without a real console.
|
||||
*
|
||||
* Behaviour copied from the real desk (both dialects):
|
||||
* - It listens on ONE UDP "receive port" (--listen).
|
||||
* - ALL feedback goes to ONE configured controller IP:port (the console's
|
||||
* "send port", normally 9000), no matter who sent the request or from
|
||||
* which source port (--reply-to).
|
||||
*
|
||||
* Two OSC dialects (--dialect):
|
||||
*
|
||||
* sseries (default) - the lowercase S-Series command list.
|
||||
* A message with no args at a readable address is a QUERY -> reply with
|
||||
* the current value. A message with an arg is a SET -> store it, then echo
|
||||
* it to the controller. /console/resend dumps the whole state.
|
||||
*
|
||||
* sd - the "DiGiCo Pad" / SD dialect the real S31 actually speaks
|
||||
* (verified against a live console).
|
||||
* A QUERY is the address plus a trailing "/?" and no args; the reply is the
|
||||
* same address WITHOUT "/?" plus the value. A SET is the address without
|
||||
* "/?" and one argument; it is stored and echoed using the console's reply
|
||||
* types (mute and send_on come back as floats 0/1). Anything the real
|
||||
* console does not answer (lowercase S-Series addresses, /Console/Resend,
|
||||
* /Console/Channels/?, whole-send queries, ...) gets no reply here either.
|
||||
*
|
||||
* Usage:
|
||||
* node tools/mock-s31.js [--dialect sseries|sd] [--listen 8000] [--reply-to IP:PORT]
|
||||
* [--channels N] [--auxes N] [--bool-int] [--live]
|
||||
* [--types] [--quiet]
|
||||
*/
|
||||
|
||||
const dgram = require('node:dgram');
|
||||
const { parseArgs } = require('node:util');
|
||||
const { encode, decode, formatArgs, typeTags, int, float } = require('./osc-lite');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command line
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const USAGE = `Usage: node tools/mock-s31.js [options]
|
||||
|
||||
--dialect NAME sseries (default): lowercase S-Series OSC list
|
||||
(/console/ping, /channel/N/fader, ...)
|
||||
sd: the dialect the real S31 iPad extension speaks
|
||||
(/Console/Name/?, /Input_Channels/N/fader/?, ...)
|
||||
--listen PORT UDP port to bind (the console's receive port) [8000]
|
||||
--reply-to IP:PORT fixed feedback destination (the console's configured
|
||||
controller). If omitted, lock onto the first sender's IP
|
||||
with port 9000.
|
||||
--channels N input channels, 1-60 [sseries 48, sd 60]
|
||||
--auxes N aux busses / sends per channel [sseries 16 (max 16),
|
||||
sd 25 (max 25)]
|
||||
--bool-int sseries only: send booleans as int 0/1 instead of OSC T/F
|
||||
(the console can be set to "normalise" booleans)
|
||||
--live every 3 s change a random send level / send-enabled and
|
||||
emit it, to exercise feedback handling
|
||||
--types also print each message's OSC type tags, e.g. ",f"
|
||||
--quiet do not print every message
|
||||
-h, --help show this help`;
|
||||
|
||||
const DIALECTS = {
|
||||
sseries: { channels: 48, auxes: 16, maxAuxes: 16 },
|
||||
sd: { channels: 60, auxes: 25, maxAuxes: 25 },
|
||||
};
|
||||
|
||||
function die(message) {
|
||||
console.error(`mock-s31: ${message}\n\n${USAGE}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function parseCli() {
|
||||
let values;
|
||||
try {
|
||||
({ values } = parseArgs({
|
||||
options: {
|
||||
dialect: { type: 'string', default: 'sseries' },
|
||||
listen: { type: 'string', default: '8000' },
|
||||
'reply-to': { type: 'string' },
|
||||
channels: { type: 'string' },
|
||||
auxes: { type: 'string' },
|
||||
'bool-int': { type: 'boolean', default: false },
|
||||
live: { type: 'boolean', default: false },
|
||||
types: { type: 'boolean', default: false },
|
||||
quiet: { type: 'boolean', default: false },
|
||||
help: { type: 'boolean', short: 'h', default: false },
|
||||
},
|
||||
allowPositionals: false,
|
||||
strict: true,
|
||||
}));
|
||||
} catch (err) {
|
||||
die(err.message);
|
||||
}
|
||||
if (values.help) {
|
||||
console.log(USAGE);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!Object.hasOwn(DIALECTS, values.dialect)) die(`--dialect must be "sseries" or "sd" (got "${values.dialect}")`);
|
||||
const defaults = DIALECTS[values.dialect];
|
||||
|
||||
const intOption = (name, min, max, fallback) => {
|
||||
const text = values[name];
|
||||
if (text === undefined) return fallback;
|
||||
if (!/^\d+$/.test(text)) die(`--${name} must be a whole number (got "${text}")`);
|
||||
const n = Number(text);
|
||||
if (n < min || n > max) die(`--${name} must be between ${min} and ${max} for the ${values.dialect} dialect (got ${n})`);
|
||||
return n;
|
||||
};
|
||||
|
||||
let replyTo = null;
|
||||
if (values['reply-to'] !== undefined) {
|
||||
const m = /^(.+):(\d{1,5})$/.exec(values['reply-to']);
|
||||
if (!m || Number(m[2]) < 1 || Number(m[2]) > 65535) die(`--reply-to must look like IP:PORT (got "${values['reply-to']}")`);
|
||||
replyTo = { host: m[1], port: Number(m[2]) };
|
||||
}
|
||||
|
||||
return {
|
||||
dialect: values.dialect,
|
||||
listen: intOption('listen', 1, 65535, 8000),
|
||||
replyTo,
|
||||
channels: intOption('channels', 1, 60, defaults.channels),
|
||||
auxes: intOption('auxes', 0, defaults.maxAuxes, defaults.auxes),
|
||||
boolInt: values['bool-int'],
|
||||
live: values.live,
|
||||
types: values.types,
|
||||
quiet: values.quiet,
|
||||
};
|
||||
}
|
||||
|
||||
const opts = parseCli();
|
||||
const isSd = opts.dialect === 'sd';
|
||||
|
||||
const DUMP_INTERVAL_MS = 2; // pacing of multi-packet replies, like a real desk
|
||||
const LIVE_INTERVAL_MS = 3000;
|
||||
const DEFAULT_REPLY_PORT = 9000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers: PRNG, value coercion, outbound typing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Small deterministic PRNG so every run starts from the same "show file". */
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Property kinds:
|
||||
* s string
|
||||
* f float32 with min/max clamp
|
||||
* b boolean (sseries; sent as T/F, or int 0/1 with --bool-int)
|
||||
* flag 0/1 number (sd; accepted as int, float or boolean; reported as a float,
|
||||
* or as an int when the spec says wire: 'i')
|
||||
* int integer with min/max clamp (sd)
|
||||
*/
|
||||
|
||||
/** Validate/clamp an inbound OSC argument for a property; undefined = unusable. */
|
||||
function coerce(spec, arg) {
|
||||
switch (spec.kind) {
|
||||
case 's':
|
||||
return typeof arg === 'string' ? arg : undefined;
|
||||
case 'f': {
|
||||
if (typeof arg !== 'number' || !Number.isFinite(arg)) return undefined;
|
||||
return Math.fround(Math.min(spec.max, Math.max(spec.min, arg)));
|
||||
}
|
||||
case 'b': {
|
||||
if (typeof arg === 'boolean') return arg;
|
||||
if (typeof arg === 'number') return arg !== 0;
|
||||
if (typeof arg === 'string' && /^(true|1|on)$/i.test(arg)) return true;
|
||||
if (typeof arg === 'string' && /^(false|0|off)$/i.test(arg)) return false;
|
||||
return undefined;
|
||||
}
|
||||
case 'flag': {
|
||||
if (typeof arg === 'boolean') return arg ? 1 : 0;
|
||||
if (typeof arg === 'number' && Number.isFinite(arg)) return arg !== 0 ? 1 : 0;
|
||||
return undefined;
|
||||
}
|
||||
case 'int': {
|
||||
let n;
|
||||
if (typeof arg === 'boolean') n = arg ? 1 : 0;
|
||||
else if (typeof arg === 'number' && Number.isFinite(arg)) n = Math.round(arg);
|
||||
else return undefined;
|
||||
return Math.min(spec.max, Math.max(spec.min, n));
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stored value -> outbound OSC argument. */
|
||||
function toArg(spec, value) {
|
||||
switch (spec.kind) {
|
||||
case 'f':
|
||||
return float(value);
|
||||
case 'b':
|
||||
return opts.boolInt ? int(value ? 1 : 0) : Boolean(value);
|
||||
case 'flag':
|
||||
return spec.wire === 'i' ? int(value) : float(value); // the real SD console reports mute / send_on as floats
|
||||
case 'int':
|
||||
return int(value);
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
const stats = { received: 0, sent: 0, ignored: 0, undecodable: 0 };
|
||||
|
||||
/** Where feedback goes: { host, port } or null until we know a controller. */
|
||||
let replyTarget = opts.replyTo;
|
||||
let warnedNoTarget = false;
|
||||
|
||||
const note = (text) => console.log(`[mock-s31] ${text}`);
|
||||
|
||||
/** Log something that is only interesting while watching the traffic. */
|
||||
function trace(text) {
|
||||
if (!opts.quiet) console.log(text);
|
||||
}
|
||||
|
||||
/** "ADDRESS [args]" or, with --types, "ADDRESS ,tags [args]". */
|
||||
function describe(address, args, types) {
|
||||
const body = formatArgs(args, types);
|
||||
return opts.types ? `${address} ,${types} ${body}` : `${address} ${body}`;
|
||||
}
|
||||
|
||||
/** Record and log a message we are deliberately not acting on. */
|
||||
function ignore(reason) {
|
||||
stats.ignored++;
|
||||
trace(`[mock-s31] ignoring: ${reason}`);
|
||||
}
|
||||
|
||||
/** Send one message to the configured controller. */
|
||||
function send(address, args) {
|
||||
if (!replyTarget) {
|
||||
if (!warnedNoTarget) {
|
||||
warnedNoTarget = true;
|
||||
note('no controller known yet (no --reply-to and nothing received); dropping feedback until one arrives');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const buf = encode(address, args);
|
||||
socket.send(buf, replyTarget.port, replyTarget.host, (err) => {
|
||||
if (err) note(`send error: ${err.message}`);
|
||||
});
|
||||
} catch (err) {
|
||||
note(`could not send ${address}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
stats.sent++;
|
||||
if (!opts.quiet) console.log(`-> ${describe(address, args, typeTags(args))}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Store a new value and emit it, as the console does for every change. */
|
||||
function setAndEmit(target, prop, spec, address, value, afterSet) {
|
||||
target[prop] = value;
|
||||
send(address, [toArg(spec, value)]);
|
||||
if (afterSet) afterSet(value);
|
||||
}
|
||||
|
||||
// Multi-packet replies (resend dump, snapshot list) are paced ~2 ms apart.
|
||||
let dumpQueue = [];
|
||||
let dumpTimer = null;
|
||||
let dumpTotal = 0;
|
||||
let dumpLabel = '';
|
||||
|
||||
function startPaced(label, queue) {
|
||||
if (!replyTarget) return; // cannot happen via the socket: the first datagram locks a target
|
||||
if (dumpTimer) {
|
||||
clearTimeout(dumpTimer);
|
||||
note(`${label} requested while a dump was running; restarting it`);
|
||||
}
|
||||
dumpQueue = queue;
|
||||
dumpTotal = queue.length;
|
||||
dumpLabel = label;
|
||||
if (dumpTotal > 20) {
|
||||
note(`${label}: sending ${dumpTotal} packets (~${Math.round((dumpTotal * (DUMP_INTERVAL_MS + 0.5)) / 100) / 10}s)`);
|
||||
}
|
||||
pumpPaced();
|
||||
}
|
||||
|
||||
function pumpPaced() {
|
||||
const next = dumpQueue.shift();
|
||||
if (!next) {
|
||||
dumpTimer = null;
|
||||
if (dumpTotal > 20) note(`${dumpLabel} complete (${dumpTotal} packets)`);
|
||||
return;
|
||||
}
|
||||
send(next[0], next[1]);
|
||||
dumpTimer = setTimeout(pumpPaced, DUMP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Dialect: sseries (lowercase S-Series OSC list)
|
||||
// channel numbering: inputs 1-60, aux busses 70-79 (extended 70-85 when more
|
||||
// than 10 auxes), groups 80-93, master 120
|
||||
// ===========================================================================
|
||||
|
||||
const AUX_BASE = 70; // aux busses are channel numbers 70..70+auxes-1
|
||||
|
||||
const INPUT_NAMES = [
|
||||
'Kick', 'Snare', 'Hat', 'Tom 1', 'Tom 2', 'Floor Tom', 'OH L', 'OH R',
|
||||
'Bass DI', 'Bass Mic', 'Keys L', 'Keys R', 'Acou Gtr', 'Elec Gtr 1', 'Elec Gtr 2',
|
||||
'Vox 1', 'Vox 2', 'Vox 3', 'Vox 4', 'Vox 5', 'Pastor', 'Choir 1', 'Choir 2',
|
||||
'Track L', 'Track R', 'Click',
|
||||
];
|
||||
const AUX_NAMES = [
|
||||
'Studio Mon', 'IEM 1', 'IEM 2', 'Wedge 1', 'Wedge 2', 'IEM 3', 'IEM 4', 'Wedge 3',
|
||||
'Wedge 4', 'Sub Feed', 'Lobby', 'Nursery', 'Stream', 'Green Room', 'FX 1', 'FX 2',
|
||||
];
|
||||
|
||||
const CHANNEL_PROPS = {
|
||||
name: { kind: 's' },
|
||||
fader: { kind: 'f', min: -150, max: 10 },
|
||||
mute: { kind: 'b' },
|
||||
solo: { kind: 'b' },
|
||||
pan: { kind: 'f', min: -1, max: 1 },
|
||||
};
|
||||
const SEND_PROPS = {
|
||||
level: { kind: 'f', min: -90, max: 10 },
|
||||
enabled: { kind: 'b' },
|
||||
pan: { kind: 'f', min: -1, max: 1 },
|
||||
};
|
||||
|
||||
/** One decimal place, stored as the float32 that actually goes on the wire. */
|
||||
const tidy = (x) => Math.fround(Math.round(x * 10) / 10);
|
||||
|
||||
/** channel number -> { num, kind, name, fader, mute, solo, pan, sends } */
|
||||
const channels = new Map();
|
||||
|
||||
function buildStateSseries() {
|
||||
const rng = mulberry32(31);
|
||||
for (let n = 1; n <= opts.channels; n++) {
|
||||
const sends = [null]; // 1-based
|
||||
for (let m = 1; m <= opts.auxes; m++) {
|
||||
const active = rng() < 0.6;
|
||||
sends.push({
|
||||
level: active ? tidy(-40 + rng() * 40) : -90,
|
||||
enabled: active,
|
||||
pan: rng() < 0.2 ? tidy(rng() * 2 - 1) : 0,
|
||||
});
|
||||
}
|
||||
channels.set(n, {
|
||||
num: n,
|
||||
kind: 'input',
|
||||
name: INPUT_NAMES[n - 1] || `Input ${n}`,
|
||||
fader: tidy(-30 + rng() * 30),
|
||||
mute: rng() < 0.08,
|
||||
solo: false,
|
||||
pan: 0,
|
||||
sends,
|
||||
});
|
||||
}
|
||||
for (let k = 0; k < opts.auxes; k++) {
|
||||
const num = AUX_BASE + k;
|
||||
channels.set(num, {
|
||||
num,
|
||||
kind: 'aux',
|
||||
name: AUX_NAMES[k] || `Aux ${k + 1}`,
|
||||
fader: tidy(-10 + rng() * 10),
|
||||
mute: false,
|
||||
solo: false,
|
||||
pan: 0,
|
||||
sends: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Query (no args) or set (one arg) of target[prop], echoing to the controller. */
|
||||
function queryOrSet(target, prop, spec, address, args, afterSet) {
|
||||
if (args.length === 0) {
|
||||
send(address, [toArg(spec, target[prop])]); // query
|
||||
return;
|
||||
}
|
||||
const value = coerce(spec, args[0]);
|
||||
if (value === undefined) {
|
||||
ignore(`unusable value ${formatArgs(args)} for ${address}`);
|
||||
return;
|
||||
}
|
||||
setAndEmit(target, prop, spec, address, value, afterSet);
|
||||
}
|
||||
|
||||
const RE_CHANNEL = /^\/channel\/(\d+)\/(name|fader|mute|solo|pan)$/;
|
||||
const RE_TOTAL_GAIN = /^\/channel\/(\d+)\/total\/gain$/;
|
||||
const RE_SEND = /^\/channel\/(\d+)\/send\/(\d+)\/(level|enabled|pan)$/;
|
||||
|
||||
let snapshot = 0; // only used to make the snapshot log lines meaningful
|
||||
|
||||
function handleSseries(address, args) {
|
||||
switch (address) {
|
||||
case '/console/ping':
|
||||
send('/console/pong', []);
|
||||
return;
|
||||
case '/console/channel/counts':
|
||||
send(address, [int(opts.channels), int(opts.auxes)]);
|
||||
return;
|
||||
case '/console/resend':
|
||||
startPaced('resend', buildDumpSseries());
|
||||
return;
|
||||
case '/digico/snapshots/fire': {
|
||||
if (typeof args[0] !== 'number') return ignore('snapshot fire without a number');
|
||||
snapshot = Math.trunc(args[0]);
|
||||
note(`snapshot fire: recall #${snapshot}`);
|
||||
return;
|
||||
}
|
||||
case '/digico/snapshots/fire/next':
|
||||
snapshot += 1;
|
||||
note(`snapshot fire next: now #${snapshot}`);
|
||||
return;
|
||||
case '/digico/snapshots/fire/previous':
|
||||
snapshot = Math.max(0, snapshot - 1);
|
||||
note(`snapshot fire previous: now #${snapshot}`);
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
let m = RE_CHANNEL.exec(address);
|
||||
if (m) {
|
||||
const num = Number(m[1]);
|
||||
const prop = m[2];
|
||||
const ch = channels.get(num);
|
||||
if (!ch) return ignore(`no such channel ${num} (${address})`);
|
||||
queryOrSet(ch, prop, CHANNEL_PROPS[prop], address, args, prop === 'fader'
|
||||
? (value) => send(`/channel/${num}/total/gain`, [float(value)]) // send-only, rides along with fader changes
|
||||
: undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
m = RE_SEND.exec(address);
|
||||
if (m) {
|
||||
const num = Number(m[1]);
|
||||
const sendNo = Number(m[2]);
|
||||
const prop = m[3];
|
||||
const ch = channels.get(num);
|
||||
if (!ch) return ignore(`no such channel ${num} (${address})`);
|
||||
const target = ch.sends && ch.sends[sendNo];
|
||||
if (!target) return ignore(`channel ${num} has no send ${sendNo} (${address})`);
|
||||
queryOrSet(target, prop, SEND_PROPS[prop], address, args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (RE_TOTAL_GAIN.test(address)) return ignore(`${address} is send-only`);
|
||||
|
||||
ignore(`unknown address ${address}`);
|
||||
}
|
||||
|
||||
/** /console/resend: counts, every input (basics + sends), then every aux bus. */
|
||||
function buildDumpSseries() {
|
||||
const out = [['/console/channel/counts', [int(opts.channels), int(opts.auxes)]]];
|
||||
const basics = (ch) => {
|
||||
out.push([`/channel/${ch.num}/name`, [toArg(CHANNEL_PROPS.name, ch.name)]]);
|
||||
out.push([`/channel/${ch.num}/fader`, [toArg(CHANNEL_PROPS.fader, ch.fader)]]);
|
||||
out.push([`/channel/${ch.num}/mute`, [toArg(CHANNEL_PROPS.mute, ch.mute)]]);
|
||||
};
|
||||
for (let n = 1; n <= opts.channels; n++) {
|
||||
const ch = channels.get(n);
|
||||
basics(ch);
|
||||
for (let s = 1; s <= opts.auxes; s++) {
|
||||
const snd = ch.sends[s];
|
||||
out.push([`/channel/${n}/send/${s}/level`, [toArg(SEND_PROPS.level, snd.level)]]);
|
||||
out.push([`/channel/${n}/send/${s}/enabled`, [toArg(SEND_PROPS.enabled, snd.enabled)]]);
|
||||
out.push([`/channel/${n}/send/${s}/pan`, [toArg(SEND_PROPS.pan, snd.pan)]]);
|
||||
}
|
||||
}
|
||||
for (let k = 0; k < opts.auxes; k++) basics(channels.get(AUX_BASE + k));
|
||||
return out;
|
||||
}
|
||||
|
||||
function liveTickSseries() {
|
||||
const n = 1 + Math.floor(Math.random() * opts.channels);
|
||||
const ch = channels.get(n);
|
||||
if (opts.auxes === 0) {
|
||||
// Nothing with sends to wiggle: move a fader instead.
|
||||
setAndEmit(ch, 'fader', CHANNEL_PROPS.fader, `/channel/${n}/fader`, tidy(-40 + Math.random() * 50),
|
||||
(v) => send(`/channel/${n}/total/gain`, [float(v)]));
|
||||
return;
|
||||
}
|
||||
const s = 1 + Math.floor(Math.random() * opts.auxes);
|
||||
const snd = ch.sends[s];
|
||||
if (Math.random() < 0.5) {
|
||||
setAndEmit(snd, 'level', SEND_PROPS.level, `/channel/${n}/send/${s}/level`, tidy(-60 + Math.random() * 60));
|
||||
} else {
|
||||
setAndEmit(snd, 'enabled', SEND_PROPS.enabled, `/channel/${n}/send/${s}/enabled`, !snd.enabled);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Dialect: sd (the "DiGiCo Pad" dialect the real S31 speaks)
|
||||
// query = address + "/?" (no args); reply = address without "/?" + value
|
||||
// ===========================================================================
|
||||
|
||||
const SD_CONSOLE_NAME = 'S31 AB';
|
||||
|
||||
/** The console has 25 busses, each exposed as an Aux_Outputs and as a Group_Outputs entry. */
|
||||
const SD_BUSS_COUNT = 25;
|
||||
|
||||
/**
|
||||
* Wire type of /Input_Channels/N/Group_Send/M/group replies: 'f' (float 0/1, like
|
||||
* mute and send_on) or 'i' (int). Not yet verified against the real console -
|
||||
* flip this one constant if it turns out to be an int.
|
||||
*/
|
||||
const SD_GROUP_ASSIGN_WIRE_TYPE = 'f';
|
||||
|
||||
/** Fixed counts the console reports besides Input_Channels / Aux_Outputs. */
|
||||
const SD_FIXED_COUNTS = {
|
||||
Group_Outputs: SD_BUSS_COUNT,
|
||||
Control_Groups: 10,
|
||||
Matrix_Outputs: 8,
|
||||
Matrix_Inputs: 10,
|
||||
Graphic_EQ: 16,
|
||||
Talkback_Outputs: 0,
|
||||
Multis: 0,
|
||||
};
|
||||
|
||||
/** One int per aux: 2 = stereo, 1 = mono (captured from the real console). */
|
||||
const SD_AUX_MODES = [2, 2, 2, 1, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2];
|
||||
|
||||
const SD_INPUT_NAMES = [
|
||||
'KICK In', 'SNARE Top', 'SNARE Bot', 'HAT', 'TOM 1', 'TOM 2', 'FLOOR TOM', 'OH L', 'OH R',
|
||||
'BASS DI', 'BASS Mic', 'KEYS L', 'KEYS R', 'ACOU GTR', 'ELEC GTR 1', 'ELEC GTR 2',
|
||||
'VOX 1', 'VOX 2', 'VOX 3', 'VOX 4', 'VOX 5', 'PASTOR', 'CHOIR 1', 'CHOIR 2',
|
||||
'TRACK L', 'TRACK R', 'CLICK',
|
||||
];
|
||||
/**
|
||||
* Captured buss names, buss order 1..25. A buss shows its name under Aux_Outputs
|
||||
* when it is in aux mode and under Group_Outputs when it is in group mode; the
|
||||
* other side reports "".
|
||||
*/
|
||||
const SD_AUX_NAMES = [
|
||||
'VOCAL VERB', 'VOCAL SLAP', 'TRACKS', 'CLK-GD-MD', 'VOCAL DELAY', 'VOCALS', 'TB', 'MC/SpkrBcastOut',
|
||||
'', '', '', '', '', 'DRUM PLATE', 'AG VERB', 'FOH Spkr', 'Studio TB', '', 'FX', 'Prop & Spot',
|
||||
'Drum Room Verb', '', '', '', '',
|
||||
];
|
||||
const SD_GROUP_NAMES = [
|
||||
'', '', '', '', '', '', '', '', 'BAND', 'VOCALS', 'MISC', 'DRUMS', 'DRUM CRUSH', '', '', '', '', 'Broadcast mix',
|
||||
'', '', '', '', 'CAFE', 'Studio', 'Master',
|
||||
];
|
||||
/** Snapshot list: index n has cue number (n+1)*100. */
|
||||
const SD_SNAPSHOTS = [
|
||||
'START', 'Pre Service', 'Worship 1', 'Worship 2', 'Message', 'Offering', 'Closing', 'BYPASS WAVES SUPERRACK!',
|
||||
];
|
||||
|
||||
const SD_PROPS = {
|
||||
name: { kind: 's', readOnly: true },
|
||||
fader: { kind: 'f', min: -150, max: 10 }, // dB, -150 = -inf
|
||||
mute: { kind: 'flag' },
|
||||
level: { kind: 'f', min: -150, max: 10 }, // send_level, dB
|
||||
on: { kind: 'flag' }, // send_on
|
||||
pan: { kind: 'f', min: 0, max: 1 }, // send_pan, 0.5 = centre
|
||||
prepost: { kind: 'int', min: 0, max: 1 }, // send_pre-post; captured as an int, range assumed 0 = pre, 1 = post
|
||||
group: { kind: 'flag', wire: SD_GROUP_ASSIGN_WIRE_TYPE }, // Group_Send/M/group assign 0/1
|
||||
};
|
||||
const SD_SEND_PROPS = { send_level: 'level', send_on: 'on', send_pan: 'pan', 'send_pre-post': 'prepost' };
|
||||
|
||||
/** Real SD faders land on 1/16 dB steps (e.g. -1.4375, -4.375). */
|
||||
const tidy16 = (x) => Math.fround(Math.round(x * 16) / 16);
|
||||
|
||||
const sdInputs = [null]; // 1-based: { name, fader, mute, sends:[null,{level,on,pan,prepost}...], groupSends:[null,{group}...] }
|
||||
const sdAuxes = [null]; // 1-based: { name, fader, mute }
|
||||
const sdGroups = [null]; // 1-based, always SD_BUSS_COUNT: { name, fader, mute }
|
||||
let sdSnapshot = 3; // /Snapshots/Current_Snapshot
|
||||
|
||||
function buildStateSd() {
|
||||
const rng = mulberry32(31);
|
||||
for (let n = 1; n <= opts.channels; n++) {
|
||||
const sends = [null];
|
||||
for (let m = 1; m <= opts.auxes; m++) {
|
||||
const active = rng() < 0.5;
|
||||
sends.push({
|
||||
level: active ? tidy16(-40 + rng() * 40) : -150,
|
||||
on: active ? 1 : 0,
|
||||
pan: rng() < 0.2 ? tidy16(rng()) : 0.5,
|
||||
prepost: 0,
|
||||
});
|
||||
}
|
||||
// Group assign: inputs 1-8 and 18-21 feed group 24, nothing else.
|
||||
const groupSends = [null];
|
||||
for (let g = 1; g <= SD_BUSS_COUNT; g++) groupSends.push({ group: 0 });
|
||||
if (n <= 8 || (n >= 18 && n <= 21)) groupSends[24].group = 1;
|
||||
sdInputs.push({
|
||||
name: SD_INPUT_NAMES[n - 1] || `INPUT ${n}`,
|
||||
fader: tidy16(-30 + rng() * 30),
|
||||
mute: rng() < 0.08 ? 1 : 0,
|
||||
sends,
|
||||
groupSends,
|
||||
});
|
||||
}
|
||||
for (let k = 0; k < opts.auxes; k++) {
|
||||
sdAuxes.push({
|
||||
name: SD_AUX_NAMES[k],
|
||||
fader: tidy16(-10 + rng() * 10),
|
||||
mute: 0,
|
||||
});
|
||||
}
|
||||
for (let k = 0; k < SD_BUSS_COUNT; k++) {
|
||||
sdGroups.push({
|
||||
name: SD_GROUP_NAMES[k],
|
||||
fader: tidy16(-10 + rng() * 10),
|
||||
mute: 0,
|
||||
});
|
||||
}
|
||||
sdGroups[24].fader = -1.6875; // captured
|
||||
// Pin the values captured from the real console so a probe sees the same numbers.
|
||||
sdInputs[1].fader = -1.4375;
|
||||
sdInputs[1].mute = 0;
|
||||
if (opts.auxes >= 1) {
|
||||
sdInputs[1].sends[1] = { level: -150, on: 0, pan: 0.5, prepost: 0 };
|
||||
sdAuxes[1].fader = -4.375;
|
||||
sdAuxes[1].mute = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const SD_RE_INPUT_NAME = /^\/Input_Channels\/(\d+)\/Channel_Input\/name$/;
|
||||
const SD_RE_INPUT_PROP = /^\/Input_Channels\/(\d+)\/(fader|mute)$/;
|
||||
const SD_RE_SEND = /^\/Input_Channels\/(\d+)\/Aux_Send\/(\d+)\/(send_level|send_on|send_pan|send_pre-post)$/;
|
||||
const SD_RE_GROUP_SEND = /^\/Input_Channels\/(\d+)\/Group_Send\/(\d+)\/group$/;
|
||||
const SD_RE_AUX_NAME = /^\/Aux_Outputs\/(\d+)\/Buss_Trim\/name$/;
|
||||
const SD_RE_AUX_PROP = /^\/Aux_Outputs\/(\d+)\/(fader|mute)$/;
|
||||
const SD_RE_GROUP_NAME = /^\/Group_Outputs\/(\d+)\/Buss_Trim\/name$/;
|
||||
const SD_RE_GROUP_PROP = /^\/Group_Outputs\/(\d+)\/(fader|mute)$/;
|
||||
|
||||
/** Map an SD leaf address to { target, prop } or null when there is no such thing. */
|
||||
function sdResolve(address) {
|
||||
let m = SD_RE_INPUT_NAME.exec(address);
|
||||
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: 'name' } : null;
|
||||
m = SD_RE_INPUT_PROP.exec(address);
|
||||
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: m[2] } : null;
|
||||
m = SD_RE_SEND.exec(address);
|
||||
if (m) {
|
||||
const input = sdInputs[Number(m[1])];
|
||||
const snd = input && input.sends[Number(m[2])];
|
||||
return snd ? { target: snd, prop: SD_SEND_PROPS[m[3]] } : null;
|
||||
}
|
||||
m = SD_RE_GROUP_SEND.exec(address);
|
||||
if (m) {
|
||||
const input = sdInputs[Number(m[1])];
|
||||
const gs = input && input.groupSends[Number(m[2])];
|
||||
return gs ? { target: gs, prop: 'group' } : null;
|
||||
}
|
||||
m = SD_RE_GROUP_NAME.exec(address);
|
||||
if (m) return sdGroups[Number(m[1])] ? { target: sdGroups[Number(m[1])], prop: 'name' } : null;
|
||||
m = SD_RE_GROUP_PROP.exec(address);
|
||||
if (m) return sdGroups[Number(m[1])] ? { target: sdGroups[Number(m[1])], prop: m[2] } : null;
|
||||
m = SD_RE_AUX_NAME.exec(address);
|
||||
if (m) return sdAuxes[Number(m[1])] ? { target: sdAuxes[Number(m[1])], prop: 'name' } : null;
|
||||
m = SD_RE_AUX_PROP.exec(address);
|
||||
if (m) return sdAuxes[Number(m[1])] ? { target: sdAuxes[Number(m[1])], prop: m[2] } : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleSd(address, args) {
|
||||
if (address.endsWith('/?')) {
|
||||
if (args.length !== 0) return ignore(`query ${address} carries arguments ${formatArgs(args)}`);
|
||||
return sdQuery(address.slice(0, -2), address);
|
||||
}
|
||||
return sdSet(address, args);
|
||||
}
|
||||
|
||||
function sdQuery(base, original) {
|
||||
if (base === '/Console/Name') return void send(base, [SD_CONSOLE_NAME]);
|
||||
if (base === '/Console/Input_Channels') return void send(base, [int(opts.channels)]);
|
||||
if (base === '/Console/Aux_Outputs') return void send(base, [int(opts.auxes)]);
|
||||
if (base === '/Console/Aux_Outputs/modes') {
|
||||
return void send(base, SD_AUX_MODES.slice(0, opts.auxes).map((mode) => int(mode)));
|
||||
}
|
||||
const fixed = /^\/Console\/(\w+)$/.exec(base);
|
||||
if (fixed && Object.hasOwn(SD_FIXED_COUNTS, fixed[1])) return void send(base, [int(SD_FIXED_COUNTS[fixed[1]])]);
|
||||
|
||||
if (base === '/Snapshots/Current_Snapshot') return void send(base, [int(sdSnapshot)]);
|
||||
if (base === '/Snapshots/names') {
|
||||
startPaced('snapshot names', SD_SNAPSHOTS.map((name, index) => [
|
||||
'/Snapshots/name', [int(index), int((index + 1) * 100), int(0), name],
|
||||
]));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const r = sdResolve(base);
|
||||
if (!r) return ignore(`no reply for ${original} (the real console is silent here)`);
|
||||
send(base, [toArg(SD_PROPS[r.prop], r.target[r.prop])]);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sdSet(address, args) {
|
||||
const r = sdResolve(address);
|
||||
if (!r) return ignore(`no reply for ${address} (unknown or unsupported address)`);
|
||||
const spec = SD_PROPS[r.prop];
|
||||
if (spec.readOnly) return ignore(`${address} is read-only`);
|
||||
if (args.length !== 1) return ignore(`${address} needs exactly one argument (got ${formatArgs(args)})`);
|
||||
const value = coerce(spec, args[0]);
|
||||
if (value === undefined) return ignore(`unusable value ${formatArgs(args)} for ${address}`);
|
||||
setAndEmit(r.target, r.prop, spec, address, value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function liveTickSd() {
|
||||
const n = 1 + Math.floor(Math.random() * opts.channels);
|
||||
const input = sdInputs[n];
|
||||
if (opts.auxes === 0) {
|
||||
setAndEmit(input, 'fader', SD_PROPS.fader, `/Input_Channels/${n}/fader`, tidy16(-40 + Math.random() * 50));
|
||||
return;
|
||||
}
|
||||
const s = 1 + Math.floor(Math.random() * opts.auxes);
|
||||
const snd = input.sends[s];
|
||||
if (Math.random() < 0.5) {
|
||||
setAndEmit(snd, 'level', SD_PROPS.level, `/Input_Channels/${n}/Aux_Send/${s}/send_level`, tidy16(-60 + Math.random() * 60));
|
||||
} else {
|
||||
setAndEmit(snd, 'on', SD_PROPS.on, `/Input_Channels/${n}/Aux_Send/${s}/send_on`, snd.on ? 0 : 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Socket wiring and lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const handle = isSd ? handleSd : handleSseries;
|
||||
const liveTick = isSd ? liveTickSd : liveTickSseries;
|
||||
|
||||
socket.on('message', (msg, rinfo) => {
|
||||
stats.received++;
|
||||
const packet = decode(msg);
|
||||
|
||||
if (packet.raw !== undefined) {
|
||||
stats.undecodable++;
|
||||
trace(`<- (undecodable, ${msg.length} bytes) ${packet.raw.slice(0, 96)} from ${rinfo.address}:${rinfo.port}`);
|
||||
} else {
|
||||
trace(`<- ${describe(packet.address, packet.args, packet.types)} from ${rinfo.address}:${rinfo.port}`);
|
||||
if (packet.unpadded) trace('[mock-s31] note: that packet was not padded to 4 bytes');
|
||||
}
|
||||
|
||||
// No fixed destination configured: remember the first sender's IP (port 9000).
|
||||
// Like the real console, the sender's source port is never used for replies.
|
||||
if (!replyTarget) {
|
||||
replyTarget = { host: rinfo.address, port: DEFAULT_REPLY_PORT };
|
||||
note(`no --reply-to given; locking feedback to ${replyTarget.host}:${replyTarget.port} (first sender was ${rinfo.address}:${rinfo.port})`);
|
||||
}
|
||||
|
||||
if (packet.raw === undefined) handle(packet.address, packet.args);
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
console.error(`[mock-s31] socket error: ${err.code === 'EADDRINUSE' ? `UDP port ${opts.listen} is already in use` : err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
socket.on('listening', () => {
|
||||
const addr = socket.address();
|
||||
const flavour = isSd
|
||||
? 'mute/send_on reported as floats 0/1'
|
||||
: `booleans as ${opts.boolInt ? 'int 0/1' : 'OSC T/F'}`;
|
||||
note(`listening on udp://${addr.address}:${addr.port} (dialect ${opts.dialect}: ${opts.channels} inputs, ${opts.auxes} auxes, ${flavour})`);
|
||||
note(replyTarget
|
||||
? `feedback goes to ${replyTarget.host}:${replyTarget.port}`
|
||||
: `feedback target not set; will lock onto the first sender's IP, port ${DEFAULT_REPLY_PORT}`);
|
||||
if (isSd && opts.boolInt) note('--bool-int has no effect in the sd dialect (the console reports mute / send_on as floats)');
|
||||
if (opts.live) {
|
||||
note(`live mode: changing a random send every ${LIVE_INTERVAL_MS / 1000}s`);
|
||||
setInterval(liveTick, LIVE_INTERVAL_MS);
|
||||
}
|
||||
});
|
||||
|
||||
function shutdown(signal) {
|
||||
if (dumpTimer) clearTimeout(dumpTimer);
|
||||
note(`${signal}: received ${stats.received} packets (${stats.undecodable} undecodable), sent ${stats.sent}, ignored ${stats.ignored}`);
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
if (isSd) buildStateSd();
|
||||
else buildStateSseries();
|
||||
socket.bind(opts.listen);
|
||||
Reference in New Issue
Block a user