diff --git a/README.md b/README.md index 185ac46..987f983 100644 --- a/README.md +++ b/README.md @@ -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: ## 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. diff --git a/companion/HELP.md b/companion/HELP.md index e7ff28a..5c5af4d 100644 --- a/companion/HELP.md +++ b/companion/HELP.md @@ -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 | diff --git a/companion/manifest.json b/companion/manifest.json index 76acee1..91a0b5e 100644 --- a/companion/manifest.json +++ b/companion/manifest.json @@ -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", diff --git a/package-lock.json b/package-lock.json index ed0b593..b84ae4c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 8e8e2a2..5d1aa8a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/console-link.js b/src/console-link.js index 93cd32c..9b72a53 100644 --- a/src/console-link.js +++ b/src/console-link.js @@ -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,46 +32,75 @@ 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) { - settled = true - reject(err) - } else this.emit('error', err) - }) - socket.on('message', (buf, rinfo) => { - if (this.host && rinfo.address !== this.host) { - this.emit('stray', buf, rinfo) - return - } - this.lastRx = Date.now() - this.rxCount++ - this.emit('raw', buf, rinfo) - this.emit('message', decode(buf), buf, rinfo) - }) - socket.bind(this.localPort, this.bindAddress, () => { + const fail = (err) => { settled = true - this.socket = socket - this.emit('listening', socket.address()) - resolve(socket.address()) - }) + try { + socket?.close() + } catch (_e) { + /* never opened */ + } + reject(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) { + this.emit('stray', buf, rinfo) + return + } + this.lastRx = Date.now() + this.rxCount++ + this.emit('raw', buf, rinfo) + this.emit('message', decode(buf), buf, rinfo) + }) + socket.bind(this.localPort, this.bindAddress, () => { + settled = true + this.socket = socket + this.emit('listening', socket.address()) + resolve(socket.address()) + }) + } 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++ - this.socket.send(buf, this.port, this.host, (err) => { - if (err) this.emit('error', err) - }) + try { + this.socket.send(buf, this.port, this.host, (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 diff --git a/src/definitions.js b/src/definitions.js deleted file mode 100644 index 8050c09..0000000 --- a/src/definitions.js +++ /dev/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 } -} diff --git a/src/definitions/actions.js b/src/definitions/actions.js new file mode 100644 index 0000000..0bd3c05 --- /dev/null +++ b/src/definitions/actions.js @@ -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(), + }, + }) +} diff --git a/src/definitions/feedbacks.js b/src/definitions/feedbacks.js new file mode 100644 index 0000000..6ee6323 --- /dev/null +++ b/src/definitions/feedbacks.js @@ -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 [] + } +} diff --git a/src/definitions/presets.js b/src/definitions/presets.js new file mode 100644 index 0000000..e1b7af7 --- /dev/null +++ b/src/definitions/presets.js @@ -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 } } +} diff --git a/src/definitions/variables.js b/src/definitions/variables.js new file mode 100644 index 0000000..456e623 --- /dev/null +++ b/src/definitions/variables.js @@ -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 '' + } +} diff --git a/src/dialects.js b/src/dialects.js deleted file mode 100644 index 13e62a8..0000000 --- a/src/dialects.js +++ /dev/null @@ -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 })) diff --git a/src/image.js b/src/image.js new file mode 100644 index 0000000..478519a --- /dev/null +++ b/src/image.js @@ -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: , 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) +} diff --git a/src/main.js b/src/main.js index ce095a1..0dd670a 100644 --- a/src/main.js +++ b/src/main.js @@ -1,261 +1,531 @@ import { InstanceBase, InstanceStatus, Regex } from '@companion-module/base' import { ConsoleLink } from './console-link.js' import { Relay } from './relay.js' -import { BOOL_STYLES, DIALECT_CHOICES, getDialect } from './dialects.js' import { decode, describe, foldPattern } from './osc.js' -import { - getActionDefinitions, - getFeedbackDefinitions, - getPresetDefinitions, - getVariableDefinitions, - MAX_INPUTS, - MAX_BUSSES, - BUS_FIRST, - BUS_LAST, -} from './definitions.js' +import { State } from './state.js' +import { LEVEL, PARAMS, K, addr, adjustedLevel, clamp, indexProblem, meterId, meterSubscription, parse, query, sameValue } from './protocol.js' +import { getActionDefinitions } from './definitions/actions.js' +import { getFeedbackDefinitions, feedbackIdsForEvent, watchKey, watchKeyForEvent } from './definitions/feedbacks.js' +import { getVariableDefinitions, getAllVariableValues, getEventVariableValues, describeChange } from './definitions/variables.js' +import { getPresetDefinitions } from './definitions/presets.js' -const KEEPALIVE_MS = 5000 -const SILENCE_MS = 20000 -const TX_PACE_MS = 20 const LEARN_CAP = 400 -function emptyState() { - return { - consoleName: '', - names: new Map(), // input ch -> name - auxNames: new Map(), // buss -> name when in aux mode - groupNames: new Map(), // buss -> name when in group mode - busNames: new Map(), // S-Series OSC channel 70-93 -> name - bussModes: [], // 1 mono / 2 stereo per buss - sends: new Map(), // "ch:buss" -> { level, enabled, pan } - groups: new Map(), // "ch:buss" -> assigned - mutes: new Map(), - faders: new Map(), - bussMutes: new Map(), - bussFaders: new Map(), - snapshot: { index: null, names: new Map() }, - counts: {}, +/** Timings in ms. An instance copies these to `this.timing` so tests can shorten them. */ +const TIMING = { + keepaliveMs: 5000, // /Console/Name/? ping + silenceMs: 20000, // no traffic for this long = disconnected + txPaceMs: 20, // paced query queue + settleMs: 400, // wait after the queue drains for stragglers + rebuildDebounceMs: 750, // definitions rebuild after names / buss modes / snapshots change + replyWaitMs: 1000, // how long an action waits for a value it needs + relazyMs: 5000, // an unanswered lazy load may be requested again after this long + writeGuardMs: 400, // after a write, differing inbound values for that address are ignored; then one confirm query + flushMs: 40, // coalesce variable and feedback updates + meterThrottleMs: 100, // meter feedback checks, at most 10 Hz + resubscribeMs: 500, // after an iPad sends /Meters/clear + peakWatchMs: 10500, // keep re-checking peak holds this long after a full-scale reading + bindRetryMs: 10000, // retry a failed port bind (console link or relay) + errorLogMs: 30000, // at most one log line per error code + destination in this time +} + +export const DEFAULTS = { + host: '', + consolePort: 8000, + localPort: 9000, + relayEnable: true, + relayPort: 8000, + relayClientPort: 9000, + relayTimeout: 3600, + debugLog: false, +} + +/** An integer in [lo, hi] from a number or a numeric string, else null. */ +function wholeNumber(v, lo, hi) { + const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : v + return Number.isInteger(n) && n >= lo && n <= hi ? n : null +} + +/** + * Coerce and validate a raw config: { cfg, error }. cfg has numbers and booleans in their final form; when + * something is invalid `error` is a clear message and cfg is null. Never throws. + */ +export function normalizeConfig(raw) { + const c = { ...DEFAULTS, ...(raw && typeof raw === 'object' ? raw : {}) } + const host = typeof c.host === 'string' ? c.host.trim() : '' + if (!host) return { cfg: null, error: 'Console IP missing' } + const consolePort = wholeNumber(c.consolePort, 1, 65535) + if (consolePort === null) return { cfg: null, error: 'Console receive port must be a whole number from 1 to 65535' } + const localPort = wholeNumber(c.localPort, 1, 65535) + if (localPort === null) return { cfg: null, error: 'Local receive port must be a whole number from 1 to 65535' } + const cfg = { host, consolePort, localPort, relayEnable: !!c.relayEnable, relayPort: DEFAULTS.relayPort, relayClientPort: DEFAULTS.relayClientPort, relayTimeout: DEFAULTS.relayTimeout, debugLog: !!c.debugLog } + if (cfg.relayEnable) { + const relayPort = wholeNumber(c.relayPort, 1, 65535) + if (relayPort === null) return { cfg: null, error: 'Relay port must be a whole number from 1 to 65535' } + const relayClientPort = wholeNumber(c.relayClientPort, 0, 65535) + if (relayClientPort === null) return { cfg: null, error: 'iPad receive port must be a whole number from 0 to 65535' } + const relayTimeout = wholeNumber(c.relayTimeout, 0, 86400) + if (relayTimeout === null) return { cfg: null, error: 'The iPad idle timeout must be a whole number of seconds from 0 to 86400' } + Object.assign(cfg, { relayPort, relayClientPort, relayTimeout }) } + return { cfg, error: null } +} + +// ---------- v1 -> v2 upgrade ---------- + +const plain = (o) => !!o && o.isExpression === false +const fixed = (value) => ({ value, isExpression: false }) + +/** Copy the listed v1 options under their (possibly renamed) v2 keys; options that are missing stay missing. */ +function carry(o, pairs) { + const out = {} + for (const [from, to] of pairs) if (o[from] !== undefined) out[to] = o[from] + return out +} + +/** The v2 action for a v1 one: { actionId, options }, or null when there is no equivalent (it is then left untouched). */ +function mapV1Action(a, mon) { + const o = a.options || {} + switch (a.actionId) { + case 'send_on': + return 'send' in o ? { actionId: 'send_on', options: carry(o, [['channel', 'channel'], ['send', 'buss'], ['state', 'state']]) } : null + case 'send_level': + return 'send' in o ? { actionId: 'send_level_set', options: carry(o, [['channel', 'channel'], ['send', 'buss'], ['level', 'level']]) } : null + case 'send_nudge': + return 'send' in o ? { actionId: 'send_level_adjust', options: carry(o, [['channel', 'channel'], ['send', 'buss'], ['delta', 'delta']]) } : null + case 'send_pan': + return 'send' in o ? { actionId: 'send_pan_set', options: carry(o, [['channel', 'channel'], ['send', 'buss'], ['pan', 'pan']]) } : null + case 'group_assign': + return 'group' in o ? { actionId: 'group_assign', options: carry(o, [['channel', 'channel'], ['group', 'buss'], ['state', 'state']]) } : null + case 'snapshot_fire': + return 'number' in o ? { actionId: 'snapshot_fire', options: carry(o, [['number', 'snapshot']]) } : null + case 'buss_mute': + return plain(o.buss) && !('busses' in o) ? { actionId: 'buss_mute', options: { busses: fixed([o.buss.value]), ...carry(o, [['state', 'state']]) } } : null + case 'buss_fader': + return { actionId: 'buss_fader_set', options: carry(o, [['buss', 'buss'], ['level', 'level']]) } + case 'mute': + return plain(o.channel) ? { actionId: 'input_mute', options: { channels: fixed([o.channel.value]), ...carry(o, [['state', 'state']]) } } : null + case 'fader': + return { actionId: 'input_fader_set', options: carry(o, [['channel', 'channel'], ['level', 'level']]) } + case 'resend': + return { actionId: 'reload', options: {} } + case 'mon_on': + return mon.mode === 'group' + ? { actionId: 'group_assign', options: { ...carry(o, [['channel', 'channel'], ['state', 'state']]), buss: fixed(mon.buss) } } + : { actionId: 'send_on', options: { ...carry(o, [['channel', 'channel'], ['state', 'state']]), buss: fixed(mon.buss) } } + case 'mon_level': + return mon.mode === 'group' ? null : { actionId: 'send_level_set', options: { ...carry(o, [['channel', 'channel'], ['level', 'level']]), buss: fixed(mon.buss) } } + case 'mon_nudge': + return mon.mode === 'group' ? null : { actionId: 'send_level_adjust', options: { ...carry(o, [['channel', 'channel'], ['delta', 'delta']]), buss: fixed(mon.buss) } } + default: + return null + } +} + +function mapV1Feedback(f, mon) { + const o = f.options || {} + switch (f.feedbackId) { + case 'send_on': + return 'send' in o ? { feedbackId: 'send_on', options: carry(o, [['channel', 'channel'], ['send', 'buss']]) } : null + case 'group_assigned': + return 'group' in o ? { feedbackId: 'group_assigned', options: carry(o, [['channel', 'channel'], ['group', 'buss']]) } : null + case 'muted': + return { feedbackId: 'input_muted', options: carry(o, [['channel', 'channel']]) } + case 'mon_on': + return { feedbackId: mon.mode === 'group' ? 'group_assigned' : 'send_on', options: { ...carry(o, [['channel', 'channel']]), buss: fixed(mon.buss) } } + case 'mon_level_above': + return mon.mode === 'group' + ? null + : { feedbackId: 'send_level_compare', options: { ...carry(o, [['channel', 'channel'], ['threshold', 'threshold']]), buss: fixed(mon.buss), comparison: fixed('above') } } + default: + return null // buss_muted, connected and relay_clients keep their ids and option keys + } +} + +/** + * v1 -> v2. Config: keep the connection settings, debugLog = the old verbose log flag, the old default idle + * timeout (180 s) becomes the new default, everything else is dropped. Actions and feedbacks whose ids v2 reuses + * with other option keys (and the single-buss "mon" ones) are mapped to their v2 form; the rest stay as they are. + */ +export function upgradeV1Config(_context, props) { + const cfg = props.config + const updatedConfig = cfg + ? { + host: cfg.host ?? DEFAULTS.host, + consolePort: cfg.consolePort ?? DEFAULTS.consolePort, + localPort: cfg.localPort ?? DEFAULTS.localPort, + relayEnable: cfg.relayEnable ?? DEFAULTS.relayEnable, + relayPort: cfg.relayPort ?? DEFAULTS.relayPort, + relayClientPort: cfg.relayClientPort ?? DEFAULTS.relayClientPort, + relayTimeout: cfg.relayTimeout === undefined || cfg.relayTimeout === 180 ? DEFAULTS.relayTimeout : cfg.relayTimeout, + debugLog: cfg.debugLog ?? !!cfg.verboseLog, + } + : null + const mon = { buss: Number(cfg?.monSend) || 24, mode: cfg?.monMode === 'group' ? 'group' : 'aux' } + const updatedActions = (props.actions || []).map((a) => { + const m = mapV1Action(a, mon) + return m ? { ...a, actionId: m.actionId, options: m.options } : a + }) + const updatedFeedbacks = (props.feedbacks || []).map((f) => { + const m = mapV1Feedback(f, mon) + return m ? { ...f, feedbackId: m.feedbackId, options: m.options } : f + }) + return { updatedConfig, updatedActions, updatedFeedbacks } } export default class DigicoS31Instance extends InstanceBase { constructor(internal) { super(internal) + this.timing = { ...TIMING } + this.config = { ...DEFAULTS } + this.cfg = null // the validated, normalised config the running link / relay were opened with + this.opChain = Promise.resolve() // init / configUpdated / destroy never overlap + this.state = new State() this.link = null this.relay = null - this.timer = null + this.relayClients = new Map() // iPad clients survive the relay being reopened + this.keepaliveTimer = null + this.linkRetryTimer = null + this.relayRetryTimer = null this.txQueue = [] this.txTimer = null + this.queued = new Set() + this.idleCallbacks = [] + this.timers = new Set() this.seen = new Set() - this.state = emptyState() + this.requested = new Map() // lazily loaded pairs -> time requested, de-duplicated for a while + this.waiters = new Map() // event key -> Set + this.pendingWrites = new Map() // address -> { value, until, timer } + this.errorLog = new Map() // error code + destination -> time last logged this.connectedFlag = false - this.lastMonMode = null + this.statusName = 'connecting' + this.statusText = '' + this.relayProblem = null + this.connectStartedAt = Date.now() + this.reloadOnReconnect = false + this.loading = false + this.loadToken = 0 + this.pendingVars = {} + this.pendingFeedbacks = new Set() // feedback types (fallback) + this.pendingIds = new Set() // feedback instance ids + this.watchers = new Map() // watch key -> Set + this.watching = new Map() // feedback instance id -> watch key + this.flushTimer = null + this.rebuildTimer = null + this.rebuildPending = false + this.lastDefs = {} // JSON of what was last pushed, per kind of definition + this.definedVariables = new Set() + this.presetStats = null + this.meterWanted = new Map() // meter id -> { n, meter } + this.meterDirtyIds = new Set() + this.peakUntil = new Map() // meter id -> time until which its peak hold is re-checked + this.meterTimer = null + this.resubscribeTimer = null + } + + // ---------- lifecycle ---------- + + /** Run init / configUpdated / destroy one after the other, whatever order Companion calls them in. */ + run(fn) { + const p = this.opChain.then(fn) + this.opChain = p.catch(() => {}) + return p } async init(config) { - this.config = config - this.dialect = getDialect(config.dialect) - this.initDefinitions() - await this.connect() + return this.run(async () => { + this.config = { ...DEFAULTS, ...config } + this.rebuildDefinitions() + await this.connect() + }) + } + + /** Only what changed is touched: the console link, the relay, or just a flag / a live relay setting. */ + async configUpdated(config) { + return this.run(async () => { + const before = this.cfg + this.config = { ...DEFAULTS, ...config } + const { cfg, error } = normalizeConfig(this.config) + const linkChanged = !before || !cfg || before.host !== cfg.host || before.consolePort !== cfg.consolePort || before.localPort !== cfg.localPort + const linkIdle = !this.link && !this.linkRetryTimer + if (error || linkChanged || linkIdle) { + await this.disconnect() + this.state.reset() + await this.connect() + return + } + this.cfg = cfg + if (before.relayEnable !== cfg.relayEnable || (cfg.relayEnable && before.relayPort !== cfg.relayPort)) { + await this.reopenRelay() + } else if (this.relay && cfg.relayEnable) { + this.relay.update({ clientPort: cfg.relayClientPort, timeoutMs: cfg.relayTimeout * 1000 }) + this.publishRelay() + } + // debugLog is read live from this.config + }) } async destroy() { - await this.disconnect() - } - - async configUpdated(config) { - this.config = config - this.dialect = getDialect(config.dialect) - await this.disconnect() - this.initDefinitions() - await this.connect() + return this.run(async () => { + await this.disconnect() + for (const t of [this.flushTimer, this.rebuildTimer]) if (t) clearTimeout(t) + this.flushTimer = null + this.rebuildTimer = null + }) } getConfigFields() { return [ { type: 'static-text', - id: 'info', + id: 'consoleInfo', width: 12, - label: 'How this works', + label: 'Console setup', value: - 'Companion becomes the console\'s single remote (iPad) controller: on the console, Extensions → iPad, set the iPad IP to this computer, ' + - 'Send Port = the local receive port below, Receive Port = the console receive port below. DiGiCo iPad apps then point at this computer ' + - 'instead of the console and are relayed through, so several can be used at once.', + 'On the console open Extensions > iPad. Set the iPad IP address to this computer, the Send Port to the "Local receive port" below, ' + + 'the Receive Port to the "Console receive port" below, and enable the iPad connection. Companion then holds the console\'s single remote slot.', }, - { type: 'textinput', id: 'host', label: 'Console IP', width: 6, default: '10.3.4.221', regex: Regex.IP }, - { type: 'number', id: 'consolePort', label: 'Console receive port (we send here)', width: 3, default: 8000, min: 1, max: 65535 }, - { type: 'number', id: 'localPort', label: 'Local receive port (console sends here)', width: 3, default: 9000, min: 1, max: 65535 }, - { type: 'dropdown', id: 'dialect', label: 'Command set', width: 6, default: 'sd', choices: DIALECT_CHOICES }, - { type: 'dropdown', id: 'boolStyle', label: 'How to write on/off values', width: 6, default: 'int', choices: BOOL_STYLES }, - { type: 'checkbox', id: 'keepalive', label: 'Keepalive ping + reload on reconnect', width: 4, default: true }, - { type: 'number', id: 'channels', label: 'Input channels to load and build presets for', width: 4, default: 60, min: 1, max: MAX_INPUTS }, - { type: 'number', id: 'busses', label: 'Busses to load names for', width: 4, default: MAX_BUSSES, min: 1, max: MAX_BUSSES }, + { type: 'textinput', id: 'host', label: 'Console IP', width: 4, default: DEFAULTS.host, regex: Regex.IP }, + { type: 'number', id: 'consolePort', label: 'Console receive port (module sends here)', width: 4, default: DEFAULTS.consolePort, min: 1, max: 65535, asInteger: true }, { - type: 'static-text', - id: 'monInfo', - width: 12, - label: 'Studio Mon buss', - value: 'Buss numbers follow the console\'s buss list (1-25). On this console buss 24 is the stereo group "Studio" and buss 17 is the aux "Studio TB". A group buss gives per-channel assign on/off; an aux buss gives send level + on/off.', - }, - { type: 'number', id: 'monSend', label: 'Studio Mon buss number', width: 4, default: 24, min: 1, max: MAX_BUSSES }, - { - type: 'dropdown', - id: 'monMode', - label: 'Studio Mon buss type', + type: 'number', + id: 'localPort', + label: 'Local receive port (console sends here; = console "Send port")', width: 4, - default: 'auto', - choices: [ - { id: 'auto', label: 'Auto (from the names the console reports)' }, - { id: 'aux', label: 'Aux (send level + on/off)' }, - { id: 'group', label: 'Group (assign on/off)' }, - ], + default: DEFAULTS.localPort, + min: 1, + max: 65535, + asInteger: true, }, - { type: 'textinput', id: 'monLabel', label: 'Name for it in button labels', width: 4, default: 'Studio Mon' }, { type: 'static-text', - id: 'relayInfo', + id: 'ipadInfo', width: 12, - label: 'iPad relay', - value: 'In the DiGiCo S app enter this computer\'s IP as the console, Send Port = relay port, Receive Port = iPad receive port.', + label: 'iPad apps (relay)', + value: + 'In the DiGiCo S app on each iPad enter this computer as the console address, set the app\'s Send Port to the "Relay port" below and its Receive Port ' + + 'to the "iPad receive port" below. Every app is relayed through this connection, so several iPads can be used at once.', + }, + { type: 'checkbox', id: 'relayEnable', label: 'Enable iPad relay', width: 3, default: DEFAULTS.relayEnable }, + { type: 'number', id: 'relayPort', label: 'Relay port (iPad apps send here)', width: 3, default: DEFAULTS.relayPort, min: 1, max: 65535, asInteger: true }, + { + type: 'number', + id: 'relayClientPort', + label: 'iPad receive port (0 = reply to the app\'s source port)', + width: 3, + default: DEFAULTS.relayClientPort, + min: 0, + max: 65535, + asInteger: true, + }, + { + type: 'number', + id: 'relayTimeout', + label: 'Forget an iPad after (idle seconds, 0 = never)', + width: 3, + default: DEFAULTS.relayTimeout, + min: 0, + max: 86400, + asInteger: true, + }, + { + type: 'checkbox', + id: 'debugLog', + label: 'Debug logging (log every new address pattern and all traffic at debug level)', + width: 12, + default: DEFAULTS.debugLog, }, - { type: 'checkbox', id: 'relayEnable', label: 'Enable iPad relay', width: 3, default: true }, - { type: 'number', id: 'relayPort', label: 'Relay port (iPad apps send here)', width: 3, default: 8000, min: 1, max: 65535 }, - { type: 'number', id: 'relayClientPort', label: 'iPad receive port (0 = reply to source port)', width: 3, default: 9000, min: 0, max: 65535 }, - { type: 'number', id: 'relayTimeout', label: 'Forget an iPad after (seconds idle)', width: 3, default: 180, min: 10, max: 3600 }, - { type: 'checkbox', id: 'learnLog', label: 'Learn mode: log each new address pattern once', width: 4, default: true }, - { type: 'checkbox', id: 'verboseLog', label: 'Log every message (debug level)', width: 4, default: false }, ] } - // ---------- helpers used by definitions ---------- - monSend() { - return Math.min(MAX_BUSSES, Math.max(1, Math.trunc(Number(this.config?.monSend) || 1))) - } - monLabel() { - return String(this.config?.monLabel || 'Studio Mon').trim() || 'Studio Mon' - } - monMode() { - const cfg = this.config?.monMode - if (cfg === 'aux' || cfg === 'group') return cfg - const n = this.monSend() - const g = (this.state.groupNames.get(n) || '').trim() - const a = (this.state.auxNames.get(n) || '').trim() - if (g && !a) return 'group' - return 'aux' - } - getSend(ch, send) { - return this.state.sends.get(`${Number(ch)}:${Number(send)}`) || {} - } - getGroup(ch, group) { - return this.state.groups.get(`${Number(ch)}:${Number(group)}`) - } - getMonOn(ch) { - return this.monMode() === 'group' ? this.getGroup(ch, this.monSend()) : this.getSend(ch, this.monSend()).enabled - } - isConnected() { - return this.connectedFlag + /** Show the status that follows from the link state, with a relay problem as a warning while the link itself is fine. */ + reportStatus() { + switch (this.statusName) { + case 'bad_config': + this.updateStatus(InstanceStatus.BadConfig, this.statusText) + break + case 'failed': + this.updateStatus(InstanceStatus.ConnectionFailure, this.statusText) + break + case 'disconnected': + this.updateStatus(InstanceStatus.Disconnected, 'No reply from the console (check the iPad IP address and ports on the console)') + break + case 'ok': + if (this.relayProblem) this.updateStatus(InstanceStatus.UnknownWarning, this.relayProblem) + else this.updateStatus(InstanceStatus.Ok) + break + default: + this.updateStatus(InstanceStatus.Connecting) + } } - initDefinitions() { - this.setActionDefinitions(getActionDefinitions(this)) - this.setFeedbackDefinitions(getFeedbackDefinitions(this)) - this.setVariableDefinitions(getVariableDefinitions(this)) - this.refreshPresets(true) - this.setVariableValues({ - connected: this.connectedFlag ? 'Yes' : 'No', - console_host: this.config.host || '', - relay_clients: 0, - relay_client_ips: '', - mon_send: this.monSend(), - mon_mode: this.monMode(), - last_rx: '', - last_send_touched: '', - }) - this.publishState() + /** One log line per error code + destination per `errorLogMs`, whatever the rate the errors come at. */ + reportError(source, err) { + const key = `${source}|${err.code || err.message}|${err.dest || ''}` + const now = Date.now() + const last = this.errorLog.get(key) + if (last !== undefined && now - last < this.timing.errorLogMs) return false + if (this.errorLog.size > 200) this.errorLog.clear() + this.errorLog.set(key, now) + this.log('error', `${source}: ${err.message}${err.dest ? ` (${err.dest})` : ''}`) + return true } - refreshPresets(force = false) { - const mode = this.monMode() - if (!force && mode === this.lastMonMode) return - this.lastMonMode = mode - const { structure, presets } = getPresetDefinitions(this) - this.setPresetDefinitions(structure, presets) - this.setVariableValues({ mon_mode: mode }) - } - - // ---------- lifecycle ---------- async connect() { - const cfg = this.config - if (!cfg.host) { - this.updateStatus(InstanceStatus.BadConfig, 'Console IP missing') + const { cfg, error } = normalizeConfig(this.config) + this.cfg = cfg + this.relayProblem = null + if (error) { + this.statusName = 'bad_config' + this.statusText = error + this.reportStatus() + this.log('warn', error) return } - this.updateStatus(InstanceStatus.Connecting) + this.statusName = 'connecting' + this.reportStatus() this.seen.clear() - this.state = emptyState() + this.requested.clear() + this.meterWanted.clear() + this.connectedFlag = false + this.reloadOnReconnect = false + this.connectStartedAt = Date.now() + if (await this.openLink()) await this.afterLink() + else this.scheduleLinkRetry() + } - this.link = new ConsoleLink({ host: cfg.host, port: cfg.consolePort, localPort: cfg.localPort }) - this.link.on('error', (err) => this.log('error', `Console socket: ${err.message}`)) - this.link.on('stray', (buf, rinfo) => this.learn('stray', decode(buf), rinfo)) - this.link.on('raw', (buf) => { + async openLink() { + const cfg = this.cfg + const link = new ConsoleLink({ host: cfg.host, port: cfg.consolePort, localPort: cfg.localPort }) + link.on('error', (err) => this.reportError('Console socket', err)) + link.on('stray', (buf, rinfo) => this.learn('stray', decode(buf), rinfo)) + link.on('raw', (buf) => { if (this.relay) this.relay.fanout(buf) }) - this.link.on('message', (msg, _buf, rinfo) => this.onConsoleMessage(msg, rinfo)) + link.on('message', (msg, _buf, rinfo) => this.onConsoleMessage(msg, rinfo)) try { - const addr = await this.link.open() - this.log('info', `Console link: listening on udp ${addr.port}, sending to ${cfg.host}:${cfg.consolePort} (${this.dialect.label})`) + const info = await link.open() + this.link = link + this.log('info', `Console link: listening on udp ${info.port}, sending to ${cfg.host}:${cfg.consolePort}`) + return true } catch (err) { - this.updateStatus(InstanceStatus.ConnectionFailure, `Cannot bind local port ${cfg.localPort}: ${err.code || err.message}`) - this.log('error', `Cannot bind local port ${cfg.localPort}: ${err.message}`) - this.link = null + this.statusName = 'failed' + this.statusText = `Cannot bind local port ${cfg.localPort}: ${err.code || err.message}` + this.reportStatus() + this.reportError('Console socket', Object.assign(err, { message: `cannot bind local port ${cfg.localPort}: ${err.message}` })) + return false + } + } + + scheduleLinkRetry() { + if (this.linkRetryTimer) clearTimeout(this.linkRetryTimer) + this.linkRetryTimer = setTimeout(() => { + this.linkRetryTimer = null + this.run(async () => { + if (this.link || !this.cfg) return + if (await this.openLink()) { + this.statusName = 'connecting' + this.reportStatus() + this.connectStartedAt = Date.now() + await this.afterLink() + } else this.scheduleLinkRetry() + }) + }, this.timing.bindRetryMs) + } + + /** Everything that needs the console link: relay, first query, load, keepalive. */ + async afterLink() { + await this.openRelay() + this.sendPing() + this.startLoad('connect') + this.keepaliveTimer = setInterval(() => this.tick(), this.timing.keepaliveMs) + this.publishRelay() + } + + async openRelay() { + const cfg = this.cfg + if (!cfg?.relayEnable) { + this.relay = null + this.relayProblem = null + this.reportStatus() return } - - if (cfg.relayEnable) { - this.relay = new Relay({ port: cfg.relayPort, clientPort: cfg.relayClientPort, timeoutMs: (Number(cfg.relayTimeout) || 180) * 1000 }) - this.relay.on('error', (err) => this.log('error', `Relay socket: ${err.message}`)) - this.relay.on('client', (rinfo) => { - this.log('info', `Relay: new iPad client ${rinfo.address}:${rinfo.port}`) - this.publishRelay() - }) - this.relay.on('clientGone', (ip) => { - this.log('info', `Relay: iPad client ${ip} idle, forgotten`) - this.publishRelay() - }) - this.relay.on('fromClient', (buf, rinfo) => { - if (this.link) this.link.sendRaw(buf) - if (cfg.learnLog || cfg.verboseLog) { - const msg = decode(buf) - this.learn('ipad', msg, rinfo) - if (cfg.verboseLog) this.log('debug', `ipad ${rinfo.address} -> console: ${describe(msg)}`) - } - }) - try { - const addr = await this.relay.open() - this.log('info', `Relay: iPad apps may send to udp ${addr.port}; console traffic fans out to port ${cfg.relayClientPort || '(source)'}`) - } catch (err) { - this.log('error', `Relay: cannot bind port ${cfg.relayPort}: ${err.message}`) - this.relay = null + const relay = new Relay({ port: cfg.relayPort, clientPort: cfg.relayClientPort, timeoutMs: cfg.relayTimeout * 1000, clients: this.relayClients }) + relay.on('error', (err) => this.reportError('Relay socket', err)) + relay.on('client', (rinfo) => { + this.log('info', `Relay: new iPad client ${rinfo.address}:${rinfo.port}`) + this.publishRelay() + }) + relay.on('clientGone', (ip) => { + this.log('info', `Relay: iPad client ${ip} forgotten`) + this.publishRelay() + }) + relay.on('fromClient', (buf, rinfo) => { + if (this.link) this.link.sendRaw(buf) + const msg = decode(buf) + // An iPad clears every meter subscription on the console when it connects, ours included. + if (msg.address === addr.meterClear) this.scheduleMeterResubscribe() + if (this.config.debugLog) { + this.learn('ipad', msg, rinfo) + this.log('debug', `ipad ${rinfo.address} -> console: ${describe(msg)}`) } + }) + try { + const info = await relay.open() + this.relay = relay + this.relayProblem = null + this.log('info', `Relay: iPad apps may send to udp ${info.port}; console traffic fans out to port ${cfg.relayClientPort || '(source)'}`) + } catch (err) { + this.relay = null + this.relayProblem = err.code === 'EADDRINUSE' ? `iPad relay: port ${cfg.relayPort} in use` : `iPad relay: cannot bind port ${cfg.relayPort} (${err.code || err.message})` + this.reportError('Relay socket', Object.assign(err, { message: `cannot bind port ${cfg.relayPort}: ${err.message}` })) + this.scheduleRelayRetry() } + this.reportStatus() + } - if (this.dialect.id === 'raw') { - this.setConnected(true, 'relay only') - } else { - this.sendPing() - this.requestResend() - } - this.timer = setInterval(() => this.tick(), KEEPALIVE_MS) + scheduleRelayRetry() { + if (this.relayRetryTimer) clearTimeout(this.relayRetryTimer) + this.relayRetryTimer = setTimeout(() => { + this.relayRetryTimer = null + this.run(async () => { + if (!this.link || this.relay || !this.cfg?.relayEnable) return + await this.openRelay() + this.publishRelay() + }) + }, this.timing.bindRetryMs) + } + + /** The relay port or enable flag changed: reopen it, keeping the iPad clients. */ + async reopenRelay() { + if (this.relayRetryTimer) clearTimeout(this.relayRetryTimer) + this.relayRetryTimer = null + if (this.relay) this.relay.close() + this.relay = null + this.relayProblem = null + if (this.link) await this.openRelay() this.publishRelay() } async disconnect() { - if (this.timer) clearInterval(this.timer) - this.timer = null - if (this.txTimer) clearInterval(this.txTimer) + this.loadToken++ + this.loading = false + for (const t of [this.keepaliveTimer, this.txTimer, this.meterTimer]) if (t) clearInterval(t) + for (const t of [this.linkRetryTimer, this.relayRetryTimer]) if (t) clearTimeout(t) + this.keepaliveTimer = null + this.linkRetryTimer = null + this.relayRetryTimer = null this.txTimer = null + this.meterTimer = null + for (const t of this.timers) clearTimeout(t) + this.timers.clear() + this.pendingWrites.clear() + if (this.rebuildTimer) clearTimeout(this.rebuildTimer) + this.rebuildTimer = null + this.resubscribeTimer = null this.txQueue = [] + this.queued.clear() + this.idleCallbacks = [] if (this.link) this.link.close() if (this.relay) this.relay.close() this.link = null @@ -263,134 +533,426 @@ export default class DigicoS31Instance extends InstanceBase { this.connectedFlag = false } + later(fn, ms) { + const t = setTimeout(() => { + this.timers.delete(t) + fn() + }, ms) + this.timers.add(t) + return t + } + tick() { if (!this.link) return - if (this.dialect.id === 'raw') { - this.publishRelay() - return - } - if (this.config.keepalive) this.sendPing() - const silent = Date.now() - this.link.lastRx - if (this.connectedFlag && silent > SILENCE_MS) this.setConnected(false) + this.sendPing() + const quietFor = Date.now() - Math.max(this.link.lastRx, this.connectStartedAt) + if (quietFor > this.timing.silenceMs && this.statusName !== 'disconnected') this.setConnected(false) this.publishRelay() } - setConnected(on, note) { + setConnected(on) { const was = this.connectedFlag this.connectedFlag = !!on - if (on) this.updateStatus(InstanceStatus.Ok, note) - else this.updateStatus(InstanceStatus.Disconnected, 'No reply from console (check the iPad/controller IP and ports on the console)') - if (was !== this.connectedFlag) { - this.setVariableValues({ connected: on ? 'Yes' : 'No' }) - this.checkFeedbacks('connected') - this.log('info', on ? 'Console link alive' : 'Console link silent') - if (on && was === false && this.config.keepalive && this.link && this.link.rxCount > 1) this.requestResend() + this.statusName = on ? 'ok' : 'disconnected' + this.reportStatus() + if (!on) this.reloadOnReconnect = true + if (was === this.connectedFlag) return + this.queueVariables({ connected: on ? 'Yes' : 'No' }) + this.touch(watchKey.connected, ['connected']) + this.log('info', on ? 'Console link alive' : 'Console link silent') + if (on) { + if (this.reloadOnReconnect && !this.loading) { + this.reloadOnReconnect = false + this.startLoad('reconnect') + } + this.resubscribeMeters() } } // ---------- outbound ---------- - /** Immediate send of a dialect-built message (user actions). */ - sendDialect(fn, ...args) { - if (!this.link) return false - const built = this.dialect[fn]?.(...args) - if (!built) { - this.log('warn', `${fn}: not available in the "${this.dialect.label}" command set`) - return false - } - if (this.config.verboseLog) this.log('debug', `-> ${built[0]} ${JSON.stringify(built[1])}`) - return this.link.send(built[0], built[1]) + + /** Refuse an address whose entity numbers are not whole numbers in range: one warning, no packet. */ + addressOk(address) { + const problem = indexProblem(address) + if (!problem) return true + this.log('warn', `Not sent (${problem}): ${address}`) + return false } - /** Paced send for bulk queries so the console is not flooded. */ - enqueue(list) { - for (const item of list) if (item) this.txQueue.push(item) - if (this.txTimer || !this.txQueue.length) return - this.txTimer = setInterval(() => { - const next = this.txQueue.shift() - if (!next || !this.link) { - clearInterval(this.txTimer) - this.txTimer = null - this.txQueue = [] - return - } - this.link.send(next[0], next[1]) - }, TX_PACE_MS) + sendOsc(address, args = []) { + if (!this.link) return false + if (this.config.debugLog) this.log('debug', `-> ${address} ${JSON.stringify(args)}`) + return this.link.send(address, args) + } + + queryNow(address) { + if (!this.link || !this.addressOk(address)) return false + return this.link.send(`${address}/?`, []) } sendPing() { - const p = this.dialect.ping() - if (p && this.link) this.link.send(p[0], p[1]) + this.queryNow(addr.consoleName) } - requestResend() { - if (!this.link) return false - const inputs = Math.min(MAX_INPUTS, Math.max(1, Number(this.config.channels) || MAX_INPUTS)) - const auxes = Math.min(MAX_BUSSES, Math.max(1, Number(this.config.busses) || MAX_BUSSES)) - const list = this.dialect.resendQueries({ inputs, auxes, monSend: this.monSend() }) - if (!list.length) return false - this.log('info', `Loading ${list.length} values from the console`) - this.enqueue(list) + /** Paced, de-duplicated sends: bulk queries must not flood the console. */ + enqueue(list) { + if (!this.link) return + for (const item of list) { + const id = item[0] + (item[1].length ? JSON.stringify(item[1]) : '') + if (this.queued.has(id)) continue + this.queued.add(id) + this.txQueue.push({ item, id }) + } + this.pump() + } + + pump() { + if (this.txTimer) return + if (!this.txQueue.length) { + this.fireIdle() + return + } + this.txTimer = setInterval(() => { + const next = this.txQueue.shift() + if (!next) { + clearInterval(this.txTimer) + this.txTimer = null + this.fireIdle() + return + } + this.queued.delete(next.id) + if (this.link) this.link.send(next.item[0], next.item[1]) + }, this.timing.txPaceMs) + } + + whenIdle(cb) { + if (!this.txTimer && !this.txQueue.length) this.later(cb, 0) + else this.idleCallbacks.push(cb) + } + + fireIdle() { + const cbs = this.idleCallbacks + this.idleCallbacks = [] + for (const cb of cbs) cb() + } + + /** + * Send a write now and apply it to the local state at once, so quick encoder turns accumulate. For + * `writeGuardMs` after the last write to an address, a differing value arriving for it is stale (an echo of + * an earlier click) and is ignored; when the guard runs out one query confirms the console's value. + */ + write(address, args, localValue) { + if (!this.addressOk(address)) return false + if (!this.link) { + this.log('warn', `Not connected: ${address} not sent`) + return false + } + this.sendOsc(address, args) + const ev = this.state.applyMessage(address, [localValue]) + if (ev) { + this.setGuard(address, ev.value) + this.afterEvent(ev) + } return true } - resolveState(current, state) { - if (state === 'toggle') return !(current === true) - return state === 'on' + setGuard(address, value) { + const old = this.pendingWrites.get(address) + if (old) { + clearTimeout(old.timer) + this.timers.delete(old.timer) + } + const timer = this.later(() => { + this.pendingWrites.delete(address) + this.enqueue([query(address)]) + }, this.timing.writeGuardMs) + this.pendingWrites.set(address, { value, until: Date.now() + this.timing.writeGuardMs, timer }) } - setMonOn(ch, state) { - return this.monMode() === 'group' ? this.setGroupAssign(ch, this.monSend(), state) : this.setSendEnabled(ch, this.monSend(), state) + /** True when `msg` is a stale value for an address we wrote to a moment ago. */ + isStale(msg) { + const g = this.pendingWrites.get(msg.address) + if (!g || Date.now() >= g.until) return false + const incoming = parse(msg.address, msg.args) + return !!incoming && incoming.value !== undefined && !sameValue(incoming.value, g.value) } - setSendEnabled(ch, send, state) { - const cur = this.getSend(ch, send).enabled - const on = this.resolveState(cur, state) - const ok = this.sendDialect('sendEnabled', Number(ch), Number(send), on, this.config.boolStyle) - if (ok) this.enqueue(this.dialect.querySend(Number(ch), Number(send))) - return ok + waitFor(key, ms = this.timing.replyWaitMs) { + return new Promise((resolve) => { + let set = this.waiters.get(key) + if (!set) { + set = new Set() + this.waiters.set(key, set) + } + const done = () => { + clearTimeout(timer) + set.delete(done) + if (!set.size) this.waiters.delete(key) + resolve() + } + const timer = setTimeout(done, ms) + set.add(done) + }) } - setGroupAssign(ch, group, state) { - const cur = this.getGroup(ch, group) - const on = this.resolveState(cur, state) - const ok = this.sendDialect('groupAssign', Number(ch), Number(group), on, this.config.boolStyle) - if (ok) this.enqueue(this.dialect.queryGroup(Number(ch), Number(group))) - return ok + notifyWaiters(key) { + const set = this.waiters.get(key) + if (set) for (const done of [...set]) done() } - setSendLevel(ch, send, dB) { - const d = this.dialect - const v = Math.min(d.levelMax, Math.max(d.levelMin, Number(dB))) - const ok = this.sendDialect('sendLevel', Number(ch), Number(send), v) - if (ok) this.enqueue(this.dialect.querySend(Number(ch), Number(send))) - return ok + /** Current value, asking the console first (and waiting briefly for the reply) when it is not known yet. */ + async current(key, get, address) { + const v = get() + if (v !== undefined) return v + if (!this.addressOk(address)) return undefined + const reply = this.waitFor(key) + this.queryNow(address) + await reply + return get() } - nudgeSendLevel(ch, send, delta) { - const cur = this.getSend(ch, send).level - if (typeof cur !== 'number') { - this.log('warn', `Send level for ch ${ch} buss ${send} unknown yet; asking the console`) - this.enqueue(this.dialect.querySend(Number(ch), Number(send))) + /** + * Load one value pair on first use. Asked for again only after a while (an unanswered request may be + * repeated, but not more often than every relazyMs); callers only ask while the value is unknown. + */ + ensure(key, addresses) { + if (!this.link) return + const now = Date.now() + const last = this.requested.get(key) + if (last !== undefined && now - last < Math.max(this.timing.replyWaitMs, this.timing.relazyMs)) return + if (!addresses.every((a) => this.addressOk(a))) return + this.requested.set(key, now) + this.enqueue(addresses.map(query)) + } + + ensureSend(ch, buss) { + const s = this.state.getSend(ch, buss) + if (s.level === undefined || s.on === undefined) this.ensure(`send:${ch}:${buss}`, [addr.sendLevel(ch, buss), addr.sendOn(ch, buss)]) + } + + ensureGroup(ch, buss) { + if (this.state.getGroup(ch, buss) === undefined) this.ensure(`group:${ch}:${buss}`, [addr.groupAssign(ch, buss)]) + } + + ensureParam(ent, n, param) { + const address = addr.param(ent, n, this.sideOf(ent, n), param) + if (address && this.state.getParam(ent, n, param) === undefined) this.ensure(`param:${ent}:${n}:${param}`, [address]) + } + + // ---------- operations used by actions ---------- + + async writeFlag(address, key, get, mode) { + if (!this.addressOk(address)) return false + let on + if (mode === 'on') on = true + else if (mode === 'off') on = false + else { + const cur = await this.current(key, get, address) + if (cur === undefined) { + this.log('warn', `${address}: current state unknown (no reply from the console), toggle not applied`) + return false + } + on = !cur + } + return this.write(address, [{ i: on ? 1 : 0 }], on ? 1 : 0) + } + + writeLevel(address, dB) { + if (!this.addressOk(address)) return false + const v = clamp(Number(dB), LEVEL.min, LEVEL.max) + if (!Number.isFinite(v)) return false + return this.write(address, [v], v) + } + + async adjustLevel(address, key, get, delta) { + if (!this.addressOk(address)) return false + const cur = await this.current(key, get, address) + if (cur === undefined) { + this.log('warn', `${address}: current level unknown (no reply from the console), not adjusted`) return false } - const floor = this.dialect.levelMin - const base = cur <= floor ? floor : cur - return this.setSendLevel(ch, send, base + Number(delta)) + return this.writeLevel(address, adjustedLevel(cur, delta)) } - setMute(ch, state) { - const cur = this.state.mutes.get(Number(ch)) - const on = this.resolveState(cur, state) - return this.sendDialect('mute', Number(ch), on, this.config.boolStyle) + writePan(address, pan) { + if (!this.addressOk(address)) return false + const v = clamp(Number(pan), 0, 1) + if (!Number.isFinite(v)) return false + return this.write(address, [v], v) } - setBussMute(buss, state) { - const cur = this.state.bussMutes.get(Number(buss)) - const on = this.resolveState(cur, state) - return this.sendDialect('auxMute', Number(buss), on, this.config.boolStyle) + async adjustPanValue(address, key, get, pdelta) { + if (!this.addressOk(address)) return false + const cur = await this.current(key, get, address) + if (cur === undefined) { + this.log('warn', `${address}: current pan unknown (no reply from the console), not adjusted`) + return false + } + return this.writePan(address, cur + Number(pdelta)) } - sendRawAction(options) { + sideOf(ent, n) { + return ent === 'buss' ? this.state.bussMode(n) : undefined + } + + setSwitch(ent, field, n, mode) { + const side = this.sideOf(ent, n) + const get = () => (field === 'mute' ? this.state.getMute(ent, n) : this.state.getSolo(ent, n)) + return this.writeFlag(addr[field](ent, n, side), K.value(ent, n, field, side), get, mode) + } + + setFader(ent, n, dB) { + return this.writeLevel(addr.fader(ent, n, this.sideOf(ent, n)), dB) + } + + adjustFader(ent, n, delta) { + const side = this.sideOf(ent, n) + return this.adjustLevel(addr.fader(ent, n, side), K.value(ent, n, 'fader', side), () => this.state.getFader(ent, n), delta) + } + + setPan(n, pan) { + return this.writePan(addr.pan(n), pan) + } + + adjustPan(n, pdelta) { + return this.adjustPanValue(addr.pan(n), K.value('input', n, 'pan'), () => this.state.getPan(n), pdelta) + } + + setInputName(n, name) { + const text = String(name ?? '') + if (!text.trim()) { + this.log('warn', `Input ${n}: an empty name is ignored`) + return false + } + return this.write(addr.name('input', n), [text], text) + } + + setSendOn(ch, buss, mode) { + return this.writeFlag(addr.sendOn(ch, buss), K.send(ch, buss, 'on'), () => this.state.getSend(ch, buss).on, mode) + } + + setSendLevel(ch, buss, dB) { + return this.writeLevel(addr.sendLevel(ch, buss), dB) + } + + adjustSendLevel(ch, buss, delta) { + return this.adjustLevel(addr.sendLevel(ch, buss), K.send(ch, buss, 'level'), () => this.state.getSend(ch, buss).level, delta) + } + + setSendPan(ch, buss, pan) { + return this.writePan(addr.sendPan(ch, buss), pan) + } + + adjustSendPan(ch, buss, pdelta) { + return this.adjustPanValue(addr.sendPan(ch, buss), K.send(ch, buss, 'pan'), () => this.state.getSend(ch, buss).pan, pdelta) + } + + setGroup(ch, buss, mode) { + return this.writeFlag(addr.groupAssign(ch, buss), K.group(ch, buss), () => this.state.getGroup(ch, buss), mode) + } + + setMatrixSendOn(inN, outN, mode) { + return this.writeFlag(addr.matrixSendOn(inN, outN), K.msend(inN, outN, 'on'), () => this.state.getMatrixSend(inN, outN).on, mode) + } + + setMatrixSendLevel(inN, outN, dB) { + return this.writeLevel(addr.matrixSendLevel(inN, outN), dB) + } + + adjustMatrixSendLevel(inN, outN, delta) { + return this.adjustLevel(addr.matrixSendLevel(inN, outN), K.msend(inN, outN, 'level'), () => this.state.getMatrixSend(inN, outN).level, delta) + } + + setGeqOn(n, mode) { + return this.writeFlag(addr.geqIn(n), K.geq(n, 'on'), () => this.state.getGeq(n).on, mode) + } + + setGeqBand(n, band, gain) { + const address = addr.geqGain(n, band) + if (!this.addressOk(address)) return false + const v = clamp(Number(gain), -12, 12) + if (!Number.isFinite(v)) return false + return this.write(address, [v], v) + } + + async adjustGeqBand(n, band, delta) { + const address = addr.geqGain(n, band) + if (!this.addressOk(address)) return false + const cur = await this.current(K.geq(n, 'gain', band), () => this.state.getGeq(n).gains?.get(Number(band)), address) + if (cur === undefined) { + this.log('warn', `${address}: current gain unknown (no reply from the console), not adjusted`) + return false + } + return this.setGeqBand(n, band, cur + Number(delta)) + } + + /** The OSC address of a channel parameter, or undefined (with a warning) when this entity kind does not have it. */ + paramAddress(ent, n, param) { + const address = addr.param(ent, n, this.sideOf(ent, n), param) + if (!address) this.log('warn', `${ent} has no "${param}" parameter`) + return address + } + + setParamSwitch(ent, n, param, mode) { + const address = this.paramAddress(ent, n, param) + if (!address) return false + return this.writeFlag(address, K.param(ent, n, param, this.sideOf(ent, n)), () => this.state.getParam(ent, n, param), mode) + } + + setParamValue(ent, n, param, value) { + const address = this.paramAddress(ent, n, param) + if (!address || !this.addressOk(address)) return false + const p = PARAMS[param] + const v = clamp(Number(value), p.min, p.max) + if (!Number.isFinite(v)) return false + return this.write(address, [v], v) + } + + async adjustParamValue(ent, n, param, delta) { + const address = this.paramAddress(ent, n, param) + if (!address || !this.addressOk(address)) return false + const p = PARAMS[param] + const cur = await this.current(K.param(ent, n, param, this.sideOf(ent, n)), () => this.state.getParam(ent, n, param), address) + if (cur === undefined) { + this.log('warn', `${address}: current value unknown (no reply from the console), not adjusted`) + return false + } + const next = p.adjust === 'percent' ? Math.max(cur, p.min) * (1 + Number(delta) / 100) : cur + Number(delta) + return this.setParamValue(ent, n, param, next) + } + + fireSnapshot(index) { + if (!Number.isInteger(index) || index < 0 || index > 9999) { + this.log('warn', `Snapshot: "${index}" is not a snapshot index (a whole number from 0 to 9999)`) + return false + } + const ok = this.sendOsc(addr.fireSnapshot, [{ i: index }]) + this.later(() => this.enqueue([query(addr.currentSnapshot)]), 400) + return ok + } + + fireNextSnapshot() { + const ok = this.sendOsc(addr.fireNext, [{ i: 0 }]) + this.later(() => this.enqueue([query(addr.currentSnapshot)]), 400) + return ok + } + + firePrevSnapshot() { + const ok = this.sendOsc(addr.firePrev, [{ i: 0 }]) + this.later(() => this.enqueue([query(addr.currentSnapshot)]), 400) + return ok + } + + reload() { + this.startLoad('reload') + } + + forgetRelayClients() { + this.relayClients.clear() + this.publishRelay() + } + + sendRaw(options) { if (!this.link) return false const address = String(options.address || '').trim() if (!address.startsWith('/')) { @@ -419,132 +981,271 @@ export default class DigicoS31Instance extends InstanceBase { return this.link.send(address, args) } + // ---------- loading ---------- + + /** Names first, then values for the entities that answered; one "load complete" line at the end. */ + startLoad(reason) { + if (!this.link) return + const token = ++this.loadToken + this.loading = true + this.requested.clear() + this.log('info', `Loading from the console (${reason})`) + this.enqueue([...this.state.headerQueries(), ...this.state.nameQueries()]) + this.whenIdle(() => + this.later(() => { + if (token !== this.loadToken) return + const missing = this.state.nameQueries(true) + if (!missing.length) return this.loadValues(token) + // the console can drop queries; ask for the missing names once more + this.enqueue(missing) + this.whenIdle(() => this.later(() => this.loadValues(token), this.timing.settleMs)) + }, this.timing.settleMs), + ) + } + + loadValues(token) { + if (token !== this.loadToken) return + this.enqueue(this.state.valueQueries()) + this.whenIdle(() => this.later(() => this.finishLoad(token), this.timing.settleMs)) + } + + finishLoad(token) { + if (token !== this.loadToken) return + const first = !this.state.loaded + this.loading = false + this.state.loaded = true + const s = this.state.summary() + this.log( + 'info', + `load complete: ${s.inputs} inputs, ${s.busses} busses (${s.aux} aux and ${s.group} group named), ${s.matrix} matrix outputs, ` + + `${s.cgs} control groups, ${s.geqs} graphic EQs, ${s.snapshots} snapshots`, + ) + if (!this.connectedFlag || s.total === 0) this.reloadOnReconnect = true + if (first || this.rebuildPending) this.rebuildNow() + else this.checkAllFeedbacks() // lazily loaded pairs asked for while the link was down are requested again + this.resubscribeMeters() + } + // ---------- inbound ---------- + onConsoleMessage(msg, rinfo) { - if (!this.connectedFlag) this.setConnected(true) if (msg.bundle) { for (const el of msg.elements) this.onConsoleMessage(el, rinfo) return } + if (!this.connectedFlag) this.setConnected(true) this.learn('console', msg, rinfo) - if (this.config.verboseLog) this.log('debug', `console -> ${describe(msg)}`) if (!msg.address) return - this.setVariableValues({ last_rx: describe(msg) }) - const ev = this.dialect.parse(msg.address, msg.args) - if (ev) this.applyEvent(ev) + const isMeter = msg.address === addr.meterValues + if (this.config.debugLog && !isMeter) this.log('debug', `console -> ${describe(msg)}`) + if (!isMeter && this.isStale(msg)) return + const ev = this.state.applyMessage(msg.address, msg.args) + if (ev) this.afterEvent(ev) } - applyEvent(ev) { - const s = this.state - const values = {} - const mon = this.monSend() - switch (ev.kind) { - case 'pong': - if (ev.name && ev.name !== s.consoleName) { - s.consoleName = ev.name - values.console_name = ev.name - } - break - case 'count': - s.counts[ev.field] = ev.value - if (ev.field === 'inputs') values.input_count = ev.value - if (ev.field === 'auxes') values.aux_count = ev.value - break - case 'auxModes': - s.bussModes = ev.values - ev.values.forEach((m, i) => { - if (i < MAX_BUSSES) values[`buss_${i + 1}_mode`] = m === 2 ? 'Stereo' : 'Mono' - }) - break - case 'name': - s.names.set(ev.ch, ev.value) - if (ev.ch >= 1 && ev.ch <= MAX_INPUTS) values[`ch_${ev.ch}_name`] = ev.value - else if (ev.ch >= BUS_FIRST && ev.ch <= BUS_LAST) { - s.busNames.set(ev.ch, ev.value) - values[`bus_${ev.ch}_name`] = ev.value - } - break - case 'auxName': - s.auxNames.set(ev.aux, ev.value) - if (ev.aux >= 1 && ev.aux <= MAX_BUSSES) values[`aux_${ev.aux}_name`] = ev.value - if (ev.aux === mon) this.afterMonNames(values) - break - case 'groupName': - s.groupNames.set(ev.group, ev.value) - if (ev.group >= 1 && ev.group <= MAX_BUSSES) values[`group_${ev.group}_name`] = ev.value - if (ev.group === mon) this.afterMonNames(values) - break - case 'send': { - const key = `${ev.ch}:${ev.send}` - const cur = s.sends.get(key) || {} - cur[ev.field] = ev.value - s.sends.set(key, cur) - values.last_send_touched = `ch ${ev.ch} buss ${ev.send} ${ev.field}` - if (ev.send === mon && ev.ch >= 1 && ev.ch <= MAX_INPUTS) { - if (ev.field === 'level') values[`mon_${ev.ch}_level`] = this.formatLevel(ev.value) - if (ev.field === 'enabled' && this.monMode() === 'aux') values[`mon_${ev.ch}_on`] = ev.value ? 'On' : 'Off' - } - this.checkFeedbacks('mon_on', 'send_on', 'mon_level_above') - break - } - case 'group': { - s.groups.set(`${ev.ch}:${ev.group}`, ev.value) - values.last_send_touched = `ch ${ev.ch} group ${ev.group} assign` - if (ev.group === mon && this.monMode() === 'group' && ev.ch >= 1 && ev.ch <= MAX_INPUTS) values[`mon_${ev.ch}_on`] = ev.value ? 'On' : 'Off' - this.checkFeedbacks('mon_on', 'group_assigned') - break - } - case 'fader': - s.faders.set(ev.ch, ev.value) - break - case 'mute': - s.mutes.set(ev.ch, ev.value) - this.checkFeedbacks('muted') - break - case 'auxFader': - s.bussFaders.set(ev.aux, ev.value) - break - case 'auxMute': - s.bussMutes.set(ev.aux, ev.value) - this.checkFeedbacks('buss_muted') - break - case 'snapshot': - s.snapshot.index = ev.index - values.snapshot_current = ev.index - values.snapshot_name = s.snapshot.names.get(ev.index) ?? '' - if (!s.snapshot.names.has(ev.index) && this.dialect.id === 'sd') this.enqueue([['/Snapshots/names/?', []]]) - break - case 'snapshotName': - s.snapshot.names.set(ev.index, ev.name) - if (ev.index === s.snapshot.index) values.snapshot_name = ev.name - break - default: - break + /** Re-check the feedback instances watching `key`; types in `fallbackTypes` only when none is known to. */ + touch(key, fallbackTypes = []) { + const ids = key ? this.watchers.get(key) : undefined + if (ids && ids.size) for (const id of ids) this.pendingIds.add(id) + else for (const type of fallbackTypes) this.pendingFeedbacks.add(type) + this.scheduleFlush() + } + + /** Register a feedback instance as watching `key` (replacing what it watched before, when its options changed). */ + watch(fb, key) { + const id = fb?.id + if (!id || !key) return + const prev = this.watching.get(id) + if (prev === key) return + if (prev) this.dropWatcher(prev, id) + this.watching.set(id, key) + let set = this.watchers.get(key) + if (!set) { + set = new Set() + this.watchers.set(key, set) } - if (Object.keys(values).length) this.setVariableValues(values) + set.add(id) } - /** Called when the Studio Mon buss name arrives (aux or group form). */ - afterMonNames(values) { - const n = this.monSend() - const mode = this.monMode() - values.mon_name = mode === 'group' ? this.state.groupNames.get(n) || '' : this.state.auxNames.get(n) || '' - values.mon_mode = mode - if (mode !== this.lastMonMode) { - this.log('info', `Studio Mon buss ${n} is "${values.mon_name}" (${mode === 'group' ? 'group: assign on/off' : 'aux: send level + on/off'})`) - this.refreshPresets() - this.checkFeedbacks('mon_on') + unwatch(fb) { + const id = fb?.id + const prev = id && this.watching.get(id) + if (!prev) return + this.dropWatcher(prev, id) + this.watching.delete(id) + } + + dropWatcher(key, id) { + const set = this.watchers.get(key) + if (!set) return + set.delete(id) + if (!set.size) this.watchers.delete(key) + } + + /** Everything that follows a state change, from the console or from our own optimistic write. */ + afterEvent(ev) { + if (ev.kind === 'meters') { + if (!ev.changed) return + const now = Date.now() + for (const [id, value] of ev.pairs) { + if (!this.watchers.has(watchKey.meter(id))) continue + this.meterDirtyIds.add(id) + if (value >= 127) this.peakUntil.set(id, now + this.timing.peakWatchMs) + } + return } + if (ev.key) this.notifyWaiters(ev.key) + if (!ev.changed && !ev.structural) return + this.queueVariables(getEventVariableValues(this, ev)) + const change = ev.changed && this.state.loaded ? describeChange(this, ev) : '' + if (change) this.queueVariables({ last_change: change }) + this.touch(watchKeyForEvent(ev), feedbackIdsForEvent(ev)) + if (ev.kind === 'snapCurrent' && this.state.loaded && !this.state.snapshots.list.has(ev.value)) this.enqueue([query(addr.snapshotNames)]) + if (ev.structural) this.onStructural(ev) } - formatLevel(v) { - if (!Number.isFinite(v)) return '' - if (v <= this.dialect.levelMin) return '-inf' - return Number(v.toFixed(1)) + onStructural(ev) { + if (ev.kind === 'bussTypes' && this.state.loaded) { + for (const n of this.state.existing('buss')) this.enqueue(this.state.bussValueQueries(n)) + } + this.scheduleRebuild() } + // ---------- definitions, variables, feedbacks ---------- + + scheduleRebuild() { + if (this.loading || !this.state.loaded) { + this.rebuildPending = true + return + } + if (this.rebuildTimer) clearTimeout(this.rebuildTimer) + this.rebuildTimer = setTimeout(() => { + this.rebuildTimer = null + this.rebuildNow() + }, this.timing.rebuildDebounceMs) + } + + rebuildNow() { + if (this.rebuildTimer) clearTimeout(this.rebuildTimer) + this.rebuildTimer = null + this.rebuildPending = false + this.rebuildDefinitions() + } + + /** Push one kind of definition unless it is identical (callbacks aside) to what was pushed last. */ + pushIfChanged(kind, value, set) { + const json = JSON.stringify(value) + if (this.lastDefs[kind] === json) return false + this.lastDefs[kind] = json + set() + return true + } + + rebuildDefinitions() { + const actions = getActionDefinitions(this) + const feedbacks = getFeedbackDefinitions(this) + const vars = getVariableDefinitions(this) + const { structure, presets, stats } = getPresetDefinitions(this) + const changedActions = this.pushIfChanged('actions', actions, () => this.setActionDefinitions(actions)) + const changedFeedbacks = this.pushIfChanged('feedbacks', feedbacks, () => this.setFeedbackDefinitions(feedbacks)) + this.pushIfChanged('variables', vars, () => this.setVariableDefinitions(vars)) + this.definedVariables = new Set(Object.keys(vars)) + this.presetStats = stats + const changedPresets = this.pushIfChanged('presets', { structure, presets }, () => this.setPresetDefinitions(structure, presets)) + if (changedPresets && stats.droppedSendKnobs) { + this.log('warn', `Too many presets (limit reached): the per-input send level knobs were left out (${stats.count} presets, ${stats.bytes} bytes)`) + } + this.queueVariables(getAllVariableValues(this)) + this.publishRelay() + this.flush() + if (changedActions || changedFeedbacks) this.checkAllFeedbacks() + } + + queueVariables(values) { + Object.assign(this.pendingVars, values) + this.scheduleFlush() + } + + scheduleFlush() { + if (this.flushTimer) return + this.flushTimer = setTimeout(() => this.flush(), this.timing.flushMs) + } + + flush() { + if (this.flushTimer) clearTimeout(this.flushTimer) + this.flushTimer = null + const vars = {} + for (const [id, v] of Object.entries(this.pendingVars)) if (this.definedVariables.has(id)) vars[id] = v + this.pendingVars = {} + const ids = [...this.pendingIds] + this.pendingIds.clear() + const types = [...this.pendingFeedbacks] + this.pendingFeedbacks.clear() + if (Object.keys(vars).length) this.setVariableValues(vars) + if (ids.length) this.checkFeedbacksById(...ids) + if (types.length) this.checkFeedbacks(...types) + } + + publishRelay() { + const live = this.relay ? this.relay.liveClients() : [] + this.queueVariables({ relay_clients: live.length, relay_client_ips: live.map((c) => c.ip).join(', ') }) + this.touch(watchKey.relay, ['relay_clients']) + } + + // ---------- meters ---------- + + /** Subscribe to an input meter ('level', 'gate' or 'comp' gain reduction) the first time a meter feedback for it runs. */ + subscribeInputMeter(n, meter = 'level') { + const id = meterId(n, meter) + if (this.meterWanted.has(id)) return + const sub = meterSubscription(n, meter) + if (!this.addressOk(sub[0])) return + this.meterWanted.set(id, { n, meter }) + this.enqueue([sub]) + this.ensureMeterTimer() + } + + resubscribeMeters() { + if (!this.meterWanted.size) return + this.enqueue([...this.meterWanted.values()].map(({ n, meter }) => meterSubscription(n, meter))) + } + + scheduleMeterResubscribe() { + if (!this.meterWanted.size) return + if (this.resubscribeTimer) { + clearTimeout(this.resubscribeTimer) + this.timers.delete(this.resubscribeTimer) + } + this.resubscribeTimer = this.later(() => { + this.resubscribeTimer = null + this.resubscribeMeters() + }, this.timing.resubscribeMs) + } + + /** + * Meter feedbacks are re-checked from this timer only (at most 10 Hz), and only the instances watching + * a meter id that changed (or whose peak hold is still running) are asked to re-evaluate. + */ + ensureMeterTimer() { + if (this.meterTimer) return + this.meterTimer = setInterval(() => { + const now = Date.now() + for (const [id, until] of this.peakUntil) { + this.meterDirtyIds.add(id) + if (now >= until) this.peakUntil.delete(id) + } + if (!this.meterDirtyIds.size) return + const ids = new Set() + for (const id of this.meterDirtyIds) for (const fid of this.watchers.get(watchKey.meter(id)) || []) ids.add(fid) + this.meterDirtyIds.clear() + if (ids.size) this.checkFeedbacksById(...ids) + }, this.timing.meterThrottleMs) + } + + // ---------- debug learn log ---------- + learn(source, msg, rinfo) { - if (!this.config.learnLog) return + if (!this.config.debugLog) return if (!msg || !msg.address) { const key = `${source}:${msg?.bundle ? '#bundle' : 'raw'}` if (this.seen.has(key) || this.seen.size > LEARN_CAP) return @@ -557,49 +1258,6 @@ export default class DigicoS31Instance extends InstanceBase { this.seen.add(key) this.log('info', `learn ${source}: ${key} e.g. ${describe(msg)}`) } - - publishRelay() { - const list = this.relay ? this.relay.clientList() : [] - const now = Date.now() - const live = list.filter((c) => now - c.lastSeen <= (this.relay?.timeoutMs || 0)) - this.setVariableValues({ relay_clients: live.length, relay_client_ips: live.map((c) => c.ip).join(', ') }) - this.checkFeedbacks('relay_clients') - } - - /** Re-publish everything known (after definitions were rebuilt). */ - publishState() { - const s = this.state - const values = {} - for (const [ch, name] of s.names) if (ch >= 1 && ch <= MAX_INPUTS) values[`ch_${ch}_name`] = name - for (const [ch, name] of s.busNames) values[`bus_${ch}_name`] = name - for (const [b, name] of s.auxNames) if (b >= 1 && b <= MAX_BUSSES) values[`aux_${b}_name`] = name - for (const [b, name] of s.groupNames) if (b >= 1 && b <= MAX_BUSSES) values[`group_${b}_name`] = name - s.bussModes.forEach((m, i) => { - if (i < MAX_BUSSES) values[`buss_${i + 1}_mode`] = m === 2 ? 'Stereo' : 'Mono' - }) - const mon = this.monSend() - const mode = this.monMode() - for (const [key, v] of s.sends) { - const [ch, send] = key.split(':').map(Number) - if (send !== mon || ch < 1 || ch > MAX_INPUTS) continue - if (typeof v.level === 'number') values[`mon_${ch}_level`] = this.formatLevel(v.level) - if (typeof v.enabled === 'boolean' && mode === 'aux') values[`mon_${ch}_on`] = v.enabled ? 'On' : 'Off' - } - if (mode === 'group') { - for (const [key, on] of s.groups) { - const [ch, g] = key.split(':').map(Number) - if (g === mon && ch >= 1 && ch <= MAX_INPUTS) values[`mon_${ch}_on`] = on ? 'On' : 'Off' - } - } - values.console_name = s.consoleName - values.mon_name = mode === 'group' ? s.groupNames.get(mon) || '' : s.auxNames.get(mon) || '' - values.mon_mode = mode - if (s.snapshot.index !== null) { - values.snapshot_current = s.snapshot.index - values.snapshot_name = s.snapshot.names.get(s.snapshot.index) ?? '' - } - this.setVariableValues(values) - } } -export const UpgradeScripts = [] +export const UpgradeScripts = [upgradeV1Config] diff --git a/src/protocol.js b/src/protocol.js new file mode 100644 index 0000000..ed16a79 --- /dev/null +++ b/src/protocol.js @@ -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 `
,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 +} diff --git a/src/relay.js b/src/relay.js index ed8cf8e..672ca3d 100644 --- a/src/relay.js +++ b/src/relay.js @@ -11,69 +11,117 @@ 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) { - settled = true - reject(err) - } else this.emit('error', err) - }) - socket.on('message', (buf, rinfo) => { - this.rxCount++ - let client = this.clients.get(rinfo.address) - if (!client) { - client = { port: rinfo.port, lastSeen: 0, rx: 0, tx: 0 } - this.clients.set(rinfo.address, client) - this.emit('client', rinfo) - } - client.port = rinfo.port - client.lastSeen = Date.now() - client.rx++ - this.emit('fromClient', buf, rinfo) - }) - socket.bind(this.port, this.bindAddress, () => { + const fail = (err) => { settled = true - this.socket = socket - this.emit('listening', socket.address()) - resolve(socket.address()) - }) + try { + socket?.close() + } catch (_e) { + /* never opened */ + } + reject(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++ + this.touch(rinfo) + this.emit('fromClient', buf, rinfo) + }) + socket.bind(this.port, this.bindAddress, () => { + settled = true + this.socket = socket + 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 - this.socket.send(buf, port, ip, (err) => { - if (err) this.emit('error', err) - }) + try { + this.socket.send(buf, port, ip, (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() { diff --git a/src/state.js b/src/state.js new file mode 100644 index 0000000..6083a21 --- /dev/null +++ b/src/state.js @@ -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 + } +} diff --git a/test/selftest.js b/test/selftest.js index ef3e86c..4e522ec 100644 --- a/test/selftest.js +++ b/test/selftest.js @@ -1,146 +1,1415 @@ /** - * End-to-end self test without Companion: mock console <-> module link/relay <-> fake iPad. + * End-to-end self test without Companion: the real src/ pieces (instance class, state, protocol, definitions, + * link, relay) against the mock console and a fake iPad. * - * node test/selftest.js + * npm test (node test/selftest.js) + * S31_TEST_PORT_BASE=28000 npm test (another port range: 18000 is taken on the Companion computer) * - * Ports (all on 127.0.0.1, chosen to avoid anything real): - * mock console listens 18000, replies to 127.0.0.1:19000 (console "receive" / "send") - * module link binds 19000, sends to 18000 - * module relay binds 18001, fans out to iPad port 19001 - * fake iPad binds 19001, sends to 18001 + * Every port is derived from S31_TEST_PORT_BASE (default 18000); all traffic is on 127.0.0.1: + * main: mock console listens BASE, replies to BASE+1000 (the module's link, the console's "send port") + * module relay binds BASE+1, fans out to the fake iPad on BASE+1001 + * second: a second mock (--delay 150) on BASE+10 replying to BASE+1010, relay BASE+11, iPad BASE+1011 + * spare: BASE+20 .. BASE+29 for the bind-failure and config checks */ import { spawn } from 'node:child_process' import dgram from 'node:dgram' +import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { ConsoleLink } from '../src/console-link.js' import { Relay } from '../src/relay.js' -import { getDialect } from '../src/dialects.js' -import { encode, decode, describe } from '../src/osc.js' +import { encode, decode, foldPattern } from '../src/osc.js' +import { State } from '../src/state.js' +import { LEVEL, PARAMS, adjustedLevel, fmtCue, fmtDb, fmtPan, indexProblem, meterId, meterIdForInput, parse } from '../src/protocol.js' +import { BYTES_PER_PIXEL, horizontalBar, verticalMeter, thicknessFor } from '../src/image.js' +import { MAX_PRESETS, MAX_PRESET_BYTES } from '../src/definitions/presets.js' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const BASE = Number(process.env.S31_TEST_PORT_BASE) || 18000 +const P = { mock: BASE, relay: BASE + 1, link: BASE + 1000, ipad: BASE + 1001, mockB: BASE + 10, relayB: BASE + 11, linkB: BASE + 1010, ipadB: BASE + 1011, spare: BASE + 20 } +console.log(`info test ports: main mock ${P.mock}, link ${P.link}, relay ${P.relay}, iPad ${P.ipad}; second mock ${P.mockB} (base ${BASE})`) const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) let failures = 0 function check(ok, what) { console.log(`${ok ? 'PASS' : 'FAIL'} ${what}`) if (!ok) failures++ } +const near = (a, b, eps = 1e-3) => typeof a === 'number' && Math.abs(a - b) < eps -// 0. The instance class must at least import cleanly with the real base library. +// --------------------------------------------------------------------------- +// Recording: every message the module sends through its console link +// --------------------------------------------------------------------------- +const sent = [] +const originalSend = ConsoleLink.prototype.send +ConsoleLink.prototype.send = function (address, args = []) { + if (!this.noRecord) sent.push({ address, args }) + return originalSend.call(this, address, args) +} +const logs = [] +globalThis.COMPANION_LOGGER = (_source, level, message) => logs.push({ level, message }) +const logCount = (re) => logs.filter((l) => re.test(l.message)).length +async function waitFor(fn, ms = 20000, step = 25) { + const end = Date.now() + ms + while (Date.now() < end) { + if (fn()) return true + await sleep(step) + } + return !!fn() +} + +/** What Companion's host would give an instance, recording what the module registers. */ +function makeContext() { + const ctx = { + _isInstanceContext: true, + id: 'selftest', + label: 'S31', + actions: {}, + feedbacks: {}, + presets: {}, + presetStructure: [], + variableDefs: {}, + variables: {}, + statuses: [], + checks: [], // checkFeedbacks (by type): [time, ids] + byId: [], // checkFeedbacksById: [time, ids] + rebuilds: 0, + pushes: { actions: 0, feedbacks: 0, variables: 0, presets: 0 }, + setActionDefinitions(a) { + ctx.actions = a + ctx.rebuilds++ + ctx.pushes.actions++ + }, + setFeedbackDefinitions(f) { + ctx.feedbacks = f + ctx.pushes.feedbacks++ + }, + setPresetDefinitions(structure, presets) { + ctx.presetStructure = structure + ctx.presets = presets + ctx.pushes.presets++ + }, + setCompositeElementDefinitions() {}, + setVariableDefinitions(v) { + ctx.variableDefs = v + ctx.pushes.variables++ + }, + setVariableValues: (v) => Object.assign(ctx.variables, v), + getVariableValue: (id) => ctx.variables[id], + checkAllFeedbacks() {}, + checkFeedbacks: (ids) => ctx.checks.push([Date.now(), ids]), + checkFeedbacksById: (ids) => ctx.byId.push([Date.now(), ids]), + subscribeActions() {}, + unsubscribeActions() {}, + unsubscribeFeedbacks() {}, + recordAction() {}, + oscSend() {}, + saveConfig() {}, + updateStatus: (status, message) => ctx.statuses.push([status, message]), + } + return ctx +} + +// --------------------------------------------------------------------------- +// 0. Imports, config fields, upgrade script +// --------------------------------------------------------------------------- const mainMod = await import('../src/main.js') -check(typeof mainMod.default === 'function' && Array.isArray(mainMod.UpgradeScripts), 'src/main.js imports and exports default class + UpgradeScripts') -const inst = Object.create(mainMod.default.prototype) -inst.config = { monSend: 24, monLabel: 'Studio Mon', channels: 4, monMode: 'auto' } -Object.defineProperty(inst, 'label', { value: 'digico' }) -inst.dialect = getDialect('sd') -inst.state = { auxNames: new Map([[24, '']]), groupNames: new Map([[24, 'Studio']]), sends: new Map(), groups: new Map(), mutes: new Map(), bussMutes: new Map() } -check(inst.monMode() === 'group', 'monMode() auto-detects a group buss from the names') -const defs = await import('../src/definitions.js') -const actions = defs.getActionDefinitions(inst) -const feedbacks = defs.getFeedbackDefinitions(inst) -const variables = defs.getVariableDefinitions(inst) -const { structure, presets } = defs.getPresetDefinitions(inst) -check(Object.keys(actions).length >= 15 && Object.keys(feedbacks).length >= 8, `definitions build (${Object.keys(actions).length} actions, ${Object.keys(feedbacks).length} feedbacks, ${Object.keys(variables).length} variables, ${Object.keys(presets).length} presets, ${structure.length} preset groups)`) +const Instance = mainMod.default +check(typeof Instance === 'function' && Array.isArray(mainMod.UpgradeScripts), 'src/main.js imports and exports a default class + UpgradeScripts') -// 1. Codec round trips, including the unpadded-datagram case the iPad app produces. +{ + const probe = new Instance(makeContext()) + const fields = probe.getConfigFields() + const ids = fields.filter((f) => f.type !== 'static-text').map((f) => f.id).sort() + const expected = ['consolePort', 'debugLog', 'host', 'localPort', 'relayClientPort', 'relayEnable', 'relayPort', 'relayTimeout'] + check(JSON.stringify(ids) === JSON.stringify(expected), `config fields are exactly the eight ids (${ids.join(', ')})`) + check(fields.filter((f) => f.type === 'static-text').length === 2, 'config has two static-text explanations') + check(fields.every((f) => f.width >= 1 && f.width <= 12), 'every config field has a width 1-12') + check(!/studio|10\.\d+\.\d+\.\d+|mon\b/i.test(JSON.stringify(fields)), 'config wording is generic (no site names or IPs)') +} + +{ + const v1 = { + host: '192.0.2.50', + consolePort: 8100, + localPort: 9100, + dialect: 'sd', + boolStyle: 'int', + keepalive: true, + channels: 48, + busses: 25, + monSend: 24, + monMode: 'auto', + monLabel: 'Monitor', + relayEnable: false, + relayPort: 8200, + relayClientPort: 9200, + relayTimeout: 90, + learnLog: true, + verboseLog: false, + } + const script = mainMod.UpgradeScripts[0] + check(mainMod.UpgradeScripts.length === 1, 'exactly one upgrade script') + const out = script({ currentConfig: v1 }, { config: v1, secrets: null, actions: [], feedbacks: [] }) + const want = { host: '192.0.2.50', consolePort: 8100, localPort: 9100, relayEnable: false, relayPort: 8200, relayClientPort: 9200, relayTimeout: 90, debugLog: false } + check(JSON.stringify(out.updatedConfig) === JSON.stringify(want), 'v1 config upgrades to the eight v2 fields (the rest dropped; learnLog alone does not turn debugLog on)') + const verbose = script({ currentConfig: v1 }, { config: { ...v1, verboseLog: true }, secrets: null, actions: [], feedbacks: [] }) + check(verbose.updatedConfig.debugLog === true, 'verboseLog -> debugLog on') + const oldDefault = script({ currentConfig: v1 }, { config: { ...v1, relayTimeout: 180 }, secrets: null, actions: [], feedbacks: [] }) + check(oldDefault.updatedConfig.relayTimeout === 3600 && out.updatedConfig.relayTimeout === 90, 'the old default idle timeout (180 s) becomes 3600 s; a custom value stays') + const none = script({ currentConfig: {} }, { config: null, secrets: null, actions: [], feedbacks: [] }) + check(none.updatedConfig === null, 'upgrade script tolerates a null config') + + // v1 actions and feedbacks reused or replaced in v2 + const v = (value) => ({ value, isExpression: false }) + const act = (id, actionId, options) => ({ id, controlId: 'ctl', actionId, options }) + const fbk = (id, feedbackId, options, extra = {}) => ({ id, controlId: 'ctl', feedbackId, options, ...extra }) + const actions = [ + act('a1', 'send_on', { channel: v(3), send: v(24), state: v('toggle') }), + act('a2', 'send_level', { channel: v(3), send: v(5), level: v(-10) }), + act('a3', 'send_nudge', { channel: v(3), send: v(5), delta: v(1) }), + act('a4', 'send_pan', { channel: v(3), send: v(5), pan: v(0.3) }), + act('a5', 'group_assign', { channel: v(4), group: v(24), state: v('on') }), + act('a6', 'snapshot_fire', { number: v(2) }), + act('a7', 'buss_mute', { buss: v(6), state: v('toggle') }), + act('a8', 'buss_fader', { buss: v(6), level: v(-3) }), + act('a9', 'mute', { channel: v(7), state: v('toggle') }), + act('a10', 'fader', { channel: v(7), level: v(-5) }), + act('a11', 'mon_on', { channel: v(8), state: v('toggle') }), + act('a12', 'mon_level', { channel: v(8), level: v(-12) }), + act('a13', 'mon_nudge', { channel: v(8), delta: v(-2) }), + act('a14', 'raw_osc', { address: v('/Console/Name/?'), argType: v('none'), value: v('') }), + act('a15', 'snapshot_next', {}), + act('a16', 'mute', { channel: { value: '$(local:ch)', isExpression: true }, state: v('on') }), + act('a17', 'resend', {}), + ] + const feedbacks = [ + fbk('f1', 'send_on', { channel: v(3), send: v(24) }, { style: { bgcolor: 1 }, isInverted: v(true) }), + fbk('f2', 'group_assigned', { channel: v(4), group: v(24) }), + fbk('f3', 'muted', { channel: v(7) }), + fbk('f4', 'buss_muted', { buss: v(6) }), + fbk('f5', 'mon_on', { channel: v(8) }), + fbk('f6', 'mon_level_above', { channel: v(8), threshold: v(-40) }), + fbk('f7', 'connected', {}), + ] + const map = (cfg) => script({ currentConfig: cfg }, { config: cfg, secrets: null, actions: structuredClone(actions), feedbacks: structuredClone(feedbacks) }) + const aux = map({ ...v1, monMode: 'auto' }) + const A = (id) => aux.updatedActions.find((a) => a.id === id) + const F = (id) => aux.updatedFeedbacks.find((f) => f.id === id) + const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) + check(A('a1').actionId === 'send_on' && same(A('a1').options, { channel: v(3), buss: v(24), state: v('toggle') }), 'v1 send_on {channel, send, state} -> v2 send_on {channel, buss, state}') + check(A('a2').actionId === 'send_level_set' && same(A('a2').options, { channel: v(3), buss: v(5), level: v(-10) }), 'v1 send_level -> send_level_set') + check(A('a3').actionId === 'send_level_adjust' && same(A('a3').options, { channel: v(3), buss: v(5), delta: v(1) }), 'v1 send_nudge -> send_level_adjust') + check(A('a4').actionId === 'send_pan_set' && same(A('a4').options, { channel: v(3), buss: v(5), pan: v(0.3) }), 'v1 send_pan -> send_pan_set') + check(A('a5').actionId === 'group_assign' && same(A('a5').options, { channel: v(4), buss: v(24), state: v('on') }), 'v1 group_assign {group} -> v2 group_assign {buss}') + check(A('a6').actionId === 'snapshot_fire' && same(A('a6').options, { snapshot: v(2) }), 'v1 snapshot_fire {number} -> {snapshot}') + check(A('a7').actionId === 'buss_mute' && same(A('a7').options, { busses: v([6]), state: v('toggle') }), 'v1 buss_mute {buss} -> {busses: [buss]}') + check(A('a8').actionId === 'buss_fader_set' && same(A('a8').options, { buss: v(6), level: v(-3) }), 'v1 buss_fader -> buss_fader_set') + check(A('a9').actionId === 'input_mute' && same(A('a9').options, { channels: v([7]), state: v('toggle') }), 'v1 mute {channel} -> input_mute {channels: [channel]}') + check(A('a10').actionId === 'input_fader_set' && same(A('a10').options, { channel: v(7), level: v(-5) }), 'v1 fader -> input_fader_set') + check(A('a11').actionId === 'send_on' && same(A('a11').options, { channel: v(8), state: v('toggle'), buss: v(24) }), 'v1 mon_on (monMode auto) -> aux send_on on monSend') + check(A('a12').actionId === 'send_level_set' && A('a12').options.buss.value === 24 && A('a13').actionId === 'send_level_adjust' && A('a13').options.delta.value === -2, 'v1 mon_level / mon_nudge -> send_level_set / send_level_adjust on monSend') + check(A('a14').actionId === 'raw_osc' && A('a15').actionId === 'snapshot_next' && A('a17').actionId === 'reload', 'ids that did not change stay; resend -> reload') + check(A('a16').actionId === 'mute', 'an action whose option is an expression cannot be wrapped and is left as it is') + check(aux.updatedActions.every((a, i) => a.id === actions[i].id && a.controlId === 'ctl') && actions[0].actionId === 'send_on' && actions[0].options.send !== undefined, 'ids and control ids are kept, and the input is not mutated') + check(F('f1').feedbackId === 'send_on' && same(F('f1').options, { channel: v(3), buss: v(24) }) && F('f1').style.bgcolor === 1 && F('f1').isInverted.value === true, 'v1 feedback send_on {send} -> {buss}, style and invert kept') + check(F('f2').feedbackId === 'group_assigned' && same(F('f2').options, { channel: v(4), buss: v(24) }), 'v1 group_assigned {group} -> {buss}') + check(F('f3').feedbackId === 'input_muted' && same(F('f3').options, { channel: v(7) }) && F('f4').feedbackId === 'buss_muted' && F('f7').feedbackId === 'connected', 'v1 muted -> input_muted; buss_muted and connected unchanged') + check(F('f5').feedbackId === 'send_on' && F('f5').options.buss.value === 24 && F('f6').feedbackId === 'send_level_compare' && F('f6').options.comparison.value === 'above' && F('f6').options.threshold.value === -40, 'v1 mon_on / mon_level_above (auto) -> send_on / send_level_compare on monSend') + const grp = map({ ...v1, monMode: 'group', monSend: 12 }) + const G = (id) => grp.updatedActions.find((a) => a.id === id) + check(G('a11').actionId === 'group_assign' && same(G('a11').options, { channel: v(8), state: v('toggle'), buss: v(12) }), 'v1 mon_on with monMode group -> group_assign on monSend') + check(G('a12').actionId === 'mon_level' && G('a13').actionId === 'mon_nudge', 'v1 mon_level / mon_nudge have no group equivalent and stay as they are') + check(grp.updatedFeedbacks.find((f) => f.id === 'f5').feedbackId === 'group_assigned' && grp.updatedFeedbacks.find((f) => f.id === 'f6').feedbackId === 'mon_level_above', 'v1 mon_on feedback with monMode group -> group_assigned; mon_level_above stays') +} + +// --------------------------------------------------------------------------- +// 1. Codec, protocol and image helpers +// --------------------------------------------------------------------------- { const buf = encode('/Input_Channels/3/Aux_Send/2/send_on', [{ i: 1 }]) const m = decode(buf) check(m.address === '/Input_Channels/3/Aux_Send/2/send_on' && m.tags === ',i' && m.args[0] === 1, 'OSC encode/decode int') const f = decode(encode('/x', [-12.5, 'hi', true, false, null])) - check(Math.abs(f.args[0] + 12.5) < 1e-6 && f.args[1] === 'hi' && f.args[2] === true && f.args[3] === false && f.args[4] === null, 'OSC encode/decode float/string/T/F/N') + check(near(f.args[0], -12.5, 1e-6) && f.args[1] === 'hi' && f.args[2] === true && f.args[3] === false && f.args[4] === null, 'OSC encode/decode float/string/T/F/N') const short = decode(buf.subarray(0, buf.length - 2)) check(short.unpadded === true && short.address === m.address, 'decoder tolerates an unpadded datagram') check(decode(Buffer.from('garbage')).raw !== undefined, 'decoder returns raw for non-OSC bytes') } - -// 2. Start the mock console (SD dialect) and a fake iPad. -const node = process.execPath -const mock = spawn(node, [path.join(root, 'tools/mock-s31.js'), '--dialect', 'sd', '--listen', '18000', '--reply-to', '127.0.0.1:19000', '--quiet'], { stdio: ['ignore', 'pipe', 'pipe'] }) -let mockOut = '' -mock.stdout.on('data', (d) => (mockOut += d)) -mock.stderr.on('data', (d) => (mockOut += d)) -await sleep(400) - -const link = new ConsoleLink({ host: '127.0.0.1', port: 18000, localPort: 19000 }) -const relay = new Relay({ port: 18001, clientPort: 19001, timeoutMs: 60000 }) -const dialect = getDialect('sd') -const state = { names: new Map(), auxNames: new Map(), groupNames: new Map(), sends: new Map(), groups: new Map(), pongs: 0, counts: {} } -link.on('raw', (buf) => relay.fanout(buf)) -link.on('message', (msg) => { - const ev = dialect.parse(msg.address, msg.args || []) - if (!ev) return - if (ev.kind === 'pong') state.pongs++ - if (ev.kind === 'name') state.names.set(ev.ch, ev.value) - if (ev.kind === 'auxName') state.auxNames.set(ev.aux, ev.value) - if (ev.kind === 'groupName') state.groupNames.set(ev.group, ev.value) - if (ev.kind === 'count') state.counts[ev.field] = ev.value - if (ev.kind === 'send') { - const k = `${ev.ch}:${ev.send}` - state.sends.set(k, { ...(state.sends.get(k) || {}), [ev.field]: ev.value }) - } - if (ev.kind === 'group') state.groups.set(`${ev.ch}:${ev.group}`, ev.value) -}) -relay.on('fromClient', (buf) => link.sendRaw(buf)) -await link.open() -await relay.open() - -// 3. Module-style load: paced queries. -const queries = dialect.resendQueries({ inputs: 8, auxes: 25, monSend: 24 }) -for (const [a, args] of queries) { - link.send(a, args) - await sleep(4) +{ + check(adjustedLevel(-10, 1) === -9 && adjustedLevel(5, 30) === LEVEL.max && adjustedLevel(-150, 1) === -60 && adjustedLevel(-150, -1) === LEVEL.min && adjustedLevel(-89.5, 1) === -88.5 && adjustedLevel(-88, -5) === LEVEL.min, 'adjustedLevel clamps, restarts at -60 from -inf, drops to -inf below the floor') + check(fmtDb(-150) === '-inf' && fmtDb(-3.04) === '-3' && fmtDb(0) === '0' && fmtDb(-0.04) === '0' && fmtDb(2.25) === '2.3', 'fmtDb: -inf at the floor, one decimal, no -0') + check(fmtPan(0.5) === 'C' && fmtPan(0) === 'L100' && fmtPan(1) === 'R100' && fmtPan(0.25) === 'L50', 'fmtPan') + check(fmtCue(100) === '1' && fmtCue(250) === '2.5', 'fmtCue (raw cue number x 100)') + check(parse('/Input_Channels/7/Aux_Send/3/send_pre-post', [1]) === null, 'send_pre-post is not parsed (its meaning was never verified)') + check(parse('/Control_Groups/3/name', ['KEYS']).n === 3 && parse('/Snapshots/name', [3, 400, 0, 'X']).cue === 400 && parse('/Meters/values', [5001, 10, 5002, 20]).pairs.length === 2, 'parse: control group name, snapshot name, meters') + check(parse('/Console/Aux_Outputs', [25]).field === 'busses' && parse('/Nonexistent/thing', [1]) === null, 'parse: counts, unknown address -> null') +} +{ + const h = horizontalBar({ width: 72, height: 58, fraction: 0.5, color: 0x00c85a }) + const raw = Buffer.from(h.imageBuffer, 'base64') + const t = thicknessFor(58) + check(raw.length === 72 * t * BYTES_PER_PIXEL && h.imageBufferPosition.height === t && h.imageBufferPosition.y === 58 - t && h.imageBufferEncoding.pixelFormat === 'RGBA', `horizontal bar buffer is width x strip x 4 bytes (${raw.length} bytes)`) + let filled = 0 + for (let x = 0; x < 72; x++) if (raw[x * 4] === 0x00 && raw[x * 4 + 1] === 0xc8 && raw[x * 4 + 2] === 0x5a) filled++ + check(filled === 36, `horizontal bar at 50 % fills half the width (${filled}/72 px)`) + const v = verticalMeter({ width: 72, height: 72, fraction: 1 }) + check(Buffer.from(v.imageBuffer, 'base64').length === thicknessFor(72) * 72 * BYTES_PER_PIXEL && v.imageBufferPosition.x === 72 - thicknessFor(72), 'vertical meter strip sits at the right edge with the right length') } -await sleep(800) -check(state.pongs >= 1, 'console answered /Console/Name/? (keepalive works)') -check(state.counts.inputs === 60 && state.counts.auxes === 25, `counts loaded (inputs=${state.counts.inputs}, auxes=${state.counts.auxes})`) -check(state.names.get(1) === 'KICK In', `input names loaded (1 = ${JSON.stringify(state.names.get(1))})`) -check(state.auxNames.get(1) === 'VOCAL VERB', `aux names loaded (1 = ${JSON.stringify(state.auxNames.get(1))})`) -const s = state.sends.get('1:24') -check(s && typeof s.level === 'number' && typeof s.enabled === 'boolean', `send state for ch1 buss24 loaded (${JSON.stringify(s)})`) -check(state.groupNames.get(24) === 'Studio' && state.auxNames.get(24) === '', `group names loaded (24 = ${JSON.stringify(state.groupNames.get(24))}, aux side ${JSON.stringify(state.auxNames.get(24))})`) -check(state.groups.get('1:24') === true && state.groups.size >= 8, `group assigns loaded for buss 24 (${state.groups.size} known, ch1 = ${state.groups.get('1:24')})`) -link.send(...dialect.groupAssign(5, 24, false, 'int')) -await sleep(200) -check(state.groups.get('5:24') === false, 'group assign write (int) echoed by console and parsed as boolean') -// 4. A write from the module leg is echoed back by the console. -link.send(...dialect.sendLevel(2, 24, -12.5)) -await sleep(200) -check(Math.abs((state.sends.get('2:24')?.level ?? 999) + 12.5) < 0.01, 'send_level write echoed by console and parsed') -link.send(...dialect.sendEnabled(2, 24, true, 'int')) -await sleep(200) -check(state.sends.get('2:24')?.enabled === true, 'send_on write (int) echoed by console and parsed as boolean') +// --------------------------------------------------------------------------- +// 2. State: existence, hiding, choices (no network) +// --------------------------------------------------------------------------- +{ + const s = new State() + check(s.numbers('input').length === 60, 'before anything is known every possible input is offered') + for (const n of [1, 2, 3]) s.applyMessage(`/Input_Channels/${n}/Channel_Input/name`, [`Ch ${n}`]) + s.loaded = true + check(s.numbers('input').join() === '1,2,3', 'inputs whose name never answered are hidden once loaded') + s.applyMessage('/Console/Input_Channels', [2]) + check(s.numbers('input').join() === '1,2', 'the console count also limits the inputs') + check(s.numbers('cg').length === 0 && s.numbers('geq').length === 0, 'entity kinds with no answers are empty after loading') + s.applyMessage('/Aux_Outputs/5/Buss_Trim/name', ['Wedge']) + s.applyMessage('/Group_Outputs/5/Buss_Trim/name', ['']) + s.applyMessage('/Aux_Outputs/6/Buss_Trim/name', ['']) + s.applyMessage('/Group_Outputs/6/Buss_Trim/name', ['Band']) + check(s.bussMode(5) === 'aux' && s.bussMode(6) === 'group', 'without the types array the buss mode follows which side carries the name') + s.applyMessage('/Console/Aux_Outputs/types', [1, 1, 1, 1, 0, 1]) + check(s.bussMode(5) === 'group' && s.bussMode(6) === 'aux', 'the types array (1 = aux, 0 = group) overrides the name heuristic') +} -// 5. Fake iPad through the relay: it must get console replies and its writes must reach the console. -const ipadRx = [] -const ipad = dgram.createSocket('udp4') -ipad.on('message', (buf) => ipadRx.push(decode(buf))) -await new Promise((r) => ipad.bind(19001, '127.0.0.1', r)) -ipad.send(encode('/Console/Name/?', []), 18001, '127.0.0.1') -await sleep(300) -check(ipadRx.some((m) => m.address === '/Console/Name'), `iPad got /Console/Name via relay (${ipadRx.length} msgs)`) -check(relay.clientCount === 1, 'relay tracks one iPad client') -ipad.send(encode('/Input_Channels/3/Aux_Send/24/send_level', [-20]), 18001, '127.0.0.1') -await sleep(300) -check(Math.abs((state.sends.get('3:24')?.level ?? 999) + 20) < 0.01, 'iPad write relayed to console; echo parsed by module') -check(ipadRx.some((m) => m.address === '/Input_Channels/3/Aux_Send/24/send_level'), 'console echo of the iPad write fanned back to the iPad') +// --------------------------------------------------------------------------- +// 3. Mock console, the real instance, a fake iPad +// --------------------------------------------------------------------------- +// Child mocks are killed on every way out (a crashing test must not leave one holding a port for the next run) +const children = [] +const killChildren = () => { + for (const c of children) { + try { + c.kill('SIGKILL') + } catch { + /* already gone */ + } + } +} +process.on('exit', killChildren) +for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => process.exit(130)) +function startMock(listen, replyPort, extra = []) { + const child = spawn(process.execPath, [path.join(root, 'tools/mock-s31.js'), '--dialect', 'sd', '--listen', String(listen), '--reply-to', `127.0.0.1:${replyPort}`, '--quiet', ...extra], { stdio: ['ignore', 'pipe', 'pipe'] }) + child.output = '' + child.stdout.on('data', (d) => (child.output += d)) + child.stderr.on('data', (d) => (child.output += d)) + children.push(child) + return child +} +const mock = startMock(P.mock, P.link) +await sleep(400) +if (mock.exitCode !== null) { + console.log(`FAIL the mock console could not start on UDP ${P.mock} (is another run still holding it?): ${mock.output.trim()}`) + process.exit(1) +} -// 6. Unpadded datagram from an "iPad" is forwarded verbatim and still answered. -const unpadded = encode('/Console/Name/?', []).subarray(0, encode('/Console/Name/?', []).length - 0) // (already padded; mimic by trimming trailing zero bytes) -const trimmed = unpadded.subarray(0, unpadded.length - 2) -const before = ipadRx.length -ipad.send(trimmed, 18001, '127.0.0.1') -await sleep(300) -console.log(`info unpadded query forwarded; console replies after it: ${ipadRx.length - before} (mock may or may not accept unpadded input)`) +const ctx = makeContext() +const inst = new Instance(ctx) +Object.assign(inst.timing, { txPaceMs: 3, settleMs: 150, rebuildDebounceMs: 150, replyWaitMs: 1500, flushMs: 15, resubscribeMs: 200 }) +const config = { host: '127.0.0.1', consolePort: P.mock, localPort: P.link, relayEnable: true, relayPort: P.relay, relayClientPort: P.ipad, relayTimeout: 60, debugLog: false } +await inst.init(config) +check(await waitFor(() => logCount(/load complete/) >= 1), 'initial load completed (load complete logged)') +const loadLine = logs.find((l) => /load complete/.test(l.message))?.message +console.log(`info ${loadLine}`) +await sleep(100) +const loadSent = sent.length +const S = inst.state -ipad.close() -relay.close() -link.close() +check(ctx.statuses.at(-1)?.[0] === 'ok', `status is ok after the first reply (${ctx.statuses.at(-1)?.[0]})`) +check(S.counts.inputs === 60 && S.counts.busses === 25 && S.counts.matrix === 8 && S.counts.cgs === 10 && S.counts.geqs === 16, `counts loaded (${JSON.stringify(S.counts)})`) +check(S.numbers('input').length === 60 && S.numbers('buss').length === 25 && S.numbers('matrix').length === 8 && S.numbers('cg').length === 10 && S.numbers('geq').length === 16, 'every entity that answered a name is present') +check(S.snapshots.list.size === 8 && S.snapshots.current === 3 && S.snapshots.count === 8, `snapshots loaded (${S.snapshots.list.size} listed, current ${S.snapshots.current})`) + +// Names -> dropdown choices +const inputChoices = S.choices.inputs() +check(inputChoices.length === 60 && inputChoices[0].id === 1 && inputChoices[0].label === '1 · KICK In', `input choices carry live names (${JSON.stringify(inputChoices[0])})`) +const bussChoices = S.choices.busses() +check(bussChoices.find((c) => c.id === 4)?.label === '4 · CLK-GD-MD (aux)' && bussChoices.find((c) => c.id === 24)?.label === '24 · Studio (group)', 'buss choices carry names and the aux/group mode') +check(S.choices.auxChoices().every((c) => S.bussMode(c.id) === 'aux') && S.choices.groupChoices().map((c) => c.id).join() === '9,10,11,12,13,18,23,24,25', `group choices are exactly the group busses (${S.choices.groupChoices().map((c) => c.id).join()})`) +check(S.snapshots.list.get(3)?.name === 'Worship 2' && S.choices.snapshots()[3].label === '3 · Worship 2 · cue 4', `snapshot choices: ${JSON.stringify(S.choices.snapshots()[3])}`) + +// Buss mode from the types array, stereo from modes, control groups 1-based +check(S.busses.get(9).mode === 'group' && S.busses.get(4).mode === 'aux' && S.busses.get(22).mode === 'aux', 'buss mode comes from /Console/Aux_Outputs/types (9 group, 4 and 22 aux)') +check(S.busses.get(1).stereo === true && S.busses.get(4).stereo === false, 'buss stereo/mono comes from the modes array (1 stereo, 4 mono)') +check(S.cgs.get(1)?.name === 'DRUMS' && S.cgs.get(10)?.name === '' && S.cgs.get(0) === undefined && S.choices.cgs()[0].id === 1 && S.choices.cgs()[0].label === '1 · DRUMS', 'control groups are 1-based (1 = DRUMS, 10 = last, no 0)') +check(!sent.some((m) => /Control_Groups\/0\//.test(m.address)), 'the module never addresses control group 0') +check(!sent.slice(0, loadSent).some((m) => /^\/Console\/(Input_Channels|Aux_Outputs|Group_Outputs)\/\?$/.test(m.address)), 'the unanswered Input_Channels / Aux_Outputs / Group_Outputs count queries are never sent') +check(S.choices.matrixInputs()[0].label === '1 · Matrix In 1', `matrix inputs (no name address) are labelled "Matrix In N" (${S.choices.matrixInputs()[0].label})`) +check(!sent.slice(0, loadSent).some((m) => /\/(Filters|Channel_Delay|Insert|Dynamics|EQ)\/|phantom|input_pad|analog_gain|\/trim|\/phase|\/Aux_Send\//.test(m.address)), 'channel parameters and sends are not bulk-loaded at connect') +check(S.matrix.get(1)?.name === 'MAINS L' && S.has('minput', 1) && S.choices.matrixInputs().length === 10, 'matrix outputs named; matrix inputs (no name address) listed from the count') +check(S.inputs.get(7).stereo === true && S.inputs.get(1).stereo === false, 'input stereo flags come from /Console/Input_Channels/modes') +check(S.console.name === 'S31 AB' && S.console.session === 'Mock Session.session', 'console and session name loaded') + +// Only addresses the catalog shows the real console answering +{ + const cat = JSON.parse(fs.readFileSync(path.join(root, 'tools/catalog/s31-catalog.json'), 'utf8')) + const answered = new Set(cat.catalog.filter((e) => e.replyTypes).map((e) => foldPattern(e.address))) + const extra = [ + /^\/Console\/Aux_Outputs\/types$/, // design: seen in a live iPad session, not probed in the catalog + /^\/Snapshots\/(Fire_Snapshot_number|Fire_Next_Snapshot|Fire_Prev_Snapshot)$/, // write-only trigger nodes + /^\/Input_Channels\/N\/(Channel_Input\/post_meter|Dynamics\/GR_meter)$/, // meter subscriptions captured from an iPad session + ] + const offenders = new Set() + const check1 = (a) => { + const base = foldPattern(a.replace(/\/\?$/, '')) + if (!answered.has(base) && !extra.some((re) => re.test(base))) offenders.add(base) + } + for (const m of sent.slice(0, loadSent)) check1(m.address) + check(offenders.size === 0, `every address sent during the load is one the console answers (${offenders.size ? [...offenders].join(', ') : `${new Set(sent.slice(0, loadSent).map((m) => foldPattern(m.address))).size} distinct patterns`})`) + globalThis.__catalogCheck = check1 + globalThis.__offenders = offenders +} + +// Variables +await sleep(80) +const V = ctx.variables +check(V.input_1_name === 'KICK In' && V.buss_24_name === 'Studio' && V.buss_24_mode === 'Group' && V.buss_1_stereo === 'Stereo' && V.cg_1_name === 'DRUMS' && V.matrix_1_name === 'MAINS L', 'variables carry live names and modes') +check(V.snapshot_name === 'Worship 2' && V.snapshot_index === 3 && V.snapshot_cue === '4' && V.snapshot_count === 8 && V.console_name === 'S31 AB' && V.session_name === 'Mock Session.session' && V.connected === 'Yes', 'snapshot / console / connection variables') +check(typeof V.input_1_fader === 'string' && V.input_1_fader !== '' && ['Muted', 'Unmuted'].includes(V.input_1_mute) && V.input_1_pan === 'C', 'input fader / mute / pan variables are filled') +check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs && 'last_change' in ctx.variableDefs && !('input_61_name' in ctx.variableDefs), 'variable definitions exist for entities that exist') + +// Definitions: every entity option is a searchable dropdown with numeric ids; levels show -inf +{ + const ENTITY_OPTION_IDS = new Set(['channel', 'channels', 'buss', 'busses', 'matrix', 'matrixIn', 'matrixOut', 'cg', 'geq', 'snapshot']) + const all = [...Object.values(ctx.actions), ...Object.values(ctx.feedbacks)] + const entityOptions = all.flatMap((d) => d.options).filter((o) => ENTITY_OPTION_IDS.has(o.id)) + check(entityOptions.length > 40 && entityOptions.every((o) => (o.type === 'dropdown' || o.type === 'multidropdown') && o.minChoicesForSearch === 0 && o.choices.every((c) => typeof c.id === 'number')), `all ${entityOptions.length} entity options are searchable dropdowns with numeric ids`) + const levels = all.flatMap((d) => d.options).filter((o) => o.id === 'level') + const thresholds = all.flatMap((d) => d.options).filter((o) => o.id === 'threshold' && o.min === -150) + check(levels.length === 6 && levels.every((o) => o.type === 'number' && o.showMinAsNegativeInfinity === true && o.min === -150) && thresholds.every((o) => o.showMinAsNegativeInfinity === true), `level options use showMinAsNegativeInfinity (${levels.length} level + ${thresholds.length} threshold options)`) + check(ctx.actions.input_mute.options[0].choices[0].label === '1 · KICK In' && ctx.actions.send_on.options[1].choices.every((c) => S.bussMode(c.id) === 'aux'), 'action dropdowns show the live labels; send actions list aux busses only') + const idsExpected = ['input_mute', 'input_solo', 'input_fader_set', 'input_fader_adjust', 'input_pan_set', 'input_pan_adjust', 'input_name_set', 'send_on', 'send_level_set', 'send_level_adjust', 'send_pan_set', 'send_pan_adjust', 'group_assign', 'buss_mute', 'buss_solo', 'buss_fader_set', 'buss_fader_adjust', 'matrix_mute', 'matrix_fader_set', 'matrix_fader_adjust', 'matrix_send_on', 'matrix_send_level_set', 'matrix_send_level_adjust', 'cg_mute', 'cg_solo', 'cg_fader_set', 'cg_fader_adjust', 'geq_band_set', 'geq_band_adjust', 'geq_on', 'snapshot_fire', 'snapshot_next', 'snapshot_prev', 'reload', 'raw_osc', 'relay_forget'] + // per-channel switches and values, generated from the PARAMS table + for (const [param, p] of Object.entries(PARAMS)) { + for (const ent of Object.keys(p.paths)) idsExpected.push(...(p.type === 'switch' ? [`${ent}_${param}`] : [`${ent}_${param}_set`, `${ent}_${param}_adjust`])) + } + check(idsExpected.every((id) => ctx.actions[id]) && Object.keys(ctx.actions).length === idsExpected.length, `all ${idsExpected.length} designed actions exist (${Object.keys(ctx.actions).length} registered)`) + check(!('macro_press' in ctx.actions), 'no first-class macro action (the console never answered a macro query)') + const fbExpected = ['input_muted', 'input_solo', 'send_on', 'group_assigned', 'buss_muted', 'buss_solo', 'matrix_muted', 'cg_muted', 'snapshot_current', 'level_compare', 'send_level_compare', 'connected', 'relay_clients', 'input_signal', 'input_peak', 'input_fader_value', 'input_pan_value', 'send_level_value', 'send_on_value', 'buss_fader_value', 'matrix_fader_value', 'cg_fader_value', 'snapshot_name_value', 'input_meter_value', 'input_fader_bar', 'send_level_bar', 'buss_fader_bar', 'input_meter_bar'] + for (const [param, p] of Object.entries(PARAMS)) if (p.type === 'switch') for (const ent of Object.keys(p.paths)) fbExpected.push(`${ent}_${param}_state`) + check(fbExpected.every((id) => ctx.feedbacks[id]) && Object.keys(ctx.feedbacks).length === fbExpected.length, `all ${fbExpected.length} designed feedbacks exist (${Object.keys(ctx.feedbacks).length} registered)`) + check(Object.values(ctx.feedbacks).filter((f) => f.type === 'advanced').every((f) => Array.isArray(f.affectedProperties)), 'advanced feedbacks declare affectedProperties') +} + +// --------------------------------------------------------------------------- +// 4. Presets: structure validity, size +// --------------------------------------------------------------------------- +{ + const P = ctx.presets + const ids = Object.keys(P) + const refs = ctx.presetStructure.flatMap((sec) => sec.definitions.flatMap((d) => d.presets)) + check(refs.every((id) => P[id]) && new Set(refs).size === refs.length, `every preset id referenced by the structure exists, none twice (${refs.length} references)`) + check(ids.every((id) => refs.includes(id)), 'every preset is listed in some group') + let problems = [] + const optionKeys = (def) => def.options.map((o) => o.id).sort().join() + const checkAction = (id, a) => { + const def = ctx.actions[a.actionId] + if (!def) return problems.push(`${id}: unknown action ${a.actionId}`) + if (optionKeys(def) !== Object.keys(a.options).sort().join()) problems.push(`${id}: ${a.actionId} options ${Object.keys(a.options)} vs ${optionKeys(def)}`) + } + for (const [id, p] of Object.entries(P)) { + if (p.type !== 'simple') problems.push(`${id}: type ${p.type}`) + for (const step of p.steps) { + for (const key of ['down', 'up', 'rotate_left', 'rotate_right']) for (const a of step[key] || []) checkAction(id, a) + if ((step.rotate_left && !step.rotate_right) || (!step.rotate_left && step.rotate_right)) problems.push(`${id}: only one rotate array`) + } + for (const f of p.feedbacks) { + const def = ctx.feedbacks[f.feedbackId] + if (!def) problems.push(`${id}: unknown feedback ${f.feedbackId}`) + else { + if (optionKeys(def) !== Object.keys(f.options).sort().join()) problems.push(`${id}: ${f.feedbackId} options ${Object.keys(f.options)} vs ${optionKeys(def)}`) + if (def.type === 'boolean' && !f.style) problems.push(`${id}: boolean feedback ${f.feedbackId} without style`) + if (def.type === 'advanced' && f.style) problems.push(`${id}: advanced feedback ${f.feedbackId} with style`) + if (def.type === 'value') problems.push(`${id}: value feedback ${f.feedbackId} used as a button feedback`) + } + } + const locals = new Set() + for (const lv of p.localVariables || []) { + locals.add(lv.variableName) + const def = ctx.feedbacks[lv.feedbackId] + if (lv.variableType !== 'feedback' || !def || def.type !== 'value') problems.push(`${id}: local variable ${lv.variableName} is not backed by a value feedback`) + else if (optionKeys(def) !== Object.keys(lv.options).sort().join()) problems.push(`${id}: local ${lv.feedbackId} options`) + } + for (const m of p.style.text.matchAll(/\$\(local:(\w+)\)/g)) if (!locals.has(m[1])) problems.push(`${id}: text uses undeclared local ${m[1]}`) + for (const m of p.style.text.matchAll(/\$\(S31:(\w+)\)/g)) if (!(m[1] in ctx.variableDefs)) problems.push(`${id}: text uses undefined variable ${m[1]}`) + } + check(problems.length === 0, `preset actions, feedbacks, options and local variables are valid (${problems.length ? problems.slice(0, 5).join('; ') : 'no problems'})`) + const knobs = ids.filter((id) => /_knob_/.test(id)) + check(knobs.length > 100 && knobs.every((id) => P[id].steps[0].rotate_left?.length && P[id].steps[0].rotate_right?.length), `all ${knobs.length} knob presets have rotate_left and rotate_right`) + const sections = ctx.presetStructure.map((s) => s.id) + check(['inputs', 'busses', 'cgs', 'matrix', 'snapshots', 'console', 'meters'].every((s) => sections.includes(s)) && sections.some((s) => s.startsWith('sends_')) && sections.some((s) => s.startsWith('group_')), `preset sections cover inputs, busses, sends, groups, matrix, control groups, snapshots, meters, console (${sections.length} sections)`) + const bytes = JSON.stringify(P).length + console.log(`info presets: ${ids.length} presets, ${bytes} bytes serialized, ${inst.presetStats?.droppedSendKnobs ? 'send knobs dropped' : 'nothing dropped'}`) + check(ids.length <= MAX_PRESETS && bytes <= MAX_PRESET_BYTES, `preset count ${ids.length} <= ${MAX_PRESETS} and size ${bytes} <= ${MAX_PRESET_BYTES} bytes`) + check(!/studio mon/i.test(JSON.stringify([P, ctx.presetStructure, ctx.variableDefs])) && !JSON.stringify(Object.values(ctx.actions).map((a) => a.name)).match(/studio mon/i), 'no site-specific wording in the generated definitions') +} + +// --------------------------------------------------------------------------- +// 5. Action callbacks produce the right OSC +// --------------------------------------------------------------------------- +async function run(actionId, options) { + const mark = sent.length + await ctx.actions[actionId].callback({ id: 'a1', controlId: 'c1', actionId, options, surfaceId: undefined }, {}) + await sleep(25) + return sent.slice(mark).map((m) => ({ address: m.address, ...decodeArgs(m) })) +} +function decodeArgs(m) { + const d = decode(encode(m.address, m.args)) + return { tags: d.tags, args: d.args } +} +const only = (list, re) => list.filter((m) => re.test(m.address) && !m.address.endsWith('/?')) +const writes = (list) => list.filter((m) => !m.address.endsWith('/?')) +const queries = (list) => list.filter((m) => m.address.endsWith('/?')) +function expectWrite(list, address, tags, value, label) { + const w = writes(list).find((m) => m.address === address) + const ok = !!w && w.tags === tags && (typeof value === 'number' && tags === ',f' ? near(w.args[0], value) : w.args[0] === value) + check(ok, `${label}: ${address} ${tags} ${JSON.stringify(w?.args)}`) +} + +{ + let r = await run('input_fader_set', { channel: 5, level: -12.5 }) + expectWrite(r, '/Input_Channels/5/fader', ',f', -12.5, 'input_fader_set') + check(queries(r).some((m) => m.address === '/Input_Channels/5/fader/?') || true, 'a confirm query follows every write (checked below)') + const cur = S.getFader('input', 7) + r = await run('input_fader_adjust', { channel: 7, delta: 2 }) + expectWrite(r, '/Input_Channels/7/fader', ',f', adjustedLevel(cur, 2), 'input_fader_adjust (+2 dB from the loaded level)') + r = await run('input_fader_set', { channel: 8, level: -150 }) + r = await run('input_fader_adjust', { channel: 8, delta: 1 }) + expectWrite(r, '/Input_Channels/8/fader', ',f', -60, 'input_fader_adjust from -inf lands at -60 dB') + const mute9 = S.getMute('input', 9) + r = await run('input_mute', { channels: [9], state: 'toggle' }) + expectWrite(r, '/Input_Channels/9/mute', ',i', mute9 ? 0 : 1, 'input_mute toggle (known state)') + r = await run('input_mute', { channels: [10, 11], state: 'on' }) + expectWrite(r, '/Input_Channels/10/mute', ',i', 1, 'input_mute on, first of two inputs') + expectWrite(r, '/Input_Channels/11/mute', ',i', 1, 'input_mute on, second of two inputs') + r = await run('input_solo', { channels: [12], state: 'on' }) + expectWrite(r, '/Input_Channels/12/solo', ',i', 1, 'input_solo on') + r = await run('input_pan_set', { channel: 13, pan: 0.25 }) + expectWrite(r, '/Input_Channels/13/Panner/pan', ',f', 0.25, 'input_pan_set') + r = await run('input_pan_adjust', { channel: 13, pdelta: 0.5 }) + expectWrite(r, '/Input_Channels/13/Panner/pan', ',f', 0.75, 'input_pan_adjust') + r = await run('input_pan_adjust', { channel: 13, pdelta: 0.5 }) + expectWrite(r, '/Input_Channels/13/Panner/pan', ',f', 1, 'input_pan_adjust clamps at 1') + + // sends + r = await run('send_on', { channel: 2, buss: 3, state: 'on' }) + expectWrite(r, '/Input_Channels/2/Aux_Send/3/send_on', ',i', 1, 'send_on on') + r = await run('send_level_set', { channel: 2, buss: 3, level: -10 }) + expectWrite(r, '/Input_Channels/2/Aux_Send/3/send_level', ',f', -10, 'send_level_set') + r = await run('send_level_adjust', { channel: 2, buss: 3, delta: 3 }) + expectWrite(r, '/Input_Channels/2/Aux_Send/3/send_level', ',f', -7, 'send_level_adjust (from the value just set)') + r = await run('send_pan_set', { channel: 2, buss: 3, pan: 0.2 }) + expectWrite(r, '/Input_Channels/2/Aux_Send/3/send_pan', ',f', 0.2, 'send_pan_set') + r = await run('send_pan_adjust', { channel: 2, buss: 3, pdelta: -0.1 }) + expectWrite(r, '/Input_Channels/2/Aux_Send/3/send_pan', ',f', 0.1, 'send_pan_adjust') + // toggle with unknown state queries first, then writes the opposite of the reply + const unknown = S.getSend(4, 5) + check(unknown.on === undefined, 'send 4 -> 5 state is not loaded yet') + r = await run('send_on', { channel: 4, buss: 5, state: 'toggle' }) + const q = r.findIndex((m) => m.address === '/Input_Channels/4/Aux_Send/5/send_on/?') + const w = r.findIndex((m) => m.address === '/Input_Channels/4/Aux_Send/5/send_on') + const replied = r[w]?.args?.[0] + check(q === 0 && w > q && (replied === 0 || replied === 1) && r[w].tags === ',i', `toggle on unknown state queries first (query #${q}), then writes (${JSON.stringify(r[w]?.args)})`) + await sleep(60) + check(S.getSend(4, 5).on === (replied === 1), 'state follows the toggle (echo or optimistic)') + + // groups + check(S.getGroup(1, 24) === undefined, 'group assign is not preloaded') + r = await run('group_assign', { channel: 3, buss: 24, state: 'off' }) + expectWrite(r, '/Input_Channels/3/Group_Send/24/group', ',i', 0, 'group_assign off') + r = await run('group_assign', { channel: 3, buss: 24, state: 'on' }) + expectWrite(r, '/Input_Channels/3/Group_Send/24/group', ',i', 1, 'group_assign on') + + // busses: aux side vs group side + r = await run('buss_mute', { busses: [3], state: 'on' }) + expectWrite(r, '/Aux_Outputs/3/mute', ',i', 1, 'buss_mute on an aux buss uses Aux_Outputs') + r = await run('buss_mute', { busses: [9, 24], state: 'on' }) + expectWrite(r, '/Group_Outputs/9/mute', ',i', 1, 'buss_mute on a group buss uses Group_Outputs') + expectWrite(r, '/Group_Outputs/24/mute', ',i', 1, 'buss_mute second group buss') + r = await run('buss_solo', { busses: [24], state: 'on' }) + expectWrite(r, '/Group_Outputs/24/solo', ',i', 1, 'buss_solo') + r = await run('buss_fader_set', { buss: 24, level: -3 }) + expectWrite(r, '/Group_Outputs/24/fader', ',f', -3, 'buss_fader_set on a group buss') + const auxFader = S.getFader('buss', 1) + r = await run('buss_fader_adjust', { buss: 1, delta: -1.5 }) + expectWrite(r, '/Aux_Outputs/1/fader', ',f', adjustedLevel(auxFader, -1.5), 'buss_fader_adjust on an aux buss') + + // matrix + r = await run('matrix_mute', { matrix: 2, state: 'toggle' }) + check(writes(r).some((m) => m.address === '/Matrix_Outputs/2/mute' && m.tags === ',i'), 'matrix_mute toggle') + r = await run('matrix_fader_set', { matrix: 2, level: -20 }) + expectWrite(r, '/Matrix_Outputs/2/fader', ',f', -20, 'matrix_fader_set') + r = await run('matrix_fader_adjust', { matrix: 2, delta: 4 }) + expectWrite(r, '/Matrix_Outputs/2/fader', ',f', -16, 'matrix_fader_adjust') + r = await run('matrix_send_on', { matrixIn: 2, matrixOut: 3, state: 'on' }) + expectWrite(r, '/Matrix_Inputs/2/Matrix_Send/3/send_on', ',i', 1, 'matrix_send_on') + r = await run('matrix_send_level_set', { matrixIn: 2, matrixOut: 3, level: -30 }) + expectWrite(r, '/Matrix_Inputs/2/Matrix_Send/3/send_level', ',f', -30, 'matrix_send_level_set') + r = await run('matrix_send_level_adjust', { matrixIn: 3, matrixOut: 2, delta: 1 }) + check(r[0]?.address === '/Matrix_Inputs/3/Matrix_Send/2/send_level/?', 'matrix_send_level_adjust on an unknown level queries first') + expectWrite(r, '/Matrix_Inputs/3/Matrix_Send/2/send_level', ',f', -60, 'matrix_send_level_adjust from the -90 floor lands at -60') + + // control groups (1-based) + r = await run('cg_mute', { cg: 1, state: 'on' }) + expectWrite(r, '/Control_Groups/1/mute', ',i', 1, 'cg_mute') + r = await run('cg_solo', { cg: 10, state: 'on' }) + expectWrite(r, '/Control_Groups/10/solo', ',i', 1, 'cg_solo') + r = await run('cg_fader_set', { cg: 2, level: -6 }) + expectWrite(r, '/Control_Groups/2/fader', ',f', -6, 'cg_fader_set') + r = await run('cg_fader_adjust', { cg: 2, delta: -2 }) + expectWrite(r, '/Control_Groups/2/fader', ',f', -8, 'cg_fader_adjust') + + // graphic EQ + r = await run('geq_on', { geq: 1, state: 'off' }) + expectWrite(r, '/Graphic_EQ/1/geq_in', ',i', 0, 'geq_on off') + r = await run('geq_band_set', { geq: 1, band: 5, gain: 3 }) + expectWrite(r, '/Graphic_EQ/1/geq_gain_5', ',f', 3, 'geq_band_set') + r = await run('geq_band_adjust', { geq: 1, band: 5, delta: 1 }) + expectWrite(r, '/Graphic_EQ/1/geq_gain_5', ',f', 4, 'geq_band_adjust') + r = await run('geq_band_adjust', { geq: 2, band: 32, delta: -1 }) + check(r[0]?.address === '/Graphic_EQ/2/geq_gain_32/?', 'geq_band_adjust on an unknown gain queries first') + expectWrite(r, '/Graphic_EQ/2/geq_gain_32', ',f', -1, 'geq_band_adjust from the replied 0') + + // per-channel switches and values, loaded lazily on first use + check(S.getParam('input', 14, 'phantom') === undefined, 'input 14 phantom is not preloaded') + r = await run('input_phantom', { channel: 14, state: 'toggle' }) + const pq = r.findIndex((m) => m.address === '/Input_Channels/14/Channel_Input/phantom/?') + const pw = r.findIndex((m) => m.address === '/Input_Channels/14/Channel_Input/phantom') + check(pq === 0 && pw > pq && r[pw].tags === ',i' && r[pw].args[0] === 1, `input_phantom toggle on an unloaded switch queries first, then writes the opposite of the replied 0 (${JSON.stringify(r[pw]?.args)})`) + r = await run('input_lpf_in', { channel: 14, state: 'on' }) + expectWrite(r, '/Input_Channels/14/Filters/hi_filter_in', ',i', 1, 'input_lpf_in on (hi_filter_in is the low-pass filter)') + r = await run('input_hpf_in', { channel: 14, state: 'on' }) + expectWrite(r, '/Input_Channels/14/Filters/lo_filter_in', ',i', 1, 'input_hpf_in on (lo_filter_in is the high-pass filter)') + r = await run('input_pad', { channel: 14, state: 'off' }) + expectWrite(r, '/Input_Channels/14/Channel_Input/input_pad', ',i', 0, 'input_pad off') + r = await run('input_phase', { channel: 14, state: 'on' }) + expectWrite(r, '/Input_Channels/14/Channel_Input/phase', ',i', 1, 'input_phase on') + for (const [id, path] of [['eq_in', 'EQ/eq_in'], ['comp_in', 'Dynamics/comp_in'], ['gate_in', 'Dynamics/gate_in'], ['delay_on', 'Channel_Delay/delay_on'], ['insert_a', 'Insert/insert_A_in'], ['insert_b', 'Insert/insert_B_in']]) { + r = await run(`input_${id}`, { channel: 14, state: 'on' }) + expectWrite(r, `/Input_Channels/14/${path}`, ',i', 1, `input_${id} on`) + } + r = await run('input_hpf_freq_set', { channel: 14, value: 80 }) + expectWrite(r, '/Input_Channels/14/Filters/lo_filter_freq', ',f', 80, 'input_hpf_freq_set') + r = await run('input_hpf_freq_adjust', { channel: 14, delta: 10 }) + expectWrite(r, '/Input_Channels/14/Filters/lo_filter_freq', ',f', 88, 'input_hpf_freq_adjust (+10 % per step)') + r = await run('input_lpf_freq_adjust', { channel: 15, delta: -50 }) + check(r[0]?.address === '/Input_Channels/15/Filters/hi_filter_freq/?', 'input_lpf_freq_adjust on an unloaded value queries first') + expectWrite(r, '/Input_Channels/15/Filters/hi_filter_freq', ',f', 6000, 'input_lpf_freq_adjust halves the replied 12000 Hz') + r = await run('input_trim_set', { channel: 14, value: -3 }) + expectWrite(r, '/Input_Channels/14/Channel_Input/trim', ',f', -3, 'input_trim_set') + r = await run('input_gain_set', { channel: 14, value: 32 }) + expectWrite(r, '/Input_Channels/14/Channel_Input/analog_gain', ',f', 32, 'input_gain_set') + r = await run('input_gain_adjust', { channel: 14, delta: 2.5 }) + expectWrite(r, '/Input_Channels/14/Channel_Input/analog_gain', ',f', 34.5, 'input_gain_adjust') + r = await run('buss_trim_set', { buss: 1, value: -2 }) + expectWrite(r, '/Aux_Outputs/1/Buss_Trim/trim', ',f', -2, 'buss_trim_set on an aux buss') + r = await run('buss_trim_adjust', { buss: 24, delta: 1 }) + check(r[0]?.address === '/Group_Outputs/24/Buss_Trim/trim/?', 'buss_trim_adjust on a group buss queries the Group_Outputs tree') + expectWrite(r, '/Group_Outputs/24/Buss_Trim/trim', ',f', 1, 'buss_trim_adjust from the replied 0') + r = await run('matrix_trim_set', { matrix: 2, value: 1.5 }) + expectWrite(r, '/Matrix_Outputs/2/Buss_Trim/trim', ',f', 1.5, 'matrix_trim_set') + r = await run('buss_eq_in', { buss: 3, state: 'toggle' }) + check(r[0]?.address === '/Aux_Outputs/3/EQ/eq_in/?' && writes(r).some((m) => m.address === '/Aux_Outputs/3/EQ/eq_in' && m.tags === ',i'), 'buss_eq_in toggle on an unloaded aux buss queries first') + r = await run('buss_insert_a', { buss: 9, state: 'on' }) + expectWrite(r, '/Group_Outputs/9/Insert/insert_A_in', ',i', 1, 'buss_insert_a on a group buss') + r = await run('buss_phase', { buss: 3, state: 'on' }) + expectWrite(r, '/Aux_Outputs/3/Buss_Trim/phase', ',i', 1, 'buss_phase on') + + // names, snapshots, raw, relay + r = await run('snapshot_fire', { snapshot: 5 }) + expectWrite(r, '/Snapshots/Fire_Snapshot_number', ',i', 5, 'snapshot_fire') + check(await waitFor(() => S.snapshots.current === 5, 3000), 'the console echoed Current_Snapshot 5 and the state followed') + await sleep(80) + check(ctx.variables.snapshot_name === 'Offering' && ctx.variables.snapshot_index === 5, 'snapshot variables follow the recall') + r = await run('snapshot_next', {}) + expectWrite(r, '/Snapshots/Fire_Next_Snapshot', ',i', 0, 'snapshot_next') + r = await run('snapshot_prev', {}) + expectWrite(r, '/Snapshots/Fire_Prev_Snapshot', ',i', 0, 'snapshot_prev') + r = await run('raw_osc', { address: '/Console/Name/?', argType: 'none', value: '' }) + check(r.some((m) => m.address === '/Console/Name/?' && m.tags === ','), 'raw_osc sends the address as given') + r = await run('raw_osc', { address: '/Input_Channels/1/Aux_Send/1/send_level', argType: 'float', value: '-5' }) + expectWrite(r, '/Input_Channels/1/Aux_Send/1/send_level', ',f', -5, 'raw_osc float') +} + +// A write is confirmed by one query once its guard window ends (not immediately) +{ + const mark = sent.length + await run('cg_fader_set', { cg: 3, level: -4 }) + await sleep(100) + check(!sent.slice(mark).some((m) => m.address === '/Control_Groups/3/fader/?'), 'no immediate confirm query after a write') + await sleep(inst.timing.writeGuardMs + 200) + const seq = sent.slice(mark).map((m) => m.address) + check(seq.filter((a) => a === '/Control_Groups/3/fader/?').length === 1 && seq.indexOf('/Control_Groups/3/fader/?') > seq.indexOf('/Control_Groups/3/fader'), 'one confirm query of the same address follows when the write guard ends') +} + +// Names: renames rebuild the definitions once (debounced) and the dropdowns pick them up +{ + const before = ctx.rebuilds + await run('input_name_set', { channel: 4, name: 'Renamed four' }) + await run('input_name_set', { channel: 5, name: 'Renamed five' }) + await run('input_name_set', { channel: 6, name: 'Renamed six' }) + await sleep(500) + check(ctx.rebuilds === before + 1, `three quick renames caused exactly one definitions rebuild (${ctx.rebuilds - before})`) + check(ctx.actions.input_mute.options[0].choices.find((c) => c.id === 4)?.label === '4 · Renamed four' && ctx.variables.input_6_name === 'Renamed six', 'renamed inputs show up in the dropdown choices and variables') + check(ctx.presets.input_mute_5.name.includes('Renamed five'), 'presets are rebuilt with the new names') +} + +// --------------------------------------------------------------------------- +// 6. Feedbacks +// --------------------------------------------------------------------------- +const fb = (feedbackId, options, image) => ({ id: `fb-${feedbackId}-${JSON.stringify(options)}`, controlId: 'c1', feedbackId, type: ctx.feedbacks[feedbackId].type, options, previousOptions: null, image }) +const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb(feedbackId, options, image), { signal: new AbortController().signal }) +{ + const mutedInput = [...S.inputs].find(([, v]) => v.mute === true)?.[0] + check(cb('input_muted', { channel: 9 }) === !S.getMute('input', 9) === false || true, 'input_muted evaluates') + check(cb('input_muted', { channel: 10 }) === true && cb('input_muted', { channel: 1 }) === (S.getMute('input', 1) === true), 'input_muted follows the state (10 was muted by an action)') + check(cb('input_solo', { channel: 12 }) === true && cb('input_solo', { channel: 1 }) === false, 'input_solo') + check(cb('send_on', { channel: 2, buss: 3 }) === true && cb('send_level_value', { channel: 2, buss: 3 }) === '-7' && cb('send_on_value', { channel: 2, buss: 3 }) === 'On', 'send_on / send_level_value / send_on_value') + check(cb('group_assigned', { channel: 3, buss: 24 }) === true, 'group_assigned') + check(cb('buss_muted', { buss: 24 }) === true && cb('buss_solo', { buss: 24 }) === true && cb('buss_muted', { buss: 3 }) === true, 'buss_muted / buss_solo') + check(cb('matrix_muted', { matrix: 2 }) === S.getMute('matrix', 2) && cb('cg_muted', { cg: 1 }) === true, 'matrix_muted / cg_muted') + check(cb('snapshot_current', { snapshot: 5 }) === (S.snapshots.current === 5) && cb('snapshot_current', { snapshot: 0 }) === false, 'snapshot_current') + check(cb('level_compare', { kind: 'buss', buss: 24, comparison: 'above', threshold: -10 }) === true && cb('level_compare', { kind: 'cg', cg: 2, comparison: 'below', threshold: -7 }) === true && cb('level_compare', { kind: 'input', channel: 7, comparison: 'above', threshold: 20 }) === false, 'level_compare for buss / control group / input') + check(cb('send_level_compare', { channel: 2, buss: 3, comparison: 'above', threshold: -10 }) === true && cb('send_level_compare', { channel: 2, buss: 3, comparison: 'below', threshold: -10 }) === false, 'send_level_compare') + check(cb('connected', {}) === true, 'connected feedback') + check(cb('input_phantom_state', { channel: 14 }) === true && cb('input_hpf_in_state', { channel: 14 }) === true && cb('input_pad_state', { channel: 14 }) === false && cb('buss_insert_a_state', { buss: 9 }) === true && cb('buss_eq_in_state', { buss: 3 }) !== undefined, 'switch feedbacks follow the loaded / written state') + check(cb('input_comp_in_state', { channel: 33 }) === false && (await waitFor(() => sent.some((m) => m.address === '/Input_Channels/33/Dynamics/comp_in/?'), 2000)), 'an unloaded switch feedback reads false and queries the console once') + await sleep(120) + check(S.getParam('input', 33, 'comp_in') === true && cb('input_comp_in_state', { channel: 33 }) === true, 'the replied switch value now drives the feedback (mock default: comp in)') + check(cb('input_fader_value', { channel: 5 }) === '-12.5' && cb('input_pan_value', { channel: 13 }) === 'R100' && cb('buss_fader_value', { buss: 24 }) === '-3' && cb('matrix_fader_value', { matrix: 2 }) === '-16' && cb('cg_fader_value', { cg: 2 }) === '-8', 'value feedbacks return dB / pan text') + check(cb('snapshot_name_value', {}) === 'Offering' || cb('snapshot_name_value', {}) === S.snapshotName(S.snapshots.current), 'snapshot_name_value follows the current snapshot') + check(cb('send_level_value', { channel: 40, buss: 8 }) === '--' && cb('send_on', { channel: 40, buss: 8 }) === false, 'an unloaded send pair reads as unknown ("--" / false) and is queued for loading') + check(await waitFor(() => sent.some((m) => m.address === '/Input_Channels/40/Aux_Send/8/send_level/?'), 2000), 'the unloaded pair is queried once its feedback runs') + await sleep(120) + check(typeof S.getSend(40, 8).level === 'number' && cb('send_level_value', { channel: 40, buss: 8 }) !== '--', 'the pair state arrives and the value feedback now returns a number') + const count40 = sent.filter((m) => m.address === '/Input_Channels/40/Aux_Send/8/send_level/?').length + cb('send_level_value', { channel: 40, buss: 8 }) + check(sent.filter((m) => m.address === '/Input_Channels/40/Aux_Send/8/send_level/?').length === count40, 'pair loading is de-duplicated') + + // bars + const image = { width: 72, height: 58 } + const t = thicknessFor(58) + const barOf = (id, options) => cb(id, { ...options, color: 0x00c85a }, image) + for (const [id, options, fraction] of [ + ['input_fader_bar', { channel: 5 }, (-12.5 + 60) / 70], + ['buss_fader_bar', { buss: 24 }, (-3 + 60) / 70], + ['send_level_bar', { channel: 2, buss: 3 }, (-7 + 60) / 70], + ]) { + const res = barOf(id, options) + const raw = Buffer.from(res.imageBuffer || '', 'base64') + let filled = 0 + for (let x = 0; x < 72; x++) if (raw[x * 4] === 0x00 && raw[x * 4 + 1] === 0xc8 && raw[x * 4 + 2] === 0x5a) filled++ + check(raw.length === 72 * t * 4 && res.imageBufferEncoding?.pixelFormat === 'RGBA' && res.imageBufferPosition?.y === 58 - t && res.imageBufferPosition?.width === 72 && Math.abs(filled - Math.round(fraction * 72)) <= 1, `${id}: base64 RGBA strip of ${raw.length} bytes, ${filled}/72 px filled`) + } + check(Object.keys(cb('input_fader_bar', { channel: 5, color: 0 }, undefined)).length === 0, 'bar feedbacks return nothing when the button gives no image size') +} + +// --------------------------------------------------------------------------- +// 7. Meters +// --------------------------------------------------------------------------- +{ + const id7 = meterIdForInput(7) + check(id7 >= 5000 && meterIdForInput(60) === 5060, `meter ids start at 5000 (input 7 -> ${id7})`) + const mark = sent.length + cb('input_meter_value', { channel: 7, unit: 'raw' }) + await sleep(80) + const sub = sent.slice(mark).find((m) => m.address === '/Input_Channels/7/Channel_Input/post_meter') + const subWire = sub && decodeArgs(sub) + check(!!subWire && subWire.tags === ',ii' && subWire.args[0] === id7 && subWire.args[1] === 1, `the first meter feedback subscribes lazily: ${JSON.stringify(subWire)}`) + cb('input_meter_value', { channel: 7, unit: 'raw' }) + check(sent.slice(mark).filter((m) => m.address === '/Input_Channels/7/Channel_Input/post_meter').length === 1, 'a meter is subscribed only once') + check(await waitFor(() => S.getMeter(id7), 3000), 'the console streams /Meters/values for the subscribed id') + { + const m2 = sent.length + cb('input_meter_value', { channel: 7, meter: 'gate', unit: 'raw' }) + cb('input_peak', { channel: 7, meter: 'comp', hold: 1 }) + await sleep(80) + const gate = sent.slice(m2).find((m) => m.address === '/Input_Channels/7/Dynamics/GR_meter' && m.args[1].i === 1) + const comp = sent.slice(m2).find((m) => m.address === '/Input_Channels/7/Dynamics/GR_meter' && m.args[1].i === 4) + check(gate?.args[0].i === meterId(7, 'gate') && comp?.args[0].i === meterId(7, 'comp') && meterId(7, 'gate') >= 5000 && meterId(7, 'comp') !== meterId(7, 'gate'), `gate and compressor gain-reduction meters subscribe with kinds 1 and 4 (ids ${meterId(7, 'gate')} / ${meterId(7, 'comp')})`) + check(await waitFor(() => S.getMeter(meterId(7, 'gate')) && S.getMeter(meterId(7, 'comp')), 3000), 'gain-reduction meters stream too') + } + const m = S.getMeter(id7) + check(m.value >= 0 && m.value <= 127, `meter values are 0..127 (${m.value})`) + + // deterministic values injected as if from the console + const inject = (value) => inst.onConsoleMessage(decode(encode('/Meters/values', [{ i: id7 }, { i: value }])), { address: '127.0.0.1', port: P.mock }) + inject(127) + check(cb('input_meter_value', { channel: 7, unit: 'raw' }) === 127 && cb('input_meter_value', { channel: 7, unit: 'percent' }) === 100, 'input_meter_value raw and percent') + check(cb('input_signal', { channel: 7, threshold: 5 }) === true && cb('input_peak', { channel: 7, hold: 2 }) === true, 'input_signal and input_peak at full scale') + inject(3) + check(cb('input_signal', { channel: 7, threshold: 5 }) === false && cb('input_peak', { channel: 7, hold: 2 }) === true, 'signal drops below the threshold while the peak is still held') + await sleep(30) + check(cb('input_peak', { channel: 7, hold: 0 }) === false, 'the peak hold expires') + inject(64) + const bar = cb('input_meter_bar', { channel: 7 }, { width: 72, height: 72 }) + check(Buffer.from(bar.imageBuffer, 'base64').length === thicknessFor(72) * 72 * 4 && bar.imageBufferPosition.x === 72 - thicknessFor(72), 'input_meter_bar returns a right-edge strip of the right size') + + // throttling: a flood of meter messages produces at most ~10 feedback checks per second, by instance id + const watching7 = inst.watchers.get('meter:' + id7) + const gateInstance = `fb-input_meter_value-${JSON.stringify({ channel: 7, meter: 'gate', unit: 'raw' })}` + check(!!watching7 && watching7.size >= 4 && !watching7.has(gateInstance) && inst.watchers.get('meter:' + meterId(7, 'gate'))?.has(gateInstance), `meter feedback instances are tracked per meter id (${watching7?.size} watch meter ${id7}; the gate-reduction one watches its own id)`) + // stop the mock's own meter stream (a raw /Meters/clear straight to the console) so only injected values move + const raw = dgram.createSocket('udp4') + raw.send(encode('/Meters/clear', []), P.mock, '127.0.0.1') + await sleep(400) + raw.close() + ctx.checks.length = 0 + ctx.byId.length = 0 + const t0 = Date.now() + for (let i = 0; i < 100; i++) { + inject(i % 128) + await sleep(10) + } + const spanMs = Date.now() - t0 + const meterChecks = ctx.byId + check(meterChecks.length > 0 && meterChecks.length <= Math.ceil((spanMs / 1000) * 10) + 1, `meter feedback checks throttled to <= 10 Hz (${meterChecks.length} checks in ${spanMs} ms for 100 messages)`) + check(meterChecks.every(([, ids]) => ids.every((x) => watching7.has(x))) && !meterChecks.some(([, ids]) => ids.includes(gateInstance)), 'a meter update re-checks only the instances watching the changed meter id') + check(ctx.checks.length === 0, 'meter updates never fall back to per-type checks') + const rebuildsBefore = ctx.rebuilds + await sleep(200) + check(ctx.rebuilds === rebuildsBefore, 'meter traffic never rebuilds the definitions') + check(!sent.some((m) => m.address === '/Meters/clear'), 'the module never sends /Meters/clear') +} + +// --------------------------------------------------------------------------- +// 8. Relay with a fake iPad: fan-out both ways, /Meters/clear resubscribe +// --------------------------------------------------------------------------- +{ + const ipadRx = [] + const ipad = dgram.createSocket('udp4') + ipad.on('message', (buf) => ipadRx.push(decode(buf))) + await new Promise((r) => ipad.bind(P.ipad, '127.0.0.1', r)) + ipad.send(encode('/Console/Name/?', []), P.relay, '127.0.0.1') + await sleep(300) + check(ipadRx.some((m) => m.address === '/Console/Name'), `iPad got /Console/Name via the relay (${ipadRx.length} msgs)`) + check(inst.relay.clientCount === 1, 'relay tracks one iPad client') + await sleep(60) + check(ctx.variables.relay_clients === 1 && ctx.variables.relay_client_ips === '127.0.0.1' && cb('relay_clients', {}) === true, 'relay_clients variable and feedback') + + ipad.send(encode('/Input_Channels/9/Aux_Send/3/send_level', [-20]), P.relay, '127.0.0.1') + await sleep(300) + check(near(S.getSend(9, 3).level, -20), 'iPad write relayed to the console; the echo updated the module state') + check(ipadRx.some((m) => m.address === '/Input_Channels/9/Aux_Send/3/send_level'), 'console echo of the iPad write fanned back to the iPad') + ipadRx.length = 0 + await run('input_fader_set', { channel: 21, level: -9 }) + await sleep(200) + check(ipadRx.some((m) => m.address === '/Input_Channels/21/fader'), 'a change made by the module reaches the iPad too') + + // /Meters/clear from the iPad: forwarded verbatim, then the module resubscribes + const mark = sent.length + const t0 = Date.now() + ipad.send(encode('/Meters/clear', []), P.relay, '127.0.0.1') + await sleep(120) + check(!sent.slice(mark).some((m) => m.address === '/Input_Channels/7/Channel_Input/post_meter'), 'no resubscribe before the delay has passed') + check(await waitFor(() => sent.slice(mark).some((m) => m.address === '/Input_Channels/7/Channel_Input/post_meter'), 2000), 'after the iPad sent /Meters/clear the module re-sent its meter subscription') + const resub = sent.slice(mark).find((m) => m.address === '/Input_Channels/7/Channel_Input/post_meter') + check(decodeArgs(resub).args[0] === meterIdForInput(7), `resubscribed with the same id (${meterIdForInput(7)})`) + check(await waitFor(() => (S.getMeter(meterIdForInput(7))?.at ?? 0) > t0 + 250, 3000), 'meter values flow again after the resubscribe') + check(!sent.some((m) => m.address === '/Meters/clear'), 'still never sent /Meters/clear ourselves') + + // unpadded datagram from an iPad is forwarded verbatim + const trimmed = encode('/Console/Name/?', []).subarray(0, encode('/Console/Name/?', []).length - 2) + const before = ipadRx.length + ipad.send(trimmed, P.relay, '127.0.0.1') + await sleep(300) + console.log(`info unpadded query forwarded; console replies after it: ${ipadRx.length - before} (the mock may or may not accept unpadded input)`) + + await run('relay_forget', {}) + check(inst.relay.clientCount === 0, 'relay_forget clears the iPad client list') + ipad.close() +} + +// --------------------------------------------------------------------------- +// 9. Reload action; silence -> disconnected -> reconnect reloads +// --------------------------------------------------------------------------- +{ + const loads = logCount(/load complete/) + const mark = sent.length + await run('reload', {}) + check(await waitFor(() => logCount(/load complete/) === loads + 1, 20000), 'reload re-ran the whole load (load complete logged again)') + const again = sent.slice(mark).map((m) => m.address) + check(again.includes('/Console/Matrix_Outputs/?') && again.includes('/Console/Aux_Outputs/types/?') && again.includes('/Input_Channels/1/Channel_Input/name/?') && again.includes('/Input_Channels/1/fader/?'), 'reload re-queries counts, buss types, names and values') + + inst.timing.silenceMs = 500 + mock.kill('SIGSTOP') + await sleep(700) + inst.tick() + check(ctx.statuses.at(-1)?.[0] === 'disconnected' && inst.connectedFlag === false, 'silence from the console -> status disconnected') + await sleep(50) + check(ctx.variables.connected === 'No' && cb('connected', {}) === false, 'connected variable and feedback follow') + const loadsBefore = logCount(/load complete/) + mock.kill('SIGCONT') + check(await waitFor(() => inst.connectedFlag === true, 8000), 'traffic resumes -> connected again') + check(await waitFor(() => logCount(/load complete/) === loadsBefore + 1, 20000), 'reconnect triggered a full reload (load complete logged again)') + check(ctx.statuses.at(-1)?.[0] === 'ok' && ctx.variables.connected === 'Yes', 'status ok after reconnect') + inst.timing.silenceMs = 20000 +} + +// --------------------------------------------------------------------------- +// 11. Config changes touch only what changed; relay clients survive; one lifecycle chain +// --------------------------------------------------------------------------- +{ + const ipadRx = [] + const ipad = dgram.createSocket('udp4') + ipad.on('message', (buf) => ipadRx.push(decode(buf))) + await new Promise((r) => ipad.bind(P.ipad, '127.0.0.1', r)) + ipad.send(encode('/Console/Name/?', []), P.relay, '127.0.0.1') + check(await waitFor(() => inst.relay?.clientCount === 1, 3000), 'an iPad is registered with the relay') + const link0 = inst.link + const relay0 = inst.relay + const clients0 = inst.relayClients + const pushes0 = JSON.stringify(ctx.pushes) + const loads0 = logCount(/Loading from the console/) + const pushFader = async (level) => { + ipadRx.length = 0 + await run('input_fader_set', { channel: 22, level }) + return waitFor(() => ipadRx.some((m) => m.address === '/Input_Channels/22/fader'), 2500) + } + + await inst.configUpdated({ ...config, debugLog: true }) + check(inst.link === link0 && inst.relay === relay0 && relay0.clientCount === 1 && inst.config.debugLog === true, 'a debugLog change only flips the flag: same link, same relay, iPad still registered') + check(await pushFader(-8), 'a console push reaches the iPad right after the debug toggle') + check(logs.some((l) => l.level === 'debug' && /console -> /.test(l.message)), 'debug logging takes effect without reconnecting') + + await inst.configUpdated({ ...config, debugLog: true, relayTimeout: 120 }) + check(inst.relay === relay0 && relay0.timeoutMs === 120000 && relay0.clientCount === 1, 'a relayTimeout change updates the live relay in place') + await inst.configUpdated({ ...config, debugLog: true, relayTimeout: 0 }) + relay0.clients.get('127.0.0.1').lastSeen = 1 // silent for ages + check(relay0.timeoutMs === 0 && relay0.clientCount === 1 && (await pushFader(-7)), 'relayTimeout 0 = never forget: an iPad silent for ages is still listed and still gets console traffic') + await inst.configUpdated({ ...config, debugLog: true, relayTimeout: 3600 }) + relay0.clients.get('127.0.0.1').lastSeen = Date.now() - 4000 * 1000 + check(relay0.clientCount === 0, 'with a 3600 s timeout an iPad silent for longer is no longer live') + relay0.clients.get('127.0.0.1').lastSeen = Date.now() + await inst.configUpdated({ ...config, debugLog: true, relayTimeout: 60, relayClientPort: 0 }) + check(inst.relay === relay0 && relay0.clientPort === 0 && (await pushFader(-6)), 'a relayClientPort change is applied in place (0 = reply to the iPad source port)') + + await inst.configUpdated({ ...config, relayPort: P.spare, relayClientPort: 0 }) + check(inst.relay !== relay0 && inst.relay.port === P.spare && inst.relay.clients === clients0 && inst.relay.clientCount === 1, 'a relay port change reopens the relay but carries the iPad client table across') + check(inst.link === link0, 'the console link is untouched by relay changes') + check(await pushFader(-5), 'the iPad receives console traffic through the reopened relay without registering again') + ipad.send(encode('/Input_Channels/23/Aux_Send/3/send_level', [-21]), P.spare, '127.0.0.1') + check(await waitFor(() => near(S.getSend(23, 3).level, -21), 2500), 'an iPad can send to the new relay port') + await inst.configUpdated({ ...config, relayEnable: false }) + check(inst.relay === null && inst.relayClients === clients0 && clients0.size === 1, 'disabling the relay closes it and keeps the client table') + await inst.configUpdated({ ...config }) + check(!!inst.relay && inst.relay.port === P.relay && inst.relay.clientCount === 1 && (await pushFader(-4)), 'enabling it again brings the relay back with the same iPad') + check(inst.link === link0 && logCount(/Loading from the console/) === loads0 && JSON.stringify(ctx.pushes) === pushes0, 'none of that reloaded the console or rebuilt / re-pushed any definition') + + // a console-link field reopens the link and reloads; the iPad stays registered; the identical result pushes nothing + const loadsDone = logCount(/load complete/) + await inst.configUpdated({ ...config, localPort: P.link + 5 }) + check(inst.link !== link0 && inst.link.localPort === P.link + 5 && logCount(/Loading from the console/) === loads0 + 1, 'a local port change reopens the console link and starts a load') + await inst.configUpdated({ ...config }) + check(await waitFor(() => logCount(/load complete/) === loadsDone + 1, 20000), 'changing it back reloads from the console') + check(inst.relayClients === clients0 && inst.relay.clientCount === 1 && (await pushFader(-3)), 'the iPad stays registered through a console-link reopen') + check(JSON.stringify(ctx.pushes) === pushes0, 'the reload after reopening the link pushed no identical definitions again') + ipad.close() +} + +// Overlapping lifecycle calls run one after the other (no EADDRINUSE from two connects racing) +{ + const c = makeContext() + const i = new Instance(c) + Object.assign(i.timing, { txPaceMs: 3, settleMs: 100 }) + const cfg = { host: '127.0.0.1', consolePort: P.spare + 3, localPort: P.spare + 4, relayEnable: true, relayPort: P.spare + 5, relayClientPort: P.spare + 6, relayTimeout: 60, debugLog: false } + const before = logCount(/EADDRINUSE|in use/) + let threw = null + try { + await Promise.all([i.init(cfg), i.configUpdated({ ...cfg, consolePort: P.spare + 7 }), i.configUpdated({ ...cfg, consolePort: P.spare + 8 }), i.configUpdated({ ...cfg, consolePort: P.spare + 9, relayPort: P.spare + 2 })]) + } catch (e) { + threw = e + } + check(!threw && !!i.link && i.link.port === P.spare + 9 && i.relay?.port === P.spare + 2 && logCount(/EADDRINUSE|in use/) === before, `init and three overlapping configUpdated calls settled in order (link -> ${i.link?.port}, relay ${i.relay?.port}, no bind errors: ${threw?.message || 'ok'})`) + let again = null + try { + await Promise.all([i.configUpdated({ ...cfg, localPort: P.spare + 4, consolePort: P.spare + 3 }), i.destroy()]) + } catch (e) { + again = e + } + check(!again && i.link === null && i.relay === null, 'configUpdated followed straight away by destroy leaves nothing open') + const probe = dgram.createSocket('udp4') + const bound = await new Promise((r) => { + probe.once('error', () => r(false)) + probe.bind(P.spare + 4, '127.0.0.1', () => r(true)) + }) + probe.close() + check(bound, 'the local port is free again after destroy') +} + +// Relay unit checks: idle timeout (0 = never) and the 16-client cap +{ + const r = new Relay({ port: P.spare + 1, timeoutMs: 0 }) + const gone = [] + r.on('clientGone', (ip) => gone.push(ip)) + r.touch({ address: '10.0.0.1', port: 1000 }) + r.clients.get('10.0.0.1').lastSeen = 1 + check(r.clientCount === 1 && r.liveClients().length === 1, 'timeout 0: a client silent since the epoch is still live') + r.update({ timeoutMs: 1000 }) + check(r.clientCount === 0, 'timeout 1 s: the same client is no longer live') + const r2 = new Relay({ port: P.spare + 1, timeoutMs: 3600000, maxClients: 16 }) + const gone2 = [] + r2.on('clientGone', (ip) => gone2.push(ip)) + for (let i = 1; i <= 16; i++) { + r2.touch({ address: `10.0.0.${i}`, port: 1000 + i }) + r2.clients.get(`10.0.0.${i}`).lastSeen = i // ten.0.0.1 is the least recently seen + } + r2.clients.get('10.0.0.1').lastSeen = 0 + r2.touch({ address: '10.0.0.99', port: 2000 }) + check(r2.clients.size === 16 && !r2.clients.has('10.0.0.1') && r2.clients.has('10.0.0.99') && gone2.join() === '10.0.0.1', 'the client table is capped at 16: the least recently seen client is evicted for a new one') + r2.touch({ address: '10.0.0.2', port: 1002 }) + check(r2.clients.size === 16 && r2.clients.has('10.0.0.2'), 'a known client does not evict anyone') + check(new Relay({ port: 1 }).maxClients === 16 && new Relay({ port: 1 }).timeoutMs === 3600000, 'relay defaults: 16 clients, 3600 s') +} + +// --------------------------------------------------------------------------- +// 12. Config validation, sockets that cannot throw, throttled error logging +// --------------------------------------------------------------------------- +{ + const bad = [ + [{ host: '' }, /Console IP/], + [{ host: '127.0.0.1', consolePort: 0 }, /Console receive port/], + [{ host: '127.0.0.1', consolePort: 70000 }, /Console receive port/], + [{ host: '127.0.0.1', localPort: 'abc' }, /Local receive port/], + [{ host: '127.0.0.1', consolePort: 8000.5 }, /Console receive port/], + [{ host: '127.0.0.1', relayEnable: true, relayPort: 0 }, /Relay port/], + [{ host: '127.0.0.1', relayEnable: true, relayClientPort: 70000 }, /iPad receive port/], + [{ host: '127.0.0.1', relayEnable: true, relayTimeout: -1 }, /idle timeout/], + [{ host: '127.0.0.1', relayEnable: true, relayTimeout: 100000 }, /idle timeout/], + ] + let allFine = true + for (const [cfg, re] of bad) { + const c = makeContext() + const i = new Instance(c) + let err = null + try { + await i.init({ consolePort: P.spare + 3, localPort: P.spare + 4, relayPort: P.spare + 5, ...cfg }) + } catch (e) { + err = e + } + const st = c.statuses.at(-1) + const ok = !err && st?.[0] === 'bad_config' && re.test(st[1]) && i.link === null && i.relay === null + if (!ok) console.log(` ${JSON.stringify(cfg)} -> ${err?.message || JSON.stringify(st)}`) + allFine &&= ok + await i.destroy() + } + check(allFine, `${bad.length} invalid configs (empty host, ports 0 / 70000 / text / fractions, bad relay values) give bad_config with a clear message and never throw`) + const n = mainMod.normalizeConfig({ host: ' 127.0.0.1 ', consolePort: '8000', localPort: '9000', relayEnable: true, relayPort: '8001', relayClientPort: '0', relayTimeout: '0' }) + check(n.cfg?.consolePort === 8000 && n.cfg.localPort === 9000 && n.cfg.relayPort === 8001 && n.cfg.relayClientPort === 0 && n.cfg.relayTimeout === 0 && n.cfg.host === '127.0.0.1', 'numeric strings are coerced; relayClientPort 0 and relayTimeout 0 are valid') + check(mainMod.normalizeConfig({ host: '127.0.0.1', relayEnable: false, relayPort: 'junk' }).error === null, 'relay fields are not validated while the relay is off') + const fields = new Instance(makeContext()).getConfigFields().filter((f) => f.type === 'number') + check(fields.length === 5 && fields.every((f) => f.asInteger === true), `every number config field (${fields.map((f) => f.id).join(', ')}) is asInteger`) + const timeoutField = fields.find((f) => f.id === 'relayTimeout') + check(timeoutField.default === 3600 && timeoutField.min === 0 && timeoutField.max === 86400, 'relayTimeout defaults to 3600 s, range 0..86400 (0 = never)') +} +{ + // sends that would throw synchronously are caught and reported + const link = new ConsoleLink({ host: '127.0.0.1', port: 70000, localPort: P.spare + 4 }) + link.noRecord = true + const errors = [] + link.on('error', (e) => errors.push(e)) + await link.open() + let threw = false + let result + try { + result = link.send('/Console/Name/?', []) + } catch { + threw = true + } + check(!threw && result === false && errors.length === 1 && errors[0].dest === '127.0.0.1:70000', `a synchronous socket.send failure (bad port) is caught: send() returns false, one error event with its destination (${errors[0]?.code})`) + let threw2 = false + try { + link.send('/x', [{ nope: 1 }]) + } catch { + threw2 = true + } + check(!threw2 && errors.length === 2, 'an argument that cannot be encoded does not throw either') + link.close() + check((() => { + try { + return link.send('/x', []) === false + } catch { + return false + } + })(), 'sending on a closed link returns false') + const relay = new Relay({ port: P.spare + 1, clientPort: 70000 }) + const relayErrors = [] + relay.on('error', (e) => relayErrors.push(e)) + await relay.open() + relay.touch({ address: '127.0.0.1', port: 1234 }) + let relayThrew = false + try { + relay.fanout(Buffer.from('x')) + } catch { + relayThrew = true + } + relay.close() + check(!relayThrew && relayErrors.length === 1 && relayErrors[0].dest === '127.0.0.1:70000', 'relay fan-out catches a synchronous send failure too') +} +{ + // at most one log line per error code + destination per 30 s + const e = (code, dest) => Object.assign(new Error('boom'), { code, dest }) + const lines = () => logCount(/Console socket: boom/) + const before = lines() + for (let i = 0; i < 25; i++) inst.reportError('Console socket', e('ENETUNREACH', '192.0.2.9:8000')) + check(lines() === before + 1, '25 identical send errors produce one log line') + inst.reportError('Console socket', e('ENETUNREACH', '192.0.2.10:8000')) + inst.reportError('Console socket', e('EHOSTUNREACH', '192.0.2.9:8000')) + check(lines() === before + 3, 'a different destination or error code logs its own line') + const savedWindow = inst.timing.errorLogMs + inst.timing.errorLogMs = 100 + await sleep(150) + inst.reportError('Console socket', e('ENETUNREACH', '192.0.2.9:8000')) + inst.timing.errorLogMs = savedWindow + check(lines() === before + 4, 'the same error logs again once the window has passed') +} + +// --------------------------------------------------------------------------- +// 13. Index validation: no packet, one warning +// --------------------------------------------------------------------------- +{ + check(indexProblem('/Input_Channels/60/fader') === null && indexProblem('/Aux_Outputs/25/mute') === null && indexProblem('/Console/Name') === null, 'indexProblem accepts in-range entity addresses and non-entity addresses') + check(['/Input_Channels/0/fader', '/Input_Channels/-1/fader', '/Input_Channels/1.5/fader', '/Input_Channels/61/fader', '/Input_Channels/NaN/fader', '/Input_Channels/undefined/fader', '/Aux_Outputs/26/fader', '/Input_Channels/3/Aux_Send/26/send_on', '/Matrix_Outputs/9/mute', '/Matrix_Inputs/11/Matrix_Send/1/send_on', '/Control_Groups/11/fader', '/Graphic_EQ/17/geq_in', '/Graphic_EQ/1/geq_gain_33', '/Graphic_EQ/1/geq_gain_1.5'].every((a) => indexProblem(a)), 'indexProblem rejects 0, negatives, fractions, NaN, too large numbers and bad send / band numbers') + const bad = [ + ['input_fader_set', { channel: 0, level: -5 }], + ['input_fader_set', { channel: -3, level: -5 }], + ['input_fader_set', { channel: 1.5, level: -5 }], + ['input_fader_set', { channel: 61, level: -5 }], + ['input_fader_set', { channel: NaN, level: -5 }], + ['input_fader_adjust', { channel: 0, delta: 1 }], + ['input_mute', { channels: [62], state: 'toggle' }], + ['input_pan_set', { channel: 99, pan: 0.5 }], + ['send_on', { channel: 3, buss: 26, state: 'toggle' }], + ['send_level_set', { channel: 3, buss: 0, level: -5 }], + ['send_level_adjust', { channel: 3, buss: 2.5, delta: 1 }], + ['group_assign', { channel: 3, buss: 30, state: 'on' }], + ['buss_mute', { busses: [26], state: 'on' }], + ['buss_fader_set', { buss: 0, level: -5 }], + ['matrix_mute', { matrix: 9, state: 'on' }], + ['matrix_send_on', { matrixIn: 11, matrixOut: 1, state: 'on' }], + ['matrix_send_level_set', { matrixIn: 1, matrixOut: 9, level: -5 }], + ['cg_mute', { cg: 11, state: 'on' }], + ['cg_fader_adjust', { cg: 0, delta: 1 }], + ['geq_on', { geq: 17, state: 'on' }], + ['geq_band_set', { geq: 1, band: 33, gain: 1 }], + ['geq_band_set', { geq: 1, band: 1.5, gain: 1 }], + ['geq_band_adjust', { geq: 1, band: 0, delta: 1 }], + ['input_phantom', { channel: 70, state: 'toggle' }], + ['input_hpf_freq_adjust', { channel: 0, delta: 5 }], + ['buss_trim_set', { buss: 26, value: 1 }], + ['matrix_trim_set', { matrix: 0, value: 1 }], + ['snapshot_fire', { snapshot: -1 }], + ['snapshot_fire', { snapshot: 1.5 }], + ['snapshot_fire', { snapshot: 10000 }], + ['snapshot_fire', { snapshot: NaN }], + ] + await sleep(inst.timing.writeGuardMs + 400) // let earlier confirm queries finish so only this test's traffic is counted + await waitFor(() => !inst.txTimer, 5000) + let packets = 0 + let wrongWarnings = 0 + let slow = 0 + for (const [id, options] of bad) { + const mark = sent.length + const warnings = logs.filter((l) => l.level === 'warn').length + const t0 = Date.now() + await ctx.actions[id].callback({ id: 'a', controlId: 'c', actionId: id, options, surfaceId: undefined }, {}) + await sleep(5) + const warned = logs.filter((l) => l.level === 'warn').length - warnings + if (Date.now() - t0 > 300) slow++ + packets += sent.slice(mark).filter((m) => !/^\/Console\/Name\/\?$/.test(m.address)).length + if (warned !== 1) { + wrongWarnings++ + console.log(` ${id} ${JSON.stringify(options)}: ${warned} warnings`) + } + } + check(packets === 0, `${bad.length} actions with an out-of-range or non-integer index sent no packet`) + check(wrongWarnings === 0, 'each rejected action logged exactly one warning') + check(slow === 0, 'a rejected toggle or adjust does not sit waiting for a reply') + const mark = sent.length + check(inst.write('/Input_Channels/0/fader', [1], 1) === false && inst.queryNow('/Input_Channels/1.5/fader') === false && (inst.ensure('t:bad', ['/Input_Channels/70/EQ/eq_in']), true), 'write, queryNow and ensure refuse bad addresses on their own') + check(sent.length === mark, 'and send nothing') + check(inst.fireSnapshot(0) === true && inst.fireSnapshot(9999) === true, 'snapshot indexes 0 and 9999 are accepted') +} + +// --------------------------------------------------------------------------- +// 14. Definitions: skip identical pushes; rebuild only on names, buss modes, snapshots, existence +// --------------------------------------------------------------------------- +{ + await sleep(inst.timing.rebuildDebounceMs + 300) + const p0 = JSON.stringify(ctx.pushes) + inst.rebuildNow() + inst.rebuildNow() + check(JSON.stringify(ctx.pushes) === p0, 'rebuilding with nothing changed pushes no action, feedback, variable or preset definition') + + // writing the name an input already has is no change at all + const same = S.rawName('input', 2) + await run('input_name_set', { channel: 2, name: same }) + await sleep(inst.timing.rebuildDebounceMs + 200) + check(JSON.stringify(ctx.pushes) === p0, 'writing an unchanged name rebuilds nothing') + + // stereo / mono flags never rebuild; the variable still follows + const flip = (arr, i) => arr.map((v, k) => (k === i ? (v === 2 ? 1 : 2) : v)) + const auxModes = [...Array(25)].map((_, i) => (S.busses.get(i + 1).stereo ? 2 : 1)) + const inputModes = [...Array(60)].map((_, i) => (S.inputs.get(i + 1).stereo ? 2 : 1)) + const wasStereo = S.busses.get(1).stereo + inst.onConsoleMessage(decode(encode('/Console/Aux_Outputs/modes', flip(auxModes, 0).map((i) => ({ i })))), { address: '127.0.0.1', port: P.mock }) + inst.onConsoleMessage(decode(encode('/Console/Input_Channels/modes', flip(inputModes, 0).map((i) => ({ i })))), { address: '127.0.0.1', port: P.mock }) + await sleep(inst.timing.rebuildDebounceMs + 300) + check(S.busses.get(1).stereo === !wasStereo && ctx.variables.buss_1_stereo === (wasStereo ? 'Mono' : 'Stereo') && JSON.stringify(ctx.pushes) === p0, 'a changed stereo flag updates the variable but pushes no definitions') + inst.onConsoleMessage(decode(encode('/Console/Aux_Outputs/modes', auxModes.map((i) => ({ i })))), { address: '127.0.0.1', port: P.mock }) + inst.onConsoleMessage(decode(encode('/Console/Input_Channels/modes', inputModes.map((i) => ({ i })))), { address: '127.0.0.1', port: P.mock }) + + // a buss mode (aux <-> group) does rebuild: labels and preset groups change + const types = [...Array(25)].map((_, i) => (S.busses.get(i + 1).mode === 'aux' ? 1 : 0)) + const wasAux = types[2] === 1 + inst.onConsoleMessage(decode(encode('/Console/Aux_Outputs/types', types.map((v, i) => ({ i: i === 2 ? 1 - v : v })))), { address: '127.0.0.1', port: P.mock }) + await sleep(inst.timing.rebuildDebounceMs + 400) + const label3 = ctx.actions.buss_mute.options[0].choices.find((c) => c.id === 3)?.label + check(wasAux && label3 === '3 · Group 3 (group)' && ctx.pushes.actions === JSON.parse(p0).actions + 1 && ctx.pushes.presets === JSON.parse(p0).presets + 1, `a changed buss type rebuilds and pushes once (${label3})`) + inst.onConsoleMessage(decode(encode('/Console/Aux_Outputs/types', types.map((i) => ({ i })))), { address: '127.0.0.1', port: P.mock }) + await sleep(inst.timing.rebuildDebounceMs + 400) + check(ctx.actions.buss_mute.options[0].choices.find((c) => c.id === 3)?.label === '3 · TRACKS (aux)', 'and back again') + + // a changed snapshot list rebuilds + const p1 = ctx.pushes.actions + inst.onConsoleMessage(decode(encode('/Snapshots/name', [{ i: 8 }, { i: 900 }, { i: 0 }, 'Extra'])), { address: '127.0.0.1', port: P.mock }) + await sleep(inst.timing.rebuildDebounceMs + 300) + check(ctx.pushes.actions === p1 + 1 && ctx.actions.snapshot_fire.options[0].choices.some((c) => c.id === 8 && /Extra/.test(c.label)), 'a new snapshot in the list rebuilds the definitions') +} + +// --------------------------------------------------------------------------- +// 15. Feedback instances are tracked per entity key and re-checked by id +// --------------------------------------------------------------------------- +{ + const mk = (feedbackId, options, id) => ({ id, controlId: 'c1', feedbackId, type: ctx.feedbacks[feedbackId].type, options, previousOptions: null }) + const run1 = (f) => ctx.feedbacks[f.feedbackId].callback(f, { signal: new AbortController().signal }) + const a = mk('input_muted', { channel: 5 }, 'inst-A') + const b = mk('input_muted', { channel: 6 }, 'inst-B') + run1(a) + run1(b) + check(inst.watchers.get('input:5')?.has('inst-A') && inst.watchers.get('input:6')?.has('inst-B') && !inst.watchers.get('input:5')?.has('inst-B'), 'each feedback instance is registered under the entity it watches') + const flipMute = (n) => inst.onConsoleMessage(decode(encode(`/Input_Channels/${n}/mute`, [S.getMute('input', n) ? 0.0 : 1.0])), { address: '127.0.0.1', port: P.mock }) + ctx.byId.length = 0 + ctx.checks.length = 0 + flipMute(5) + await sleep(80) + const ids = ctx.byId.flatMap(([, x]) => x) + check(ids.includes('inst-A') && !ids.includes('inst-B') && !ctx.checks.some(([, t]) => t.includes('input_muted')), 'a change to input 5 re-checks only the instance watching input 5, with no per-type check') + ctx.byId.length = 0 + flipMute(51) + await sleep(80) + check(ctx.checks.some(([, t]) => t.includes('input_muted')) && !ctx.byId.flatMap(([, x]) => x).includes('inst-A'), 'with nobody watching input 51 the per-type check is the fallback') + // options change: the instance moves to its new key + run1({ ...a, options: { channel: 6 } }) + check(!inst.watchers.get('input:5')?.has('inst-A') && inst.watchers.get('input:6')?.has('inst-A'), 'a feedback whose options changed is dropped from its old key and registered under the new one') + ctx.feedbacks.input_muted.unsubscribe(a, {}) + ctx.feedbacks.input_muted.unsubscribe(b, {}) + check(!inst.watching.has('inst-A') && !inst.watching.has('inst-B') && !inst.watchers.get('input:6')?.has('inst-A') && !inst.watchers.get('input:6')?.has('inst-B'), 'unsubscribe drops the instance') + check(Object.values(ctx.feedbacks).every((f) => typeof f.unsubscribe === 'function'), 'every feedback definition has an unsubscribe hook') + // send pairs and the snapshot are tracked too + const s1 = mk('send_on', { channel: 3, buss: 24 }, 'inst-S1') + const s2 = mk('send_on', { channel: 4, buss: 24 }, 'inst-S2') + const sn = mk('snapshot_current', { snapshot: 2 }, 'inst-N') + for (const f of [s1, s2, sn]) run1(f) + await sleep(120) + ctx.byId.length = 0 + inst.onConsoleMessage(decode(encode('/Input_Channels/3/Aux_Send/24/send_on', [S.getSend(3, 24).on ? 0.0 : 1.0])), { address: '127.0.0.1', port: P.mock }) + inst.onConsoleMessage(decode(encode('/Snapshots/Current_Snapshot', [{ i: S.snapshots.current === 1 ? 2 : 1 }])), { address: '127.0.0.1', port: P.mock }) + await sleep(80) + const ids2 = ctx.byId.flatMap(([, x]) => x) + check(ids2.includes('inst-S1') && !ids2.includes('inst-S2') && ids2.includes('inst-N'), 'a send change re-checks only that send pair; a snapshot change re-checks the snapshot feedbacks') + for (const f of [s1, s2, sn]) ctx.feedbacks[f.feedbackId].unsubscribe(f, {}) + // level_compare follows the kind option + const lc = mk('level_compare', { kind: 'cg', cg: 2, comparison: 'above', threshold: -50 }, 'inst-L') + run1(lc) + check(inst.watchers.get('cg:2')?.has('inst-L'), 'level_compare registers under the entity kind it compares') + ctx.feedbacks.level_compare.unsubscribe(lc, {}) + + // every option referenced by an isVisibleExpression must not become an expression + const bad = [] + for (const [id, def] of [...Object.entries(ctx.actions), ...Object.entries(ctx.feedbacks)]) { + for (const o of def.options) { + if (!o.isVisibleExpression) continue + for (const m of o.isVisibleExpression.matchAll(/\$\(options:(\w+)\)/g)) { + const ref = def.options.find((x) => x.id === m[1]) + if (!ref || ref.disableAutoExpression !== true) bad.push(`${id}.${o.id} -> ${m[1]}`) + } + } + } + check(Object.values(ctx.feedbacks).some((f) => f.options.some((o) => o.isVisibleExpression)) && bad.length === 0, `options referenced by isVisibleExpression have disableAutoExpression (${bad.join(', ') || 'level_compare.kind'})`) +} + +// --------------------------------------------------------------------------- +// 16. Names, results, removed unverified actions +// --------------------------------------------------------------------------- +{ + const mark = sent.length + const nameBefore = S.rawName('input', 9) + const warns = logs.filter((l) => l.level === 'warn').length + for (const bad of ['', ' ']) await run('input_name_set', { channel: 9, name: bad }) + check(!sent.slice(mark).some((m) => /Channel_Input\/name$/.test(m.address)) && S.rawName('input', 9) === nameBefore && logs.filter((l) => l.level === 'warn').length === warns + 2, 'an empty (or blank) input name is ignored with a warning and nothing is written') + const results = [] + for (const [id, options] of [['input_fader_set', { channel: 20, level: -9 }], ['input_mute', { channels: [20, 21], state: 'on' }], ['send_on', { channel: 20, buss: 3, state: 'on' }], ['snapshot_next', {}], ['input_fader_set', { channel: 0, level: 1 }], ['raw_osc', { address: '/Console/Name/?', argType: 'none', value: '' }]]) { + results.push(await ctx.actions[id].callback({ id: 'a', controlId: 'c', actionId: id, options, surfaceId: undefined }, {})) + } + check(results.every((x) => x === undefined), 'action callbacks return nothing') + check(!('send_prepost' in ctx.actions) && !('send_pre' in ctx.feedbacks), 'the unverified send pre/post action and feedback are gone') + check(!Object.keys(ctx.actions).some((id) => /delay_(set|adjust)$/.test(id)) && 'input_delay_on' in ctx.actions && 'buss_delay_on' in ctx.actions, 'the delay value actions are gone; the delay in/out switches stay') + check(ctx.actions.input_gain_set.options[1].min === 0 && ctx.actions.input_gain_set.options[1].max === 60 && ctx.actions.input_trim_set.options[1].min === -40 && ctx.actions.input_hpf_freq_set.options[1].min === 20 && ctx.actions.input_hpf_freq_set.options[1].max === 20000, 'analog gain is limited to 0..60, trim to -40..40, filter frequencies to 20..20000') + check(/Verify once on your console/.test(ctx.actions.snapshot_fire.description) && /0 = first/.test(ctx.actions.snapshot_fire.description), 'snapshot_fire says it fires by list position and should be verified first') + let r = await run('input_gain_set', { channel: 14, value: 99 }) + expectWrite(r, '/Input_Channels/14/Channel_Input/analog_gain', ',f', 60, 'a gain above the range is clamped to 60') + r = await run('input_hpf_freq_set', { channel: 14, value: 5 }) + expectWrite(r, '/Input_Channels/14/Filters/lo_filter_freq', ',f', 20, 'a filter frequency below the range is clamped to 20 Hz') +} + +// --------------------------------------------------------------------------- +// 17. A slow console (every answer and echo 150 ms late): bind failures, encoder clicks, lazy loads +// --------------------------------------------------------------------------- +const mockB = startMock(P.mockB, P.linkB, ['--channels', '12', '--delay', '150']) +await sleep(400) +if (mockB.exitCode !== null) { + console.log(`FAIL the second mock console could not start on UDP ${P.mockB}: ${mockB.output.trim()}`) + process.exit(1) +} +const cfgB = { host: '127.0.0.1', consolePort: P.mockB, localPort: P.linkB, relayEnable: true, relayPort: P.relayB, relayClientPort: P.ipadB, relayTimeout: 60, debugLog: false } +const evOf = (actionId, options) => ({ id: 'a', controlId: 'c', actionId, options, surfaceId: undefined }) +const fastTiming = { txPaceMs: 3, settleMs: 150, rebuildDebounceMs: 150, replyWaitMs: 400, flushMs: 15, resubscribeMs: 200, bindRetryMs: 300, relazyMs: 300 } +const occupy = async (port) => { + const blocker = dgram.createSocket('udp4') + await new Promise((resolve, reject) => { + blocker.once('error', reject) + blocker.bind(port, resolve) + }) + return blocker +} + +{ + // The console-link port is taken: connection_failure, retried, and a config change ends the retrying + const blocker = await occupy(P.linkB) + const bctx = makeContext() + const b = new Instance(bctx) + Object.assign(b.timing, fastTiming) + await b.init(cfgB) + const st = () => bctx.statuses.at(-1) + check(st()?.[0] === 'connection_failure' && /Cannot bind local port \d+: EADDRINUSE/.test(st()[1]) && b.link === null, `an occupied local port is shown as connection_failure (${st()?.[1]})`) + await sleep(800) + check(b.link === null && st()[0] === 'connection_failure', 'it keeps retrying while the port stays occupied') + await b.configUpdated({ ...cfgB, localPort: P.linkB + 1 }) + check(b.link?.localPort === P.linkB + 1 && b.linkRetryTimer === null && !!b.relay, 'a config change ends the retrying and binds the new port') + await b.configUpdated(cfgB) + check(st()[0] === 'connection_failure' && b.linkRetryTimer !== null, 'changing back to the occupied port fails again and retries') + const loadsB = logCount(/load complete/) + blocker.close() + check(await waitFor(() => b.link && b.link.localPort === P.linkB, 3000), 'once the port is free the retry binds the console link') + check(await waitFor(() => logCount(/load complete/) === loadsB + 1, 20000) && st()[0] === 'ok', 'and the connection loads and reports ok') + + // Encoder clicks against a console that answers 150 ms late + const SB = b.state + const fader = () => SB.getFader('input', 1) + const clicks = async (intervalMs) => { + await bctx.actions.input_fader_set.callback(evOf('input_fader_set', { channel: 1, level: -10 }), {}) + await sleep(b.timing.writeGuardMs + 700) + const mark = sent.length + for (let k = 0; k < 10; k++) { + await bctx.actions.input_fader_adjust.callback(evOf('input_fader_adjust', { channel: 1, delta: 1 }), {}) + await sleep(intervalMs) + } + const atEnd = fader() + const written = sent.slice(mark).filter((m) => m.address === '/Input_Channels/1/fader').map((m) => m.args[0]) + await sleep(b.timing.writeGuardMs + 700) + return { atEnd, settled: fader(), written } + } + b.timing.writeGuardMs = 0 // control: without the guard the same clicks are lost to stale echoes + const control = await clicks(40) + b.timing.writeGuardMs = 400 + check(control.settled < -0.5, `control: with no write guard, 10 clicks at 40 ms against a slow console lose clicks (ends at ${control.settled} dB, not 0)`) + for (const interval of [40, 100]) { + const r = await clicks(interval) + check(near(r.atEnd, 0) && near(r.settled, 0) && r.written.length === 10 && r.written.every((v, k) => near(v, -9 + k)), `10 clicks of +1 dB at ${interval} ms from -10 end at 0 dB and stay there (written ${r.written.map((v) => Math.round(v)).join(',')}; end ${r.atEnd}, settled ${r.settled})`) + } + const mark = sent.length + await bctx.actions.input_fader_set.callback(evOf('input_fader_set', { channel: 2, level: -20 }), {}) + await sleep(b.timing.writeGuardMs + 700) + check(sent.slice(mark).filter((m) => m.address === '/Input_Channels/2/fader/?').length === 1 && near(SB.getFader('input', 2), -20), 'after the guard one confirm query asks for the value and the state agrees') + // the stale-value guard lets an equal value through and a later change from the surface is accepted + b.onConsoleMessage(decode(encode('/Input_Channels/2/fader', [-15])), { address: '127.0.0.1', port: P.mockB }) + check(near(SB.getFader('input', 2), -15), 'once the guard has ended a value pushed from the surface is applied') + + // Lazy loads: a toggle on a value the console never answers does nothing; the same key may be asked again later + const m2 = sent.length + const warns = logCount(/toggle not applied/) + const t0 = Date.now() + await bctx.actions.input_phantom.callback(evOf('input_phantom', { channel: 40, state: 'toggle' }), {}) + const took = Date.now() - t0 + const sent2 = sent.slice(m2) + check(sent2.some((m) => m.address === '/Input_Channels/40/Channel_Input/phantom/?') && !sent2.some((m) => m.address === '/Input_Channels/40/Channel_Input/phantom') && logCount(/toggle not applied/) === warns + 1 && took >= 350 && took < 1500, `a toggle on a state the console never answers queries, waits (${took} ms), warns and writes nothing (no default to on)`) + const probe = '/Input_Channels/40/EQ/eq_in/?' + const asks = () => sent.filter((m) => m.address === probe).length + const a0 = asks() + b.ensure('t:eq', ['/Input_Channels/40/EQ/eq_in']) + b.ensure('t:eq', ['/Input_Channels/40/EQ/eq_in']) + await sleep(60) + check(asks() === a0 + 1, 'a lazy load is requested once, not on every evaluation') + await sleep(b.timing.replyWaitMs + 150) + b.ensure('t:eq', ['/Input_Channels/40/EQ/eq_in']) + await sleep(60) + check(asks() === a0 + 2, 'an unanswered lazy load may be requested again after the wait') + b.ensure('t:eq', ['/Input_Channels/40/EQ/eq_in']) + await sleep(60) + check(asks() === a0 + 2, 'but not more than once per wait') + await b.destroy() + check(b.link === null && b.relay === null, 'destroy closes the link and the relay') +} + +{ + // The relay port is taken while the console link is fine: warning, retry, back to ok + const blocker = await occupy(P.relayB) + const rctx = makeContext() + const r = new Instance(rctx) + Object.assign(r.timing, fastTiming) + await r.init(cfgB) + const st = () => rctx.statuses.at(-1) + check(await waitFor(() => st()?.[0] === 'unknown_warning', 8000) && st()[1] === `iPad relay: port ${P.relayB} in use` && !!r.link && r.relay === null, `an occupied relay port shows a warning while the console link is fine ("${st()?.[1]}")`) + check(r.connectedFlag === true && r.relayRetryTimer !== null, 'the console side keeps working and the relay bind is retried') + await sleep(800) + check(st()[0] === 'unknown_warning', 'the warning stays while the port is occupied') + blocker.close() + check(await waitFor(() => !!r.relay && st()?.[0] === 'ok', 5000), 'when the port is free the retry opens the relay and the status goes back to ok') + const ipad = dgram.createSocket('udp4') + ipad.send(encode('/Console/Name/?', []), P.relayB, '127.0.0.1') + check(await waitFor(() => r.relay.clientCount === 1, 2000), 'the relay then accepts iPad traffic') + ipad.close() + // a config change while the relay is blocked cancels the retrying + await r.configUpdated({ ...cfgB, relayEnable: false }) + check(r.relay === null && r.relayRetryTimer === null && st()[0] === 'ok', 'disabling the relay clears the problem') + await r.destroy() +} +mockB.kill('SIGTERM') + +// --------------------------------------------------------------------------- +// 10. Every address sent during the whole run was one the console answers +// --------------------------------------------------------------------------- +{ + for (const m of sent) globalThis.__catalogCheck(m.address) + const off = [...globalThis.__offenders] + check(off.length === 0, `every address sent during the whole run is answered by the real console, a documented trigger node or a meter subscription${off.length ? `: ${off.join(', ')}` : ''}`) +} + +// --------------------------------------------------------------------------- +// Shutdown +// --------------------------------------------------------------------------- +await inst.destroy() mock.kill('SIGTERM') await sleep(200) if (failures) { - console.log(`\n${failures} check(s) failed. Mock output:\n${mockOut.slice(-2000)}`) + console.log(`\n${failures} check(s) failed. Mock output:\n${mock.output.slice(-1500)}\nSecond mock output:\n${mockB.output.slice(-800)}\nRecent module log:\n${logs.slice(-30).map((l) => `[${l.level}] ${l.message}`).join('\n')}`) process.exit(1) } console.log('\nAll checks passed.') +process.exit(0) diff --git a/tools/README.md b/tools/README.md index ce90ea8..fec2ded 100644 --- a/tools/README.md +++ b/tools/README.md @@ -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 diff --git a/tools/mock-s31.js b/tools/mock-s31.js index eb6c9dd..531fa3a 100755 --- a/tools/mock-s31.js +++ b/tools/mock-s31.js @@ -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 //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();