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:
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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 []
|
||||
}
|
||||
}
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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
@@ -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 }))
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+321
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
+1362
-93
File diff suppressed because it is too large
Load Diff
+41
-25
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user