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

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 15:55:18 -04:00
co-authored by Claude Fable 5.1
commit 131cdfd81f
23 changed files with 10066 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
pkg/
*.tgz
*.log
.DS_Store
+42
View File
@@ -0,0 +1,42 @@
# companion-module-digico-s31
Bitfocus Companion (5.0+) connection module for the DiGiCo S21/S31: per-channel control of
one buss (the Apollo Beach "Studio" group by default), aux send level/on/pan, mutes, faders,
snapshots — plus a UDP relay so several DiGiCo iPad apps can share the console's single iPad slot.
User-facing documentation (console + iPad setup, variables, the verified protocol table) is in
[companion/HELP.md](companion/HELP.md); Companion shows it on the connection's help button.
## Layout
| Path | Purpose |
| --- | --- |
| `src/main.js` | the `InstanceBase` subclass (config, lifecycle, state, learn log) |
| `src/definitions.js` | actions, feedbacks, variables, presets |
| `src/dialects.js` | address builders/parsers: `sd` (iPad extension, verified), `sseries` (OSC Control extension), `raw` |
| `src/console-link.js` | one UDP socket to the console (fixed local port) |
| `src/relay.js` | iPad relay: tracks clients, forwards verbatim, fans out |
| `src/osc.js` | tolerant OSC codec |
| `tools/` | `mock-s31.js` (console emulator, both dialects), `fake-ipad.js`, see `tools/README.md` |
| `test/selftest.js` | end-to-end check: mock console ↔ link/relay ↔ fake iPad |
## Develop
```bash
npm install # @companion-module/base ~2.1.3 (2.2.x is rejected by Companion 5.0.x)
npm test # runs test/selftest.js against the mock console
```
## Install as a developer module (how it runs on AB-ProPresenter)
1. Copy this folder, including `node_modules`, to the Companion computer, e.g.
`~/Apps/companion-dev-modules/companion-module-digico-s31/`.
2. In the Companion launcher → Developer, enable developer modules and set the path to the
*parent* folder (`~/Apps/companion-dev-modules`); the launcher restarts Companion with
`--extra-module-path`. (On AB this was done by editing `dev_modules_path` in
`~/Library/Application Support/companion/config.json` and relaunching Companion.)
3. Connections → Add → "DiGiCo: S21; S31" (version *Dev*). Later file changes hot-reload the
connection.
The console side (Extensions → iPad) must point at the Companion computer's IP with
Send Port 9000 / Receive Port 8000 (the module defaults).
+96
View File
@@ -0,0 +1,96 @@
# DiGiCo S31 (S-Series) — Studio Mon control + iPad relay
This connection does two things:
1. **Controls which channels feed one buss** — by default the *Studio* buss — plus any aux send
level/on/pan, channel mute/fader, buss master fader/mute and snapshot recall, with feedbacks,
variables and ready-made presets.
2. **Relays the DiGiCo iPad app.** An S-Series console talks to exactly one iPad at a time.
Companion takes that slot; the iPad apps point at the Companion computer instead of the
console. Everything an app sends is forwarded to the console unchanged and every console
message is copied to every app seen recently, so several iPads can be used at once.
## What the console speaks (verified on "S31 AB", 2026-10-15)
The S31's **iPad** extension speaks the DiGiCo Pad / SD-style OSC tree over UDP:
| Address | Reply | Notes |
| --- | --- | --- |
| `/Console/Name/?` | `/Console/Name "S31 AB"` | used as keepalive |
| `/Console/Input_Channels/?`, `/Console/Aux_Outputs/?` | int counts (60, 25) | 25 busses, each in aux *or* group mode |
| `/Console/Aux_Outputs/modes/?` | 25 ints, 2 = stereo, 1 = mono | |
| `/Input_Channels/N/Channel_Input/name/?` | string | |
| `/Input_Channels/N/fader/?`, `/mute/?` | float dB (−150 = −∞), float 0/1 | |
| `/Input_Channels/N/Aux_Send/M/send_level` | float dB, −150 = off | write a float |
| `/Input_Channels/N/Aux_Send/M/send_on` | float 0/1 | write int 0/1 |
| `/Input_Channels/N/Aux_Send/M/send_pan` | float 0..1, 0.5 centre | |
| `/Input_Channels/N/Group_Send/M/group` | float 0/1 | group assign; write int 0/1 |
| `/Aux_Outputs/M/Buss_Trim/name/?`, `/Group_Outputs/M/Buss_Trim/name/?` | string | a buss is named on one side only |
| `/Aux_Outputs/M/fader`, `/mute` (and `/Group_Outputs/M/…`) | float | buss masters |
| `/Snapshots/Current_Snapshot/?`, `/Snapshots/names/?` | int; `/Snapshots/name [i, cue×100, 0, name]` | |
A query is the address plus `/?` with no argument; the reply is the same address without `/?`.
Every reply goes to the controller IP and **Send Port** configured on the console, never to the
asking socket, which is why a fixed local port is bound. The lowercase S-Series *OSC Control*
command list (`/channel/N/…`, `/console/resend`) is **not** answered on the iPad leg; it belongs
to the separate *OSC Control* extension and is available here as an alternative command set.
## Console setup (once, at the surface)
Main Menu → **Extensions → iPad**:
| Console field | Value |
| --- | --- |
| iPad IP address | the Companion computer (AB-ProPresenter: `10.3.11.152`) |
| Send port *(console → iPad)* | this connection's **Local receive port** (default `9000`) |
| Receive port *(iPad → console)* | this connection's **Console receive port** (default `8000`) |
| iPad connection enabled | yes |
## iPad app setup
In the DiGiCo S app connect page, for every iPad:
| iPad field | Value |
| --- | --- |
| Console IP | the Companion computer, **not** the console |
| Send port | this connection's **Relay port** (default `8000`) |
| Receive port | this connection's **iPad receive port** (default `9000`) |
An iPad that goes quiet for *Forget an iPad after* seconds is dropped from the fan-out list.
`$(S31:relay_clients)` shows how many are live.
## Studio Mon buss
Set **Studio Mon buss number** (1-25, the console's buss order). The module reads the buss
names and works out whether it is an **aux** (send level + on/off per channel) or a **group**
(assign on/off per channel); override with *Studio Mon buss type* if needed. On this console:
| Buss | Aux name | Group name |
| --- | --- | --- |
| 1-8 | VOCAL VERB, VOCAL SLAP, TRACKS, CLK-GD-MD, VOCAL DELAY, VOCALS, TB, MC/SpkrBcastOut | |
| 9-13 | | BAND, VOCALS, MISC, DRUMS, DRUM CRUSH |
| 14-17 | DRUM PLATE, AG VERB, FOH Spkr, Studio TB | |
| 18 | | Broadcast mix |
| 19-21 | FX, Prop & Spot, Drum Room Verb | |
| 23-25 | | CAFE, **Studio**, Master |
The default is buss **24 "Studio"** (stereo group). Presets under *Studio Mon* give one
on/off button per input (green when assigned/on, label = channel name); for an aux buss the
+1 dB / −1 dB nudge presets appear as well.
## Variables
`$(S31:ch_N_name)`, `$(S31:aux_N_name)`, `$(S31:group_N_name)`, `$(S31:buss_N_mode)`,
`$(S31:mon_N_on)`, `$(S31:mon_N_level)`, `$(S31:mon_name)`, `$(S31:mon_mode)`,
`$(S31:snapshot_name)`, `$(S31:connected)`, `$(S31:relay_clients)`, `$(S31:last_send_touched)`,
`$(S31:last_rx)`.
## Troubleshooting
- **No reply from console**: the console only sends to the iPad IP + Send Port set on the
console. Check those, and that nothing else on this computer owns the local receive port
(on AB-ProPresenter UDP 8002 belongs to Dante Via; 8000 and 9000 are free).
- **Learn mode** logs every new address pattern once (Connections → log). Touch a control on
the surface and the matching address appears; `$(S31:last_send_touched)` shows the last
send/assign that changed.
- Press **Reload from console** after a session change to refresh names and levels.
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "../node_modules/@companion-module/base/assets/manifest.schema.json",
"type": "connection",
"id": "digico-s31",
"name": "digico-s31",
"shortname": "DiGiCo S31",
"description": "DiGiCo S21/S31 aux-send control over OSC with a relay that lets several DiGiCo iPad apps share the console's single remote slot",
"version": "0.1.0",
"license": "MIT",
"repository": "http://chronicles-ab.waterschurch.org:3000/Waters-Church/companion-module-digico-s31",
"bugs": "http://chronicles-ab.waterschurch.org:3000/Waters-Church/companion-module-digico-s31/issues",
"maintainers": [{ "name": "Waters Church Production" }],
"legacyIds": [],
"runtime": {
"type": "node22",
"api": "nodejs-ipc",
"apiVersion": "2.1.3",
"entrypoint": "../src/main.js"
},
"manufacturer": "DiGiCo",
"products": ["S21", "S31"],
"keywords": ["Audio", "Sound", "Mixer", "OSC"]
}
+216
View File
@@ -0,0 +1,216 @@
# companion-module-digico-s31 — v2 design (generic S-Series module)
Status: decided 2026-10-15. This is the brief for the v2 implementation. Where this document and
`tools/catalog/s31-catalog.md` (what the real console answers) disagree, the catalog wins and the
design note is updated.
## Goals
1. Generic: nothing church-specific in settings, names, help or defaults. No "Studio Mon".
2. Minimal settings: console address/ports, relay, debug. Everything else is discovered.
3. Rich, discoverable control: every option that identifies a channel/buss/matrix/CG/snapshot is a
**dropdown populated with the live names** from the console, never a bare integer field.
4. Encoder-friendly: relative "adjust" actions for every level, and presets that use rotary steps.
5. Feedback everywhere: boolean feedbacks for every switch, dynamic-text and bar feedbacks for
every level, and presets wired with them.
## Settings (connection config) — the complete list
| id | type | default | label |
| --- | --- | --- | --- |
| `host` | textinput (Regex.IP) | `` | Console IP |
| `consolePort` | number | 8000 | Console receive port (module sends here) |
| `localPort` | number | 9000 | Local receive port (console sends here; = console "Send port") |
| `relayEnable` | checkbox | true | Enable iPad relay |
| `relayPort` | number | 8000 | Relay port (iPad apps send here) |
| `relayClientPort` | number | 9000 | iPad receive port (0 = reply to the app's source port) |
| `relayTimeout` | number | 180 | Forget an iPad after this many idle seconds |
| `debugLog` | checkbox | false | Debug logging (log every new address pattern + all traffic at debug level) |
Two `static-text` blocks explain the console-side iPad extension settings and the iPad-app settings
(generic wording: "this computer", no IPs). Nothing else. Keepalive is always on. Dialect is fixed to
the DiGiCo Pad / SD tree (the only one the iPad extension speaks). On/off values are written as
int 0/1, levels and pans as floats (DiGiCo SD OSC list).
## Topology discovery
Maxima probed (S31 limits; S21 simply answers fewer): inputs 60, busses 25 (`Aux_Outputs` and
`Group_Outputs` are the same 25 busses, named on the side matching the buss mode), matrix outputs
8, matrix inputs 10, control groups 10, graphic EQs 16. On connect the module queries the counts
(`/Console/Input_Channels/?` etc.; the S31 answers these only sometimes) and the **name** of every
index; an entity whose name query never answered within the load is treated as absent and hidden
from dropdowns, variables and presets. Buss mode: `group` when the Group side name is non-empty
and the Aux side is empty, otherwise `aux`. Stereo/mono from `/Console/Aux_Outputs/modes`.
Use exactly the addresses the catalog marks as answered. Never send an address the catalog does
not list as answered (except `raw_osc`).
## State store (`src/state.js`)
Maps keyed by entity index: `inputs[n] = {name, fader, mute, solo, pan}`, `busses[n] = {name,
mode, stereo, fader, mute, solo}`, `matrix[n]`, `cgs[n]`, `geqs[n]`; `sends["ch:buss"] = {level,
on, pan, prepost}`; `groups["ch:buss"] = bool`; `matrixSends["in:out"]`; `snapshots = {current,
list: Map(index -> {name, cue})}`; `macros`; `console = {name}`. One `applyMessage(address, args)`
entry point returning the normalised event; one `choices` section that builds dropdown choice arrays:
- inputs: `{ id: n, label: "n · Name" }` (name falls back to `Input n`)
- busses: `"n · Name (aux)"` / `"n · Name (group)"`; filtered variants `auxChoices`, `groupChoices`
- matrix / cgs / geqs: `"n · Name"`
- snapshots: `"index · Name"` (+ ` · cue X` when a cue number exists)
Definitions are rebuilt (actions, feedbacks, variables, presets) whenever a name, mode or the
snapshot list changes, debounced ~750 ms, and once when the initial load finishes. Option ids stay
numeric so existing buttons survive renames.
## Actions
Option conventions: `channel` = inputs dropdown; `channels` = inputs multidropdown (for bulk
mute/solo); `buss` = busses dropdown (aux-only or group-only where it matters); `state` = dropdown
on/off/toggle; `level` = number dB (−150..10, step 0.5); `delta` = number dB (−30..30, default 1,
for encoders); `pan` = number 0..1 (0.5 centre), `pdelta` = number.
| id | options | notes |
| --- | --- | --- |
| `input_mute` | channels, state | |
| `input_solo` | channels, state | if solo answered in the catalog |
| `input_fader_set` | channel, level | |
| `input_fader_adjust` | channel, delta | clamps; from −∞ the first step lands at −60 dB |
| `input_pan_set` / `input_pan_adjust` | channel, pan / pdelta | |
| `input_name_set` | channel, name (textinput, useVariables) | |
| `send_on` | channel, buss(aux), state | |
| `send_level_set` / `send_level_adjust` | channel, buss(aux), level / delta | |
| `send_pan_set` / `send_pan_adjust` | channel, buss(aux), pan / pdelta | |
| `send_prepost` | channel, buss(aux), mode pre/post/toggle | if answered |
| `group_assign` | channel, buss(group), state | |
| `buss_mute` / `buss_solo` | busses (multi), state | |
| `buss_fader_set` / `buss_fader_adjust` | buss, level / delta | |
| `matrix_mute` / `matrix_fader_set` / `matrix_fader_adjust` | matrix, … | |
| `matrix_send_on` / `matrix_send_level_set` / `matrix_send_level_adjust` | matrixIn, matrixOut, … | only if the catalog shows Matrix_Send answering |
| `cg_mute` / `cg_solo` / `cg_fader_set` / `cg_fader_adjust` | cg, … | |
| `geq_band_set` / `geq_band_adjust` / `geq_on` | geq, band, gain | only if answered |
| `snapshot_fire` | snapshot (dropdown, allowCustom for a raw number) | |
| `snapshot_next` / `snapshot_prev` | — | |
| `macro_press` | macro | only if macros answer |
| `reload` | — | re-query everything |
| `raw_osc` | address, argType, value | experiments |
| `relay_forget` | — | |
Toggle semantics need the current value: if unknown, query it first and apply the toggle when the
reply arrives (or default to "on"). Every write is followed by a query of the same address so the
state is confirmed even if the console does not echo.
## Feedbacks
Boolean (with sensible `defaultStyle`): `input_muted`, `input_solo`, `send_on`, `group_assigned`,
`send_pre` (pre-fade), `buss_muted`, `buss_solo`, `matrix_muted`, `cg_muted`, `snapshot_current`
(snapshot dropdown), `level_compare` (entity kind dropdown input/buss/matrix/cg + index + above/below
+ threshold), `send_level_compare`, `connected`, `relay_clients`.
Value feedbacks (`type: 'value'`, return a JsonValue) feed preset **local variables** so button
text can show live numbers without thousands of module variables: `input_fader_value` (dB string,
`-inf` at the floor), `input_pan_value`, `send_level_value`, `send_on_value` (`On`/`Off`),
`buss_fader_value`, `matrix_fader_value`, `cg_fader_value`, `snapshot_name_value`,
`input_meter_value`. Presets declare `localVariables: [{ variableName: 'level', variableType:
'feedback', feedbackId: 'send_level_value', options: {…} }]` and use `$(local:level)` in the text.
Advanced (bar): `input_fader_bar`, `send_level_bar`, `buss_fader_bar` — returns an `imageBuffer`
(base64 RGBA, `imageBufferEncoding: { pixelFormat: 'RGBA' }`, `imageBufferPosition` sized from
`event.image`) drawing a horizontal bar along the bottom edge of the button proportional to the
level (−60..+10 mapped to 0..100 %), colour option. Implemented with a small RGBA helper in
`src/image.js`. Verify bytes-per-pixel on the real Companion (4 for RGBA).
Base 2.1 has **no feedback `subscribe`**. Send-pair state is loaded on first use instead: when a
feedback or action callback finds the pair's state unknown, enqueue the queries for that pair
(de-duplicated per pair) and return the unknown state; the console's reply updates state and
`checkFeedbacks` re-renders. Sends are never bulk-loaded (60 × 25 × 3 queries would take a minute).
Number options for levels use `showMinAsNegativeInfinity: true`; every entity dropdown sets
`minChoicesForSearch: 0` so lists are searchable. Visibility of conditional options uses
`isVisibleExpression` strings (the only form in base 2.1).
## Variables
Only for entities that exist: `input_N_name|fader|mute|pan`, `buss_N_name|mode|stereo|fader|mute`,
`matrix_N_name|fader|mute`, `cg_N_name|fader|mute`, `snapshot_index|name|cue|count`,
`console_name`, `connected`, `relay_clients`, `relay_client_ips`, `last_change`. No per-send
variables (use the advanced text feedbacks).
## Presets (type `simple`; names carry the live entity name; rebuilt when names load)
Structure groups and their definitions:
1. **Inputs** — per input: *Mute* (toggle; feedback muted = red; text `$(label:input_N_name)`),
*Fader (knob)* (rotary: left/right = `input_fader_adjust` ∓1 dB; press = `input_mute` toggle;
feedbacks: `input_text` with `{name}\n{fader}`, `input_fader_bar`, muted = red), *Solo*.
2. **Busses** — per buss: *Master mute*, *Master fader (knob)* (rotary ±1 dB, press = mute toggle,
text + bar).
3. **Sends to <aux name>** — one group per aux buss, per input: *On/off* (toggle; green when on;
`send_text` `{name}\n{level}`), *Level (knob)* (rotary ±1 dB, press toggles on/off; text + bar).
4. **Group <name>** — one group per group buss, per input: *Assign* (toggle, green when assigned).
5. **Matrix** — per output: mute, fader knob. **Control groups** — per CG: mute, fader knob.
6. **Snapshots** — per snapshot: *Fire* (green when current, text = name); *Next*, *Previous*,
*Current* (display only, `snapshot_text`).
7. **Console** — Reload, Connection status, Relay status (count).
Rotary presets set `options.rotaryActions: true` and use the `rotate_left` / `rotate_right` step
arrays (verify exact field names in `@companion-module/base` 2.1.3 types).
## Loading, keepalive, relay
As in v1: fixed local port; keepalive `/Console/Name/?` every 5 s; 20 s silence → Disconnected;
reconnect → full reload; paced query queue (20 ms); learn log of new address patterns when debug
is on; verbatim relay with fan-out and idle timeout. The console pushes changes made on the surface
unsolicited, so after the initial load the state stays current without polling.
## Protocol facts learned from a live iPad session (2026-10-15)
Addresses the DiGiCo S app itself uses on this console (all verified answering):
- `/Console/Aux_Outputs/types/?` → 25 ints, **1 = aux, 0 = group**: the authoritative buss-mode
source (use it; fall back to the name heuristic only if it does not answer).
`/Console/Input_Channels/modes/?` → 60 ints (stereo modes per input), `/Console/Group_Outputs/modes/?`.
- `/Console/Session/Filename/?` → session file name (expose as `session_name`).
- `/Input_Channels/N/Panner/pan` (float 0..1) is the pan address (not `/Input_Channels/N/pan`).
- `/Input_Channels/N/solo` (float 0/1); `/Input_Channels/N/CGs_level`, `/CGs_mute` (control-group
contribution, read-only display); `/Input_Channels/N/Channel_Input/main_alt_in`, `stereo_mode`,
`analog_gain`, `trim`; `/Input_Channels/N/total_gain` (read-only, pushed with fader moves).
- `/Control_Groups/N/fader|mute|solo` — the iPad queried `Control_Groups/0`, so CG indexing may be
**0-based**; the catalog decides (probe both 0 and 1).
- `/Group_Outputs/N/fader|mute|solo`, `/Aux_Outputs/N/fader|mute`, `/Matrix_Outputs/N/fader`.
- `/Snapshots/name/? [i]` (int arg) returns one snapshot's name; `/Snapshots/Surface_Snapshot`.
- `/Layout/Layout/Banks` (surface bank layout; read-only, ignore).
- The console pushes every change made at the surface unsolicited (fader, mute, solo, sends, EQ…).
## Metering (captured from a live iPad session through the relay, 2026-10-15)
The console streams meters only to subscribed ids, using the same iPad leg:
- Subscribe: send `/<Entity>/N/<meter point> ,ii [meterId, kind]` where the **client chooses
`meterId`** (int) and `kind` selects the meter: seen `/Input_Channels/N/Channel_Input/post_meter
[id, 1]` (post-fader input level) and `/Input_Channels/N/Dynamics/GR_meter [id, 1]` plus
`[id2, 4]` (two gain-reduction meters per channel, presumably gate and compressor). The iPad uses
ids 0-19 (its visible bank), 200+, and 300+.
- Stream: `/Meters/values ,ii… [id, value, id, value, …]` — only ids whose value changed, several
times a second; values are integers 0..127 (127 seen as full scale / peak).
- `/Meters/clear` (no args) clears every subscription on the console, including ours. The iPad
sends it on connect. When the relay sees an iPad send `/Meters/clear`, the module must re-send its
own subscriptions after ~500 ms. Never send `/Meters/clear` from the module.
- The module uses ids from **5000** upward (5000 + input index for post meters; 6000 + index for
buss output meters if `/Aux_Outputs/N/.../post_meter`-style addresses answer — the builder probes
the mock only; the real address for buss meters is unknown and must be learned from the iPad log)
so they never collide with an iPad's ids. Subscriptions are made lazily: the first time a meter
feedback for an entity runs, subscribe; re-subscribe after reconnect and after `/Meters/clear`.
- Exposed as: value feedback `input_meter_value` (0-127 and percent), boolean `input_signal`
(value above a threshold option, default 5) and `input_peak` (≥ 127, with a hold time option),
advanced `input_meter_bar` (vertical bar at the right edge, green/amber/red zones), and the same
trio for busses once their address is known. Meter updates must NOT trigger definition rebuilds
or variable updates for all channels; only `checkFeedbacks` for the meter feedback ids, throttled
to at most ~10 Hz.
Known relay limitation: the console holds one meter subscription table; two iPads subscribing the
same ids for different banks will both receive mixed data. Document it; nothing to fix in v2.
## Out of scope for v2
Per-channel EQ/dynamics/filters (available via `raw_osc`), S-Series OSC Control dialect
(removed), snapshot editing.
+37
View File
@@ -0,0 +1,37 @@
{
"name": "companion-module-digico-s31",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "companion-module-digico-s31",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@companion-module/base": "~2.1.3"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@companion-module/base": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/@companion-module/base/-/base-2.1.3.tgz",
"integrity": "sha512-/Sf4qL+e3fSf3abcqIcMUQSTCO4tytdImQ+mUl2c0hJh3V+ne/audMtJX8NIHoJGlHgJOAMv5Xt6QFYiyJ4o3g==",
"license": "MIT",
"dependencies": {
"colord": "^2.9.3"
},
"engines": {
"node": "^22.20 || ^26.5"
}
},
"node_modules/colord": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz",
"integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==",
"license": "MIT"
}
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "companion-module-digico-s31",
"version": "0.1.0",
"description": "DiGiCo S21/S31 aux-send control for Bitfocus Companion, with a relay so several DiGiCo iPad apps can share the console's single remote slot",
"type": "module",
"main": "src/main.js",
"license": "MIT",
"private": true,
"scripts": {
"test": "node test/selftest.js",
"package": "companion-module-build"
},
"dependencies": {
"@companion-module/base": "~2.1.3"
},
"engines": {
"node": ">=22"
}
}
+83
View File
@@ -0,0 +1,83 @@
import dgram from 'node:dgram'
import { EventEmitter } from 'node:events'
import { encode, decode } from './osc.js'
/**
* One UDP socket toward the console.
*
* The S-Series sends every reply and every change notification to the single
* controller IP:port configured on the console, never to the packet's source
* port, so we bind a fixed local port (the console's "Send Port") and send to
* the console's "Receive Port".
*
* Events: 'raw' (buf, rinfo) for every datagram from the console,
* 'message' (decoded, buf, rinfo), 'stray' (buf, rinfo) for packets from
* other hosts, 'error' (err), 'listening' (addressInfo).
*/
export class ConsoleLink extends EventEmitter {
constructor({ host, port, localPort, bindAddress = '0.0.0.0' }) {
super()
this.host = host
this.port = Number(port)
this.localPort = Number(localPort)
this.bindAddress = bindAddress
this.socket = null
this.lastRx = 0
this.rxCount = 0
this.txCount = 0
}
open() {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: false })
let settled = false
socket.on('error', (err) => {
if (!settled) {
settled = true
reject(err)
} else this.emit('error', err)
})
socket.on('message', (buf, rinfo) => {
if (this.host && rinfo.address !== this.host) {
this.emit('stray', buf, rinfo)
return
}
this.lastRx = Date.now()
this.rxCount++
this.emit('raw', buf, rinfo)
this.emit('message', decode(buf), buf, rinfo)
})
socket.bind(this.localPort, this.bindAddress, () => {
settled = true
this.socket = socket
this.emit('listening', socket.address())
resolve(socket.address())
})
})
}
send(address, args = []) {
return this.sendRaw(encode(address, args))
}
sendRaw(buf) {
if (!this.socket || !this.host) return false
this.txCount++
this.socket.send(buf, this.port, this.host, (err) => {
if (err) this.emit('error', err)
})
return true
}
close() {
const s = this.socket
this.socket = null
if (s) {
try {
s.close()
} catch (_e) {
/* already closed */
}
}
}
}
+345
View File
@@ -0,0 +1,345 @@
import { combineRgb } from '@companion-module/base'
export const MAX_INPUTS = 60
export const MAX_BUSSES = 25
export const BUS_FIRST = 70
export const BUS_LAST = 93
const GREEN = combineRgb(0, 160, 60)
const RED = combineRgb(190, 30, 30)
const AMBER = combineRgb(200, 140, 0)
const WHITE = combineRgb(255, 255, 255)
const BLACK = combineRgb(0, 0, 0)
const DARK = combineRgb(30, 30, 30)
const STATE_CHOICES = [
{ id: 'on', label: 'On' },
{ id: 'off', label: 'Off' },
{ id: 'toggle', label: 'Toggle' },
]
export const CHANNEL_HELP = 'inputs 1-60; with the S-Series OSC Control set busses are 70-93 and master 120'
function channelOption(id = 'channel', label = 'Input channel (1-60)') {
return { type: 'number', id, label, default: 1, min: 1, max: 120, step: 1 }
}
function bussOption(inst, id = 'send', label = 'Buss number (1-25)') {
return { type: 'number', id, label, default: inst.monSend(), min: 1, max: MAX_BUSSES, step: 1 }
}
function levelOption(inst, id = 'level', label) {
const d = inst.dialect
return {
type: 'number',
id,
label: label || `Level (dB, ${d.levelMin} = off)`,
default: 0,
min: d.levelMin,
max: d.levelMax,
step: 0.5,
required: true,
}
}
function deltaOption() {
return { type: 'number', id: 'delta', label: 'Change (dB)', default: 1, min: -30, max: 30, step: 0.5 }
}
function stateOption(label = 'State') {
return { type: 'dropdown', id: 'state', label, default: 'toggle', choices: STATE_CHOICES }
}
export function getActionDefinitions(inst) {
const mon = inst.monLabel()
const d = inst.dialect
return {
mon_on: {
name: `${mon}: channel on/off (aux send on, or group assign)`,
description: 'Uses the Studio Mon buss from the connection settings; works whether that buss is an aux (send on/off) or a group (assign).',
options: [channelOption(), stateOption()],
callback: async ({ options }) => inst.setMonOn(options.channel, options.state),
},
mon_level: {
name: `${mon}: set send level (aux mode only)`,
options: [channelOption(), levelOption(inst)],
callback: async ({ options }) => inst.setSendLevel(options.channel, inst.monSend(), options.level),
},
mon_nudge: {
name: `${mon}: nudge send level (aux mode only)`,
options: [channelOption(), deltaOption()],
callback: async ({ options }) => inst.nudgeSendLevel(options.channel, inst.monSend(), options.delta),
},
send_on: {
name: 'Aux send: on/off',
options: [channelOption(), bussOption(inst, 'send', 'Aux buss number (1-25)'), stateOption()],
callback: async ({ options }) => inst.setSendEnabled(options.channel, options.send, options.state),
},
send_level: {
name: 'Aux send: set level',
options: [channelOption(), bussOption(inst, 'send', 'Aux buss number (1-25)'), levelOption(inst)],
callback: async ({ options }) => inst.setSendLevel(options.channel, options.send, options.level),
},
send_nudge: {
name: 'Aux send: nudge level',
options: [channelOption(), bussOption(inst, 'send', 'Aux buss number (1-25)'), deltaOption()],
callback: async ({ options }) => inst.nudgeSendLevel(options.channel, options.send, options.delta),
},
send_pan: {
name: 'Aux send: set pan',
options: [
channelOption(),
bussOption(inst, 'send', 'Aux buss number (1-25)'),
{ type: 'number', id: 'pan', label: `Pan (${d.panMin} L … ${d.panMax} R, centre ${d.panCentre})`, default: d.panCentre, min: d.panMin, max: d.panMax, step: 0.05 },
],
callback: async ({ options }) => inst.sendDialect('sendPan', options.channel, options.send, options.pan),
},
group_assign: {
name: 'Group: assign channel on/off',
options: [channelOption(), bussOption(inst, 'group', 'Group buss number (1-25)'), stateOption()],
callback: async ({ options }) => inst.setGroupAssign(options.channel, options.group, options.state),
},
fader: {
name: 'Channel fader (dB)',
options: [channelOption('channel', `Channel number (${CHANNEL_HELP})`), { type: 'number', id: 'level', label: 'Level (dB)', default: 0, min: -150, max: 10, step: 0.5 }],
callback: async ({ options }) => inst.sendDialect('fader', options.channel, options.level),
},
mute: {
name: 'Channel mute',
options: [channelOption('channel', `Channel number (${CHANNEL_HELP})`), stateOption()],
callback: async ({ options }) => inst.setMute(options.channel, options.state),
},
buss_fader: {
name: 'Aux buss master fader (dB)',
options: [bussOption(inst, 'buss', 'Aux buss number (1-25)'), { type: 'number', id: 'level', label: 'Level (dB)', default: 0, min: -150, max: 10, step: 0.5 }],
callback: async ({ options }) => inst.sendDialect('auxFader', options.buss, options.level),
},
buss_mute: {
name: 'Aux buss master mute',
options: [bussOption(inst, 'buss', 'Aux buss number (1-25)'), stateOption()],
callback: async ({ options }) => inst.setBussMute(options.buss, options.state),
},
snapshot_fire: {
name: 'Snapshot: fire number',
options: [{ type: 'number', id: 'number', label: 'Snapshot number', default: 1, min: 0, max: 9999, step: 1 }],
callback: async ({ options }) => inst.sendDialect('snapshotFire', Math.trunc(options.number)),
},
snapshot_next: { name: 'Snapshot: next', options: [], callback: async () => inst.sendDialect('snapshotNext') },
snapshot_prev: { name: 'Snapshot: previous', options: [], callback: async () => inst.sendDialect('snapshotPrev') },
resend: {
name: 'Console: reload names, levels and sends',
options: [],
callback: async () => inst.requestResend(),
},
raw_osc: {
name: 'Send raw OSC message',
description: 'For experiments: any address, optional single argument. Append /? to an address to query it.',
options: [
{ type: 'textinput', id: 'address', label: 'Address', default: '/Console/Name/?', useVariables: true },
{
type: 'dropdown',
id: 'argType',
label: 'Argument type',
default: 'none',
choices: [
{ id: 'none', label: 'None' },
{ id: 'int', label: 'Integer' },
{ id: 'float', label: 'Float' },
{ id: 'string', label: 'String' },
{ id: 'bool', label: 'Boolean (T/F)' },
],
},
{ type: 'textinput', id: 'value', label: 'Value', default: '', useVariables: true },
],
callback: async ({ options }) => inst.sendRawAction(options),
},
relay_forget: {
name: 'Relay: forget all iPad clients',
options: [],
callback: async () => inst.relay?.forgetClients(),
},
}
}
export function getFeedbackDefinitions(inst) {
const mon = inst.monLabel()
const d = inst.dialect
return {
mon_on: {
type: 'boolean',
name: `${mon}: channel is on (send on / assigned)`,
defaultStyle: { bgcolor: GREEN, color: WHITE },
options: [channelOption()],
callback: ({ options }) => inst.getMonOn(options.channel) === true,
},
send_on: {
type: 'boolean',
name: 'Aux send: is on',
defaultStyle: { bgcolor: GREEN, color: WHITE },
options: [channelOption(), bussOption(inst, 'send', 'Aux buss number (1-25)')],
callback: ({ options }) => inst.getSend(options.channel, options.send).enabled === true,
},
group_assigned: {
type: 'boolean',
name: 'Group: channel is assigned',
defaultStyle: { bgcolor: GREEN, color: WHITE },
options: [channelOption(), bussOption(inst, 'group', 'Group buss number (1-25)')],
callback: ({ options }) => inst.getGroup(options.channel, options.group) === true,
},
mon_level_above: {
type: 'boolean',
name: `${mon}: send level is above`,
defaultStyle: { bgcolor: AMBER, color: BLACK },
options: [channelOption(), { type: 'number', id: 'threshold', label: 'Threshold (dB)', default: -60, min: d.levelMin, max: d.levelMax, step: 0.5 }],
callback: ({ options }) => {
const v = inst.getSend(options.channel, inst.monSend()).level
return typeof v === 'number' && v > options.threshold
},
},
muted: {
type: 'boolean',
name: 'Channel is muted',
defaultStyle: { bgcolor: RED, color: WHITE },
options: [channelOption('channel', `Channel number (${CHANNEL_HELP})`)],
callback: ({ options }) => inst.state.mutes.get(Number(options.channel)) === true,
},
buss_muted: {
type: 'boolean',
name: 'Aux buss master is muted',
defaultStyle: { bgcolor: RED, color: WHITE },
options: [bussOption(inst, 'buss', 'Aux buss number (1-25)')],
callback: ({ options }) => inst.state.bussMutes.get(Number(options.buss)) === true,
},
connected: {
type: 'boolean',
name: 'Console link is alive',
defaultStyle: { bgcolor: GREEN, color: WHITE },
options: [],
callback: () => inst.isConnected(),
},
relay_clients: {
type: 'boolean',
name: 'Relay has at least one iPad connected',
defaultStyle: { bgcolor: GREEN, color: WHITE },
options: [],
callback: () => (inst.relay?.clientCount || 0) > 0,
},
}
}
export function getVariableDefinitions(inst) {
const mon = inst.monLabel()
const defs = {
connected: { name: 'Console link alive (Yes/No)' },
console_name: { name: 'Console name' },
console_host: { name: 'Console IP' },
relay_clients: { name: 'iPad apps connected through the relay' },
relay_client_ips: { name: 'iPad client IPs' },
last_rx: { name: 'Last message received from the console' },
last_send_touched: { name: 'Last send/assign that changed (ch/buss/field)' },
input_count: { name: 'Input channel count reported by the console' },
aux_count: { name: 'Buss count reported by the console' },
snapshot_current: { name: 'Current snapshot index' },
snapshot_name: { name: 'Current snapshot name' },
mon_send: { name: `${mon} buss number (from config)` },
mon_name: { name: `${mon} buss name (from the console)` },
mon_mode: { name: `${mon} buss mode (aux / group)` },
}
for (let n = 1; n <= MAX_INPUTS; n++) defs[`ch_${n}_name`] = { name: `Input ${n} name` }
for (let n = 1; n <= MAX_BUSSES; n++) {
defs[`aux_${n}_name`] = { name: `Buss ${n} name (aux mode)` }
defs[`group_${n}_name`] = { name: `Buss ${n} name (group mode)` }
defs[`buss_${n}_mode`] = { name: `Buss ${n} Stereo/Mono` }
}
for (let n = BUS_FIRST; n <= BUS_LAST; n++) defs[`bus_${n}_name`] = { name: `S-Series OSC channel ${n} name` }
for (let n = 1; n <= MAX_INPUTS; n++) {
defs[`mon_${n}_level`] = { name: `${mon} send level, input ${n} (dB)` }
defs[`mon_${n}_on`] = { name: `${mon} on/off (send on or assigned), input ${n}` }
}
return defs
}
export function getPresetDefinitions(inst) {
const label = inst.label
const mon = inst.monLabel()
const count = Math.min(MAX_INPUTS, Math.max(1, Number(inst.config.channels) || 48))
const groupMode = inst.monMode() === 'group'
const presets = {}
const onIds = []
const upIds = []
const downIds = []
for (let n = 1; n <= count; n++) {
presets[`mon_on_${n}`] = {
type: 'simple',
name: `${mon} ch ${n} on/off`,
style: {
text: groupMode ? `$(${label}:ch_${n}_name)\\n$(${label}:mon_${n}_on)` : `$(${label}:ch_${n}_name)\\n$(${label}:mon_${n}_level)dB`,
size: 'auto',
color: WHITE,
bgcolor: DARK,
},
steps: [{ down: [{ actionId: 'mon_on', options: { channel: n, state: 'toggle' } }], up: [] }],
feedbacks: [{ feedbackId: 'mon_on', options: { channel: n }, style: { bgcolor: GREEN, color: WHITE } }],
}
onIds.push(`mon_on_${n}`)
if (!groupMode) {
presets[`mon_up_${n}`] = {
type: 'simple',
name: `${mon} ch ${n} +1 dB`,
style: { text: `$(${label}:ch_${n}_name)\\n▲ +1`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [{ actionId: 'mon_nudge', options: { channel: n, delta: 1 } }], up: [] }],
feedbacks: [],
}
presets[`mon_down_${n}`] = {
type: 'simple',
name: `${mon} ch ${n} -1 dB`,
style: { text: `$(${label}:ch_${n}_name)\\n▼ -1`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [{ actionId: 'mon_nudge', options: { channel: n, delta: -1 } }], up: [] }],
feedbacks: [],
}
upIds.push(`mon_up_${n}`)
downIds.push(`mon_down_${n}`)
}
}
presets.resend = {
type: 'simple',
name: 'Reload from console',
style: { text: `$(${label}:console_name)\\nReload`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [{ actionId: 'resend', options: {} }], up: [] }],
feedbacks: [{ feedbackId: 'connected', options: {}, style: { bgcolor: GREEN, color: WHITE } }],
}
presets.snapshot_next = {
type: 'simple',
name: 'Snapshot next',
style: { text: `$(${label}:snapshot_name)\\nNEXT ▶`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [{ actionId: 'snapshot_next', options: {} }], up: [] }],
feedbacks: [],
}
presets.snapshot_prev = {
type: 'simple',
name: 'Snapshot previous',
style: { text: `◀ PREV\\n$(${label}:snapshot_name)`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [{ actionId: 'snapshot_prev', options: {} }], up: [] }],
feedbacks: [],
}
presets.relay_status = {
type: 'simple',
name: 'Relay status',
style: { text: `iPads\\n$(${label}:relay_clients)`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [], up: [] }],
feedbacks: [{ feedbackId: 'relay_clients', options: {}, style: { bgcolor: GREEN, color: WHITE } }],
}
const monDefs = [{ id: 'mon_on', type: 'simple', name: `${mon} on/off per input`, presets: onIds }]
if (!groupMode) {
monDefs.push({ id: 'mon_up', type: 'simple', name: `${mon} +1 dB per input`, presets: upIds })
monDefs.push({ id: 'mon_down', type: 'simple', name: `${mon} -1 dB per input`, presets: downIds })
}
const structure = [
{ id: 'mon', name: `${mon} (buss ${inst.monSend()}, ${groupMode ? 'group assign' : 'aux send'})`, definitions: monDefs },
{
id: 'console',
name: 'Console',
definitions: [{ id: 'console', type: 'simple', name: 'Console', presets: ['resend', 'snapshot_next', 'snapshot_prev', 'relay_status'] }],
},
]
return { structure, presets }
}
+200
View File
@@ -0,0 +1,200 @@
/**
* Address dialects the console link can speak.
*
* sd The "DiGiCo Pad" tree (/Input_Channels/N/Aux_Send/M/send_level …). This is
* what the S31's *iPad* extension speaks — verified live on 2026-10-15 against
* console "S31 AB" (10.3.4.221): queries are the address + "/?", replies come
* back on the same address without "/?", and every reply goes to the controller
* IP:port configured on the console. Levels are dB floats (−150 = off, +10 max),
* send_on / mute are reported as float 0/1 (DiGiCo's SD list says write ints),
* pan is 0..1 with 0.5 centre. The SD iPad app and the stock companion
* digico-osc "IPAD" command set use this same tree.
*
* sseries The S21/S31 *OSC Control* extension (Extensions > OSC Control): lower-case,
* channel-number based (/channel/N/send/M/level, /console/resend …). Inputs 1-60,
* aux busses 70-79, groups 80-93, matrix 100-107, CGs 110-119, master 120. The
* S31's iPad extension does NOT answer these.
*
* raw No parsing, no keepalive: the module is only a transparent relay.
*
* Builders return [address, args] or null when the dialect has no equivalent.
* parse() turns an inbound message into a normalised event or null.
*/
import { toBool, toNumber } from './osc.js'
export const BOOL_STYLES = [
{ id: 'int', label: 'Integer 0/1 (DiGiCo Pad default)' },
{ id: 'float', label: 'Float 0.0/1.0 (as the S31 reports them)' },
{ id: 'tf', label: 'OSC True/False tags (S-Series OSC Control default)' },
]
function boolArg(on, style) {
if (style === 'tf') return !!on
if (style === 'float') return on ? 1 : 0 // plain numbers encode as float32
return { i: on ? 1 : 0 }
}
const sd = {
id: 'sd',
label: 'DiGiCo Pad / iPad extension (/Input_Channels/N/…)',
pongAddress: '/Console/Name',
levelMin: -150,
levelMax: 10,
panMin: 0,
panMax: 1,
panCentre: 0.5,
ping: () => ['/Console/Name/?', []],
/** Everything the module wants after (re)connecting; sent paced by the queue. */
resendQueries({ inputs, auxes, monSend }) {
const q = [
['/Console/Name/?', []],
['/Console/Input_Channels/?', []],
['/Console/Aux_Outputs/?', []],
['/Console/Aux_Outputs/modes/?', []],
['/Snapshots/Current_Snapshot/?', []],
['/Snapshots/names/?', []],
]
for (let a = 1; a <= auxes; a++) {
q.push([`/Aux_Outputs/${a}/Buss_Trim/name/?`, []])
q.push([`/Group_Outputs/${a}/Buss_Trim/name/?`, []])
}
for (let n = 1; n <= inputs; n++) {
q.push([`/Input_Channels/${n}/Channel_Input/name/?`, []])
q.push([`/Input_Channels/${n}/mute/?`, []])
q.push([`/Input_Channels/${n}/Aux_Send/${monSend}/send_level/?`, []])
q.push([`/Input_Channels/${n}/Aux_Send/${monSend}/send_on/?`, []])
q.push([`/Input_Channels/${n}/Group_Send/${monSend}/group/?`, []])
}
// The console has been seen to drop queries sent in the first few ms after a
// connection opens, so ask the cheap global ones again at the end.
q.push(['/Console/Input_Channels/?', []], ['/Console/Aux_Outputs/?', []], ['/Snapshots/Current_Snapshot/?', []])
return q
},
querySend: (ch, send) => [
[`/Input_Channels/${ch}/Aux_Send/${send}/send_level/?`, []],
[`/Input_Channels/${ch}/Aux_Send/${send}/send_on/?`, []],
],
queryGroup: (ch, group) => [[`/Input_Channels/${ch}/Group_Send/${group}/group/?`, []]],
sendLevel: (ch, send, dB) => [`/Input_Channels/${ch}/Aux_Send/${send}/send_level`, [Number(dB)]],
sendEnabled: (ch, send, on, style) => [`/Input_Channels/${ch}/Aux_Send/${send}/send_on`, [boolArg(on, style)]],
sendPan: (ch, send, pan) => [`/Input_Channels/${ch}/Aux_Send/${send}/send_pan`, [Number(pan)]],
groupAssign: (ch, group, on, style) => [`/Input_Channels/${ch}/Group_Send/${group}/group`, [boolArg(on, style)]],
fader: (ch, dB) => [`/Input_Channels/${ch}/fader`, [Number(dB)]],
mute: (ch, on, style) => [`/Input_Channels/${ch}/mute`, [boolArg(on, style)]],
auxFader: (aux, dB) => [`/Aux_Outputs/${aux}/fader`, [Number(dB)]],
auxMute: (aux, on, style) => [`/Aux_Outputs/${aux}/mute`, [boolArg(on, style)]],
snapshotFire: (n) => ['/Snapshots/Fire_Snapshot_number', [{ i: n }]],
snapshotNext: () => ['/Snapshots/Fire_Next_Snapshot', [{ i: 0 }]],
snapshotPrev: () => ['/Snapshots/Fire_Prev_Snapshot', [{ i: 0 }]],
parse(address, args) {
let m
if (address === '/Console/Name') return { kind: 'pong', name: String(args[0] ?? '') }
if (address === '/Console/Input_Channels') return { kind: 'count', field: 'inputs', value: toNumber(args[0]) }
if (address === '/Console/Aux_Outputs') return { kind: 'count', field: 'auxes', value: toNumber(args[0]) }
if (address === '/Console/Group_Outputs') return { kind: 'count', field: 'groups', value: toNumber(args[0]) }
if (address === '/Console/Aux_Outputs/modes') return { kind: 'auxModes', values: args.map(toNumber) }
if ((m = /^\/Input_Channels\/(\d+)\/Aux_Send\/(\d+)\/send_(level|on|pan)$/.exec(address))) {
const field = m[3] === 'on' ? 'enabled' : m[3]
const value = field === 'enabled' ? toBool(args[0]) : toNumber(args[0])
return { kind: 'send', ch: +m[1], send: +m[2], field, value }
}
if ((m = /^\/Input_Channels\/(\d+)\/Group_Send\/(\d+)\/group$/.exec(address))) return { kind: 'group', ch: +m[1], group: +m[2], value: toBool(args[0]) }
if ((m = /^\/Input_Channels\/(\d+)\/Channel_Input\/name$/.exec(address))) return { kind: 'name', ch: +m[1], value: String(args[0] ?? '') }
if ((m = /^\/Input_Channels\/(\d+)\/fader$/.exec(address))) return { kind: 'fader', ch: +m[1], value: toNumber(args[0]) }
if ((m = /^\/Input_Channels\/(\d+)\/mute$/.exec(address))) return { kind: 'mute', ch: +m[1], value: toBool(args[0]) }
if ((m = /^\/Aux_Outputs\/(\d+)\/Buss_Trim\/name$/.exec(address))) return { kind: 'auxName', aux: +m[1], value: String(args[0] ?? '') }
if ((m = /^\/Group_Outputs\/(\d+)\/Buss_Trim\/name$/.exec(address))) return { kind: 'groupName', group: +m[1], value: String(args[0] ?? '') }
if ((m = /^\/Aux_Outputs\/(\d+)\/fader$/.exec(address))) return { kind: 'auxFader', aux: +m[1], value: toNumber(args[0]) }
if ((m = /^\/Aux_Outputs\/(\d+)\/mute$/.exec(address))) return { kind: 'auxMute', aux: +m[1], value: toBool(args[0]) }
if (address === '/Snapshots/Current_Snapshot') return { kind: 'snapshot', index: toNumber(args[0]) }
if ((m = /^\/Snapshots\/Recall_Snapshot\/(\d+)$/.exec(address))) return { kind: 'snapshot', index: +m[1] }
if (address === '/Snapshots/name') {
// [index, cueNumber*100, ?, name]
return { kind: 'snapshotName', index: toNumber(args[0]), cue: toNumber(args[1]) / 100, name: String(args[args.length - 1] ?? '') }
}
return null
},
}
const sseries = {
id: 'sseries',
label: 'S-Series OSC Control extension (/channel/N/…)',
pongAddress: '/console/pong',
levelMin: -90,
levelMax: 10,
panMin: -1,
panMax: 1,
panCentre: 0,
ping: () => ['/console/ping', []],
resendQueries: () => [['/console/resend', []]],
querySend: (ch, send) => [
[`/channel/${ch}/send/${send}/level`, []],
[`/channel/${ch}/send/${send}/enabled`, []],
],
sendLevel: (ch, send, dB) => [`/channel/${ch}/send/${send}/level`, [Number(dB)]],
sendEnabled: (ch, send, on, style) => [`/channel/${ch}/send/${send}/enabled`, [boolArg(on, style === 'int' ? 'tf' : style)]],
sendPan: (ch, send, pan) => [`/channel/${ch}/send/${send}/pan`, [Number(pan)]],
queryGroup: () => [],
groupAssign: (ch, group, on, style) => [`/channel/${ch}/send/${group}/enabled`, [boolArg(on, style === 'int' ? 'tf' : style)]],
fader: (ch, dB) => [`/channel/${ch}/fader`, [Number(dB)]],
mute: (ch, on, style) => [`/channel/${ch}/mute`, [boolArg(on, style === 'int' ? 'tf' : style)]],
auxFader: (aux, dB) => [`/channel/${69 + aux}/fader`, [Number(dB)]],
auxMute: (aux, on, style) => [`/channel/${69 + aux}/mute`, [boolArg(on, style === 'int' ? 'tf' : style)]],
snapshotFire: (n) => ['/digico/snapshots/fire', [{ i: n }]],
snapshotNext: () => ['/digico/snapshots/fire/next', []],
snapshotPrev: () => ['/digico/snapshots/fire/previous', []],
parse(address, args) {
let m
if (address === '/console/pong') return { kind: 'pong' }
if (address === '/console/channel/counts') return { kind: 'count', field: 'inputs', value: toNumber(args[0]), extra: args.map(toNumber) }
if ((m = /^\/channel\/(\d+)\/send\/(\d+)\/(level|enabled|pan)$/.exec(address))) {
const field = m[3]
const value = field === 'enabled' ? toBool(args[0]) : toNumber(args[0])
return { kind: 'send', ch: +m[1], send: +m[2], field, value }
}
if ((m = /^\/channel\/(\d+)\/name$/.exec(address))) {
const ch = +m[1]
if (ch >= 70 && ch <= 79) return { kind: 'auxName', aux: ch - 69, value: String(args[0] ?? '') }
return { kind: 'name', ch, value: String(args[0] ?? '') }
}
if ((m = /^\/channel\/(\d+)\/fader$/.exec(address))) return { kind: 'fader', ch: +m[1], value: toNumber(args[0]) }
if ((m = /^\/channel\/(\d+)\/mute$/.exec(address))) return { kind: 'mute', ch: +m[1], value: toBool(args[0]) }
return null
},
}
const raw = {
id: 'raw',
label: 'Relay only (no parsing, no keepalive)',
pongAddress: null,
levelMin: -150,
levelMax: 10,
panMin: 0,
panMax: 1,
panCentre: 0.5,
ping: () => null,
resendQueries: () => [],
querySend: () => [],
queryGroup: () => [],
sendLevel: () => null,
sendEnabled: () => null,
sendPan: () => null,
groupAssign: () => null,
fader: () => null,
mute: () => null,
auxFader: () => null,
auxMute: () => null,
snapshotFire: () => null,
snapshotNext: () => null,
snapshotPrev: () => null,
parse: () => null,
}
export const DIALECTS = { sd, sseries, raw }
export function getDialect(id) {
return DIALECTS[id] || sd
}
export const DIALECT_CHOICES = Object.values(DIALECTS).map((d) => ({ id: d.id, label: d.label }))
+605
View File
@@ -0,0 +1,605 @@
import { InstanceBase, InstanceStatus, Regex } from '@companion-module/base'
import { ConsoleLink } from './console-link.js'
import { Relay } from './relay.js'
import { BOOL_STYLES, DIALECT_CHOICES, getDialect } from './dialects.js'
import { decode, describe, foldPattern } from './osc.js'
import {
getActionDefinitions,
getFeedbackDefinitions,
getPresetDefinitions,
getVariableDefinitions,
MAX_INPUTS,
MAX_BUSSES,
BUS_FIRST,
BUS_LAST,
} from './definitions.js'
const KEEPALIVE_MS = 5000
const SILENCE_MS = 20000
const TX_PACE_MS = 20
const LEARN_CAP = 400
function emptyState() {
return {
consoleName: '',
names: new Map(), // input ch -> name
auxNames: new Map(), // buss -> name when in aux mode
groupNames: new Map(), // buss -> name when in group mode
busNames: new Map(), // S-Series OSC channel 70-93 -> name
bussModes: [], // 1 mono / 2 stereo per buss
sends: new Map(), // "ch:buss" -> { level, enabled, pan }
groups: new Map(), // "ch:buss" -> assigned
mutes: new Map(),
faders: new Map(),
bussMutes: new Map(),
bussFaders: new Map(),
snapshot: { index: null, names: new Map() },
counts: {},
}
}
export default class DigicoS31Instance extends InstanceBase {
constructor(internal) {
super(internal)
this.link = null
this.relay = null
this.timer = null
this.txQueue = []
this.txTimer = null
this.seen = new Set()
this.state = emptyState()
this.connectedFlag = false
this.lastMonMode = null
}
async init(config) {
this.config = config
this.dialect = getDialect(config.dialect)
this.initDefinitions()
await this.connect()
}
async destroy() {
await this.disconnect()
}
async configUpdated(config) {
this.config = config
this.dialect = getDialect(config.dialect)
await this.disconnect()
this.initDefinitions()
await this.connect()
}
getConfigFields() {
return [
{
type: 'static-text',
id: 'info',
width: 12,
label: 'How this works',
value:
'Companion becomes the console\'s single remote (iPad) controller: on the console, Extensions → iPad, set the iPad IP to this computer, ' +
'Send Port = the local receive port below, Receive Port = the console receive port below. DiGiCo iPad apps then point at this computer ' +
'instead of the console and are relayed through, so several can be used at once.',
},
{ type: 'textinput', id: 'host', label: 'Console IP', width: 6, default: '10.3.4.221', regex: Regex.IP },
{ type: 'number', id: 'consolePort', label: 'Console receive port (we send here)', width: 3, default: 8000, min: 1, max: 65535 },
{ type: 'number', id: 'localPort', label: 'Local receive port (console sends here)', width: 3, default: 9000, min: 1, max: 65535 },
{ type: 'dropdown', id: 'dialect', label: 'Command set', width: 6, default: 'sd', choices: DIALECT_CHOICES },
{ type: 'dropdown', id: 'boolStyle', label: 'How to write on/off values', width: 6, default: 'int', choices: BOOL_STYLES },
{ type: 'checkbox', id: 'keepalive', label: 'Keepalive ping + reload on reconnect', width: 4, default: true },
{ type: 'number', id: 'channels', label: 'Input channels to load and build presets for', width: 4, default: 60, min: 1, max: MAX_INPUTS },
{ type: 'number', id: 'busses', label: 'Busses to load names for', width: 4, default: MAX_BUSSES, min: 1, max: MAX_BUSSES },
{
type: 'static-text',
id: 'monInfo',
width: 12,
label: 'Studio Mon buss',
value: 'Buss numbers follow the console\'s buss list (1-25). On this console buss 24 is the stereo group "Studio" and buss 17 is the aux "Studio TB". A group buss gives per-channel assign on/off; an aux buss gives send level + on/off.',
},
{ type: 'number', id: 'monSend', label: 'Studio Mon buss number', width: 4, default: 24, min: 1, max: MAX_BUSSES },
{
type: 'dropdown',
id: 'monMode',
label: 'Studio Mon buss type',
width: 4,
default: 'auto',
choices: [
{ id: 'auto', label: 'Auto (from the names the console reports)' },
{ id: 'aux', label: 'Aux (send level + on/off)' },
{ id: 'group', label: 'Group (assign on/off)' },
],
},
{ type: 'textinput', id: 'monLabel', label: 'Name for it in button labels', width: 4, default: 'Studio Mon' },
{
type: 'static-text',
id: 'relayInfo',
width: 12,
label: 'iPad relay',
value: 'In the DiGiCo S app enter this computer\'s IP as the console, Send Port = relay port, Receive Port = iPad receive port.',
},
{ type: 'checkbox', id: 'relayEnable', label: 'Enable iPad relay', width: 3, default: true },
{ type: 'number', id: 'relayPort', label: 'Relay port (iPad apps send here)', width: 3, default: 8000, min: 1, max: 65535 },
{ type: 'number', id: 'relayClientPort', label: 'iPad receive port (0 = reply to source port)', width: 3, default: 9000, min: 0, max: 65535 },
{ type: 'number', id: 'relayTimeout', label: 'Forget an iPad after (seconds idle)', width: 3, default: 180, min: 10, max: 3600 },
{ type: 'checkbox', id: 'learnLog', label: 'Learn mode: log each new address pattern once', width: 4, default: true },
{ type: 'checkbox', id: 'verboseLog', label: 'Log every message (debug level)', width: 4, default: false },
]
}
// ---------- helpers used by definitions ----------
monSend() {
return Math.min(MAX_BUSSES, Math.max(1, Math.trunc(Number(this.config?.monSend) || 1)))
}
monLabel() {
return String(this.config?.monLabel || 'Studio Mon').trim() || 'Studio Mon'
}
monMode() {
const cfg = this.config?.monMode
if (cfg === 'aux' || cfg === 'group') return cfg
const n = this.monSend()
const g = (this.state.groupNames.get(n) || '').trim()
const a = (this.state.auxNames.get(n) || '').trim()
if (g && !a) return 'group'
return 'aux'
}
getSend(ch, send) {
return this.state.sends.get(`${Number(ch)}:${Number(send)}`) || {}
}
getGroup(ch, group) {
return this.state.groups.get(`${Number(ch)}:${Number(group)}`)
}
getMonOn(ch) {
return this.monMode() === 'group' ? this.getGroup(ch, this.monSend()) : this.getSend(ch, this.monSend()).enabled
}
isConnected() {
return this.connectedFlag
}
initDefinitions() {
this.setActionDefinitions(getActionDefinitions(this))
this.setFeedbackDefinitions(getFeedbackDefinitions(this))
this.setVariableDefinitions(getVariableDefinitions(this))
this.refreshPresets(true)
this.setVariableValues({
connected: this.connectedFlag ? 'Yes' : 'No',
console_host: this.config.host || '',
relay_clients: 0,
relay_client_ips: '',
mon_send: this.monSend(),
mon_mode: this.monMode(),
last_rx: '',
last_send_touched: '',
})
this.publishState()
}
refreshPresets(force = false) {
const mode = this.monMode()
if (!force && mode === this.lastMonMode) return
this.lastMonMode = mode
const { structure, presets } = getPresetDefinitions(this)
this.setPresetDefinitions(structure, presets)
this.setVariableValues({ mon_mode: mode })
}
// ---------- lifecycle ----------
async connect() {
const cfg = this.config
if (!cfg.host) {
this.updateStatus(InstanceStatus.BadConfig, 'Console IP missing')
return
}
this.updateStatus(InstanceStatus.Connecting)
this.seen.clear()
this.state = emptyState()
this.link = new ConsoleLink({ host: cfg.host, port: cfg.consolePort, localPort: cfg.localPort })
this.link.on('error', (err) => this.log('error', `Console socket: ${err.message}`))
this.link.on('stray', (buf, rinfo) => this.learn('stray', decode(buf), rinfo))
this.link.on('raw', (buf) => {
if (this.relay) this.relay.fanout(buf)
})
this.link.on('message', (msg, _buf, rinfo) => this.onConsoleMessage(msg, rinfo))
try {
const addr = await this.link.open()
this.log('info', `Console link: listening on udp ${addr.port}, sending to ${cfg.host}:${cfg.consolePort} (${this.dialect.label})`)
} catch (err) {
this.updateStatus(InstanceStatus.ConnectionFailure, `Cannot bind local port ${cfg.localPort}: ${err.code || err.message}`)
this.log('error', `Cannot bind local port ${cfg.localPort}: ${err.message}`)
this.link = null
return
}
if (cfg.relayEnable) {
this.relay = new Relay({ port: cfg.relayPort, clientPort: cfg.relayClientPort, timeoutMs: (Number(cfg.relayTimeout) || 180) * 1000 })
this.relay.on('error', (err) => this.log('error', `Relay socket: ${err.message}`))
this.relay.on('client', (rinfo) => {
this.log('info', `Relay: new iPad client ${rinfo.address}:${rinfo.port}`)
this.publishRelay()
})
this.relay.on('clientGone', (ip) => {
this.log('info', `Relay: iPad client ${ip} idle, forgotten`)
this.publishRelay()
})
this.relay.on('fromClient', (buf, rinfo) => {
if (this.link) this.link.sendRaw(buf)
if (cfg.learnLog || cfg.verboseLog) {
const msg = decode(buf)
this.learn('ipad', msg, rinfo)
if (cfg.verboseLog) this.log('debug', `ipad ${rinfo.address} -> console: ${describe(msg)}`)
}
})
try {
const addr = await this.relay.open()
this.log('info', `Relay: iPad apps may send to udp ${addr.port}; console traffic fans out to port ${cfg.relayClientPort || '(source)'}`)
} catch (err) {
this.log('error', `Relay: cannot bind port ${cfg.relayPort}: ${err.message}`)
this.relay = null
}
}
if (this.dialect.id === 'raw') {
this.setConnected(true, 'relay only')
} else {
this.sendPing()
this.requestResend()
}
this.timer = setInterval(() => this.tick(), KEEPALIVE_MS)
this.publishRelay()
}
async disconnect() {
if (this.timer) clearInterval(this.timer)
this.timer = null
if (this.txTimer) clearInterval(this.txTimer)
this.txTimer = null
this.txQueue = []
if (this.link) this.link.close()
if (this.relay) this.relay.close()
this.link = null
this.relay = null
this.connectedFlag = false
}
tick() {
if (!this.link) return
if (this.dialect.id === 'raw') {
this.publishRelay()
return
}
if (this.config.keepalive) this.sendPing()
const silent = Date.now() - this.link.lastRx
if (this.connectedFlag && silent > SILENCE_MS) this.setConnected(false)
this.publishRelay()
}
setConnected(on, note) {
const was = this.connectedFlag
this.connectedFlag = !!on
if (on) this.updateStatus(InstanceStatus.Ok, note)
else this.updateStatus(InstanceStatus.Disconnected, 'No reply from console (check the iPad/controller IP and ports on the console)')
if (was !== this.connectedFlag) {
this.setVariableValues({ connected: on ? 'Yes' : 'No' })
this.checkFeedbacks('connected')
this.log('info', on ? 'Console link alive' : 'Console link silent')
if (on && was === false && this.config.keepalive && this.link && this.link.rxCount > 1) this.requestResend()
}
}
// ---------- outbound ----------
/** Immediate send of a dialect-built message (user actions). */
sendDialect(fn, ...args) {
if (!this.link) return false
const built = this.dialect[fn]?.(...args)
if (!built) {
this.log('warn', `${fn}: not available in the "${this.dialect.label}" command set`)
return false
}
if (this.config.verboseLog) this.log('debug', `-> ${built[0]} ${JSON.stringify(built[1])}`)
return this.link.send(built[0], built[1])
}
/** Paced send for bulk queries so the console is not flooded. */
enqueue(list) {
for (const item of list) if (item) this.txQueue.push(item)
if (this.txTimer || !this.txQueue.length) return
this.txTimer = setInterval(() => {
const next = this.txQueue.shift()
if (!next || !this.link) {
clearInterval(this.txTimer)
this.txTimer = null
this.txQueue = []
return
}
this.link.send(next[0], next[1])
}, TX_PACE_MS)
}
sendPing() {
const p = this.dialect.ping()
if (p && this.link) this.link.send(p[0], p[1])
}
requestResend() {
if (!this.link) return false
const inputs = Math.min(MAX_INPUTS, Math.max(1, Number(this.config.channels) || MAX_INPUTS))
const auxes = Math.min(MAX_BUSSES, Math.max(1, Number(this.config.busses) || MAX_BUSSES))
const list = this.dialect.resendQueries({ inputs, auxes, monSend: this.monSend() })
if (!list.length) return false
this.log('info', `Loading ${list.length} values from the console`)
this.enqueue(list)
return true
}
resolveState(current, state) {
if (state === 'toggle') return !(current === true)
return state === 'on'
}
setMonOn(ch, state) {
return this.monMode() === 'group' ? this.setGroupAssign(ch, this.monSend(), state) : this.setSendEnabled(ch, this.monSend(), state)
}
setSendEnabled(ch, send, state) {
const cur = this.getSend(ch, send).enabled
const on = this.resolveState(cur, state)
const ok = this.sendDialect('sendEnabled', Number(ch), Number(send), on, this.config.boolStyle)
if (ok) this.enqueue(this.dialect.querySend(Number(ch), Number(send)))
return ok
}
setGroupAssign(ch, group, state) {
const cur = this.getGroup(ch, group)
const on = this.resolveState(cur, state)
const ok = this.sendDialect('groupAssign', Number(ch), Number(group), on, this.config.boolStyle)
if (ok) this.enqueue(this.dialect.queryGroup(Number(ch), Number(group)))
return ok
}
setSendLevel(ch, send, dB) {
const d = this.dialect
const v = Math.min(d.levelMax, Math.max(d.levelMin, Number(dB)))
const ok = this.sendDialect('sendLevel', Number(ch), Number(send), v)
if (ok) this.enqueue(this.dialect.querySend(Number(ch), Number(send)))
return ok
}
nudgeSendLevel(ch, send, delta) {
const cur = this.getSend(ch, send).level
if (typeof cur !== 'number') {
this.log('warn', `Send level for ch ${ch} buss ${send} unknown yet; asking the console`)
this.enqueue(this.dialect.querySend(Number(ch), Number(send)))
return false
}
const floor = this.dialect.levelMin
const base = cur <= floor ? floor : cur
return this.setSendLevel(ch, send, base + Number(delta))
}
setMute(ch, state) {
const cur = this.state.mutes.get(Number(ch))
const on = this.resolveState(cur, state)
return this.sendDialect('mute', Number(ch), on, this.config.boolStyle)
}
setBussMute(buss, state) {
const cur = this.state.bussMutes.get(Number(buss))
const on = this.resolveState(cur, state)
return this.sendDialect('auxMute', Number(buss), on, this.config.boolStyle)
}
sendRawAction(options) {
if (!this.link) return false
const address = String(options.address || '').trim()
if (!address.startsWith('/')) {
this.log('warn', `raw OSC: address must start with "/": ${address}`)
return false
}
const v = String(options.value ?? '')
let args = []
switch (options.argType) {
case 'int':
args = [{ i: Math.trunc(Number(v)) || 0 }]
break
case 'float':
args = [Number(v) || 0]
break
case 'string':
args = [v]
break
case 'bool':
args = [/^(1|true|on|yes)$/i.test(v.trim())]
break
default:
args = []
}
this.log('info', `raw OSC -> ${address} ${JSON.stringify(args)}`)
return this.link.send(address, args)
}
// ---------- inbound ----------
onConsoleMessage(msg, rinfo) {
if (!this.connectedFlag) this.setConnected(true)
if (msg.bundle) {
for (const el of msg.elements) this.onConsoleMessage(el, rinfo)
return
}
this.learn('console', msg, rinfo)
if (this.config.verboseLog) this.log('debug', `console -> ${describe(msg)}`)
if (!msg.address) return
this.setVariableValues({ last_rx: describe(msg) })
const ev = this.dialect.parse(msg.address, msg.args)
if (ev) this.applyEvent(ev)
}
applyEvent(ev) {
const s = this.state
const values = {}
const mon = this.monSend()
switch (ev.kind) {
case 'pong':
if (ev.name && ev.name !== s.consoleName) {
s.consoleName = ev.name
values.console_name = ev.name
}
break
case 'count':
s.counts[ev.field] = ev.value
if (ev.field === 'inputs') values.input_count = ev.value
if (ev.field === 'auxes') values.aux_count = ev.value
break
case 'auxModes':
s.bussModes = ev.values
ev.values.forEach((m, i) => {
if (i < MAX_BUSSES) values[`buss_${i + 1}_mode`] = m === 2 ? 'Stereo' : 'Mono'
})
break
case 'name':
s.names.set(ev.ch, ev.value)
if (ev.ch >= 1 && ev.ch <= MAX_INPUTS) values[`ch_${ev.ch}_name`] = ev.value
else if (ev.ch >= BUS_FIRST && ev.ch <= BUS_LAST) {
s.busNames.set(ev.ch, ev.value)
values[`bus_${ev.ch}_name`] = ev.value
}
break
case 'auxName':
s.auxNames.set(ev.aux, ev.value)
if (ev.aux >= 1 && ev.aux <= MAX_BUSSES) values[`aux_${ev.aux}_name`] = ev.value
if (ev.aux === mon) this.afterMonNames(values)
break
case 'groupName':
s.groupNames.set(ev.group, ev.value)
if (ev.group >= 1 && ev.group <= MAX_BUSSES) values[`group_${ev.group}_name`] = ev.value
if (ev.group === mon) this.afterMonNames(values)
break
case 'send': {
const key = `${ev.ch}:${ev.send}`
const cur = s.sends.get(key) || {}
cur[ev.field] = ev.value
s.sends.set(key, cur)
values.last_send_touched = `ch ${ev.ch} buss ${ev.send} ${ev.field}`
if (ev.send === mon && ev.ch >= 1 && ev.ch <= MAX_INPUTS) {
if (ev.field === 'level') values[`mon_${ev.ch}_level`] = this.formatLevel(ev.value)
if (ev.field === 'enabled' && this.monMode() === 'aux') values[`mon_${ev.ch}_on`] = ev.value ? 'On' : 'Off'
}
this.checkFeedbacks('mon_on', 'send_on', 'mon_level_above')
break
}
case 'group': {
s.groups.set(`${ev.ch}:${ev.group}`, ev.value)
values.last_send_touched = `ch ${ev.ch} group ${ev.group} assign`
if (ev.group === mon && this.monMode() === 'group' && ev.ch >= 1 && ev.ch <= MAX_INPUTS) values[`mon_${ev.ch}_on`] = ev.value ? 'On' : 'Off'
this.checkFeedbacks('mon_on', 'group_assigned')
break
}
case 'fader':
s.faders.set(ev.ch, ev.value)
break
case 'mute':
s.mutes.set(ev.ch, ev.value)
this.checkFeedbacks('muted')
break
case 'auxFader':
s.bussFaders.set(ev.aux, ev.value)
break
case 'auxMute':
s.bussMutes.set(ev.aux, ev.value)
this.checkFeedbacks('buss_muted')
break
case 'snapshot':
s.snapshot.index = ev.index
values.snapshot_current = ev.index
values.snapshot_name = s.snapshot.names.get(ev.index) ?? ''
if (!s.snapshot.names.has(ev.index) && this.dialect.id === 'sd') this.enqueue([['/Snapshots/names/?', []]])
break
case 'snapshotName':
s.snapshot.names.set(ev.index, ev.name)
if (ev.index === s.snapshot.index) values.snapshot_name = ev.name
break
default:
break
}
if (Object.keys(values).length) this.setVariableValues(values)
}
/** Called when the Studio Mon buss name arrives (aux or group form). */
afterMonNames(values) {
const n = this.monSend()
const mode = this.monMode()
values.mon_name = mode === 'group' ? this.state.groupNames.get(n) || '' : this.state.auxNames.get(n) || ''
values.mon_mode = mode
if (mode !== this.lastMonMode) {
this.log('info', `Studio Mon buss ${n} is "${values.mon_name}" (${mode === 'group' ? 'group: assign on/off' : 'aux: send level + on/off'})`)
this.refreshPresets()
this.checkFeedbacks('mon_on')
}
}
formatLevel(v) {
if (!Number.isFinite(v)) return ''
if (v <= this.dialect.levelMin) return '-inf'
return Number(v.toFixed(1))
}
learn(source, msg, rinfo) {
if (!this.config.learnLog) return
if (!msg || !msg.address) {
const key = `${source}:${msg?.bundle ? '#bundle' : 'raw'}`
if (this.seen.has(key) || this.seen.size > LEARN_CAP) return
this.seen.add(key)
this.log('info', `learn ${source}: ${describe(msg)} from ${rinfo?.address}:${rinfo?.port}`)
return
}
const key = `${source}:${foldPattern(msg.address)} ${msg.tags || ''}${msg.unpadded ? ' (unpadded)' : ''}`
if (this.seen.has(key) || this.seen.size > LEARN_CAP) return
this.seen.add(key)
this.log('info', `learn ${source}: ${key} e.g. ${describe(msg)}`)
}
publishRelay() {
const list = this.relay ? this.relay.clientList() : []
const now = Date.now()
const live = list.filter((c) => now - c.lastSeen <= (this.relay?.timeoutMs || 0))
this.setVariableValues({ relay_clients: live.length, relay_client_ips: live.map((c) => c.ip).join(', ') })
this.checkFeedbacks('relay_clients')
}
/** Re-publish everything known (after definitions were rebuilt). */
publishState() {
const s = this.state
const values = {}
for (const [ch, name] of s.names) if (ch >= 1 && ch <= MAX_INPUTS) values[`ch_${ch}_name`] = name
for (const [ch, name] of s.busNames) values[`bus_${ch}_name`] = name
for (const [b, name] of s.auxNames) if (b >= 1 && b <= MAX_BUSSES) values[`aux_${b}_name`] = name
for (const [b, name] of s.groupNames) if (b >= 1 && b <= MAX_BUSSES) values[`group_${b}_name`] = name
s.bussModes.forEach((m, i) => {
if (i < MAX_BUSSES) values[`buss_${i + 1}_mode`] = m === 2 ? 'Stereo' : 'Mono'
})
const mon = this.monSend()
const mode = this.monMode()
for (const [key, v] of s.sends) {
const [ch, send] = key.split(':').map(Number)
if (send !== mon || ch < 1 || ch > MAX_INPUTS) continue
if (typeof v.level === 'number') values[`mon_${ch}_level`] = this.formatLevel(v.level)
if (typeof v.enabled === 'boolean' && mode === 'aux') values[`mon_${ch}_on`] = v.enabled ? 'On' : 'Off'
}
if (mode === 'group') {
for (const [key, on] of s.groups) {
const [ch, g] = key.split(':').map(Number)
if (g === mon && ch >= 1 && ch <= MAX_INPUTS) values[`mon_${ch}_on`] = on ? 'On' : 'Off'
}
}
values.console_name = s.consoleName
values.mon_name = mode === 'group' ? s.groupNames.get(mon) || '' : s.auxNames.get(mon) || ''
values.mon_mode = mode
if (s.snapshot.index !== null) {
values.snapshot_current = s.snapshot.index
values.snapshot_name = s.snapshot.names.get(s.snapshot.index) ?? ''
}
this.setVariableValues(values)
}
}
export const UpgradeScripts = []
+182
View File
@@ -0,0 +1,182 @@
/**
* Minimal, tolerant OSC 1.0 codec.
*
* The DiGiCo iPad app is known to emit datagrams that are not padded to a
* 4-byte multiple, so the decoder never throws: anything it cannot read comes
* back as `{ raw }` and the relay still forwards the original bytes verbatim.
*
* Argument encoding rules (encode):
* boolean -> T / F
* null -> N
* number -> f (float32) use { i: n } for an int32
* string -> s
* { i: n } -> i { f: n } -> f { s: x } -> s { b: Buffer } -> blob
*/
function pad4(len) {
return (4 - (len % 4)) % 4
}
function encodeString(value) {
const bytes = Buffer.from(String(value), 'utf8')
const out = Buffer.alloc(bytes.length + 1 + pad4(bytes.length + 1))
bytes.copy(out)
return out
}
function encodeBlob(buf) {
const out = Buffer.alloc(4 + buf.length + pad4(buf.length))
out.writeInt32BE(buf.length, 0)
buf.copy(out, 4)
return out
}
function int32(n) {
const b = Buffer.alloc(4)
b.writeInt32BE(Math.trunc(n), 0)
return b
}
function float32(n) {
const b = Buffer.alloc(4)
b.writeFloatBE(Number(n), 0)
return b
}
export function encode(address, args = []) {
let tags = ','
const parts = []
for (const arg of args) {
if (arg === true) tags += 'T'
else if (arg === false) tags += 'F'
else if (arg === null || arg === undefined) tags += 'N'
else if (typeof arg === 'number') {
tags += 'f'
parts.push(float32(arg))
} else if (typeof arg === 'string') {
tags += 's'
parts.push(encodeString(arg))
} else if (Buffer.isBuffer(arg)) {
tags += 'b'
parts.push(encodeBlob(arg))
} else if (typeof arg === 'object') {
if ('i' in arg) {
tags += 'i'
parts.push(int32(arg.i))
} else if ('f' in arg) {
tags += 'f'
parts.push(float32(arg.f))
} else if ('s' in arg) {
tags += 's'
parts.push(encodeString(arg.s))
} else if ('b' in arg) {
tags += 'b'
parts.push(encodeBlob(arg.b))
} else throw new Error('Unsupported OSC argument object')
} else throw new Error(`Unsupported OSC argument type ${typeof arg}`)
}
return Buffer.concat([encodeString(address), encodeString(tags), ...parts])
}
function readString(buf, offset) {
let end = buf.indexOf(0, offset)
if (end < 0) end = buf.length
const value = buf.toString('utf8', offset, end)
let next = end + 1
next += pad4(next)
return [value, next]
}
/**
* Decode one datagram. Returns
* { address, args, tags, unpadded } for a message
* { bundle: true, elements: [...] } for a bundle
* { raw: '<hex>' } for anything unreadable
*/
export function decode(buf) {
try {
if (!Buffer.isBuffer(buf) || buf.length === 0) return { raw: '' }
if (buf.length >= 8 && buf.toString('ascii', 0, 7) === '#bundle') {
const elements = []
let off = 16
while (off + 4 <= buf.length) {
const size = buf.readInt32BE(off)
off += 4
if (size <= 0 || off + size > buf.length) break
elements.push(decode(buf.subarray(off, off + size)))
off += size
}
return { bundle: true, elements }
}
if (buf[0] !== 0x2f) return { raw: buf.toString('hex') }
let [address, off] = readString(buf, 0)
let tags = ''
if (off < buf.length && buf[off] === 0x2c) [tags, off] = readString(buf, off)
const args = []
for (const t of tags.slice(1)) {
if (t === 'i') {
if (off + 4 > buf.length) break
args.push(buf.readInt32BE(off))
off += 4
} else if (t === 'f') {
if (off + 4 > buf.length) break
args.push(buf.readFloatBE(off))
off += 4
} else if (t === 'd') {
if (off + 8 > buf.length) break
args.push(buf.readDoubleBE(off))
off += 8
} else if (t === 'h') {
if (off + 8 > buf.length) break
args.push(Number(buf.readBigInt64BE(off)))
off += 8
} else if (t === 's' || t === 'S') {
let s
;[s, off] = readString(buf, off)
args.push(s)
} else if (t === 'b') {
if (off + 4 > buf.length) break
const size = buf.readInt32BE(off)
off += 4
args.push(buf.subarray(off, Math.min(buf.length, off + size)))
off += size + pad4(size)
} else if (t === 'T') args.push(true)
else if (t === 'F') args.push(false)
else if (t === 'N') args.push(null)
else if (t === 'I') args.push(Infinity)
else break
}
return { address, args, tags, unpadded: buf.length % 4 !== 0 }
} catch (e) {
return { raw: buf.toString('hex'), error: e.message }
}
}
/** "/channel/12/send/3/level" -> "/channel/N/send/N/level" */
export function foldPattern(address) {
return String(address).replace(/\/-?\d+(?:\.\d+)?(?=\/|$)/g, '/N')
}
/** Coerce an OSC argument to boolean (T/F, 0/1, 0.0/1.0, "true"). */
export function toBool(value) {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value > 0.5
if (typeof value === 'string') return /^(1|true|on|yes)$/i.test(value.trim())
return false
}
export function toNumber(value) {
if (typeof value === 'number') return value
if (typeof value === 'boolean') return value ? 1 : 0
const n = Number.parseFloat(value)
return Number.isFinite(n) ? n : NaN
}
/** Short human form of a decoded message for logs. */
export function describe(msg) {
if (!msg) return '(empty)'
if (msg.raw !== undefined) return `raw ${msg.raw.slice(0, 48)}${msg.raw.length > 48 ? '…' : ''}`
if (msg.bundle) return `#bundle(${msg.elements.length})`
const args = msg.args.map((a) => (typeof a === 'number' ? Number(a.toFixed(3)) : Buffer.isBuffer(a) ? `<blob ${a.length}>` : a))
return `${msg.address} ${JSON.stringify(args)}`
}
+110
View File
@@ -0,0 +1,110 @@
import dgram from 'node:dgram'
import { EventEmitter } from 'node:events'
/**
* iPad-app relay.
*
* The console accepts exactly one iPad / OSC controller. This module occupies
* that slot, and any number of DiGiCo iPad apps point at this machine instead
* of the console. Everything an app sends is forwarded to the console byte for
* byte (via ConsoleLink.sendRaw), and everything the console sends back is
* fanned out to every app seen recently. Nothing is rewritten, so the relay
* does not care which dialect the iPad extension speaks.
*
* Events: 'fromClient' (buf, rinfo), 'client' (rinfo) on first sight,
* 'clientGone' (ip), 'error' (err), 'listening' (addressInfo).
*/
export class Relay extends EventEmitter {
constructor({ port, clientPort = 0, timeoutMs = 180000, bindAddress = '0.0.0.0' }) {
super()
this.port = Number(port)
this.clientPort = Number(clientPort) || 0
this.timeoutMs = Number(timeoutMs) || 180000
this.bindAddress = bindAddress
this.socket = null
this.clients = new Map() // ip -> { port, lastSeen, rx, tx }
this.rxCount = 0
this.txCount = 0
}
open() {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: false })
let settled = false
socket.on('error', (err) => {
if (!settled) {
settled = true
reject(err)
} else this.emit('error', err)
})
socket.on('message', (buf, rinfo) => {
this.rxCount++
let client = this.clients.get(rinfo.address)
if (!client) {
client = { port: rinfo.port, lastSeen: 0, rx: 0, tx: 0 }
this.clients.set(rinfo.address, client)
this.emit('client', rinfo)
}
client.port = rinfo.port
client.lastSeen = Date.now()
client.rx++
this.emit('fromClient', buf, rinfo)
})
socket.bind(this.port, this.bindAddress, () => {
settled = true
this.socket = socket
this.emit('listening', socket.address())
resolve(socket.address())
})
})
}
/** Send one datagram from the console to every live client. */
fanout(buf) {
if (!this.socket) return 0
const now = Date.now()
let n = 0
for (const [ip, client] of this.clients) {
if (now - client.lastSeen > this.timeoutMs) {
this.clients.delete(ip)
this.emit('clientGone', ip)
continue
}
const port = this.clientPort || client.port
this.socket.send(buf, port, ip, (err) => {
if (err) this.emit('error', err)
})
client.tx++
this.txCount++
n++
}
return n
}
get clientCount() {
const now = Date.now()
let n = 0
for (const c of this.clients.values()) if (now - c.lastSeen <= this.timeoutMs) n++
return n
}
clientList() {
return [...this.clients].map(([ip, c]) => ({ ip, ...c }))
}
forgetClients() {
this.clients.clear()
}
close() {
const s = this.socket
this.socket = null
if (s) {
try {
s.close()
} catch (_e) {
/* already closed */
}
}
}
}
+146
View File
@@ -0,0 +1,146 @@
/**
* End-to-end self test without Companion: mock console <-> module link/relay <-> fake iPad.
*
* node test/selftest.js
*
* Ports (all on 127.0.0.1, chosen to avoid anything real):
* mock console listens 18000, replies to 127.0.0.1:19000 (console "receive" / "send")
* module link binds 19000, sends to 18000
* module relay binds 18001, fans out to iPad port 19001
* fake iPad binds 19001, sends to 18001
*/
import { spawn } from 'node:child_process'
import dgram from 'node:dgram'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { ConsoleLink } from '../src/console-link.js'
import { Relay } from '../src/relay.js'
import { getDialect } from '../src/dialects.js'
import { encode, decode, describe } from '../src/osc.js'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
let failures = 0
function check(ok, what) {
console.log(`${ok ? 'PASS' : 'FAIL'} ${what}`)
if (!ok) failures++
}
// 0. The instance class must at least import cleanly with the real base library.
const mainMod = await import('../src/main.js')
check(typeof mainMod.default === 'function' && Array.isArray(mainMod.UpgradeScripts), 'src/main.js imports and exports default class + UpgradeScripts')
const inst = Object.create(mainMod.default.prototype)
inst.config = { monSend: 24, monLabel: 'Studio Mon', channels: 4, monMode: 'auto' }
Object.defineProperty(inst, 'label', { value: 'digico' })
inst.dialect = getDialect('sd')
inst.state = { auxNames: new Map([[24, '']]), groupNames: new Map([[24, 'Studio']]), sends: new Map(), groups: new Map(), mutes: new Map(), bussMutes: new Map() }
check(inst.monMode() === 'group', 'monMode() auto-detects a group buss from the names')
const defs = await import('../src/definitions.js')
const actions = defs.getActionDefinitions(inst)
const feedbacks = defs.getFeedbackDefinitions(inst)
const variables = defs.getVariableDefinitions(inst)
const { structure, presets } = defs.getPresetDefinitions(inst)
check(Object.keys(actions).length >= 15 && Object.keys(feedbacks).length >= 8, `definitions build (${Object.keys(actions).length} actions, ${Object.keys(feedbacks).length} feedbacks, ${Object.keys(variables).length} variables, ${Object.keys(presets).length} presets, ${structure.length} preset groups)`)
// 1. Codec round trips, including the unpadded-datagram case the iPad app produces.
{
const buf = encode('/Input_Channels/3/Aux_Send/2/send_on', [{ i: 1 }])
const m = decode(buf)
check(m.address === '/Input_Channels/3/Aux_Send/2/send_on' && m.tags === ',i' && m.args[0] === 1, 'OSC encode/decode int')
const f = decode(encode('/x', [-12.5, 'hi', true, false, null]))
check(Math.abs(f.args[0] + 12.5) < 1e-6 && f.args[1] === 'hi' && f.args[2] === true && f.args[3] === false && f.args[4] === null, 'OSC encode/decode float/string/T/F/N')
const short = decode(buf.subarray(0, buf.length - 2))
check(short.unpadded === true && short.address === m.address, 'decoder tolerates an unpadded datagram')
check(decode(Buffer.from('garbage')).raw !== undefined, 'decoder returns raw for non-OSC bytes')
}
// 2. Start the mock console (SD dialect) and a fake iPad.
const node = process.execPath
const mock = spawn(node, [path.join(root, 'tools/mock-s31.js'), '--dialect', 'sd', '--listen', '18000', '--reply-to', '127.0.0.1:19000', '--quiet'], { stdio: ['ignore', 'pipe', 'pipe'] })
let mockOut = ''
mock.stdout.on('data', (d) => (mockOut += d))
mock.stderr.on('data', (d) => (mockOut += d))
await sleep(400)
const link = new ConsoleLink({ host: '127.0.0.1', port: 18000, localPort: 19000 })
const relay = new Relay({ port: 18001, clientPort: 19001, timeoutMs: 60000 })
const dialect = getDialect('sd')
const state = { names: new Map(), auxNames: new Map(), groupNames: new Map(), sends: new Map(), groups: new Map(), pongs: 0, counts: {} }
link.on('raw', (buf) => relay.fanout(buf))
link.on('message', (msg) => {
const ev = dialect.parse(msg.address, msg.args || [])
if (!ev) return
if (ev.kind === 'pong') state.pongs++
if (ev.kind === 'name') state.names.set(ev.ch, ev.value)
if (ev.kind === 'auxName') state.auxNames.set(ev.aux, ev.value)
if (ev.kind === 'groupName') state.groupNames.set(ev.group, ev.value)
if (ev.kind === 'count') state.counts[ev.field] = ev.value
if (ev.kind === 'send') {
const k = `${ev.ch}:${ev.send}`
state.sends.set(k, { ...(state.sends.get(k) || {}), [ev.field]: ev.value })
}
if (ev.kind === 'group') state.groups.set(`${ev.ch}:${ev.group}`, ev.value)
})
relay.on('fromClient', (buf) => link.sendRaw(buf))
await link.open()
await relay.open()
// 3. Module-style load: paced queries.
const queries = dialect.resendQueries({ inputs: 8, auxes: 25, monSend: 24 })
for (const [a, args] of queries) {
link.send(a, args)
await sleep(4)
}
await sleep(800)
check(state.pongs >= 1, 'console answered /Console/Name/? (keepalive works)')
check(state.counts.inputs === 60 && state.counts.auxes === 25, `counts loaded (inputs=${state.counts.inputs}, auxes=${state.counts.auxes})`)
check(state.names.get(1) === 'KICK In', `input names loaded (1 = ${JSON.stringify(state.names.get(1))})`)
check(state.auxNames.get(1) === 'VOCAL VERB', `aux names loaded (1 = ${JSON.stringify(state.auxNames.get(1))})`)
const s = state.sends.get('1:24')
check(s && typeof s.level === 'number' && typeof s.enabled === 'boolean', `send state for ch1 buss24 loaded (${JSON.stringify(s)})`)
check(state.groupNames.get(24) === 'Studio' && state.auxNames.get(24) === '', `group names loaded (24 = ${JSON.stringify(state.groupNames.get(24))}, aux side ${JSON.stringify(state.auxNames.get(24))})`)
check(state.groups.get('1:24') === true && state.groups.size >= 8, `group assigns loaded for buss 24 (${state.groups.size} known, ch1 = ${state.groups.get('1:24')})`)
link.send(...dialect.groupAssign(5, 24, false, 'int'))
await sleep(200)
check(state.groups.get('5:24') === false, 'group assign write (int) echoed by console and parsed as boolean')
// 4. A write from the module leg is echoed back by the console.
link.send(...dialect.sendLevel(2, 24, -12.5))
await sleep(200)
check(Math.abs((state.sends.get('2:24')?.level ?? 999) + 12.5) < 0.01, 'send_level write echoed by console and parsed')
link.send(...dialect.sendEnabled(2, 24, true, 'int'))
await sleep(200)
check(state.sends.get('2:24')?.enabled === true, 'send_on write (int) echoed by console and parsed as boolean')
// 5. Fake iPad through the relay: it must get console replies and its writes must reach the console.
const ipadRx = []
const ipad = dgram.createSocket('udp4')
ipad.on('message', (buf) => ipadRx.push(decode(buf)))
await new Promise((r) => ipad.bind(19001, '127.0.0.1', r))
ipad.send(encode('/Console/Name/?', []), 18001, '127.0.0.1')
await sleep(300)
check(ipadRx.some((m) => m.address === '/Console/Name'), `iPad got /Console/Name via relay (${ipadRx.length} msgs)`)
check(relay.clientCount === 1, 'relay tracks one iPad client')
ipad.send(encode('/Input_Channels/3/Aux_Send/24/send_level', [-20]), 18001, '127.0.0.1')
await sleep(300)
check(Math.abs((state.sends.get('3:24')?.level ?? 999) + 20) < 0.01, 'iPad write relayed to console; echo parsed by module')
check(ipadRx.some((m) => m.address === '/Input_Channels/3/Aux_Send/24/send_level'), 'console echo of the iPad write fanned back to the iPad')
// 6. Unpadded datagram from an "iPad" is forwarded verbatim and still answered.
const unpadded = encode('/Console/Name/?', []).subarray(0, encode('/Console/Name/?', []).length - 0) // (already padded; mimic by trimming trailing zero bytes)
const trimmed = unpadded.subarray(0, unpadded.length - 2)
const before = ipadRx.length
ipad.send(trimmed, 18001, '127.0.0.1')
await sleep(300)
console.log(`info unpadded query forwarded; console replies after it: ${ipadRx.length - before} (mock may or may not accept unpadded input)`)
ipad.close()
relay.close()
link.close()
mock.kill('SIGTERM')
await sleep(200)
if (failures) {
console.log(`\n${failures} check(s) failed. Mock output:\n${mockOut.slice(-2000)}`)
process.exit(1)
}
console.log('\nAll checks passed.')
+140
View File
@@ -0,0 +1,140 @@
# Developer tools
Two small, dependency-free Node.js tools (Node 22+) for developing the S31 module
without a console. They share `osc-lite.js`, a tiny OSC encoder/decoder.
`tools/package.json` pins this folder to CommonJS, because the module's own
`package.json` is `"type": "module"` and would otherwise make Node load these
files as ESM. Keep it.
Both tools speak two OSC dialects, chosen with `--dialect`:
| Dialect | What it is |
| --- | --- |
| `sseries` (default) | The lowercase S-Series command list: `/console/ping`, `/channel/N/fader`, ... |
| `sd` | The "DiGiCo Pad" dialect the **real S31 iPad extension actually speaks**: `/Console/Name/?`, `/Input_Channels/N/fader/?`, ... Verified against a live console. |
## `mock-s31.js` - fake S31 console
Emulates the console's OSC extension. It listens on one UDP port and sends
**all** feedback to **one** configured controller IP:port, whoever asked and
from whichever source port, like the real desk.
```
node tools/mock-s31.js [--dialect sseries|sd] [--listen 8000] [--reply-to IP:PORT]
[--channels N] [--auxes N] [--bool-int] [--live]
[--types] [--quiet]
```
| Flag | Meaning |
| --- | --- |
| `--dialect` | `sseries` (default) or `sd`. |
| `--listen` | UDP port to bind (the console's receive port). Default 8000. |
| `--reply-to` | Fixed feedback destination, e.g. `127.0.0.1:9000`. If omitted, locks onto the first sender's IP with port 9000 and prints that. |
| `--channels` | Input count, 1-60. Default 48 (`sseries`) or 60 (`sd`). |
| `--auxes` | Aux busses / sends per channel. Default 16, max 16 (`sseries`); default 25, max 25 (`sd`). |
| `--bool-int` | `sseries` only: send booleans as int 0/1 instead of OSC `T`/`F`. |
| `--live` | Every 3 s, change a random send level or send on/off and emit it. |
| `--types` | Also print each message's OSC type tags, e.g. `/Input_Channels/1/fader ,f [-1.4375]`. |
| `--quiet` | Don't print every message. |
Traffic is printed as `<- ADDRESS [args] from IP:PORT` and `-> ADDRESS [args]`.
Whole-number floats print with a decimal (`-10.0`) so you can tell int from float.
### sseries dialect
No args at a readable address = query (reply with the value); with an arg =
set (store, then echo to the controller). `/console/resend` dumps the whole
state at ~2 ms per packet. Values are clamped (fader -150..10, send level
-90..10, pan -1..1). Addresses: `/console/{ping,channel/counts,resend}`,
`/channel/N/{name,fader,mute,solo,pan}`, `/channel/N/total/gain` (send-only,
sent with fader changes), `/channel/N/send/M/{level,enabled,pan}`, and
`/digico/snapshots/fire[/next|/previous]` (logged only). Inputs are 1-60, aux
busses 70..70+auxes-1.
### sd dialect
A **query** is the address plus a trailing `/?` and no args; the reply is the
same address without `/?` plus the value. A **set** is the address without `/?`
and one arg; it is stored and echoed back in the console's reply types.
| Query | Reply type |
| --- | --- |
| `/Console/Name/?` | `s` ("S31 AB") |
| `/Console/{Input_Channels,Aux_Outputs,Group_Outputs,Control_Groups,Matrix_Outputs,Matrix_Inputs,Graphic_EQ,Talkback_Outputs,Multis}/?` | `i` |
| `/Console/Aux_Outputs/modes/?` | one `i` per aux (2 = stereo, 1 = mono) |
| `/Input_Channels/N/Channel_Input/name/?`, `/Aux_Outputs/N/Buss_Trim/name/?`, `/Group_Outputs/N/Buss_Trim/name/?` | `s` (read-only). The 25 busses appear under both Aux_Outputs and Group_Outputs; a buss reports its name on the side matching its mode and `""` on the other (captured names). |
| `/Input_Channels/N/fader/?`, `/Aux_Outputs/N/fader/?`, `/Group_Outputs/N/fader/?` | `f`, dB, -150 = -inf, max +10 (group 24 = -1.6875) |
| `/Input_Channels/N/mute/?`, `/Aux_Outputs/N/mute/?`, `/Group_Outputs/N/mute/?` | `f` 0/1 (a float, not an int) |
| `/Input_Channels/N/Aux_Send/M/send_level/?` | `f`, dB, -150..10 |
| `/Input_Channels/N/Aux_Send/M/send_on/?` | `f` 0/1 |
| `/Input_Channels/N/Aux_Send/M/send_pan/?` | `f`, 0..1, 0.5 = centre |
| `/Input_Channels/N/Aux_Send/M/send_pre-post/?` | `i` (captured as an int, default 0; settable, clamped 0..1 - range is an assumption) |
| `/Input_Channels/N/Group_Send/M/group/?` | `f` 0/1 group assign, M = 1..25. Default: inputs 1-8 and 18-21 on group 24, nothing else. The reply type is the single constant `SD_GROUP_ASSIGN_WIRE_TYPE` in `mock-s31.js` (`'f'`, assumed like mute/send_on; set `'i'` if the real console reports an int). |
| `/Snapshots/Current_Snapshot/?` | `i` |
| `/Snapshots/names/?` | one `/Snapshots/name ,iiis [index, cue*100, 0, "name"]` per snapshot |
Sets accept int or float for `mute`/`send_on` (any non-zero = on) and clamp
`fader`/`send_level` to -150..10 and `send_pan` to 0..1. Like the real console
it stays **silent** on everything else: all lowercase S-Series addresses,
`/Console/Resend`, `/Console/Channels/?`, `/Input_Channels/N/Aux_Send/M/?`,
`/Input_Channels/N/Aux_Send/?`, `/Macros/Buttons/?`, out-of-range channel/send
numbers, and sets of read-only names. (`--bool-int` has no effect in `sd`.)
## `fake-ipad.js` - stand-in for the DiGiCo iPad app
Binds the receive port, sends its opening requests, repeats a keepalive every
5 s, prints everything it receives and a packet-count summary every 10 s.
```
node tools/fake-ipad.js --console IP --send-port 8000 --receive-port 9000
[--dialect sseries|sd] [--name A] [--types] [--quiet]
```
- `sseries`: sends `/console/ping`, then `/console/resend`; keepalive `/console/ping`; `/console/pong` counts as a pong.
- `sd`: sends `/Console/Name/?`, `/Console/Input_Channels/?`, `/Console/Aux_Outputs/?`, `/Console/Aux_Outputs/modes/?`, names of inputs 1-8 and aux outputs 1-4, then `/Snapshots/names/?` (20 ms apart); keepalive `/Console/Name/?`; a `/Console/Name` reply counts as a pong.
Type `ADDRESS [arg ...]` lines on stdin to send your own messages
(`true`/`false` = boolean, `5` = int, `5.0` or `-12.5` = float, anything else or
`"quoted"` = string; `quit` exits):
```
/channel/3/send/2/level -12.5 # sseries set
/Input_Channels/3/Aux_Send/2/send_on 1 # sd set (echo comes back as ,f [1])
/Input_Channels/3/Aux_Send/2/send_level/? # sd query
/channel/3/name "Lead Vox"
```
## End-to-end loops on one machine
The module sits between the console and the iPad: the console's feedback goes
to the module (port 9000), and the module relays to and from the iPad. Use the
ports the module is actually configured with; `9100` and `9200` below are just
examples for "the relay's iPad-facing port" and "where the relay forwards
feedback to the iPad".
### S-Series dialect
```sh
# 1. Mock console: listens on 8000, replies to whatever listens on 9000
node tools/mock-s31.js --listen 8000 --reply-to 127.0.0.1:9000
# 2. Start the Companion module (or any relay) listening on 9000, sending to 127.0.0.1:8000
# 3. Fake iPad pointed at the relay (sends to 9100, receives the relayed feedback on 9200)
node tools/fake-ipad.js --console 127.0.0.1 --send-port 9100 --receive-port 9200
```
### SD dialect (what the real S31 speaks)
```sh
# 1. Mock console in SD mode: listens on 8000, replies to 127.0.0.1:9000
node tools/mock-s31.js --dialect sd --listen 8000 --reply-to 127.0.0.1:9000 --types
# 2. Start the Companion module (or any relay) listening on 9000, sending to 127.0.0.1:8000
# 3. Fake iPad (SD) pointed at the relay
node tools/fake-ipad.js --dialect sd --console 127.0.0.1 --send-port 9100 --receive-port 9200 --types
```
No module yet? Skip step 2 and point the iPad straight at the mock to check the
tools themselves, e.g.
`node tools/fake-ipad.js --dialect sd --console 127.0.0.1 --send-port 8000 --receive-port 9000`.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env node
'use strict';
/**
* fake-ipad.js - stand-in for the DiGiCo iPad app, for testing the Companion
* module (or an OSC relay) against the mock console or a real S31.
*
* Two OSC dialects (--dialect):
*
* sseries (default) sends /console/ping then /console/resend on start, then
* /console/ping every 5 s; /console/pong counts as a pong.
* sd the "DiGiCo Pad" dialect the real S31 speaks. On start it
* queries /Console/Name/?, /Console/Input_Channels/?,
* /Console/Aux_Outputs/?, /Console/Aux_Outputs/modes/?,
* the names of inputs 1-8 and aux outputs 1-4, and
* /Snapshots/names/?; then /Console/Name/? every 5 s as a
* keepalive. A /Console/Name reply counts as a pong.
*
* In both dialects it binds --receive-port, prints everything it receives and a
* packet-count summary every 10 s, and reads lines from stdin of the form
* `ADDRESS [arg ...]` and sends them, e.g.
* /channel/3/send/2/level -12.5 (sseries)
* /Input_Channels/3/Aux_Send/2/send_on 1 (sd set)
* /Input_Channels/3/Aux_Send/2/send_level/? (sd query)
* /channel/3/name "Lead Vox"
* true/false -> boolean, whole numbers -> int, decimals -> float, anything else
* (or anything in double quotes) -> string. Type "quit" to exit.
*
* Usage:
* node tools/fake-ipad.js --console IP --send-port 8000 --receive-port 9000
* [--dialect sseries|sd] [--name A] [--types] [--quiet]
*/
const dgram = require('node:dgram');
const readline = require('node:readline');
const { parseArgs } = require('node:util');
const { encode, decode, formatArgs, typeTags, int, float } = require('./osc-lite');
// ---------------------------------------------------------------------------
// Command line
// ---------------------------------------------------------------------------
const USAGE = `Usage: node tools/fake-ipad.js --console IP [options]
--console IP host to send requests to (console, relay or mock) (required)
--send-port PORT UDP port on that host [8000]
--receive-port PORT local UDP port to bind for feedback [9000]
--dialect NAME sseries (default): lowercase S-Series OSC list
sd: the dialect the real S31 iPad extension speaks
--name NAME label used in the output lines [A]
--types also print each message's OSC type tags, e.g. ",f"
--quiet only print the periodic summary, not every packet
-h, --help show this help
Type "ADDRESS [arg ...]" lines on stdin to send your own messages.`;
function die(message) {
console.error(`fake-ipad: ${message}\n\n${USAGE}`);
process.exit(2);
}
function parseCli() {
let values;
try {
({ values } = parseArgs({
options: {
console: { type: 'string' },
'send-port': { type: 'string', default: '8000' },
'receive-port': { type: 'string', default: '9000' },
dialect: { type: 'string', default: 'sseries' },
name: { type: 'string', default: 'A' },
types: { type: 'boolean', default: false },
quiet: { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
},
allowPositionals: false,
strict: true,
}));
} catch (err) {
die(err.message);
}
if (values.help) {
console.log(USAGE);
process.exit(0);
}
if (!values.console) die('--console IP is required');
if (values.dialect !== 'sseries' && values.dialect !== 'sd') die(`--dialect must be "sseries" or "sd" (got "${values.dialect}")`);
const port = (name) => {
const text = values[name];
if (!/^\d+$/.test(text) || Number(text) < 1 || Number(text) > 65535) die(`--${name} must be a port number 1-65535 (got "${text}")`);
return Number(text);
};
return {
host: values.console,
sendPort: port('send-port'),
receivePort: port('receive-port'),
dialect: values.dialect,
name: values.name,
types: values.types,
quiet: values.quiet,
};
}
const opts = parseCli();
const isSd = opts.dialect === 'sd';
const tag = `[${opts.name}]`;
/** What a "pong" is in this dialect, and the message that provokes one. */
const PONG_ADDRESS = isSd ? '/Console/Name' : '/console/pong';
const KEEPALIVE_ADDRESS = isSd ? '/Console/Name/?' : '/console/ping';
const STARTUP_GAP_MS = isSd ? 20 : 0; // be gentle with a real desk: pace the SD burst
/** The opening requests, in order. */
function startupAddresses() {
if (!isSd) return ['/console/ping', '/console/resend'];
const list = ['/Console/Name/?', '/Console/Input_Channels/?', '/Console/Aux_Outputs/?', '/Console/Aux_Outputs/modes/?'];
for (let n = 1; n <= 8; n++) list.push(`/Input_Channels/${n}/Channel_Input/name/?`);
for (let n = 1; n <= 4; n++) list.push(`/Aux_Outputs/${n}/Buss_Trim/name/?`);
list.push('/Snapshots/names/?');
return list;
}
// ---------------------------------------------------------------------------
// Transport and statistics
// ---------------------------------------------------------------------------
const socket = dgram.createSocket('udp4');
const stats = {
received: 0, // total packets
sent: 0,
pongs: 0,
undecodable: 0,
unpadded: 0,
windowReceived: 0, // packets since the last summary
};
const seenAddresses = new Set();
let pingTimer = null;
let summaryTimer = null;
/** "ADDRESS [args]" or, with --types, "ADDRESS ,tags [args]". */
function describe(address, args, types) {
const body = formatArgs(args, types);
return opts.types ? `${address} ,${types} ${body}` : `${address} ${body}`;
}
/** Encode and send one message to the console. */
function sendMessage(address, args) {
let buf;
try {
buf = encode(address, args);
} catch (err) {
console.log(`${tag} ! cannot encode ${address}: ${err.message}`);
return;
}
socket.send(buf, opts.sendPort, opts.host, (err) => {
if (err) console.log(`${tag} ! send error: ${err.message}`);
});
stats.sent++;
if (!opts.quiet) console.log(`${tag} -> ${describe(address, args, typeTags(args))}`);
}
function printSummary(label) {
console.log(
`${tag} ${label}: received ${stats.received} packets (+${stats.windowReceived} in the last interval), ` +
`${seenAddresses.size} distinct addresses, ${stats.pongs} pong(s), sent ${stats.sent}` +
(stats.undecodable ? `, ${stats.undecodable} undecodable` : '') +
(stats.unpadded ? `, ${stats.unpadded} unpadded` : ''),
);
stats.windowReceived = 0;
}
socket.on('message', (msg) => {
stats.received++;
stats.windowReceived++;
const packet = decode(msg);
if (packet.raw !== undefined) {
stats.undecodable++;
if (!opts.quiet) console.log(`${tag} <- (undecodable, ${msg.length} bytes) ${packet.raw.slice(0, 96)}`);
return;
}
seenAddresses.add(packet.address);
if (packet.unpadded) stats.unpadded++;
if (packet.address === PONG_ADDRESS) stats.pongs++;
if (!opts.quiet) console.log(`${tag} <- ${describe(packet.address, packet.args, packet.types)}`);
});
socket.on('error', (err) => {
console.error(`${tag} socket error: ${err.code === 'EADDRINUSE' ? `UDP port ${opts.receivePort} is already in use` : err.message}`);
process.exit(1);
});
socket.on('listening', () => {
const addr = socket.address();
console.log(`${tag} listening on udp://${addr.address}:${addr.port}; talking to ${opts.host}:${opts.sendPort} (dialect ${opts.dialect})`);
// Same opening sequence as the iPad app, then a keepalive.
startupAddresses().forEach((address, i) => {
if (STARTUP_GAP_MS === 0) sendMessage(address, []);
else setTimeout(() => sendMessage(address, []), i * STARTUP_GAP_MS);
});
pingTimer = setInterval(() => sendMessage(KEEPALIVE_ADDRESS, []), 5000);
summaryTimer = setInterval(() => printSummary('summary'), 10000);
});
// ---------------------------------------------------------------------------
// Interactive stdin: "ADDRESS [arg ...]"
// ---------------------------------------------------------------------------
/** Split a line into tokens; "double quoted" tokens are always strings. */
function tokenize(line) {
const tokens = [];
const re = /"((?:[^"\\]|\\.)*)"|(\S+)/g;
let m;
while ((m = re.exec(line)) !== null) {
if (m[1] !== undefined) tokens.push({ text: m[1].replace(/\\(.)/g, '$1'), quoted: true });
else tokens.push({ text: m[2], quoted: false });
}
return tokens;
}
/** true/false -> boolean, integers -> int, decimals -> float, else string. */
function parseValue(token) {
if (token.quoted) return token.text;
const text = token.text;
if (text === 'true') return true;
if (text === 'false') return false;
if (/^[+-]?\d+$/.test(text)) {
const n = Number(text);
return n >= -2147483648 && n <= 2147483647 ? int(n) : float(n);
}
if (/^[+-]?(\d+\.\d*|\.\d+|\d+(?=[eE]))([eE][+-]?\d+)?$/.test(text)) return float(Number(text));
return text;
}
function handleLine(line) {
const trimmed = line.trim();
if (!trimmed) return;
if (trimmed === 'quit' || trimmed === 'exit') {
shutdown('quit');
return;
}
const tokens = tokenize(trimmed);
const address = tokens.shift().text;
if (address[0] !== '/') {
console.log(`${tag} ? expected "ADDRESS [arg ...]" starting with '/', e.g. ${isSd ? '/Input_Channels/3/Aux_Send/2/send_level -12.5' : '/channel/3/send/2/level -12.5'}`);
return;
}
sendMessage(address, tokens.map(parseValue));
}
// terminal:false keeps Ctrl-C as a normal SIGINT. If stdin is closed or not a
// terminal (running in the background, piped input) we simply keep running.
const rl = readline.createInterface({ input: process.stdin, terminal: false });
rl.on('line', handleLine);
rl.on('close', () => {});
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
function shutdown(reason) {
clearInterval(pingTimer);
clearInterval(summaryTimer);
printSummary(`final (${reason})`);
try {
rl.close();
socket.close();
} catch {
// already closed
}
process.exit(0);
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
socket.bind(opts.receivePort);
+792
View File
@@ -0,0 +1,792 @@
#!/usr/bin/env node
'use strict';
/**
* mock-s31.js - emulates the DiGiCo S31 iPad "OSC Control" extension for
* developing and testing the Companion module without a real console.
*
* Behaviour copied from the real desk (both dialects):
* - It listens on ONE UDP "receive port" (--listen).
* - ALL feedback goes to ONE configured controller IP:port (the console's
* "send port", normally 9000), no matter who sent the request or from
* which source port (--reply-to).
*
* Two OSC dialects (--dialect):
*
* sseries (default) - the lowercase S-Series command list.
* A message with no args at a readable address is a QUERY -> reply with
* the current value. A message with an arg is a SET -> store it, then echo
* it to the controller. /console/resend dumps the whole state.
*
* sd - the "DiGiCo Pad" / SD dialect the real S31 actually speaks
* (verified against a live console).
* A QUERY is the address plus a trailing "/?" and no args; the reply is the
* same address WITHOUT "/?" plus the value. A SET is the address without
* "/?" and one argument; it is stored and echoed using the console's reply
* types (mute and send_on come back as floats 0/1). Anything the real
* console does not answer (lowercase S-Series addresses, /Console/Resend,
* /Console/Channels/?, whole-send queries, ...) gets no reply here either.
*
* Usage:
* node tools/mock-s31.js [--dialect sseries|sd] [--listen 8000] [--reply-to IP:PORT]
* [--channels N] [--auxes N] [--bool-int] [--live]
* [--types] [--quiet]
*/
const dgram = require('node:dgram');
const { parseArgs } = require('node:util');
const { encode, decode, formatArgs, typeTags, int, float } = require('./osc-lite');
// ---------------------------------------------------------------------------
// Command line
// ---------------------------------------------------------------------------
const USAGE = `Usage: node tools/mock-s31.js [options]
--dialect NAME sseries (default): lowercase S-Series OSC list
(/console/ping, /channel/N/fader, ...)
sd: the dialect the real S31 iPad extension speaks
(/Console/Name/?, /Input_Channels/N/fader/?, ...)
--listen PORT UDP port to bind (the console's receive port) [8000]
--reply-to IP:PORT fixed feedback destination (the console's configured
controller). If omitted, lock onto the first sender's IP
with port 9000.
--channels N input channels, 1-60 [sseries 48, sd 60]
--auxes N aux busses / sends per channel [sseries 16 (max 16),
sd 25 (max 25)]
--bool-int sseries only: send booleans as int 0/1 instead of OSC T/F
(the console can be set to "normalise" booleans)
--live every 3 s change a random send level / send-enabled and
emit it, to exercise feedback handling
--types also print each message's OSC type tags, e.g. ",f"
--quiet do not print every message
-h, --help show this help`;
const DIALECTS = {
sseries: { channels: 48, auxes: 16, maxAuxes: 16 },
sd: { channels: 60, auxes: 25, maxAuxes: 25 },
};
function die(message) {
console.error(`mock-s31: ${message}\n\n${USAGE}`);
process.exit(2);
}
function parseCli() {
let values;
try {
({ values } = parseArgs({
options: {
dialect: { type: 'string', default: 'sseries' },
listen: { type: 'string', default: '8000' },
'reply-to': { type: 'string' },
channels: { type: 'string' },
auxes: { type: 'string' },
'bool-int': { type: 'boolean', default: false },
live: { type: 'boolean', default: false },
types: { type: 'boolean', default: false },
quiet: { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
},
allowPositionals: false,
strict: true,
}));
} catch (err) {
die(err.message);
}
if (values.help) {
console.log(USAGE);
process.exit(0);
}
if (!Object.hasOwn(DIALECTS, values.dialect)) die(`--dialect must be "sseries" or "sd" (got "${values.dialect}")`);
const defaults = DIALECTS[values.dialect];
const intOption = (name, min, max, fallback) => {
const text = values[name];
if (text === undefined) return fallback;
if (!/^\d+$/.test(text)) die(`--${name} must be a whole number (got "${text}")`);
const n = Number(text);
if (n < min || n > max) die(`--${name} must be between ${min} and ${max} for the ${values.dialect} dialect (got ${n})`);
return n;
};
let replyTo = null;
if (values['reply-to'] !== undefined) {
const m = /^(.+):(\d{1,5})$/.exec(values['reply-to']);
if (!m || Number(m[2]) < 1 || Number(m[2]) > 65535) die(`--reply-to must look like IP:PORT (got "${values['reply-to']}")`);
replyTo = { host: m[1], port: Number(m[2]) };
}
return {
dialect: values.dialect,
listen: intOption('listen', 1, 65535, 8000),
replyTo,
channels: intOption('channels', 1, 60, defaults.channels),
auxes: intOption('auxes', 0, defaults.maxAuxes, defaults.auxes),
boolInt: values['bool-int'],
live: values.live,
types: values.types,
quiet: values.quiet,
};
}
const opts = parseCli();
const isSd = opts.dialect === 'sd';
const DUMP_INTERVAL_MS = 2; // pacing of multi-packet replies, like a real desk
const LIVE_INTERVAL_MS = 3000;
const DEFAULT_REPLY_PORT = 9000;
// ---------------------------------------------------------------------------
// Shared helpers: PRNG, value coercion, outbound typing
// ---------------------------------------------------------------------------
/** Small deterministic PRNG so every run starts from the same "show file". */
function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Property kinds:
* s string
* f float32 with min/max clamp
* b boolean (sseries; sent as T/F, or int 0/1 with --bool-int)
* flag 0/1 number (sd; accepted as int, float or boolean; reported as a float,
* or as an int when the spec says wire: 'i')
* int integer with min/max clamp (sd)
*/
/** Validate/clamp an inbound OSC argument for a property; undefined = unusable. */
function coerce(spec, arg) {
switch (spec.kind) {
case 's':
return typeof arg === 'string' ? arg : undefined;
case 'f': {
if (typeof arg !== 'number' || !Number.isFinite(arg)) return undefined;
return Math.fround(Math.min(spec.max, Math.max(spec.min, arg)));
}
case 'b': {
if (typeof arg === 'boolean') return arg;
if (typeof arg === 'number') return arg !== 0;
if (typeof arg === 'string' && /^(true|1|on)$/i.test(arg)) return true;
if (typeof arg === 'string' && /^(false|0|off)$/i.test(arg)) return false;
return undefined;
}
case 'flag': {
if (typeof arg === 'boolean') return arg ? 1 : 0;
if (typeof arg === 'number' && Number.isFinite(arg)) return arg !== 0 ? 1 : 0;
return undefined;
}
case 'int': {
let n;
if (typeof arg === 'boolean') n = arg ? 1 : 0;
else if (typeof arg === 'number' && Number.isFinite(arg)) n = Math.round(arg);
else return undefined;
return Math.min(spec.max, Math.max(spec.min, n));
}
default:
return undefined;
}
}
/** Stored value -> outbound OSC argument. */
function toArg(spec, value) {
switch (spec.kind) {
case 'f':
return float(value);
case 'b':
return opts.boolInt ? int(value ? 1 : 0) : Boolean(value);
case 'flag':
return spec.wire === 'i' ? int(value) : float(value); // the real SD console reports mute / send_on as floats
case 'int':
return int(value);
default:
return value;
}
}
// ---------------------------------------------------------------------------
// Transport
// ---------------------------------------------------------------------------
const socket = dgram.createSocket('udp4');
const stats = { received: 0, sent: 0, ignored: 0, undecodable: 0 };
/** Where feedback goes: { host, port } or null until we know a controller. */
let replyTarget = opts.replyTo;
let warnedNoTarget = false;
const note = (text) => console.log(`[mock-s31] ${text}`);
/** Log something that is only interesting while watching the traffic. */
function trace(text) {
if (!opts.quiet) console.log(text);
}
/** "ADDRESS [args]" or, with --types, "ADDRESS ,tags [args]". */
function describe(address, args, types) {
const body = formatArgs(args, types);
return opts.types ? `${address} ,${types} ${body}` : `${address} ${body}`;
}
/** Record and log a message we are deliberately not acting on. */
function ignore(reason) {
stats.ignored++;
trace(`[mock-s31] ignoring: ${reason}`);
}
/** Send one message to the configured controller. */
function send(address, args) {
if (!replyTarget) {
if (!warnedNoTarget) {
warnedNoTarget = true;
note('no controller known yet (no --reply-to and nothing received); dropping feedback until one arrives');
}
return false;
}
try {
const buf = encode(address, args);
socket.send(buf, replyTarget.port, replyTarget.host, (err) => {
if (err) note(`send error: ${err.message}`);
});
} catch (err) {
note(`could not send ${address}: ${err.message}`);
return false;
}
stats.sent++;
if (!opts.quiet) console.log(`-> ${describe(address, args, typeTags(args))}`);
return true;
}
/** Store a new value and emit it, as the console does for every change. */
function setAndEmit(target, prop, spec, address, value, afterSet) {
target[prop] = value;
send(address, [toArg(spec, value)]);
if (afterSet) afterSet(value);
}
// Multi-packet replies (resend dump, snapshot list) are paced ~2 ms apart.
let dumpQueue = [];
let dumpTimer = null;
let dumpTotal = 0;
let dumpLabel = '';
function startPaced(label, queue) {
if (!replyTarget) return; // cannot happen via the socket: the first datagram locks a target
if (dumpTimer) {
clearTimeout(dumpTimer);
note(`${label} requested while a dump was running; restarting it`);
}
dumpQueue = queue;
dumpTotal = queue.length;
dumpLabel = label;
if (dumpTotal > 20) {
note(`${label}: sending ${dumpTotal} packets (~${Math.round((dumpTotal * (DUMP_INTERVAL_MS + 0.5)) / 100) / 10}s)`);
}
pumpPaced();
}
function pumpPaced() {
const next = dumpQueue.shift();
if (!next) {
dumpTimer = null;
if (dumpTotal > 20) note(`${dumpLabel} complete (${dumpTotal} packets)`);
return;
}
send(next[0], next[1]);
dumpTimer = setTimeout(pumpPaced, DUMP_INTERVAL_MS);
}
// ===========================================================================
// Dialect: sseries (lowercase S-Series OSC list)
// channel numbering: inputs 1-60, aux busses 70-79 (extended 70-85 when more
// than 10 auxes), groups 80-93, master 120
// ===========================================================================
const AUX_BASE = 70; // aux busses are channel numbers 70..70+auxes-1
const INPUT_NAMES = [
'Kick', 'Snare', 'Hat', 'Tom 1', 'Tom 2', 'Floor Tom', 'OH L', 'OH R',
'Bass DI', 'Bass Mic', 'Keys L', 'Keys R', 'Acou Gtr', 'Elec Gtr 1', 'Elec Gtr 2',
'Vox 1', 'Vox 2', 'Vox 3', 'Vox 4', 'Vox 5', 'Pastor', 'Choir 1', 'Choir 2',
'Track L', 'Track R', 'Click',
];
const AUX_NAMES = [
'Studio Mon', 'IEM 1', 'IEM 2', 'Wedge 1', 'Wedge 2', 'IEM 3', 'IEM 4', 'Wedge 3',
'Wedge 4', 'Sub Feed', 'Lobby', 'Nursery', 'Stream', 'Green Room', 'FX 1', 'FX 2',
];
const CHANNEL_PROPS = {
name: { kind: 's' },
fader: { kind: 'f', min: -150, max: 10 },
mute: { kind: 'b' },
solo: { kind: 'b' },
pan: { kind: 'f', min: -1, max: 1 },
};
const SEND_PROPS = {
level: { kind: 'f', min: -90, max: 10 },
enabled: { kind: 'b' },
pan: { kind: 'f', min: -1, max: 1 },
};
/** One decimal place, stored as the float32 that actually goes on the wire. */
const tidy = (x) => Math.fround(Math.round(x * 10) / 10);
/** channel number -> { num, kind, name, fader, mute, solo, pan, sends } */
const channels = new Map();
function buildStateSseries() {
const rng = mulberry32(31);
for (let n = 1; n <= opts.channels; n++) {
const sends = [null]; // 1-based
for (let m = 1; m <= opts.auxes; m++) {
const active = rng() < 0.6;
sends.push({
level: active ? tidy(-40 + rng() * 40) : -90,
enabled: active,
pan: rng() < 0.2 ? tidy(rng() * 2 - 1) : 0,
});
}
channels.set(n, {
num: n,
kind: 'input',
name: INPUT_NAMES[n - 1] || `Input ${n}`,
fader: tidy(-30 + rng() * 30),
mute: rng() < 0.08,
solo: false,
pan: 0,
sends,
});
}
for (let k = 0; k < opts.auxes; k++) {
const num = AUX_BASE + k;
channels.set(num, {
num,
kind: 'aux',
name: AUX_NAMES[k] || `Aux ${k + 1}`,
fader: tidy(-10 + rng() * 10),
mute: false,
solo: false,
pan: 0,
sends: null,
});
}
}
/** Query (no args) or set (one arg) of target[prop], echoing to the controller. */
function queryOrSet(target, prop, spec, address, args, afterSet) {
if (args.length === 0) {
send(address, [toArg(spec, target[prop])]); // query
return;
}
const value = coerce(spec, args[0]);
if (value === undefined) {
ignore(`unusable value ${formatArgs(args)} for ${address}`);
return;
}
setAndEmit(target, prop, spec, address, value, afterSet);
}
const RE_CHANNEL = /^\/channel\/(\d+)\/(name|fader|mute|solo|pan)$/;
const RE_TOTAL_GAIN = /^\/channel\/(\d+)\/total\/gain$/;
const RE_SEND = /^\/channel\/(\d+)\/send\/(\d+)\/(level|enabled|pan)$/;
let snapshot = 0; // only used to make the snapshot log lines meaningful
function handleSseries(address, args) {
switch (address) {
case '/console/ping':
send('/console/pong', []);
return;
case '/console/channel/counts':
send(address, [int(opts.channels), int(opts.auxes)]);
return;
case '/console/resend':
startPaced('resend', buildDumpSseries());
return;
case '/digico/snapshots/fire': {
if (typeof args[0] !== 'number') return ignore('snapshot fire without a number');
snapshot = Math.trunc(args[0]);
note(`snapshot fire: recall #${snapshot}`);
return;
}
case '/digico/snapshots/fire/next':
snapshot += 1;
note(`snapshot fire next: now #${snapshot}`);
return;
case '/digico/snapshots/fire/previous':
snapshot = Math.max(0, snapshot - 1);
note(`snapshot fire previous: now #${snapshot}`);
return;
default:
break;
}
let m = RE_CHANNEL.exec(address);
if (m) {
const num = Number(m[1]);
const prop = m[2];
const ch = channels.get(num);
if (!ch) return ignore(`no such channel ${num} (${address})`);
queryOrSet(ch, prop, CHANNEL_PROPS[prop], address, args, prop === 'fader'
? (value) => send(`/channel/${num}/total/gain`, [float(value)]) // send-only, rides along with fader changes
: undefined);
return;
}
m = RE_SEND.exec(address);
if (m) {
const num = Number(m[1]);
const sendNo = Number(m[2]);
const prop = m[3];
const ch = channels.get(num);
if (!ch) return ignore(`no such channel ${num} (${address})`);
const target = ch.sends && ch.sends[sendNo];
if (!target) return ignore(`channel ${num} has no send ${sendNo} (${address})`);
queryOrSet(target, prop, SEND_PROPS[prop], address, args);
return;
}
if (RE_TOTAL_GAIN.test(address)) return ignore(`${address} is send-only`);
ignore(`unknown address ${address}`);
}
/** /console/resend: counts, every input (basics + sends), then every aux bus. */
function buildDumpSseries() {
const out = [['/console/channel/counts', [int(opts.channels), int(opts.auxes)]]];
const basics = (ch) => {
out.push([`/channel/${ch.num}/name`, [toArg(CHANNEL_PROPS.name, ch.name)]]);
out.push([`/channel/${ch.num}/fader`, [toArg(CHANNEL_PROPS.fader, ch.fader)]]);
out.push([`/channel/${ch.num}/mute`, [toArg(CHANNEL_PROPS.mute, ch.mute)]]);
};
for (let n = 1; n <= opts.channels; n++) {
const ch = channels.get(n);
basics(ch);
for (let s = 1; s <= opts.auxes; s++) {
const snd = ch.sends[s];
out.push([`/channel/${n}/send/${s}/level`, [toArg(SEND_PROPS.level, snd.level)]]);
out.push([`/channel/${n}/send/${s}/enabled`, [toArg(SEND_PROPS.enabled, snd.enabled)]]);
out.push([`/channel/${n}/send/${s}/pan`, [toArg(SEND_PROPS.pan, snd.pan)]]);
}
}
for (let k = 0; k < opts.auxes; k++) basics(channels.get(AUX_BASE + k));
return out;
}
function liveTickSseries() {
const n = 1 + Math.floor(Math.random() * opts.channels);
const ch = channels.get(n);
if (opts.auxes === 0) {
// Nothing with sends to wiggle: move a fader instead.
setAndEmit(ch, 'fader', CHANNEL_PROPS.fader, `/channel/${n}/fader`, tidy(-40 + Math.random() * 50),
(v) => send(`/channel/${n}/total/gain`, [float(v)]));
return;
}
const s = 1 + Math.floor(Math.random() * opts.auxes);
const snd = ch.sends[s];
if (Math.random() < 0.5) {
setAndEmit(snd, 'level', SEND_PROPS.level, `/channel/${n}/send/${s}/level`, tidy(-60 + Math.random() * 60));
} else {
setAndEmit(snd, 'enabled', SEND_PROPS.enabled, `/channel/${n}/send/${s}/enabled`, !snd.enabled);
}
}
// ===========================================================================
// Dialect: sd (the "DiGiCo Pad" dialect the real S31 speaks)
// query = address + "/?" (no args); reply = address without "/?" + value
// ===========================================================================
const SD_CONSOLE_NAME = 'S31 AB';
/** The console has 25 busses, each exposed as an Aux_Outputs and as a Group_Outputs entry. */
const SD_BUSS_COUNT = 25;
/**
* Wire type of /Input_Channels/N/Group_Send/M/group replies: 'f' (float 0/1, like
* mute and send_on) or 'i' (int). Not yet verified against the real console -
* flip this one constant if it turns out to be an int.
*/
const SD_GROUP_ASSIGN_WIRE_TYPE = 'f';
/** Fixed counts the console reports besides Input_Channels / Aux_Outputs. */
const SD_FIXED_COUNTS = {
Group_Outputs: SD_BUSS_COUNT,
Control_Groups: 10,
Matrix_Outputs: 8,
Matrix_Inputs: 10,
Graphic_EQ: 16,
Talkback_Outputs: 0,
Multis: 0,
};
/** One int per aux: 2 = stereo, 1 = mono (captured from the real console). */
const SD_AUX_MODES = [2, 2, 2, 1, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2];
const SD_INPUT_NAMES = [
'KICK In', 'SNARE Top', 'SNARE Bot', 'HAT', 'TOM 1', 'TOM 2', 'FLOOR TOM', 'OH L', 'OH R',
'BASS DI', 'BASS Mic', 'KEYS L', 'KEYS R', 'ACOU GTR', 'ELEC GTR 1', 'ELEC GTR 2',
'VOX 1', 'VOX 2', 'VOX 3', 'VOX 4', 'VOX 5', 'PASTOR', 'CHOIR 1', 'CHOIR 2',
'TRACK L', 'TRACK R', 'CLICK',
];
/**
* Captured buss names, buss order 1..25. A buss shows its name under Aux_Outputs
* when it is in aux mode and under Group_Outputs when it is in group mode; the
* other side reports "".
*/
const SD_AUX_NAMES = [
'VOCAL VERB', 'VOCAL SLAP', 'TRACKS', 'CLK-GD-MD', 'VOCAL DELAY', 'VOCALS', 'TB', 'MC/SpkrBcastOut',
'', '', '', '', '', 'DRUM PLATE', 'AG VERB', 'FOH Spkr', 'Studio TB', '', 'FX', 'Prop & Spot',
'Drum Room Verb', '', '', '', '',
];
const SD_GROUP_NAMES = [
'', '', '', '', '', '', '', '', 'BAND', 'VOCALS', 'MISC', 'DRUMS', 'DRUM CRUSH', '', '', '', '', 'Broadcast mix',
'', '', '', '', 'CAFE', 'Studio', 'Master',
];
/** Snapshot list: index n has cue number (n+1)*100. */
const SD_SNAPSHOTS = [
'START', 'Pre Service', 'Worship 1', 'Worship 2', 'Message', 'Offering', 'Closing', 'BYPASS WAVES SUPERRACK!',
];
const SD_PROPS = {
name: { kind: 's', readOnly: true },
fader: { kind: 'f', min: -150, max: 10 }, // dB, -150 = -inf
mute: { kind: 'flag' },
level: { kind: 'f', min: -150, max: 10 }, // send_level, dB
on: { kind: 'flag' }, // send_on
pan: { kind: 'f', min: 0, max: 1 }, // send_pan, 0.5 = centre
prepost: { kind: 'int', min: 0, max: 1 }, // send_pre-post; captured as an int, range assumed 0 = pre, 1 = post
group: { kind: 'flag', wire: SD_GROUP_ASSIGN_WIRE_TYPE }, // Group_Send/M/group assign 0/1
};
const SD_SEND_PROPS = { send_level: 'level', send_on: 'on', send_pan: 'pan', 'send_pre-post': 'prepost' };
/** Real SD faders land on 1/16 dB steps (e.g. -1.4375, -4.375). */
const tidy16 = (x) => Math.fround(Math.round(x * 16) / 16);
const sdInputs = [null]; // 1-based: { name, fader, mute, sends:[null,{level,on,pan,prepost}...], groupSends:[null,{group}...] }
const sdAuxes = [null]; // 1-based: { name, fader, mute }
const sdGroups = [null]; // 1-based, always SD_BUSS_COUNT: { name, fader, mute }
let sdSnapshot = 3; // /Snapshots/Current_Snapshot
function buildStateSd() {
const rng = mulberry32(31);
for (let n = 1; n <= opts.channels; n++) {
const sends = [null];
for (let m = 1; m <= opts.auxes; m++) {
const active = rng() < 0.5;
sends.push({
level: active ? tidy16(-40 + rng() * 40) : -150,
on: active ? 1 : 0,
pan: rng() < 0.2 ? tidy16(rng()) : 0.5,
prepost: 0,
});
}
// Group assign: inputs 1-8 and 18-21 feed group 24, nothing else.
const groupSends = [null];
for (let g = 1; g <= SD_BUSS_COUNT; g++) groupSends.push({ group: 0 });
if (n <= 8 || (n >= 18 && n <= 21)) groupSends[24].group = 1;
sdInputs.push({
name: SD_INPUT_NAMES[n - 1] || `INPUT ${n}`,
fader: tidy16(-30 + rng() * 30),
mute: rng() < 0.08 ? 1 : 0,
sends,
groupSends,
});
}
for (let k = 0; k < opts.auxes; k++) {
sdAuxes.push({
name: SD_AUX_NAMES[k],
fader: tidy16(-10 + rng() * 10),
mute: 0,
});
}
for (let k = 0; k < SD_BUSS_COUNT; k++) {
sdGroups.push({
name: SD_GROUP_NAMES[k],
fader: tidy16(-10 + rng() * 10),
mute: 0,
});
}
sdGroups[24].fader = -1.6875; // captured
// Pin the values captured from the real console so a probe sees the same numbers.
sdInputs[1].fader = -1.4375;
sdInputs[1].mute = 0;
if (opts.auxes >= 1) {
sdInputs[1].sends[1] = { level: -150, on: 0, pan: 0.5, prepost: 0 };
sdAuxes[1].fader = -4.375;
sdAuxes[1].mute = 0;
}
}
const SD_RE_INPUT_NAME = /^\/Input_Channels\/(\d+)\/Channel_Input\/name$/;
const SD_RE_INPUT_PROP = /^\/Input_Channels\/(\d+)\/(fader|mute)$/;
const SD_RE_SEND = /^\/Input_Channels\/(\d+)\/Aux_Send\/(\d+)\/(send_level|send_on|send_pan|send_pre-post)$/;
const SD_RE_GROUP_SEND = /^\/Input_Channels\/(\d+)\/Group_Send\/(\d+)\/group$/;
const SD_RE_AUX_NAME = /^\/Aux_Outputs\/(\d+)\/Buss_Trim\/name$/;
const SD_RE_AUX_PROP = /^\/Aux_Outputs\/(\d+)\/(fader|mute)$/;
const SD_RE_GROUP_NAME = /^\/Group_Outputs\/(\d+)\/Buss_Trim\/name$/;
const SD_RE_GROUP_PROP = /^\/Group_Outputs\/(\d+)\/(fader|mute)$/;
/** Map an SD leaf address to { target, prop } or null when there is no such thing. */
function sdResolve(address) {
let m = SD_RE_INPUT_NAME.exec(address);
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: 'name' } : null;
m = SD_RE_INPUT_PROP.exec(address);
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: m[2] } : null;
m = SD_RE_SEND.exec(address);
if (m) {
const input = sdInputs[Number(m[1])];
const snd = input && input.sends[Number(m[2])];
return snd ? { target: snd, prop: SD_SEND_PROPS[m[3]] } : null;
}
m = SD_RE_GROUP_SEND.exec(address);
if (m) {
const input = sdInputs[Number(m[1])];
const gs = input && input.groupSends[Number(m[2])];
return gs ? { target: gs, prop: 'group' } : null;
}
m = SD_RE_GROUP_NAME.exec(address);
if (m) return sdGroups[Number(m[1])] ? { target: sdGroups[Number(m[1])], prop: 'name' } : null;
m = SD_RE_GROUP_PROP.exec(address);
if (m) return sdGroups[Number(m[1])] ? { target: sdGroups[Number(m[1])], prop: m[2] } : null;
m = SD_RE_AUX_NAME.exec(address);
if (m) return sdAuxes[Number(m[1])] ? { target: sdAuxes[Number(m[1])], prop: 'name' } : null;
m = SD_RE_AUX_PROP.exec(address);
if (m) return sdAuxes[Number(m[1])] ? { target: sdAuxes[Number(m[1])], prop: m[2] } : null;
return null;
}
function handleSd(address, args) {
if (address.endsWith('/?')) {
if (args.length !== 0) return ignore(`query ${address} carries arguments ${formatArgs(args)}`);
return sdQuery(address.slice(0, -2), address);
}
return sdSet(address, args);
}
function sdQuery(base, original) {
if (base === '/Console/Name') return void send(base, [SD_CONSOLE_NAME]);
if (base === '/Console/Input_Channels') return void send(base, [int(opts.channels)]);
if (base === '/Console/Aux_Outputs') return void send(base, [int(opts.auxes)]);
if (base === '/Console/Aux_Outputs/modes') {
return void send(base, SD_AUX_MODES.slice(0, opts.auxes).map((mode) => int(mode)));
}
const fixed = /^\/Console\/(\w+)$/.exec(base);
if (fixed && Object.hasOwn(SD_FIXED_COUNTS, fixed[1])) return void send(base, [int(SD_FIXED_COUNTS[fixed[1]])]);
if (base === '/Snapshots/Current_Snapshot') return void send(base, [int(sdSnapshot)]);
if (base === '/Snapshots/names') {
startPaced('snapshot names', SD_SNAPSHOTS.map((name, index) => [
'/Snapshots/name', [int(index), int((index + 1) * 100), int(0), name],
]));
return undefined;
}
const r = sdResolve(base);
if (!r) return ignore(`no reply for ${original} (the real console is silent here)`);
send(base, [toArg(SD_PROPS[r.prop], r.target[r.prop])]);
return undefined;
}
function sdSet(address, args) {
const r = sdResolve(address);
if (!r) return ignore(`no reply for ${address} (unknown or unsupported address)`);
const spec = SD_PROPS[r.prop];
if (spec.readOnly) return ignore(`${address} is read-only`);
if (args.length !== 1) return ignore(`${address} needs exactly one argument (got ${formatArgs(args)})`);
const value = coerce(spec, args[0]);
if (value === undefined) return ignore(`unusable value ${formatArgs(args)} for ${address}`);
setAndEmit(r.target, r.prop, spec, address, value);
return undefined;
}
function liveTickSd() {
const n = 1 + Math.floor(Math.random() * opts.channels);
const input = sdInputs[n];
if (opts.auxes === 0) {
setAndEmit(input, 'fader', SD_PROPS.fader, `/Input_Channels/${n}/fader`, tidy16(-40 + Math.random() * 50));
return;
}
const s = 1 + Math.floor(Math.random() * opts.auxes);
const snd = input.sends[s];
if (Math.random() < 0.5) {
setAndEmit(snd, 'level', SD_PROPS.level, `/Input_Channels/${n}/Aux_Send/${s}/send_level`, tidy16(-60 + Math.random() * 60));
} else {
setAndEmit(snd, 'on', SD_PROPS.on, `/Input_Channels/${n}/Aux_Send/${s}/send_on`, snd.on ? 0 : 1);
}
}
// ---------------------------------------------------------------------------
// Socket wiring and lifecycle
// ---------------------------------------------------------------------------
const handle = isSd ? handleSd : handleSseries;
const liveTick = isSd ? liveTickSd : liveTickSseries;
socket.on('message', (msg, rinfo) => {
stats.received++;
const packet = decode(msg);
if (packet.raw !== undefined) {
stats.undecodable++;
trace(`<- (undecodable, ${msg.length} bytes) ${packet.raw.slice(0, 96)} from ${rinfo.address}:${rinfo.port}`);
} else {
trace(`<- ${describe(packet.address, packet.args, packet.types)} from ${rinfo.address}:${rinfo.port}`);
if (packet.unpadded) trace('[mock-s31] note: that packet was not padded to 4 bytes');
}
// No fixed destination configured: remember the first sender's IP (port 9000).
// Like the real console, the sender's source port is never used for replies.
if (!replyTarget) {
replyTarget = { host: rinfo.address, port: DEFAULT_REPLY_PORT };
note(`no --reply-to given; locking feedback to ${replyTarget.host}:${replyTarget.port} (first sender was ${rinfo.address}:${rinfo.port})`);
}
if (packet.raw === undefined) handle(packet.address, packet.args);
});
socket.on('error', (err) => {
console.error(`[mock-s31] socket error: ${err.code === 'EADDRINUSE' ? `UDP port ${opts.listen} is already in use` : err.message}`);
process.exit(1);
});
socket.on('listening', () => {
const addr = socket.address();
const flavour = isSd
? 'mute/send_on reported as floats 0/1'
: `booleans as ${opts.boolInt ? 'int 0/1' : 'OSC T/F'}`;
note(`listening on udp://${addr.address}:${addr.port} (dialect ${opts.dialect}: ${opts.channels} inputs, ${opts.auxes} auxes, ${flavour})`);
note(replyTarget
? `feedback goes to ${replyTarget.host}:${replyTarget.port}`
: `feedback target not set; will lock onto the first sender's IP, port ${DEFAULT_REPLY_PORT}`);
if (isSd && opts.boolInt) note('--bool-int has no effect in the sd dialect (the console reports mute / send_on as floats)');
if (opts.live) {
note(`live mode: changing a random send every ${LIVE_INTERVAL_MS / 1000}s`);
setInterval(liveTick, LIVE_INTERVAL_MS);
}
});
function shutdown(signal) {
if (dumpTimer) clearTimeout(dumpTimer);
note(`${signal}: received ${stats.received} packets (${stats.undecodable} undecodable), sent ${stats.sent}, ignored ${stats.ignored}`);
try {
socket.close();
} catch {
// already closed
}
process.exit(0);
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
if (isSd) buildStateSd();
else buildStateSseries();
socket.bind(opts.listen);
+284
View File
@@ -0,0 +1,284 @@
'use strict';
/**
* osc-lite.js - a deliberately tiny OSC 1.0 encoder/decoder (no dependencies).
*
* Supported type tags: i (int32), f (float32), s (string), T, F, N.
* T/F are represented as JS booleans, N as null.
*
* Exports:
* encode(address, args) -> Buffer
* decode(buffer) -> { address, args, types, unpadded } | { raw }
* formatArgs(args, types)-> printable "[a, b]" string for log lines
* typeTags(args) -> type-tag string (no comma) encode() would use, e.g. "ff"
* int(n), float(n) -> explicitly typed argument wrappers for encode()
*
* Encoding arguments
* A plain JS value is typed automatically:
* boolean -> T/F, string -> s, null/undefined -> N,
* integer number (int32 range) -> i, any other number -> f.
* Because JS cannot tell 1 from 1.0, use float(1) when a float must go on
* the wire even though its value happens to be whole (e.g. a fader at -10 dB).
*
* Decoding is tolerant and never throws:
* - A well-formed (4-byte padded) message decodes with unpadded:false.
* - A message whose strings are NUL-terminated but NOT padded to 4 bytes, or
* whose last string is missing its final padding, still decodes, with
* unpadded:true.
* - Anything else (not an OSC message, truncated numbers, bundles, tags we do
* not support) comes back as { raw: '<hex>' }.
*/
const INT32_MIN = -2147483648;
const INT32_MAX = 2147483647;
/** Round n up to the next multiple of 4. */
const pad4 = (n) => (n + 3) & ~3;
/** Explicitly typed argument wrappers for encode(). */
const int = (value) => ({ type: 'i', value });
const float = (value) => ({ type: 'f', value });
const isTyped = (a) => a !== null && typeof a === 'object' && typeof a.type === 'string';
// ---------------------------------------------------------------------------
// Encoding
// ---------------------------------------------------------------------------
/** NUL-terminated string padded with NULs to a multiple of 4 bytes. */
function oscString(text) {
const body = Buffer.from(String(text), 'utf8');
const out = Buffer.alloc(pad4(body.length + 1)); // zero-filled: terminator + padding
body.copy(out);
return out;
}
/** Turn any accepted argument form into { type, value? }. */
function normalize(arg) {
if (isTyped(arg)) {
switch (arg.type) {
case 'i':
case 'f':
case 's':
return { type: arg.type, value: arg.value };
case 'T':
case 'F':
case 'N':
return { type: arg.type };
default:
throw new TypeError(`unsupported OSC type tag '${arg.type}'`);
}
}
if (arg === null || arg === undefined) return { type: 'N' };
switch (typeof arg) {
case 'boolean':
return { type: arg ? 'T' : 'F' };
case 'string':
return { type: 's', value: arg };
case 'number':
if (Number.isInteger(arg) && arg >= INT32_MIN && arg <= INT32_MAX) return { type: 'i', value: arg };
return { type: 'f', value: arg };
default:
throw new TypeError(`cannot encode a ${typeof arg} as an OSC argument`);
}
}
/** Payload bytes for one normalised argument (T/F/N carry none). */
function argData(arg) {
switch (arg.type) {
case 'i': {
const v = arg.value;
if (!Number.isInteger(v) || v < INT32_MIN || v > INT32_MAX) {
throw new RangeError(`${v} does not fit an OSC int32`);
}
const b = Buffer.alloc(4);
b.writeInt32BE(v, 0);
return b;
}
case 'f': {
const b = Buffer.alloc(4);
b.writeFloatBE(Number(arg.value), 0);
return b;
}
case 's':
return oscString(arg.value);
default:
return null;
}
}
/**
* Encode one OSC message.
* @param {string} address must start with '/'
* @param {Array} [args] plain values and/or int()/float() wrappers
* @returns {Buffer}
*/
function encode(address, args = []) {
if (typeof address !== 'string' || address[0] !== '/') {
throw new TypeError(`OSC address must be a string starting with '/' (got ${JSON.stringify(address)})`);
}
const list = (Array.isArray(args) ? args : [args]).map(normalize);
const parts = [oscString(address), oscString(',' + list.map((a) => a.type).join(''))];
for (const arg of list) {
const data = argData(arg);
if (data) parts.push(data);
}
return Buffer.concat(parts);
}
// ---------------------------------------------------------------------------
// Decoding
// ---------------------------------------------------------------------------
/**
* One parsing attempt.
* @param {Buffer} buf
* @param {boolean} padded strings occupy a multiple of 4 bytes (standard OSC)
* @param {boolean} strict require well-formed padding and exact consumption
* @returns {object|null} null when this interpretation does not fit
*/
function parseMessage(buf, padded, strict) {
let off = 0;
let short = false; // a string's terminator/padding ran past the end of the buffer
// Read one NUL-terminated string, or null when it is malformed.
const readString = () => {
if (off >= buf.length) return null;
const nul = buf.indexOf(0, off);
if (nul === -1) {
if (strict) return null;
const rest = buf.toString('utf8', off);
off = buf.length;
short = true;
return rest;
}
const text = buf.toString('utf8', off, nul);
const next = padded ? off + pad4(nul - off + 1) : nul + 1;
if (next > buf.length) {
if (strict) return null;
short = true;
off = buf.length;
} else {
if (strict) {
for (let k = nul + 1; k < next; k++) if (buf[k] !== 0) return null;
}
off = next;
}
return text;
};
const address = readString();
if (address === null || address[0] !== '/') return null;
const args = [];
let tags = '';
// A packet may legitimately end after the address (pre-1.0 style, no type tags).
if (off < buf.length) {
const tagString = readString();
if (tagString === null || tagString[0] !== ',') return null;
tags = tagString.slice(1);
for (const t of tags) {
switch (t) {
case 'i':
if (off + 4 > buf.length) return null;
args.push(buf.readInt32BE(off));
off += 4;
break;
case 'f':
if (off + 4 > buf.length) return null;
args.push(buf.readFloatBE(off));
off += 4;
break;
case 's': {
const s = readString();
if (s === null) return null;
args.push(s);
break;
}
case 'T':
args.push(true);
break;
case 'F':
args.push(false);
break;
case 'N':
args.push(null);
break;
default:
return null; // tag we do not support
}
}
}
if (strict && off !== buf.length) return null;
return { address, args, types: tags, unpadded: short };
}
/**
* Decode one OSC message. Never throws.
* @param {Buffer|Uint8Array} input
* @returns {{address:string,args:Array,types:string,unpadded:boolean}|{raw:string}}
* `types` is the type-tag string without the leading comma (e.g. "if").
*/
function decode(input) {
let buf;
try {
buf = Buffer.isBuffer(input) ? input : Buffer.from(input);
} catch {
return { raw: '' };
}
try {
// 1) standard, correctly padded
const standard = parseMessage(buf, true, true);
if (standard) return standard;
// 2) strings NUL-terminated but not padded to 4 bytes
const compact = parseMessage(buf, false, true);
if (compact) return { ...compact, unpadded: true };
// 3) padded, but last string short of its padding / trailing junk
const lenient = parseMessage(buf, true, false);
if (lenient) return lenient;
} catch {
// fall through to raw
}
return { raw: buf.toString('hex') };
}
// ---------------------------------------------------------------------------
// Logging helper
// ---------------------------------------------------------------------------
/** Format one number: floats trimmed of float32 noise, whole floats shown as "-10.0". */
function formatNumber(v, type) {
if (type === 'i') return String(v);
if (!Number.isFinite(v)) return String(v);
const text = String(Number(v.toPrecision(7)));
return type === 'f' && /^-?\d+$/.test(text) ? `${text}.0` : text;
}
/**
* Printable form of an argument list, e.g. `[-12.5, true, "Kick"]`.
* @param {Array} args plain (decoded) values or int()/float() wrappers
* @param {string} [types] type-tag string from decode(), so whole floats print as -10.0
*/
function formatArgs(args, types) {
const parts = (args || []).map((a, idx) => {
const typed = isTyped(a);
const type = typed ? a.type : types ? types[idx] : undefined;
const value = typed ? a.value : a;
if (type === 'T') return 'true';
if (type === 'F') return 'false';
if (type === 'N' || value === null || value === undefined) return 'nil';
if (typeof value === 'string') return JSON.stringify(value);
if (typeof value === 'boolean') return String(value);
if (typeof value === 'number') return formatNumber(value, type);
return String(value);
});
return `[${parts.join(', ')}]`;
}
/** The type-tag string (without the leading comma) that encode() would produce for args. */
function typeTags(args) {
const list = args === undefined ? [] : Array.isArray(args) ? args : [args];
return list.map(normalize).map((a) => a.type).join('');
}
module.exports = { encode, decode, formatArgs, typeTags, int, float };
+6
View File
@@ -0,0 +1,6 @@
{
"name": "digico-s31-dev-tools",
"private": true,
"description": "Developer tools are CommonJS even though the parent module package is ESM",
"type": "commonjs"
}
+938
View File
@@ -0,0 +1,938 @@
#!/usr/bin/env node
'use strict';
/**
* probe-catalog.js - build a catalog of what a DiGiCo S31 answers over its iPad
* OSC extension, using READ-ONLY queries only.
*
* SAFETY (live console):
* - Every message sent is an OSC address ending in "/?" with NO arguments.
* assertSafeQuery() refuses anything else, and the encoded packet is decoded
* again and checked to carry zero arguments before it is sent.
* - Lowercase S-Series addresses (/console/...) are refused.
* - One socket, sends at least 60 ms apart (INTERVAL_MS = 65), no retries.
* - Write-only action nodes from the 2014 list (Save session, Fire snapshot,
* Macro press) are NOT queried unless --include-actions is passed.
* - Every HEARTBEAT_EVERY queries a /Console/Name/? heartbeat is sent; three
* missed heartbeats in a row stop the run (results so far are still written).
*
* Network path: queries go to the Companion relay UDP 10.3.11.152:8000, which
* forwards to the console; replies are fanned out to this machine on UDP 9000.
*
* Usage:
* node tools/probe-catalog.js [--dry-run] [--include-actions] [--test-local]
* [--raw-log FILE] [--tail-seconds 15]
*
* --test-local sends to a mock-s31.js on 127.0.0.1:8000 instead (tool testing only).
*
* Outputs (relative to this file): catalog/s31-catalog.json, catalog/s31-catalog.md
*/
const dgram = require('node:dgram');
const fs = require('node:fs');
const path = require('node:path');
const { encode, decode } = require('./osc-lite');
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const INTERVAL_MS = 65; // spec: >= 60 ms between sends
const MIN_GAP_MS = 60;
const WINDOW_MS = 400; // reply must arrive within this long after its query
const CONNECT_TIMEOUT_MS = 2000;
const HEARTBEAT_EVERY = 120;
const HEARTBEAT_MAX_MISSES = 3;
const MAX_STORED_REPLIES = 300;
const HERE = __dirname;
const LIST_FILE = path.join(HERE, 'sd-osc-list-2014.txt');
const OUT_DIR = path.join(HERE, 'catalog');
const argv = process.argv.slice(2);
const flag = (name) => argv.includes(name);
const opt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : dflt;
};
// --test-local points the probe at a mock-s31.js on 127.0.0.1 (for testing the tool only).
const TEST_LOCAL = flag('--test-local');
const RELAY_HOST = TEST_LOCAL ? '127.0.0.1' : '10.3.11.152';
const RELAY_PORT = 8000;
const LISTEN_PORT = 9000;
const DRY_RUN = flag('--dry-run');
const INCLUDE_ACTIONS = flag('--include-actions');
const RAW_LOG = opt('--raw-log', null);
const TAIL_SECONDS = Number(opt('--tail-seconds', '15'));
/** Max index per entity on this console, and which instances to probe. */
const ENTITIES = {
Input_Channels: { max: 60, probe: [1, 2] },
Aux_Outputs: { max: 25, probe: [1, 2] },
Group_Outputs: { max: 25, probe: [1, 2] },
Matrix_Outputs: { max: 8, probe: [1, 2] },
Matrix_Inputs: { max: 10, probe: [1, 2] },
Control_Groups: { max: 10, probe: [1, 2] },
Graphic_EQ: { max: 16, probe: [1, 2] },
Multis: { max: 0, probe: [1] },
};
/** Sub-index probes for the second wildcard after these segments. */
const SUB_PROBE = { Aux_Send: [1, 24], Group_Send: [1, 24], Matrix_Send: [1] };
/** Write-only trigger nodes in the 2014 list: skipped unless --include-actions. */
const ACTION_NODES = new Set([
'/Filing/Save_current_Session',
'/Snapshots/Fire_Snapshot_number',
'/Snapshots/Fire_Prev_Snapshot',
'/Snapshots/Fire_Next_Snapshot',
'/Macros/Buttons/press',
]);
/** Candidate name addresses per entity, in preference order ('#' = index). */
const NAME_CANDIDATES = {
Input_Channels: ['/Input_Channels/#/Channel_Input/name'],
Aux_Outputs: ['/Aux_Outputs/#/Buss_Trim/name'],
Group_Outputs: ['/Group_Outputs/#/Buss_Trim/name'],
Matrix_Outputs: ['/Matrix_Outputs/#/Buss_Trim/name', '/Matrix_Outputs/#/name'],
Matrix_Inputs: ['/Matrix_Inputs/#/Buss_Trim/name', '/Matrix_Inputs/#/name'],
Control_Groups: ['/Control_Groups/#/name', '/Control_Groups/#/Buss_Trim/name'],
Graphic_EQ: ['/Graphic_EQ/#/name', '/Graphic_EQ/#/Buss_Trim/name'],
};
/** Extra candidates not in the 2014 list (base addresses; "/?" is appended). */
const EXTRA_ADDRESSES = [
'/Console/Name', '/Console/Channels', '/Console/Input_Channels', '/Console/Aux_Outputs',
'/Console/Group_Outputs', '/Console/Matrix_Outputs', '/Console/Matrix_Inputs',
'/Console/Control_Groups', '/Console/Graphic_EQ', '/Console/Multis', '/Console/Talkback_Outputs',
'/Console/Aux_Outputs/modes', '/Console/Group_Outputs/modes', '/Console/Matrix_Outputs/modes',
'/Console/Version', '/Console/Session', '/Console/Session/name', '/Console/Sample_Rate',
'/Console/Meters', '/Console/Meter', '/Console/Metering', '/Console/Solo', '/Console/Solo_Busses',
'/Console/Talkback', '/Console/Oscillator', '/Console/FX',
'/Snapshots/Current_Snapshot', '/Snapshots/names', '/Snapshots/name', '/Snapshots/count',
'/Snapshots/Total',
'/Macros/Buttons', '/Macros/names', '/Macros/count', '/Macros/1/name', '/Macros/Buttons/1/name',
'/Macros/Buttons/state',
'/Talkback_Outputs/1/name', '/Solo/1/level', '/Solo_Busses/1/fader',
'/Input_Channels/1/meter', '/Input_Channels/1/Meter', '/Input_Channels/1/meters',
'/Input_Channels/1/Channel_Input/meter', '/Input_Channels/1/Channel_Input/input_meter',
'/Input_Channels/1/Channel_Output/meter', '/Input_Channels/1/Channel_Input/signal',
'/Input_Channels/1/Channel_Input/source', '/Input_Channels/1/Channel_Input/route',
'/Input_Channels/1/Channel_Input/input_route', '/Input_Channels/1/Channel_Input/socket',
'/Input_Channels/1/Channel_Input/mode', '/Input_Channels/1/Channel_Input/stereo',
'/Input_Channels/1/stereo', '/Input_Channels/1/colour', '/Input_Channels/1/color',
'/Input_Channels/1/Channel_Input/colour', '/Input_Channels/1/Aux_Send/24/send_pre-post',
'/Input_Channels/1/Group_Send/24/group', '/Input_Channels/1/Direct_Output/send_level',
'/Input_Channels/1/Direct_Output/send_on', '/Input_Channels/1/Channel_Output/direct_out',
'/Aux_Outputs/1/Buss_Trim/name', '/Aux_Outputs/1/Buss_Trim/colour', '/Aux_Outputs/1/mode',
'/Aux_Outputs/1/stereo', '/Aux_Outputs/1/meter',
'/Group_Outputs/1/Buss_Trim/name', '/Matrix_Outputs/1/Buss_Trim/name', '/Matrix_Outputs/1/name',
'/Matrix_Inputs/1/name', '/Matrix_Inputs/1/Buss_Trim/name',
'/Control_Groups/1/name', '/Control_Groups/1/Buss_Trim/name', '/Control_Groups/1/fader',
'/Control_Groups/1/mute', '/Control_Groups/1/solo',
'/Graphic_EQ/1/name', '/Graphic_EQ/1/Buss_Trim/name', '/Graphic_EQ/1/on',
'/Graphic_EQ/1/Bands/1/gain', '/Graphic_EQ/1/gain/1',
'/FX/1/name', '/Multis/1/name',
'/Talkback/level', '/Talkback/on', '/Oscillator/on', '/Oscillator/level', '/Oscillator/freq',
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const T0 = process.hrtime.bigint();
const now = () => Number(process.hrtime.bigint() - T0) / 1e6;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Replace the first all-digit path segment with N and the second with M. */
function patternOf(address) {
let count = 0;
return address
.split('/')
.map((s) => (/^\d+$/.test(s) ? (count++ === 0 ? 'N' : 'M') : s))
.join('/');
}
/** Numeric path segments of an address as { n, m } (null when absent). */
function instanceOf(address) {
const nums = address.split('/').filter((s) => /^\d+$/.test(s)).map(Number);
return { n: nums.length > 0 ? nums[0] : null, m: nums.length > 1 ? nums[1] : null };
}
const treeOf = (address) => address.split('/')[1] || '';
const round6 = (v) => (typeof v === 'number' && Number.isFinite(v) ? Number(v.toPrecision(6)) : v);
/** Refuse anything that is not a plain read-only query. Throws on violation. */
function assertSafeQuery(address) {
if (typeof address !== 'string') throw new Error('query address must be a string');
if (!address.endsWith('/?')) throw new Error(`refusing non-query address (no trailing /?): ${address}`);
if (!/^\/[A-Z][A-Za-z0-9_\-/]*\/\?$/.test(address)) {
throw new Error(`refusing address (must start with /<Capital>, only [A-Za-z0-9_-/]): ${address}`);
}
if (address.includes('//') || address.includes('*')) throw new Error(`refusing malformed address: ${address}`);
if (address.startsWith('/console/')) throw new Error(`refusing S-Series address: ${address}`);
const base = address.slice(0, -2);
if (ACTION_NODES.has(base) && !INCLUDE_ACTIONS) throw new Error(`refusing action node: ${address}`);
// The encoded packet must decode back to the same address with zero arguments.
const buf = encode(address, []);
const back = decode(buf);
if (back.address !== address || !Array.isArray(back.args) || back.args.length !== 0 || back.types !== '') {
throw new Error(`encoded packet is not an argument-less query: ${address}`);
}
return buf;
}
// ---------------------------------------------------------------------------
// Parse the 2014 command list
// ---------------------------------------------------------------------------
function parseDocList(text) {
const rows = [];
const skipped = [];
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (!line.startsWith('/sd/')) continue;
// Example lines carry arguments ("/sd/Input_Channels/1/mute, i,1 - ..."): not table rows.
if (/^\/sd\/\S*,\s/.test(line)) {
skipped.push(line);
continue;
}
const tokens = line.split(/\s+/);
const ti = tokens.findIndex((t) => /^(Int|Float|String)$/i.test(t));
if (ti < 0) {
skipped.push(line);
continue;
}
const docPath = tokens.slice(0, ti).join('').replace(/\/+$/, ''); // "/sd/Entity/*/Sub/param"
const addr = docPath.replace(/^\/sd/, '');
const type = tokens[ti][0].toUpperCase() + tokens[ti].slice(1).toLowerCase();
const min = tokens[ti + 1] !== undefined ? Number(tokens[ti + 1]) : null;
const max = tokens[ti + 2] !== undefined ? Number(tokens[ti + 2]) : null;
rows.push({
template: addr,
documentedType: type,
documentedRange: min !== null && max !== null ? { min, max } : null,
});
}
return { rows, skipped };
}
/** Expand "*" wildcards of one row into concrete addresses (without "/?"). */
function expandRow(row) {
const segs = row.template.split('/'); // ['', 'Input_Channels', '*', ...]
const entity = segs[1];
const star = segs.reduce((acc, s, i) => (s === '*' ? [...acc, i] : acc), []);
if (star.length === 0) return [row.template];
const ent = ENTITIES[entity];
const entIdx = ent ? ent.probe : [1];
let out = [];
for (const n of entIdx) {
const segsN = segs.slice();
segsN[star[0]] = String(n);
if (star.length > 1) {
const subName = segs[star[1] - 1];
const subs = SUB_PROBE[subName] || [1];
for (const m of subs) {
const segsM = segsN.slice();
segsM[star[1]] = String(m);
out.push(segsM.join('/'));
}
} else {
out.push(segsN.join('/'));
}
}
return out;
}
// ---------------------------------------------------------------------------
// Build the query list
// ---------------------------------------------------------------------------
/** entries: Map base-address -> entry (insertion order = send order). */
function buildEntries() {
const entries = new Map();
const sourceCounts = { extra: 0, doc: 0, actionSkipped: [] };
const ensure = (base, source) => {
let e = entries.get(base);
if (!e) {
e = {
address: base,
query: `${base}/?`,
pattern: patternOf(base),
tree: treeOf(base),
inst: instanceOf(base),
sources: [],
documentedType: null,
documentedRange: null,
skipped: false,
};
entries.set(base, e);
}
if (!e.sources.includes(source)) e.sources.push(source);
return e;
};
for (const base of EXTRA_ADDRESSES) {
ensure(base, 'extra');
sourceCounts.extra++;
}
const text = fs.readFileSync(LIST_FILE, 'utf8');
const { rows, skipped } = parseDocList(text);
for (const row of rows) {
for (const base of expandRow(row)) {
const e = ensure(base, 'doc');
e.documentedType = row.documentedType;
e.documentedRange = row.documentedRange;
if (ACTION_NODES.has(base) && !INCLUDE_ACTIONS) {
e.skipped = true;
e.skipReason = 'write-only action node in the 2014 list (trigger); not queried by default';
sourceCounts.actionSkipped.push(base);
}
sourceCounts.doc++;
}
}
return { entries, rows, skippedLines: skipped, sourceCounts };
}
// ---------------------------------------------------------------------------
// Probe run
// ---------------------------------------------------------------------------
async function main() {
const { entries, rows, skippedLines, sourceCounts } = buildEntries();
const toSend = [...entries.values()].filter((e) => !e.skipped);
// Validate every query up front (throws before any packet leaves the machine).
for (const e of toSend) assertSafeQuery(e.query);
assertSafeQuery('/Console/Name/?');
const docPatterns = new Set(rows.map((r) => patternOf(r.template)));
console.log(
`list: ${rows.length} doc rows (${skippedLines.length} example lines ignored), ` +
`${docPatterns.size} distinct patterns, ${sourceCounts.extra} extra candidates`
);
console.log(`planned phase-1 queries: ${toSend.length} (+ names phase, + heartbeats)`);
if (sourceCounts.actionSkipped.length) {
console.log(`skipped action nodes: ${[...new Set(sourceCounts.actionSkipped)].join(', ')}`);
}
if (DRY_RUN) {
const perTree = {};
for (const e of toSend) perTree[e.tree] = (perTree[e.tree] || 0) + 1;
console.log('queries per tree:', JSON.stringify(perTree));
console.log('dry run only; nothing sent');
return;
}
const sock = dgram.createSocket({ type: 'udp4', reuseAddr: false });
const sentLog = []; // every query sent, in order
const packets = []; // every packet received
const unsolicited = [];
const sendErrors = [];
let lastSend = -1e9;
let minGap = Infinity;
let sentCount = 0;
let currentPhase = 'connect';
const sourcesSeen = new Map();
const parseFailures = [];
sock.on('error', (err) => {
console.error(`socket error: ${err.message}`);
process.exit(3);
});
sock.on('message', (msg, rinfo) => {
const t = now();
const key = `${rinfo.address}:${rinfo.port}`;
sourcesSeen.set(key, (sourcesSeen.get(key) || 0) + 1);
const d = decode(msg);
const pkt = { tMs: round6(t), from: key, bytes: msg.length, phase: currentPhase };
if (d.raw !== undefined) {
pkt.raw = d.raw;
packets.push(pkt);
parseFailures.push(pkt);
unsolicited.push({ ...pkt, reason: 'unparsed' });
return;
}
pkt.address = d.address;
pkt.types = d.types;
pkt.args = d.args.map(round6);
pkt.unpadded = d.unpadded;
// Match: newest query with the same reply address inside the window.
let match = null;
let kind = null;
for (let i = sentLog.length - 1; i >= 0; i--) {
const q = sentLog[i];
if (t - q.sentAt > WINDOW_MS) break;
if (q.replyAddress === d.address) {
match = q;
kind = 'exact';
break;
}
}
if (!match) {
for (let i = sentLog.length - 1; i >= 0; i--) {
const q = sentLog[i];
if (t - q.sentAt > WINDOW_MS) break;
if (q.replyAddress.startsWith(d.address) || d.address.startsWith(q.replyAddress)) {
match = q;
kind = 'related';
break;
}
}
}
if (match) {
pkt.matched = match.query;
pkt.matchKind = kind;
pkt.latencyMs = round6(t - match.sentAt);
const e = match.entry;
if (e.replies.length < MAX_STORED_REPLIES) {
e.replies.push({
address: d.address,
types: d.types,
args: d.args.map(round6),
latencyMs: pkt.latencyMs,
match: kind,
unpadded: d.unpadded,
});
}
e.replyCount++;
if (e.firstLatencyMs === null) e.firstLatencyMs = pkt.latencyMs;
match.replied = true;
} else {
// Unsolicited: tag how long after the newest same-address query it arrived (late reply?).
let after = null;
for (let i = sentLog.length - 1; i >= 0; i--) {
if (sentLog[i].replyAddress === d.address) {
after = round6(t - sentLog[i].sentAt);
break;
}
}
unsolicited.push({ ...pkt, reason: after === null ? 'no-query-for-address' : 'outside-window', afterQueryMs: after });
}
packets.push(pkt);
});
await new Promise((resolve, reject) => {
sock.once('error', reject);
sock.bind({ port: LISTEN_PORT, address: '0.0.0.0', exclusive: true }, () => {
sock.removeListener('error', reject);
resolve();
});
});
console.log(`listening on udp4 0.0.0.0:${LISTEN_PORT}; queries go to ${RELAY_HOST}:${RELAY_PORT}`);
/** Send one query (paced) and register it. */
async function send(entry, tag) {
const buf = assertSafeQuery(entry.query);
const wait = lastSend + INTERVAL_MS - now();
if (wait > 0) await sleep(wait);
const t = now();
if (sentCount > 0) minGap = Math.min(minGap, t - lastSend);
lastSend = t;
const rec = {
query: entry.query,
replyAddress: entry.address,
sentAt: t,
entry,
tag: tag || 'probe',
replied: false,
};
sentLog.push(rec);
entry.sentCount = (entry.sentCount || 0) + 1;
sentCount++;
sock.send(buf, RELAY_PORT, RELAY_HOST, (err) => {
if (err) sendErrors.push({ query: entry.query, error: err.message });
});
return rec;
}
const newEntryState = (e) => {
e.replies = [];
e.replyCount = 0;
e.firstLatencyMs = null;
return e;
};
for (const e of entries.values()) newEntryState(e);
// ---- connectivity check -------------------------------------------------
const nameEntry = entries.get('/Console/Name');
const connRec = await send(nameEntry, 'connectivity');
const connDeadline = connRec.sentAt + CONNECT_TIMEOUT_MS;
while (now() < connDeadline && !(nameEntry.replyCount > 0)) await sleep(10);
// The match window is 400 ms; a reply between 400 ms and 2 s is still accepted for this check.
const connUnsolicited = unsolicited.find((u) => u.address === '/Console/Name');
const connectivityOk = nameEntry.replyCount > 0 || !!connUnsolicited;
const connReply = nameEntry.replies[0] || connUnsolicited || null;
if (!connectivityOk) {
console.error('CONNECTIVITY CHECK FAILED: no /Console/Name reply within 2 s from the relay. Stopping (nothing else sent).');
console.error(`packets received during the wait: ${packets.length}`);
sock.close();
process.exitCode = 2;
return;
}
const connName = connReply && (connReply.args || [])[0];
console.log(`connectivity OK: /Console/Name -> ${JSON.stringify(connName)} (types ${connReply && connReply.types})`);
if (!nameEntry.replyCount && connUnsolicited) {
// Late (>400 ms) but within 2 s: keep as an unsolicited packet and record that it was slow.
console.log(`note: the first reply was slow (${connUnsolicited.afterQueryMs} ms), outside the 400 ms window`);
}
// ---- phase 1 + names ----------------------------------------------------
const heartbeats = [];
let misses = 0;
let aborted = null;
let sinceHeartbeat = 0;
async function maybeHeartbeat() {
if (sinceHeartbeat < HEARTBEAT_EVERY) return true;
sinceHeartbeat = 0;
const hbEntry = { address: '/Console/Name', query: '/Console/Name/?', replies: [], replyCount: 0, firstLatencyMs: null };
const rec = await send(hbEntry, 'heartbeat');
await sleep(WINDOW_MS + 20);
const ok = rec.replied;
heartbeats.push({ tMs: round6(rec.sentAt), ok, latencyMs: hbEntry.firstLatencyMs });
misses = ok ? 0 : misses + 1;
if (!ok) console.log(` heartbeat MISSED at ${Math.round(rec.sentAt)} ms (${misses}/${HEARTBEAT_MAX_MISSES})`);
if (misses >= HEARTBEAT_MAX_MISSES) {
aborted = `console stopped answering the heartbeat ${misses} times in a row at ${Math.round(rec.sentAt)} ms`;
return false;
}
return true;
}
currentPhase = 'probe';
const startProbe = now();
let done = 0;
const phase1 = toSend.filter((e) => e !== nameEntry);
for (const e of phase1) {
if (!(await maybeHeartbeat())) break;
await send(e);
sinceHeartbeat++;
done++;
if (done % 100 === 0) {
const answered = phase1.slice(0, done).filter((x) => x.replyCount > 0).length;
console.log(` ${done}/${phase1.length} sent, ${answered} answered so far (${Math.round((now() - startProbe) / 1000)} s)`);
}
}
// ---- names phase ----------------------------------------------------------
const nameTables = {};
if (!aborted) {
await sleep(WINDOW_MS + 20);
currentPhase = 'names';
const nameQueries = [];
for (const [entity, cands] of Object.entries(NAME_CANDIDATES)) {
let chosen = null;
for (const c of cands) {
const probes = ENTITIES[entity].probe.map((n) => entries.get(c.replace('#', String(n)))).filter(Boolean);
if (probes.some((p) => p.replyCount > 0)) {
chosen = c;
break;
}
}
nameTables[entity] = { addressPattern: chosen ? patternOf(chosen.replace('#', '1')) : null, max: ENTITIES[entity].max, values: {}, unanswered: [] };
if (!chosen) continue;
for (let n = 1; n <= ENTITIES[entity].max; n++) {
const base = chosen.replace('#', String(n));
let e = entries.get(base);
if (!e) {
e = newEntryState({
address: base,
query: `${base}/?`,
pattern: patternOf(base),
tree: treeOf(base),
inst: instanceOf(base),
sources: ['names'],
documentedType: null,
documentedRange: null,
skipped: false,
});
for (const doc of [...entries.values()]) {
if (doc.pattern === e.pattern && doc.documentedType) {
e.documentedType = doc.documentedType;
e.documentedRange = doc.documentedRange;
break;
}
}
entries.set(base, e);
nameQueries.push(e);
} else if (!e.sources.includes('names')) {
e.sources.push('names');
}
nameTables[entity].entryFor = nameTables[entity].entryFor || [];
nameTables[entity].entryFor.push(e);
}
}
console.log(`names phase: ${nameQueries.length} new queries`);
for (const e of nameQueries) {
if (!(await maybeHeartbeat())) break;
await send(e);
sinceHeartbeat++;
}
}
// Let the last window close, then listen for unsolicited traffic.
await sleep(WINDOW_MS + 100);
const probeEnd = now();
currentPhase = 'tail';
const tailStart = now();
console.log(`probe finished at ${Math.round(probeEnd / 1000)} s; listening ${TAIL_SECONDS} s for unsolicited traffic`);
await sleep(TAIL_SECONDS * 1000);
const end = now();
sock.close();
// ---- finalize name tables ---------------------------------------------------
for (const [entity, tbl] of Object.entries(nameTables)) {
for (const e of tbl.entryFor || []) {
const r = e.replies.find((x) => x.match === 'exact') || e.replies[0];
if (r && r.args.length > 0) tbl.values[e.inst.n] = r.args[0];
else tbl.unanswered.push(e.inst.n);
}
delete tbl.entryFor;
tbl.answered = Object.keys(tbl.values).length;
}
// ---- build catalog ------------------------------------------------------------
const unsolicitedAddresses = new Set(unsolicited.filter((u) => u.address).map((u) => u.address));
const catalog = [];
for (const e of entries.values()) {
const first = e.replies.find((r) => r.match === 'exact') || e.replies[0] || null;
catalog.push({
address: e.address,
query: e.query,
pattern: e.pattern,
tree: e.tree,
instance: e.inst,
sources: e.sources,
documentedType: e.documentedType,
documentedRange: e.documentedRange,
queried: !e.skipped && (e.sentCount || 0) > 0,
skipped: e.skipped || undefined,
skipReason: e.skipReason,
answered: e.replyCount > 0,
replyAddress: first ? first.address : null,
replyTypes: first ? first.types : null,
exampleValue: first ? (first.args.length === 1 ? first.args[0] : first.args) : null,
replyCount: e.replyCount,
replyMatch: first ? first.match : null,
latencyMs: e.firstLatencyMs,
unsolicited: unsolicitedAddresses.has(e.address),
replies: e.replyCount > 1 || (first && first.address !== e.address) ? e.replies : undefined,
});
}
const queriedAddresses = new Set(catalog.map((c) => c.address));
for (const addr of unsolicitedAddresses) {
if (queriedAddresses.has(addr)) continue;
const u = unsolicited.find((x) => x.address === addr);
catalog.push({
address: addr,
query: null,
pattern: patternOf(addr),
tree: treeOf(addr),
instance: instanceOf(addr),
sources: ['unsolicited'],
documentedType: null,
documentedRange: null,
queried: false,
answered: false,
replyAddress: addr,
replyTypes: u.types,
exampleValue: u.args.length === 1 ? u.args[0] : u.args,
unsolicited: true,
});
}
// ---- summary ----------------------------------------------------------------------
const perTree = {};
for (const c of catalog) {
if (!c.queried && !c.skipped) continue;
const t = (perTree[c.tree] = perTree[c.tree] || { queried: 0, answered: 0, unanswered: 0, skipped: 0 });
if (c.skipped) t.skipped++;
else {
t.queried++;
if (c.answered) t.answered++;
else t.unanswered++;
}
}
const answeredPatterns = new Set(catalog.filter((c) => c.answered).map((c) => c.pattern));
const queriedPatterns = new Set(catalog.filter((c) => c.queried).map((c) => c.pattern));
const summary = {
relay: `${RELAY_HOST}:${RELAY_PORT}`,
listen: `udp4 0.0.0.0:${LISTEN_PORT}`,
consoleName: connName,
startedAt: new Date(Date.now() - end).toISOString(),
durationSeconds: round6(end / 1000),
probeSeconds: round6((probeEnd - startProbe) / 1000),
tailSeconds: round6((end - tailStart) / 1000),
intervalMs: INTERVAL_MS,
minObservedSendGapMs: round6(minGap),
windowMs: WINDOW_MS,
queriesSent: sentCount,
distinctAddressesQueried: catalog.filter((c) => c.queried).length,
addressesAnswered: catalog.filter((c) => c.answered).length,
addressesUnanswered: catalog.filter((c) => c.queried && !c.answered).length,
patternsQueried: queriedPatterns.size,
patternsAnswered: answeredPatterns.size,
packetsReceived: packets.length,
packetsMatched: packets.filter((p) => p.matched).length,
packetsUnsolicited: unsolicited.length,
unsolicitedDuringProbe: unsolicited.filter((u) => u.phase !== 'tail').length,
unsolicitedInTail: unsolicited.filter((u) => u.phase === 'tail').length,
unparsedPackets: parseFailures.length,
unpaddedPackets: packets.filter((p) => p.unpadded).length,
replySources: Object.fromEntries(sourcesSeen),
heartbeats: { total: heartbeats.length, missed: heartbeats.filter((h) => !h.ok).length, detail: heartbeats },
aborted,
sendErrors,
skippedActionNodes: [...new Set(sourceCounts.actionSkipped)],
exampleLinesIgnored: skippedLines.length,
perTree,
};
const out = { summary, names: nameTables, catalog, unsolicited };
fs.mkdirSync(OUT_DIR, { recursive: true });
const jsonPath = path.join(OUT_DIR, 's31-catalog.json');
const oneLine = (list) => list.map((x) => ' ' + JSON.stringify(x)).join(',\n');
const jsonText =
'{\n' +
` "summary": ${JSON.stringify(out.summary, null, 2).replace(/\n/g, '\n ')},\n` +
` "names": ${JSON.stringify(out.names, null, 2).replace(/\n/g, '\n ')},\n` +
` "catalog": [\n${oneLine(out.catalog)}\n ],\n` +
` "unsolicited": [\n${oneLine(out.unsolicited)}\n ]\n}\n`;
JSON.parse(jsonText); // sanity: must be valid JSON
fs.writeFileSync(jsonPath, jsonText);
fs.writeFileSync(path.join(OUT_DIR, 's31-catalog.md'), renderMarkdown(out));
if (RAW_LOG) fs.writeFileSync(RAW_LOG, packets.map((p) => JSON.stringify(p)).join('\n') + '\n');
console.log(
`done in ${Math.round(end / 1000)} s: ${sentCount} queries, ${summary.addressesAnswered}/${summary.distinctAddressesQueried} addresses answered, ` +
`${packets.length} packets (${unsolicited.length} unsolicited), min send gap ${summary.minObservedSendGapMs} ms`
);
if (aborted) console.log(`ABORTED EARLY: ${aborted}`);
console.log(`wrote ${jsonPath}`);
}
// ---------------------------------------------------------------------------
// Markdown report
// ---------------------------------------------------------------------------
const EXPECT = { Int: 'i', Float: 'f', String: 's' };
function fmtValue(v) {
if (Array.isArray(v)) return `[${v.map(fmtValue).join(', ')}]`;
if (typeof v === 'string') return JSON.stringify(v);
return String(v);
}
const esc = (s) => String(s).replace(/\|/g, '\\|');
/** [1,2,3,5] -> "1-3,5" */
function compressList(nums) {
const a = [...new Set(nums)].sort((x, y) => x - y);
const parts = [];
for (let i = 0; i < a.length; ) {
let j = i;
while (j + 1 < a.length && a[j + 1] === a[j] + 1) j++;
parts.push(j - i >= 2 ? `${a[i]}-${a[j]}` : a.slice(i, j + 1).join(','));
i = j + 1;
}
return parts.join(',');
}
const fmtRange = (r) => (r ? `${r.min}..${r.max}` : '');
function renderMarkdown({ summary, names, catalog, unsolicited }) {
const L = [];
const queried = catalog.filter((c) => c.queried);
const answered = queried.filter((c) => c.answered);
// Group by pattern.
const byPattern = new Map();
for (const c of queried) {
let g = byPattern.get(c.pattern);
if (!g) {
g = { pattern: c.pattern, tree: c.tree, documentedType: null, documentedRange: null, items: [] };
byPattern.set(c.pattern, g);
}
if (c.documentedType) {
g.documentedType = c.documentedType;
g.documentedRange = c.documentedRange;
}
g.items.push(c);
}
const groups = [...byPattern.values()];
const answeredGroups = groups.filter((g) => g.items.some((i) => i.answered));
const unansweredGroups = groups.filter((g) => !g.items.some((i) => i.answered));
L.push('# DiGiCo S31 OSC catalog (iPad extension, read-only probe)');
L.push('');
L.push(`Generated by \`tools/probe-catalog.js\` on ${summary.startedAt}. Console: ${fmtValue(summary.consoleName)}. Queries sent to the relay ${summary.relay}; replies on ${summary.listen}.`);
L.push('');
L.push(`- ${summary.queriesSent} queries sent in ${summary.probeSeconds} s (interval ${summary.intervalMs} ms, minimum observed gap ${summary.minObservedSendGapMs} ms), then ${summary.tailSeconds} s of listening.`);
L.push(`- ${summary.addressesAnswered} of ${summary.distinctAddressesQueried} distinct addresses answered (${summary.patternsAnswered} of ${summary.patternsQueried} patterns).`);
L.push(`- ${summary.packetsReceived} packets received: ${summary.packetsMatched} matched to a query, ${summary.packetsUnsolicited} unsolicited (${summary.unsolicitedDuringProbe} during probing, ${summary.unsolicitedInTail} in the final listen).`);
L.push(`- Heartbeats (/Console/Name/? every ${HEARTBEAT_EVERY} queries): ${summary.heartbeats.total} sent, ${summary.heartbeats.missed} missed.${summary.aborted ? ` **Run aborted early: ${summary.aborted}.**` : ''}`);
if (summary.skippedActionNodes.length) {
L.push(`- Not queried (write-only trigger nodes in the 2014 list): ${summary.skippedActionNodes.map((a) => `\`${a}\``).join(', ')}.`);
}
L.push('');
L.push('Pattern notation: N = first numeric index (channel / buss / entity), M = second numeric index (send buss, band). "Documented" is the 2014 DiGiCo SD OSC list type and OSC min/max.');
L.push('');
// Per-tree counts.
L.push('## Counts per tree');
L.push('');
L.push('| Tree | Addresses queried | Answered | Unanswered | Skipped |');
L.push('| --- | ---: | ---: | ---: | ---: |');
for (const [tree, t] of Object.entries(summary.perTree)) {
L.push(`| ${tree} | ${t.queried} | ${t.answered} | ${t.unanswered} | ${t.skipped || ''} |`);
}
L.push('');
// Surprises.
L.push('## Surprising / noteworthy');
L.push('');
const surprises = [];
const matrix = {};
const mismatchByLeaf = {};
for (const c of answered) {
if (!c.documentedType || !c.replyTypes) continue;
const key = `${c.documentedType} -> ${c.replyTypes || '(none)'}`;
matrix[key] = (matrix[key] || 0) + 1;
if (EXPECT[c.documentedType] !== c.replyTypes) {
const leaf = c.pattern;
(mismatchByLeaf[key] = mismatchByLeaf[key] || new Set()).add(leaf);
}
}
if (Object.keys(matrix).length) {
surprises.push('Documented type -> type tag actually received (answered addresses with a documented type):');
for (const [k, n] of Object.entries(matrix).sort()) surprises.push(` - ${k}: ${n} addresses${k.split(' -> ')[0] && EXPECT[k.split(' -> ')[0]] !== k.split(' -> ')[1] ? ' (MISMATCH)' : ''}`);
}
for (const [key, set] of Object.entries(mismatchByLeaf)) {
const list = [...set].sort();
surprises.push(`Mismatch ${key}: ${list.length} pattern(s)${list.length <= 40 ? ': ' + list.map((p) => '`' + p + '`').join(', ') : ' (see JSON)'}`);
}
// Observed numeric values outside the documented range.
const outOfRange = [];
for (const g of answeredGroups) {
if (!g.documentedRange) continue;
const vals = g.items.filter((i) => i.answered && typeof i.exampleValue === 'number').map((i) => i.exampleValue);
if (vals.some((v) => v < g.documentedRange.min || v > g.documentedRange.max)) outOfRange.push(`\`${g.pattern}\` (documented ${fmtRange(g.documentedRange)}, saw ${[...new Set(vals)].join(', ')})`);
}
if (outOfRange.length) surprises.push(`Values outside the documented OSC range (${outOfRange.length}): ${outOfRange.slice(0, 40).join('; ')}${outOfRange.length > 40 ? '; ...' : ''}`);
// Partial answers.
const partial = answeredGroups.filter((g) => g.items.some((i) => !i.answered)).map((g) => `\`${g.pattern}\` (${g.items.filter((i) => i.answered).length}/${g.items.length})`);
if (partial.length) surprises.push(`Patterns answered for some instances but not all probed instances (${partial.length}): ${partial.slice(0, 60).join(', ')}${partial.length > 60 ? ', ...' : ''}`);
// Multi-reply / different reply address.
const multi = answered.filter((c) => c.replyCount > 1);
if (multi.length) surprises.push(`Queries that produced more than one reply message (${multi.length}): ${multi.slice(0, 20).map((c) => `\`${c.query}\` x${c.replyCount}`).join(', ')}`);
const diffAddr = answered.filter((c) => c.replyAddress !== c.address);
if (diffAddr.length) surprises.push(`Replies on a different address than the query (${diffAddr.length}): ${diffAddr.slice(0, 20).map((c) => `\`${c.query}\` -> \`${c.replyAddress}\``).join(', ')}`);
if (summary.unpaddedPackets) surprises.push(`${summary.unpaddedPackets} received packets were not 4-byte padded OSC (osc-lite decoded them leniently).`);
if (summary.unparsedPackets) surprises.push(`${summary.unparsedPackets} received packets could not be decoded (raw hex in JSON "unsolicited" entries).`);
const lateish = unsolicited.filter((u) => u.reason === 'outside-window' && u.afterQueryMs !== null && u.afterQueryMs < 2000);
if (lateish.length) surprises.push(`${lateish.length} unsolicited replies arrived 0.4-2 s after a query for the same address (possibly slow replies, not human input); see the unsolicited section.`);
if (summary.heartbeats.missed) surprises.push(`${summary.heartbeats.missed} of ${summary.heartbeats.total} heartbeats were missed: the console (or relay) went quiet during the run.`);
if (surprises.length === 0) surprises.push('Nothing unexpected detected by the automatic checks.');
for (const s of surprises) L.push(s.startsWith(' ') ? s : `- ${s}`);
L.push('');
// Answered table by tree.
L.push('## Answered addresses, by tree');
L.push('');
const trees = [...new Set(answeredGroups.map((g) => g.tree))];
for (const tree of trees) {
const gs = answeredGroups.filter((g) => g.tree === tree);
L.push(`### /${tree} (${gs.length} patterns answered)`);
L.push('');
L.push('| Pattern | Answered N / M | Reply tags | Example value(s) | Documented type, range |');
L.push('| --- | --- | --- | --- | --- |');
for (const g of gs) {
const ans = g.items.filter((i) => i.answered);
const ns = [...new Set(ans.map((i) => i.instance.n).filter((v) => v !== null))];
const ms = [...new Set(ans.map((i) => i.instance.m).filter((v) => v !== null))];
const inst = [ns.length ? `N=${compressList(ns)}` : '', ms.length ? `M=${compressList(ms)}` : ''].filter(Boolean).join(' ') || '-';
const partialMark = ans.length < g.items.length ? ` (${ans.length}/${g.items.length})` : '';
const tags = [...new Set(ans.map((i) => (i.replyAddress !== i.address ? `${i.replyTypes} @${i.replyAddress}` : i.replyTypes)))].join(', ');
const ex = [...new Set(ans.map((i) => fmtValue(i.exampleValue)))].slice(0, 4).join(' / ');
const doc = g.documentedType ? `${g.documentedType}${g.documentedRange ? ' ' + fmtRange(g.documentedRange) : ''}` : '(not in 2014 list)';
L.push(`| \`${g.pattern}\` | ${inst}${partialMark} | ${esc(tags)} | ${esc(ex)} | ${doc} |`);
}
L.push('');
}
if (trees.length === 0) {
L.push('_No address answered._');
L.push('');
}
// Unanswered.
const docUnanswered = unansweredGroups.filter((g) => g.items.some((i) => i.sources.includes('doc')));
const extraUnanswered = unansweredGroups.filter((g) => !g.items.some((i) => i.sources.includes('doc')));
L.push(`## Unanswered documented patterns (${docUnanswered.length})`);
L.push('');
L.push('Patterns from the 2014 list for which none of the probed instances answered.');
L.push('');
const unTrees = [...new Set(docUnanswered.map((g) => g.tree))];
for (const tree of unTrees) {
const gs = docUnanswered.filter((g) => g.tree === tree);
L.push(`### /${tree} (${gs.length})`);
L.push('');
for (const g of gs) L.push(`- \`${g.pattern}\``);
L.push('');
}
L.push(`## Unanswered extra candidates (${extraUnanswered.length})`);
L.push('');
for (const g of extraUnanswered) L.push(`- \`${g.items.map((i) => i.address).join('`, `')}\``);
L.push('');
if (summary.skippedActionNodes.length) {
L.push('## Not queried');
L.push('');
for (const a of summary.skippedActionNodes) L.push(`- \`${a}\` (write-only trigger node; skipped for safety, pass --include-actions to query it)`);
L.push('');
}
// Unsolicited.
L.push(`## Unsolicited messages (${unsolicited.length})`);
L.push('');
if (unsolicited.length === 0) {
L.push('None seen, including during the final listen.');
} else {
L.push('| t (s) | Phase | Address | Tags | Value | Note |');
L.push('| ---: | --- | --- | --- | --- | --- |');
for (const u of unsolicited.slice(0, 500)) {
const note = u.reason === 'outside-window' ? `${u.afterQueryMs} ms after a query for this address` : u.reason;
L.push(`| ${round6(u.tMs / 1000)} | ${u.phase} | \`${u.address || '(unparsed)'}\` | ${u.types ?? ''} | ${esc(u.args ? fmtValue(u.args) : u.raw || '')} | ${note} |`);
}
if (unsolicited.length > 500) L.push(`| | | ... ${unsolicited.length - 500} more (see JSON) | | | |`);
}
L.push('');
// Names.
L.push('## Live name tables');
L.push('');
for (const [entity, tbl] of Object.entries(names)) {
L.push(`### ${entity}`);
L.push('');
if (!tbl.addressPattern) {
L.push('_No name address answered for this entity._');
L.push('');
continue;
}
L.push(`Address: \`${tbl.addressPattern}\` (${tbl.answered} of ${tbl.max} answered${tbl.unanswered.length ? `, no reply for ${tbl.unanswered.join(', ')}` : ''}).`);
const sample = catalog.find((c) => c.pattern === tbl.addressPattern && c.answered);
if (sample) L.push(`Reply format: \`${sample.replyAddress} ,${sample.replyTypes} ${fmtValue(Array.isArray(sample.exampleValue) ? sample.exampleValue : [sample.exampleValue])}\`.`);
L.push('');
L.push('| # | Name |');
L.push('| ---: | --- |');
for (const [idx, nm] of Object.entries(tbl.values)) L.push(`| ${idx} | ${esc(fmtValue(nm))} |`);
L.push('');
}
return L.join('\n') + '\n';
}
main().catch((err) => {
console.error(`fatal: ${err.stack || err.message}`);
process.exit(1);
});
+589
View File
@@ -0,0 +1,589 @@
DiGiCo OSC Command List for Other OSC 17/11/2014
Example Message formats:
/sd/Input_Channels/1/Channel_Input/name, s,FRED - Change Input channel 1 label to "Fred"
/sd/Input_Channels/1/mute, i,1 - Set Input channel 1 Mute ON
/sd/Input_Channels/1/fader, f,1 - Set Input channel 1 fader to MAX
TYPE OSC MIN OSC MAX
Prefix
/sd/ Input_Channels / * / Channel_Input / trim Float 0 1
/sd/ Input_Channels / * / Channel_Input / phase Int 0 3
/sd/ Input_Channels / * / Channel_Input / main / alt_in Int 0 1
/sd/ Input_Channels / * / Channel_Input / phantom Int 0 1
/sd/ Input_Channels / * / Channel_Input / alt_phantom Int 0 1
/sd/ Input_Channels / * / Channel_Input / analog_gain Float 0 1
/sd/ Input_Channels / * / Channel_Input / alt_analog_gain Float 0 1
/sd/ Input_Channels / * / Channel_Input / input_pad Int 0 1
/sd/ Input_Channels / * / Channel_Input / alt_input_pad Int 0 1
/sd/ Input_Channels / * / Channel_Input / name String
/sd/ Input_Channels / * / Aux_Send / * / send_level Float 0 1
/sd/ Input_Channels / * / Aux_Send / * / send_on Int 0 1
/sd/ Input_Channels / * / Aux_Send / * / send_pan Float 0 1
/sd/ Input_Channels / * / Group_Send / * / group Int 0 1
/sd/ Input_Channels / * / EQ / eq_in Int 0 1
/sd/ Input_Channels / * / EQ / eq_on_1 Int 0 1
/sd/ Input_Channels / * / EQ / eq_on_2 Int 0 1
/sd/ Input_Channels / * / EQ / eq_on_3 Int 0 1
/sd/ Input_Channels / * / EQ / eq_on_4 Int 0 1
/sd/ Input_Channels / * / EQ / eq_curve_1 Int 1 4
/sd/ Input_Channels / * / EQ / eq_curve_2 Int 1 4
/sd/ Input_Channels / * / EQ / eq_curve_3 Int 1 4
/sd/ Input_Channels / * / EQ / eq_curve_4 Int 1 4
/sd/ Input_Channels / * / EQ / eq_freq_1 Float 0 1
/sd/ Input_Channels / * / EQ / eq_freq_2 Float 0 1
/sd/ Input_Channels / * / EQ / eq_freq_3 Float 0 1
/sd/ Input_Channels / * / EQ / eq_freq_4 Float 0 1
/sd/ Input_Channels / * / EQ / eq_Q_1 Float 0 1
/sd/ Input_Channels / * / EQ / eq_Q_2 Float 0 1
/sd/ Input_Channels / * / EQ / eq_Q_3 Float 0 1
/sd/ Input_Channels / * / EQ / eq_Q_4 Float 0 1
/sd/ Input_Channels / * / EQ / eq_gain_1 Float 0 1
/sd/ Input_Channels / * / EQ / eq_gain_2 Float 0 1
/sd/ Input_Channels / * / EQ / eq_gain_3 Float 0 1
/sd/ Input_Channels / * / EQ / eq_gain_4 Float 0 1
/sd/ Input_Channels / * / EQ / eq_over-under_1 Int 0 1
/sd/ Input_Channels / * / EQ / eq_over-under_2 Int 0 1
/sd/ Input_Channels / * / EQ / eq_over-under_3 Int 0 1
/sd/ Input_Channels / * / EQ / eq_over-under_4 Int 0 1
/sd/ Input_Channels / * / EQ / eq_thresh_1 Float 0 1
/sd/ Input_Channels / * / EQ / eq_thresh_2 Float 0 1
/sd/ Input_Channels / * / EQ / eq_thresh_3 Float 0 1
/sd/ Input_Channels / * / EQ / eq_thresh_4 Float 0 1
/sd/ Input_Channels / * / EQ / eq_ratio_1 Float 0 1
/sd/ Input_Channels / * / EQ / eq_ratio_2 Float 0 1
/sd/ Input_Channels / * / EQ / eq_ratio_3 Float 0 1
/sd/ Input_Channels / * / EQ / eq_ratio_4 Float 0 1
/sd/ Input_Channels / * / EQ / eq_attack_1 Float 0 1
/sd/ Input_Channels / * / EQ / eq_attack_2 Float 0 1
/sd/ Input_Channels / * / EQ / eq_attack_3 Float 0 1
/sd/ Input_Channels / * / EQ / eq_attack_4 Float 0 1
/sd/ Input_Channels / * / EQ / eq_release_1 Float 0 1
/sd/ Input_Channels / * / EQ / eq_release_2 Float 0 1
/sd/ Input_Channels / * / EQ / eq_release_3 Float 0 1
/sd/ Input_Channels / * / EQ / eq_release_4 Float 0 1
/sd/ Input_Channels / * / EQ / eq_symm_Q_1 Int 0 1
/sd/ Input_Channels / * / EQ / eq_symm_Q_2 Int 0 1
/sd/ Input_Channels / * / EQ / eq_symm_Q_3 Int 0 1
/sd/ Input_Channels / * / EQ / eq_symm_Q_4 Int 0 1
/sd/ Input_Channels / * / EQ / dynamic_eq_on_1 Int 0 1
/sd/ Input_Channels / * / EQ / dynamic_eq_on_2 Int 0 1
/sd/ Input_Channels / * / EQ / dynamic_eq_on_3 Int 0 1
/sd/ Input_Channels / * / EQ / dynamic_eq_on_4 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_in Int 0 1
/sd/ Input_Channels / * / Dynamics / gate_in Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_band_in_1 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_band_in_2 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_band_in_3 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_LP_crossover Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_HP_crossover Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_gain_1 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_gain_2 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_gain_3 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_gain_4 Float 0 1
/sd/ Input_Channels / * / Dynamics / gate_range Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_thresh_1 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_thresh_2 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_thresh_3 Float 0 1
/sd/ Input_Channels / * / Dynamics / gate_thresh Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_knee_1 Int 0 2
/sd/ Input_Channels / * / Dynamics / comp_knee_2 Int 0 2
/sd/ Input_Channels / * / Dynamics / comp_knee_3 Int 0 2
/sd/ Input_Channels / * / Dynamics / comp_knee_4 Int 0 2
/sd/ Input_Channels / * / Dynamics / comp_ratio_1 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_ratio_2 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_ratio_3 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_ratio_4 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_attack_1 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_attack_2 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_attack_3 Float 0 1
/sd/ Input_Channels / * / Dynamics / gate_attack Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_release_1 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_release_2 Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_release_3 Float 0 1
/sd/ Input_Channels / * / Dynamics / gate_release Float 0 1
/sd/ Input_Channels / * / Dynamics / gate_hold Float 0 1
/sd/ Input_Channels / * / Dynamics / key_solo Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_listen_1 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_listen_2 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_listen_3 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_all_gain Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_all_thresh Float 0 1
/sd/ Input_Channels / * / Dynamics / comp_auto-gain_1 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_auto-gain_2 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_auto-gain_3 Int 0 1
/sd/ Input_Channels / * / Dynamics / comp_auto-gain_4 Int 0 1
/sd/ Input_Channels / * / Dynamics / desser_centre_freq Float 0 1
/sd/ Input_Channels / * / Dynamics / gate_centre_freq Float 0 1
/sd/ Input_Channels / * / Dynamics / desser_freq_width Float 0 1
/sd/ Input_Channels / * / Dynamics / gate_freq_width Float 0 1
/sd/ Input_Channels / * / Filters / hi_filter_in Int 0 1
/sd/ Input_Channels / * / Filters / lo_filter_in Int 0 1
/sd/ Input_Channels / * / Filters / hi_filter_freq Float 0 1
/sd/ Input_Channels / * / Filters / lo_filter_freq Float 0 1
/sd/ Input_Channels / * / Insert / insert_A_in Int 0 1
/sd/ Input_Channels / * / Insert / insert_B_in Int 0 1
/sd/ Input_Channels / * / Channel_Delay / delay_on Int 0 1
/sd/ Input_Channels / * / Channel_Delay / delay Float 0 1
/sd/ Input_Channels / * / Channel_Delay / fine_delay Float 0 1
/sd/ Input_Channels / * / CGs_level Float 0 1
/sd/ Input_Channels / * / solo Int 0 1
/sd/ Input_Channels / * / CGs_mute Int 0 1
/sd/ Input_Channels / * / fader Float 0 1
/sd/ Input_Channels / * / mute Int 0 1
/sd/ Input_Channels / * / Panner / f-b Float 0 1
/sd/ Input_Channels / * / Panner / pan Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_in Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_1 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_2 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_3 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_4 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_5 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_6 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_7 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_on_8 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_curve_1 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_curve_2 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_curve_3 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_curve_4 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_curve_5 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_curve_6 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_curve_7 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_curve_8 Int 1 4
/sd/ Aux_Outputs / * / EQ / eq_freq_1 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_freq_2 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_freq_3 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_freq_4 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_freq_5 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_freq_6 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_freq_7 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_freq_8 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_1 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_2 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_3 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_4 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_5 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_6 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_7 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_Q_8 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_1 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_2 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_3 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_4 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_5 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_6 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_7 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_gain_8 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_over-under_1 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_over-under_2 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_over-under_3 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_over-under_4 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_thresh_1 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_thresh_2 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_thresh_3 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_thresh_4 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_ratio_1 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_ratio_2 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_ratio_3 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_ratio_4 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_attack_1 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_attack_2 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_attack_3 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_attack_4 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_release_1 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_release_2 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_release_3 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_release_4 Float 0 1
/sd/ Aux_Outputs / * / EQ / eq_pre-ins Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_1 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_2 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_3 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_4 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_5 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_6 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_7 Int 0 1
/sd/ Aux_Outputs / * / EQ / eq_symm_Q_8 Int 0 1
/sd/ Aux_Outputs / * / EQ / dynamic_eq_on_1 Int 0 1
/sd/ Aux_Outputs / * / EQ / dynamic_eq_on_2 Int 0 1
/sd/ Aux_Outputs / * / EQ / dynamic_eq_on_3 Int 0 1
/sd/ Aux_Outputs / * / EQ / dynamic_eq_on_4 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_in Int 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_in Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_band_in_1 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_band_in_2 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_band_in_3 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_LP_crossover Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_HP_crossover Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_gain_1 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_gain_2 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_gain_3 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_gain_4 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_range Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_thresh_1 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_thresh_2 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_thresh_3 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_thresh Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_knee_1 Int 0 2
/sd/ Aux_Outputs / * / Dynamics / comp_knee_2 Int 0 2
/sd/ Aux_Outputs / * / Dynamics / comp_knee_3 Int 0 2
/sd/ Aux_Outputs / * / Dynamics / comp_knee_4 Int 0 2
/sd/ Aux_Outputs / * / Dynamics / comp_ratio_1 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_ratio_2 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_ratio_3 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_ratio_4 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_attack_1 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_attack_2 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_attack_3 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_attack Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_release_1 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_release_2 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_release_3 Float 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_release Float 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_hold Float 0 1
/sd/ Aux_Outputs / * / Dynamics / key_solo Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_listen_1 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_listen_2 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_listen_3 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_all_gain Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_all_thresh Float 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_auto-gain_1 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_auto-gain_2 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_auto-gain_3 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / comp_auto-gain_4 Int 0 1
/sd/ Aux_Outputs / * / Dynamics / desser_centre_freq Float 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_centre_freq Float 0 1
/sd/ Aux_Outputs / * / Dynamics / desser_freq_width Float 0 1
/sd/ Aux_Outputs / * / Dynamics / gate_freq_width Float 0 1
/sd/ Aux_Outputs / * / Insert / insert_A_in Int 0 1
/sd/ Aux_Outputs / * / Insert / insert_B_in Int 0 1
/sd/ Aux_Outputs / * / Channel_Delay / delay_on Int 0 1
/sd/ Aux_Outputs / * / Channel_Delay / delay Float 0 1
/sd/ Aux_Outputs / * / Channel_Delay / fine_delay Float 0 1
/sd/ Aux_Outputs / * / Buss_Trim / trim Float 0 1
/sd/ Aux_Outputs / * / Buss_Trim / phase Int 0 3
/sd/ Aux_Outputs / * / Buss_Trim / name String
/sd/ Aux_Outputs / * / CGs_level Float 0 1
/sd/ Aux_Outputs / * / solo Int 0 1
/sd/ Aux_Outputs / * / CGs_mute Int 0 1
/sd/ Aux_Outputs / * / fader Float 0 1
/sd/ Aux_Outputs / * / mute Int 0 1
/sd/ Group_Outputs / * / Group_Send / * / group Int 0 1
/sd/ Group_Outputs / * / EQ / eq_in Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_1 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_2 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_3 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_4 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_5 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_6 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_7 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_on_8 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_curve_1 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_curve_2 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_curve_3 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_curve_4 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_curve_5 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_curve_6 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_curve_7 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_curve_8 Int 1 4
/sd/ Group_Outputs / * / EQ / eq_freq_1 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_freq_2 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_freq_3 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_freq_4 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_freq_5 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_freq_6 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_freq_7 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_freq_8 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_1 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_2 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_3 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_4 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_5 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_6 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_7 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_Q_8 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_1 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_2 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_3 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_4 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_5 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_6 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_7 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_gain_8 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_over-under_1 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_over-under_2 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_over-under_3 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_over-under_4 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_thresh_1 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_thresh_2 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_thresh_3 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_thresh_4 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_ratio_1 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_ratio_2 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_ratio_3 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_ratio_4 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_attack_1 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_attack_2 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_attack_3 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_attack_4 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_release_1 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_release_2 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_release_3 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_release_4 Float 0 1
/sd/ Group_Outputs / * / EQ / eq_pre-ins Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_1 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_2 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_3 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_4 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_5 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_6 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_7 Int 0 1
/sd/ Group_Outputs / * / EQ / eq_symm_Q_8 Int 0 1
/sd/ Group_Outputs / * / EQ / dynamic_eq_on_1 Int 0 1
/sd/ Group_Outputs / * / EQ / dynamic_eq_on_2 Int 0 1
/sd/ Group_Outputs / * / EQ / dynamic_eq_on_3 Int 0 1
/sd/ Group_Outputs / * / EQ / dynamic_eq_on_4 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_in Int 0 1
/sd/ Group_Outputs / * / Dynamics / gate_in Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_band_in_1 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_band_in_2 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_band_in_3 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_LP_crossover Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_HP_crossover Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_gain_1 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_gain_2 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_gain_3 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_gain_4 Float 0 1
/sd/ Group_Outputs / * / Dynamics / gate_range Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_thresh_1 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_thresh_2 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_thresh_3 Float 0 1
/sd/ Group_Outputs / * / Dynamics / gate_thresh Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_knee_1 Int 0 2
/sd/ Group_Outputs / * / Dynamics / comp_knee_2 Int 0 2
/sd/ Group_Outputs / * / Dynamics / comp_knee_3 Int 0 2
/sd/ Group_Outputs / * / Dynamics / comp_knee_4 Int 0 2
/sd/ Group_Outputs / * / Dynamics / comp_ratio_1 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_ratio_2 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_ratio_3 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_ratio_4 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_attack_1 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_attack_2 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_attack_3 Float 0 1
/sd/ Group_Outputs / * / Dynamics / gate_attack Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_release_1 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_release_2 Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_release_3 Float 0 1
/sd/ Group_Outputs / * / Dynamics / gate_release Float 0 1
/sd/ Group_Outputs / * / Dynamics / gate_hold Float 0 1
/sd/ Group_Outputs / * / Dynamics / key_solo Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_listen_1 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_listen_2 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_listen_3 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_all_gain Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_all_thresh Float 0 1
/sd/ Group_Outputs / * / Dynamics / comp_auto-gain_1 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_auto-gain_2 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_auto-gain_3 Int 0 1
/sd/ Group_Outputs / * / Dynamics / comp_auto-gain_4 Int 0 1
/sd/ Group_Outputs / * / Dynamics / desser_centre_freq Float 0 1
/sd/ Group_Outputs / * / Dynamics / gate_centre_freq Float 0 1
/sd/ Group_Outputs / * / Dynamics / desser_freq_width Float 0 1
/sd/ Group_Outputs / * / Dynamics / gate_freq_width Float 0 1
/sd/ Group_Outputs / * / Insert / insert_A_in Int 0 1
/sd/ Group_Outputs / * / Insert / insert_B_in Int 0 1
/sd/ Group_Outputs / * / Channel_Delay / delay_on Int 0 1
/sd/ Group_Outputs / * / Channel_Delay / delay Float 0 1
/sd/ Group_Outputs / * / Channel_Delay / fine_delay Float 0 1
/sd/ Group_Outputs / * / Buss_Trim / trim Float 0 1
/sd/ Group_Outputs / * / Buss_Trim / phase Int 0 3
/sd/ Group_Outputs / * / Buss_Trim / name String
/sd/ Group_Outputs / * / CGs_level Float 0 1
/sd/ Group_Outputs / * / solo Int 0 1
/sd/ Group_Outputs / * / CGs_mute Int 0 1
/sd/ Group_Outputs / * / fader Float 0 1
/sd/ Group_Outputs / * / mute Int 0 1
/sd/ Control_Groups / * / solo Int 0 1
/sd/ Control_Groups / * / fader Float 0 1
/sd/ Control_Groups / * / mute Int 0 1
/sd/ Control_Groups / * / name String
/sd/ Matrix_Inputs / * / Matrix_Send / * / send_level Float 0 1
/sd/ Matrix_Inputs / * / Matrix_Send / * / send_on Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_in Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_1 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_2 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_3 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_4 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_5 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_6 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_7 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_on_8 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_curve_1 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_curve_2 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_curve_3 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_curve_4 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_curve_5 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_curve_6 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_curve_7 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_curve_8 Int 1 4
/sd/ Matrix_Outputs / * / EQ / eq_freq_1 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_freq_2 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_freq_3 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_freq_4 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_freq_5 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_freq_6 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_freq_7 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_freq_8 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_1 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_2 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_3 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_4 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_5 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_6 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_7 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_Q_8 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_1 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_2 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_3 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_4 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_5 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_6 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_7 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_gain_8 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_over-under_1 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_over-under_2 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_over-under_3 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_over-under_4 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_thresh_1 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_thresh_2 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_thresh_3 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_thresh_4 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_ratio_1 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_ratio_2 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_ratio_3 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_ratio_4 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_attack_1 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_attack_2 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_attack_3 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_attack_4 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_release_1 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_release_2 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_release_3 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_release_4 Float 0 1
/sd/ Matrix_Outputs / * / EQ / eq_pre-ins Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_1 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_2 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_3 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_4 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_5 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_6 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_7 Int 0 1
/sd/ Matrix_Outputs / * / EQ / eq_symm_Q_8 Int 0 1
/sd/ Matrix_Outputs / * / EQ / dynamic_eq_on_1 Int 0 1
/sd/ Matrix_Outputs / * / EQ / dynamic_eq_on_2 Int 0 1
/sd/ Matrix_Outputs / * / EQ / dynamic_eq_on_3 Int 0 1
/sd/ Matrix_Outputs / * / EQ / dynamic_eq_on_4 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_in Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_in Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_band_in_1 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_band_in_2 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_band_in_3 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_LP_crossover Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_HP_crossover Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_gain_1 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_gain_2 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_gain_3 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_gain_4 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_range Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_thresh_1 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_thresh_2 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_thresh_3 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_thresh Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_knee_1 Int 0 2
/sd/ Matrix_Outputs / * / Dynamics / comp_knee_2 Int 0 2
/sd/ Matrix_Outputs / * / Dynamics / comp_knee_3 Int 0 2
/sd/ Matrix_Outputs / * / Dynamics / comp_knee_4 Int 0 2
/sd/ Matrix_Outputs / * / Dynamics / comp_ratio_1 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_ratio_2 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_ratio_3 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_ratio_4 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_attack_1 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_attack_2 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_attack_3 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_attack Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_release_1 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_release_2 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_release_3 Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_release Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_hold Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / key_solo Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_listen_1 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_listen_2 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_listen_3 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_all_gain Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_all_thresh Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_auto-gain_1 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_auto-gain_2 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_auto-gain_3 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / comp_auto-gain_4 Int 0 1
/sd/ Matrix_Outputs / * / Dynamics / desser_centre_freq Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_centre_freq Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / desser_freq_width Float 0 1
/sd/ Matrix_Outputs / * / Dynamics / gate_freq_width Float 0 1
/sd/ Matrix_Outputs / * / Insert / insert_A_in Int 0 1
/sd/ Matrix_Outputs / * / Insert / insert_B_in Int 0 1
/sd/ Matrix_Outputs / * / Channel_Delay / delay_on Int 0 1
/sd/ Matrix_Outputs / * / Channel_Delay / delay Float 0 1
/sd/ Matrix_Outputs / * / Channel_Delay / fine_delay Float 0 1
/sd/ Matrix_Outputs / * / Buss_Trim / trim Float 0 1
/sd/ Matrix_Outputs / * / Buss_Trim / phase Int 0 3
/sd/ Matrix_Outputs / * / Buss_Trim / name String
/sd/ Matrix_Outputs / * / CGs_level Float 0 1
/sd/ Matrix_Outputs / * / solo Int 0 1
/sd/ Matrix_Outputs / * / CGs_mute Int 0 1
/sd/ Matrix_Outputs / * / fader Float 0 1
/sd/ Matrix_Outputs / * / mute Int 0 1
/sd/ Graphic_EQ / * / geq_in Int 0 1
/sd/ Graphic_EQ / * / geq_gain_1 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_2 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_3 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_4 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_5 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_6 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_7 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_8 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_9 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_10 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_11 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_12 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_13 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_14 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_15 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_16 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_17 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_18 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_19 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_20 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_21 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_22 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_23 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_24 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_25 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_26 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_27 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_28 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_29 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_30 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_31 Float 0 1
/sd/ Graphic_EQ / * / geq_gain_32 Float 0 1
/sd/ Multis / * / solo Int 0 1
/sd/ Multis / * / fader Float 0 1
/sd/ Multis / * / mute Int 0 1
/sd/ Multis / * / name String
/sd/ Filing / Save_current_Session / Int 0 0
/sd/ Snapshots / Fire_Snapshot_number / Int 0 9999
/sd/ Snapshots / Fire_Prev_Snapshot / Int 0 0
/sd/ Snapshots / Fire_Next_Snapshot / Int 0 0
/sd/ Macros / Buttons / press Int 0 255