v2: generic S-Series module with live discovery, dynamic options and meters

Rewrite of the module around what the console itself reports:

- Settings cut to console address/ports, the iPad relay and debug logging;
  nothing site-specific remains. An upgrade script carries v1 configs and
  remaps v1 actions/feedbacks whose ids were reused.
- On connect the module reads every name, buss type (aux/group), stereo
  mode, fader/mute/solo/pan and the snapshot list; entities that never
  answer are hidden. Definitions rebuild (debounced, skipped when
  identical) when names, buss types or snapshots change.
- Every entity option is a searchable dropdown of live names. 70 actions
  cover inputs, aux sends, group assigns, busses, matrix outputs and sends,
  control groups, graphic EQs, channel-strip switches, trim/gain/filters and
  snapshots, each level with set and encoder-friendly adjust variants.
- Boolean, value and bar feedbacks for every state, input level and
  gain-reduction meters via the console's meter subscription protocol, and
  about 2800 presets including rotary knob presets with live level text.
- A write guard keeps fast encoder turns from being undone by delayed
  echoes; feedbacks are re-checked per instance.
- Relay: the client table survives config changes, idle iPads are kept for
  an hour by default (0 = forever), bind failures retry and show in status.

npm test: 326 checks pass on Node 25 and on Companion's Node 22.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 17:11:17 -04:00
co-authored by Claude Opus 5.5
parent 8693fe924a
commit dad8010239
20 changed files with 5102 additions and 1327 deletions
+281 -66
View File
@@ -18,19 +18,21 @@
* 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).
* sd - the "DiGiCo Pad" / SD dialect the real S31 iPad extension speaks
* (tools/catalog/s31-catalog.md lists every address it answers).
* 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.
* types (mute, solo and send_on come back as floats 0/1). It also streams
* meters to subscribed ids and implements the write-only snapshot triggers.
* 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]
* [--delay MS] [--types] [--quiet]
*/
const dgram = require('node:dgram');
@@ -52,10 +54,12 @@ const USAGE = `Usage: node tools/mock-s31.js [options]
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),
--auxes N aux busses / sends per channel (sd: busses) [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)
--delay MS answer and echo every message MS milliseconds late (0-5000)
[0]; for testing a slow console and stale echoes
--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"
@@ -82,6 +86,7 @@ function parseCli() {
'reply-to': { type: 'string' },
channels: { type: 'string' },
auxes: { type: 'string' },
delay: { type: 'string' },
'bool-int': { type: 'boolean', default: false },
live: { type: 'boolean', default: false },
types: { type: 'boolean', default: false },
@@ -124,6 +129,7 @@ function parseCli() {
replyTo,
channels: intOption('channels', 1, 60, defaults.channels),
auxes: intOption('auxes', 0, defaults.maxAuxes, defaults.auxes),
delay: intOption('delay', 0, 5000, 0),
boolInt: values['bool-int'],
live: values.live,
types: values.types,
@@ -254,9 +260,12 @@ function send(address, args) {
}
try {
const buf = encode(address, args);
socket.send(buf, replyTarget.port, replyTarget.host, (err) => {
const target = replyTarget;
const deliver = () => socket.send(buf, target.port, target.host, (err) => {
if (err) note(`send error: ${err.message}`);
});
if (opts.delay > 0) setTimeout(deliver, opts.delay);
else deliver();
} catch (err) {
note(`could not send ${address}: ${err.message}`);
return false;
@@ -279,8 +288,14 @@ let dumpTimer = null;
let dumpTotal = 0;
let dumpLabel = '';
function startPaced(label, queue) {
function startPaced(label, queue, { append = false } = {}) {
if (!replyTarget) return; // cannot happen via the socket: the first datagram locks a target
if (dumpTimer && append) {
// sd dialect: bursts queue up behind each other instead of restarting the running one
dumpQueue.push(...queue);
dumpTotal += queue.length;
return;
}
if (dumpTimer) {
clearTimeout(dumpTimer);
note(`${label} requested while a dump was running; restarting it`);
@@ -503,23 +518,28 @@ function liveTickSseries() {
// ===========================================================================
// Dialect: sd (the "DiGiCo Pad" dialect the real S31 speaks)
// query = address + "/?" (no args); reply = address without "/?" + value
// Every address answered here is one the real console was seen to answer
// (tools/catalog/s31-catalog.md), with the same reply types. Entity numbers
// are 1-based, control groups included; snapshot indexes are 0-based.
// ===========================================================================
const SD_CONSOLE_NAME = 'S31 AB';
const SD_SESSION_NAME = 'Mock Session.session';
/** The console has 25 busses, each exposed as an Aux_Outputs and as a Group_Outputs entry. */
const SD_BUSS_COUNT = 25;
/** Number of busses (each exposed as an Aux_Outputs and as a Group_Outputs entry); --auxes sets it. */
const SD_BUSS_MAX = 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.
* mute and send_on) or 'i' (int). The catalog shows 'f'.
*/
const SD_GROUP_ASSIGN_WIRE_TYPE = 'f';
/** Fixed counts the console reports besides Input_Channels / Aux_Outputs. */
/**
* Counts the console answers when asked (catalog). The Input_Channels, Aux_Outputs and Group_Outputs counts never answer a
* query; the console pushes all nine once, ~200 ms after first contact (sdCountBurst).
*/
const SD_FIXED_COUNTS = {
Group_Outputs: SD_BUSS_COUNT,
Control_Groups: 10,
Matrix_Outputs: 8,
Matrix_Inputs: 10,
@@ -527,9 +547,21 @@ const SD_FIXED_COUNTS = {
Talkback_Outputs: 0,
Multis: 0,
};
const SD_BURST_DELAY_MS = 200;
let sdBurstDone = false;
const SD_MATRIX_OUT = SD_FIXED_COUNTS.Matrix_Outputs;
const SD_MATRIX_IN = SD_FIXED_COUNTS.Matrix_Inputs;
const SD_CGS = SD_FIXED_COUNTS.Control_Groups;
const SD_GEQS = SD_FIXED_COUNTS.Graphic_EQ;
const SD_GEQ_BANDS = 32;
/** 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];
/** One int per buss: 2 = stereo, 1 = mono (captured from the real console; Aux and Group arrays were identical). */
const SD_BUSS_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];
/** One int per input: 1 = mono, 2 = stereo (captured). */
const SD_INPUT_MODES = [
1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 2, 2,
1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 2, 2, 2, 2, 1, 2, 1, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1,
];
const SD_INPUT_NAMES = [
'KICK In', 'SNARE Top', 'SNARE Bot', 'HAT', 'TOM 1', 'TOM 2', 'FLOOR TOM', 'OH L', 'OH R',
@@ -551,36 +583,95 @@ 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_MATRIX_NAMES = ['MAINS L', 'MAINS R', 'SUB L', 'SUB R', 'ProP LR', 'Matrix 6', 'Matrix 7', 'Studio'];
const SD_CG_NAMES = ['DRUMS', 'GTR', 'KEYS', 'TRKS', 'VOCALS', 'FX', '', 'ProP', 'MC & SPEAKER', ''];
/** Snapshot list: index n has cue number (n+1)*100 (reported as iiis [index, cue*100, 0, name]). */
const SD_SNAPSHOTS = [
'START', 'Pre Service', 'Worship 1', 'Worship 2', 'Message', 'Offering', 'Closing', 'BYPASS WAVES SUPERRACK!',
];
const SD_PROPS = {
name: { kind: 's', readOnly: true },
inputName: { kind: 's' }, // input names accept a write (not verified on the real console)
fader: { kind: 'f', min: -150, max: 10 }, // dB, -150 = -inf
mute: { kind: 'flag' },
solo: { kind: 'flag' },
pan: { kind: 'f', min: 0, max: 1 }, // 0.5 = centre (Panner/pan and send_pan)
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
on: { kind: 'flag' }, // send_on / geq_in
prepost: { kind: 'int', min: 0, max: 1 }, // send_pre-post; captured as an int, range assumed
group: { kind: 'flag', wire: SD_GROUP_ASSIGN_WIRE_TYPE }, // Group_Send/M/group assign 0/1
geqGain: { kind: 'f', min: -12, max: 12 },
};
/**
* Per-channel switches and values answered on inputs and busses (Aux_Outputs and Group_Outputs),
* keyed by the path under /<tree>/N/. Switches are written as ints and reported as floats 0/1.
*/
const SD_SWITCH = { kind: 'flag' };
const sdValue = (min, max) => ({ kind: 'f', min, max });
const SD_PARAM_SPECS = {
input: {
'Channel_Input/trim': { spec: sdValue(-40, 40), def: 0 },
'Channel_Input/phase': { spec: SD_SWITCH, def: 0 },
'Channel_Input/phantom': { spec: SD_SWITCH, def: 0 },
'Channel_Input/analog_gain': { spec: sdValue(-20, 80), def: 0 },
'Channel_Input/input_pad': { spec: SD_SWITCH, def: 0 },
'EQ/eq_in': { spec: SD_SWITCH, def: 1 },
'Dynamics/comp_in': { spec: SD_SWITCH, def: 1 },
'Dynamics/gate_in': { spec: SD_SWITCH, def: 0 },
'Filters/hi_filter_in': { spec: SD_SWITCH, def: 0 },
'Filters/lo_filter_in': { spec: SD_SWITCH, def: 0 },
'Filters/hi_filter_freq': { spec: sdValue(20, 20000), def: 12000 },
'Filters/lo_filter_freq': { spec: sdValue(20, 20000), def: 40 },
'Channel_Delay/delay_on': { spec: SD_SWITCH, def: 0 },
'Channel_Delay/delay': { spec: sdValue(0, 10000), def: 0 },
'Insert/insert_A_in': { spec: SD_SWITCH, def: 0 },
'Insert/insert_B_in': { spec: SD_SWITCH, def: 0 },
},
buss: {
'Buss_Trim/trim': { spec: sdValue(-40, 40), def: 0 },
'Buss_Trim/phase': { spec: SD_SWITCH, def: 0 },
'EQ/eq_in': { spec: SD_SWITCH, def: 1 },
'Dynamics/comp_in': { spec: SD_SWITCH, def: 0 },
'Dynamics/gate_in': { spec: SD_SWITCH, def: 0 },
'Channel_Delay/delay_on': { spec: SD_SWITCH, def: 0 },
'Channel_Delay/delay': { spec: sdValue(0, 10000), def: 0 },
'Insert/insert_A_in': { spec: SD_SWITCH, def: 0 },
'Insert/insert_B_in': { spec: SD_SWITCH, def: 0 },
},
matrix: {
'Buss_Trim/trim': { spec: sdValue(-40, 40), def: 0 },
},
};
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 sdBusCount = 0; // set from --auxes when the state is built
const sdInputs = [null]; // 1-based: { name, fader, mute, solo, pan, sends:[null,{level,on,pan,prepost}...], groupSends:[null,{group}...] }
const sdAuxes = [null]; // 1-based: { name, fader, mute, solo }
const sdGroups = [null]; // 1-based: { name, fader, mute, solo }
const sdMatrix = [null]; // 1-based matrix outputs: { name, fader, mute, solo }
const sdMatrixIn = [null]; // 1-based matrix inputs: { sends:[null,{level,on}...] }
const sdCgs = [null]; // 1-based control groups: { name, fader, mute, solo }
const sdGeqs = [null]; // 1-based graphic EQs: { name, on, gains:[null, ...] }
let sdSnapshot = 3; // /Snapshots/Current_Snapshot
const sdMeterSubs = new Map(); // meter id -> last value sent
let sdMeterTimer = null;
const SD_METER_INTERVAL_MS = 200; // ~5 Hz
function sdBussTypes() {
// 1 = aux, 0 = group; a buss named only on the Group side is a group.
return Array.from({ length: sdBusCount }, (_, i) => (SD_GROUP_NAMES[i] && !SD_AUX_NAMES[i] ? 0 : 1));
}
function buildStateSd() {
const rng = mulberry32(31);
sdBusCount = Math.min(opts.auxes, SD_BUSS_MAX);
for (let n = 1; n <= opts.channels; n++) {
const sends = [null];
for (let m = 1; m <= opts.auxes; m++) {
for (let m = 1; m <= sdBusCount; m++) {
const active = rng() < 0.5;
sends.push({
level: active ? tidy16(-40 + rng() * 40) : -150,
@@ -591,35 +682,39 @@ function buildStateSd() {
}
// 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;
for (let g = 1; g <= sdBusCount; g++) groupSends.push({ group: 0 });
if (sdBusCount >= 24 && (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,
solo: 0,
pan: 0.5,
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 < sdBusCount; k++) {
sdAuxes.push({ name: SD_AUX_NAMES[k], fader: tidy16(-10 + rng() * 10), mute: 0, solo: 0 });
sdGroups.push({ name: SD_GROUP_NAMES[k], fader: tidy16(-10 + rng() * 10), mute: 0, solo: 0 });
}
for (let k = 0; k < SD_BUSS_COUNT; k++) {
sdGroups.push({
name: SD_GROUP_NAMES[k],
fader: tidy16(-10 + rng() * 10),
mute: 0,
});
if (sdBusCount >= 24) sdGroups[24].fader = -1.6875; // captured
for (let k = 0; k < SD_MATRIX_OUT; k++) sdMatrix.push({ name: SD_MATRIX_NAMES[k], fader: tidy16(-30 + rng() * 25), mute: 0, solo: 0 });
for (let k = 0; k < SD_MATRIX_IN; k++) {
const sends = [null];
for (let m = 1; m <= SD_MATRIX_OUT; m++) sends.push({ level: -90, on: 0 }); // captured: -90 / 0
sdMatrixIn.push({ sends });
}
for (let k = 0; k < SD_CGS; k++) sdCgs.push({ name: SD_CG_NAMES[k], fader: tidy16(-20 + rng() * 20), mute: 0, solo: 0 });
for (let k = 1; k <= SD_GEQS; k++) {
const gains = [null];
for (let b = 1; b <= SD_GEQ_BANDS; b++) gains.push(0);
sdGeqs.push({ name: `Graphic EQ ${k}`, on: 1, gains });
}
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) {
if (sdBusCount >= 1) {
sdInputs[1].sends[1] = { level: -150, on: 0, pan: 0.5, prepost: 0 };
sdAuxes[1].fader = -4.375;
sdAuxes[1].mute = 0;
@@ -627,40 +722,84 @@ function buildStateSd() {
}
const SD_RE_INPUT_NAME = /^\/Input_Channels\/(\d+)\/Channel_Input\/name$/;
const SD_RE_INPUT_PROP = /^\/Input_Channels\/(\d+)\/(fader|mute)$/;
const SD_RE_INPUT_PROP = /^\/Input_Channels\/(\d+)\/(fader|mute|solo)$/;
const SD_RE_INPUT_PAN = /^\/Input_Channels\/(\d+)\/Panner\/pan$/;
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_AUX_PROP = /^\/Aux_Outputs\/(\d+)\/(fader|mute|solo)$/;
const SD_RE_GROUP_NAME = /^\/Group_Outputs\/(\d+)\/Buss_Trim\/name$/;
const SD_RE_GROUP_PROP = /^\/Group_Outputs\/(\d+)\/(fader|mute)$/;
const SD_RE_GROUP_PROP = /^\/Group_Outputs\/(\d+)\/(fader|mute|solo)$/;
const SD_RE_MATRIX_NAME = /^\/Matrix_Outputs\/(\d+)\/Buss_Trim\/name$/;
const SD_RE_MATRIX_PROP = /^\/Matrix_Outputs\/(\d+)\/(fader|mute|solo)$/;
const SD_RE_MATRIX_SEND = /^\/Matrix_Inputs\/(\d+)\/Matrix_Send\/(\d+)\/(send_level|send_on)$/;
const SD_RE_CG_NAME = /^\/Control_Groups\/(\d+)\/name$/;
const SD_RE_CG_PROP = /^\/Control_Groups\/(\d+)\/(fader|mute|solo)$/;
const SD_RE_GEQ = /^\/Graphic_EQ\/(\d+)\/(name|geq_in|geq_gain_(\d+))$/;
const SD_RE_PARAM = /^\/(Input_Channels|Aux_Outputs|Group_Outputs|Matrix_Outputs)\/(\d+)\/((?:Channel_Input|Filters|Channel_Delay|EQ|Dynamics|Insert|Buss_Trim)\/\w+)$/;
const SD_RE_METER_SUB = /^\/Input_Channels\/(\d+)\/(?:Channel_Input\/post_meter|Dynamics\/GR_meter)$/;
/** Map an SD leaf address to { target, prop } or null when there is no such thing. */
/** Map an SD leaf address to { target, prop, spec } 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;
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: 'name', spec: SD_PROPS.inputName } : null;
m = SD_RE_INPUT_PROP.exec(address);
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: m[2] } : null;
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: m[2], spec: SD_PROPS[m[2]] } : null;
m = SD_RE_INPUT_PAN.exec(address);
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: 'pan', spec: SD_PROPS.pan } : 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;
const prop = SD_SEND_PROPS[m[3]];
return snd ? { target: snd, prop, spec: SD_PROPS[prop] } : 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;
return gs ? { target: gs, prop: 'group', spec: SD_PROPS.group } : null;
}
const tables = [
[SD_RE_GROUP_NAME, sdGroups, 'name'], [SD_RE_GROUP_PROP, sdGroups, null],
[SD_RE_AUX_NAME, sdAuxes, 'name'], [SD_RE_AUX_PROP, sdAuxes, null],
[SD_RE_MATRIX_NAME, sdMatrix, 'name'], [SD_RE_MATRIX_PROP, sdMatrix, null],
[SD_RE_CG_NAME, sdCgs, 'name'], [SD_RE_CG_PROP, sdCgs, null],
];
for (const [re, list, fixedProp] of tables) {
m = re.exec(address);
if (!m) continue;
const target = list[Number(m[1])];
if (!target) return null;
const prop = fixedProp || m[2];
return { target, prop, spec: SD_PROPS[prop] };
}
m = SD_RE_PARAM.exec(address);
if (m) {
const kind = { Input_Channels: 'input', Matrix_Outputs: 'matrix' }[m[1]] || 'buss';
const entry = SD_PARAM_SPECS[kind][m[3]];
const owner = { Input_Channels: sdInputs, Aux_Outputs: sdAuxes, Group_Outputs: sdGroups, Matrix_Outputs: sdMatrix }[m[1]][Number(m[2])];
if (!entry || !owner) return null;
if (!owner.params) owner.params = {};
if (!(m[3] in owner.params)) owner.params[m[3]] = entry.def;
return { target: owner.params, prop: m[3], spec: entry.spec };
}
m = SD_RE_MATRIX_SEND.exec(address);
if (m) {
const input = sdMatrixIn[Number(m[1])];
const snd = input && input.sends[Number(m[2])];
const prop = m[3] === 'send_on' ? 'on' : 'level';
return snd ? { target: snd, prop, spec: SD_PROPS[prop] } : null;
}
m = SD_RE_GEQ.exec(address);
if (m) {
const geq = sdGeqs[Number(m[1])];
if (!geq) return null;
if (m[2] === 'name') return { target: geq, prop: 'name', spec: SD_PROPS.name };
if (m[2] === 'geq_in') return { target: geq, prop: 'on', spec: SD_PROPS.on };
const band = Number(m[3]);
return band >= 1 && band <= SD_GEQ_BANDS ? { target: geq.gains, prop: band, spec: SD_PROPS.geqGain } : 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;
}
@@ -672,34 +811,89 @@ function handleSd(address, args) {
return sdSet(address, args);
}
/** The nine counts the console pushes once, shortly after first contact (catalog: unsolicited, order as captured). */
function sdCountBurst() {
return [
['/Console/Input_Channels', [int(opts.channels)]],
['/Console/Aux_Outputs', [int(sdBusCount)]],
['/Console/Group_Outputs', [int(sdBusCount)]],
['/Console/Control_Groups', [int(SD_CGS)]],
['/Console/Talkback_Outputs', [int(0)]],
['/Console/Matrix_Outputs', [int(SD_MATRIX_OUT)]],
['/Console/Matrix_Inputs', [int(SD_MATRIX_IN)]],
['/Console/Graphic_EQ', [int(SD_GEQS)]],
['/Console/Multis', [int(0)]],
];
}
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)));
if (base === '/Console/Session/Filename') return void send(base, [SD_SESSION_NAME]);
// The Input_Channels / Aux_Outputs / Group_Outputs counts never answer a query (catalog): only the burst does.
if (base === '/Console/Aux_Outputs/types') return void send(base, sdBussTypes().map((t) => int(t)));
if (base === '/Console/Aux_Outputs/modes' || base === '/Console/Group_Outputs/modes') {
return void send(base, SD_BUSS_MODES.slice(0, sdBusCount).map((mode) => int(mode)));
}
if (base === '/Console/Input_Channels/modes') return void send(base, SD_INPUT_MODES.slice(0, opts.channels).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/count') return void send(base, [int(SD_SNAPSHOTS.length)]);
// /Snapshots/name/? without an argument is unreliable on the real console: left silent here.
if (base === '/Snapshots/names') {
startPaced('snapshot names', SD_SNAPSHOTS.map((name, index) => [
'/Snapshots/name', [int(index), int((index + 1) * 100), int(0), name],
]));
]), { append: true });
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])]);
send(base, [toArg(r.spec, r.target[r.prop])]);
return undefined;
}
function sdFireSnapshot(index) {
sdSnapshot = index;
send('/Snapshots/Current_Snapshot', [int(sdSnapshot)]);
}
function sdSet(address, args) {
// Write-only trigger nodes
if (address === '/Snapshots/Fire_Snapshot_number') {
if (typeof args[0] !== 'number' || args[0] < 0 || args[0] >= SD_SNAPSHOTS.length) return ignore(`snapshot fire with ${formatArgs(args)}`);
note(`snapshot fire: recall #${Math.trunc(args[0])}`);
return void sdFireSnapshot(Math.trunc(args[0]));
}
if (address === '/Snapshots/Fire_Next_Snapshot') {
note('snapshot fire next');
return void sdFireSnapshot(Math.min(SD_SNAPSHOTS.length - 1, sdSnapshot + 1));
}
if (address === '/Snapshots/Fire_Prev_Snapshot') {
note('snapshot fire previous');
return void sdFireSnapshot(Math.max(0, sdSnapshot - 1));
}
if (address === '/Macros/Buttons/press') {
note(`macro press ${formatArgs(args)} (logged only: the real console never answered a macro query)`);
return undefined;
}
// Meter subscriptions: /Input_Channels/N/Channel_Input/post_meter [id, kind]; /Meters/clear drops them all
if (address === '/Meters/clear') {
sdMeterSubs.clear();
note('meters cleared');
return undefined;
}
const sub = SD_RE_METER_SUB.exec(address);
if (sub) {
if (!sdInputs[Number(sub[1])] || typeof args[0] !== 'number') return ignore(`meter subscription ${address} ${formatArgs(args)}`);
sdMeterSubs.set(Math.trunc(args[0]), -1);
return undefined;
}
const r = sdResolve(address);
if (!r) return ignore(`no reply for ${address} (unknown or unsupported address)`);
const spec = SD_PROPS[r.prop];
const spec = r.spec;
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]);
@@ -708,14 +902,27 @@ function sdSet(address, args) {
return undefined;
}
/** ~5 Hz: random 0..127 for every subscribed id whose value changed, as /Meters/values [id, v, id, v ...]. */
function sdMeterTick() {
if (!sdMeterSubs.size || !replyTarget) return;
const pairs = [];
for (const [id, last] of sdMeterSubs) {
const v = Math.random() < 0.15 ? 0 : Math.floor(Math.random() * 128);
if (v === last) continue;
sdMeterSubs.set(id, v);
pairs.push(int(id), int(v));
}
for (let i = 0; i < pairs.length; i += 40) send('/Meters/values', pairs.slice(i, i + 40));
}
function liveTickSd() {
const n = 1 + Math.floor(Math.random() * opts.channels);
const input = sdInputs[n];
if (opts.auxes === 0) {
if (sdBusCount === 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 s = 1 + Math.floor(Math.random() * sdBusCount);
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));
@@ -750,6 +957,12 @@ socket.on('message', (msg, rinfo) => {
note(`no --reply-to given; locking feedback to ${replyTarget.host}:${replyTarget.port} (first sender was ${rinfo.address}:${rinfo.port})`);
}
// The real console pushes all its counts once, shortly after first contact, and not reliably later.
if (isSd && !sdBurstDone) {
sdBurstDone = true;
setTimeout(() => startPaced('count burst', sdCountBurst(), { append: true }), SD_BURST_DELAY_MS);
}
if (packet.raw === undefined) handle(packet.address, packet.args);
});
@@ -768,6 +981,7 @@ socket.on('listening', () => {
? `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 (isSd) sdMeterTimer = setInterval(sdMeterTick, SD_METER_INTERVAL_MS);
if (opts.live) {
note(`live mode: changing a random send every ${LIVE_INTERVAL_MS / 1000}s`);
setInterval(liveTick, LIVE_INTERVAL_MS);
@@ -776,6 +990,7 @@ socket.on('listening', () => {
function shutdown(signal) {
if (dumpTimer) clearTimeout(dumpTimer);
if (sdMeterTimer) clearInterval(sdMeterTimer);
note(`${signal}: received ${stats.received} packets (${stats.undecodable} undecodable), sent ${stats.sent}, ignored ${stats.ignored}`);
try {
socket.close();