v2: generic S-Series module with live discovery, dynamic options and meters

Rewrite of the module around what the console itself reports:

- Settings cut to console address/ports, the iPad relay and debug logging;
  nothing site-specific remains. An upgrade script carries v1 configs and
  remaps v1 actions/feedbacks whose ids were reused.
- On connect the module reads every name, buss type (aux/group), stereo
  mode, fader/mute/solo/pan and the snapshot list; entities that never
  answer are hidden. Definitions rebuild (debounced, skipped when
  identical) when names, buss types or snapshots change.
- Every entity option is a searchable dropdown of live names. 70 actions
  cover inputs, aux sends, group assigns, busses, matrix outputs and sends,
  control groups, graphic EQs, channel-strip switches, trim/gain/filters and
  snapshots, each level with set and encoder-friendly adjust variants.
- Boolean, value and bar feedbacks for every state, input level and
  gain-reduction meters via the console's meter subscription protocol, and
  about 2800 presets including rotary knob presets with live level text.
- A write guard keeps fast encoder turns from being undone by delayed
  echoes; feedbacks are re-checked per instance.
- Relay: the client table survives config changes, idle iPads are kept for
  an hour by default (0 = forever), bind failures retry and show in status.

npm test: 326 checks pass on Node 25 and on Companion's Node 22.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 17:11:17 -04:00
co-authored by Claude Opus 5.5
parent 8693fe924a
commit dad8010239
20 changed files with 5102 additions and 1327 deletions
+47 -21
View File
@@ -1,42 +1,68 @@
# 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.
Bitfocus Companion (5.0+) connection module for DiGiCo S-Series consoles (S21 / S31). It discovers the
console over the iPad extension's OSC interface (names, busses, matrix, control groups, graphic EQs,
snapshots), builds dropdowns, variables and presets from the live names, and controls faders, mutes,
solos, pans, aux sends, group assigns, matrix sends, per-channel switches and snapshots, with feedbacks,
rotary-encoder presets and input meters. It also relays the DiGiCo iPad app: the console accepts exactly
one iPad, Companion takes that slot, and any number of iPad apps connect through it.
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.
User documentation (console and iPad setup, actions, feedbacks, variables, presets, meters and the
protocol table) is in [companion/HELP.md](companion/HELP.md); Companion shows it on the connection's help button.
Repository: <http://chronicles-ab.waterschurch.org:3000/Waters-Church/companion-module-digico-s31>
## 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/main.js` | the `InstanceBase` subclass: config, lifecycle, load/keepalive/reconnect, paced query queue, actions' operations, meters, debug learn log, upgrade script |
| `src/state.js` | everything known about the console, dropdown choice builders, load plans |
| `src/protocol.js` | the iPad-extension OSC tree: address builders, message parser, value formatting |
| `src/image.js` | tiny RGBA drawing helpers for the bar feedbacks |
| `src/definitions/` | `actions.js`, `feedbacks.js`, `variables.js`, `presets.js` |
| `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 |
| `tools/` | `mock-s31.js` (console emulator), `fake-ipad.js`, the probe and the console's address catalog, see `tools/README.md` |
| `test/selftest.js` | end-to-end check: the real module against the mock console and a fake iPad |
| `docs/` | design notes |
## 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
npm test # runs test/selftest.js against the mock console on 127.0.0.1 (ports 18000-19001)
```
## Install as a developer module (how it runs on AB-ProPresenter)
The module only uses `node:dgram` and `node:events`, so it runs under Companion's `--permission`
sandbox on its bundled Node 22 (no file writes, no child processes). No other dependencies.
To try it without a console, run the mock and point a connection at it:
```bash
node tools/mock-s31.js --dialect sd --listen 8000 --reply-to 127.0.0.1:9000
```
## Install as a developer module
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.
`~/companion-dev-modules/companion-module-digico-s31/`.
2. In the Companion launcher, open Developer, enable developer modules and set the path to the
*parent* folder (`~/companion-dev-modules`); the launcher restarts Companion with
`--extra-module-path`. Alternatively set `dev_modules_path` in Companion's `config.json` and relaunch.
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).
On the console, Extensions > iPad must point at the Companion computer's IP address, with the console's
Send port set to the connection's *Local receive port* (default 9000) and its Receive port set to the
connection's *Console receive port* (default 8000).
## Upgrading from 0.1
The connection settings are kept: an upgrade script maps the v1 config to the new eight settings
(`verboseLog` becomes *Debug logging*, the old 180 s iPad idle timeout becomes the new 3600 s default,
everything else is dropped). The v1 actions and feedbacks whose ids v2 reuses with different options
(`send_on`, `send_level`, `send_nudge`, `send_pan`, `group_assign`, `snapshot_fire`, `buss_mute`, `buss_fader`,
`mute`, `fader`, `muted`, `send_on` / `group_assigned` feedbacks) and the single-buss `mon_*` ones (using the old
single-buss number and type, `auto` read as aux) are rewritten to their v2 form; anything without an equivalent is
left as it was.
+165 -68
View File
@@ -1,96 +1,193 @@
# DiGiCo S31 (S-Series) — Studio Mon control + iPad relay
# DiGiCo S-Series (S21 / S31): control and 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.
1. **Controls the console over OSC.** On connect it asks the console what it has (inputs, busses,
matrix outputs, control groups, graphic EQs, snapshots) and builds its dropdowns, variables and
presets from the live names. Faders, mutes, solos, pans, aux sends, group assigns, matrix sends,
control groups, graphic EQs, per-channel switches (phantom, EQ in, filters, inserts ...) and
snapshots are covered, with feedbacks, variables, rotary-encoder presets and input meters.
2. **Relays the DiGiCo iPad app.** An S-Series console talks to exactly one iPad at a time. Companion
takes that slot and the iPad apps connect to this computer instead; everything an app sends is
forwarded to the console unchanged, and everything the console sends is copied to every app, so
several iPads can be used together with Companion.
## Console setup (once, at the surface)
Main Menu → **Extensions → iPad**:
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 IP address | the computer running Companion |
| Send port *(console to this computer)* | this connection's **Local receive port** (default `9000`) |
| Receive port *(this computer to the console)* | this connection's **Console receive port** (default `8000`) |
| iPad connection | enabled |
The console sends every reply and every change to that one IP address and Send port, never to the
port a query came from. That is why the connection binds a fixed local port.
## iPad app setup
In the DiGiCo S app connect page, for every iPad:
In the DiGiCo S app, for every iPad:
| iPad field | Value |
| --- | --- |
| Console IP | the Companion computer, **not** the console |
| Console IP | the computer running Companion, **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.
An iPad that stays quiet for *Forget an iPad after* seconds (0 = never) is dropped from the fan-out list, and at most
16 are kept. `$(label:relay_clients)` shows how many are live. Changing only the idle timeout, the iPad receive port or
Debug logging does not interrupt the relay or the console link; changing the relay port reopens the relay and keeps the
list of iPads. If the relay port is already in use the connection shows the warning "iPad relay: port N in use" and tries
again every 10 seconds (a console-link port that cannot be bound shows a connection failure and is retried the same way). Other controllers share the console through the relay,
and anything they change reaches this module too, so feedbacks follow moves made on the surface or on an iPad.
## Studio Mon buss
## Settings
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 |
| Setting | Default | Meaning |
| --- | --- | --- |
| 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 |
| Console IP | | The console's address |
| Console receive port | 8000 | UDP port the module sends to |
| Local receive port | 9000 | UDP port the module listens on (the console's Send port) |
| Enable iPad relay | on | Turns the relay on |
| Relay port | 8000 | Where iPad apps send |
| iPad receive port | 9000 | Where relayed console traffic goes (0 = the app's own source port) |
| Forget an iPad after | 3600 s | Idle time before an iPad is dropped (0 = never; an iPad that only watches sends nothing). At most 16 iPads are kept; the least recently seen is dropped first |
| Debug logging | off | Logs every new address pattern once and all traffic at debug level |
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.
## What it discovers
On every connect (and on the **Reload** action, and automatically after the console was silent for
20 seconds and comes back) the module asks for the console name, the matrix, control group and graphic EQ
counts, the buss types and modes, every name, the session name, the snapshot list, and the fader, mute,
solo and pan of everything that answered. The console does not answer the input and buss counts, so an
entity exists when its **name** answered; entities that never answer are hidden from dropdowns, variables
and presets. Dropdown entries read `1 · KICK In`, `24 · Monitors (group)`, `3 · Message · cue 4`.
When a name, a buss mode or the snapshot list changes, the definitions are rebuilt a moment later.
Sends, group assigns, matrix sends, graphic EQ gains and the per-channel switches are **not** bulk-loaded:
each is read the first time a button or action needs it.
The 25 busses are each an **aux** or a **group**, according to `/Console/Aux_Outputs/types` (1 = aux,
0 = group). Aux busses take sends (level, on, pan); group busses take input assigns. A buss's
fader, mute and solo are on the Aux_Outputs or Group_Outputs side that matches its mode.
## Actions
Level actions take dB (`-inf` at the bottom of the number field). Every level has a **set** and a
relative **adjust** version for rotary encoders; adjusting from -inf lands at -60 dB on the first step up.
Toggles read the current state first when it is not known yet.
| Group | Actions |
| --- | --- |
| Inputs | mute, solo (several inputs at once), fader set/adjust, pan set/adjust, set name |
| Aux sends | on/off, level set/adjust, pan set/adjust |
| Groups | assign input on/off |
| Busses | mute, solo (several), fader set/adjust |
| Matrix | output mute, fader set/adjust; matrix send on/off, level set/adjust |
| Control groups | mute, solo, fader set/adjust |
| Graphic EQ | in/out, band gain set/adjust (bands 1-32) |
| Channel strip | per input and per buss: phantom, phase, pad, EQ in, compressor in, gate in, high-pass in, low-pass in, delay in, insert A/B in (on/off); trim (-40 to +40 dB), analog gain (0 to 60 dB), filter frequencies (20 to 20000 Hz) (set/adjust; trim also on matrix outputs) |
| Snapshots | fire (pick from the list or type an index), next, previous |
| Console | reload, raw OSC, relay: forget all iPads |
The channel-strip values are what the console reports (trim and gain in dB, filters in Hz). In the console's
own naming `lo_filter` is the high-pass filter and `hi_filter` the low-pass filter; the actions are named for
what they do. Delay times and the send pre/post fader switch are not offered: their units and meaning were
never verified.
**Encoders and writes.** Every write updates the module's state at once, so a fast encoder turn accumulates. For
0.4 seconds after the last write to a value, a different value arriving for it is treated as a late echo of an
earlier step and ignored; then one query confirms what the console has. The snapshot *Fire* action sends the
snapshot's list position (0 = the first); verify it once on your console before using it live.
**Macros:** the console never answered a macro query, so there is no macro action. If your console supports it, the
*Send raw OSC* action can send `/Macros/Buttons/press` with an integer argument (0-255).
## Feedbacks
* Boolean (button colour): input muted / solo, aux send on, group assigned, buss muted /
solo, matrix muted, control group muted, snapshot is current, fader level above/below a threshold, send
level above/below, console link alive, relay has an iPad, every channel-strip switch above, and the meter
feedbacks below.
* Value (use with a preset's local variable to put live numbers in button text): input fader, input pan,
send level, send On/Off, buss / matrix / control group fader, current snapshot name, input meter value.
* Advanced bars: input fader, aux send level and buss fader draw a horizontal bar along the bottom of the
button (-60 to +10 dB); the input meter draws a vertical bar at the right edge (green, amber, red).
## 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)`.
`input_N_name|fader|mute|pan`, `buss_N_name|mode|stereo|fader|mute`, `matrix_N_name|fader|mute`,
`cg_N_name|fader|mute` (only for entities that exist), `snapshot_index|name|cue|count`, `console_name`,
`session_name`, `connected`, `relay_clients`, `relay_client_ips` and `last_change`. Use them as
`$(label:input_1_name)` with the connection's label. There are no per-send variables: use the value feedbacks.
## Presets
Presets are named with the live entity names and rebuilt when names load.
| Preset group | What the buttons do |
| --- | --- |
| Inputs: Mute | One button per input; red when muted |
| Inputs: Fader (knob) | Rotary: turn = fader -/+1 dB, press = mute; shows name and dB, with a level bar |
| Inputs: Solo | One button per input |
| Busses | Master mute, and master fader knob (turn = fader, press = mute) per named buss |
| Sends to *aux name* | One section per named aux buss: per input an **On/off** button (green when on, shows the level) and a **Level (knob)** (turn = send level, press = on/off, with a bar) |
| Group *group name* | One section per named group buss: per input an **Assign** toggle, green when assigned |
| Matrix, Control groups | Mute and fader knob per output / group |
| Snapshots | **Fire** per snapshot (green when current), next, previous, current (display) |
| Input meters | Level, gate gain reduction and compressor gain reduction buttons per input |
| Console | Reload, connection status, relay status |
Busses and control groups with no name are unused slots and get no presets. If the preset list would exceed
3000 entries or 4 MB, the per-input send level knobs are left out and a warning is logged.
## Meters
Input meters are subscribed on the console the first time a meter feedback renders (ids start at 5000 so
they never collide with an iPad's). The console then streams values 0-127 several times a second; the
module re-checks the meter feedbacks at most 10 times a second. Level is the post-fader input meter; gate and
compressor gain reduction are the two reduction meters. There is no known buss meter address, so busses
have no meters. The console keeps one subscription table: an iPad connecting clears it (`/Meters/clear`),
and the module re-subscribes half a second after it sees that pass through the relay. Two iPads watching
different banks both receive the mixed stream. The module never sends `/Meters/clear` itself.
## 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.
* **No reply from the console.** The console only answers to the iPad IP address and Send port set on the
console. Check them, and that nothing else on this computer owns the local receive port.
* **Dropdowns show generic names.** The load has not finished or the console did not answer the names;
press **Reload**. The log shows `load complete` with the counts when it is done.
* **Something changed on the console is not shown.** Press Reload after a session change; sends and
per-channel values are only read when first needed.
* **Debug logging** lists every new address pattern once (Connections log), so touching a control on the
surface shows its address; `$(label:last_change)` shows the last change reported.
## Protocol (condensed)
A query is the address plus `/?` with no argument; the reply is the same address without `/?`. All 0/1
switches are **reported as floats** but written as integers 0/1; levels, sends and pans are floats. Numbers
are 1-based, snapshot indexes 0-based.
| Address | Reply | Notes |
| --- | --- | --- |
| `/Console/Name` | s | also the keepalive (every 5 s) |
| `/Console/{Matrix_Outputs,Matrix_Inputs,Control_Groups,Graphic_EQ}` | i | counts that answer |
| `/Console/{Input_Channels,Aux_Outputs,Group_Outputs}` | i | pushed once after first contact, never answered |
| `/Console/Aux_Outputs/types` | 25 x i | 1 = aux, 0 = group |
| `/Console/{Aux_Outputs,Group_Outputs,Input_Channels}/modes` | i per buss / input | 2 = stereo, 1 = mono |
| `/Console/Session/Filename` | s | |
| `/Input_Channels/N/Channel_Input/name`, `/Aux_Outputs/N/Buss_Trim/name`, `/Group_Outputs/N/Buss_Trim/name`, `/Matrix_Outputs/N/Buss_Trim/name`, `/Control_Groups/N/name`, `/Graphic_EQ/N/name` | s | matrix inputs have no name address |
| `/{Input_Channels,Aux_Outputs,Group_Outputs,Matrix_Outputs,Control_Groups}/N/{fader,mute,solo}` | f | fader in dB, -150 = -inf |
| `/Input_Channels/N/Panner/pan` | f | 0..1, 0.5 = centre |
| `/Input_Channels/N/Aux_Send/M/{send_level,send_on,send_pan}` | f | |
| `/Input_Channels/N/Group_Send/M/group` | f | |
| `/Matrix_Inputs/N/Matrix_Send/M/{send_level,send_on}` | f | |
| `/Graphic_EQ/N/{geq_in,geq_gain_1..32}` | f | |
| `/Input_Channels/N/{Channel_Input,Filters,Channel_Delay,EQ,Dynamics,Insert}/...` and the buss equivalents | f | the channel-strip switches and values above |
| `/Snapshots/Current_Snapshot`, `/Snapshots/count` | i | |
| `/Snapshots/names` | one `/Snapshots/name` `iiis` per snapshot: index, cue x 100, ?, name | |
| `/Snapshots/Fire_Snapshot_number`, `/Snapshots/Fire_Next_Snapshot`, `/Snapshots/Fire_Prev_Snapshot` | none | write-only int |
| `/Input_Channels/N/Channel_Input/post_meter`, `/Input_Channels/N/Dynamics/GR_meter` | none | send `,ii [id, kind]` (kind 1 level and gate, 4 compressor) to subscribe |
| `/Meters/values` | `ii...` | pushed pairs `[id, value]`, value 0..127 |
| `/Meters/clear` | none | clears every subscription on the console; sent by iPad apps, never by this module |
+2 -2
View File
@@ -4,8 +4,8 @@
"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",
"description": "DiGiCo S21/S31 control over OSC: discovers the console, builds dropdowns, variables and presets from its names, and relays the DiGiCo iPad app so several iPads can share the console's single remote slot",
"version": "0.2.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",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "companion-module-digico-s31",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "companion-module-digico-s31",
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"dependencies": {
"@companion-module/base": "~2.1.3"
+2 -2
View File
@@ -1,7 +1,7 @@
{
"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",
"version": "0.2.0",
"description": "DiGiCo S21/S31 control for Bitfocus Companion: discovers the console, dropdowns with live names, rotary presets, feedbacks and input meters, plus a relay so several DiGiCo iPad apps can share the console's single remote slot",
"type": "module",
"main": "src/main.js",
"license": "MIT",
+38 -6
View File
@@ -10,6 +10,9 @@ import { encode, decode } from './osc.js'
* port, so we bind a fixed local port (the console's "Send Port") and send to
* the console's "Receive Port".
*
* Nothing here throws on the send path: a synchronous failure (bad port, closed socket, an argument
* that cannot be encoded) is reported as an 'error' event with `err.dest` set and send() returns false.
*
* 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).
@@ -29,13 +32,22 @@ export class ConsoleLink extends EventEmitter {
open() {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: false })
let socket
let settled = false
socket.on('error', (err) => {
if (!settled) {
const fail = (err) => {
settled = true
try {
socket?.close()
} catch (_e) {
/* never opened */
}
reject(err)
} else this.emit('error', err)
}
try {
socket = dgram.createSocket({ type: 'udp4', reuseAddr: false })
socket.on('error', (err) => {
if (!settled) fail(err)
else this.emitError(err)
})
socket.on('message', (buf, rinfo) => {
if (this.host && rinfo.address !== this.host) {
@@ -53,22 +65,42 @@ export class ConsoleLink extends EventEmitter {
this.emit('listening', socket.address())
resolve(socket.address())
})
} catch (err) {
fail(err)
}
})
}
send(address, args = []) {
return this.sendRaw(encode(address, args))
let buf
try {
buf = encode(address, args)
} catch (err) {
this.emitError(err)
return false
}
return this.sendRaw(buf)
}
sendRaw(buf) {
if (!this.socket || !this.host) return false
this.txCount++
try {
this.socket.send(buf, this.port, this.host, (err) => {
if (err) this.emit('error', err)
if (err) this.emitError(err)
})
} catch (err) {
this.emitError(err)
return false
}
return true
}
emitError(err) {
err.dest = `${this.host}:${this.port}`
if (this.listenerCount('error')) this.emit('error', err)
}
close() {
const s = this.socket
this.socket = null
-345
View File
@@ -1,345 +0,0 @@
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 }
}
+324
View File
@@ -0,0 +1,324 @@
import { LEVEL, LIMITS, PARAMS } from '../protocol.js'
export const STATE_CHOICES = [
{ id: 'on', label: 'On' },
{ id: 'off', label: 'Off' },
{ id: 'toggle', label: 'Toggle' },
]
const firstId = (choices, fallback = 1) => (choices.length ? choices[0].id : fallback)
/** Entity option builders shared by actions and feedbacks. Choices are read from the live state when the definitions are built. */
export const opt = {
dropdown(id, label, choices, extra = {}) {
return { type: 'dropdown', id, label, choices, default: firstId(choices), minChoicesForSearch: 0, ...extra }
},
multi(id, label, choices) {
const def = choices.length ? [choices[0].id] : []
return { type: 'multidropdown', id, label, choices, default: def, minSelection: 1, minChoicesForSearch: 0 }
},
channel: (inst, id = 'channel', label = 'Input') => opt.dropdown(id, label, inst.state.choices.inputs()),
channels: (inst, id = 'channels', label = 'Inputs') => opt.multi(id, label, inst.state.choices.inputs()),
buss: (inst, kind = 'all', id = 'buss', label) => {
const choices = kind === 'aux' ? inst.state.choices.auxChoices() : kind === 'group' ? inst.state.choices.groupChoices() : inst.state.choices.busses()
return opt.dropdown(id, label || (kind === 'aux' ? 'Aux buss' : kind === 'group' ? 'Group buss' : 'Buss'), choices)
},
busses: (inst, id = 'busses', label = 'Busses') => opt.multi(id, label, inst.state.choices.busses()),
matrix: (inst, id = 'matrix', label = 'Matrix output') => opt.dropdown(id, label, inst.state.choices.matrix()),
matrixIn: (inst, id = 'matrixIn', label = 'Matrix input') => opt.dropdown(id, label, inst.state.choices.matrixInputs()),
cg: (inst, id = 'cg', label = 'Control group') => opt.dropdown(id, label, inst.state.choices.cgs()),
geq: (inst, id = 'geq', label = 'Graphic EQ') => opt.dropdown(id, label, inst.state.choices.geqs()),
snapshot: (inst, id = 'snapshot', label = 'Snapshot') => opt.dropdown(id, label, inst.state.choices.snapshots(), { allowCustom: true, default: firstId(inst.state.choices.snapshots(), 0) }),
state: (label = 'State') => ({ type: 'dropdown', id: 'state', label, choices: STATE_CHOICES, default: 'toggle' }),
level: (id = 'level', label = 'Level (dB)') => ({
type: 'number',
id,
label,
default: 0,
min: LEVEL.min,
max: LEVEL.max,
step: 0.5,
showMinAsNegativeInfinity: true,
}),
delta: (id = 'delta', label = 'Change (dB, for encoders)') => ({ type: 'number', id, label, default: 1, min: -30, max: 30, step: 0.5 }),
pan: (id = 'pan', label = 'Pan (0 = left, 0.5 = centre, 1 = right)') => ({ type: 'number', id, label, default: 0.5, min: 0, max: 1, step: 0.01 }),
pdelta: (id = 'pdelta', label = 'Pan change (for encoders)') => ({ type: 'number', id, label, default: 0.05, min: -1, max: 1, step: 0.01 }),
}
/** Normalise a multidropdown / dropdown value to an array of numbers. */
export const numbers = (v) => (Array.isArray(v) ? v : v === undefined || v === '' ? [] : [v]).map(Number).filter(Number.isFinite)
/** Which entity kinds get actions / feedbacks for a parameter: 'input', 'buss' and/or 'matrix'. */
export const paramEntities = (param) => Object.keys(PARAMS[param].paths)
/** Entity dropdown for a parameter's entity kind. */
export const paramEntityOption = (inst, ent) => (ent === 'input' ? opt.channel(inst) : ent === 'matrix' ? opt.matrix(inst) : opt.buss(inst, 'all'))
/** Switch toggles and value set/adjust actions for the lazily loaded per-channel parameters (PARAMS). */
function getParamActions(inst) {
const out = {}
for (const [param, p] of Object.entries(PARAMS)) {
for (const ent of paramEntities(param)) {
const entOpt = paramEntityOption(inst, ent)
const who = { input: 'Input', buss: 'Buss', matrix: 'Matrix output' }[ent]
const target = (options) => Number(options[entOpt.id])
if (p.type === 'switch') {
out[`${ent}_${param}`] = {
name: `${who}: ${p.label} (on/off)`,
description: 'The current state is read from the console the first time it is needed.',
options: [paramEntityOption(inst, ent), opt.state()],
callback: async ({ options }) => inst.setParamSwitch(ent, target(options), param, options.state),
}
continue
}
const unit = p.adjust === 'percent' ? '%' : p.unit
out[`${ent}_${param}_set`] = {
name: `${who}: set ${p.label.toLowerCase()}`,
description: `Value in ${p.unit}, as the console reports it (not verified on hardware: read the value back first).`,
options: [paramEntityOption(inst, ent), { type: 'number', id: 'value', label: `${p.label} (${p.unit})`, default: p.min < 0 ? 0 : p.min, min: p.min, max: p.max, step: p.step }],
callback: async ({ options }) => inst.setParamValue(ent, target(options), param, Number(options.value)),
}
out[`${ent}_${param}_adjust`] = {
name: `${who}: adjust ${p.label.toLowerCase()} (relative)`,
description: p.adjust === 'percent' ? 'For rotary encoders: each step changes the frequency by this percentage.' : 'For rotary encoders.',
options: [
paramEntityOption(inst, ent),
{ type: 'number', id: 'delta', label: `Change (${unit})`, default: p.delta, min: -(p.adjust === 'percent' ? 100 : (p.max - p.min) / 2), max: p.adjust === 'percent' ? 100 : (p.max - p.min) / 2, step: p.adjust === 'percent' ? 1 : p.step },
],
callback: async ({ options }) => inst.adjustParamValue(ent, target(options), param, Number(options.delta)),
}
}
}
return out
}
/** Action callbacks return nothing, and an error inside one is logged instead of thrown into Companion. */
function quiet(inst, defs) {
for (const def of Object.values(defs)) {
const run = def.callback
def.callback = async (event, context) => {
try {
await run(event, context)
} catch (err) {
inst.log('error', `${event?.actionId || 'action'}: ${err.message}`)
}
}
}
return defs
}
export function getActionDefinitions(inst) {
const each = (list, fn) => Promise.all(numbers(list).map(fn))
const n = (v) => Number(v)
return quiet(inst, {
...getParamActions(inst),
input_mute: {
name: 'Input: mute',
description: 'Mute, unmute or toggle one or more inputs.',
options: [opt.channels(inst), opt.state()],
callback: async ({ options }) => void (await each(options.channels, (ch) => inst.setSwitch('input', 'mute', ch, options.state))),
},
input_solo: {
name: 'Input: solo',
options: [opt.channels(inst), opt.state()],
callback: async ({ options }) => void (await each(options.channels, (ch) => inst.setSwitch('input', 'solo', ch, options.state))),
},
input_fader_set: {
name: 'Input: set fader',
options: [opt.channel(inst), opt.level()],
callback: async ({ options }) => inst.setFader('input', n(options.channel), n(options.level)),
},
input_fader_adjust: {
name: 'Input: adjust fader (relative)',
description: 'For rotary encoders. From -inf the first step up lands at -60 dB.',
options: [opt.channel(inst), opt.delta()],
callback: async ({ options }) => inst.adjustFader('input', n(options.channel), n(options.delta)),
},
input_pan_set: {
name: 'Input: set pan',
options: [opt.channel(inst), opt.pan()],
callback: async ({ options }) => inst.setPan(n(options.channel), n(options.pan)),
},
input_pan_adjust: {
name: 'Input: adjust pan (relative)',
options: [opt.channel(inst), opt.pdelta()],
callback: async ({ options }) => inst.adjustPan(n(options.channel), n(options.pdelta)),
},
input_name_set: {
name: 'Input: set name',
options: [opt.channel(inst), { type: 'textinput', id: 'name', label: 'Name', default: '', useVariables: true }],
callback: async ({ options }) => inst.setInputName(n(options.channel), String(options.name ?? '')),
},
send_on: {
name: 'Aux send: on/off',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.state()],
callback: async ({ options }) => inst.setSendOn(n(options.channel), n(options.buss), options.state),
},
send_level_set: {
name: 'Aux send: set level',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.level()],
callback: async ({ options }) => inst.setSendLevel(n(options.channel), n(options.buss), n(options.level)),
},
send_level_adjust: {
name: 'Aux send: adjust level (relative)',
description: 'For rotary encoders. From -inf the first step up lands at -60 dB.',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.delta()],
callback: async ({ options }) => inst.adjustSendLevel(n(options.channel), n(options.buss), n(options.delta)),
},
send_pan_set: {
name: 'Aux send: set pan',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.pan()],
callback: async ({ options }) => inst.setSendPan(n(options.channel), n(options.buss), n(options.pan)),
},
send_pan_adjust: {
name: 'Aux send: adjust pan (relative)',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.pdelta()],
callback: async ({ options }) => inst.adjustSendPan(n(options.channel), n(options.buss), n(options.pdelta)),
},
group_assign: {
name: 'Group: assign input on/off',
options: [opt.channel(inst), opt.buss(inst, 'group'), opt.state()],
callback: async ({ options }) => inst.setGroup(n(options.channel), n(options.buss), options.state),
},
buss_mute: {
name: 'Buss: mute',
options: [opt.busses(inst), opt.state()],
callback: async ({ options }) => void (await each(options.busses, (b) => inst.setSwitch('buss', 'mute', b, options.state))),
},
buss_solo: {
name: 'Buss: solo',
options: [opt.busses(inst), opt.state()],
callback: async ({ options }) => void (await each(options.busses, (b) => inst.setSwitch('buss', 'solo', b, options.state))),
},
buss_fader_set: {
name: 'Buss: set fader',
options: [opt.buss(inst), opt.level()],
callback: async ({ options }) => inst.setFader('buss', n(options.buss), n(options.level)),
},
buss_fader_adjust: {
name: 'Buss: adjust fader (relative)',
options: [opt.buss(inst), opt.delta()],
callback: async ({ options }) => inst.adjustFader('buss', n(options.buss), n(options.delta)),
},
matrix_mute: {
name: 'Matrix output: mute',
options: [opt.matrix(inst), opt.state()],
callback: async ({ options }) => inst.setSwitch('matrix', 'mute', n(options.matrix), options.state),
},
matrix_fader_set: {
name: 'Matrix output: set fader',
options: [opt.matrix(inst), opt.level()],
callback: async ({ options }) => inst.setFader('matrix', n(options.matrix), n(options.level)),
},
matrix_fader_adjust: {
name: 'Matrix output: adjust fader (relative)',
options: [opt.matrix(inst), opt.delta()],
callback: async ({ options }) => inst.adjustFader('matrix', n(options.matrix), n(options.delta)),
},
matrix_send_on: {
name: 'Matrix send: on/off',
options: [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut'), opt.state()],
callback: async ({ options }) => inst.setMatrixSendOn(n(options.matrixIn), n(options.matrixOut), options.state),
},
matrix_send_level_set: {
name: 'Matrix send: set level',
options: [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut'), opt.level()],
callback: async ({ options }) => inst.setMatrixSendLevel(n(options.matrixIn), n(options.matrixOut), n(options.level)),
},
matrix_send_level_adjust: {
name: 'Matrix send: adjust level (relative)',
options: [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut'), opt.delta()],
callback: async ({ options }) => inst.adjustMatrixSendLevel(n(options.matrixIn), n(options.matrixOut), n(options.delta)),
},
cg_mute: {
name: 'Control group: mute',
options: [opt.cg(inst), opt.state()],
callback: async ({ options }) => inst.setSwitch('cg', 'mute', n(options.cg), options.state),
},
cg_solo: {
name: 'Control group: solo',
options: [opt.cg(inst), opt.state()],
callback: async ({ options }) => inst.setSwitch('cg', 'solo', n(options.cg), options.state),
},
cg_fader_set: {
name: 'Control group: set fader',
options: [opt.cg(inst), opt.level()],
callback: async ({ options }) => inst.setFader('cg', n(options.cg), n(options.level)),
},
cg_fader_adjust: {
name: 'Control group: adjust fader (relative)',
options: [opt.cg(inst), opt.delta()],
callback: async ({ options }) => inst.adjustFader('cg', n(options.cg), n(options.delta)),
},
geq_on: {
name: 'Graphic EQ: in/out',
options: [opt.geq(inst), opt.state('State')],
callback: async ({ options }) => inst.setGeqOn(n(options.geq), options.state),
},
geq_band_set: {
name: 'Graphic EQ: set band gain',
description: 'Band 1-32, gain in dB.',
options: [
opt.geq(inst),
{ type: 'number', id: 'band', label: 'Band', default: 1, min: 1, max: LIMITS.geqBands, step: 1 },
{ type: 'number', id: 'gain', label: 'Gain (dB)', default: 0, min: -12, max: 12, step: 0.5 },
],
callback: async ({ options }) => inst.setGeqBand(n(options.geq), n(options.band), n(options.gain)),
},
geq_band_adjust: {
name: 'Graphic EQ: adjust band gain (relative)',
options: [
opt.geq(inst),
{ type: 'number', id: 'band', label: 'Band', default: 1, min: 1, max: LIMITS.geqBands, step: 1 },
{ type: 'number', id: 'delta', label: 'Change (dB, for encoders)', default: 1, min: -12, max: 12, step: 0.5 },
],
callback: async ({ options }) => inst.adjustGeqBand(n(options.geq), n(options.band), n(options.delta)),
},
snapshot_fire: {
name: 'Snapshot: fire',
description: 'Pick a snapshot, or type a snapshot index. Fires by snapshot list position (0 = first). Verify once on your console before using live.',
options: [opt.snapshot(inst)],
callback: async ({ options }) => inst.fireSnapshot(n(options.snapshot)),
},
snapshot_next: { name: 'Snapshot: next', options: [], callback: async () => inst.fireNextSnapshot() },
snapshot_prev: { name: 'Snapshot: previous', options: [], callback: async () => inst.firePrevSnapshot() },
reload: {
name: 'Console: reload names and levels',
options: [],
callback: async () => inst.reload(),
},
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.sendRaw(options),
},
relay_forget: {
name: 'Relay: forget all iPad clients',
options: [],
callback: async () => inst.forgetRelayClients(),
},
})
}
+321
View File
@@ -0,0 +1,321 @@
import { combineRgb } from '@companion-module/base'
import { LEVEL, METERS, PARAMS, clamp, fmtDb, fmtPan, meterId } from '../protocol.js'
import { horizontalBar, verticalMeter } from '../image.js'
import { opt, paramEntities, paramEntityOption } from './actions.js'
export const COLORS = {
white: combineRgb(255, 255, 255),
black: combineRgb(0, 0, 0),
red: combineRgb(190, 30, 30),
green: combineRgb(0, 150, 55),
amber: combineRgb(220, 150, 0),
dark: combineRgb(30, 30, 30),
bar: combineRgb(0, 200, 90),
}
const n = (v) => Number(v)
/** -60 dB .. +10 dB mapped to 0..1 for the level bars. */
const levelFraction = (db) => clamp((db + 60) / (LEVEL.max + 60), 0, 1)
const colorOption = { type: 'colorpicker', id: 'color', label: 'Bar colour', default: COLORS.bar, returnType: 'number' }
const KIND_VISIBLE = (kind) => `$(options:kind) == '${kind}'`
/**
* Feedback instances are tracked per thing they watch (an entity, a send pair, a meter id, the snapshot, the
* link): each callback registers `fb.id` under a watch key, and a state change re-checks just those instances
* (checkFeedbacksById). The per-type check is only a fallback for keys nobody is known to watch.
*/
export const watchKey = {
entity: (ent, num) => `${ent}:${num}`,
send: (ch, buss) => `send:${ch}:${buss}`,
group: (ch, buss) => `group:${ch}:${buss}`,
meter: (id) => `meter:${id}`,
snapshot: 'snapshot',
connected: 'connected',
relay: 'relay',
}
export function getFeedbackDefinitions(inst) {
const s = inst.state
/** Wrap a callback so the instance is registered under its watch key (key(options) may be null) before it runs. */
const tracked = (key, callback) => (fb, context) => {
if (key) inst.watch(fb, key(fb.options))
return callback(fb, context)
}
const unsubscribe = (fb) => inst.unwatch(fb)
const bool = (name, style, options, key, callback) => ({ type: 'boolean', name, defaultStyle: style, options, callback: tracked(key, callback), unsubscribe })
const value = (name, options, key, callback) => ({ type: 'value', name, options, callback: tracked(key, callback), unsubscribe })
const bar = (name, options, key, make) => ({
type: 'advanced',
name,
affectedProperties: ['imageBuffer'],
options: [...options, colorOption],
callback: tracked(key, (fb) => {
if (!fb.image) return {}
return make(fb, fb.image)
}),
unsubscribe,
})
const RED = { bgcolor: COLORS.red, color: COLORS.white }
const GREEN = { bgcolor: COLORS.green, color: COLORS.white }
const AMBER = { bgcolor: COLORS.amber, color: COLORS.black }
const faderOf = (o) => {
switch (o.kind) {
case 'buss':
return s.getFader('buss', n(o.buss))
case 'matrix':
return s.getFader('matrix', n(o.matrix))
case 'cg':
return s.getFader('cg', n(o.cg))
default:
return s.getFader('input', n(o.channel))
}
}
const compareKey = (o) => {
const kind = ['buss', 'matrix', 'cg'].includes(o.kind) ? o.kind : 'input'
return watchKey.entity(kind, n(o[{ input: 'channel', buss: 'buss', matrix: 'matrix', cg: 'cg' }[kind]]))
}
const compare = (v, o) => v !== undefined && (o.comparison === 'below' ? v < n(o.threshold) : v > n(o.threshold))
const comparisonOption = {
type: 'dropdown',
id: 'comparison',
label: 'Is',
default: 'above',
choices: [
{ id: 'above', label: 'Above' },
{ id: 'below', label: 'Below' },
],
}
const thresholdOption = { type: 'number', id: 'threshold', label: 'Threshold (dB)', default: -20, min: LEVEL.min, max: LEVEL.max, step: 0.5, showMinAsNegativeInfinity: true }
const meterOption = {
type: 'dropdown',
id: 'meter',
label: 'Meter',
default: 'level',
choices: Object.entries(METERS).map(([id, m]) => ({ id, label: m.label })),
}
const meterName = (o) => (METERS[o.meter] ? o.meter : 'level')
const meterKey = (o) => watchKey.meter(meterId(n(o.channel), meterName(o)))
const meterOf = (options) => {
const ch = n(options.channel)
const meter = meterName(options)
inst.subscribeInputMeter(ch, meter)
return s.getMeter(meterId(ch, meter))
}
const entityKey = (ent, id) => (o) => watchKey.entity(ent, n(o[id]))
const sendKey = (o) => watchKey.send(n(o.channel), n(o.buss))
/** Boolean feedback for every switch in PARAMS, on the entity kinds that have it. */
const paramFeedbacks = {}
for (const [param, p] of Object.entries(PARAMS)) {
if (p.type !== 'switch') continue
for (const ent of paramEntities(param)) {
const style = param === 'phantom' ? RED : GREEN
const entOption = paramEntityOption(inst, ent)
paramFeedbacks[`${ent}_${param}_state`] = bool(
`${{ input: 'Input', buss: 'Buss', matrix: 'Matrix output' }[ent]}: ${p.label} is on`,
style,
[entOption],
entityKey(ent, entOption.id),
({ options }) => {
const target = n(options[entOption.id])
inst.ensureParam(ent, target, param)
return s.getParam(ent, target, param) === true
},
)
}
}
return {
...paramFeedbacks,
input_muted: bool('Input: muted', RED, [opt.channel(inst)], entityKey('input', 'channel'), ({ options }) => s.getMute('input', n(options.channel)) === true),
input_solo: bool('Input: solo on', AMBER, [opt.channel(inst)], entityKey('input', 'channel'), ({ options }) => s.getSolo('input', n(options.channel)) === true),
send_on: bool('Aux send: on', GREEN, [opt.channel(inst), opt.buss(inst, 'aux')], sendKey, ({ options }) => {
inst.ensureSend(n(options.channel), n(options.buss))
return s.getSend(n(options.channel), n(options.buss)).on === true
}),
group_assigned: bool('Group: input assigned', GREEN, [opt.channel(inst), opt.buss(inst, 'group')], (o) => watchKey.group(n(o.channel), n(o.buss)), ({ options }) => {
inst.ensureGroup(n(options.channel), n(options.buss))
return s.getGroup(n(options.channel), n(options.buss)) === true
}),
buss_muted: bool('Buss: muted', RED, [opt.buss(inst)], entityKey('buss', 'buss'), ({ options }) => s.getMute('buss', n(options.buss)) === true),
buss_solo: bool('Buss: solo on', AMBER, [opt.buss(inst)], entityKey('buss', 'buss'), ({ options }) => s.getSolo('buss', n(options.buss)) === true),
matrix_muted: bool('Matrix output: muted', RED, [opt.matrix(inst)], entityKey('matrix', 'matrix'), ({ options }) => s.getMute('matrix', n(options.matrix)) === true),
cg_muted: bool('Control group: muted', RED, [opt.cg(inst)], entityKey('cg', 'cg'), ({ options }) => s.getMute('cg', n(options.cg)) === true),
snapshot_current: bool('Snapshot: is the current one', GREEN, [opt.snapshot(inst)], () => watchKey.snapshot, ({ options }) => s.snapshots.current === Math.trunc(n(options.snapshot))),
level_compare: bool(
'Fader level: above/below',
AMBER,
[
{
type: 'dropdown',
id: 'kind',
label: 'What',
default: 'input',
// the entity dropdowns below show or hide on this option, so it must not turn into an expression
disableAutoExpression: true,
choices: [
{ id: 'input', label: 'Input' },
{ id: 'buss', label: 'Buss' },
{ id: 'matrix', label: 'Matrix output' },
{ id: 'cg', label: 'Control group' },
],
},
{ ...opt.channel(inst), isVisibleExpression: KIND_VISIBLE('input') },
{ ...opt.buss(inst), isVisibleExpression: KIND_VISIBLE('buss') },
{ ...opt.matrix(inst), isVisibleExpression: KIND_VISIBLE('matrix') },
{ ...opt.cg(inst), isVisibleExpression: KIND_VISIBLE('cg') },
comparisonOption,
thresholdOption,
],
compareKey,
({ options }) => compare(faderOf(options), options),
),
send_level_compare: bool('Aux send level: above/below', AMBER, [opt.channel(inst), opt.buss(inst, 'aux'), comparisonOption, thresholdOption], sendKey, ({ options }) => {
inst.ensureSend(n(options.channel), n(options.buss))
return compare(s.getSend(n(options.channel), n(options.buss)).level, options)
}),
connected: bool('Console link is alive', GREEN, [], () => watchKey.connected, () => inst.connectedFlag === true),
relay_clients: bool('Relay has at least one iPad', GREEN, [], () => watchKey.relay, () => (inst.relay?.clientCount || 0) > 0),
input_signal: bool(
'Input meter: signal present',
GREEN,
[opt.channel(inst), meterOption, { type: 'number', id: 'threshold', label: 'Signal above (0-127)', default: 5, min: 0, max: 127, step: 1 }],
meterKey,
({ options }) => (meterOf(options)?.value ?? 0) > n(options.threshold),
),
input_peak: bool(
'Input meter: peak (full scale) hold',
RED,
[opt.channel(inst), meterOption, { type: 'number', id: 'hold', label: 'Hold (seconds)', default: 2, min: 0, max: 10, step: 0.5 }],
meterKey,
({ options }) => {
const m = meterOf(options)
return !!m && m.peakAt > 0 && Date.now() - m.peakAt <= n(options.hold) * 1000
},
),
input_fader_value: value('Input: fader level (dB text)', [opt.channel(inst)], entityKey('input', 'channel'), ({ options }) => fmtDb(s.getFader('input', n(options.channel))) || '--'),
input_pan_value: value('Input: pan (text)', [opt.channel(inst)], entityKey('input', 'channel'), ({ options }) => fmtPan(s.getPan(n(options.channel))) || '--'),
send_level_value: value('Aux send: level (dB text)', [opt.channel(inst), opt.buss(inst, 'aux')], sendKey, ({ options }) => {
inst.ensureSend(n(options.channel), n(options.buss))
return fmtDb(s.getSend(n(options.channel), n(options.buss)).level) || '--'
}),
send_on_value: value('Aux send: On/Off (text)', [opt.channel(inst), opt.buss(inst, 'aux')], sendKey, ({ options }) => {
inst.ensureSend(n(options.channel), n(options.buss))
const on = s.getSend(n(options.channel), n(options.buss)).on
return on === undefined ? '--' : on ? 'On' : 'Off'
}),
buss_fader_value: value('Buss: fader level (dB text)', [opt.buss(inst)], entityKey('buss', 'buss'), ({ options }) => fmtDb(s.getFader('buss', n(options.buss))) || '--'),
matrix_fader_value: value('Matrix output: fader level (dB text)', [opt.matrix(inst)], entityKey('matrix', 'matrix'), ({ options }) => fmtDb(s.getFader('matrix', n(options.matrix))) || '--'),
cg_fader_value: value('Control group: fader level (dB text)', [opt.cg(inst)], entityKey('cg', 'cg'), ({ options }) => fmtDb(s.getFader('cg', n(options.cg))) || '--'),
snapshot_name_value: value('Snapshot: current name', [], () => watchKey.snapshot, () => s.snapshotName(s.snapshots.current) ?? '--'),
input_meter_value: value(
'Input meter: value',
[
opt.channel(inst),
meterOption,
{
type: 'dropdown',
id: 'unit',
label: 'Unit',
default: 'raw',
choices: [
{ id: 'raw', label: 'Raw (0-127)' },
{ id: 'percent', label: 'Percent (0-100)' },
],
},
],
meterKey,
({ options }) => {
const v = meterOf(options)?.value ?? 0
return options.unit === 'percent' ? Math.round((v / 127) * 100) : v
},
),
input_fader_bar: bar('Input: fader bar', [opt.channel(inst)], entityKey('input', 'channel'), (fb, image) => {
const v = s.getFader('input', n(fb.options.channel))
return v === undefined ? {} : horizontalBar({ ...image, fraction: levelFraction(v), color: fb.options.color })
}),
send_level_bar: bar('Aux send: level bar', [opt.channel(inst), opt.buss(inst, 'aux')], sendKey, (fb, image) => {
inst.ensureSend(n(fb.options.channel), n(fb.options.buss))
const v = s.getSend(n(fb.options.channel), n(fb.options.buss)).level
return v === undefined ? {} : horizontalBar({ ...image, fraction: levelFraction(v), color: fb.options.color })
}),
buss_fader_bar: bar('Buss: fader bar', [opt.buss(inst)], entityKey('buss', 'buss'), (fb, image) => {
const v = s.getFader('buss', n(fb.options.buss))
return v === undefined ? {} : horizontalBar({ ...image, fraction: levelFraction(v), color: fb.options.color })
}),
input_meter_bar: {
type: 'advanced',
name: 'Input meter: bar (right edge)',
affectedProperties: ['imageBuffer'],
options: [opt.channel(inst), meterOption],
callback: tracked(meterKey, (fb) => {
const m = meterOf(fb.options)
if (!fb.image) return {}
return verticalMeter({ ...fb.image, fraction: (m?.value ?? 0) / 127 })
}),
unsubscribe,
},
}
}
/** The watch key an event concerns, or null (then only the per-type fallback applies). */
export function watchKeyForEvent(ev) {
switch (ev.kind) {
case 'mute':
case 'solo':
case 'fader':
case 'pan':
case 'param':
return watchKey.entity(ev.ent, ev.n)
case 'send':
return watchKey.send(ev.ch, ev.buss)
case 'group':
return watchKey.group(ev.ch, ev.buss)
case 'snapCurrent':
case 'snapName':
return watchKey.snapshot
default:
return null
}
}
/** Fallback: the feedback types to re-check by type when nobody is known to watch the event's key. */
export function feedbackIdsForEvent(ev) {
switch (ev.kind) {
case 'mute':
return { input: ['input_muted'], buss: ['buss_muted'], matrix: ['matrix_muted'], cg: ['cg_muted'] }[ev.ent] || []
case 'solo':
return { input: ['input_solo'], buss: ['buss_solo'] }[ev.ent] || []
case 'fader':
return (
{
input: ['input_fader_value', 'input_fader_bar', 'level_compare'],
buss: ['buss_fader_value', 'buss_fader_bar', 'level_compare'],
matrix: ['matrix_fader_value', 'level_compare'],
cg: ['cg_fader_value', 'level_compare'],
}[ev.ent] || []
)
case 'pan':
return ['input_pan_value']
case 'send':
if (ev.field === 'level') return ['send_level_value', 'send_level_bar', 'send_level_compare']
if (ev.field === 'on') return ['send_on', 'send_on_value']
return []
case 'group':
return ['group_assigned']
case 'param':
return PARAMS[ev.param].type === 'switch' ? [`${ev.ent}_${ev.param}_state`] : []
case 'snapCurrent':
case 'snapName':
return ['snapshot_current', 'snapshot_name_value']
default:
return []
}
}
+265
View File
@@ -0,0 +1,265 @@
import { COLORS } from './feedbacks.js'
/** Hard limits agreed for the preset list (Companion keeps all of it in memory and sends it to the web UI). */
export const MAX_PRESETS = 3000
export const MAX_PRESET_BYTES = 4 * 1024 * 1024
const { white: WHITE, dark: DARK, red: RED, green: GREEN, amber: AMBER, bar: BAR } = COLORS
const fb = (feedbackId, options, bg, color = WHITE) => ({ feedbackId, options, style: { bgcolor: bg, color } })
const act = (actionId, options) => ({ actionId, options })
/** Build every preset from the live state. `withSendKnobs` is the first thing dropped when the list is too big. */
function build(inst, withSendKnobs) {
const s = inst.state
const L = inst.label
const presets = {}
const structure = []
const nameVar = (ent, n) => `$(${L}:${ent}_${n}_name)`
const localFeedback = (variableName, feedbackId, options) => ({ variableName, variableType: 'feedback', feedbackId, options })
const simple = (name, text, down, feedbacks = [], extra = {}) => ({
type: 'simple',
name,
style: { text, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down, up: [] }],
feedbacks,
...extra,
})
/** Rotary preset: press = `press`, turning = `adjust` with ∓1 (the action's delta option). */
const knob = (name, text, press, adjustActionId, adjustOptions, localVariables, feedbacks) => ({
type: 'simple',
name,
style: { text, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [
{
down: press,
up: [],
rotate_left: [act(adjustActionId, { ...adjustOptions, delta: -1 })],
rotate_right: [act(adjustActionId, { ...adjustOptions, delta: 1 })],
},
],
feedbacks,
localVariables,
})
const group = (id, name, ids) => ({ id, type: 'simple', name, presets: ids })
// ---- Inputs
{
const muteIds = []
const knobIds = []
const soloIds = []
const meterIds = { level: [], gate: [], comp: [] }
for (const n of s.numbers('input')) {
const name = s.displayName('input', n)
const tag = `${n} · ${name}`
presets[`input_mute_${n}`] = simple(`Mute: ${tag}`, nameVar('input', n), [act('input_mute', { channels: [n], state: 'toggle' })], [fb('input_muted', { channel: n }, RED)])
presets[`input_knob_${n}`] = knob(
`Fader knob: ${tag}`,
`${nameVar('input', n)}\\n$(local:level) dB`,
[act('input_mute', { channels: [n], state: 'toggle' })],
'input_fader_adjust',
{ channel: n },
[localFeedback('level', 'input_fader_value', { channel: n })],
[fb('input_muted', { channel: n }, RED), { feedbackId: 'input_fader_bar', options: { channel: n, color: BAR } }],
)
presets[`input_solo_${n}`] = simple(`Solo: ${tag}`, `${nameVar('input', n)}\\nSOLO`, [act('input_solo', { channels: [n], state: 'toggle' })], [fb('input_solo', { channel: n }, AMBER, COLORS.black)])
for (const [meter, prefix, label] of [
['level', 'input_meter', 'Meter'],
['gate', 'input_gate_gr', 'Gate reduction'],
['comp', 'input_comp_gr', 'Compressor reduction'],
]) {
presets[`${prefix}_${n}`] = simple(
`${label}: ${tag}`,
nameVar('input', n),
[],
[
{ feedbackId: 'input_meter_bar', options: { channel: n, meter } },
fb('input_signal', { channel: n, meter, threshold: 5 }, GREEN),
fb('input_peak', { channel: n, meter, hold: 2 }, RED),
],
)
meterIds[meter].push(`${prefix}_${n}`)
}
muteIds.push(`input_mute_${n}`)
knobIds.push(`input_knob_${n}`)
soloIds.push(`input_solo_${n}`)
}
if (muteIds.length) {
structure.push({
id: 'inputs',
name: 'Inputs',
definitions: [group('inputs_mute', 'Mute', muteIds), group('inputs_knob', 'Fader (knob)', knobIds), group('inputs_solo', 'Solo', soloIds)],
})
structure.push({
id: 'meters',
name: 'Input meters',
description: 'Meters subscribe on the console when the button first renders.',
definitions: [
group('meters_input', 'Level', meterIds.level),
group('meters_gate', 'Gate gain reduction', meterIds.gate),
group('meters_comp', 'Compressor gain reduction', meterIds.comp),
],
})
}
}
// ---- Busses
{
const muteIds = []
const knobIds = []
for (const n of s.named('buss')) {
const tag = `${n} · ${s.displayName('buss', n)} (${s.bussMode(n)})`
presets[`buss_mute_${n}`] = simple(`Master mute: ${tag}`, nameVar('buss', n), [act('buss_mute', { busses: [n], state: 'toggle' })], [fb('buss_muted', { buss: n }, RED)])
presets[`buss_knob_${n}`] = knob(
`Master fader (knob): ${tag}`,
`${nameVar('buss', n)}\\n$(local:level) dB`,
[act('buss_mute', { busses: [n], state: 'toggle' })],
'buss_fader_adjust',
{ buss: n },
[localFeedback('level', 'buss_fader_value', { buss: n })],
[fb('buss_muted', { buss: n }, RED), { feedbackId: 'buss_fader_bar', options: { buss: n, color: BAR } }],
)
muteIds.push(`buss_mute_${n}`)
knobIds.push(`buss_knob_${n}`)
}
if (muteIds.length) {
structure.push({ id: 'busses', name: 'Busses', definitions: [group('busses_mute', 'Master mute', muteIds), group('busses_knob', 'Master fader (knob)', knobIds)] })
}
}
// ---- Sends to each aux buss
const inputs = s.numbers('input')
for (const b of s.namedBusses('aux')) {
const bname = s.displayName('buss', b)
const onIds = []
const knobIds = []
for (const ch of inputs) {
const cname = s.displayName('input', ch)
const tag = `${ch} · ${cname} → ${b} · ${bname}`
const text = `${nameVar('input', ch)}\\n$(local:level) dB`
const lv = [localFeedback('level', 'send_level_value', { channel: ch, buss: b })]
presets[`send_on_${ch}_${b}`] = simple(`Send on/off: ${tag}`, text, [act('send_on', { channel: ch, buss: b, state: 'toggle' })], [fb('send_on', { channel: ch, buss: b }, GREEN)], { localVariables: lv })
onIds.push(`send_on_${ch}_${b}`)
if (withSendKnobs) {
presets[`send_knob_${ch}_${b}`] = knob(
`Send level (knob): ${tag}`,
text,
[act('send_on', { channel: ch, buss: b, state: 'toggle' })],
'send_level_adjust',
{ channel: ch, buss: b },
lv,
[fb('send_on', { channel: ch, buss: b }, GREEN), { feedbackId: 'send_level_bar', options: { channel: ch, buss: b, color: BAR } }],
)
knobIds.push(`send_knob_${ch}_${b}`)
}
}
const defs = [group(`sends_${b}_on`, 'On/off', onIds)]
if (withSendKnobs) defs.push(group(`sends_${b}_knob`, 'Level (knob)', knobIds))
if (onIds.length) structure.push({ id: `sends_${b}`, name: `Sends to ${bname}`, definitions: defs })
}
// ---- Assign to each group buss
for (const b of s.namedBusses('group')) {
const bname = s.displayName('buss', b)
const ids = []
for (const ch of inputs) {
presets[`group_${ch}_${b}`] = simple(
`Assign: ${ch} · ${s.displayName('input', ch)} → ${b} · ${bname}`,
nameVar('input', ch),
[act('group_assign', { channel: ch, buss: b, state: 'toggle' })],
[fb('group_assigned', { channel: ch, buss: b }, GREEN)],
)
ids.push(`group_${ch}_${b}`)
}
if (ids.length) structure.push({ id: `group_${b}`, name: `Group ${bname}`, definitions: [group(`group_${b}_assign`, 'Assign', ids)] })
}
// ---- Matrix and control groups
{
const muteIds = []
const knobIds = []
for (const n of s.named('matrix')) {
const tag = `${n} · ${s.displayName('matrix', n)}`
presets[`matrix_mute_${n}`] = simple(`Mute: ${tag}`, nameVar('matrix', n), [act('matrix_mute', { matrix: n, state: 'toggle' })], [fb('matrix_muted', { matrix: n }, RED)])
presets[`matrix_knob_${n}`] = knob(
`Fader (knob): ${tag}`,
`${nameVar('matrix', n)}\\n$(local:level) dB`,
[act('matrix_mute', { matrix: n, state: 'toggle' })],
'matrix_fader_adjust',
{ matrix: n },
[localFeedback('level', 'matrix_fader_value', { matrix: n })],
[fb('matrix_muted', { matrix: n }, RED)],
)
muteIds.push(`matrix_mute_${n}`)
knobIds.push(`matrix_knob_${n}`)
}
if (muteIds.length) structure.push({ id: 'matrix', name: 'Matrix', definitions: [group('matrix_mute', 'Mute', muteIds), group('matrix_knob', 'Fader (knob)', knobIds)] })
}
{
const muteIds = []
const knobIds = []
for (const n of s.named('cg')) {
const tag = `${n} · ${s.displayName('cg', n)}`
presets[`cg_mute_${n}`] = simple(`Mute: ${tag}`, nameVar('cg', n), [act('cg_mute', { cg: n, state: 'toggle' })], [fb('cg_muted', { cg: n }, RED)])
presets[`cg_knob_${n}`] = knob(
`Fader (knob): ${tag}`,
`${nameVar('cg', n)}\\n$(local:level) dB`,
[act('cg_mute', { cg: n, state: 'toggle' })],
'cg_fader_adjust',
{ cg: n },
[localFeedback('level', 'cg_fader_value', { cg: n })],
[fb('cg_muted', { cg: n }, RED)],
)
muteIds.push(`cg_mute_${n}`)
knobIds.push(`cg_knob_${n}`)
}
if (muteIds.length) structure.push({ id: 'cgs', name: 'Control groups', definitions: [group('cgs_mute', 'Mute', muteIds), group('cgs_knob', 'Fader (knob)', knobIds)] })
}
// ---- Snapshots
{
const fireIds = []
for (const [index, snap] of [...s.snapshots.list].sort((a, b) => a[0] - b[0])) {
presets[`snap_fire_${index}`] = simple(`Fire ${index} · ${snap.name}`, `${index}\\n${snap.name}`, [act('snapshot_fire', { snapshot: index })], [fb('snapshot_current', { snapshot: index }, GREEN)])
fireIds.push(`snap_fire_${index}`)
}
presets.snap_next = simple('Next snapshot', 'NEXT ▶', [act('snapshot_next', {})])
presets.snap_prev = simple('Previous snapshot', '◀ PREV', [act('snapshot_prev', {})])
presets.snap_current = simple('Current snapshot (display)', `$(${L}:snapshot_index)\\n$(local:snap)`, [], [], {
localVariables: [localFeedback('snap', 'snapshot_name_value', {})],
})
const defs = []
if (fireIds.length) defs.push(group('snapshots_fire', 'Fire', fireIds))
defs.push(group('snapshots_nav', 'Next / previous / current', ['snap_next', 'snap_prev', 'snap_current']))
structure.push({ id: 'snapshots', name: 'Snapshots', definitions: defs })
}
// ---- Console
{
presets.console_reload = simple('Reload from console', `$(${L}:console_name)\\nReload`, [act('reload', {})], [fb('connected', {}, GREEN)])
presets.console_status = simple('Connection status', 'Console', [], [fb('connected', {}, GREEN), { ...fb('connected', {}, RED), isInverted: true }])
presets.console_relay = simple('Relay status', `iPads\\n$(${L}:relay_clients)`, [], [fb('relay_clients', {}, GREEN)])
structure.push({ id: 'console', name: 'Console', definitions: [group('console_all', 'Console', ['console_reload', 'console_status', 'console_relay'])] })
}
return { structure, presets }
}
/**
* Presets for the current state: { structure, presets, stats }. If the list would exceed the agreed
* limits the per-input send level knobs (the largest group) are left out and stats.droppedSendKnobs is set.
*/
export function getPresetDefinitions(inst) {
let built = build(inst, true)
let count = Object.keys(built.presets).length
let bytes = JSON.stringify(built.presets).length
let droppedSendKnobs = false
if (count > MAX_PRESETS || bytes > MAX_PRESET_BYTES) {
built = build(inst, false)
droppedSendKnobs = true
count = Object.keys(built.presets).length
bytes = JSON.stringify(built.presets).length
}
return { ...built, stats: { count, bytes, droppedSendKnobs } }
}
+128
View File
@@ -0,0 +1,128 @@
import { fmtCue, fmtDb, fmtPan } from '../protocol.js'
const ENTITY_VARS = {
input: ['name', 'fader', 'mute', 'pan'],
buss: ['name', 'mode', 'stereo', 'fader', 'mute'],
matrix: ['name', 'fader', 'mute'],
cg: ['name', 'fader', 'mute'],
}
const VAR_LABEL = {
name: 'name',
fader: 'fader level (dB)',
mute: 'mute state',
pan: 'pan',
mode: 'mode (Aux/Group)',
stereo: 'Stereo/Mono',
}
const ENT_LABEL = { input: 'Input', buss: 'Buss', matrix: 'Matrix output', cg: 'Control group' }
/** Variables exist only for entities the console reported (all possible ones until the first load finishes). */
export function getVariableDefinitions(inst) {
const defs = {
console_name: { name: 'Console name' },
session_name: { name: 'Session file name' },
connected: { name: 'Console link alive (Yes/No)' },
relay_clients: { name: 'iPad apps connected through the relay' },
relay_client_ips: { name: 'iPad client IPs' },
last_change: { name: 'Last change reported by the console' },
snapshot_index: { name: 'Current snapshot index' },
snapshot_name: { name: 'Current snapshot name' },
snapshot_cue: { name: 'Current snapshot cue number' },
snapshot_count: { name: 'Number of snapshots' },
}
for (const ent of Object.keys(ENTITY_VARS)) {
for (const n of inst.state.numbers(ent)) {
for (const field of ENTITY_VARS[ent]) defs[`${ent}_${n}_${field}`] = { name: `${ENT_LABEL[ent]} ${n} ${VAR_LABEL[field]}` }
}
}
return defs
}
const muteText = (m) => (m === undefined ? '' : m ? 'Muted' : 'Unmuted')
/** All current values of one entity, keyed by variable id. */
export function entityVariableValues(inst, ent, n) {
const s = inst.state
const p = `${ent}_${n}_`
const out = { [`${p}name`]: s.displayName(ent, n), [`${p}fader`]: fmtDb(s.getFader(ent, n)), [`${p}mute`]: muteText(s.getMute(ent, n)) }
if (ent === 'input') out[`${p}pan`] = fmtPan(s.getPan(n))
if (ent === 'buss') {
out[`${p}mode`] = s.bussMode(n) === 'group' ? 'Group' : 'Aux'
const st = s.busses.get(n)?.stereo
out[`${p}stereo`] = st === undefined ? '' : st ? 'Stereo' : 'Mono'
}
return out
}
export function snapshotVariableValues(inst) {
const s = inst.state.snapshots
const cur = s.list.get(s.current)
return {
snapshot_index: s.current ?? '',
snapshot_name: cur?.name ?? '',
snapshot_cue: cur ? fmtCue(cur.cue) : '',
snapshot_count: s.count ?? s.list.size,
}
}
/** Everything, after the definitions were rebuilt. */
export function getAllVariableValues(inst) {
const s = inst.state
const out = { console_name: s.console.name, session_name: s.console.session, connected: inst.connectedFlag ? 'Yes' : 'No', ...snapshotVariableValues(inst) }
for (const ent of Object.keys(ENTITY_VARS)) for (const n of s.numbers(ent)) Object.assign(out, entityVariableValues(inst, ent, n))
return out
}
/** Variable values affected by one event; {} when none. */
export function getEventVariableValues(inst, ev) {
switch (ev.kind) {
case 'consoleName':
return { console_name: ev.value }
case 'session':
return { session_name: ev.value }
case 'name':
case 'fader':
case 'mute':
case 'pan':
return entityVariableValues(inst, ev.ent, ev.n)
case 'snapCurrent':
case 'snapName':
case 'snapCount':
return snapshotVariableValues(inst)
case 'bussTypes':
case 'bussModes': {
// stereo / mono (and aux / group) for every buss; no definition rebuild for a stereo flag alone
const out = {}
for (const n of inst.state.existing('buss')) Object.assign(out, entityVariableValues(inst, 'buss', n))
return out
}
default:
return {}
}
}
/** Short text for last_change, or '' when the event is not worth showing. */
export function describeChange(inst, ev) {
const s = inst.state
const who = (ent, n) => `${ENT_LABEL[ent]} ${n} (${s.displayName(ent, n)})`
switch (ev.kind) {
case 'fader':
return `${who(ev.ent, ev.n)} fader ${fmtDb(ev.value)}`
case 'mute':
return `${who(ev.ent, ev.n)} ${ev.value ? 'muted' : 'unmuted'}`
case 'solo':
return `${who(ev.ent, ev.n)} solo ${ev.value ? 'on' : 'off'}`
case 'pan':
return `${who(ev.ent, ev.n)} pan ${fmtPan(ev.value)}`
case 'send':
return `Input ${ev.ch} send ${ev.buss} ${ev.field} ${ev.field === 'level' ? fmtDb(ev.value) : ev.value}`
case 'group':
return `Input ${ev.ch} group ${ev.buss} ${ev.value ? 'assigned' : 'unassigned'}`
case 'snapCurrent':
return `Snapshot ${ev.value}`
default:
return ''
}
}
-200
View File
@@ -1,200 +0,0 @@
/**
* 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 }))
+90
View File
@@ -0,0 +1,90 @@
/**
* Tiny RGBA drawing helpers for the advanced "bar" feedbacks. A bar is drawn as a thin strip
* along one edge of the button (not a full-button image), so the strip's pixel buffer is
* returned together with the position Companion should draw it at:
*
* { imageBuffer: <base64 RGBA>, imageBufferEncoding: { pixelFormat: 'RGBA' },
* imageBufferPosition: { x, y, width, height } }
*
* Buffer length is always width * height * 4 of the strip (4 bytes per RGBA pixel).
*/
export const BYTES_PER_PIXEL = 4
/** 0xRRGGBB number (Companion colour) -> [r, g, b] */
export function rgbOf(color) {
const c = Number(color) || 0
return [(c >> 16) & 255, (c >> 8) & 255, c & 255]
}
export class Canvas {
constructor(width, height) {
this.width = width
this.height = height
this.data = Buffer.alloc(width * height * BYTES_PER_PIXEL)
}
fillRect(x, y, w, h, [r, g, b], a = 255) {
const x0 = Math.max(0, x)
const y0 = Math.max(0, y)
const x1 = Math.min(this.width, x + w)
const y1 = Math.min(this.height, y + h)
for (let py = y0; py < y1; py++) {
for (let px = x0; px < x1; px++) {
const o = (py * this.width + px) * BYTES_PER_PIXEL
this.data[o] = r
this.data[o + 1] = g
this.data[o + 2] = b
this.data[o + 3] = a
}
}
}
toBase64() {
return this.data.toString('base64')
}
}
const TRACK = [24, 24, 24]
function result(canvas, x, y) {
return {
imageBuffer: canvas.toBase64(),
imageBufferEncoding: { pixelFormat: 'RGBA' },
imageBufferPosition: { x, y, width: canvas.width, height: canvas.height },
}
}
/** Strip thickness: about 12 % of the button, never thinner than 4 px. */
export function thicknessFor(size) {
return Math.max(4, Math.round(size * 0.12))
}
/** Horizontal bar along the bottom edge, filled left to right to `fraction` (0..1). */
export function horizontalBar({ width, height, fraction, color }) {
const t = Math.min(height, thicknessFor(height))
const canvas = new Canvas(width, t)
const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width)
canvas.fillRect(0, 0, width, t, TRACK)
canvas.fillRect(0, 0, filled, t, rgbOf(color))
return result(canvas, 0, height - t)
}
const ZONES = [
{ upTo: 0.6, rgb: [0, 190, 60] },
{ upTo: 0.85, rgb: [230, 170, 0] },
{ upTo: 1.01, rgb: [220, 30, 30] },
]
/** Vertical meter at the right edge, filled bottom to top; green / amber / red zones by height. */
export function verticalMeter({ width, height, fraction }) {
const t = Math.min(width, thicknessFor(width))
const canvas = new Canvas(t, height)
const filled = Math.round(Math.min(1, Math.max(0, fraction)) * height)
for (let row = 0; row < height; row++) {
const fromBottom = height - 1 - row
const zone = ZONES.find((z) => (fromBottom + 0.5) / height <= z.upTo) || ZONES[ZONES.length - 1]
canvas.fillRect(0, row, t, 1, fromBottom < filled ? zone.rgb : TRACK)
}
return result(canvas, width - t, 0)
}
+1069 -411
View File
File diff suppressed because it is too large Load Diff
+321
View File
@@ -0,0 +1,321 @@
/**
* The DiGiCo S-Series iPad-extension OSC tree ("DiGiCo Pad" / SD style), as the real
* S31 answers it (tools/catalog/s31-catalog.md). Only addresses the console has been seen
* to answer are built here, plus the write-only trigger nodes.
*
* A query is the address plus "/?" with no arguments; the reply comes back on the same
* address without "/?" (see tools/README.md). Entity numbers are 1-based everywhere
* (inputs, busses, matrix outputs, control groups, graphic EQs); snapshot indexes are
* 0-based, as the console reports them.
*
* Event objects produced by parse() are plain data, no Companion types.
*/
import { toBool, toNumber } from './osc.js'
/** Maxima probed on an S31; an S21 simply answers fewer. */
export const LIMITS = { inputs: 60, busses: 25, matrix: 8, matrixIn: 10, cgs: 10, geqs: 16, geqBands: 32 }
/** dB values. -150 is the console's "off" (-inf); anything at or below FLOOR displays as -inf. */
export const LEVEL = { min: -150, max: 10, floor: -90, restart: -60 }
/** Meter ids chosen by this module start here so they never collide with an iPad's (0-19, 200+, 300+). */
export const METER_BASE_ID = 5000
/**
* Input meters, subscribed by sending `<address> ,ii [id, kind]`. The console then streams
* /Meters/values [id, value, ...] (0..127, only changed ids). There is no known buss meter address.
*/
export const METERS = {
level: { label: 'Level (post-fader)', offset: 0, kind: 1, path: 'Channel_Input/post_meter' },
gate: { label: 'Gate gain reduction', offset: 100, kind: 1, path: 'Dynamics/GR_meter' },
comp: { label: 'Compressor gain reduction', offset: 200, kind: 4, path: 'Dynamics/GR_meter' },
}
export function meterId(n, meter = 'level') {
return METER_BASE_ID + (METERS[meter] || METERS.level).offset + Number(n)
}
export const meterIdForInput = (n) => meterId(n, 'level')
/** [address, args] that subscribes this module to one input meter. */
export function meterSubscription(n, meter = 'level') {
const m = METERS[meter] || METERS.level
return [`/Input_Channels/${n}/${m.path}`, [{ i: meterId(n, meter) }, { i: m.kind }]]
}
/**
* Per-channel switches and values the console answers on inputs and busses (trim also on matrix outputs). They are loaded lazily
* (the first time an action or feedback needs one), never bulk-loaded. `paths` is per entity kind;
* a buss uses the Aux_Outputs or Group_Outputs tree according to its mode.
*
* lo_filter_* is the high-pass filter and hi_filter_* the low-pass filter: judged from the example
* values (45.6 Hz and 12831 Hz), the console's own naming is the other way round.
* `phase` is documented 0..3 (stereo inputs have one bit per side); it is treated as on/off here.
* Delay values and send pre/post are deliberately absent: their units / meaning were never verified.
*/
export const PARAMS = {
phantom: { type: 'switch', label: 'Phantom power (+48 V)', paths: { input: 'Channel_Input/phantom' } },
phase: { type: 'switch', label: 'Phase invert', paths: { input: 'Channel_Input/phase', buss: 'Buss_Trim/phase' } },
pad: { type: 'switch', label: 'Input pad', paths: { input: 'Channel_Input/input_pad' } },
eq_in: { type: 'switch', label: 'EQ in', paths: { input: 'EQ/eq_in', buss: 'EQ/eq_in' } },
comp_in: { type: 'switch', label: 'Compressor in', paths: { input: 'Dynamics/comp_in', buss: 'Dynamics/comp_in' } },
gate_in: { type: 'switch', label: 'Gate in', paths: { input: 'Dynamics/gate_in', buss: 'Dynamics/gate_in' } },
hpf_in: { type: 'switch', label: 'High-pass filter in', paths: { input: 'Filters/lo_filter_in' } },
lpf_in: { type: 'switch', label: 'Low-pass filter in', paths: { input: 'Filters/hi_filter_in' } },
delay_on: { type: 'switch', label: 'Delay in', paths: { input: 'Channel_Delay/delay_on', buss: 'Channel_Delay/delay_on' } },
insert_a: { type: 'switch', label: 'Insert A in', paths: { input: 'Insert/insert_A_in', buss: 'Insert/insert_A_in' } },
insert_b: { type: 'switch', label: 'Insert B in', paths: { input: 'Insert/insert_B_in', buss: 'Insert/insert_B_in' } },
trim: { type: 'value', label: 'Trim', unit: 'dB', min: -40, max: 40, step: 0.5, delta: 1, adjust: 'linear', paths: { input: 'Channel_Input/trim', buss: 'Buss_Trim/trim', matrix: 'Buss_Trim/trim' } },
gain: { type: 'value', label: 'Analog gain', unit: 'dB', min: 0, max: 60, step: 0.5, delta: 1, adjust: 'linear', paths: { input: 'Channel_Input/analog_gain' } },
hpf_freq: { type: 'value', label: 'High-pass filter frequency', unit: 'Hz', min: 20, max: 20000, step: 1, delta: 5, adjust: 'percent', paths: { input: 'Filters/lo_filter_freq' } },
lpf_freq: { type: 'value', label: 'Low-pass filter frequency', unit: 'Hz', min: 20, max: 20000, step: 1, delta: 5, adjust: 'percent', paths: { input: 'Filters/hi_filter_freq' } },
}
const PARAM_BY_PATH = { input: {}, buss: {}, matrix: {} }
for (const [param, p] of Object.entries(PARAMS)) for (const [ent, path] of Object.entries(p.paths)) PARAM_BY_PATH[ent][path] = param
export const COUNT_FIELDS = {
Input_Channels: 'inputs',
Aux_Outputs: 'busses',
Group_Outputs: 'busses',
Control_Groups: 'cgs',
Matrix_Outputs: 'matrix',
Matrix_Inputs: 'matrixIn',
Graphic_EQ: 'geqs',
Talkback_Outputs: 'talkback',
Multis: 'multis',
}
const TREE = { input: 'Input_Channels', aux: 'Aux_Outputs', group: 'Group_Outputs', matrix: 'Matrix_Outputs', cg: 'Control_Groups' }
/** ent: 'input' | 'buss' | 'matrix' | 'cg'; side matters for 'buss' only ('aux' | 'group'). */
function tree(ent, side) {
if (ent === 'buss') return side === 'group' ? TREE.group : TREE.aux
return TREE[ent]
}
export const addr = {
consoleName: '/Console/Name',
session: '/Console/Session/Filename',
count: (field) => `/Console/${Object.keys(COUNT_FIELDS).find((k) => COUNT_FIELDS[k] === field)}`,
bussTypes: '/Console/Aux_Outputs/types',
auxModes: '/Console/Aux_Outputs/modes',
groupModes: '/Console/Group_Outputs/modes',
inputModes: '/Console/Input_Channels/modes',
name(ent, n, side) {
if (ent === 'input') return `/Input_Channels/${n}/Channel_Input/name`
if (ent === 'buss') return `/${tree(ent, side)}/${n}/Buss_Trim/name`
if (ent === 'matrix') return `/Matrix_Outputs/${n}/Buss_Trim/name`
if (ent === 'cg') return `/Control_Groups/${n}/name`
if (ent === 'geq') return `/Graphic_EQ/${n}/name`
throw new Error(`no name address for ${ent}`)
},
fader: (ent, n, side) => `/${tree(ent, side)}/${n}/fader`,
mute: (ent, n, side) => `/${tree(ent, side)}/${n}/mute`,
solo: (ent, n, side) => `/${tree(ent, side)}/${n}/solo`,
pan: (n) => `/Input_Channels/${n}/Panner/pan`,
sendLevel: (ch, buss) => `/Input_Channels/${ch}/Aux_Send/${buss}/send_level`,
sendOn: (ch, buss) => `/Input_Channels/${ch}/Aux_Send/${buss}/send_on`,
sendPan: (ch, buss) => `/Input_Channels/${ch}/Aux_Send/${buss}/send_pan`,
groupAssign: (ch, buss) => `/Input_Channels/${ch}/Group_Send/${buss}/group`,
matrixSendLevel: (inN, outN) => `/Matrix_Inputs/${inN}/Matrix_Send/${outN}/send_level`,
matrixSendOn: (inN, outN) => `/Matrix_Inputs/${inN}/Matrix_Send/${outN}/send_on`,
geqIn: (n) => `/Graphic_EQ/${n}/geq_in`,
geqGain: (n, band) => `/Graphic_EQ/${n}/geq_gain_${band}`,
currentSnapshot: '/Snapshots/Current_Snapshot',
snapshotNames: '/Snapshots/names',
snapshotCount: '/Snapshots/count',
fireSnapshot: '/Snapshots/Fire_Snapshot_number',
fireNext: '/Snapshots/Fire_Next_Snapshot',
firePrev: '/Snapshots/Fire_Prev_Snapshot',
meterValues: '/Meters/values',
meterClear: '/Meters/clear',
inputMeter: (n) => `/Input_Channels/${n}/Channel_Input/post_meter`,
/** A per-channel switch or value from PARAMS; undefined when this entity kind does not have it. */
param(ent, n, side, param) {
const path = PARAMS[param]?.paths[ent]
return path ? `/${tree(ent, side)}/${n}/${path}` : undefined
},
}
/** [address + "/?", []] */
export const query = (address) => [`${address}/?`, []]
/** Canonical keys for "something about this value changed" (used for waiting on a reply). */
export const K = {
value: (ent, n, field, side) => (ent === 'buss' ? `buss:${side}:${n}:${field}` : `${ent}:${n}:${field}`),
send: (ch, buss, field) => `send:${ch}:${buss}:${field}`,
group: (ch, buss) => `group:${ch}:${buss}`,
msend: (inN, outN, field) => `msend:${inN}:${outN}:${field}`,
geq: (n, field, band) => (band ? `geq:${n}:${field}:${band}` : `geq:${n}:${field}`),
param: (ent, n, param, side) => `param:${ent}:${side || '-'}:${n}:${param}`,
}
const num = (v) => toNumber(v)
const str = (v) => (v === undefined || v === null ? '' : String(v))
const RE_COUNT = /^\/Console\/(Input_Channels|Aux_Outputs|Group_Outputs|Control_Groups|Matrix_Outputs|Matrix_Inputs|Graphic_EQ|Talkback_Outputs|Multis)$/
const RE_NAME = /^\/(?:Input_Channels\/(\d+)\/Channel_Input|(Aux_Outputs|Group_Outputs|Matrix_Outputs)\/(\d+)\/Buss_Trim|(Control_Groups|Graphic_EQ)\/(\d+))\/name$/
const RE_LEVEL = /^\/(Input_Channels|Aux_Outputs|Group_Outputs|Matrix_Outputs|Control_Groups)\/(\d+)\/(fader|mute|solo)$/
const RE_PAN = /^\/Input_Channels\/(\d+)\/Panner\/pan$/
const RE_AUX_SEND = /^\/Input_Channels\/(\d+)\/Aux_Send\/(\d+)\/(send_level|send_on|send_pan)$/
const RE_GROUP_SEND = /^\/Input_Channels\/(\d+)\/Group_Send\/(\d+)\/group$/
const RE_MATRIX_SEND = /^\/Matrix_Inputs\/(\d+)\/Matrix_Send\/(\d+)\/(send_level|send_on)$/
const RE_GEQ = /^\/Graphic_EQ\/(\d+)\/(geq_in|geq_gain_(\d+))$/
const RE_PARAM = /^\/(Input_Channels|Aux_Outputs|Group_Outputs|Matrix_Outputs)\/(\d+)\/((?:Channel_Input|Filters|Channel_Delay|EQ|Dynamics|Insert|Buss_Trim)\/[\w-]+)$/
const ENT_BY_TREE = { Input_Channels: ['input'], Aux_Outputs: ['buss', 'aux'], Group_Outputs: ['buss', 'group'], Matrix_Outputs: ['matrix'], Control_Groups: ['cg'], Graphic_EQ: ['geq'] }
/**
* Turn an inbound message into a normalised event, or null when it is not something the
* module uses. Events: { kind, key?, ... } where kind is one of
* consoleName session count bussTypes bussModes inputModes name fader mute solo pan
* send group msend geq param snapCurrent snapName snapCount meters
*/
export function parse(address, args = []) {
let m
if (address === addr.consoleName) return { kind: 'consoleName', value: str(args[0]) }
if (address === addr.session) return { kind: 'session', value: str(args[0]) }
if ((m = RE_COUNT.exec(address))) return { kind: 'count', field: COUNT_FIELDS[m[1]], source: m[1], value: num(args[0]) }
if (address === addr.bussTypes) return { kind: 'bussTypes', values: args.map(num) }
if (address === addr.auxModes) return { kind: 'bussModes', side: 'aux', values: args.map(num) }
if (address === addr.groupModes) return { kind: 'bussModes', side: 'group', values: args.map(num) }
if (address === addr.inputModes) return { kind: 'inputModes', values: args.map(num) }
if ((m = RE_NAME.exec(address))) {
if (m[1]) return { kind: 'name', ent: 'input', n: +m[1], value: str(args[0]), key: K.value('input', +m[1], 'name') }
if (m[2]) {
const [ent, side] = ENT_BY_TREE[m[2]]
return { kind: 'name', ent, side, n: +m[3], value: str(args[0]), key: K.value(ent, +m[3], 'name', side) }
}
const [ent] = ENT_BY_TREE[m[4]]
return { kind: 'name', ent, n: +m[5], value: str(args[0]), key: K.value(ent, +m[5], 'name') }
}
if ((m = RE_LEVEL.exec(address))) {
const [ent, side] = ENT_BY_TREE[m[1]]
const field = m[3]
const value = field === 'fader' ? num(args[0]) : toBool(args[0])
return { kind: field, ent, side, n: +m[2], value, key: K.value(ent, +m[2], field, side) }
}
if ((m = RE_PAN.exec(address))) return { kind: 'pan', ent: 'input', n: +m[1], value: num(args[0]), key: K.value('input', +m[1], 'pan') }
if ((m = RE_AUX_SEND.exec(address))) {
const field = { send_level: 'level', send_on: 'on', send_pan: 'pan' }[m[3]]
const value = field === 'on' ? toBool(args[0]) : num(args[0])
return { kind: 'send', ch: +m[1], buss: +m[2], field, value, key: K.send(+m[1], +m[2], field) }
}
if ((m = RE_GROUP_SEND.exec(address))) return { kind: 'group', ch: +m[1], buss: +m[2], value: toBool(args[0]), key: K.group(+m[1], +m[2]) }
if ((m = RE_MATRIX_SEND.exec(address))) {
const field = m[3] === 'send_on' ? 'on' : 'level'
const value = field === 'on' ? toBool(args[0]) : num(args[0])
return { kind: 'msend', input: +m[1], out: +m[2], field, value, key: K.msend(+m[1], +m[2], field) }
}
if ((m = RE_GEQ.exec(address))) {
if (m[2] === 'geq_in') return { kind: 'geq', n: +m[1], field: 'on', value: toBool(args[0]), key: K.geq(+m[1], 'on') }
return { kind: 'geq', n: +m[1], field: 'gain', band: +m[3], value: num(args[0]), key: K.geq(+m[1], 'gain', +m[3]) }
}
if ((m = RE_PARAM.exec(address))) {
const [ent, side] = ENT_BY_TREE[m[1]]
const param = PARAM_BY_PATH[ent]?.[m[3]]
if (param) {
const raw = num(args[0])
const value = PARAMS[param].type === 'switch' ? raw !== 0 : raw
return { kind: 'param', ent, side, n: +m[2], param, value, key: K.param(ent, +m[2], param, side) }
}
}
if (address === addr.currentSnapshot) return { kind: 'snapCurrent', value: num(args[0]) }
if (address === addr.snapshotCount) return { kind: 'snapCount', value: num(args[0]) }
if (address === '/Snapshots/name') {
// one reply per snapshot to /Snapshots/names/?: [index, cue number x 100, ?, name]
return { kind: 'snapName', index: num(args[0]), cue: num(args[1]), name: str(args[args.length - 1]) }
}
if (address === addr.meterValues) {
const pairs = []
for (let i = 0; i + 1 < args.length; i += 2) pairs.push([num(args[i]), num(args[i + 1])])
return { kind: 'meters', pairs }
}
return null
}
/** dB text for a level: "-inf" at/below the floor, one decimal otherwise. */
export function fmtDb(v) {
if (typeof v !== 'number' || !Number.isFinite(v)) return ''
if (v <= LEVEL.floor) return '-inf'
const r = Math.round(v * 10) / 10
return String(Object.is(r, -0) ? 0 : r)
}
/** Pan 0..1 (0.5 centre) as "C", "L50", "R30". */
export function fmtPan(p) {
if (typeof p !== 'number' || !Number.isFinite(p)) return ''
const pct = Math.round((p - 0.5) * 200)
if (pct === 0) return 'C'
return pct < 0 ? `L${-pct}` : `R${pct}`
}
/** Snapshot cue number as the surface shows it: raw 100 -> "1", 250 -> "2.5". */
export function fmtCue(raw) {
if (typeof raw !== 'number' || !Number.isFinite(raw)) return ''
return String(Number((raw / 100).toFixed(2)))
}
export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v))
/**
* New dB value for a relative move. From -inf the first upward step lands at LEVEL.restart,
* downward steps stop at -inf, everything else clamps to the console range.
*/
export function adjustedLevel(current, delta, lo = LEVEL.min, hi = LEVEL.max) {
const d = Number(delta)
if (current === undefined || !Number.isFinite(current) || current <= LEVEL.floor) {
return d > 0 ? clamp(LEVEL.restart, lo, hi) : lo
}
const next = clamp(current + d, lo, hi)
return next <= LEVEL.floor ? lo : next
}
const MAX_BY_TREE = {
Input_Channels: LIMITS.inputs,
Aux_Outputs: LIMITS.busses,
Group_Outputs: LIMITS.busses,
Matrix_Outputs: LIMITS.matrix,
Matrix_Inputs: LIMITS.matrixIn,
Control_Groups: LIMITS.cgs,
Graphic_EQ: LIMITS.geqs,
}
const MAX_BY_SEND = { Aux_Send: LIMITS.busses, Group_Send: LIMITS.busses, Matrix_Send: LIMITS.matrix }
const isIndex = (text, max) => /^[1-9]\d*$/.test(String(text)) && Number(text) <= max
/**
* Central index check for an entity address: null when fine (or not an entity address at all, e.g. /Console/...
* or a raw OSC address), otherwise a short reason. Entity numbers must be whole numbers from 1 up to the
* S31 maximum (inputs 60, busses 25, matrix 8 / 10, control groups 10, graphic EQs 16, send 1-25, band 1-32).
*/
export function indexProblem(address) {
if (typeof address !== 'string' || !address.startsWith('/')) return 'not an OSC address'
const seg = address.split('/')
const max = MAX_BY_TREE[seg[1]]
if (max === undefined) return null
if (!isIndex(seg[2], max)) return `${seg[1]} number "${seg[2]}" is not a whole number from 1 to ${max}`
const subMax = MAX_BY_SEND[seg[3]]
if (subMax !== undefined && !isIndex(seg[4], subMax)) return `${seg[3]} number "${seg[4]}" is not a whole number from 1 to ${subMax}`
const band = /^geq_gain_(.*)$/.exec(seg[3] || '')
if (band && !isIndex(band[1], LIMITS.geqBands)) return `band "${band[1]}" is not a whole number from 1 to ${LIMITS.geqBands}`
return null
}
/** Two parsed event values are the same (numbers within float32 noise). */
export function sameValue(a, b) {
if (typeof a === 'number' && typeof b === 'number') return Math.abs(a - b) < 1e-3
return a === b
}
+76 -21
View File
@@ -11,43 +11,50 @@ import { EventEmitter } from 'node:events'
* fanned out to every app seen recently. Nothing is rewritten, so the relay
* does not care which dialect the iPad extension speaks.
*
* The client table can be passed in (`clients`) so it survives closing and reopening the relay
* on another port. It holds at most `maxClients` entries (the least recently seen is evicted),
* and an iPad that has been quiet for `timeoutMs` is forgotten, except when `timeoutMs` is 0
* (never forget): an iPad that only watches sends nothing.
*
* 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' }) {
constructor({ port, clientPort = 0, timeoutMs = 3600000, maxClients = 16, clients = new Map(), bindAddress = '0.0.0.0' }) {
super()
this.port = Number(port)
this.clientPort = Number(clientPort) || 0
this.timeoutMs = Number(timeoutMs) || 180000
this.timeoutMs = Number(timeoutMs) || 0 // 0 = never forget
this.maxClients = Number(maxClients) || 16
this.bindAddress = bindAddress
this.socket = null
this.clients = new Map() // ip -> { port, lastSeen, rx, tx }
this.clients = clients // 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 socket
let settled = false
socket.on('error', (err) => {
if (!settled) {
const fail = (err) => {
settled = true
try {
socket?.close()
} catch (_e) {
/* never opened */
}
reject(err)
} else this.emit('error', err)
}
try {
socket = dgram.createSocket({ type: 'udp4', reuseAddr: false })
socket.on('error', (err) => {
if (!settled) fail(err)
else if (this.listenerCount('error')) 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.touch(rinfo)
this.emit('fromClient', buf, rinfo)
})
socket.bind(this.port, this.bindAddress, () => {
@@ -56,24 +63,65 @@ export class Relay extends EventEmitter {
this.emit('listening', socket.address())
resolve(socket.address())
})
} catch (err) {
fail(err)
}
})
}
/** Note a datagram from an iPad: register or refresh its entry, evicting the least recently seen when the table is full. */
touch(rinfo) {
const now = Date.now()
let client = this.clients.get(rinfo.address)
if (!client) {
while (this.clients.size >= this.maxClients) this.evictOldest()
client = { port: rinfo.port, lastSeen: now, rx: 0, tx: 0 }
this.clients.set(rinfo.address, client)
this.emit('client', rinfo)
}
client.port = rinfo.port
client.lastSeen = now
client.rx++
}
evictOldest() {
let oldest = null
for (const [ip, c] of this.clients) if (oldest === null || c.lastSeen < this.clients.get(oldest).lastSeen) oldest = ip
if (oldest === null) return
this.clients.delete(oldest)
this.emit('clientGone', oldest)
}
isLive(client, now = Date.now()) {
return this.timeoutMs === 0 || now - client.lastSeen <= this.timeoutMs
}
/** Apply a changed iPad receive port / idle timeout to the running relay. */
update({ clientPort, timeoutMs }) {
if (clientPort !== undefined) this.clientPort = Number(clientPort) || 0
if (timeoutMs !== undefined) this.timeoutMs = Number(timeoutMs) || 0
}
/** 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) {
if (!this.isLive(client, now)) {
this.clients.delete(ip)
this.emit('clientGone', ip)
continue
}
const port = this.clientPort || client.port
try {
this.socket.send(buf, port, ip, (err) => {
if (err) this.emit('error', err)
if (err) this.emitError(err, `${ip}:${port}`)
})
} catch (err) {
this.emitError(err, `${ip}:${port}`)
continue
}
client.tx++
this.txCount++
n++
@@ -81,11 +129,18 @@ export class Relay extends EventEmitter {
return n
}
emitError(err, dest) {
err.dest = dest
if (this.listenerCount('error')) this.emit('error', err)
}
get clientCount() {
return this.liveClients().length
}
liveClients() {
const now = Date.now()
let n = 0
for (const c of this.clients.values()) if (now - c.lastSeen <= this.timeoutMs) n++
return n
return [...this.clients].filter(([, c]) => this.isLive(c, now)).map(([ip, c]) => ({ ip, ...c }))
}
clientList() {
+503
View File
@@ -0,0 +1,503 @@
/**
* Everything the module knows about the console, keyed by entity number, plus the dropdown
* choice builders and the query plans used to load it.
*
* Entities: input, buss (aux and group are two views of the same 25 busses, named and levelled
* on the side matching the buss mode), matrix (matrix outputs), minput (matrix inputs, no
* name address), cg (control groups), geq (graphic EQs). All numbers are 1-based.
*
* applyMessage(address, args) is the single entry point for console traffic (and for the
* module's own optimistic writes): it returns the parsed event annotated with
* changed the stored value differs from before
* structural names, buss modes (aux/group), counts or the snapshot list changed (definitions need a rebuild;
* stereo/mono flags alone never are)
*/
import { LIMITS, K, addr, query, parse, fmtCue } from './protocol.js'
const EPS = 1e-4
/** Assign and report whether the value actually changed. */
function setField(obj, field, value) {
const old = obj[field]
obj[field] = value
if (typeof value === 'number' && typeof old === 'number') return Math.abs(value - old) > EPS
return old !== value
}
function rec(map, key) {
let r = map.get(key)
if (!r) {
r = {}
map.set(key, r)
}
return r
}
/** One of the 25 busses. aux / group hold the fader, mute and solo of each side. */
export class Buss {
constructor(n) {
this.n = n
this.auxName = undefined
this.groupName = undefined
this.type = undefined // 'aux' | 'group' once /Console/Aux_Outputs/types answered
this.modeAux = undefined // 1 mono / 2 stereo, from /Console/Aux_Outputs/modes
this.modeGroup = undefined // from /Console/Group_Outputs/modes
this.aux = {}
this.group = {}
}
/** The authoritative `types` array wins; without it, a buss named only on the Group side is a group. */
get mode() {
if (this.type) return this.type
const g = (this.groupName || '').trim()
const a = (this.auxName || '').trim()
return g && !a ? 'group' : 'aux'
}
get name() {
return this.mode === 'group' ? this.groupName : this.auxName
}
get stereo() {
const m = this.mode === 'group' ? (this.modeGroup ?? this.modeAux) : (this.modeAux ?? this.modeGroup)
return m === undefined ? undefined : m === 2
}
get fader() {
return this[this.mode].fader
}
get mute() {
return this[this.mode].mute
}
get solo() {
return this[this.mode].solo
}
}
const FALLBACK_NAME = { input: 'Input', matrix: 'Matrix', cg: 'CG', geq: 'Graphic EQ', minput: 'Matrix In' }
export class State {
constructor() {
this.loaded = false
this.reset()
this.choices = this.#makeChoices()
}
reset() {
this.loaded = false
this.console = { name: '', session: '' }
this.counts = {}
this.inputs = new Map()
this.busses = new Map()
this.matrix = new Map()
this.cgs = new Map()
this.geqs = new Map()
this.sends = new Map() // "ch:buss" -> { level, on, pan }
this.groups = new Map() // "ch:buss" -> boolean
this.matrixSends = new Map() // "in:out" -> { level, on }
this.params = new Map() // K.param(...) -> boolean | number (per-channel switches and values, loaded lazily)
this.snapshots = { current: undefined, count: undefined, list: new Map() } // index -> { name, cue }
this.meters = new Map() // meter id -> { value, at, peakAt }
}
// ---------- inbound ----------
applyMessage(address, args = []) {
const ev = parse(address, args)
if (!ev) return null
ev.changed = false
ev.structural = false
switch (ev.kind) {
case 'consoleName':
ev.changed = setField(this.console, 'name', ev.value)
break
case 'session':
ev.changed = setField(this.console, 'session', ev.value)
break
case 'count':
if (Number.isFinite(ev.value)) {
ev.changed = this.counts[ev.field] !== ev.value
this.counts[ev.field] = ev.value
ev.structural = ev.changed
}
break
case 'bussTypes': {
const r = this.#applyBussArray(ev.values, (b, v) => (b.type = v === 1 ? 'aux' : v === 0 ? 'group' : undefined))
ev.structural = r.mode // the buss mode shows in labels and decides which presets exist
ev.changed = r.mode || r.stereo
break
}
case 'bussModes': {
// stereo / mono is shown in a variable only: it never rebuilds the definitions
const field = ev.side === 'group' ? 'modeGroup' : 'modeAux'
const r = this.#applyBussArray(ev.values, (b, v) => (b[field] = v))
ev.changed = r.mode || r.stereo
break
}
case 'inputModes':
ev.values.forEach((v, i) => {
if (setField(rec(this.inputs, i + 1), 'stereo', v === 2)) ev.changed = true
})
break
case 'name':
ev.changed = this.#applyName(ev)
ev.structural = ev.changed
break
case 'fader':
case 'mute':
case 'solo':
case 'pan':
ev.changed = setField(this.#levelTarget(ev), ev.kind, ev.value)
break
case 'send':
ev.changed = setField(rec(this.sends, `${ev.ch}:${ev.buss}`), ev.field, ev.value)
break
case 'group': {
const key = `${ev.ch}:${ev.buss}`
ev.changed = this.groups.get(key) !== ev.value
this.groups.set(key, ev.value)
break
}
case 'msend':
ev.changed = setField(rec(this.matrixSends, `${ev.input}:${ev.out}`), ev.field, ev.value)
break
case 'param': {
const old = this.params.get(ev.key)
this.params.set(ev.key, ev.value)
ev.changed = typeof old === 'number' && typeof ev.value === 'number' ? Math.abs(old - ev.value) > EPS : old !== ev.value
break
}
case 'geq': {
const g = rec(this.geqs, ev.n)
if (ev.field === 'on') ev.changed = setField(g, 'on', ev.value)
else {
if (!g.gains) g.gains = new Map()
ev.changed = g.gains.get(ev.band) !== ev.value
g.gains.set(ev.band, ev.value)
}
break
}
case 'snapCurrent':
ev.changed = this.snapshots.current !== ev.value
this.snapshots.current = ev.value
break
case 'snapCount':
ev.changed = this.snapshots.count !== ev.value
this.snapshots.count = ev.value
break
case 'snapName': {
const old = this.snapshots.list.get(ev.index)
this.snapshots.list.set(ev.index, { name: ev.name, cue: ev.cue })
ev.changed = !old || old.name !== ev.name || old.cue !== ev.cue
ev.structural = ev.changed
break
}
case 'meters': {
const now = Date.now()
for (const [id, value] of ev.pairs) {
const m = this.meters.get(id) || { value: 0, at: 0, peakAt: 0 }
m.value = value
m.at = now
if (value >= 127) m.peakAt = now
this.meters.set(id, m)
}
ev.changed = ev.pairs.length > 0
break
}
default:
break
}
return ev
}
/** Apply `fn(buss, value)` over a per-buss array; reports whether any buss mode or stereo flag changed. */
#applyBussArray(values, fn) {
const r = { mode: false, stereo: false }
values.forEach((v, i) => {
const b = this.#buss(i + 1)
const before = [b.mode, b.stereo]
fn(b, v)
if (b.mode !== before[0]) r.mode = true
if (b.stereo !== before[1]) r.stereo = true
})
return r
}
#buss(n) {
let b = this.busses.get(n)
if (!b) {
b = new Buss(n)
this.busses.set(n, b)
}
return b
}
#applyName(ev) {
if (ev.ent === 'buss') {
const b = this.#buss(ev.n)
return setField(b, ev.side === 'group' ? 'groupName' : 'auxName', ev.value)
}
return setField(rec(this.#map(ev.ent), ev.n), 'name', ev.value)
}
#levelTarget(ev) {
if (ev.ent === 'buss') return this.#buss(ev.n)[ev.side === 'group' ? 'group' : 'aux']
return rec(this.#map(ev.ent), ev.n)
}
#map(ent) {
switch (ent) {
case 'input':
return this.inputs
case 'matrix':
return this.matrix
case 'cg':
return this.cgs
case 'geq':
return this.geqs
default:
throw new Error(`no entity map for ${ent}`)
}
}
// ---------- existence, names ----------
/** Highest number worth asking about: the console's own count when it told us, else the S31 maximum. */
limit(ent) {
const c = this.counts
switch (ent) {
case 'input':
return c.inputs ?? LIMITS.inputs
case 'buss':
return c.busses ?? LIMITS.busses
case 'matrix':
return c.matrix ?? LIMITS.matrix
case 'minput':
return c.matrixIn ?? LIMITS.matrixIn
case 'cg':
return c.cgs ?? LIMITS.cgs
case 'geq':
return c.geqs ?? LIMITS.geqs
default:
return 0
}
}
/** An entity exists when its name answered (matrix inputs have no name: they exist while matrix outputs do). */
has(ent, n) {
if (!(n >= 1 && n <= this.limit(ent))) return false
switch (ent) {
case 'buss': {
const b = this.busses.get(n)
return !!b && (b.auxName !== undefined || b.groupName !== undefined)
}
case 'minput':
return this.limit('matrix') > 0 && (this.counts.matrixIn !== undefined || this.matrix.size > 0)
default:
return this.#map(ent).get(n)?.name !== undefined
}
}
/** Numbers of the entities that exist (their name answered). */
existing(ent) {
const out = []
const max = this.limit(ent)
for (let n = 1; n <= max; n++) if (this.has(ent, n)) out.push(n)
return out
}
/** Entity numbers for dropdowns and variables: those that exist; before the first load finishes and while nothing has answered, every possible number. */
numbers(ent) {
const out = this.existing(ent)
if (!out.length && !this.loaded) for (let n = 1; n <= this.limit(ent); n++) out.push(n)
return out
}
/** The name as the console reports it (may be ""), or undefined while unknown. */
rawName(ent, n) {
if (ent === 'buss') return this.busses.get(n)?.name
if (ent === 'minput') return undefined
return this.#map(ent).get(n)?.name
}
/** Name for labels and variables: the console's, or "Input 7" / "Aux 3" / "Group 24" style fallbacks. */
displayName(ent, n) {
const raw = this.rawName(ent, n)
if (raw && raw.trim()) return raw
if (ent === 'buss') return `${this.bussMode(n) === 'group' ? 'Group' : 'Aux'} ${n}`
return `${FALLBACK_NAME[ent]} ${n}`
}
bussMode(n) {
return this.busses.get(n)?.mode ?? 'aux'
}
/** Existing entities that carry a name; unnamed busses and control groups are unused slots and get no presets. */
named(ent) {
return this.numbers(ent).filter((n) => (this.rawName(ent, n) || '').trim())
}
/** Named busses whose active side is `mode` ('aux' | 'group'). */
namedBusses(mode) {
return this.named('buss').filter((n) => !mode || this.bussMode(n) === mode)
}
// ---------- levels and switches ----------
getFader(ent, n) {
if (ent === 'buss') return this.busses.get(n)?.fader
return this.#map(ent).get(n)?.fader
}
getMute(ent, n) {
if (ent === 'buss') return this.busses.get(n)?.mute
return this.#map(ent).get(n)?.mute
}
getSolo(ent, n) {
if (ent === 'buss') return this.busses.get(n)?.solo
return this.#map(ent).get(n)?.solo
}
getPan(n) {
return this.inputs.get(n)?.pan
}
getSend(ch, buss) {
return this.sends.get(`${ch}:${buss}`) || {}
}
getGroup(ch, buss) {
return this.groups.get(`${ch}:${buss}`)
}
getMatrixSend(inN, outN) {
return this.matrixSends.get(`${inN}:${outN}`) || {}
}
getGeq(n) {
return this.geqs.get(n) || {}
}
/** A per-channel switch (boolean) or value from PARAMS; a buss reads the side matching its mode. */
getParam(ent, n, param) {
return this.params.get(K.param(ent, n, param, ent === 'buss' ? this.bussMode(n) : undefined))
}
getMeter(id) {
return this.meters.get(id)
}
snapshotName(index) {
return this.snapshots.list.get(index)?.name
}
// ---------- dropdown choices ----------
#makeChoices() {
const entity = (ent, suffix) => () => this.numbers(ent).map((n) => ({ id: n, label: `${n} · ${this.displayName(ent, n)}${suffix ? suffix(n) : ''}` }))
const bussSuffix = (n) => ` (${this.bussMode(n)})`
const busses = entity('buss', bussSuffix)
return {
inputs: entity('input'),
busses,
auxChoices: () => busses().filter((c) => this.bussMode(c.id) === 'aux'),
groupChoices: () => busses().filter((c) => this.bussMode(c.id) === 'group'),
matrix: entity('matrix'),
matrixInputs: entity('minput'),
cgs: entity('cg'),
geqs: entity('geq'),
snapshots: () =>
[...this.snapshots.list]
.sort((a, b) => a[0] - b[0])
.map(([index, s]) => ({ id: index, label: `${index} · ${s.name}${s.cue > 0 ? ` · cue ${fmtCue(s.cue)}` : ''}` })),
}
}
// ---------- load plans ----------
/**
* The queries every (re)load starts with. The console never answers the Input_Channels, Aux_Outputs and
* Group_Outputs counts (it pushes all nine once, shortly after first contact), so topology comes from the
* name queries; the four counts that do answer are asked for here.
*/
headerQueries() {
return [
query(addr.consoleName),
query(addr.count('matrix')),
query(addr.count('matrixIn')),
query(addr.count('cgs')),
query(addr.count('geqs')),
query(addr.bussTypes),
query(addr.auxModes),
query(addr.groupModes),
query(addr.inputModes),
query(addr.session),
query(addr.currentSnapshot),
query(addr.snapshotCount),
query(addr.snapshotNames),
]
}
nameQueries(onlyMissing = false) {
const q = []
const want = (ent, n, side) => {
if (!onlyMissing) return true
if (ent === 'buss') {
const b = this.busses.get(n)
return (side === 'group' ? b?.groupName : b?.auxName) === undefined
}
return this.#map(ent).get(n)?.name === undefined
}
for (let n = 1; n <= this.limit('input'); n++) if (want('input', n)) q.push(query(addr.name('input', n)))
for (let n = 1; n <= this.limit('buss'); n++) {
if (want('buss', n, 'aux')) q.push(query(addr.name('buss', n, 'aux')))
if (want('buss', n, 'group')) q.push(query(addr.name('buss', n, 'group')))
}
for (let n = 1; n <= this.limit('matrix'); n++) if (want('matrix', n)) q.push(query(addr.name('matrix', n)))
for (let n = 1; n <= this.limit('cg'); n++) if (want('cg', n)) q.push(query(addr.name('cg', n)))
for (let n = 1; n <= this.limit('geq'); n++) if (want('geq', n)) q.push(query(addr.name('geq', n)))
return q
}
bussValueQueries(n) {
const side = this.bussMode(n)
return [query(addr.fader('buss', n, side)), query(addr.mute('buss', n, side)), query(addr.solo('buss', n, side))]
}
/** Fader / mute / solo (/ pan) for every entity that exists. */
valueQueries() {
const q = []
for (const n of this.existing('input')) {
q.push(query(addr.fader('input', n)), query(addr.mute('input', n)), query(addr.solo('input', n)), query(addr.pan(n)))
}
for (const n of this.existing('buss')) q.push(...this.bussValueQueries(n))
for (const n of this.existing('matrix')) q.push(query(addr.fader('matrix', n)), query(addr.mute('matrix', n)), query(addr.solo('matrix', n)))
for (const n of this.existing('cg')) q.push(query(addr.fader('cg', n)), query(addr.mute('cg', n)), query(addr.solo('cg', n)))
return q
}
/** Counts of what was found, for the "load complete" log line. */
summary() {
const found = (ent) => {
let k = 0
for (let n = 1; n <= this.limit(ent); n++) if (this.has(ent, n)) k++
return k
}
const out = {
inputs: found('input'),
busses: found('buss'),
aux: this.namedBusses('aux').length,
group: this.namedBusses('group').length,
matrix: found('matrix'),
cgs: found('cg'),
geqs: found('geq'),
snapshots: this.snapshots.list.size,
}
out.total = out.inputs + out.busses + out.matrix + out.cgs + out.geqs
return out
}
}
+1374 -105
View File
File diff suppressed because it is too large Load Diff
+41 -25
View File
@@ -12,7 +12,7 @@ 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. |
| `sd` | The "DiGiCo Pad" dialect the **real S31 iPad extension actually speaks**: `/Console/Name/?`, `/Input_Channels/N/fader/?`, ... Verified against a live console (`tools/catalog/s31-catalog.md`); this is the dialect the Companion module uses. |
## `mock-s31.js` - fake S31 console
@@ -23,7 +23,7 @@ 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]
[--delay MS] [--types] [--quiet]
```
| Flag | Meaning |
@@ -34,6 +34,7 @@ node tools/mock-s31.js [--dialect sseries|sd] [--listen 8000] [--reply-to IP:POR
| `--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`. |
| `--delay MS` | `sd`: answer and echo every message MS milliseconds late (0-5000, default 0), to test a slow console and stale echoes. |
| `--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. |
@@ -52,32 +53,47 @@ sent with fader changes), `/channel/N/send/M/{level,enabled,pan}`, and
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.
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. The mock answers the addresses listed in `tools/catalog/s31-catalog.md` (the real
console's answers) with the catalog's reply types. Entity numbers are 1-based, **control groups
included** (`/Control_Groups/1/name` is the first); snapshot indexes are 0-based. `--channels N` limits the
inputs, `--auxes N` the busses (default 25), and the matrix (8 outputs, 10 inputs), 10 control groups and
16 graphic EQs are fixed.
| Query | Reply type |
| Address | 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 |
| `/Console/Name/?`, `/Console/Session/Filename/?` | `s` |
| `/Console/{Matrix_Outputs,Matrix_Inputs,Control_Groups,Graphic_EQ,Talkback_Outputs,Multis}/?` | `i` |
| `/Console/{Input_Channels,Aux_Outputs,Group_Outputs}/?` | **silent**, like the real console. All nine counts are pushed once, ~200 ms after the first datagram it receives |
| `/Console/Aux_Outputs/types/?` | one `i` per buss, 1 = aux, 0 = group (a buss named only on the Group side is a group) |
| `/Console/{Aux_Outputs,Group_Outputs}/modes/?`, `/Console/Input_Channels/modes/?` | one `i` per buss / input (2 = stereo, 1 = mono, captured) |
| `/Input_Channels/N/Channel_Input/name/?`, `/Aux_Outputs/N/Buss_Trim/name/?`, `/Group_Outputs/N/Buss_Trim/name/?`, `/Matrix_Outputs/N/Buss_Trim/name/?`, `/Control_Groups/N/name/?`, `/Graphic_EQ/N/name/?` | `s`. Only input names accept a write here (not verified on the real console); the others are 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. Matrix inputs have no name address |
| `/{Input_Channels,Aux_Outputs,Group_Outputs,Matrix_Outputs,Control_Groups}/N/fader/?` | `f`, dB, -150 = -inf, max +10 |
| `/{...}/N/mute/?`, `/{...}/N/solo/?` | `f` 0/1 (a float, not an int) |
| `/Input_Channels/N/Panner/pan/?` | `f`, 0..1, 0.5 = centre |
| `/Input_Channels/N/Aux_Send/M/{send_level,send_on,send_pan}/?` | `f` (level dB -150..10, on 0/1, pan 0..1) |
| `/Input_Channels/N/Aux_Send/M/send_pre-post/?` | `i` (captured as an int, default 0; settable 0..1, which value means "pre" is an assumption; the Companion module does not use it) |
| `/Input_Channels/N/Group_Send/M/group/?` | `f` 0/1 group assign. Default: inputs 1-8 and 18-21 on group 24. The wire type is the constant `SD_GROUP_ASSIGN_WIRE_TYPE` |
| `/Matrix_Inputs/N/Matrix_Send/M/{send_level,send_on}/?` | `f` (default -90 and 0, captured) |
| `/Graphic_EQ/N/{geq_in,geq_gain_1..32}/?` | `f` |
| `/Input_Channels/N/{Channel_Input/{trim,phase,phantom,analog_gain,input_pad},Filters/{hi,lo}_filter_{in,freq},Channel_Delay/{delay_on,delay},EQ/eq_in,Dynamics/{comp_in,gate_in},Insert/insert_{A,B}_in}/?` | `f` (switches 0/1) |
| `/{Aux_Outputs,Group_Outputs}/N/{Buss_Trim/{trim,phase},EQ/eq_in,Dynamics/{comp_in,gate_in},Channel_Delay/{delay_on,delay},Insert/insert_{A,B}_in}/?`, `/Matrix_Outputs/N/Buss_Trim/trim/?` | `f` |
| `/Snapshots/Current_Snapshot/?`, `/Snapshots/count/?` | `i` |
| `/Snapshots/names/?` | one `/Snapshots/name ,iiis [index, cue*100, 0, "name"]` per snapshot. `/Snapshots/name/?` without an argument stays silent (unreliable on the real console) |
| `/Snapshots/Fire_Snapshot_number [i]`, `/Snapshots/Fire_Next_Snapshot`, `/Snapshots/Fire_Prev_Snapshot` | write-only triggers; the mock moves its current snapshot and pushes `/Snapshots/Current_Snapshot` |
| `/Macros/Buttons/press [i]` | logged only (no macro query is answered) |
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`.)
**Meters.** Subscribe with `/Input_Channels/N/Channel_Input/post_meter ,ii [id, 1]` (post-fader level) or
`/Input_Channels/N/Dynamics/GR_meter ,ii [id, 1|4]` (gate / compressor gain reduction), where the client
chooses `id`. Every subscribed id then gets a random value 0..127 about five times a second, as
`/Meters/values ,ii... [id, v, id, v, ...]` (only ids whose value changed). `/Meters/clear` drops every
subscription, like the real console.
Sets accept int or float for the 0/1 switches (any non-zero = on) and clamp levels, pans and values to
their ranges. Like the real console the mock stays **silent** on everything else: all lowercase S-Series
addresses, `/Console/Resend`, `/Console/Channels/?`, whole-send queries, out-of-range entity numbers,
sets of read-only names. (`--bool-int` has no effect in `sd`.)
## `fake-ipad.js` - stand-in for the DiGiCo iPad app
+281 -66
View File
@@ -18,19 +18,21 @@
* 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).
* sd - the "DiGiCo Pad" / SD dialect the real S31 iPad extension speaks
* (tools/catalog/s31-catalog.md lists every address it answers).
* A QUERY is the address plus a trailing "/?" and no args; the reply is the
* same address WITHOUT "/?" plus the value. A SET is the address without
* "/?" and one argument; it is stored and echoed using the console's reply
* types (mute 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.
* types (mute, solo and send_on come back as floats 0/1). It also streams
* meters to subscribed ids and implements the write-only snapshot triggers.
* Anything the real console does not answer (lowercase S-Series addresses,
* /Console/Resend, /Console/Channels/?, whole-send queries, ...) gets no
* reply here either.
*
* Usage:
* node tools/mock-s31.js [--dialect sseries|sd] [--listen 8000] [--reply-to IP:PORT]
* [--channels N] [--auxes N] [--bool-int] [--live]
* [--types] [--quiet]
* [--delay MS] [--types] [--quiet]
*/
const dgram = require('node:dgram');
@@ -52,10 +54,12 @@ const USAGE = `Usage: node tools/mock-s31.js [options]
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),
--auxes N aux busses / sends per channel (sd: busses) [sseries 16 (max 16),
sd 25 (max 25)]
--bool-int sseries only: send booleans as int 0/1 instead of OSC T/F
(the console can be set to "normalise" booleans)
--delay MS answer and echo every message MS milliseconds late (0-5000)
[0]; for testing a slow console and stale echoes
--live every 3 s change a random send level / send-enabled and
emit it, to exercise feedback handling
--types also print each message's OSC type tags, e.g. ",f"
@@ -82,6 +86,7 @@ function parseCli() {
'reply-to': { type: 'string' },
channels: { type: 'string' },
auxes: { type: 'string' },
delay: { type: 'string' },
'bool-int': { type: 'boolean', default: false },
live: { type: 'boolean', default: false },
types: { type: 'boolean', default: false },
@@ -124,6 +129,7 @@ function parseCli() {
replyTo,
channels: intOption('channels', 1, 60, defaults.channels),
auxes: intOption('auxes', 0, defaults.maxAuxes, defaults.auxes),
delay: intOption('delay', 0, 5000, 0),
boolInt: values['bool-int'],
live: values.live,
types: values.types,
@@ -254,9 +260,12 @@ function send(address, args) {
}
try {
const buf = encode(address, args);
socket.send(buf, replyTarget.port, replyTarget.host, (err) => {
const target = replyTarget;
const deliver = () => socket.send(buf, target.port, target.host, (err) => {
if (err) note(`send error: ${err.message}`);
});
if (opts.delay > 0) setTimeout(deliver, opts.delay);
else deliver();
} catch (err) {
note(`could not send ${address}: ${err.message}`);
return false;
@@ -279,8 +288,14 @@ let dumpTimer = null;
let dumpTotal = 0;
let dumpLabel = '';
function startPaced(label, queue) {
function startPaced(label, queue, { append = false } = {}) {
if (!replyTarget) return; // cannot happen via the socket: the first datagram locks a target
if (dumpTimer && append) {
// sd dialect: bursts queue up behind each other instead of restarting the running one
dumpQueue.push(...queue);
dumpTotal += queue.length;
return;
}
if (dumpTimer) {
clearTimeout(dumpTimer);
note(`${label} requested while a dump was running; restarting it`);
@@ -503,23 +518,28 @@ function liveTickSseries() {
// ===========================================================================
// Dialect: sd (the "DiGiCo Pad" dialect the real S31 speaks)
// query = address + "/?" (no args); reply = address without "/?" + value
// Every address answered here is one the real console was seen to answer
// (tools/catalog/s31-catalog.md), with the same reply types. Entity numbers
// are 1-based, control groups included; snapshot indexes are 0-based.
// ===========================================================================
const SD_CONSOLE_NAME = 'S31 AB';
const SD_SESSION_NAME = 'Mock Session.session';
/** The console has 25 busses, each exposed as an Aux_Outputs and as a Group_Outputs entry. */
const SD_BUSS_COUNT = 25;
/** Number of busses (each exposed as an Aux_Outputs and as a Group_Outputs entry); --auxes sets it. */
const SD_BUSS_MAX = 25;
/**
* Wire type of /Input_Channels/N/Group_Send/M/group replies: 'f' (float 0/1, like
* mute and send_on) or 'i' (int). Not yet verified against the real console -
* flip this one constant if it turns out to be an int.
* mute and send_on) or 'i' (int). The catalog shows 'f'.
*/
const SD_GROUP_ASSIGN_WIRE_TYPE = 'f';
/** Fixed counts the console reports besides Input_Channels / Aux_Outputs. */
/**
* Counts the console answers when asked (catalog). The Input_Channels, Aux_Outputs and Group_Outputs counts never answer a
* query; the console pushes all nine once, ~200 ms after first contact (sdCountBurst).
*/
const SD_FIXED_COUNTS = {
Group_Outputs: SD_BUSS_COUNT,
Control_Groups: 10,
Matrix_Outputs: 8,
Matrix_Inputs: 10,
@@ -527,9 +547,21 @@ const SD_FIXED_COUNTS = {
Talkback_Outputs: 0,
Multis: 0,
};
const SD_BURST_DELAY_MS = 200;
let sdBurstDone = false;
const SD_MATRIX_OUT = SD_FIXED_COUNTS.Matrix_Outputs;
const SD_MATRIX_IN = SD_FIXED_COUNTS.Matrix_Inputs;
const SD_CGS = SD_FIXED_COUNTS.Control_Groups;
const SD_GEQS = SD_FIXED_COUNTS.Graphic_EQ;
const SD_GEQ_BANDS = 32;
/** One int per 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];
/** One int per buss: 2 = stereo, 1 = mono (captured from the real console; Aux and Group arrays were identical). */
const SD_BUSS_MODES = [2, 2, 2, 1, 2, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2];
/** One int per input: 1 = mono, 2 = stereo (captured). */
const SD_INPUT_MODES = [
1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 2, 2,
1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 2, 2, 2, 2, 1, 2, 1, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1,
];
const SD_INPUT_NAMES = [
'KICK In', 'SNARE Top', 'SNARE Bot', 'HAT', 'TOM 1', 'TOM 2', 'FLOOR TOM', 'OH L', 'OH R',
@@ -551,36 +583,95 @@ 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_MATRIX_NAMES = ['MAINS L', 'MAINS R', 'SUB L', 'SUB R', 'ProP LR', 'Matrix 6', 'Matrix 7', 'Studio'];
const SD_CG_NAMES = ['DRUMS', 'GTR', 'KEYS', 'TRKS', 'VOCALS', 'FX', '', 'ProP', 'MC & SPEAKER', ''];
/** Snapshot list: index n has cue number (n+1)*100 (reported as iiis [index, cue*100, 0, name]). */
const SD_SNAPSHOTS = [
'START', 'Pre Service', 'Worship 1', 'Worship 2', 'Message', 'Offering', 'Closing', 'BYPASS WAVES SUPERRACK!',
];
const SD_PROPS = {
name: { kind: 's', readOnly: true },
inputName: { kind: 's' }, // input names accept a write (not verified on the real console)
fader: { kind: 'f', min: -150, max: 10 }, // dB, -150 = -inf
mute: { kind: 'flag' },
solo: { kind: 'flag' },
pan: { kind: 'f', min: 0, max: 1 }, // 0.5 = centre (Panner/pan and send_pan)
level: { kind: 'f', min: -150, max: 10 }, // send_level, dB
on: { kind: 'flag' }, // send_on
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
on: { kind: 'flag' }, // send_on / geq_in
prepost: { kind: 'int', min: 0, max: 1 }, // send_pre-post; captured as an int, range assumed
group: { kind: 'flag', wire: SD_GROUP_ASSIGN_WIRE_TYPE }, // Group_Send/M/group assign 0/1
geqGain: { kind: 'f', min: -12, max: 12 },
};
/**
* Per-channel switches and values answered on inputs and busses (Aux_Outputs and Group_Outputs),
* keyed by the path under /<tree>/N/. Switches are written as ints and reported as floats 0/1.
*/
const SD_SWITCH = { kind: 'flag' };
const sdValue = (min, max) => ({ kind: 'f', min, max });
const SD_PARAM_SPECS = {
input: {
'Channel_Input/trim': { spec: sdValue(-40, 40), def: 0 },
'Channel_Input/phase': { spec: SD_SWITCH, def: 0 },
'Channel_Input/phantom': { spec: SD_SWITCH, def: 0 },
'Channel_Input/analog_gain': { spec: sdValue(-20, 80), def: 0 },
'Channel_Input/input_pad': { spec: SD_SWITCH, def: 0 },
'EQ/eq_in': { spec: SD_SWITCH, def: 1 },
'Dynamics/comp_in': { spec: SD_SWITCH, def: 1 },
'Dynamics/gate_in': { spec: SD_SWITCH, def: 0 },
'Filters/hi_filter_in': { spec: SD_SWITCH, def: 0 },
'Filters/lo_filter_in': { spec: SD_SWITCH, def: 0 },
'Filters/hi_filter_freq': { spec: sdValue(20, 20000), def: 12000 },
'Filters/lo_filter_freq': { spec: sdValue(20, 20000), def: 40 },
'Channel_Delay/delay_on': { spec: SD_SWITCH, def: 0 },
'Channel_Delay/delay': { spec: sdValue(0, 10000), def: 0 },
'Insert/insert_A_in': { spec: SD_SWITCH, def: 0 },
'Insert/insert_B_in': { spec: SD_SWITCH, def: 0 },
},
buss: {
'Buss_Trim/trim': { spec: sdValue(-40, 40), def: 0 },
'Buss_Trim/phase': { spec: SD_SWITCH, def: 0 },
'EQ/eq_in': { spec: SD_SWITCH, def: 1 },
'Dynamics/comp_in': { spec: SD_SWITCH, def: 0 },
'Dynamics/gate_in': { spec: SD_SWITCH, def: 0 },
'Channel_Delay/delay_on': { spec: SD_SWITCH, def: 0 },
'Channel_Delay/delay': { spec: sdValue(0, 10000), def: 0 },
'Insert/insert_A_in': { spec: SD_SWITCH, def: 0 },
'Insert/insert_B_in': { spec: SD_SWITCH, def: 0 },
},
matrix: {
'Buss_Trim/trim': { spec: sdValue(-40, 40), def: 0 },
},
};
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 sdBusCount = 0; // set from --auxes when the state is built
const sdInputs = [null]; // 1-based: { name, fader, mute, solo, pan, sends:[null,{level,on,pan,prepost}...], groupSends:[null,{group}...] }
const sdAuxes = [null]; // 1-based: { name, fader, mute, solo }
const sdGroups = [null]; // 1-based: { name, fader, mute, solo }
const sdMatrix = [null]; // 1-based matrix outputs: { name, fader, mute, solo }
const sdMatrixIn = [null]; // 1-based matrix inputs: { sends:[null,{level,on}...] }
const sdCgs = [null]; // 1-based control groups: { name, fader, mute, solo }
const sdGeqs = [null]; // 1-based graphic EQs: { name, on, gains:[null, ...] }
let sdSnapshot = 3; // /Snapshots/Current_Snapshot
const sdMeterSubs = new Map(); // meter id -> last value sent
let sdMeterTimer = null;
const SD_METER_INTERVAL_MS = 200; // ~5 Hz
function sdBussTypes() {
// 1 = aux, 0 = group; a buss named only on the Group side is a group.
return Array.from({ length: sdBusCount }, (_, i) => (SD_GROUP_NAMES[i] && !SD_AUX_NAMES[i] ? 0 : 1));
}
function buildStateSd() {
const rng = mulberry32(31);
sdBusCount = Math.min(opts.auxes, SD_BUSS_MAX);
for (let n = 1; n <= opts.channels; n++) {
const sends = [null];
for (let m = 1; m <= opts.auxes; m++) {
for (let m = 1; m <= sdBusCount; m++) {
const active = rng() < 0.5;
sends.push({
level: active ? tidy16(-40 + rng() * 40) : -150,
@@ -591,35 +682,39 @@ function buildStateSd() {
}
// 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;
for (let g = 1; g <= sdBusCount; g++) groupSends.push({ group: 0 });
if (sdBusCount >= 24 && (n <= 8 || (n >= 18 && n <= 21))) groupSends[24].group = 1;
sdInputs.push({
name: SD_INPUT_NAMES[n - 1] || `INPUT ${n}`,
fader: tidy16(-30 + rng() * 30),
mute: rng() < 0.08 ? 1 : 0,
solo: 0,
pan: 0.5,
sends,
groupSends,
});
}
for (let k = 0; k < opts.auxes; k++) {
sdAuxes.push({
name: SD_AUX_NAMES[k],
fader: tidy16(-10 + rng() * 10),
mute: 0,
});
for (let k = 0; k < sdBusCount; k++) {
sdAuxes.push({ name: SD_AUX_NAMES[k], fader: tidy16(-10 + rng() * 10), mute: 0, solo: 0 });
sdGroups.push({ name: SD_GROUP_NAMES[k], fader: tidy16(-10 + rng() * 10), mute: 0, solo: 0 });
}
for (let k = 0; k < SD_BUSS_COUNT; k++) {
sdGroups.push({
name: SD_GROUP_NAMES[k],
fader: tidy16(-10 + rng() * 10),
mute: 0,
});
if (sdBusCount >= 24) sdGroups[24].fader = -1.6875; // captured
for (let k = 0; k < SD_MATRIX_OUT; k++) sdMatrix.push({ name: SD_MATRIX_NAMES[k], fader: tidy16(-30 + rng() * 25), mute: 0, solo: 0 });
for (let k = 0; k < SD_MATRIX_IN; k++) {
const sends = [null];
for (let m = 1; m <= SD_MATRIX_OUT; m++) sends.push({ level: -90, on: 0 }); // captured: -90 / 0
sdMatrixIn.push({ sends });
}
for (let k = 0; k < SD_CGS; k++) sdCgs.push({ name: SD_CG_NAMES[k], fader: tidy16(-20 + rng() * 20), mute: 0, solo: 0 });
for (let k = 1; k <= SD_GEQS; k++) {
const gains = [null];
for (let b = 1; b <= SD_GEQ_BANDS; b++) gains.push(0);
sdGeqs.push({ name: `Graphic EQ ${k}`, on: 1, gains });
}
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) {
if (sdBusCount >= 1) {
sdInputs[1].sends[1] = { level: -150, on: 0, pan: 0.5, prepost: 0 };
sdAuxes[1].fader = -4.375;
sdAuxes[1].mute = 0;
@@ -627,40 +722,84 @@ function buildStateSd() {
}
const SD_RE_INPUT_NAME = /^\/Input_Channels\/(\d+)\/Channel_Input\/name$/;
const SD_RE_INPUT_PROP = /^\/Input_Channels\/(\d+)\/(fader|mute)$/;
const SD_RE_INPUT_PROP = /^\/Input_Channels\/(\d+)\/(fader|mute|solo)$/;
const SD_RE_INPUT_PAN = /^\/Input_Channels\/(\d+)\/Panner\/pan$/;
const SD_RE_SEND = /^\/Input_Channels\/(\d+)\/Aux_Send\/(\d+)\/(send_level|send_on|send_pan|send_pre-post)$/;
const SD_RE_GROUP_SEND = /^\/Input_Channels\/(\d+)\/Group_Send\/(\d+)\/group$/;
const SD_RE_AUX_NAME = /^\/Aux_Outputs\/(\d+)\/Buss_Trim\/name$/;
const SD_RE_AUX_PROP = /^\/Aux_Outputs\/(\d+)\/(fader|mute)$/;
const SD_RE_AUX_PROP = /^\/Aux_Outputs\/(\d+)\/(fader|mute|solo)$/;
const SD_RE_GROUP_NAME = /^\/Group_Outputs\/(\d+)\/Buss_Trim\/name$/;
const SD_RE_GROUP_PROP = /^\/Group_Outputs\/(\d+)\/(fader|mute)$/;
const SD_RE_GROUP_PROP = /^\/Group_Outputs\/(\d+)\/(fader|mute|solo)$/;
const SD_RE_MATRIX_NAME = /^\/Matrix_Outputs\/(\d+)\/Buss_Trim\/name$/;
const SD_RE_MATRIX_PROP = /^\/Matrix_Outputs\/(\d+)\/(fader|mute|solo)$/;
const SD_RE_MATRIX_SEND = /^\/Matrix_Inputs\/(\d+)\/Matrix_Send\/(\d+)\/(send_level|send_on)$/;
const SD_RE_CG_NAME = /^\/Control_Groups\/(\d+)\/name$/;
const SD_RE_CG_PROP = /^\/Control_Groups\/(\d+)\/(fader|mute|solo)$/;
const SD_RE_GEQ = /^\/Graphic_EQ\/(\d+)\/(name|geq_in|geq_gain_(\d+))$/;
const SD_RE_PARAM = /^\/(Input_Channels|Aux_Outputs|Group_Outputs|Matrix_Outputs)\/(\d+)\/((?:Channel_Input|Filters|Channel_Delay|EQ|Dynamics|Insert|Buss_Trim)\/\w+)$/;
const SD_RE_METER_SUB = /^\/Input_Channels\/(\d+)\/(?:Channel_Input\/post_meter|Dynamics\/GR_meter)$/;
/** Map an SD leaf address to { target, prop } or null when there is no such thing. */
/** Map an SD leaf address to { target, prop, spec } or null when there is no such thing. */
function sdResolve(address) {
let m = SD_RE_INPUT_NAME.exec(address);
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: 'name' } : null;
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: 'name', spec: SD_PROPS.inputName } : null;
m = SD_RE_INPUT_PROP.exec(address);
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: m[2] } : null;
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: m[2], spec: SD_PROPS[m[2]] } : null;
m = SD_RE_INPUT_PAN.exec(address);
if (m) return sdInputs[Number(m[1])] ? { target: sdInputs[Number(m[1])], prop: 'pan', spec: SD_PROPS.pan } : null;
m = SD_RE_SEND.exec(address);
if (m) {
const input = sdInputs[Number(m[1])];
const snd = input && input.sends[Number(m[2])];
return snd ? { target: snd, prop: SD_SEND_PROPS[m[3]] } : null;
const prop = SD_SEND_PROPS[m[3]];
return snd ? { target: snd, prop, spec: SD_PROPS[prop] } : null;
}
m = SD_RE_GROUP_SEND.exec(address);
if (m) {
const input = sdInputs[Number(m[1])];
const gs = input && input.groupSends[Number(m[2])];
return gs ? { target: gs, prop: 'group' } : null;
return gs ? { target: gs, prop: 'group', spec: SD_PROPS.group } : null;
}
const tables = [
[SD_RE_GROUP_NAME, sdGroups, 'name'], [SD_RE_GROUP_PROP, sdGroups, null],
[SD_RE_AUX_NAME, sdAuxes, 'name'], [SD_RE_AUX_PROP, sdAuxes, null],
[SD_RE_MATRIX_NAME, sdMatrix, 'name'], [SD_RE_MATRIX_PROP, sdMatrix, null],
[SD_RE_CG_NAME, sdCgs, 'name'], [SD_RE_CG_PROP, sdCgs, null],
];
for (const [re, list, fixedProp] of tables) {
m = re.exec(address);
if (!m) continue;
const target = list[Number(m[1])];
if (!target) return null;
const prop = fixedProp || m[2];
return { target, prop, spec: SD_PROPS[prop] };
}
m = SD_RE_PARAM.exec(address);
if (m) {
const kind = { Input_Channels: 'input', Matrix_Outputs: 'matrix' }[m[1]] || 'buss';
const entry = SD_PARAM_SPECS[kind][m[3]];
const owner = { Input_Channels: sdInputs, Aux_Outputs: sdAuxes, Group_Outputs: sdGroups, Matrix_Outputs: sdMatrix }[m[1]][Number(m[2])];
if (!entry || !owner) return null;
if (!owner.params) owner.params = {};
if (!(m[3] in owner.params)) owner.params[m[3]] = entry.def;
return { target: owner.params, prop: m[3], spec: entry.spec };
}
m = SD_RE_MATRIX_SEND.exec(address);
if (m) {
const input = sdMatrixIn[Number(m[1])];
const snd = input && input.sends[Number(m[2])];
const prop = m[3] === 'send_on' ? 'on' : 'level';
return snd ? { target: snd, prop, spec: SD_PROPS[prop] } : null;
}
m = SD_RE_GEQ.exec(address);
if (m) {
const geq = sdGeqs[Number(m[1])];
if (!geq) return null;
if (m[2] === 'name') return { target: geq, prop: 'name', spec: SD_PROPS.name };
if (m[2] === 'geq_in') return { target: geq, prop: 'on', spec: SD_PROPS.on };
const band = Number(m[3]);
return band >= 1 && band <= SD_GEQ_BANDS ? { target: geq.gains, prop: band, spec: SD_PROPS.geqGain } : null;
}
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;
}
@@ -672,34 +811,89 @@ function handleSd(address, args) {
return sdSet(address, args);
}
/** The nine counts the console pushes once, shortly after first contact (catalog: unsolicited, order as captured). */
function sdCountBurst() {
return [
['/Console/Input_Channels', [int(opts.channels)]],
['/Console/Aux_Outputs', [int(sdBusCount)]],
['/Console/Group_Outputs', [int(sdBusCount)]],
['/Console/Control_Groups', [int(SD_CGS)]],
['/Console/Talkback_Outputs', [int(0)]],
['/Console/Matrix_Outputs', [int(SD_MATRIX_OUT)]],
['/Console/Matrix_Inputs', [int(SD_MATRIX_IN)]],
['/Console/Graphic_EQ', [int(SD_GEQS)]],
['/Console/Multis', [int(0)]],
];
}
function sdQuery(base, original) {
if (base === '/Console/Name') return void send(base, [SD_CONSOLE_NAME]);
if (base === '/Console/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)));
if (base === '/Console/Session/Filename') return void send(base, [SD_SESSION_NAME]);
// The Input_Channels / Aux_Outputs / Group_Outputs counts never answer a query (catalog): only the burst does.
if (base === '/Console/Aux_Outputs/types') return void send(base, sdBussTypes().map((t) => int(t)));
if (base === '/Console/Aux_Outputs/modes' || base === '/Console/Group_Outputs/modes') {
return void send(base, SD_BUSS_MODES.slice(0, sdBusCount).map((mode) => int(mode)));
}
if (base === '/Console/Input_Channels/modes') return void send(base, SD_INPUT_MODES.slice(0, opts.channels).map((mode) => int(mode)));
const fixed = /^\/Console\/(\w+)$/.exec(base);
if (fixed && Object.hasOwn(SD_FIXED_COUNTS, fixed[1])) return void send(base, [int(SD_FIXED_COUNTS[fixed[1]])]);
if (base === '/Snapshots/Current_Snapshot') return void send(base, [int(sdSnapshot)]);
if (base === '/Snapshots/count') return void send(base, [int(SD_SNAPSHOTS.length)]);
// /Snapshots/name/? without an argument is unreliable on the real console: left silent here.
if (base === '/Snapshots/names') {
startPaced('snapshot names', SD_SNAPSHOTS.map((name, index) => [
'/Snapshots/name', [int(index), int((index + 1) * 100), int(0), name],
]));
]), { append: true });
return undefined;
}
const r = sdResolve(base);
if (!r) return ignore(`no reply for ${original} (the real console is silent here)`);
send(base, [toArg(SD_PROPS[r.prop], r.target[r.prop])]);
send(base, [toArg(r.spec, r.target[r.prop])]);
return undefined;
}
function sdFireSnapshot(index) {
sdSnapshot = index;
send('/Snapshots/Current_Snapshot', [int(sdSnapshot)]);
}
function sdSet(address, args) {
// Write-only trigger nodes
if (address === '/Snapshots/Fire_Snapshot_number') {
if (typeof args[0] !== 'number' || args[0] < 0 || args[0] >= SD_SNAPSHOTS.length) return ignore(`snapshot fire with ${formatArgs(args)}`);
note(`snapshot fire: recall #${Math.trunc(args[0])}`);
return void sdFireSnapshot(Math.trunc(args[0]));
}
if (address === '/Snapshots/Fire_Next_Snapshot') {
note('snapshot fire next');
return void sdFireSnapshot(Math.min(SD_SNAPSHOTS.length - 1, sdSnapshot + 1));
}
if (address === '/Snapshots/Fire_Prev_Snapshot') {
note('snapshot fire previous');
return void sdFireSnapshot(Math.max(0, sdSnapshot - 1));
}
if (address === '/Macros/Buttons/press') {
note(`macro press ${formatArgs(args)} (logged only: the real console never answered a macro query)`);
return undefined;
}
// Meter subscriptions: /Input_Channels/N/Channel_Input/post_meter [id, kind]; /Meters/clear drops them all
if (address === '/Meters/clear') {
sdMeterSubs.clear();
note('meters cleared');
return undefined;
}
const sub = SD_RE_METER_SUB.exec(address);
if (sub) {
if (!sdInputs[Number(sub[1])] || typeof args[0] !== 'number') return ignore(`meter subscription ${address} ${formatArgs(args)}`);
sdMeterSubs.set(Math.trunc(args[0]), -1);
return undefined;
}
const r = sdResolve(address);
if (!r) return ignore(`no reply for ${address} (unknown or unsupported address)`);
const spec = SD_PROPS[r.prop];
const spec = r.spec;
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]);
@@ -708,14 +902,27 @@ function sdSet(address, args) {
return undefined;
}
/** ~5 Hz: random 0..127 for every subscribed id whose value changed, as /Meters/values [id, v, id, v ...]. */
function sdMeterTick() {
if (!sdMeterSubs.size || !replyTarget) return;
const pairs = [];
for (const [id, last] of sdMeterSubs) {
const v = Math.random() < 0.15 ? 0 : Math.floor(Math.random() * 128);
if (v === last) continue;
sdMeterSubs.set(id, v);
pairs.push(int(id), int(v));
}
for (let i = 0; i < pairs.length; i += 40) send('/Meters/values', pairs.slice(i, i + 40));
}
function liveTickSd() {
const n = 1 + Math.floor(Math.random() * opts.channels);
const input = sdInputs[n];
if (opts.auxes === 0) {
if (sdBusCount === 0) {
setAndEmit(input, 'fader', SD_PROPS.fader, `/Input_Channels/${n}/fader`, tidy16(-40 + Math.random() * 50));
return;
}
const s = 1 + Math.floor(Math.random() * opts.auxes);
const s = 1 + Math.floor(Math.random() * sdBusCount);
const snd = input.sends[s];
if (Math.random() < 0.5) {
setAndEmit(snd, 'level', SD_PROPS.level, `/Input_Channels/${n}/Aux_Send/${s}/send_level`, tidy16(-60 + Math.random() * 60));
@@ -750,6 +957,12 @@ socket.on('message', (msg, rinfo) => {
note(`no --reply-to given; locking feedback to ${replyTarget.host}:${replyTarget.port} (first sender was ${rinfo.address}:${rinfo.port})`);
}
// The real console pushes all its counts once, shortly after first contact, and not reliably later.
if (isSd && !sdBurstDone) {
sdBurstDone = true;
setTimeout(() => startPaced('count burst', sdCountBurst(), { append: true }), SD_BURST_DELAY_MS);
}
if (packet.raw === undefined) handle(packet.address, packet.args);
});
@@ -768,6 +981,7 @@ socket.on('listening', () => {
? `feedback goes to ${replyTarget.host}:${replyTarget.port}`
: `feedback target not set; will lock onto the first sender's IP, port ${DEFAULT_REPLY_PORT}`);
if (isSd && opts.boolInt) note('--bool-int has no effect in the sd dialect (the console reports mute / send_on as floats)');
if (isSd) sdMeterTimer = setInterval(sdMeterTick, SD_METER_INTERVAL_MS);
if (opts.live) {
note(`live mode: changing a random send every ${LIVE_INTERVAL_MS / 1000}s`);
setInterval(liveTick, LIVE_INTERVAL_MS);
@@ -776,6 +990,7 @@ socket.on('listening', () => {
function shutdown(signal) {
if (dumpTimer) clearTimeout(dumpTimer);
if (sdMeterTimer) clearInterval(sdMeterTimer);
note(`${signal}: received ${stats.received} packets (${stats.undecodable} undecodable), sent ${stats.sent}, ignored ${stats.ignored}`);
try {
socket.close();