#!/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 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, 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 * 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] * [--delay MS] [--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 (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" --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' }, delay: { 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), delay: intOption('delay', 0, 5000, 0), 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; 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') * 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); 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; } 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, { 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`); } 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 // 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'; /** 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). The catalog shows 'f'. */ const SD_GROUP_ASSIGN_WIRE_TYPE = 'f'; /** * 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 = { Control_Groups: 10, Matrix_Outputs: 8, Matrix_Inputs: 10, Graphic_EQ: 16, 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 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', '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', ]; 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 / 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 }, matrixLevel: { kind: 'f', min: -90, max: 10 }, // a matrix send floors at -90 dB (S31: off sends read -90), unlike -150 elsewhere }; /** * Per-channel switches and values answered on inputs and busses (Aux_Outputs and Group_Outputs), * keyed by the path under //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); 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 <= sdBusCount; 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 <= 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 < 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 }); } 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 }); } // 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 (sdBusCount >= 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|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|solo)$/; const SD_RE_GROUP_NAME = /^\/Group_Outputs\/(\d+)\/Buss_Trim\/name$/; 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, 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', 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], 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])]; 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', 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: prop === 'level' ? SD_PROPS.matrixLevel : SD_PROPS.on } : 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; } return null; } function handleSd(address, args, types) { 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, types); } /** 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/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(r.spec, r.target[r.prop])]); return undefined; } function sdFireSnapshot(index) { sdSnapshot = index; send('/Snapshots/Current_Snapshot', [int(sdSnapshot)]); } function sdSet(address, args, types) { // 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 = r.spec; if (spec.readOnly) return ignore(`${address} is read-only`); // 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)`); 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; } /** ~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 (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() * 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)); } 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})`); } // 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, packet.types); }); 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 (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); } }); 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(); } 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);