Companion module for the DiGiCo S31 with an iPad relay (v1)

Bitfocus Companion 5 connection module (ESM, @companion-module/base ~2.1.3,
runtime node22) for the DiGiCo S21/S31. It occupies the console's single
iPad slot, controls per-channel sends/assigns to one buss, aux send
level/on/pan, mutes, faders and snapshots, and relays any number of DiGiCo
iPad apps through to the console byte-for-byte.

The S31's iPad extension was probed live: it speaks the SD/DiGiCo Pad OSC
tree (console receives on 8000, replies only to the configured iPad port
9000). tools/mock-s31.js emulates the console and test/selftest.js runs the
link + relay end to end against it. docs/design-v2.md is the brief for the
generic v2 rewrite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 15:55:18 -04:00
co-authored by Claude Fable 5.1
commit 131cdfd81f
23 changed files with 10066 additions and 0 deletions
+140
View File
@@ -0,0 +1,140 @@
# Developer tools
Two small, dependency-free Node.js tools (Node 22+) for developing the S31 module
without a console. They share `osc-lite.js`, a tiny OSC encoder/decoder.
`tools/package.json` pins this folder to CommonJS, because the module's own
`package.json` is `"type": "module"` and would otherwise make Node load these
files as ESM. Keep it.
Both tools speak two OSC dialects, chosen with `--dialect`:
| Dialect | What it is |
| --- | --- |
| `sseries` (default) | The lowercase S-Series command list: `/console/ping`, `/channel/N/fader`, ... |
| `sd` | The "DiGiCo Pad" dialect the **real S31 iPad extension actually speaks**: `/Console/Name/?`, `/Input_Channels/N/fader/?`, ... Verified against a live console. |
## `mock-s31.js` - fake S31 console
Emulates the console's OSC extension. It listens on one UDP port and sends
**all** feedback to **one** configured controller IP:port, whoever asked and
from whichever source port, like the real desk.
```
node tools/mock-s31.js [--dialect sseries|sd] [--listen 8000] [--reply-to IP:PORT]
[--channels N] [--auxes N] [--bool-int] [--live]
[--types] [--quiet]
```
| Flag | Meaning |
| --- | --- |
| `--dialect` | `sseries` (default) or `sd`. |
| `--listen` | UDP port to bind (the console's receive port). Default 8000. |
| `--reply-to` | Fixed feedback destination, e.g. `127.0.0.1:9000`. If omitted, locks onto the first sender's IP with port 9000 and prints that. |
| `--channels` | Input count, 1-60. Default 48 (`sseries`) or 60 (`sd`). |
| `--auxes` | Aux busses / sends per channel. Default 16, max 16 (`sseries`); default 25, max 25 (`sd`). |
| `--bool-int` | `sseries` only: send booleans as int 0/1 instead of OSC `T`/`F`. |
| `--live` | Every 3 s, change a random send level or send on/off and emit it. |
| `--types` | Also print each message's OSC type tags, e.g. `/Input_Channels/1/fader ,f [-1.4375]`. |
| `--quiet` | Don't print every message. |
Traffic is printed as `<- ADDRESS [args] from IP:PORT` and `-> ADDRESS [args]`.
Whole-number floats print with a decimal (`-10.0`) so you can tell int from float.
### sseries dialect
No args at a readable address = query (reply with the value); with an arg =
set (store, then echo to the controller). `/console/resend` dumps the whole
state at ~2 ms per packet. Values are clamped (fader -150..10, send level
-90..10, pan -1..1). Addresses: `/console/{ping,channel/counts,resend}`,
`/channel/N/{name,fader,mute,solo,pan}`, `/channel/N/total/gain` (send-only,
sent with fader changes), `/channel/N/send/M/{level,enabled,pan}`, and
`/digico/snapshots/fire[/next|/previous]` (logged only). Inputs are 1-60, aux
busses 70..70+auxes-1.
### sd dialect
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 arg; it is stored and echoed back in the console's reply types.
| Query | Reply type |
| --- | --- |
| `/Console/Name/?` | `s` ("S31 AB") |
| `/Console/{Input_Channels,Aux_Outputs,Group_Outputs,Control_Groups,Matrix_Outputs,Matrix_Inputs,Graphic_EQ,Talkback_Outputs,Multis}/?` | `i` |
| `/Console/Aux_Outputs/modes/?` | one `i` per aux (2 = stereo, 1 = mono) |
| `/Input_Channels/N/Channel_Input/name/?`, `/Aux_Outputs/N/Buss_Trim/name/?`, `/Group_Outputs/N/Buss_Trim/name/?` | `s` (read-only). The 25 busses appear under both Aux_Outputs and Group_Outputs; a buss reports its name on the side matching its mode and `""` on the other (captured names). |
| `/Input_Channels/N/fader/?`, `/Aux_Outputs/N/fader/?`, `/Group_Outputs/N/fader/?` | `f`, dB, -150 = -inf, max +10 (group 24 = -1.6875) |
| `/Input_Channels/N/mute/?`, `/Aux_Outputs/N/mute/?`, `/Group_Outputs/N/mute/?` | `f` 0/1 (a float, not an int) |
| `/Input_Channels/N/Aux_Send/M/send_level/?` | `f`, dB, -150..10 |
| `/Input_Channels/N/Aux_Send/M/send_on/?` | `f` 0/1 |
| `/Input_Channels/N/Aux_Send/M/send_pan/?` | `f`, 0..1, 0.5 = centre |
| `/Input_Channels/N/Aux_Send/M/send_pre-post/?` | `i` (captured as an int, default 0; settable, clamped 0..1 - range is an assumption) |
| `/Input_Channels/N/Group_Send/M/group/?` | `f` 0/1 group assign, M = 1..25. Default: inputs 1-8 and 18-21 on group 24, nothing else. The reply type is the single constant `SD_GROUP_ASSIGN_WIRE_TYPE` in `mock-s31.js` (`'f'`, assumed like mute/send_on; set `'i'` if the real console reports an int). |
| `/Snapshots/Current_Snapshot/?` | `i` |
| `/Snapshots/names/?` | one `/Snapshots/name ,iiis [index, cue*100, 0, "name"]` per snapshot |
Sets accept int or float for `mute`/`send_on` (any non-zero = on) and clamp
`fader`/`send_level` to -150..10 and `send_pan` to 0..1. Like the real console
it stays **silent** on everything else: all lowercase S-Series addresses,
`/Console/Resend`, `/Console/Channels/?`, `/Input_Channels/N/Aux_Send/M/?`,
`/Input_Channels/N/Aux_Send/?`, `/Macros/Buttons/?`, out-of-range channel/send
numbers, and sets of read-only names. (`--bool-int` has no effect in `sd`.)
## `fake-ipad.js` - stand-in for the DiGiCo iPad app
Binds the receive port, sends its opening requests, repeats a keepalive every
5 s, prints everything it receives and a packet-count summary every 10 s.
```
node tools/fake-ipad.js --console IP --send-port 8000 --receive-port 9000
[--dialect sseries|sd] [--name A] [--types] [--quiet]
```
- `sseries`: sends `/console/ping`, then `/console/resend`; keepalive `/console/ping`; `/console/pong` counts as a pong.
- `sd`: sends `/Console/Name/?`, `/Console/Input_Channels/?`, `/Console/Aux_Outputs/?`, `/Console/Aux_Outputs/modes/?`, names of inputs 1-8 and aux outputs 1-4, then `/Snapshots/names/?` (20 ms apart); keepalive `/Console/Name/?`; a `/Console/Name` reply counts as a pong.
Type `ADDRESS [arg ...]` lines on stdin to send your own messages
(`true`/`false` = boolean, `5` = int, `5.0` or `-12.5` = float, anything else or
`"quoted"` = string; `quit` exits):
```
/channel/3/send/2/level -12.5 # sseries set
/Input_Channels/3/Aux_Send/2/send_on 1 # sd set (echo comes back as ,f [1])
/Input_Channels/3/Aux_Send/2/send_level/? # sd query
/channel/3/name "Lead Vox"
```
## End-to-end loops on one machine
The module sits between the console and the iPad: the console's feedback goes
to the module (port 9000), and the module relays to and from the iPad. Use the
ports the module is actually configured with; `9100` and `9200` below are just
examples for "the relay's iPad-facing port" and "where the relay forwards
feedback to the iPad".
### S-Series dialect
```sh
# 1. Mock console: listens on 8000, replies to whatever listens on 9000
node tools/mock-s31.js --listen 8000 --reply-to 127.0.0.1:9000
# 2. Start the Companion module (or any relay) listening on 9000, sending to 127.0.0.1:8000
# 3. Fake iPad pointed at the relay (sends to 9100, receives the relayed feedback on 9200)
node tools/fake-ipad.js --console 127.0.0.1 --send-port 9100 --receive-port 9200
```
### SD dialect (what the real S31 speaks)
```sh
# 1. Mock console in SD mode: listens on 8000, replies to 127.0.0.1:9000
node tools/mock-s31.js --dialect sd --listen 8000 --reply-to 127.0.0.1:9000 --types
# 2. Start the Companion module (or any relay) listening on 9000, sending to 127.0.0.1:8000
# 3. Fake iPad (SD) pointed at the relay
node tools/fake-ipad.js --dialect sd --console 127.0.0.1 --send-port 9100 --receive-port 9200 --types
```
No module yet? Skip step 2 and point the iPad straight at the mock to check the
tools themselves, e.g.
`node tools/fake-ipad.js --dialect sd --console 127.0.0.1 --send-port 8000 --receive-port 9000`.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env node
'use strict';
/**
* fake-ipad.js - stand-in for the DiGiCo iPad app, for testing the Companion
* module (or an OSC relay) against the mock console or a real S31.
*
* Two OSC dialects (--dialect):
*
* sseries (default) sends /console/ping then /console/resend on start, then
* /console/ping every 5 s; /console/pong counts as a pong.
* sd the "DiGiCo Pad" dialect the real S31 speaks. On start it
* queries /Console/Name/?, /Console/Input_Channels/?,
* /Console/Aux_Outputs/?, /Console/Aux_Outputs/modes/?,
* the names of inputs 1-8 and aux outputs 1-4, and
* /Snapshots/names/?; then /Console/Name/? every 5 s as a
* keepalive. A /Console/Name reply counts as a pong.
*
* In both dialects it binds --receive-port, prints everything it receives and a
* packet-count summary every 10 s, and reads lines from stdin of the form
* `ADDRESS [arg ...]` and sends them, e.g.
* /channel/3/send/2/level -12.5 (sseries)
* /Input_Channels/3/Aux_Send/2/send_on 1 (sd set)
* /Input_Channels/3/Aux_Send/2/send_level/? (sd query)
* /channel/3/name "Lead Vox"
* true/false -> boolean, whole numbers -> int, decimals -> float, anything else
* (or anything in double quotes) -> string. Type "quit" to exit.
*
* Usage:
* node tools/fake-ipad.js --console IP --send-port 8000 --receive-port 9000
* [--dialect sseries|sd] [--name A] [--types] [--quiet]
*/
const dgram = require('node:dgram');
const readline = require('node:readline');
const { parseArgs } = require('node:util');
const { encode, decode, formatArgs, typeTags, int, float } = require('./osc-lite');
// ---------------------------------------------------------------------------
// Command line
// ---------------------------------------------------------------------------
const USAGE = `Usage: node tools/fake-ipad.js --console IP [options]
--console IP host to send requests to (console, relay or mock) (required)
--send-port PORT UDP port on that host [8000]
--receive-port PORT local UDP port to bind for feedback [9000]
--dialect NAME sseries (default): lowercase S-Series OSC list
sd: the dialect the real S31 iPad extension speaks
--name NAME label used in the output lines [A]
--types also print each message's OSC type tags, e.g. ",f"
--quiet only print the periodic summary, not every packet
-h, --help show this help
Type "ADDRESS [arg ...]" lines on stdin to send your own messages.`;
function die(message) {
console.error(`fake-ipad: ${message}\n\n${USAGE}`);
process.exit(2);
}
function parseCli() {
let values;
try {
({ values } = parseArgs({
options: {
console: { type: 'string' },
'send-port': { type: 'string', default: '8000' },
'receive-port': { type: 'string', default: '9000' },
dialect: { type: 'string', default: 'sseries' },
name: { type: 'string', default: 'A' },
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 (!values.console) die('--console IP is required');
if (values.dialect !== 'sseries' && values.dialect !== 'sd') die(`--dialect must be "sseries" or "sd" (got "${values.dialect}")`);
const port = (name) => {
const text = values[name];
if (!/^\d+$/.test(text) || Number(text) < 1 || Number(text) > 65535) die(`--${name} must be a port number 1-65535 (got "${text}")`);
return Number(text);
};
return {
host: values.console,
sendPort: port('send-port'),
receivePort: port('receive-port'),
dialect: values.dialect,
name: values.name,
types: values.types,
quiet: values.quiet,
};
}
const opts = parseCli();
const isSd = opts.dialect === 'sd';
const tag = `[${opts.name}]`;
/** What a "pong" is in this dialect, and the message that provokes one. */
const PONG_ADDRESS = isSd ? '/Console/Name' : '/console/pong';
const KEEPALIVE_ADDRESS = isSd ? '/Console/Name/?' : '/console/ping';
const STARTUP_GAP_MS = isSd ? 20 : 0; // be gentle with a real desk: pace the SD burst
/** The opening requests, in order. */
function startupAddresses() {
if (!isSd) return ['/console/ping', '/console/resend'];
const list = ['/Console/Name/?', '/Console/Input_Channels/?', '/Console/Aux_Outputs/?', '/Console/Aux_Outputs/modes/?'];
for (let n = 1; n <= 8; n++) list.push(`/Input_Channels/${n}/Channel_Input/name/?`);
for (let n = 1; n <= 4; n++) list.push(`/Aux_Outputs/${n}/Buss_Trim/name/?`);
list.push('/Snapshots/names/?');
return list;
}
// ---------------------------------------------------------------------------
// Transport and statistics
// ---------------------------------------------------------------------------
const socket = dgram.createSocket('udp4');
const stats = {
received: 0, // total packets
sent: 0,
pongs: 0,
undecodable: 0,
unpadded: 0,
windowReceived: 0, // packets since the last summary
};
const seenAddresses = new Set();
let pingTimer = null;
let summaryTimer = null;
/** "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}`;
}
/** Encode and send one message to the console. */
function sendMessage(address, args) {
let buf;
try {
buf = encode(address, args);
} catch (err) {
console.log(`${tag} ! cannot encode ${address}: ${err.message}`);
return;
}
socket.send(buf, opts.sendPort, opts.host, (err) => {
if (err) console.log(`${tag} ! send error: ${err.message}`);
});
stats.sent++;
if (!opts.quiet) console.log(`${tag} -> ${describe(address, args, typeTags(args))}`);
}
function printSummary(label) {
console.log(
`${tag} ${label}: received ${stats.received} packets (+${stats.windowReceived} in the last interval), ` +
`${seenAddresses.size} distinct addresses, ${stats.pongs} pong(s), sent ${stats.sent}` +
(stats.undecodable ? `, ${stats.undecodable} undecodable` : '') +
(stats.unpadded ? `, ${stats.unpadded} unpadded` : ''),
);
stats.windowReceived = 0;
}
socket.on('message', (msg) => {
stats.received++;
stats.windowReceived++;
const packet = decode(msg);
if (packet.raw !== undefined) {
stats.undecodable++;
if (!opts.quiet) console.log(`${tag} <- (undecodable, ${msg.length} bytes) ${packet.raw.slice(0, 96)}`);
return;
}
seenAddresses.add(packet.address);
if (packet.unpadded) stats.unpadded++;
if (packet.address === PONG_ADDRESS) stats.pongs++;
if (!opts.quiet) console.log(`${tag} <- ${describe(packet.address, packet.args, packet.types)}`);
});
socket.on('error', (err) => {
console.error(`${tag} socket error: ${err.code === 'EADDRINUSE' ? `UDP port ${opts.receivePort} is already in use` : err.message}`);
process.exit(1);
});
socket.on('listening', () => {
const addr = socket.address();
console.log(`${tag} listening on udp://${addr.address}:${addr.port}; talking to ${opts.host}:${opts.sendPort} (dialect ${opts.dialect})`);
// Same opening sequence as the iPad app, then a keepalive.
startupAddresses().forEach((address, i) => {
if (STARTUP_GAP_MS === 0) sendMessage(address, []);
else setTimeout(() => sendMessage(address, []), i * STARTUP_GAP_MS);
});
pingTimer = setInterval(() => sendMessage(KEEPALIVE_ADDRESS, []), 5000);
summaryTimer = setInterval(() => printSummary('summary'), 10000);
});
// ---------------------------------------------------------------------------
// Interactive stdin: "ADDRESS [arg ...]"
// ---------------------------------------------------------------------------
/** Split a line into tokens; "double quoted" tokens are always strings. */
function tokenize(line) {
const tokens = [];
const re = /"((?:[^"\\]|\\.)*)"|(\S+)/g;
let m;
while ((m = re.exec(line)) !== null) {
if (m[1] !== undefined) tokens.push({ text: m[1].replace(/\\(.)/g, '$1'), quoted: true });
else tokens.push({ text: m[2], quoted: false });
}
return tokens;
}
/** true/false -> boolean, integers -> int, decimals -> float, else string. */
function parseValue(token) {
if (token.quoted) return token.text;
const text = token.text;
if (text === 'true') return true;
if (text === 'false') return false;
if (/^[+-]?\d+$/.test(text)) {
const n = Number(text);
return n >= -2147483648 && n <= 2147483647 ? int(n) : float(n);
}
if (/^[+-]?(\d+\.\d*|\.\d+|\d+(?=[eE]))([eE][+-]?\d+)?$/.test(text)) return float(Number(text));
return text;
}
function handleLine(line) {
const trimmed = line.trim();
if (!trimmed) return;
if (trimmed === 'quit' || trimmed === 'exit') {
shutdown('quit');
return;
}
const tokens = tokenize(trimmed);
const address = tokens.shift().text;
if (address[0] !== '/') {
console.log(`${tag} ? expected "ADDRESS [arg ...]" starting with '/', e.g. ${isSd ? '/Input_Channels/3/Aux_Send/2/send_level -12.5' : '/channel/3/send/2/level -12.5'}`);
return;
}
sendMessage(address, tokens.map(parseValue));
}
// terminal:false keeps Ctrl-C as a normal SIGINT. If stdin is closed or not a
// terminal (running in the background, piped input) we simply keep running.
const rl = readline.createInterface({ input: process.stdin, terminal: false });
rl.on('line', handleLine);
rl.on('close', () => {});
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
function shutdown(reason) {
clearInterval(pingTimer);
clearInterval(summaryTimer);
printSummary(`final (${reason})`);
try {
rl.close();
socket.close();
} catch {
// already closed
}
process.exit(0);
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
socket.bind(opts.receivePort);
+792
View File
@@ -0,0 +1,792 @@
#!/usr/bin/env node
'use strict';
/**
* mock-s31.js - emulates the DiGiCo S31 iPad "OSC Control" extension for
* developing and testing the Companion module without a real console.
*
* Behaviour copied from the real desk (both dialects):
* - It listens on ONE UDP "receive port" (--listen).
* - ALL feedback goes to ONE configured controller IP:port (the console's
* "send port", normally 9000), no matter who sent the request or from
* which source port (--reply-to).
*
* Two OSC dialects (--dialect):
*
* sseries (default) - the lowercase S-Series command list.
* A message with no args at a readable address is a QUERY -> reply with
* the current value. A message with an arg is a SET -> store it, then echo
* it to the controller. /console/resend dumps the whole state.
*
* sd - the "DiGiCo Pad" / SD dialect the real S31 actually speaks
* (verified against a live console).
* A QUERY is the address plus a trailing "/?" and no args; the reply is the
* same address WITHOUT "/?" plus the value. A SET is the address without
* "/?" and one argument; it is stored and echoed using the console's reply
* types (mute and send_on come back as floats 0/1). Anything the real
* console does not answer (lowercase S-Series addresses, /Console/Resend,
* /Console/Channels/?, whole-send queries, ...) gets no reply here either.
*
* Usage:
* node tools/mock-s31.js [--dialect sseries|sd] [--listen 8000] [--reply-to IP:PORT]
* [--channels N] [--auxes N] [--bool-int] [--live]
* [--types] [--quiet]
*/
const dgram = require('node:dgram');
const { parseArgs } = require('node:util');
const { encode, decode, formatArgs, typeTags, int, float } = require('./osc-lite');
// ---------------------------------------------------------------------------
// Command line
// ---------------------------------------------------------------------------
const USAGE = `Usage: node tools/mock-s31.js [options]
--dialect NAME sseries (default): lowercase S-Series OSC list
(/console/ping, /channel/N/fader, ...)
sd: the dialect the real S31 iPad extension speaks
(/Console/Name/?, /Input_Channels/N/fader/?, ...)
--listen PORT UDP port to bind (the console's receive port) [8000]
--reply-to IP:PORT fixed feedback destination (the console's configured
controller). If omitted, lock onto the first sender's IP
with port 9000.
--channels N input channels, 1-60 [sseries 48, sd 60]
--auxes N aux busses / sends per channel [sseries 16 (max 16),
sd 25 (max 25)]
--bool-int sseries only: send booleans as int 0/1 instead of OSC T/F
(the console can be set to "normalise" booleans)
--live every 3 s change a random send level / send-enabled and
emit it, to exercise feedback handling
--types also print each message's OSC type tags, e.g. ",f"
--quiet do not print every message
-h, --help show this help`;
const DIALECTS = {
sseries: { channels: 48, auxes: 16, maxAuxes: 16 },
sd: { channels: 60, auxes: 25, maxAuxes: 25 },
};
function die(message) {
console.error(`mock-s31: ${message}\n\n${USAGE}`);
process.exit(2);
}
function parseCli() {
let values;
try {
({ values } = parseArgs({
options: {
dialect: { type: 'string', default: 'sseries' },
listen: { type: 'string', default: '8000' },
'reply-to': { type: 'string' },
channels: { type: 'string' },
auxes: { type: 'string' },
'bool-int': { type: 'boolean', default: false },
live: { type: 'boolean', default: false },
types: { type: 'boolean', default: false },
quiet: { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
},
allowPositionals: false,
strict: true,
}));
} catch (err) {
die(err.message);
}
if (values.help) {
console.log(USAGE);
process.exit(0);
}
if (!Object.hasOwn(DIALECTS, values.dialect)) die(`--dialect must be "sseries" or "sd" (got "${values.dialect}")`);
const defaults = DIALECTS[values.dialect];
const intOption = (name, min, max, fallback) => {
const text = values[name];
if (text === undefined) return fallback;
if (!/^\d+$/.test(text)) die(`--${name} must be a whole number (got "${text}")`);
const n = Number(text);
if (n < min || n > max) die(`--${name} must be between ${min} and ${max} for the ${values.dialect} dialect (got ${n})`);
return n;
};
let replyTo = null;
if (values['reply-to'] !== undefined) {
const m = /^(.+):(\d{1,5})$/.exec(values['reply-to']);
if (!m || Number(m[2]) < 1 || Number(m[2]) > 65535) die(`--reply-to must look like IP:PORT (got "${values['reply-to']}")`);
replyTo = { host: m[1], port: Number(m[2]) };
}
return {
dialect: values.dialect,
listen: intOption('listen', 1, 65535, 8000),
replyTo,
channels: intOption('channels', 1, 60, defaults.channels),
auxes: intOption('auxes', 0, defaults.maxAuxes, defaults.auxes),
boolInt: values['bool-int'],
live: values.live,
types: values.types,
quiet: values.quiet,
};
}
const opts = parseCli();
const isSd = opts.dialect === 'sd';
const DUMP_INTERVAL_MS = 2; // pacing of multi-packet replies, like a real desk
const LIVE_INTERVAL_MS = 3000;
const DEFAULT_REPLY_PORT = 9000;
// ---------------------------------------------------------------------------
// Shared helpers: PRNG, value coercion, outbound typing
// ---------------------------------------------------------------------------
/** Small deterministic PRNG so every run starts from the same "show file". */
function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Property kinds:
* s string
* f float32 with min/max clamp
* b boolean (sseries; sent as T/F, or int 0/1 with --bool-int)
* flag 0/1 number (sd; accepted as int, float or boolean; reported as a float,
* or as an int when the spec says wire: 'i')
* int integer with min/max clamp (sd)
*/
/** Validate/clamp an inbound OSC argument for a property; undefined = unusable. */
function coerce(spec, arg) {
switch (spec.kind) {
case 's':
return typeof arg === 'string' ? arg : undefined;
case 'f': {
if (typeof arg !== 'number' || !Number.isFinite(arg)) return undefined;
return Math.fround(Math.min(spec.max, Math.max(spec.min, arg)));
}
case 'b': {
if (typeof arg === 'boolean') return arg;
if (typeof arg === 'number') return arg !== 0;
if (typeof arg === 'string' && /^(true|1|on)$/i.test(arg)) return true;
if (typeof arg === 'string' && /^(false|0|off)$/i.test(arg)) return false;
return undefined;
}
case 'flag': {
if (typeof arg === 'boolean') return arg ? 1 : 0;
if (typeof arg === 'number' && Number.isFinite(arg)) return arg !== 0 ? 1 : 0;
return undefined;
}
case 'int': {
let n;
if (typeof arg === 'boolean') n = arg ? 1 : 0;
else if (typeof arg === 'number' && Number.isFinite(arg)) n = Math.round(arg);
else return undefined;
return Math.min(spec.max, Math.max(spec.min, n));
}
default:
return undefined;
}
}
/** Stored value -> outbound OSC argument. */
function toArg(spec, value) {
switch (spec.kind) {
case 'f':
return float(value);
case 'b':
return opts.boolInt ? int(value ? 1 : 0) : Boolean(value);
case 'flag':
return spec.wire === 'i' ? int(value) : float(value); // the real SD console reports mute / send_on as floats
case 'int':
return int(value);
default:
return value;
}
}
// ---------------------------------------------------------------------------
// Transport
// ---------------------------------------------------------------------------
const socket = dgram.createSocket('udp4');
const stats = { received: 0, sent: 0, ignored: 0, undecodable: 0 };
/** Where feedback goes: { host, port } or null until we know a controller. */
let replyTarget = opts.replyTo;
let warnedNoTarget = false;
const note = (text) => console.log(`[mock-s31] ${text}`);
/** Log something that is only interesting while watching the traffic. */
function trace(text) {
if (!opts.quiet) console.log(text);
}
/** "ADDRESS [args]" or, with --types, "ADDRESS ,tags [args]". */
function describe(address, args, types) {
const body = formatArgs(args, types);
return opts.types ? `${address} ,${types} ${body}` : `${address} ${body}`;
}
/** Record and log a message we are deliberately not acting on. */
function ignore(reason) {
stats.ignored++;
trace(`[mock-s31] ignoring: ${reason}`);
}
/** Send one message to the configured controller. */
function send(address, args) {
if (!replyTarget) {
if (!warnedNoTarget) {
warnedNoTarget = true;
note('no controller known yet (no --reply-to and nothing received); dropping feedback until one arrives');
}
return false;
}
try {
const buf = encode(address, args);
socket.send(buf, replyTarget.port, replyTarget.host, (err) => {
if (err) note(`send error: ${err.message}`);
});
} catch (err) {
note(`could not send ${address}: ${err.message}`);
return false;
}
stats.sent++;
if (!opts.quiet) console.log(`-> ${describe(address, args, typeTags(args))}`);
return true;
}
/** Store a new value and emit it, as the console does for every change. */
function setAndEmit(target, prop, spec, address, value, afterSet) {
target[prop] = value;
send(address, [toArg(spec, value)]);
if (afterSet) afterSet(value);
}
// Multi-packet replies (resend dump, snapshot list) are paced ~2 ms apart.
let dumpQueue = [];
let dumpTimer = null;
let dumpTotal = 0;
let dumpLabel = '';
function startPaced(label, queue) {
if (!replyTarget) return; // cannot happen via the socket: the first datagram locks a target
if (dumpTimer) {
clearTimeout(dumpTimer);
note(`${label} requested while a dump was running; restarting it`);
}
dumpQueue = queue;
dumpTotal = queue.length;
dumpLabel = label;
if (dumpTotal > 20) {
note(`${label}: sending ${dumpTotal} packets (~${Math.round((dumpTotal * (DUMP_INTERVAL_MS + 0.5)) / 100) / 10}s)`);
}
pumpPaced();
}
function pumpPaced() {
const next = dumpQueue.shift();
if (!next) {
dumpTimer = null;
if (dumpTotal > 20) note(`${dumpLabel} complete (${dumpTotal} packets)`);
return;
}
send(next[0], next[1]);
dumpTimer = setTimeout(pumpPaced, DUMP_INTERVAL_MS);
}
// ===========================================================================
// Dialect: sseries (lowercase S-Series OSC list)
// channel numbering: inputs 1-60, aux busses 70-79 (extended 70-85 when more
// than 10 auxes), groups 80-93, master 120
// ===========================================================================
const AUX_BASE = 70; // aux busses are channel numbers 70..70+auxes-1
const INPUT_NAMES = [
'Kick', 'Snare', 'Hat', 'Tom 1', 'Tom 2', 'Floor Tom', 'OH L', 'OH R',
'Bass DI', 'Bass Mic', 'Keys L', 'Keys R', 'Acou Gtr', 'Elec Gtr 1', 'Elec Gtr 2',
'Vox 1', 'Vox 2', 'Vox 3', 'Vox 4', 'Vox 5', 'Pastor', 'Choir 1', 'Choir 2',
'Track L', 'Track R', 'Click',
];
const AUX_NAMES = [
'Studio Mon', 'IEM 1', 'IEM 2', 'Wedge 1', 'Wedge 2', 'IEM 3', 'IEM 4', 'Wedge 3',
'Wedge 4', 'Sub Feed', 'Lobby', 'Nursery', 'Stream', 'Green Room', 'FX 1', 'FX 2',
];
const CHANNEL_PROPS = {
name: { kind: 's' },
fader: { kind: 'f', min: -150, max: 10 },
mute: { kind: 'b' },
solo: { kind: 'b' },
pan: { kind: 'f', min: -1, max: 1 },
};
const SEND_PROPS = {
level: { kind: 'f', min: -90, max: 10 },
enabled: { kind: 'b' },
pan: { kind: 'f', min: -1, max: 1 },
};
/** One decimal place, stored as the float32 that actually goes on the wire. */
const tidy = (x) => Math.fround(Math.round(x * 10) / 10);
/** channel number -> { num, kind, name, fader, mute, solo, pan, sends } */
const channels = new Map();
function buildStateSseries() {
const rng = mulberry32(31);
for (let n = 1; n <= opts.channels; n++) {
const sends = [null]; // 1-based
for (let m = 1; m <= opts.auxes; m++) {
const active = rng() < 0.6;
sends.push({
level: active ? tidy(-40 + rng() * 40) : -90,
enabled: active,
pan: rng() < 0.2 ? tidy(rng() * 2 - 1) : 0,
});
}
channels.set(n, {
num: n,
kind: 'input',
name: INPUT_NAMES[n - 1] || `Input ${n}`,
fader: tidy(-30 + rng() * 30),
mute: rng() < 0.08,
solo: false,
pan: 0,
sends,
});
}
for (let k = 0; k < opts.auxes; k++) {
const num = AUX_BASE + k;
channels.set(num, {
num,
kind: 'aux',
name: AUX_NAMES[k] || `Aux ${k + 1}`,
fader: tidy(-10 + rng() * 10),
mute: false,
solo: false,
pan: 0,
sends: null,
});
}
}
/** Query (no args) or set (one arg) of target[prop], echoing to the controller. */
function queryOrSet(target, prop, spec, address, args, afterSet) {
if (args.length === 0) {
send(address, [toArg(spec, target[prop])]); // query
return;
}
const value = coerce(spec, args[0]);
if (value === undefined) {
ignore(`unusable value ${formatArgs(args)} for ${address}`);
return;
}
setAndEmit(target, prop, spec, address, value, afterSet);
}
const RE_CHANNEL = /^\/channel\/(\d+)\/(name|fader|mute|solo|pan)$/;
const RE_TOTAL_GAIN = /^\/channel\/(\d+)\/total\/gain$/;
const RE_SEND = /^\/channel\/(\d+)\/send\/(\d+)\/(level|enabled|pan)$/;
let snapshot = 0; // only used to make the snapshot log lines meaningful
function handleSseries(address, args) {
switch (address) {
case '/console/ping':
send('/console/pong', []);
return;
case '/console/channel/counts':
send(address, [int(opts.channels), int(opts.auxes)]);
return;
case '/console/resend':
startPaced('resend', buildDumpSseries());
return;
case '/digico/snapshots/fire': {
if (typeof args[0] !== 'number') return ignore('snapshot fire without a number');
snapshot = Math.trunc(args[0]);
note(`snapshot fire: recall #${snapshot}`);
return;
}
case '/digico/snapshots/fire/next':
snapshot += 1;
note(`snapshot fire next: now #${snapshot}`);
return;
case '/digico/snapshots/fire/previous':
snapshot = Math.max(0, snapshot - 1);
note(`snapshot fire previous: now #${snapshot}`);
return;
default:
break;
}
let m = RE_CHANNEL.exec(address);
if (m) {
const num = Number(m[1]);
const prop = m[2];
const ch = channels.get(num);
if (!ch) return ignore(`no such channel ${num} (${address})`);
queryOrSet(ch, prop, CHANNEL_PROPS[prop], address, args, prop === 'fader'
? (value) => send(`/channel/${num}/total/gain`, [float(value)]) // send-only, rides along with fader changes
: undefined);
return;
}
m = RE_SEND.exec(address);
if (m) {
const num = Number(m[1]);
const sendNo = Number(m[2]);
const prop = m[3];
const ch = channels.get(num);
if (!ch) return ignore(`no such channel ${num} (${address})`);
const target = ch.sends && ch.sends[sendNo];
if (!target) return ignore(`channel ${num} has no send ${sendNo} (${address})`);
queryOrSet(target, prop, SEND_PROPS[prop], address, args);
return;
}
if (RE_TOTAL_GAIN.test(address)) return ignore(`${address} is send-only`);
ignore(`unknown address ${address}`);
}
/** /console/resend: counts, every input (basics + sends), then every aux bus. */
function buildDumpSseries() {
const out = [['/console/channel/counts', [int(opts.channels), int(opts.auxes)]]];
const basics = (ch) => {
out.push([`/channel/${ch.num}/name`, [toArg(CHANNEL_PROPS.name, ch.name)]]);
out.push([`/channel/${ch.num}/fader`, [toArg(CHANNEL_PROPS.fader, ch.fader)]]);
out.push([`/channel/${ch.num}/mute`, [toArg(CHANNEL_PROPS.mute, ch.mute)]]);
};
for (let n = 1; n <= opts.channels; n++) {
const ch = channels.get(n);
basics(ch);
for (let s = 1; s <= opts.auxes; s++) {
const snd = ch.sends[s];
out.push([`/channel/${n}/send/${s}/level`, [toArg(SEND_PROPS.level, snd.level)]]);
out.push([`/channel/${n}/send/${s}/enabled`, [toArg(SEND_PROPS.enabled, snd.enabled)]]);
out.push([`/channel/${n}/send/${s}/pan`, [toArg(SEND_PROPS.pan, snd.pan)]]);
}
}
for (let k = 0; k < opts.auxes; k++) basics(channels.get(AUX_BASE + k));
return out;
}
function liveTickSseries() {
const n = 1 + Math.floor(Math.random() * opts.channels);
const ch = channels.get(n);
if (opts.auxes === 0) {
// Nothing with sends to wiggle: move a fader instead.
setAndEmit(ch, 'fader', CHANNEL_PROPS.fader, `/channel/${n}/fader`, tidy(-40 + Math.random() * 50),
(v) => send(`/channel/${n}/total/gain`, [float(v)]));
return;
}
const s = 1 + Math.floor(Math.random() * opts.auxes);
const snd = ch.sends[s];
if (Math.random() < 0.5) {
setAndEmit(snd, 'level', SEND_PROPS.level, `/channel/${n}/send/${s}/level`, tidy(-60 + Math.random() * 60));
} else {
setAndEmit(snd, 'enabled', SEND_PROPS.enabled, `/channel/${n}/send/${s}/enabled`, !snd.enabled);
}
}
// ===========================================================================
// Dialect: sd (the "DiGiCo Pad" dialect the real S31 speaks)
// query = address + "/?" (no args); reply = address without "/?" + value
// ===========================================================================
const SD_CONSOLE_NAME = 'S31 AB';
/** The console has 25 busses, each exposed as an Aux_Outputs and as a Group_Outputs entry. */
const SD_BUSS_COUNT = 25;
/**
* Wire type of /Input_Channels/N/Group_Send/M/group replies: 'f' (float 0/1, like
* mute and send_on) or 'i' (int). Not yet verified against the real console -
* flip this one constant if it turns out to be an int.
*/
const SD_GROUP_ASSIGN_WIRE_TYPE = 'f';
/** Fixed counts the console reports besides Input_Channels / Aux_Outputs. */
const SD_FIXED_COUNTS = {
Group_Outputs: SD_BUSS_COUNT,
Control_Groups: 10,
Matrix_Outputs: 8,
Matrix_Inputs: 10,
Graphic_EQ: 16,
Talkback_Outputs: 0,
Multis: 0,
};
/** One int per aux: 2 = stereo, 1 = mono (captured from the real console). */
const SD_AUX_MODES = [2, 2, 2, 1, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2];
const SD_INPUT_NAMES = [
'KICK In', 'SNARE Top', 'SNARE Bot', 'HAT', 'TOM 1', 'TOM 2', 'FLOOR TOM', 'OH L', 'OH R',
'BASS DI', 'BASS Mic', 'KEYS L', 'KEYS R', 'ACOU GTR', 'ELEC GTR 1', 'ELEC GTR 2',
'VOX 1', 'VOX 2', 'VOX 3', 'VOX 4', 'VOX 5', 'PASTOR', 'CHOIR 1', 'CHOIR 2',
'TRACK L', 'TRACK R', 'CLICK',
];
/**
* Captured buss names, buss order 1..25. A buss shows its name under Aux_Outputs
* when it is in aux mode and under Group_Outputs when it is in group mode; the
* other side reports "".
*/
const SD_AUX_NAMES = [
'VOCAL VERB', 'VOCAL SLAP', 'TRACKS', 'CLK-GD-MD', 'VOCAL DELAY', 'VOCALS', 'TB', 'MC/SpkrBcastOut',
'', '', '', '', '', 'DRUM PLATE', 'AG VERB', 'FOH Spkr', 'Studio TB', '', 'FX', 'Prop & Spot',
'Drum Room Verb', '', '', '', '',
];
const SD_GROUP_NAMES = [
'', '', '', '', '', '', '', '', 'BAND', 'VOCALS', 'MISC', 'DRUMS', 'DRUM CRUSH', '', '', '', '', 'Broadcast mix',
'', '', '', '', 'CAFE', 'Studio', 'Master',
];
/** Snapshot list: index n has cue number (n+1)*100. */
const SD_SNAPSHOTS = [
'START', 'Pre Service', 'Worship 1', 'Worship 2', 'Message', 'Offering', 'Closing', 'BYPASS WAVES SUPERRACK!',
];
const SD_PROPS = {
name: { kind: 's', readOnly: true },
fader: { kind: 'f', min: -150, max: 10 }, // dB, -150 = -inf
mute: { kind: 'flag' },
level: { kind: 'f', min: -150, max: 10 }, // send_level, dB
on: { kind: 'flag' }, // send_on
pan: { kind: 'f', min: 0, max: 1 }, // send_pan, 0.5 = centre
prepost: { kind: 'int', min: 0, max: 1 }, // send_pre-post; captured as an int, range assumed 0 = pre, 1 = post
group: { kind: 'flag', wire: SD_GROUP_ASSIGN_WIRE_TYPE }, // Group_Send/M/group assign 0/1
};
const SD_SEND_PROPS = { send_level: 'level', send_on: 'on', send_pan: 'pan', 'send_pre-post': 'prepost' };
/** Real SD faders land on 1/16 dB steps (e.g. -1.4375, -4.375). */
const tidy16 = (x) => Math.fround(Math.round(x * 16) / 16);
const sdInputs = [null]; // 1-based: { name, fader, mute, sends:[null,{level,on,pan,prepost}...], groupSends:[null,{group}...] }
const sdAuxes = [null]; // 1-based: { name, fader, mute }
const sdGroups = [null]; // 1-based, always SD_BUSS_COUNT: { name, fader, mute }
let sdSnapshot = 3; // /Snapshots/Current_Snapshot
function buildStateSd() {
const rng = mulberry32(31);
for (let n = 1; n <= opts.channels; n++) {
const sends = [null];
for (let m = 1; m <= opts.auxes; m++) {
const active = rng() < 0.5;
sends.push({
level: active ? tidy16(-40 + rng() * 40) : -150,
on: active ? 1 : 0,
pan: rng() < 0.2 ? tidy16(rng()) : 0.5,
prepost: 0,
});
}
// Group assign: inputs 1-8 and 18-21 feed group 24, nothing else.
const groupSends = [null];
for (let g = 1; g <= SD_BUSS_COUNT; g++) groupSends.push({ group: 0 });
if (n <= 8 || (n >= 18 && n <= 21)) groupSends[24].group = 1;
sdInputs.push({
name: SD_INPUT_NAMES[n - 1] || `INPUT ${n}`,
fader: tidy16(-30 + rng() * 30),
mute: rng() < 0.08 ? 1 : 0,
sends,
groupSends,
});
}
for (let k = 0; k < opts.auxes; k++) {
sdAuxes.push({
name: SD_AUX_NAMES[k],
fader: tidy16(-10 + rng() * 10),
mute: 0,
});
}
for (let k = 0; k < SD_BUSS_COUNT; k++) {
sdGroups.push({
name: SD_GROUP_NAMES[k],
fader: tidy16(-10 + rng() * 10),
mute: 0,
});
}
sdGroups[24].fader = -1.6875; // captured
// Pin the values captured from the real console so a probe sees the same numbers.
sdInputs[1].fader = -1.4375;
sdInputs[1].mute = 0;
if (opts.auxes >= 1) {
sdInputs[1].sends[1] = { level: -150, on: 0, pan: 0.5, prepost: 0 };
sdAuxes[1].fader = -4.375;
sdAuxes[1].mute = 0;
}
}
const SD_RE_INPUT_NAME = /^\/Input_Channels\/(\d+)\/Channel_Input\/name$/;
const SD_RE_INPUT_PROP = /^\/Input_Channels\/(\d+)\/(fader|mute)$/;
const SD_RE_SEND = /^\/Input_Channels\/(\d+)\/Aux_Send\/(\d+)\/(send_level|send_on|send_pan|send_pre-post)$/;
const SD_RE_GROUP_SEND = /^\/Input_Channels\/(\d+)\/Group_Send\/(\d+)\/group$/;
const SD_RE_AUX_NAME = /^\/Aux_Outputs\/(\d+)\/Buss_Trim\/name$/;
const SD_RE_AUX_PROP = /^\/Aux_Outputs\/(\d+)\/(fader|mute)$/;
const SD_RE_GROUP_NAME = /^\/Group_Outputs\/(\d+)\/Buss_Trim\/name$/;
const SD_RE_GROUP_PROP = /^\/Group_Outputs\/(\d+)\/(fader|mute)$/;
/** Map an SD leaf address to { target, prop } or null when there is no such thing. */
function sdResolve(address) {
let m = SD_RE_INPUT_NAME.exec(address);
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: 'name' } : null;
m = SD_RE_INPUT_PROP.exec(address);
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: m[2] } : null;
m = SD_RE_SEND.exec(address);
if (m) {
const input = sdInputs[Number(m[1])];
const snd = input && input.sends[Number(m[2])];
return snd ? { target: snd, prop: SD_SEND_PROPS[m[3]] } : null;
}
m = SD_RE_GROUP_SEND.exec(address);
if (m) {
const input = sdInputs[Number(m[1])];
const gs = input && input.groupSends[Number(m[2])];
return gs ? { target: gs, prop: 'group' } : null;
}
m = SD_RE_GROUP_NAME.exec(address);
if (m) return sdGroups[Number(m[1])] ? { target: sdGroups[Number(m[1])], prop: 'name' } : null;
m = SD_RE_GROUP_PROP.exec(address);
if (m) return sdGroups[Number(m[1])] ? { target: sdGroups[Number(m[1])], prop: m[2] } : null;
m = SD_RE_AUX_NAME.exec(address);
if (m) return sdAuxes[Number(m[1])] ? { target: sdAuxes[Number(m[1])], prop: 'name' } : null;
m = SD_RE_AUX_PROP.exec(address);
if (m) return sdAuxes[Number(m[1])] ? { target: sdAuxes[Number(m[1])], prop: m[2] } : null;
return null;
}
function handleSd(address, args) {
if (address.endsWith('/?')) {
if (args.length !== 0) return ignore(`query ${address} carries arguments ${formatArgs(args)}`);
return sdQuery(address.slice(0, -2), address);
}
return sdSet(address, args);
}
function sdQuery(base, original) {
if (base === '/Console/Name') return void send(base, [SD_CONSOLE_NAME]);
if (base === '/Console/Input_Channels') return void send(base, [int(opts.channels)]);
if (base === '/Console/Aux_Outputs') return void send(base, [int(opts.auxes)]);
if (base === '/Console/Aux_Outputs/modes') {
return void send(base, SD_AUX_MODES.slice(0, opts.auxes).map((mode) => int(mode)));
}
const fixed = /^\/Console\/(\w+)$/.exec(base);
if (fixed && Object.hasOwn(SD_FIXED_COUNTS, fixed[1])) return void send(base, [int(SD_FIXED_COUNTS[fixed[1]])]);
if (base === '/Snapshots/Current_Snapshot') return void send(base, [int(sdSnapshot)]);
if (base === '/Snapshots/names') {
startPaced('snapshot names', SD_SNAPSHOTS.map((name, index) => [
'/Snapshots/name', [int(index), int((index + 1) * 100), int(0), name],
]));
return undefined;
}
const r = sdResolve(base);
if (!r) return ignore(`no reply for ${original} (the real console is silent here)`);
send(base, [toArg(SD_PROPS[r.prop], r.target[r.prop])]);
return undefined;
}
function sdSet(address, args) {
const r = sdResolve(address);
if (!r) return ignore(`no reply for ${address} (unknown or unsupported address)`);
const spec = SD_PROPS[r.prop];
if (spec.readOnly) return ignore(`${address} is read-only`);
if (args.length !== 1) return ignore(`${address} needs exactly one argument (got ${formatArgs(args)})`);
const value = coerce(spec, args[0]);
if (value === undefined) return ignore(`unusable value ${formatArgs(args)} for ${address}`);
setAndEmit(r.target, r.prop, spec, address, value);
return undefined;
}
function liveTickSd() {
const n = 1 + Math.floor(Math.random() * opts.channels);
const input = sdInputs[n];
if (opts.auxes === 0) {
setAndEmit(input, 'fader', SD_PROPS.fader, `/Input_Channels/${n}/fader`, tidy16(-40 + Math.random() * 50));
return;
}
const s = 1 + Math.floor(Math.random() * opts.auxes);
const snd = input.sends[s];
if (Math.random() < 0.5) {
setAndEmit(snd, 'level', SD_PROPS.level, `/Input_Channels/${n}/Aux_Send/${s}/send_level`, tidy16(-60 + Math.random() * 60));
} else {
setAndEmit(snd, 'on', SD_PROPS.on, `/Input_Channels/${n}/Aux_Send/${s}/send_on`, snd.on ? 0 : 1);
}
}
// ---------------------------------------------------------------------------
// Socket wiring and lifecycle
// ---------------------------------------------------------------------------
const handle = isSd ? handleSd : handleSseries;
const liveTick = isSd ? liveTickSd : liveTickSseries;
socket.on('message', (msg, rinfo) => {
stats.received++;
const packet = decode(msg);
if (packet.raw !== undefined) {
stats.undecodable++;
trace(`<- (undecodable, ${msg.length} bytes) ${packet.raw.slice(0, 96)} from ${rinfo.address}:${rinfo.port}`);
} else {
trace(`<- ${describe(packet.address, packet.args, packet.types)} from ${rinfo.address}:${rinfo.port}`);
if (packet.unpadded) trace('[mock-s31] note: that packet was not padded to 4 bytes');
}
// No fixed destination configured: remember the first sender's IP (port 9000).
// Like the real console, the sender's source port is never used for replies.
if (!replyTarget) {
replyTarget = { host: rinfo.address, port: DEFAULT_REPLY_PORT };
note(`no --reply-to given; locking feedback to ${replyTarget.host}:${replyTarget.port} (first sender was ${rinfo.address}:${rinfo.port})`);
}
if (packet.raw === undefined) handle(packet.address, packet.args);
});
socket.on('error', (err) => {
console.error(`[mock-s31] socket error: ${err.code === 'EADDRINUSE' ? `UDP port ${opts.listen} is already in use` : err.message}`);
process.exit(1);
});
socket.on('listening', () => {
const addr = socket.address();
const flavour = isSd
? 'mute/send_on reported as floats 0/1'
: `booleans as ${opts.boolInt ? 'int 0/1' : 'OSC T/F'}`;
note(`listening on udp://${addr.address}:${addr.port} (dialect ${opts.dialect}: ${opts.channels} inputs, ${opts.auxes} auxes, ${flavour})`);
note(replyTarget
? `feedback goes to ${replyTarget.host}:${replyTarget.port}`
: `feedback target not set; will lock onto the first sender's IP, port ${DEFAULT_REPLY_PORT}`);
if (isSd && opts.boolInt) note('--bool-int has no effect in the sd dialect (the console reports mute / send_on as floats)');
if (opts.live) {
note(`live mode: changing a random send every ${LIVE_INTERVAL_MS / 1000}s`);
setInterval(liveTick, LIVE_INTERVAL_MS);
}
});
function shutdown(signal) {
if (dumpTimer) clearTimeout(dumpTimer);
note(`${signal}: received ${stats.received} packets (${stats.undecodable} undecodable), sent ${stats.sent}, ignored ${stats.ignored}`);
try {
socket.close();
} catch {
// already closed
}
process.exit(0);
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
if (isSd) buildStateSd();
else buildStateSseries();
socket.bind(opts.listen);
+284
View File
@@ -0,0 +1,284 @@
'use strict';
/**
* osc-lite.js - a deliberately tiny OSC 1.0 encoder/decoder (no dependencies).
*
* Supported type tags: i (int32), f (float32), s (string), T, F, N.
* T/F are represented as JS booleans, N as null.
*
* Exports:
* encode(address, args) -> Buffer
* decode(buffer) -> { address, args, types, unpadded } | { raw }
* formatArgs(args, types)-> printable "[a, b]" string for log lines
* typeTags(args) -> type-tag string (no comma) encode() would use, e.g. "ff"
* int(n), float(n) -> explicitly typed argument wrappers for encode()
*
* Encoding arguments
* A plain JS value is typed automatically:
* boolean -> T/F, string -> s, null/undefined -> N,
* integer number (int32 range) -> i, any other number -> f.
* Because JS cannot tell 1 from 1.0, use float(1) when a float must go on
* the wire even though its value happens to be whole (e.g. a fader at -10 dB).
*
* Decoding is tolerant and never throws:
* - A well-formed (4-byte padded) message decodes with unpadded:false.
* - A message whose strings are NUL-terminated but NOT padded to 4 bytes, or
* whose last string is missing its final padding, still decodes, with
* unpadded:true.
* - Anything else (not an OSC message, truncated numbers, bundles, tags we do
* not support) comes back as { raw: '<hex>' }.
*/
const INT32_MIN = -2147483648;
const INT32_MAX = 2147483647;
/** Round n up to the next multiple of 4. */
const pad4 = (n) => (n + 3) & ~3;
/** Explicitly typed argument wrappers for encode(). */
const int = (value) => ({ type: 'i', value });
const float = (value) => ({ type: 'f', value });
const isTyped = (a) => a !== null && typeof a === 'object' && typeof a.type === 'string';
// ---------------------------------------------------------------------------
// Encoding
// ---------------------------------------------------------------------------
/** NUL-terminated string padded with NULs to a multiple of 4 bytes. */
function oscString(text) {
const body = Buffer.from(String(text), 'utf8');
const out = Buffer.alloc(pad4(body.length + 1)); // zero-filled: terminator + padding
body.copy(out);
return out;
}
/** Turn any accepted argument form into { type, value? }. */
function normalize(arg) {
if (isTyped(arg)) {
switch (arg.type) {
case 'i':
case 'f':
case 's':
return { type: arg.type, value: arg.value };
case 'T':
case 'F':
case 'N':
return { type: arg.type };
default:
throw new TypeError(`unsupported OSC type tag '${arg.type}'`);
}
}
if (arg === null || arg === undefined) return { type: 'N' };
switch (typeof arg) {
case 'boolean':
return { type: arg ? 'T' : 'F' };
case 'string':
return { type: 's', value: arg };
case 'number':
if (Number.isInteger(arg) && arg >= INT32_MIN && arg <= INT32_MAX) return { type: 'i', value: arg };
return { type: 'f', value: arg };
default:
throw new TypeError(`cannot encode a ${typeof arg} as an OSC argument`);
}
}
/** Payload bytes for one normalised argument (T/F/N carry none). */
function argData(arg) {
switch (arg.type) {
case 'i': {
const v = arg.value;
if (!Number.isInteger(v) || v < INT32_MIN || v > INT32_MAX) {
throw new RangeError(`${v} does not fit an OSC int32`);
}
const b = Buffer.alloc(4);
b.writeInt32BE(v, 0);
return b;
}
case 'f': {
const b = Buffer.alloc(4);
b.writeFloatBE(Number(arg.value), 0);
return b;
}
case 's':
return oscString(arg.value);
default:
return null;
}
}
/**
* Encode one OSC message.
* @param {string} address must start with '/'
* @param {Array} [args] plain values and/or int()/float() wrappers
* @returns {Buffer}
*/
function encode(address, args = []) {
if (typeof address !== 'string' || address[0] !== '/') {
throw new TypeError(`OSC address must be a string starting with '/' (got ${JSON.stringify(address)})`);
}
const list = (Array.isArray(args) ? args : [args]).map(normalize);
const parts = [oscString(address), oscString(',' + list.map((a) => a.type).join(''))];
for (const arg of list) {
const data = argData(arg);
if (data) parts.push(data);
}
return Buffer.concat(parts);
}
// ---------------------------------------------------------------------------
// Decoding
// ---------------------------------------------------------------------------
/**
* One parsing attempt.
* @param {Buffer} buf
* @param {boolean} padded strings occupy a multiple of 4 bytes (standard OSC)
* @param {boolean} strict require well-formed padding and exact consumption
* @returns {object|null} null when this interpretation does not fit
*/
function parseMessage(buf, padded, strict) {
let off = 0;
let short = false; // a string's terminator/padding ran past the end of the buffer
// Read one NUL-terminated string, or null when it is malformed.
const readString = () => {
if (off >= buf.length) return null;
const nul = buf.indexOf(0, off);
if (nul === -1) {
if (strict) return null;
const rest = buf.toString('utf8', off);
off = buf.length;
short = true;
return rest;
}
const text = buf.toString('utf8', off, nul);
const next = padded ? off + pad4(nul - off + 1) : nul + 1;
if (next > buf.length) {
if (strict) return null;
short = true;
off = buf.length;
} else {
if (strict) {
for (let k = nul + 1; k < next; k++) if (buf[k] !== 0) return null;
}
off = next;
}
return text;
};
const address = readString();
if (address === null || address[0] !== '/') return null;
const args = [];
let tags = '';
// A packet may legitimately end after the address (pre-1.0 style, no type tags).
if (off < buf.length) {
const tagString = readString();
if (tagString === null || tagString[0] !== ',') return null;
tags = tagString.slice(1);
for (const t of tags) {
switch (t) {
case 'i':
if (off + 4 > buf.length) return null;
args.push(buf.readInt32BE(off));
off += 4;
break;
case 'f':
if (off + 4 > buf.length) return null;
args.push(buf.readFloatBE(off));
off += 4;
break;
case 's': {
const s = readString();
if (s === null) return null;
args.push(s);
break;
}
case 'T':
args.push(true);
break;
case 'F':
args.push(false);
break;
case 'N':
args.push(null);
break;
default:
return null; // tag we do not support
}
}
}
if (strict && off !== buf.length) return null;
return { address, args, types: tags, unpadded: short };
}
/**
* Decode one OSC message. Never throws.
* @param {Buffer|Uint8Array} input
* @returns {{address:string,args:Array,types:string,unpadded:boolean}|{raw:string}}
* `types` is the type-tag string without the leading comma (e.g. "if").
*/
function decode(input) {
let buf;
try {
buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
} catch {
return { raw: '' };
}
try {
// 1) standard, correctly padded
const standard = parseMessage(buf, true, true);
if (standard) return standard;
// 2) strings NUL-terminated but not padded to 4 bytes
const compact = parseMessage(buf, false, true);
if (compact) return { ...compact, unpadded: true };
// 3) padded, but last string short of its padding / trailing junk
const lenient = parseMessage(buf, true, false);
if (lenient) return lenient;
} catch {
// fall through to raw
}
return { raw: buf.toString('hex') };
}
// ---------------------------------------------------------------------------
// Logging helper
// ---------------------------------------------------------------------------
/** Format one number: floats trimmed of float32 noise, whole floats shown as "-10.0". */
function formatNumber(v, type) {
if (type === 'i') return String(v);
if (!Number.isFinite(v)) return String(v);
const text = String(Number(v.toPrecision(7)));
return type === 'f' && /^-?\d+$/.test(text) ? `${text}.0` : text;
}
/**
* Printable form of an argument list, e.g. `[-12.5, true, "Kick"]`.
* @param {Array} args plain (decoded) values or int()/float() wrappers
* @param {string} [types] type-tag string from decode(), so whole floats print as -10.0
*/
function formatArgs(args, types) {
const parts = (args || []).map((a, idx) => {
const typed = isTyped(a);
const type = typed ? a.type : types ? types[idx] : undefined;
const value = typed ? a.value : a;
if (type === 'T') return 'true';
if (type === 'F') return 'false';
if (type === 'N' || value === null || value === undefined) return 'nil';
if (typeof value === 'string') return JSON.stringify(value);
if (typeof value === 'boolean') return String(value);
if (typeof value === 'number') return formatNumber(value, type);
return String(value);
});
return `[${parts.join(', ')}]`;
}
/** The type-tag string (without the leading comma) that encode() would produce for args. */
function typeTags(args) {
const list = args === undefined ? [] : Array.isArray(args) ? args : [args];
return list.map(normalize).map((a) => a.type).join('');
}
module.exports = { encode, decode, formatArgs, typeTags, int, float };
+6
View File
@@ -0,0 +1,6 @@
{
"name": "digico-s31-dev-tools",
"private": true,
"description": "Developer tools are CommonJS even though the parent module package is ESM",
"type": "commonjs"
}
+938
View File
@@ -0,0 +1,938 @@
#!/usr/bin/env node
'use strict';
/**
* probe-catalog.js - build a catalog of what a DiGiCo S31 answers over its iPad
* OSC extension, using READ-ONLY queries only.
*
* SAFETY (live console):
* - Every message sent is an OSC address ending in "/?" with NO arguments.
* assertSafeQuery() refuses anything else, and the encoded packet is decoded
* again and checked to carry zero arguments before it is sent.
* - Lowercase S-Series addresses (/console/...) are refused.
* - One socket, sends at least 60 ms apart (INTERVAL_MS = 65), no retries.
* - Write-only action nodes from the 2014 list (Save session, Fire snapshot,
* Macro press) are NOT queried unless --include-actions is passed.
* - Every HEARTBEAT_EVERY queries a /Console/Name/? heartbeat is sent; three
* missed heartbeats in a row stop the run (results so far are still written).
*
* Network path: queries go to the Companion relay UDP 10.3.11.152:8000, which
* forwards to the console; replies are fanned out to this machine on UDP 9000.
*
* Usage:
* node tools/probe-catalog.js [--dry-run] [--include-actions] [--test-local]
* [--raw-log FILE] [--tail-seconds 15]
*
* --test-local sends to a mock-s31.js on 127.0.0.1:8000 instead (tool testing only).
*
* Outputs (relative to this file): catalog/s31-catalog.json, catalog/s31-catalog.md
*/
const dgram = require('node:dgram');
const fs = require('node:fs');
const path = require('node:path');
const { encode, decode } = require('./osc-lite');
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const INTERVAL_MS = 65; // spec: >= 60 ms between sends
const MIN_GAP_MS = 60;
const WINDOW_MS = 400; // reply must arrive within this long after its query
const CONNECT_TIMEOUT_MS = 2000;
const HEARTBEAT_EVERY = 120;
const HEARTBEAT_MAX_MISSES = 3;
const MAX_STORED_REPLIES = 300;
const HERE = __dirname;
const LIST_FILE = path.join(HERE, 'sd-osc-list-2014.txt');
const OUT_DIR = path.join(HERE, 'catalog');
const argv = process.argv.slice(2);
const flag = (name) => argv.includes(name);
const opt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : dflt;
};
// --test-local points the probe at a mock-s31.js on 127.0.0.1 (for testing the tool only).
const TEST_LOCAL = flag('--test-local');
const RELAY_HOST = TEST_LOCAL ? '127.0.0.1' : '10.3.11.152';
const RELAY_PORT = 8000;
const LISTEN_PORT = 9000;
const DRY_RUN = flag('--dry-run');
const INCLUDE_ACTIONS = flag('--include-actions');
const RAW_LOG = opt('--raw-log', null);
const TAIL_SECONDS = Number(opt('--tail-seconds', '15'));
/** Max index per entity on this console, and which instances to probe. */
const ENTITIES = {
Input_Channels: { max: 60, probe: [1, 2] },
Aux_Outputs: { max: 25, probe: [1, 2] },
Group_Outputs: { max: 25, probe: [1, 2] },
Matrix_Outputs: { max: 8, probe: [1, 2] },
Matrix_Inputs: { max: 10, probe: [1, 2] },
Control_Groups: { max: 10, probe: [1, 2] },
Graphic_EQ: { max: 16, probe: [1, 2] },
Multis: { max: 0, probe: [1] },
};
/** Sub-index probes for the second wildcard after these segments. */
const SUB_PROBE = { Aux_Send: [1, 24], Group_Send: [1, 24], Matrix_Send: [1] };
/** Write-only trigger nodes in the 2014 list: skipped unless --include-actions. */
const ACTION_NODES = new Set([
'/Filing/Save_current_Session',
'/Snapshots/Fire_Snapshot_number',
'/Snapshots/Fire_Prev_Snapshot',
'/Snapshots/Fire_Next_Snapshot',
'/Macros/Buttons/press',
]);
/** Candidate name addresses per entity, in preference order ('#' = index). */
const NAME_CANDIDATES = {
Input_Channels: ['/Input_Channels/#/Channel_Input/name'],
Aux_Outputs: ['/Aux_Outputs/#/Buss_Trim/name'],
Group_Outputs: ['/Group_Outputs/#/Buss_Trim/name'],
Matrix_Outputs: ['/Matrix_Outputs/#/Buss_Trim/name', '/Matrix_Outputs/#/name'],
Matrix_Inputs: ['/Matrix_Inputs/#/Buss_Trim/name', '/Matrix_Inputs/#/name'],
Control_Groups: ['/Control_Groups/#/name', '/Control_Groups/#/Buss_Trim/name'],
Graphic_EQ: ['/Graphic_EQ/#/name', '/Graphic_EQ/#/Buss_Trim/name'],
};
/** Extra candidates not in the 2014 list (base addresses; "/?" is appended). */
const EXTRA_ADDRESSES = [
'/Console/Name', '/Console/Channels', '/Console/Input_Channels', '/Console/Aux_Outputs',
'/Console/Group_Outputs', '/Console/Matrix_Outputs', '/Console/Matrix_Inputs',
'/Console/Control_Groups', '/Console/Graphic_EQ', '/Console/Multis', '/Console/Talkback_Outputs',
'/Console/Aux_Outputs/modes', '/Console/Group_Outputs/modes', '/Console/Matrix_Outputs/modes',
'/Console/Version', '/Console/Session', '/Console/Session/name', '/Console/Sample_Rate',
'/Console/Meters', '/Console/Meter', '/Console/Metering', '/Console/Solo', '/Console/Solo_Busses',
'/Console/Talkback', '/Console/Oscillator', '/Console/FX',
'/Snapshots/Current_Snapshot', '/Snapshots/names', '/Snapshots/name', '/Snapshots/count',
'/Snapshots/Total',
'/Macros/Buttons', '/Macros/names', '/Macros/count', '/Macros/1/name', '/Macros/Buttons/1/name',
'/Macros/Buttons/state',
'/Talkback_Outputs/1/name', '/Solo/1/level', '/Solo_Busses/1/fader',
'/Input_Channels/1/meter', '/Input_Channels/1/Meter', '/Input_Channels/1/meters',
'/Input_Channels/1/Channel_Input/meter', '/Input_Channels/1/Channel_Input/input_meter',
'/Input_Channels/1/Channel_Output/meter', '/Input_Channels/1/Channel_Input/signal',
'/Input_Channels/1/Channel_Input/source', '/Input_Channels/1/Channel_Input/route',
'/Input_Channels/1/Channel_Input/input_route', '/Input_Channels/1/Channel_Input/socket',
'/Input_Channels/1/Channel_Input/mode', '/Input_Channels/1/Channel_Input/stereo',
'/Input_Channels/1/stereo', '/Input_Channels/1/colour', '/Input_Channels/1/color',
'/Input_Channels/1/Channel_Input/colour', '/Input_Channels/1/Aux_Send/24/send_pre-post',
'/Input_Channels/1/Group_Send/24/group', '/Input_Channels/1/Direct_Output/send_level',
'/Input_Channels/1/Direct_Output/send_on', '/Input_Channels/1/Channel_Output/direct_out',
'/Aux_Outputs/1/Buss_Trim/name', '/Aux_Outputs/1/Buss_Trim/colour', '/Aux_Outputs/1/mode',
'/Aux_Outputs/1/stereo', '/Aux_Outputs/1/meter',
'/Group_Outputs/1/Buss_Trim/name', '/Matrix_Outputs/1/Buss_Trim/name', '/Matrix_Outputs/1/name',
'/Matrix_Inputs/1/name', '/Matrix_Inputs/1/Buss_Trim/name',
'/Control_Groups/1/name', '/Control_Groups/1/Buss_Trim/name', '/Control_Groups/1/fader',
'/Control_Groups/1/mute', '/Control_Groups/1/solo',
'/Graphic_EQ/1/name', '/Graphic_EQ/1/Buss_Trim/name', '/Graphic_EQ/1/on',
'/Graphic_EQ/1/Bands/1/gain', '/Graphic_EQ/1/gain/1',
'/FX/1/name', '/Multis/1/name',
'/Talkback/level', '/Talkback/on', '/Oscillator/on', '/Oscillator/level', '/Oscillator/freq',
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const T0 = process.hrtime.bigint();
const now = () => Number(process.hrtime.bigint() - T0) / 1e6;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Replace the first all-digit path segment with N and the second with M. */
function patternOf(address) {
let count = 0;
return address
.split('/')
.map((s) => (/^\d+$/.test(s) ? (count++ === 0 ? 'N' : 'M') : s))
.join('/');
}
/** Numeric path segments of an address as { n, m } (null when absent). */
function instanceOf(address) {
const nums = address.split('/').filter((s) => /^\d+$/.test(s)).map(Number);
return { n: nums.length > 0 ? nums[0] : null, m: nums.length > 1 ? nums[1] : null };
}
const treeOf = (address) => address.split('/')[1] || '';
const round6 = (v) => (typeof v === 'number' && Number.isFinite(v) ? Number(v.toPrecision(6)) : v);
/** Refuse anything that is not a plain read-only query. Throws on violation. */
function assertSafeQuery(address) {
if (typeof address !== 'string') throw new Error('query address must be a string');
if (!address.endsWith('/?')) throw new Error(`refusing non-query address (no trailing /?): ${address}`);
if (!/^\/[A-Z][A-Za-z0-9_\-/]*\/\?$/.test(address)) {
throw new Error(`refusing address (must start with /<Capital>, only [A-Za-z0-9_-/]): ${address}`);
}
if (address.includes('//') || address.includes('*')) throw new Error(`refusing malformed address: ${address}`);
if (address.startsWith('/console/')) throw new Error(`refusing S-Series address: ${address}`);
const base = address.slice(0, -2);
if (ACTION_NODES.has(base) && !INCLUDE_ACTIONS) throw new Error(`refusing action node: ${address}`);
// The encoded packet must decode back to the same address with zero arguments.
const buf = encode(address, []);
const back = decode(buf);
if (back.address !== address || !Array.isArray(back.args) || back.args.length !== 0 || back.types !== '') {
throw new Error(`encoded packet is not an argument-less query: ${address}`);
}
return buf;
}
// ---------------------------------------------------------------------------
// Parse the 2014 command list
// ---------------------------------------------------------------------------
function parseDocList(text) {
const rows = [];
const skipped = [];
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (!line.startsWith('/sd/')) continue;
// Example lines carry arguments ("/sd/Input_Channels/1/mute, i,1 - ..."): not table rows.
if (/^\/sd\/\S*,\s/.test(line)) {
skipped.push(line);
continue;
}
const tokens = line.split(/\s+/);
const ti = tokens.findIndex((t) => /^(Int|Float|String)$/i.test(t));
if (ti < 0) {
skipped.push(line);
continue;
}
const docPath = tokens.slice(0, ti).join('').replace(/\/+$/, ''); // "/sd/Entity/*/Sub/param"
const addr = docPath.replace(/^\/sd/, '');
const type = tokens[ti][0].toUpperCase() + tokens[ti].slice(1).toLowerCase();
const min = tokens[ti + 1] !== undefined ? Number(tokens[ti + 1]) : null;
const max = tokens[ti + 2] !== undefined ? Number(tokens[ti + 2]) : null;
rows.push({
template: addr,
documentedType: type,
documentedRange: min !== null && max !== null ? { min, max } : null,
});
}
return { rows, skipped };
}
/** Expand "*" wildcards of one row into concrete addresses (without "/?"). */
function expandRow(row) {
const segs = row.template.split('/'); // ['', 'Input_Channels', '*', ...]
const entity = segs[1];
const star = segs.reduce((acc, s, i) => (s === '*' ? [...acc, i] : acc), []);
if (star.length === 0) return [row.template];
const ent = ENTITIES[entity];
const entIdx = ent ? ent.probe : [1];
let out = [];
for (const n of entIdx) {
const segsN = segs.slice();
segsN[star[0]] = String(n);
if (star.length > 1) {
const subName = segs[star[1] - 1];
const subs = SUB_PROBE[subName] || [1];
for (const m of subs) {
const segsM = segsN.slice();
segsM[star[1]] = String(m);
out.push(segsM.join('/'));
}
} else {
out.push(segsN.join('/'));
}
}
return out;
}
// ---------------------------------------------------------------------------
// Build the query list
// ---------------------------------------------------------------------------
/** entries: Map base-address -> entry (insertion order = send order). */
function buildEntries() {
const entries = new Map();
const sourceCounts = { extra: 0, doc: 0, actionSkipped: [] };
const ensure = (base, source) => {
let e = entries.get(base);
if (!e) {
e = {
address: base,
query: `${base}/?`,
pattern: patternOf(base),
tree: treeOf(base),
inst: instanceOf(base),
sources: [],
documentedType: null,
documentedRange: null,
skipped: false,
};
entries.set(base, e);
}
if (!e.sources.includes(source)) e.sources.push(source);
return e;
};
for (const base of EXTRA_ADDRESSES) {
ensure(base, 'extra');
sourceCounts.extra++;
}
const text = fs.readFileSync(LIST_FILE, 'utf8');
const { rows, skipped } = parseDocList(text);
for (const row of rows) {
for (const base of expandRow(row)) {
const e = ensure(base, 'doc');
e.documentedType = row.documentedType;
e.documentedRange = row.documentedRange;
if (ACTION_NODES.has(base) && !INCLUDE_ACTIONS) {
e.skipped = true;
e.skipReason = 'write-only action node in the 2014 list (trigger); not queried by default';
sourceCounts.actionSkipped.push(base);
}
sourceCounts.doc++;
}
}
return { entries, rows, skippedLines: skipped, sourceCounts };
}
// ---------------------------------------------------------------------------
// Probe run
// ---------------------------------------------------------------------------
async function main() {
const { entries, rows, skippedLines, sourceCounts } = buildEntries();
const toSend = [...entries.values()].filter((e) => !e.skipped);
// Validate every query up front (throws before any packet leaves the machine).
for (const e of toSend) assertSafeQuery(e.query);
assertSafeQuery('/Console/Name/?');
const docPatterns = new Set(rows.map((r) => patternOf(r.template)));
console.log(
`list: ${rows.length} doc rows (${skippedLines.length} example lines ignored), ` +
`${docPatterns.size} distinct patterns, ${sourceCounts.extra} extra candidates`
);
console.log(`planned phase-1 queries: ${toSend.length} (+ names phase, + heartbeats)`);
if (sourceCounts.actionSkipped.length) {
console.log(`skipped action nodes: ${[...new Set(sourceCounts.actionSkipped)].join(', ')}`);
}
if (DRY_RUN) {
const perTree = {};
for (const e of toSend) perTree[e.tree] = (perTree[e.tree] || 0) + 1;
console.log('queries per tree:', JSON.stringify(perTree));
console.log('dry run only; nothing sent');
return;
}
const sock = dgram.createSocket({ type: 'udp4', reuseAddr: false });
const sentLog = []; // every query sent, in order
const packets = []; // every packet received
const unsolicited = [];
const sendErrors = [];
let lastSend = -1e9;
let minGap = Infinity;
let sentCount = 0;
let currentPhase = 'connect';
const sourcesSeen = new Map();
const parseFailures = [];
sock.on('error', (err) => {
console.error(`socket error: ${err.message}`);
process.exit(3);
});
sock.on('message', (msg, rinfo) => {
const t = now();
const key = `${rinfo.address}:${rinfo.port}`;
sourcesSeen.set(key, (sourcesSeen.get(key) || 0) + 1);
const d = decode(msg);
const pkt = { tMs: round6(t), from: key, bytes: msg.length, phase: currentPhase };
if (d.raw !== undefined) {
pkt.raw = d.raw;
packets.push(pkt);
parseFailures.push(pkt);
unsolicited.push({ ...pkt, reason: 'unparsed' });
return;
}
pkt.address = d.address;
pkt.types = d.types;
pkt.args = d.args.map(round6);
pkt.unpadded = d.unpadded;
// Match: newest query with the same reply address inside the window.
let match = null;
let kind = null;
for (let i = sentLog.length - 1; i >= 0; i--) {
const q = sentLog[i];
if (t - q.sentAt > WINDOW_MS) break;
if (q.replyAddress === d.address) {
match = q;
kind = 'exact';
break;
}
}
if (!match) {
for (let i = sentLog.length - 1; i >= 0; i--) {
const q = sentLog[i];
if (t - q.sentAt > WINDOW_MS) break;
if (q.replyAddress.startsWith(d.address) || d.address.startsWith(q.replyAddress)) {
match = q;
kind = 'related';
break;
}
}
}
if (match) {
pkt.matched = match.query;
pkt.matchKind = kind;
pkt.latencyMs = round6(t - match.sentAt);
const e = match.entry;
if (e.replies.length < MAX_STORED_REPLIES) {
e.replies.push({
address: d.address,
types: d.types,
args: d.args.map(round6),
latencyMs: pkt.latencyMs,
match: kind,
unpadded: d.unpadded,
});
}
e.replyCount++;
if (e.firstLatencyMs === null) e.firstLatencyMs = pkt.latencyMs;
match.replied = true;
} else {
// Unsolicited: tag how long after the newest same-address query it arrived (late reply?).
let after = null;
for (let i = sentLog.length - 1; i >= 0; i--) {
if (sentLog[i].replyAddress === d.address) {
after = round6(t - sentLog[i].sentAt);
break;
}
}
unsolicited.push({ ...pkt, reason: after === null ? 'no-query-for-address' : 'outside-window', afterQueryMs: after });
}
packets.push(pkt);
});
await new Promise((resolve, reject) => {
sock.once('error', reject);
sock.bind({ port: LISTEN_PORT, address: '0.0.0.0', exclusive: true }, () => {
sock.removeListener('error', reject);
resolve();
});
});
console.log(`listening on udp4 0.0.0.0:${LISTEN_PORT}; queries go to ${RELAY_HOST}:${RELAY_PORT}`);
/** Send one query (paced) and register it. */
async function send(entry, tag) {
const buf = assertSafeQuery(entry.query);
const wait = lastSend + INTERVAL_MS - now();
if (wait > 0) await sleep(wait);
const t = now();
if (sentCount > 0) minGap = Math.min(minGap, t - lastSend);
lastSend = t;
const rec = {
query: entry.query,
replyAddress: entry.address,
sentAt: t,
entry,
tag: tag || 'probe',
replied: false,
};
sentLog.push(rec);
entry.sentCount = (entry.sentCount || 0) + 1;
sentCount++;
sock.send(buf, RELAY_PORT, RELAY_HOST, (err) => {
if (err) sendErrors.push({ query: entry.query, error: err.message });
});
return rec;
}
const newEntryState = (e) => {
e.replies = [];
e.replyCount = 0;
e.firstLatencyMs = null;
return e;
};
for (const e of entries.values()) newEntryState(e);
// ---- connectivity check -------------------------------------------------
const nameEntry = entries.get('/Console/Name');
const connRec = await send(nameEntry, 'connectivity');
const connDeadline = connRec.sentAt + CONNECT_TIMEOUT_MS;
while (now() < connDeadline && !(nameEntry.replyCount > 0)) await sleep(10);
// The match window is 400 ms; a reply between 400 ms and 2 s is still accepted for this check.
const connUnsolicited = unsolicited.find((u) => u.address === '/Console/Name');
const connectivityOk = nameEntry.replyCount > 0 || !!connUnsolicited;
const connReply = nameEntry.replies[0] || connUnsolicited || null;
if (!connectivityOk) {
console.error('CONNECTIVITY CHECK FAILED: no /Console/Name reply within 2 s from the relay. Stopping (nothing else sent).');
console.error(`packets received during the wait: ${packets.length}`);
sock.close();
process.exitCode = 2;
return;
}
const connName = connReply && (connReply.args || [])[0];
console.log(`connectivity OK: /Console/Name -> ${JSON.stringify(connName)} (types ${connReply && connReply.types})`);
if (!nameEntry.replyCount && connUnsolicited) {
// Late (>400 ms) but within 2 s: keep as an unsolicited packet and record that it was slow.
console.log(`note: the first reply was slow (${connUnsolicited.afterQueryMs} ms), outside the 400 ms window`);
}
// ---- phase 1 + names ----------------------------------------------------
const heartbeats = [];
let misses = 0;
let aborted = null;
let sinceHeartbeat = 0;
async function maybeHeartbeat() {
if (sinceHeartbeat < HEARTBEAT_EVERY) return true;
sinceHeartbeat = 0;
const hbEntry = { address: '/Console/Name', query: '/Console/Name/?', replies: [], replyCount: 0, firstLatencyMs: null };
const rec = await send(hbEntry, 'heartbeat');
await sleep(WINDOW_MS + 20);
const ok = rec.replied;
heartbeats.push({ tMs: round6(rec.sentAt), ok, latencyMs: hbEntry.firstLatencyMs });
misses = ok ? 0 : misses + 1;
if (!ok) console.log(` heartbeat MISSED at ${Math.round(rec.sentAt)} ms (${misses}/${HEARTBEAT_MAX_MISSES})`);
if (misses >= HEARTBEAT_MAX_MISSES) {
aborted = `console stopped answering the heartbeat ${misses} times in a row at ${Math.round(rec.sentAt)} ms`;
return false;
}
return true;
}
currentPhase = 'probe';
const startProbe = now();
let done = 0;
const phase1 = toSend.filter((e) => e !== nameEntry);
for (const e of phase1) {
if (!(await maybeHeartbeat())) break;
await send(e);
sinceHeartbeat++;
done++;
if (done % 100 === 0) {
const answered = phase1.slice(0, done).filter((x) => x.replyCount > 0).length;
console.log(` ${done}/${phase1.length} sent, ${answered} answered so far (${Math.round((now() - startProbe) / 1000)} s)`);
}
}
// ---- names phase ----------------------------------------------------------
const nameTables = {};
if (!aborted) {
await sleep(WINDOW_MS + 20);
currentPhase = 'names';
const nameQueries = [];
for (const [entity, cands] of Object.entries(NAME_CANDIDATES)) {
let chosen = null;
for (const c of cands) {
const probes = ENTITIES[entity].probe.map((n) => entries.get(c.replace('#', String(n)))).filter(Boolean);
if (probes.some((p) => p.replyCount > 0)) {
chosen = c;
break;
}
}
nameTables[entity] = { addressPattern: chosen ? patternOf(chosen.replace('#', '1')) : null, max: ENTITIES[entity].max, values: {}, unanswered: [] };
if (!chosen) continue;
for (let n = 1; n <= ENTITIES[entity].max; n++) {
const base = chosen.replace('#', String(n));
let e = entries.get(base);
if (!e) {
e = newEntryState({
address: base,
query: `${base}/?`,
pattern: patternOf(base),
tree: treeOf(base),
inst: instanceOf(base),
sources: ['names'],
documentedType: null,
documentedRange: null,
skipped: false,
});
for (const doc of [...entries.values()]) {
if (doc.pattern === e.pattern && doc.documentedType) {
e.documentedType = doc.documentedType;
e.documentedRange = doc.documentedRange;
break;
}
}
entries.set(base, e);
nameQueries.push(e);
} else if (!e.sources.includes('names')) {
e.sources.push('names');
}
nameTables[entity].entryFor = nameTables[entity].entryFor || [];
nameTables[entity].entryFor.push(e);
}
}
console.log(`names phase: ${nameQueries.length} new queries`);
for (const e of nameQueries) {
if (!(await maybeHeartbeat())) break;
await send(e);
sinceHeartbeat++;
}
}
// Let the last window close, then listen for unsolicited traffic.
await sleep(WINDOW_MS + 100);
const probeEnd = now();
currentPhase = 'tail';
const tailStart = now();
console.log(`probe finished at ${Math.round(probeEnd / 1000)} s; listening ${TAIL_SECONDS} s for unsolicited traffic`);
await sleep(TAIL_SECONDS * 1000);
const end = now();
sock.close();
// ---- finalize name tables ---------------------------------------------------
for (const [entity, tbl] of Object.entries(nameTables)) {
for (const e of tbl.entryFor || []) {
const r = e.replies.find((x) => x.match === 'exact') || e.replies[0];
if (r && r.args.length > 0) tbl.values[e.inst.n] = r.args[0];
else tbl.unanswered.push(e.inst.n);
}
delete tbl.entryFor;
tbl.answered = Object.keys(tbl.values).length;
}
// ---- build catalog ------------------------------------------------------------
const unsolicitedAddresses = new Set(unsolicited.filter((u) => u.address).map((u) => u.address));
const catalog = [];
for (const e of entries.values()) {
const first = e.replies.find((r) => r.match === 'exact') || e.replies[0] || null;
catalog.push({
address: e.address,
query: e.query,
pattern: e.pattern,
tree: e.tree,
instance: e.inst,
sources: e.sources,
documentedType: e.documentedType,
documentedRange: e.documentedRange,
queried: !e.skipped && (e.sentCount || 0) > 0,
skipped: e.skipped || undefined,
skipReason: e.skipReason,
answered: e.replyCount > 0,
replyAddress: first ? first.address : null,
replyTypes: first ? first.types : null,
exampleValue: first ? (first.args.length === 1 ? first.args[0] : first.args) : null,
replyCount: e.replyCount,
replyMatch: first ? first.match : null,
latencyMs: e.firstLatencyMs,
unsolicited: unsolicitedAddresses.has(e.address),
replies: e.replyCount > 1 || (first && first.address !== e.address) ? e.replies : undefined,
});
}
const queriedAddresses = new Set(catalog.map((c) => c.address));
for (const addr of unsolicitedAddresses) {
if (queriedAddresses.has(addr)) continue;
const u = unsolicited.find((x) => x.address === addr);
catalog.push({
address: addr,
query: null,
pattern: patternOf(addr),
tree: treeOf(addr),
instance: instanceOf(addr),
sources: ['unsolicited'],
documentedType: null,
documentedRange: null,
queried: false,
answered: false,
replyAddress: addr,
replyTypes: u.types,
exampleValue: u.args.length === 1 ? u.args[0] : u.args,
unsolicited: true,
});
}
// ---- summary ----------------------------------------------------------------------
const perTree = {};
for (const c of catalog) {
if (!c.queried && !c.skipped) continue;
const t = (perTree[c.tree] = perTree[c.tree] || { queried: 0, answered: 0, unanswered: 0, skipped: 0 });
if (c.skipped) t.skipped++;
else {
t.queried++;
if (c.answered) t.answered++;
else t.unanswered++;
}
}
const answeredPatterns = new Set(catalog.filter((c) => c.answered).map((c) => c.pattern));
const queriedPatterns = new Set(catalog.filter((c) => c.queried).map((c) => c.pattern));
const summary = {
relay: `${RELAY_HOST}:${RELAY_PORT}`,
listen: `udp4 0.0.0.0:${LISTEN_PORT}`,
consoleName: connName,
startedAt: new Date(Date.now() - end).toISOString(),
durationSeconds: round6(end / 1000),
probeSeconds: round6((probeEnd - startProbe) / 1000),
tailSeconds: round6((end - tailStart) / 1000),
intervalMs: INTERVAL_MS,
minObservedSendGapMs: round6(minGap),
windowMs: WINDOW_MS,
queriesSent: sentCount,
distinctAddressesQueried: catalog.filter((c) => c.queried).length,
addressesAnswered: catalog.filter((c) => c.answered).length,
addressesUnanswered: catalog.filter((c) => c.queried && !c.answered).length,
patternsQueried: queriedPatterns.size,
patternsAnswered: answeredPatterns.size,
packetsReceived: packets.length,
packetsMatched: packets.filter((p) => p.matched).length,
packetsUnsolicited: unsolicited.length,
unsolicitedDuringProbe: unsolicited.filter((u) => u.phase !== 'tail').length,
unsolicitedInTail: unsolicited.filter((u) => u.phase === 'tail').length,
unparsedPackets: parseFailures.length,
unpaddedPackets: packets.filter((p) => p.unpadded).length,
replySources: Object.fromEntries(sourcesSeen),
heartbeats: { total: heartbeats.length, missed: heartbeats.filter((h) => !h.ok).length, detail: heartbeats },
aborted,
sendErrors,
skippedActionNodes: [...new Set(sourceCounts.actionSkipped)],
exampleLinesIgnored: skippedLines.length,
perTree,
};
const out = { summary, names: nameTables, catalog, unsolicited };
fs.mkdirSync(OUT_DIR, { recursive: true });
const jsonPath = path.join(OUT_DIR, 's31-catalog.json');
const oneLine = (list) => list.map((x) => ' ' + JSON.stringify(x)).join(',\n');
const jsonText =
'{\n' +
` "summary": ${JSON.stringify(out.summary, null, 2).replace(/\n/g, '\n ')},\n` +
` "names": ${JSON.stringify(out.names, null, 2).replace(/\n/g, '\n ')},\n` +
` "catalog": [\n${oneLine(out.catalog)}\n ],\n` +
` "unsolicited": [\n${oneLine(out.unsolicited)}\n ]\n}\n`;
JSON.parse(jsonText); // sanity: must be valid JSON
fs.writeFileSync(jsonPath, jsonText);
fs.writeFileSync(path.join(OUT_DIR, 's31-catalog.md'), renderMarkdown(out));
if (RAW_LOG) fs.writeFileSync(RAW_LOG, packets.map((p) => JSON.stringify(p)).join('\n') + '\n');
console.log(
`done in ${Math.round(end / 1000)} s: ${sentCount} queries, ${summary.addressesAnswered}/${summary.distinctAddressesQueried} addresses answered, ` +
`${packets.length} packets (${unsolicited.length} unsolicited), min send gap ${summary.minObservedSendGapMs} ms`
);
if (aborted) console.log(`ABORTED EARLY: ${aborted}`);
console.log(`wrote ${jsonPath}`);
}
// ---------------------------------------------------------------------------
// Markdown report
// ---------------------------------------------------------------------------
const EXPECT = { Int: 'i', Float: 'f', String: 's' };
function fmtValue(v) {
if (Array.isArray(v)) return `[${v.map(fmtValue).join(', ')}]`;
if (typeof v === 'string') return JSON.stringify(v);
return String(v);
}
const esc = (s) => String(s).replace(/\|/g, '\\|');
/** [1,2,3,5] -> "1-3,5" */
function compressList(nums) {
const a = [...new Set(nums)].sort((x, y) => x - y);
const parts = [];
for (let i = 0; i < a.length; ) {
let j = i;
while (j + 1 < a.length && a[j + 1] === a[j] + 1) j++;
parts.push(j - i >= 2 ? `${a[i]}-${a[j]}` : a.slice(i, j + 1).join(','));
i = j + 1;
}
return parts.join(',');
}
const fmtRange = (r) => (r ? `${r.min}..${r.max}` : '');
function renderMarkdown({ summary, names, catalog, unsolicited }) {
const L = [];
const queried = catalog.filter((c) => c.queried);
const answered = queried.filter((c) => c.answered);
// Group by pattern.
const byPattern = new Map();
for (const c of queried) {
let g = byPattern.get(c.pattern);
if (!g) {
g = { pattern: c.pattern, tree: c.tree, documentedType: null, documentedRange: null, items: [] };
byPattern.set(c.pattern, g);
}
if (c.documentedType) {
g.documentedType = c.documentedType;
g.documentedRange = c.documentedRange;
}
g.items.push(c);
}
const groups = [...byPattern.values()];
const answeredGroups = groups.filter((g) => g.items.some((i) => i.answered));
const unansweredGroups = groups.filter((g) => !g.items.some((i) => i.answered));
L.push('# DiGiCo S31 OSC catalog (iPad extension, read-only probe)');
L.push('');
L.push(`Generated by \`tools/probe-catalog.js\` on ${summary.startedAt}. Console: ${fmtValue(summary.consoleName)}. Queries sent to the relay ${summary.relay}; replies on ${summary.listen}.`);
L.push('');
L.push(`- ${summary.queriesSent} queries sent in ${summary.probeSeconds} s (interval ${summary.intervalMs} ms, minimum observed gap ${summary.minObservedSendGapMs} ms), then ${summary.tailSeconds} s of listening.`);
L.push(`- ${summary.addressesAnswered} of ${summary.distinctAddressesQueried} distinct addresses answered (${summary.patternsAnswered} of ${summary.patternsQueried} patterns).`);
L.push(`- ${summary.packetsReceived} packets received: ${summary.packetsMatched} matched to a query, ${summary.packetsUnsolicited} unsolicited (${summary.unsolicitedDuringProbe} during probing, ${summary.unsolicitedInTail} in the final listen).`);
L.push(`- Heartbeats (/Console/Name/? every ${HEARTBEAT_EVERY} queries): ${summary.heartbeats.total} sent, ${summary.heartbeats.missed} missed.${summary.aborted ? ` **Run aborted early: ${summary.aborted}.**` : ''}`);
if (summary.skippedActionNodes.length) {
L.push(`- Not queried (write-only trigger nodes in the 2014 list): ${summary.skippedActionNodes.map((a) => `\`${a}\``).join(', ')}.`);
}
L.push('');
L.push('Pattern notation: N = first numeric index (channel / buss / entity), M = second numeric index (send buss, band). "Documented" is the 2014 DiGiCo SD OSC list type and OSC min/max.');
L.push('');
// Per-tree counts.
L.push('## Counts per tree');
L.push('');
L.push('| Tree | Addresses queried | Answered | Unanswered | Skipped |');
L.push('| --- | ---: | ---: | ---: | ---: |');
for (const [tree, t] of Object.entries(summary.perTree)) {
L.push(`| ${tree} | ${t.queried} | ${t.answered} | ${t.unanswered} | ${t.skipped || ''} |`);
}
L.push('');
// Surprises.
L.push('## Surprising / noteworthy');
L.push('');
const surprises = [];
const matrix = {};
const mismatchByLeaf = {};
for (const c of answered) {
if (!c.documentedType || !c.replyTypes) continue;
const key = `${c.documentedType} -> ${c.replyTypes || '(none)'}`;
matrix[key] = (matrix[key] || 0) + 1;
if (EXPECT[c.documentedType] !== c.replyTypes) {
const leaf = c.pattern;
(mismatchByLeaf[key] = mismatchByLeaf[key] || new Set()).add(leaf);
}
}
if (Object.keys(matrix).length) {
surprises.push('Documented type -> type tag actually received (answered addresses with a documented type):');
for (const [k, n] of Object.entries(matrix).sort()) surprises.push(` - ${k}: ${n} addresses${k.split(' -> ')[0] && EXPECT[k.split(' -> ')[0]] !== k.split(' -> ')[1] ? ' (MISMATCH)' : ''}`);
}
for (const [key, set] of Object.entries(mismatchByLeaf)) {
const list = [...set].sort();
surprises.push(`Mismatch ${key}: ${list.length} pattern(s)${list.length <= 40 ? ': ' + list.map((p) => '`' + p + '`').join(', ') : ' (see JSON)'}`);
}
// Observed numeric values outside the documented range.
const outOfRange = [];
for (const g of answeredGroups) {
if (!g.documentedRange) continue;
const vals = g.items.filter((i) => i.answered && typeof i.exampleValue === 'number').map((i) => i.exampleValue);
if (vals.some((v) => v < g.documentedRange.min || v > g.documentedRange.max)) outOfRange.push(`\`${g.pattern}\` (documented ${fmtRange(g.documentedRange)}, saw ${[...new Set(vals)].join(', ')})`);
}
if (outOfRange.length) surprises.push(`Values outside the documented OSC range (${outOfRange.length}): ${outOfRange.slice(0, 40).join('; ')}${outOfRange.length > 40 ? '; ...' : ''}`);
// Partial answers.
const partial = answeredGroups.filter((g) => g.items.some((i) => !i.answered)).map((g) => `\`${g.pattern}\` (${g.items.filter((i) => i.answered).length}/${g.items.length})`);
if (partial.length) surprises.push(`Patterns answered for some instances but not all probed instances (${partial.length}): ${partial.slice(0, 60).join(', ')}${partial.length > 60 ? ', ...' : ''}`);
// Multi-reply / different reply address.
const multi = answered.filter((c) => c.replyCount > 1);
if (multi.length) surprises.push(`Queries that produced more than one reply message (${multi.length}): ${multi.slice(0, 20).map((c) => `\`${c.query}\` x${c.replyCount}`).join(', ')}`);
const diffAddr = answered.filter((c) => c.replyAddress !== c.address);
if (diffAddr.length) surprises.push(`Replies on a different address than the query (${diffAddr.length}): ${diffAddr.slice(0, 20).map((c) => `\`${c.query}\` -> \`${c.replyAddress}\``).join(', ')}`);
if (summary.unpaddedPackets) surprises.push(`${summary.unpaddedPackets} received packets were not 4-byte padded OSC (osc-lite decoded them leniently).`);
if (summary.unparsedPackets) surprises.push(`${summary.unparsedPackets} received packets could not be decoded (raw hex in JSON "unsolicited" entries).`);
const lateish = unsolicited.filter((u) => u.reason === 'outside-window' && u.afterQueryMs !== null && u.afterQueryMs < 2000);
if (lateish.length) surprises.push(`${lateish.length} unsolicited replies arrived 0.4-2 s after a query for the same address (possibly slow replies, not human input); see the unsolicited section.`);
if (summary.heartbeats.missed) surprises.push(`${summary.heartbeats.missed} of ${summary.heartbeats.total} heartbeats were missed: the console (or relay) went quiet during the run.`);
if (surprises.length === 0) surprises.push('Nothing unexpected detected by the automatic checks.');
for (const s of surprises) L.push(s.startsWith(' ') ? s : `- ${s}`);
L.push('');
// Answered table by tree.
L.push('## Answered addresses, by tree');
L.push('');
const trees = [...new Set(answeredGroups.map((g) => g.tree))];
for (const tree of trees) {
const gs = answeredGroups.filter((g) => g.tree === tree);
L.push(`### /${tree} (${gs.length} patterns answered)`);
L.push('');
L.push('| Pattern | Answered N / M | Reply tags | Example value(s) | Documented type, range |');
L.push('| --- | --- | --- | --- | --- |');
for (const g of gs) {
const ans = g.items.filter((i) => i.answered);
const ns = [...new Set(ans.map((i) => i.instance.n).filter((v) => v !== null))];
const ms = [...new Set(ans.map((i) => i.instance.m).filter((v) => v !== null))];
const inst = [ns.length ? `N=${compressList(ns)}` : '', ms.length ? `M=${compressList(ms)}` : ''].filter(Boolean).join(' ') || '-';
const partialMark = ans.length < g.items.length ? ` (${ans.length}/${g.items.length})` : '';
const tags = [...new Set(ans.map((i) => (i.replyAddress !== i.address ? `${i.replyTypes} @${i.replyAddress}` : i.replyTypes)))].join(', ');
const ex = [...new Set(ans.map((i) => fmtValue(i.exampleValue)))].slice(0, 4).join(' / ');
const doc = g.documentedType ? `${g.documentedType}${g.documentedRange ? ' ' + fmtRange(g.documentedRange) : ''}` : '(not in 2014 list)';
L.push(`| \`${g.pattern}\` | ${inst}${partialMark} | ${esc(tags)} | ${esc(ex)} | ${doc} |`);
}
L.push('');
}
if (trees.length === 0) {
L.push('_No address answered._');
L.push('');
}
// Unanswered.
const docUnanswered = unansweredGroups.filter((g) => g.items.some((i) => i.sources.includes('doc')));
const extraUnanswered = unansweredGroups.filter((g) => !g.items.some((i) => i.sources.includes('doc')));
L.push(`## Unanswered documented patterns (${docUnanswered.length})`);
L.push('');
L.push('Patterns from the 2014 list for which none of the probed instances answered.');
L.push('');
const unTrees = [...new Set(docUnanswered.map((g) => g.tree))];
for (const tree of unTrees) {
const gs = docUnanswered.filter((g) => g.tree === tree);
L.push(`### /${tree} (${gs.length})`);
L.push('');
for (const g of gs) L.push(`- \`${g.pattern}\``);
L.push('');
}
L.push(`## Unanswered extra candidates (${extraUnanswered.length})`);
L.push('');
for (const g of extraUnanswered) L.push(`- \`${g.items.map((i) => i.address).join('`, `')}\``);
L.push('');
if (summary.skippedActionNodes.length) {
L.push('## Not queried');
L.push('');
for (const a of summary.skippedActionNodes) L.push(`- \`${a}\` (write-only trigger node; skipped for safety, pass --include-actions to query it)`);
L.push('');
}
// Unsolicited.
L.push(`## Unsolicited messages (${unsolicited.length})`);
L.push('');
if (unsolicited.length === 0) {
L.push('None seen, including during the final listen.');
} else {
L.push('| t (s) | Phase | Address | Tags | Value | Note |');
L.push('| ---: | --- | --- | --- | --- | --- |');
for (const u of unsolicited.slice(0, 500)) {
const note = u.reason === 'outside-window' ? `${u.afterQueryMs} ms after a query for this address` : u.reason;
L.push(`| ${round6(u.tMs / 1000)} | ${u.phase} | \`${u.address || '(unparsed)'}\` | ${u.types ?? ''} | ${esc(u.args ? fmtValue(u.args) : u.raw || '')} | ${note} |`);
}
if (unsolicited.length > 500) L.push(`| | | ... ${unsolicited.length - 500} more (see JSON) | | | |`);
}
L.push('');
// Names.
L.push('## Live name tables');
L.push('');
for (const [entity, tbl] of Object.entries(names)) {
L.push(`### ${entity}`);
L.push('');
if (!tbl.addressPattern) {
L.push('_No name address answered for this entity._');
L.push('');
continue;
}
L.push(`Address: \`${tbl.addressPattern}\` (${tbl.answered} of ${tbl.max} answered${tbl.unanswered.length ? `, no reply for ${tbl.unanswered.join(', ')}` : ''}).`);
const sample = catalog.find((c) => c.pattern === tbl.addressPattern && c.answered);
if (sample) L.push(`Reply format: \`${sample.replyAddress} ,${sample.replyTypes} ${fmtValue(Array.isArray(sample.exampleValue) ? sample.exampleValue : [sample.exampleValue])}\`.`);
L.push('');
L.push('| # | Name |');
L.push('| ---: | --- |');
for (const [idx, nm] of Object.entries(tbl.values)) L.push(`| ${idx} | ${esc(fmtValue(nm))} |`);
L.push('');
}
return L.join('\n') + '\n';
}
main().catch((err) => {
console.error(`fatal: ${err.stack || err.message}`);
process.exit(1);
});
+589
View File
@@ -0,0 +1,589 @@
DiGiCo OSC Command List for Other OSC 17/11/2014
Example Message formats:
/sd/Input_Channels/1/Channel_Input/name, s,FRED - Change Input channel 1 label to "Fred"
/sd/Input_Channels/1/mute, i,1 - Set Input channel 1 Mute ON
/sd/Input_Channels/1/fader, f,1 - Set Input channel 1 fader to MAX
TYPE OSC MIN OSC MAX
Prefix
/sd/ Input_Channels / * / Channel_Input / trim Float 0 1
/sd/ Input_Channels / * / Channel_Input / phase Int 0 3
/sd/ Input_Channels / * / Channel_Input / main / alt_in Int 0 1
/sd/ Input_Channels / * / Channel_Input / phantom Int 0 1
/sd/ Input_Channels / * / Channel_Input / alt_phantom Int 0 1
/sd/ Input_Channels / * / Channel_Input / analog_gain Float 0 1
/sd/ Input_Channels / * / Channel_Input / alt_analog_gain Float 0 1
/sd/ Input_Channels / * / Channel_Input / input_pad Int 0 1
/sd/ Input_Channels / * / Channel_Input / alt_input_pad Int 0 1
/sd/ Input_Channels / * / Channel_Input / name String
/sd/ Input_Channels / * / Aux_Send / * / send_level Float 0 1
/sd/ Input_Channels / * / Aux_Send / * / send_on Int 0 1
/sd/ Input_Channels / * / Aux_Send / * / send_pan Float 0 1
/sd/ Input_Channels / * / Group_Send / * / group Int 0 1
/sd/ Input_Channels / * / EQ / eq_in Int 0 1
/sd/ Input_Channels / * / EQ / eq_on_1 Int 0 1
/sd/ Input_Channels / * / EQ / eq_on_2 Int 0 1
/sd/ Input_Channels / * / EQ / eq_on_3 Int 0 1
/sd/ Input_Channels / * / EQ / eq_on_4 Int 0 1
/sd/ Input_Channels / * / EQ / eq_curve_1 Int 1 4
/sd/ Input_Channels / * / EQ / eq_curve_2 Int 1 4
/sd/ Input_Channels / * / EQ / eq_curve_3 Int 1 4
/sd/ Input_Channels / * / EQ / eq_curve_4 Int 1 4
/sd/ Input_Channels / * / EQ / eq_freq_1 Float 0 1
/sd/ Input_Channels / * / EQ / eq_freq_2 Float 0 1
/sd/ Input_Channels / * / EQ / eq_freq_3 Float 0 1
/sd/ Input_Channels / * / EQ / eq_freq_4 Float 0 1
/sd/ Input_Channels / * / EQ / eq_Q_1 Float 0 1
/sd/ Input_Channels / * / EQ / eq_Q_2 Float 0 1
/sd/ Input_Channels / * / EQ / eq_Q_3 Float 0 1
/sd/ Input_Channels / * / EQ / eq_Q_4 Float 0 1
/sd/ Input_Channels / * / EQ / eq_gain_1 Float 0 1
/sd/ Input_Channels / * / EQ / eq_gain_2 Float 0 1
/sd/ Input_Channels / * / EQ / eq_gain_3 Float 0 1
/sd/ Input_Channels / * / EQ / eq_gain_4 Float 0 1
/sd/ Input_Channels / * / EQ / eq_over-under_1 Int 0 1
/sd/ Input_Channels / * / EQ / eq_over-under_2 Int 0 1
/sd/ Input_Channels / * / EQ / eq_over-under_3 Int 0 1
/sd/ Input_Channels / * / EQ / eq_over-under_4 Int 0 1
/sd/ Input_Channels / * / EQ / eq_thresh_1 Float 0 1
/sd/ Input_Channels / * / EQ / eq_thresh_2 Float 0 1
/sd/ Input_Channels / * / EQ / eq_thresh_3 Float 0 1
/sd/ Input_Channels / * / EQ / eq_thresh_4 Float 0 1
/sd/ Input_Channels / * / EQ / eq_ratio_1 Float 0 1
/sd/ Input_Channels / * / EQ / eq_ratio_2 Float 0 1
/sd/ Input_Channels / * / EQ / eq_ratio_3 Float 0 1
/sd/ Input_Channels / * / EQ / eq_ratio_4 Float 0 1
/sd/ Input_Channels / * / EQ / eq_attack_1 Float 0 1
/sd/ Input_Channels / * / EQ / eq_attack_2 Float 0 1
/sd/ Input_Channels / * / EQ / eq_attack_3 Float 0 1
/sd/ Input_Channels / * / EQ / eq_attack_4 Float 0 1
/sd/ Input_Channels / * / EQ / eq_release_1 Float 0 1
/sd/ Input_Channels / * / EQ / eq_release_2 Float 0 1
/sd/ Input_Channels / * / EQ / eq_release_3 Float 0 1
/sd/ Input_Channels / * / EQ / eq_release_4 Float 0 1
/sd/ Input_Channels / * / EQ / eq_symm_Q_1 Int 0 1
/sd/ Input_Channels / * / EQ / eq_symm_Q_2 Int 0 1
/sd/ Input_Channels / * / EQ / eq_symm_Q_3 Int 0 1
/sd/ Input_Channels / * / EQ / eq_symm_Q_4 Int 0 1
/sd/ Input_Channels / * / EQ / dynamic_eq_on_1 Int 0 1
/sd/ Input_Channels / * / EQ / dynamic_eq_on_2 Int 0 1
/sd/ Input_Channels / * / EQ / dynamic_eq_on_3 Int 0 1
/sd/ Input_Channels / * / EQ / dynamic_eq_on_4 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_in Int 0 1
/sd/ Input_Channels / * / Dynamics / gate_in Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_band_in_1 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_band_in_2 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_band_in_3 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_LP_crossover Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_HP_crossover Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_gain_1 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_gain_2 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_gain_3 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_gain_4 Float 0 1
/sd/ Input_Channels / * / Dynamics / gate_range Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_thresh_1 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_thresh_2 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_thresh_3 Float 0 1
/sd/ Input_Channels / * / Dynamics / gate_thresh Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_knee_1 Int 0 2
/sd/ Input_Channels / * / Dynamics / comp_knee_2 Int 0 2
/sd/ Input_Channels / * / Dynamics / comp_knee_3 Int 0 2
/sd/ Input_Channels / * / Dynamics / comp_knee_4 Int 0 2
/sd/ Input_Channels / * / Dynamics / comp_ratio_1 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_ratio_2 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_ratio_3 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_ratio_4 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_attack_1 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_attack_2 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_attack_3 Float 0 1
/sd/ Input_Channels / * / Dynamics / gate_attack Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_release_1 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_release_2 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_release_3 Float 0 1
/sd/ Input_Channels / * / Dynamics / gate_release Float 0 1
/sd/ Input_Channels / * / Dynamics / gate_hold Float 0 1
/sd/ Input_Channels / * / Dynamics / key_solo Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_listen_1 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_listen_2 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_listen_3 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_all_gain Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_all_thresh Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_auto-gain_1 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_auto-gain_2 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_auto-gain_3 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_auto-gain_4 Int 0 1
/sd/ Input_Channels / * / Dynamics / desser_centre_freq Float 0 1
/sd/ Input_Channels / * / Dynamics / gate_centre_freq Float 0 1
/sd/ Input_Channels / * / Dynamics / desser_freq_width Float 0 1
/sd/ Input_Channels / * / Dynamics / gate_freq_width Float 0 1
/sd/ Input_Channels / * / Filters / hi_filter_in Int 0 1
/sd/ Input_Channels / * / Filters / lo_filter_in Int 0 1
/sd/ Input_Channels / * / Filters / hi_filter_freq Float 0 1
/sd/ Input_Channels / * / Filters / lo_filter_freq Float 0 1
/sd/ Input_Channels / * / Insert / insert_A_in Int 0 1
/sd/ Input_Channels / * / Insert / insert_B_in Int 0 1
/sd/ Input_Channels / * / Channel_Delay / delay_on Int 0 1
/sd/ Input_Channels / * / Channel_Delay / delay Float 0 1
/sd/ Input_Channels / * / Channel_Delay / fine_delay Float 0 1
/sd/ Input_Channels / * / CGs_level Float 0 1
/sd/ Input_Channels / * / solo Int 0 1
/sd/ Input_Channels / * / CGs_mute Int 0 1
/sd/ Input_Channels / * / fader Float 0 1
/sd/ Input_Channels / * / mute Int 0 1
/sd/ Input_Channels / * / Panner / f-b Float 0 1
/sd/ Input_Channels / * / Panner / pan Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_in Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_1 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_2 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_3 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_4 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_5 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_6 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_7 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_8 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_curve_1 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_curve_2 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_curve_3 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_curve_4 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_curve_5 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_curve_6 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_curve_7 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_curve_8 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_freq_1 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_freq_2 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_freq_3 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_freq_4 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_freq_5 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_freq_6 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_freq_7 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_freq_8 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_1 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_2 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_3 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_4 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_5 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_6 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_7 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_8 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_1 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_2 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_3 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_4 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_5 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_6 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_7 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_8 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_over-under_1 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_over-under_2 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_over-under_3 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_over-under_4 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_thresh_1 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_thresh_2 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_thresh_3 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_thresh_4 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_ratio_1 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_ratio_2 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_ratio_3 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_ratio_4 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_attack_1 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_attack_2 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_attack_3 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_attack_4 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_release_1 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_release_2 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_release_3 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_release_4 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_pre-ins Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_1 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_2 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_3 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_4 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_5 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_6 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_7 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_8 Int 0 1
/sd/ Aux_Outputs / * / EQ / dynamic_eq_on_1 Int 0 1
/sd/ Aux_Outputs / * / EQ / dynamic_eq_on_2 Int 0 1
/sd/ Aux_Outputs / * / EQ / dynamic_eq_on_3 Int 0 1
/sd/ Aux_Outputs / * / EQ / dynamic_eq_on_4 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_in Int 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_in Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_band_in_1 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_band_in_2 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_band_in_3 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_LP_crossover Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_HP_crossover Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_gain_1 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_gain_2 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_gain_3 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_gain_4 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_range Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_thresh_1 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_thresh_2 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_thresh_3 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_thresh Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_knee_1 Int 0 2
/sd/ Aux_Outputs / * / Dynamics / comp_knee_2 Int 0 2
/sd/ Aux_Outputs / * / Dynamics / comp_knee_3 Int 0 2
/sd/ Aux_Outputs / * / Dynamics / comp_knee_4 Int 0 2
/sd/ Aux_Outputs / * / Dynamics / comp_ratio_1 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_ratio_2 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_ratio_3 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_ratio_4 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_attack_1 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_attack_2 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_attack_3 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_attack Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_release_1 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_release_2 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_release_3 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_release Float 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_hold Float 0 1
/sd/ Aux_Outputs / * / Dynamics / key_solo Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_listen_1 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_listen_2 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_listen_3 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_all_gain Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_all_thresh Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_auto-gain_1 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_auto-gain_2 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_auto-gain_3 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_auto-gain_4 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / desser_centre_freq Float 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_centre_freq Float 0 1
/sd/ Aux_Outputs / * / Dynamics / desser_freq_width Float 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_freq_width Float 0 1
/sd/ Aux_Outputs / * / Insert / insert_A_in Int 0 1
/sd/ Aux_Outputs / * / Insert / insert_B_in Int 0 1
/sd/ Aux_Outputs / * / Channel_Delay / delay_on Int 0 1
/sd/ Aux_Outputs / * / Channel_Delay / delay Float 0 1
/sd/ Aux_Outputs / * / Channel_Delay / fine_delay Float 0 1
/sd/ Aux_Outputs / * / Buss_Trim / trim Float 0 1
/sd/ Aux_Outputs / * / Buss_Trim / phase Int 0 3
/sd/ Aux_Outputs / * / Buss_Trim / name String
/sd/ Aux_Outputs / * / CGs_level Float 0 1
/sd/ Aux_Outputs / * / solo Int 0 1
/sd/ Aux_Outputs / * / CGs_mute Int 0 1
/sd/ Aux_Outputs / * / fader Float 0 1
/sd/ Aux_Outputs / * / mute Int 0 1
/sd/ Group_Outputs / * / Group_Send / * / group Int 0 1
/sd/ Group_Outputs / * / EQ / eq_in Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_1 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_2 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_3 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_4 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_5 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_6 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_7 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_8 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_curve_1 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_curve_2 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_curve_3 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_curve_4 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_curve_5 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_curve_6 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_curve_7 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_curve_8 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_freq_1 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_freq_2 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_freq_3 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_freq_4 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_freq_5 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_freq_6 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_freq_7 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_freq_8 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_1 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_2 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_3 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_4 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_5 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_6 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_7 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_8 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_1 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_2 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_3 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_4 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_5 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_6 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_7 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_8 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_over-under_1 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_over-under_2 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_over-under_3 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_over-under_4 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_thresh_1 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_thresh_2 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_thresh_3 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_thresh_4 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_ratio_1 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_ratio_2 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_ratio_3 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_ratio_4 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_attack_1 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_attack_2 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_attack_3 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_attack_4 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_release_1 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_release_2 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_release_3 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_release_4 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_pre-ins Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_1 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_2 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_3 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_4 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_5 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_6 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_7 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_8 Int 0 1
/sd/ Group_Outputs / * / EQ / dynamic_eq_on_1 Int 0 1
/sd/ Group_Outputs / * / EQ / dynamic_eq_on_2 Int 0 1
/sd/ Group_Outputs / * / EQ / dynamic_eq_on_3 Int 0 1
/sd/ Group_Outputs / * / EQ / dynamic_eq_on_4 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_in Int 0 1
/sd/ Group_Outputs / * / Dynamics / gate_in Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_band_in_1 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_band_in_2 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_band_in_3 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_LP_crossover Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_HP_crossover Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_gain_1 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_gain_2 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_gain_3 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_gain_4 Float 0 1
/sd/ Group_Outputs / * / Dynamics / gate_range Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_thresh_1 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_thresh_2 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_thresh_3 Float 0 1
/sd/ Group_Outputs / * / Dynamics / gate_thresh Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_knee_1 Int 0 2
/sd/ Group_Outputs / * / Dynamics / comp_knee_2 Int 0 2
/sd/ Group_Outputs / * / Dynamics / comp_knee_3 Int 0 2
/sd/ Group_Outputs / * / Dynamics / comp_knee_4 Int 0 2
/sd/ Group_Outputs / * / Dynamics / comp_ratio_1 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_ratio_2 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_ratio_3 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_ratio_4 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_attack_1 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_attack_2 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_attack_3 Float 0 1
/sd/ Group_Outputs / * / Dynamics / gate_attack Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_release_1 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_release_2 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_release_3 Float 0 1
/sd/ Group_Outputs / * / Dynamics / gate_release Float 0 1
/sd/ Group_Outputs / * / Dynamics / gate_hold Float 0 1
/sd/ Group_Outputs / * / Dynamics / key_solo Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_listen_1 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_listen_2 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_listen_3 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_all_gain Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_all_thresh Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_auto-gain_1 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_auto-gain_2 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_auto-gain_3 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_auto-gain_4 Int 0 1
/sd/ Group_Outputs / * / Dynamics / desser_centre_freq Float 0 1
/sd/ Group_Outputs / * / Dynamics / gate_centre_freq Float 0 1
/sd/ Group_Outputs / * / Dynamics / desser_freq_width Float 0 1
/sd/ Group_Outputs / * / Dynamics / gate_freq_width Float 0 1
/sd/ Group_Outputs / * / Insert / insert_A_in Int 0 1
/sd/ Group_Outputs / * / Insert / insert_B_in Int 0 1
/sd/ Group_Outputs / * / Channel_Delay / delay_on Int 0 1
/sd/ Group_Outputs / * / Channel_Delay / delay Float 0 1
/sd/ Group_Outputs / * / Channel_Delay / fine_delay Float 0 1
/sd/ Group_Outputs / * / Buss_Trim / trim Float 0 1
/sd/ Group_Outputs / * / Buss_Trim / phase Int 0 3
/sd/ Group_Outputs / * / Buss_Trim / name String
/sd/ Group_Outputs / * / CGs_level Float 0 1
/sd/ Group_Outputs / * / solo Int 0 1
/sd/ Group_Outputs / * / CGs_mute Int 0 1
/sd/ Group_Outputs / * / fader Float 0 1
/sd/ Group_Outputs / * / mute Int 0 1
/sd/ Control_Groups / * / solo Int 0 1
/sd/ Control_Groups / * / fader Float 0 1
/sd/ Control_Groups / * / mute Int 0 1
/sd/ Control_Groups / * / name String
/sd/ Matrix_Inputs / * / Matrix_Send / * / send_level Float 0 1
/sd/ Matrix_Inputs / * / Matrix_Send / * / send_on Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_in Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_1 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_2 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_3 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_4 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_5 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_6 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_7 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_8 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_curve_1 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_curve_2 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_curve_3 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_curve_4 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_curve_5 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_curve_6 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_curve_7 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_curve_8 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_freq_1 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_freq_2 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_freq_3 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_freq_4 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_freq_5 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_freq_6 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_freq_7 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_freq_8 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_1 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_2 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_3 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_4 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_5 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_6 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_7 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_8 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_1 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_2 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_3 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_4 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_5 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_6 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_7 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_8 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_over-under_1 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_over-under_2 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_over-under_3 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_over-under_4 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_thresh_1 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_thresh_2 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_thresh_3 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_thresh_4 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_ratio_1 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_ratio_2 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_ratio_3 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_ratio_4 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_attack_1 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_attack_2 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_attack_3 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_attack_4 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_release_1 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_release_2 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_release_3 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_release_4 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_pre-ins Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_1 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_2 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_3 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_4 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_5 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_6 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_7 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_8 Int 0 1
/sd/ Matrix_Outputs / * / EQ / dynamic_eq_on_1 Int 0 1
/sd/ Matrix_Outputs / * / EQ / dynamic_eq_on_2 Int 0 1
/sd/ Matrix_Outputs / * / EQ / dynamic_eq_on_3 Int 0 1
/sd/ Matrix_Outputs / * / EQ / dynamic_eq_on_4 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_in Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_in Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_band_in_1 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_band_in_2 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_band_in_3 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_LP_crossover Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_HP_crossover Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_gain_1 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_gain_2 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_gain_3 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_gain_4 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_range Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_thresh_1 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_thresh_2 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_thresh_3 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_thresh Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_knee_1 Int 0 2
/sd/ Matrix_Outputs / * / Dynamics / comp_knee_2 Int 0 2
/sd/ Matrix_Outputs / * / Dynamics / comp_knee_3 Int 0 2
/sd/ Matrix_Outputs / * / Dynamics / comp_knee_4 Int 0 2
/sd/ Matrix_Outputs / * / Dynamics / comp_ratio_1 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_ratio_2 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_ratio_3 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_ratio_4 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_attack_1 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_attack_2 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_attack_3 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_attack Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_release_1 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_release_2 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_release_3 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_release Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_hold Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / key_solo Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_listen_1 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_listen_2 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_listen_3 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_all_gain Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_all_thresh Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_auto-gain_1 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_auto-gain_2 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_auto-gain_3 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_auto-gain_4 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / desser_centre_freq Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_centre_freq Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / desser_freq_width Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_freq_width Float 0 1
/sd/ Matrix_Outputs / * / Insert / insert_A_in Int 0 1
/sd/ Matrix_Outputs / * / Insert / insert_B_in Int 0 1
/sd/ Matrix_Outputs / * / Channel_Delay / delay_on Int 0 1
/sd/ Matrix_Outputs / * / Channel_Delay / delay Float 0 1
/sd/ Matrix_Outputs / * / Channel_Delay / fine_delay Float 0 1
/sd/ Matrix_Outputs / * / Buss_Trim / trim Float 0 1
/sd/ Matrix_Outputs / * / Buss_Trim / phase Int 0 3
/sd/ Matrix_Outputs / * / Buss_Trim / name String
/sd/ Matrix_Outputs / * / CGs_level Float 0 1
/sd/ Matrix_Outputs / * / solo Int 0 1
/sd/ Matrix_Outputs / * / CGs_mute Int 0 1
/sd/ Matrix_Outputs / * / fader Float 0 1
/sd/ Matrix_Outputs / * / mute Int 0 1
/sd/ Graphic_EQ / * / geq_in Int 0 1
/sd/ Graphic_EQ / * / geq_gain_1 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_2 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_3 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_4 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_5 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_6 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_7 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_8 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_9 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_10 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_11 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_12 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_13 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_14 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_15 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_16 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_17 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_18 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_19 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_20 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_21 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_22 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_23 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_24 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_25 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_26 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_27 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_28 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_29 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_30 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_31 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_32 Float 0 1
/sd/ Multis / * / solo Int 0 1
/sd/ Multis / * / fader Float 0 1
/sd/ Multis / * / mute Int 0 1
/sd/ Multis / * / name String
/sd/ Filing / Save_current_Session / Int 0 0
/sd/ Snapshots / Fire_Snapshot_number / Int 0 9999
/sd/ Snapshots / Fire_Prev_Snapshot / Int 0 0
/sd/ Snapshots / Fire_Next_Snapshot / Int 0 0
/sd/ Macros / Buttons / press Int 0 255