2026-10-15 15:54:39 -04:00
|
|
|
#!/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.
|
|
|
|
|
*
|
2026-10-15 17:11:17 -04:00
|
|
|
* sd - the "DiGiCo Pad" / SD dialect the real S31 iPad extension speaks
|
|
|
|
|
* (tools/catalog/s31-catalog.md lists every address it answers).
|
2026-10-15 15:54:39 -04:00
|
|
|
* 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
|
2026-10-15 17:32:17 -04:00
|
|
|
* types (mute, solo and send_on come back as floats 0/1, and a 0/1
|
|
|
|
|
* switch only accepts a SET as a float: int and T/F writes are ignored). It also streams
|
2026-10-15 17:11:17 -04:00
|
|
|
* 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.
|
2026-10-15 15:54:39 -04:00
|
|
|
*
|
|
|
|
|
* Usage:
|
|
|
|
|
* node tools/mock-s31.js [--dialect sseries|sd] [--listen 8000] [--reply-to IP:PORT]
|
|
|
|
|
* [--channels N] [--auxes N] [--bool-int] [--live]
|
2026-10-15 17:11:17 -04:00
|
|
|
* [--delay MS] [--types] [--quiet]
|
2026-10-15 15:54:39 -04:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
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]
|
2026-10-15 17:11:17 -04:00
|
|
|
--auxes N aux busses / sends per channel (sd: busses) [sseries 16 (max 16),
|
2026-10-15 15:54:39 -04:00
|
|
|
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)
|
2026-10-15 17:11:17 -04:00
|
|
|
--delay MS answer and echo every message MS milliseconds late (0-5000)
|
|
|
|
|
[0]; for testing a slow console and stale echoes
|
2026-10-15 15:54:39 -04:00
|
|
|
--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' },
|
2026-10-15 17:11:17 -04:00
|
|
|
delay: { type: 'string' },
|
2026-10-15 15:54:39 -04:00
|
|
|
'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),
|
2026-10-15 17:11:17 -04:00
|
|
|
delay: intOption('delay', 0, 5000, 0),
|
2026-10-15 15:54:39 -04:00
|
|
|
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)
|
2026-10-15 17:32:17 -04:00
|
|
|
* flag 0/1 number (sd; a SET is accepted only as a float 0.0 / 1.0, exactly like the S31: int and
|
|
|
|
|
* T/F writes are silently ignored; reported as a float, or as an int when the spec says wire: 'i')
|
2026-10-15 15:54:39 -04:00
|
|
|
* 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);
|
2026-10-15 17:11:17 -04:00
|
|
|
const target = replyTarget;
|
|
|
|
|
const deliver = () => socket.send(buf, target.port, target.host, (err) => {
|
2026-10-15 15:54:39 -04:00
|
|
|
if (err) note(`send error: ${err.message}`);
|
|
|
|
|
});
|
2026-10-15 17:11:17 -04:00
|
|
|
if (opts.delay > 0) setTimeout(deliver, opts.delay);
|
|
|
|
|
else deliver();
|
2026-10-15 15:54:39 -04:00
|
|
|
} 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 = '';
|
|
|
|
|
|
2026-10-15 17:11:17 -04:00
|
|
|
function startPaced(label, queue, { append = false } = {}) {
|
2026-10-15 15:54:39 -04:00
|
|
|
if (!replyTarget) return; // cannot happen via the socket: the first datagram locks a target
|
2026-10-15 17:11:17 -04:00
|
|
|
if (dumpTimer && append) {
|
|
|
|
|
// sd dialect: bursts queue up behind each other instead of restarting the running one
|
|
|
|
|
dumpQueue.push(...queue);
|
|
|
|
|
dumpTotal += queue.length;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-15 15:54:39 -04:00
|
|
|
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
|
2026-10-15 17:11:17 -04:00
|
|
|
// 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.
|
2026-10-15 15:54:39 -04:00
|
|
|
// ===========================================================================
|
|
|
|
|
|
|
|
|
|
const SD_CONSOLE_NAME = 'S31 AB';
|
2026-10-15 17:11:17 -04:00
|
|
|
const SD_SESSION_NAME = 'Mock Session.session';
|
2026-10-15 15:54:39 -04:00
|
|
|
|
2026-10-15 17:11:17 -04:00
|
|
|
/** Number of busses (each exposed as an Aux_Outputs and as a Group_Outputs entry); --auxes sets it. */
|
|
|
|
|
const SD_BUSS_MAX = 25;
|
2026-10-15 15:54:39 -04:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Wire type of /Input_Channels/N/Group_Send/M/group replies: 'f' (float 0/1, like
|
2026-10-15 17:11:17 -04:00
|
|
|
* mute and send_on) or 'i' (int). The catalog shows 'f'.
|
2026-10-15 15:54:39 -04:00
|
|
|
*/
|
|
|
|
|
const SD_GROUP_ASSIGN_WIRE_TYPE = 'f';
|
|
|
|
|
|
2026-10-15 17:11:17 -04:00
|
|
|
/**
|
|
|
|
|
* 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).
|
|
|
|
|
*/
|
2026-10-15 15:54:39 -04:00
|
|
|
const SD_FIXED_COUNTS = {
|
|
|
|
|
Control_Groups: 10,
|
|
|
|
|
Matrix_Outputs: 8,
|
|
|
|
|
Matrix_Inputs: 10,
|
|
|
|
|
Graphic_EQ: 16,
|
|
|
|
|
Talkback_Outputs: 0,
|
|
|
|
|
Multis: 0,
|
|
|
|
|
};
|
2026-10-15 17:11:17 -04:00
|
|
|
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;
|
2026-10-15 15:54:39 -04:00
|
|
|
|
2026-10-15 17:11:17 -04:00
|
|
|
/** 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,
|
|
|
|
|
];
|
2026-10-15 15:54:39 -04:00
|
|
|
|
|
|
|
|
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',
|
|
|
|
|
];
|
2026-10-20 10:25:39 -04:00
|
|
|
/** Matrix input names as the console reports them: padded with trailing spaces, with runs of spaces inside (captured). */
|
|
|
|
|
const SD_MATRIX_IN_NAMES = ['TB ', 'Studio L', 'Studio R', 'Master L', 'Master R', 'GUIDE ', 'Studio TB ', 'Dante In 63 ', 'Dante In 64 ', ''];
|
2026-10-15 17:11:17 -04:00
|
|
|
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]). */
|
2026-10-15 15:54:39 -04:00
|
|
|
const SD_SNAPSHOTS = [
|
|
|
|
|
'START', 'Pre Service', 'Worship 1', 'Worship 2', 'Message', 'Offering', 'Closing', 'BYPASS WAVES SUPERRACK!',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const SD_PROPS = {
|
|
|
|
|
name: { kind: 's', readOnly: true },
|
2026-10-15 17:11:17 -04:00
|
|
|
inputName: { kind: 's' }, // input names accept a write (not verified on the real console)
|
2026-10-15 15:54:39 -04:00
|
|
|
fader: { kind: 'f', min: -150, max: 10 }, // dB, -150 = -inf
|
|
|
|
|
mute: { kind: 'flag' },
|
2026-10-15 17:11:17 -04:00
|
|
|
solo: { kind: 'flag' },
|
|
|
|
|
pan: { kind: 'f', min: 0, max: 1 }, // 0.5 = centre (Panner/pan and send_pan)
|
2026-10-15 15:54:39 -04:00
|
|
|
level: { kind: 'f', min: -150, max: 10 }, // send_level, dB
|
2026-10-15 17:11:17 -04:00
|
|
|
on: { kind: 'flag' }, // send_on / geq_in
|
|
|
|
|
prepost: { kind: 'int', min: 0, max: 1 }, // send_pre-post; captured as an int, range assumed
|
2026-10-15 15:54:39 -04:00
|
|
|
group: { kind: 'flag', wire: SD_GROUP_ASSIGN_WIRE_TYPE }, // Group_Send/M/group assign 0/1
|
2026-10-15 17:11:17 -04:00
|
|
|
geqGain: { kind: 'f', min: -12, max: 12 },
|
2026-10-19 15:44:43 -04:00
|
|
|
matrixLevel: { kind: 'f', min: -90, max: 10 }, // a matrix send floors at -90 dB (S31: off sends read -90), unlike -150 elsewhere
|
2026-10-15 17:11:17 -04:00
|
|
|
};
|
|
|
|
|
/**
|
|
|
|
|
* 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 },
|
|
|
|
|
},
|
2026-10-20 10:25:39 -04:00
|
|
|
minput: {
|
|
|
|
|
'Channel_Input/trim': { spec: sdValue(-40, 40), def: 0 },
|
|
|
|
|
'Channel_Input/phase': { spec: SD_SWITCH, def: 0 },
|
|
|
|
|
},
|
2026-10-15 15:54:39 -04:00
|
|
|
};
|
|
|
|
|
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);
|
|
|
|
|
|
2026-10-15 17:11:17 -04:00
|
|
|
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 }
|
2026-10-20 10:25:39 -04:00
|
|
|
const sdMatrixIn = [null]; // 1-based matrix inputs: { name, sends:[null,{level,on}...] }
|
2026-10-15 17:11:17 -04:00
|
|
|
const sdCgs = [null]; // 1-based control groups: { name, fader, mute, solo }
|
|
|
|
|
const sdGeqs = [null]; // 1-based graphic EQs: { name, on, gains:[null, ...] }
|
2026-10-15 15:54:39 -04:00
|
|
|
let sdSnapshot = 3; // /Snapshots/Current_Snapshot
|
2026-10-20 10:25:39 -04:00
|
|
|
/** meter id -> { last, kind: 'level' | 'gr', live }: what was last sent and what to send. Silence is 127; live channels read 20-60. */
|
|
|
|
|
const sdMeterSubs = new Map();
|
|
|
|
|
const SD_LIVE_INPUTS = 3; // inputs 1-3 carry signal; aux 1, group 9 and matrix output 1 do too
|
2026-10-15 17:11:17 -04:00
|
|
|
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));
|
|
|
|
|
}
|
2026-10-15 15:54:39 -04:00
|
|
|
|
|
|
|
|
function buildStateSd() {
|
|
|
|
|
const rng = mulberry32(31);
|
2026-10-15 17:11:17 -04:00
|
|
|
sdBusCount = Math.min(opts.auxes, SD_BUSS_MAX);
|
2026-10-15 15:54:39 -04:00
|
|
|
for (let n = 1; n <= opts.channels; n++) {
|
|
|
|
|
const sends = [null];
|
2026-10-15 17:11:17 -04:00
|
|
|
for (let m = 1; m <= sdBusCount; m++) {
|
2026-10-15 15:54:39 -04:00
|
|
|
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];
|
2026-10-15 17:11:17 -04:00
|
|
|
for (let g = 1; g <= sdBusCount; g++) groupSends.push({ group: 0 });
|
|
|
|
|
if (sdBusCount >= 24 && (n <= 8 || (n >= 18 && n <= 21))) groupSends[24].group = 1;
|
2026-10-15 15:54:39 -04:00
|
|
|
sdInputs.push({
|
|
|
|
|
name: SD_INPUT_NAMES[n - 1] || `INPUT ${n}`,
|
|
|
|
|
fader: tidy16(-30 + rng() * 30),
|
|
|
|
|
mute: rng() < 0.08 ? 1 : 0,
|
2026-10-15 17:11:17 -04:00
|
|
|
solo: 0,
|
|
|
|
|
pan: 0.5,
|
2026-10-15 15:54:39 -04:00
|
|
|
sends,
|
|
|
|
|
groupSends,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-10-15 17:11:17 -04:00
|
|
|
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 });
|
2026-10-15 15:54:39 -04:00
|
|
|
}
|
2026-10-15 17:11:17 -04:00
|
|
|
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
|
2026-10-20 10:25:39 -04:00
|
|
|
sdMatrixIn.push({ name: SD_MATRIX_IN_NAMES[k], sends });
|
2026-10-15 17:11:17 -04:00
|
|
|
}
|
|
|
|
|
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 });
|
2026-10-15 15:54:39 -04:00
|
|
|
}
|
|
|
|
|
// Pin the values captured from the real console so a probe sees the same numbers.
|
|
|
|
|
sdInputs[1].fader = -1.4375;
|
|
|
|
|
sdInputs[1].mute = 0;
|
2026-10-15 17:11:17 -04:00
|
|
|
if (sdBusCount >= 1) {
|
2026-10-15 15:54:39 -04:00
|
|
|
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$/;
|
2026-10-15 17:11:17 -04:00
|
|
|
const SD_RE_INPUT_PROP = /^\/Input_Channels\/(\d+)\/(fader|mute|solo)$/;
|
|
|
|
|
const SD_RE_INPUT_PAN = /^\/Input_Channels\/(\d+)\/Panner\/pan$/;
|
2026-10-15 15:54:39 -04:00
|
|
|
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$/;
|
2026-10-15 17:11:17 -04:00
|
|
|
const SD_RE_AUX_PROP = /^\/Aux_Outputs\/(\d+)\/(fader|mute|solo)$/;
|
2026-10-15 15:54:39 -04:00
|
|
|
const SD_RE_GROUP_NAME = /^\/Group_Outputs\/(\d+)\/Buss_Trim\/name$/;
|
2026-10-15 17:11:17 -04:00
|
|
|
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+))$/;
|
2026-10-20 10:25:39 -04:00
|
|
|
const SD_RE_MINPUT_NAME = /^\/Matrix_Inputs\/(\d+)\/Channel_Input\/name$/;
|
|
|
|
|
const SD_RE_PARAM = /^\/(Input_Channels|Aux_Outputs|Group_Outputs|Matrix_Outputs|Matrix_Inputs)\/(\d+)\/((?:Channel_Input|Filters|Channel_Delay|EQ|Dynamics|Insert|Buss_Trim)\/\w+)$/;
|
|
|
|
|
const SD_RE_METER_SUB = /^\/(Input_Channels|Aux_Outputs|Group_Outputs|Matrix_Outputs)\/(\d+)\/(Channel_Input\/post_meter|Dynamics\/GR_meter|Output\/meter)$/;
|
2026-10-15 15:54:39 -04:00
|
|
|
|
2026-10-15 17:11:17 -04:00
|
|
|
/** Map an SD leaf address to { target, prop, spec } or null when there is no such thing. */
|
2026-10-15 15:54:39 -04:00
|
|
|
function sdResolve(address) {
|
|
|
|
|
let m = SD_RE_INPUT_NAME.exec(address);
|
2026-10-15 17:11:17 -04:00
|
|
|
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: 'name', spec: SD_PROPS.inputName } : null;
|
2026-10-15 15:54:39 -04:00
|
|
|
m = SD_RE_INPUT_PROP.exec(address);
|
2026-10-15 17:11:17 -04:00
|
|
|
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;
|
2026-10-15 15:54:39 -04:00
|
|
|
m = SD_RE_SEND.exec(address);
|
|
|
|
|
if (m) {
|
|
|
|
|
const input = sdInputs[Number(m[1])];
|
|
|
|
|
const snd = input && input.sends[Number(m[2])];
|
2026-10-15 17:11:17 -04:00
|
|
|
const prop = SD_SEND_PROPS[m[3]];
|
|
|
|
|
return snd ? { target: snd, prop, spec: SD_PROPS[prop] } : null;
|
2026-10-15 15:54:39 -04:00
|
|
|
}
|
|
|
|
|
m = SD_RE_GROUP_SEND.exec(address);
|
|
|
|
|
if (m) {
|
|
|
|
|
const input = sdInputs[Number(m[1])];
|
|
|
|
|
const gs = input && input.groupSends[Number(m[2])];
|
2026-10-15 17:11:17 -04:00
|
|
|
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] };
|
|
|
|
|
}
|
2026-10-20 10:25:39 -04:00
|
|
|
m = SD_RE_MINPUT_NAME.exec(address);
|
|
|
|
|
if (m) return sdMatrixIn[Number(m[1])] ? { target: sdMatrixIn[Number(m[1])], prop: 'name', spec: SD_PROPS.name } : null;
|
2026-10-15 17:11:17 -04:00
|
|
|
m = SD_RE_PARAM.exec(address);
|
|
|
|
|
if (m) {
|
2026-10-20 10:25:39 -04:00
|
|
|
const kind = { Input_Channels: 'input', Matrix_Outputs: 'matrix', Matrix_Inputs: 'minput' }[m[1]] || 'buss';
|
2026-10-15 17:11:17 -04:00
|
|
|
const entry = SD_PARAM_SPECS[kind][m[3]];
|
2026-10-20 10:25:39 -04:00
|
|
|
const owner = { Input_Channels: sdInputs, Aux_Outputs: sdAuxes, Group_Outputs: sdGroups, Matrix_Outputs: sdMatrix, Matrix_Inputs: sdMatrixIn }[m[1]][Number(m[2])];
|
2026-10-15 17:11:17 -04:00
|
|
|
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';
|
2026-10-19 15:44:43 -04:00
|
|
|
return snd ? { target: snd, prop, spec: prop === 'level' ? SD_PROPS.matrixLevel : SD_PROPS.on } : null;
|
2026-10-15 17:11:17 -04:00
|
|
|
}
|
|
|
|
|
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;
|
2026-10-15 15:54:39 -04:00
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 17:32:17 -04:00
|
|
|
function handleSd(address, args, types) {
|
2026-10-15 15:54:39 -04:00
|
|
|
if (address.endsWith('/?')) {
|
|
|
|
|
if (args.length !== 0) return ignore(`query ${address} carries arguments ${formatArgs(args)}`);
|
|
|
|
|
return sdQuery(address.slice(0, -2), address);
|
|
|
|
|
}
|
2026-10-15 17:32:17 -04:00
|
|
|
return sdSet(address, args, types);
|
2026-10-15 15:54:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-10-15 17:11:17 -04:00
|
|
|
/** 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)]],
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 15:54:39 -04:00
|
|
|
function sdQuery(base, original) {
|
|
|
|
|
if (base === '/Console/Name') return void send(base, [SD_CONSOLE_NAME]);
|
2026-10-15 17:11:17 -04:00
|
|
|
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)));
|
2026-10-15 15:54:39 -04:00
|
|
|
}
|
2026-10-15 17:11:17 -04:00
|
|
|
if (base === '/Console/Input_Channels/modes') return void send(base, SD_INPUT_MODES.slice(0, opts.channels).map((mode) => int(mode)));
|
2026-10-15 15:54:39 -04:00
|
|
|
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)]);
|
2026-10-15 17:11:17 -04:00
|
|
|
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.
|
2026-10-15 15:54:39 -04:00
|
|
|
if (base === '/Snapshots/names') {
|
|
|
|
|
startPaced('snapshot names', SD_SNAPSHOTS.map((name, index) => [
|
|
|
|
|
'/Snapshots/name', [int(index), int((index + 1) * 100), int(0), name],
|
2026-10-15 17:11:17 -04:00
|
|
|
]), { append: true });
|
2026-10-15 15:54:39 -04:00
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const r = sdResolve(base);
|
|
|
|
|
if (!r) return ignore(`no reply for ${original} (the real console is silent here)`);
|
2026-10-15 17:11:17 -04:00
|
|
|
send(base, [toArg(r.spec, r.target[r.prop])]);
|
2026-10-15 15:54:39 -04:00
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 17:11:17 -04:00
|
|
|
function sdFireSnapshot(index) {
|
|
|
|
|
sdSnapshot = index;
|
|
|
|
|
send('/Snapshots/Current_Snapshot', [int(sdSnapshot)]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 17:32:17 -04:00
|
|
|
function sdSet(address, args, types) {
|
2026-10-15 17:11:17 -04:00
|
|
|
// 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) {
|
2026-10-20 10:25:39 -04:00
|
|
|
const [, tree, numText, path] = sub;
|
|
|
|
|
const owner = { Input_Channels: sdInputs, Aux_Outputs: sdAuxes, Group_Outputs: sdGroups, Matrix_Outputs: sdMatrix }[tree];
|
|
|
|
|
const input = tree === 'Input_Channels';
|
|
|
|
|
if (!owner[Number(numText)] || (input && path === 'Output/meter') || (!input && path !== 'Output/meter') || typeof args[0] !== 'number') {
|
|
|
|
|
return ignore(`meter subscription ${address} ${formatArgs(args)}`);
|
|
|
|
|
}
|
|
|
|
|
const num = Number(numText);
|
|
|
|
|
const kind = path === 'Dynamics/GR_meter' ? 'gr' : 'level';
|
|
|
|
|
const live = kind === 'level' && ((input && num <= SD_LIVE_INPUTS) || (tree === 'Aux_Outputs' && num === 1) || (tree === 'Group_Outputs' && num === 9) || (tree === 'Matrix_Outputs' && num === 1));
|
|
|
|
|
sdMeterSubs.set(Math.trunc(args[0]), { last: -1, kind, live });
|
2026-10-15 17:11:17 -04:00
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 15:54:39 -04:00
|
|
|
const r = sdResolve(address);
|
|
|
|
|
if (!r) return ignore(`no reply for ${address} (unknown or unsupported address)`);
|
2026-10-15 17:11:17 -04:00
|
|
|
const spec = r.spec;
|
2026-10-15 15:54:39 -04:00
|
|
|
if (spec.readOnly) return ignore(`${address} is read-only`);
|
2026-10-15 17:32:17 -04:00
|
|
|
// Verified on a real S31 (2026-10-15): a 0/1 switch only takes a float write; ints and T/F are ignored without any reply.
|
|
|
|
|
if (spec.kind === 'flag' && types !== 'f') return ignore(`${address} is a switch: only a float 0.0 / 1.0 is accepted, not ,${types} (no reply, no change)`);
|
2026-10-15 15:54:39 -04:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-20 10:25:39 -04:00
|
|
|
/**
|
|
|
|
|
* ~5 Hz: /Meters/values [id, v, id, v ...] for every subscribed id whose value changed. The scale is inverted, as on the
|
|
|
|
|
* real console: a level meter reads about the dB below full scale, so silence is 127 and live channels read 20-60;
|
|
|
|
|
* a gain reduction meter reads the dB of reduction (0-12, now and then 127 = inactive).
|
|
|
|
|
*/
|
2026-10-15 17:11:17 -04:00
|
|
|
function sdMeterTick() {
|
|
|
|
|
if (!sdMeterSubs.size || !replyTarget) return;
|
|
|
|
|
const pairs = [];
|
2026-10-20 10:25:39 -04:00
|
|
|
for (const [id, meter] of sdMeterSubs) {
|
|
|
|
|
let v;
|
|
|
|
|
if (meter.kind === 'gr') v = Math.random() < 0.1 ? 127 : Math.floor(Math.random() * 13);
|
|
|
|
|
else v = meter.live ? 20 + Math.floor(Math.random() * 41) : 127;
|
|
|
|
|
if (v === meter.last) continue;
|
|
|
|
|
meter.last = v;
|
2026-10-15 17:11:17 -04:00
|
|
|
pairs.push(int(id), int(v));
|
|
|
|
|
}
|
|
|
|
|
for (let i = 0; i < pairs.length; i += 40) send('/Meters/values', pairs.slice(i, i + 40));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 15:54:39 -04:00
|
|
|
function liveTickSd() {
|
|
|
|
|
const n = 1 + Math.floor(Math.random() * opts.channels);
|
|
|
|
|
const input = sdInputs[n];
|
2026-10-15 17:11:17 -04:00
|
|
|
if (sdBusCount === 0) {
|
2026-10-15 15:54:39 -04:00
|
|
|
setAndEmit(input, 'fader', SD_PROPS.fader, `/Input_Channels/${n}/fader`, tidy16(-40 + Math.random() * 50));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-10-15 17:11:17 -04:00
|
|
|
const s = 1 + Math.floor(Math.random() * sdBusCount);
|
2026-10-15 15:54:39 -04:00
|
|
|
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})`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 17:11:17 -04:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-15 17:32:17 -04:00
|
|
|
if (packet.raw === undefined) handle(packet.address, packet.args, packet.types);
|
2026-10-15 15:54:39 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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)');
|
2026-10-15 17:11:17 -04:00
|
|
|
if (isSd) sdMeterTimer = setInterval(sdMeterTick, SD_METER_INTERVAL_MS);
|
2026-10-15 15:54:39 -04:00
|
|
|
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);
|
2026-10-15 17:11:17 -04:00
|
|
|
if (sdMeterTimer) clearInterval(sdMeterTimer);
|
2026-10-15 15:54:39 -04:00
|
|
|
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);
|