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:
+140
@@ -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
Executable
+278
@@ -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);
|
||||
Executable
+792
@@ -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);
|
||||
Executable
+284
@@ -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 };
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user