Files
companion-module-digico-s31/tools/mock-s31.js
T

793 lines
29 KiB
JavaScript
Raw Normal View History

#!/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);