Name matrix inputs, fix the meter scale, add output meters

Found on the live S31 (read-only, plus meter subscriptions):

- Matrix inputs are named at /Matrix_Inputs/N/Channel_Input/name (an
  address missing from DiGiCo's 2014 list). Their names now appear in
  every matrix-send dropdown, preset and the new matrix_in_N_name
  variables, falling back to "Matrix In N" when empty. Matrix inputs also
  get trim and phase.
- Console names are space-padded where the surface wraps them; every name
  is now trimmed and its whitespace runs collapsed ("LEAD V RESI").
- Meter values are inverted: 127 is silence and lower is louder, about
  1 dB per step below full scale (silent channels read 127, the room mic
  36-48). The module treated 127 as peak. Level meters now read dBFS
  (approximate), gauges span -60..0 dBFS, signal and peak thresholds are
  in dBFS; gain reduction reads dB of reduction, 127 meaning none.
- Bus and matrix outputs have meters (/Aux_Outputs, /Group_Outputs and
  /Matrix_Outputs/N/Output/meter): new signal, peak, value and gauge
  feedbacks and "Buss meters" / "Matrix meters" presets.

3002 presets, 2.7 MB. npm test: 425 checks pass on Node 25 and on
Companion's Node 22. Deployed and checked on the booth Companion 5.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 10:25:39 -04:00
co-authored by Claude Opus 5.5
parent 9f64d20a24
commit 52777f3d7c
11 changed files with 436 additions and 152 deletions
+44 -23
View File
@@ -6,7 +6,7 @@ This connection does two things:
matrix outputs, control groups, graphic EQs, snapshots) and builds its dropdowns, variables and 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, 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 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. snapshots are covered, with feedbacks, variables, rotary-encoder presets and input and output meters.
2. **Relays the DiGiCo iPad app.** An S-Series console talks to exactly one iPad at a time. Companion 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 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 forwarded to the console unchanged, and everything the console sends is copied to every app, so
@@ -60,10 +60,13 @@ and anything they change reaches this module too, so feedbacks follow moves made
On every connect (and on the **Reload** action, and automatically after the console was silent for 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 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, counts, the buss types and modes, every name (inputs, busses, matrix inputs and outputs, control groups, graphic EQs), 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 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 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`. and presets. Dropdown entries read `1 · KICK In`, `24 · Monitors (group)`, `3 · Message · cue 4`. The console pads names
with trailing spaces and wraps long ones with runs of spaces; every name is trimmed and its whitespace collapsed to single
spaces when it arrives, so dropdowns, variables and presets show `Studio TB`, not `Studio TB `. A matrix input with
no name reads `Matrix In N`.
When a name, a buss mode or the snapshot list changes, the definitions are rebuilt a moment later. 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: 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. each is read the first time a button or action needs it.
@@ -89,7 +92,7 @@ is not known yet.
| Matrix | output mute, fader set/adjust; matrix send on/off, level set/adjust | | Matrix | output mute, fader set/adjust; matrix send on/off, level set/adjust |
| Control groups | mute, solo, fader set/adjust | | Control groups | mute, solo, fader set/adjust |
| Graphic EQ | in/out, band gain set/adjust (bands 1-32) | | 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) | | 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 and matrix inputs, phase also on matrix inputs) |
| Snapshots | fire (pick from the list or type an index), next, previous | | Snapshots | fire (pick from the list or type an index), next, previous |
| Console | reload, raw OSC, relay: forget all iPads | | Console | reload, raw OSC, relay: forget all iPads |
@@ -116,12 +119,12 @@ own value changes.
* Boolean (button colour): input muted / solo; aux send on; group assigned; buss muted / solo; matrix output muted / * Boolean (button colour): input muted / solo; aux send on; group assigned; buss muted / solo; matrix output muted /
solo; control group muted / solo; **matrix send on**; **graphic EQ in**; snapshot is current; fader level above/below solo; control group muted / solo; **matrix send on**; **graphic EQ in**; snapshot is current; fader level above/below
a threshold; aux send level above/below; console link alive; relay has an iPad; every channel-strip switch a threshold; aux send level above/below; console link alive; relay has an iPad; every channel-strip switch
(phantom, phase, pad, EQ in, compressor in, gate in, high-pass in, low-pass in, delay in, insert A/B in); and the meter (phantom, phase, pad, EQ in, compressor in, gate in, high-pass in, low-pass in, delay in, insert A/B in; phase also
feedbacks below. per matrix input); and the meter feedbacks (signal present, peak hold) for inputs, busses and matrix outputs.
* Value (put live numbers in button text with a preset's local variable): input fader and pan; aux send level, On/Off * Value (put live numbers in button text with a preset's local variable): input fader and pan; aux send level, On/Off
and pan; buss / matrix / control group fader; **matrix send level**; **graphic EQ band gain**; trim, analog gain and and pan; buss / matrix / control group fader; **matrix send level**; **graphic EQ band gain**; trim, analog gain and
filter frequency per input and buss (trim also per matrix output); current snapshot name; input meter value. Levels filter frequency per input and buss (trim also per matrix output and matrix input); current snapshot name; meter level
read `-inf` at or below -90 dB. text (see **Meters**). Levels read `-inf` at or below -90 dB.
* Numeric, for gauges (see **Gauges** below): the `*_pos` position feedbacks, `input_pan_num` / `send_pan_num` and * Numeric, for gauges (see **Gauges** below): the `*_pos` position feedbacks, `input_pan_num` / `send_pan_num` and
`geq_band_db`. `geq_band_db`.
* Advanced image bars (**legacy image**, deprecated by Companion 5.1, kept so existing buttons keep working): input * Advanced image bars (**legacy image**, deprecated by Companion 5.1, kept so existing buttons keep working): input
@@ -142,7 +145,7 @@ number you feed to a gauge through a button local variable:
| `input_fader_pos`, `buss_fader_pos`, `matrix_fader_pos`, `cg_fader_pos`, `send_level_pos`, `matrix_send_level_pos` | 0-100 | fader / send position on a fader taper; unknown reads 0 | | `input_fader_pos`, `buss_fader_pos`, `matrix_fader_pos`, `cg_fader_pos`, `send_level_pos`, `matrix_send_level_pos` | 0-100 | fader / send position on a fader taper; unknown reads 0 |
| `input_pan_num`, `send_pan_num` | 0-1 | the console's pan, 0.5 = centre (unknown reads 0.5) | | `input_pan_num`, `send_pan_num` | 0-1 | the console's pan, 0.5 = centre (unknown reads 0.5) |
| `geq_band_db` | -12 to +12 | graphic EQ band gain in dB (unknown reads 0) | | `geq_band_db` | -12 to +12 | graphic EQ band gain in dB (unknown reads 0) |
| `input_meter_pos` | 0-100 | input meter (0-127 scaled); has the *Meter* option level / gate / compressor reduction | | `input_meter_pos`, `buss_meter_pos`, `matrix_meter_pos` | 0-100 | meter position: levels over -60..0 dBFS (-inf = 0), gain reduction over 0..20 dB; the input meter has the *Meter* option level / gate / compressor reduction |
The fader taper maps +10 dB = 100, +5 = 87.5, 0 = 75, -5 = 65, -10 = 55, -20 = 40, -30 = 28, -40 = 18, -50 = 11 and The fader taper maps +10 dB = 100, +5 = 87.5, 0 = 75, -5 = 65, -10 = 55, -20 = 40, -30 = 28, -40 = 18, -50 = 11 and
-60 = 6 (straight lines in between), falls to about 1 just above -90 dB, and reads 0 at or below -90 dB (off). -60 = 6 (straight lines in between), falls to about 1 just above -90 dB, and reads 0 at or below -90 dB (off).
@@ -158,7 +161,7 @@ To add a gauge to your own button in Companion 5.1:
## Variables ## Variables
`input_N_name|fader|mute|pan`, `buss_N_name|mode|stereo|fader|mute`, `matrix_N_name|fader|mute`, `input_N_name|fader|mute|pan`, `buss_N_name|mode|stereo|fader|mute`, `matrix_N_name|fader|mute`, `matrix_in_N_name`,
`cg_N_name|fader|mute` (only for entities that exist), `snapshot_index|name|cue|count`, `console_name`, `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 `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. `$(label:input_1_name)` with the connection's label. There are no per-send variables: use the value feedbacks.
@@ -178,25 +181,42 @@ buttons.
| 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 gauge) | | 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 gauge) |
| Group *group name* | One section per named group buss: per input an **Assign** toggle, green when assigned | | Group *group name* | One section per named group buss: per input an **Assign** toggle, green when assigned |
| Matrix, Control groups | Mute and fader knob (with a gauge) per output / group | | Matrix, Control groups | Mute and fader knob (with a gauge) per output / group |
| Matrix sends to *output name* | One section per matrix output: per matrix input an **On/off** button (green when on; shows `Matrix In N` and the level) and a **Level (knob)** (turn = send level, press = on/off, with a gauge) | | Matrix sends to *output name* | One section per matrix output: per matrix input an **On/off** button (green when on; shows the matrix input's name and the level) and a **Level (knob)** (turn = send level, press = on/off, with a gauge) |
| Graphic EQs | One **In/out** toggle per graphic EQ, green when in | | Graphic EQs | One **In/out** toggle per graphic EQ, green when in |
| Snapshots | **Fire** per snapshot (green when current), next, previous, current (display) | | Snapshots | **Fire** per snapshot (green when current), next, previous, current (display) |
| Input meters | Level, gate gain reduction and compressor gain reduction buttons per input, each with a vertical gauge at the right edge (green below 70, amber 70-90, red above 90) | | Input meters | Level, gate gain reduction and compressor gain reduction buttons per input, each with a vertical gauge at the right edge (green below 70, amber 70-90, red above 90) |
| Buss meters, Matrix meters | One level meter button per named buss and per matrix output, same vertical gauge |
| Console | Reload, connection status, relay status | | 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 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 gate / compressor reduction meter presets are left out first, then the 3200 entries or 4 MB, the per-input gate / compressor reduction meter presets are left out first, then the
per-input aux send level knobs, and a warning is logged. per-input aux send level knobs, and a warning is logged.
## Meters ## Meters
Input meters are subscribed on the console the first time a meter feedback renders (ids start at 5000 so Meters are subscribed on the console the first time a meter feedback renders (ids start at 5000, so they never collide
they never collide with an iPad's). The console then streams values 0-127 several times a second; the with an iPad's: input meters 5000-5260, buss meters 5400 + N, matrix output meters 5450 + N). The console then streams
module re-checks the meter feedbacks at most 10 times a second. Level is the post-fader input meter; gate and integer values 0-127 several times a second, only for the ones that changed, and the module re-checks the meter
compressor gain reduction are the two reduction meters. There is no known buss meter address, so busses feedbacks at most 10 times a second, and only the buttons showing the meter that moved.
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 **The scale is inverted and approximate.** A level meter value is about the **dB below full scale**: 0 = full scale,
different banks both receive the mixed stream. The module never sends `/Meters/clear` itself. 42 = about -42 dBFS, **127 = silence (-inf)**. A gain reduction meter value is about the **dB of reduction** (0-12 is
typical, 127 = inactive). It has not been calibrated against a reference tone, so treat the numbers as roughly 1 dB per
step, not as a measurement.
| Feedback | What it shows |
| --- | --- |
| `input_meter_value`, `buss_meter_value`, `matrix_meter_value` | text: `-42` or `-inf` (dBFS, approx.); gain reduction meters: `3` (dB) |
| `input_meter_pos`, `buss_meter_pos`, `matrix_meter_pos` | gauge position 0-100: levels over -60..0 dBFS, gain reduction over 0..20 dB |
| `input_signal`, `buss_signal`, `matrix_signal` | signal present: level above a dBFS threshold (option, default -50; gain reduction meters: any reduction) |
| `input_peak`, `buss_peak`, `matrix_peak` | level at or above a dBFS value (option, default -1) held for the *Hold* time; not for gain reduction meters |
Input meters are level (post-fader), gate gain reduction and compressor gain reduction. Output meters exist for aux
busses, group busses (the address follows the buss's current aux / group side) and matrix outputs; they are level meters.
Control groups and matrix inputs have no meter. 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 (and after a
reconnect). Two iPads watching different banks both receive the mixed stream. The module never sends `/Meters/clear`
itself.
## Troubleshooting ## Troubleshooting
@@ -224,17 +244,18 @@ Levels, sends and pans are floats too. Numbers are 1-based, snapshot indexes 0-b
| `/Console/Aux_Outputs/types` | 25 x i | 1 = aux, 0 = group | | `/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/{Aux_Outputs,Group_Outputs,Input_Channels}/modes` | i per buss / input | 2 = stereo, 1 = mono |
| `/Console/Session/Filename` | s | | | `/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/N/Channel_Input/name`, `/Matrix_Inputs/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 | names arrive padded; the module cleans them |
| `/{Input_Channels,Aux_Outputs,Group_Outputs,Matrix_Outputs,Control_Groups}/N/{fader,mute,solo}` | f | fader in dB, -150 = -inf | | `/{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/Panner/pan` | f | 0..1, 0.5 = centre |
| `/Input_Channels/N/Aux_Send/M/{send_level,send_on,send_pan}` | f | | | `/Input_Channels/N/Aux_Send/M/{send_level,send_on,send_pan}` | f | |
| `/Input_Channels/N/Group_Send/M/group` | f | | | `/Input_Channels/N/Group_Send/M/group` | f | |
| `/Matrix_Inputs/N/Matrix_Send/M/{send_level,send_on}` | f | | | `/Matrix_Inputs/N/Matrix_Send/M/{send_level,send_on}` | f | a matrix send floors at -90 dB |
| `/Matrix_Inputs/N/Channel_Input/{trim,phase}` | f | |
| `/Graphic_EQ/N/{geq_in,geq_gain_1..32}` | 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 | | `/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/Current_Snapshot`, `/Snapshots/count` | i | |
| `/Snapshots/names` | one `/Snapshots/name` `iiis` per snapshot: index, cue x 100, ?, name | | | `/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 | | `/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 | | `/Input_Channels/N/Channel_Input/post_meter`, `/Input_Channels/N/Dynamics/GR_meter`, `/{Aux_Outputs,Group_Outputs,Matrix_Outputs}/N/Output/meter` | none | send `,ii [id, kind]` (kind 1 level and gate and outputs, 4 compressor) to subscribe |
| `/Meters/values` | `ii...` | pushed pairs `[id, value]`, value 0..127 | | `/Meters/values` | `ii...` | pushed pairs `[id, value]`, value 0..127: inverted scale, 127 = silence / inactive |
| `/Meters/clear` | none | clears every subscription on the console; sent by iPad apps, never by this module | | `/Meters/clear` | none | clears every subscription on the console; sent by iPad apps, never by this module |
+6 -3
View File
@@ -52,11 +52,14 @@ const ADJUST_NOTE = 'From −∞ the first step up jumps to −60 dB.'
export const numbers = (v) => (Array.isArray(v) ? v : v === undefined || v === '' ? [] : [v]).map(Number).filter(Number.isFinite) 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'. */ /** Which entity kinds get actions / feedbacks for a parameter: 'input', 'buss', 'matrix' and/or 'minput' (matrix input). */
export const paramEntities = (param) => Object.keys(PARAMS[param].paths) export const paramEntities = (param) => Object.keys(PARAMS[param].paths)
/** Entity dropdown for a parameter's entity kind. */ /** 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')) export const paramEntityOption = (inst, ent) => (ent === 'input' ? opt.channel(inst) : ent === 'matrix' ? opt.matrix(inst) : ent === 'minput' ? opt.matrixIn(inst) : opt.buss(inst, 'all'))
/** Display name of an entity kind in action and feedback names. */
export const ENTITY_TITLE = { input: 'Input', buss: 'Buss', matrix: 'Matrix output', minput: 'Matrix input' }
/** Switch toggles and value set/adjust actions for the lazily loaded per-channel parameters (PARAMS). */ /** Switch toggles and value set/adjust actions for the lazily loaded per-channel parameters (PARAMS). */
function getParamActions(inst) { function getParamActions(inst) {
@@ -64,7 +67,7 @@ function getParamActions(inst) {
for (const [param, p] of Object.entries(PARAMS)) { for (const [param, p] of Object.entries(PARAMS)) {
for (const ent of paramEntities(param)) { for (const ent of paramEntities(param)) {
const entOpt = paramEntityOption(inst, ent) const entOpt = paramEntityOption(inst, ent)
const who = { input: 'Input', buss: 'Buss', matrix: 'Matrix output' }[ent] const who = ENTITY_TITLE[ent]
const target = (options) => Number(options[entOpt.id]) const target = (options) => Number(options[entOpt.id])
if (p.type === 'switch') { if (p.type === 'switch') {
out[`${ent}_${param}`] = { out[`${ent}_${param}`] = {
+50 -35
View File
@@ -1,7 +1,7 @@
import { combineRgb } from '@companion-module/base' import { combineRgb } from '@companion-module/base'
import { GEQ_RANGE, LEVEL, METERS, PARAMS, clamp, faderPosition, fmtDb, fmtPan, fmtValue, meterId } from '../protocol.js' import { GEQ_RANGE, LEVEL, METERS, PARAMS, clamp, faderPosition, fmtDb, fmtMeterLevel, fmtPan, fmtValue, meterGrDb, meterId, meterLevelDb, meterPosition, outputMeterId } from '../protocol.js'
import { horizontalBar, verticalMeter } from '../image.js' import { horizontalBar, verticalMeter } from '../image.js'
import { opt, paramEntities, paramEntityOption } from './actions.js' import { ENTITY_TITLE, opt, paramEntities, paramEntityOption } from './actions.js'
export const COLORS = { export const COLORS = {
white: combineRgb(255, 255, 255), white: combineRgb(255, 255, 255),
@@ -111,6 +111,32 @@ export function getFeedbackDefinitions(inst) {
inst.subscribeInputMeter(ch, meter) inst.subscribeInputMeter(ch, meter)
return s.getMeter(meterId(ch, meter)) return s.getMeter(meterId(ch, meter))
} }
const isGr = (o) => meterName(o) !== 'level'
/** Output meters (busses, matrix outputs) are level meters; the console subscription follows a buss's aux / group side. */
const outputKey = (ent, id) => (o) => watchKey.meter(outputMeterId(ent, n(o[id])))
const outputMeterOf = (ent, id, options) => {
inst.subscribeOutputMeter(ent, n(options[id]))
return s.getMeter(outputMeterId(ent, n(options[id])))
}
// the meter scale is inverted and approximate: level value v = about dB below full scale (127 = silence), gain reduction v = dB
const signalOption = { type: 'number', id: 'thresholdDb', label: 'Signal above (dBFS, approx.)', default: -50, min: -80, max: 0, step: 1 }
const peakOption = { type: 'number', id: 'peakDb', label: 'Peak at or above (dBFS, approx.)', default: -1, min: -20, max: 0, step: 1 }
const holdOption = { type: 'number', id: 'hold', label: 'Hold (seconds)', default: 2, min: 0, max: 10, step: 0.5 }
const levelSignal = (m, o) => meterLevelDb(m?.value) > n(o.thresholdDb ?? signalOption.default)
const levelPeak = (id, o) => {
const at = s.meterPeakAt(id, n(o.peakDb ?? peakOption.default))
return at > 0 && Date.now() - at <= n(o.hold ?? holdOption.default) * 1000
}
const meterText = (m, gr) => (gr ? String(meterGrDb(m?.value)) : fmtMeterLevel(m?.value))
const outputMeters = (ent, id, entOption, who) => ({
[`${ent}_signal`]: bool(`${who} meter: signal present`, GREEN, [entOption(), signalOption], outputKey(ent, id), ({ options }) => levelSignal(outputMeterOf(ent, id, options), options)),
[`${ent}_peak`]: bool(`${who} meter: peak hold (dBFS, approx.)`, RED, [entOption(), peakOption, holdOption], outputKey(ent, id), ({ options }) => {
outputMeterOf(ent, id, options)
return levelPeak(outputMeterId(ent, n(options[id])), options)
}),
[`${ent}_meter_value`]: value(`${who} meter: level text (dBFS, approx.)`, [entOption()], outputKey(ent, id), ({ options }) => fmtMeterLevel(outputMeterOf(ent, id, options)?.value)),
[`${ent}_meter_pos`]: value(`${who} meter: position (0-100 over -60..0 dBFS, for gauges)`, [entOption()], outputKey(ent, id), ({ options }) => meterPosition(outputMeterOf(ent, id, options)?.value, 'level')),
})
const entityKey = (ent, id) => (o) => watchKey.entity(ent, n(o[id])) const entityKey = (ent, id) => (o) => watchKey.entity(ent, n(o[id]))
const sendKey = (o) => watchKey.send(n(o.channel), n(o.buss)) const sendKey = (o) => watchKey.send(n(o.channel), n(o.buss))
const msendKey = (o) => watchKey.msend(n(o.matrixIn), n(o.matrixOut)) const msendKey = (o) => watchKey.msend(n(o.matrixIn), n(o.matrixOut))
@@ -133,7 +159,7 @@ export function getFeedbackDefinitions(inst) {
for (const ent of paramEntities(param)) { for (const ent of paramEntities(param)) {
const entOption = paramEntityOption(inst, ent) const entOption = paramEntityOption(inst, ent)
paramFeedbacks[`${ent}_${param}_value`] = value( paramFeedbacks[`${ent}_${param}_value`] = value(
`${{ input: 'Input', buss: 'Buss', matrix: 'Matrix output' }[ent]}: ${p.label} (${p.unit} text)`, `${ENTITY_TITLE[ent]}: ${p.label} (${p.unit} text)`,
[entOption], [entOption],
entityKey(ent, entOption.id), entityKey(ent, entOption.id),
({ options }) => { ({ options }) => {
@@ -149,7 +175,7 @@ export function getFeedbackDefinitions(inst) {
const style = param === 'phantom' ? RED : GREEN const style = param === 'phantom' ? RED : GREEN
const entOption = paramEntityOption(inst, ent) const entOption = paramEntityOption(inst, ent)
paramFeedbacks[`${ent}_${param}_state`] = bool( paramFeedbacks[`${ent}_${param}_state`] = bool(
`${{ input: 'Input', buss: 'Buss', matrix: 'Matrix output' }[ent]}: ${p.label} is on`, `${ENTITY_TITLE[ent]}: ${p.label} is on`,
style, style,
[entOption], [entOption],
entityKey(ent, entOption.id), entityKey(ent, entOption.id),
@@ -222,22 +248,27 @@ export function getFeedbackDefinitions(inst) {
relay_clients: bool('Relay has at least one iPad', GREEN, [], () => watchKey.relay, () => (inst.relay?.clientCount || 0) > 0), relay_clients: bool('Relay has at least one iPad', GREEN, [], () => watchKey.relay, () => (inst.relay?.clientCount || 0) > 0),
input_signal: bool( input_signal: bool(
'Input meter: signal present', 'Input meter: signal present (level above a dBFS threshold; gain reduction meters: any reduction)',
GREEN, GREEN,
[opt.channel(inst), meterOption, { type: 'number', id: 'threshold', label: 'Signal above (0-127)', default: 5, min: 0, max: 127, step: 1 }], [opt.channel(inst), meterOption, signalOption],
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, meterKey,
({ options }) => { ({ options }) => {
const m = meterOf(options) const m = meterOf(options)
return !!m && m.peakAt > 0 && Date.now() - m.peakAt <= n(options.hold) * 1000 return isGr(options) ? meterGrDb(m?.value) > 0 : levelSignal(m, options)
}, },
), ),
input_peak: bool(
'Input meter: peak hold (level at or above a dBFS threshold; not for gain reduction meters)',
RED,
[opt.channel(inst), meterOption, peakOption, holdOption],
meterKey,
({ options }) => {
meterOf(options)
return !isGr(options) && levelPeak(meterId(n(options.channel), 'level'), options)
},
),
...outputMeters('buss', 'buss', () => opt.buss(inst), 'Buss'),
...outputMeters('matrix', 'matrix', () => opt.matrix(inst), 'Matrix output'),
input_fader_value: value('Input: fader level (dB text)', [opt.channel(inst)], entityKey('input', 'channel'), ({ options }) => fmtDb(s.getFader('input', n(options.channel))) || '--'), 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))) || '--'), input_pan_value: value('Input: pan (text)', [opt.channel(inst)], entityKey('input', 'channel'), ({ options }) => fmtPan(s.getPan(n(options.channel))) || '--'),
@@ -276,29 +307,13 @@ export function getFeedbackDefinitions(inst) {
return s.getSend(n(options.channel), n(options.buss)).pan ?? 0.5 return s.getSend(n(options.channel), n(options.buss)).pan ?? 0.5
}), }),
geq_band_db: value('Graphic EQ: band gain (dB number -12..12, for gauges)', [opt.geq(inst), opt.band()], geqGainKey, ({ options }) => geqGainOf(options) ?? 0), geq_band_db: value('Graphic EQ: band gain (dB number -12..12, for gauges)', [opt.geq(inst), opt.band()], geqGainKey, ({ options }) => geqGainOf(options) ?? 0),
input_meter_pos: value('Input meter: position (0-100, for gauges)', [opt.channel(inst), meterOption], meterKey, ({ options }) => ((meterOf(options)?.value ?? 0) / 127) * 100), input_meter_pos: value('Input meter: position (0-100, for gauges; level over -60..0 dBFS, gain reduction over 0..20 dB)', [opt.channel(inst), meterOption], meterKey, ({ options }) => meterPosition(meterOf(options)?.value, isGr(options) ? 'gr' : 'level')),
snapshot_name_value: value('Snapshot: current name', [], () => watchKey.snapshot, () => s.snapshotName(s.snapshots.current) ?? '--'), snapshot_name_value: value('Snapshot: current name', [], () => watchKey.snapshot, () => s.snapshotName(s.snapshots.current) ?? '--'),
input_meter_value: value( input_meter_value: value(
'Input meter: value', 'Input meter: value text (level in dBFS, approx., -inf = silence; gain reduction in dB)',
[ [opt.channel(inst), meterOption],
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, meterKey,
({ options }) => { ({ options }) => meterText(meterOf(options), isGr(options)),
const v = meterOf(options)?.value ?? 0
return options.unit === 'percent' ? Math.round((v / 127) * 100) : v
},
), ),
input_fader_bar: bar('Input: fader bar (legacy image)', [opt.channel(inst)], entityKey('input', 'channel'), (fb, image) => { input_fader_bar: bar('Input: fader bar (legacy image)', [opt.channel(inst)], entityKey('input', 'channel'), (fb, image) => {
@@ -338,7 +353,7 @@ export function getFeedbackDefinitions(inst) {
callback: tracked(meterKey, (fb) => { callback: tracked(meterKey, (fb) => {
const m = meterOf(fb.options) const m = meterOf(fb.options)
if (!fb.image) return {} if (!fb.image) return {}
return verticalMeter({ ...fb.image, fraction: (m?.value ?? 0) / 127 }) return verticalMeter({ ...fb.image, fraction: meterPosition(m?.value, isGr(fb.options) ? 'gr' : 'level') / 100 })
}), }),
unsubscribe, unsubscribe,
}, },
+54 -10
View File
@@ -1,7 +1,8 @@
import { COLORS } from './feedbacks.js' import { COLORS } from './feedbacks.js'
import { VAR_PREFIX } from './variables.js'
/** Hard limits agreed for the preset list (Companion keeps all of it in memory and sends it to the web UI). */ /** 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_PRESETS = 3200
export const MAX_PRESET_BYTES = 4 * 1024 * 1024 export const MAX_PRESET_BYTES = 4 * 1024 * 1024
const { white: WHITE, dark: DARK, red: RED, green: GREEN, amber: AMBER } = COLORS const { white: WHITE, dark: DARK, red: RED, green: GREEN, amber: AMBER } = COLORS
@@ -30,7 +31,7 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
const L = inst.label const L = inst.label
const presets = {} const presets = {}
const structure = [] const structure = []
const nameVar = (ent, n) => `$(${L}:${ent}_${n}_name)` const nameVar = (ent, n) => `$(${L}:${VAR_PREFIX[ent]}_${n}_name)`
const localFeedback = (variableName, feedbackId, options) => ({ variableName, variableType: 'feedback', feedbackId, options }) const localFeedback = (variableName, feedbackId, options) => ({ variableName, variableType: 'feedback', feedbackId, options })
const simple = (name, text, down, feedbacks = [], extra = {}) => ({ const simple = (name, text, down, feedbacks = [], extra = {}) => ({
@@ -86,8 +87,11 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
feedbacks, feedbacks,
localVariables: [localFeedback('level', levelId, levelOptions), localFeedback('pos', posId, posOptions)], localVariables: [localFeedback('level', levelId, levelOptions), localFeedback('pos', posId, posOptions)],
}) })
/** Layered input meter: name on the left, a vertical gauge (green / amber / red zones) at the right edge. */ /**
const layeredMeter = ({ name, title, options }) => ({ * Layered meter: name on the left, a vertical gauge (green / amber / red zones) at the right edge; the position comes
* from `posFeedback`. A signal feedback tints the background dark green and, for level meters, a peak feedback red.
*/
const layeredMeter = ({ name, title, posFeedback, signal, peak }) => ({
type: 'layered', type: 'layered',
name, name,
elements: [ elements: [
@@ -115,8 +119,8 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
}, },
], ],
steps: [{ down: [], up: [] }], steps: [{ down: [], up: [] }],
feedbacks: [tint('input_signal', { ...options, threshold: 5 }, SIGNAL_BG), tint('input_peak', { ...options, hold: 2 }, MUTED_RED)], feedbacks: [tint(signal[0], signal[1], SIGNAL_BG), ...(peak ? [tint(peak[0], peak[1], MUTED_RED)] : [])],
localVariables: [localFeedback('pos', 'input_meter_pos', options)], localVariables: [localFeedback('pos', posFeedback[0], posFeedback[1])],
}) })
const group = (id, name, ids) => ({ id, type: 'simple', name, presets: ids }) const group = (id, name, ids) => ({ id, type: 'simple', name, presets: ids })
@@ -146,7 +150,15 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
['comp', 'input_comp_gr', 'Compressor reduction'], ['comp', 'input_comp_gr', 'Compressor reduction'],
]) { ]) {
if (meter !== 'level' && !grMeters) continue if (meter !== 'level' && !grMeters) continue
presets[`${prefix}_${n}`] = layeredMeter({ name: `${label}: ${tag}`, title: nameVar('input', n), options: { channel: n, meter } }) const options = { channel: n, meter }
presets[`${prefix}_${n}`] = layeredMeter({
name: `${label}: ${tag}`,
title: nameVar('input', n),
posFeedback: ['input_meter_pos', options],
signal: ['input_signal', { ...options, thresholdDb: -50 }],
// a gain reduction meter has no peak
peak: meter === 'level' ? ['input_peak', { ...options, peakDb: -1, hold: 2 }] : null,
})
meterIds[meter].push(`${prefix}_${n}`) meterIds[meter].push(`${prefix}_${n}`)
} }
muteIds.push(`input_mute_${n}`) muteIds.push(`input_mute_${n}`)
@@ -290,14 +302,14 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
const onIds = [] const onIds = []
const knobIds = [] const knobIds = []
for (const inN of s.numbers('minput')) { for (const inN of s.numbers('minput')) {
const tag = `Matrix In ${inN} → ${out} · ${oname}` const tag = `${s.displayName('minput', inN)} → ${out} · ${oname}`
const text = `Matrix In ${inN}\\n$(local:level) dB` const text = `${nameVar('minput', inN)}\\n$(local:level) dB`
const options = { matrixIn: inN, matrixOut: out } const options = { matrixIn: inN, matrixOut: out }
const lv = [localFeedback('level', 'matrix_send_level_value', options)] const lv = [localFeedback('level', 'matrix_send_level_value', options)]
presets[`msend_on_${inN}_${out}`] = simple(`Matrix send on/off: ${tag}`, text, [act('matrix_send_on', { ...options, state: 'toggle' })], [fb('matrix_send_on', options, GREEN)], { localVariables: lv }) presets[`msend_on_${inN}_${out}`] = simple(`Matrix send on/off: ${tag}`, text, [act('matrix_send_on', { ...options, state: 'toggle' })], [fb('matrix_send_on', options, GREEN)], { localVariables: lv })
presets[`msend_knob_${inN}_${out}`] = layeredKnob({ presets[`msend_knob_${inN}_${out}`] = layeredKnob({
name: `Matrix send level (knob): ${tag}`, name: `Matrix send level (knob): ${tag}`,
title: `Matrix In ${inN}`, title: nameVar('minput', inN),
press: [act('matrix_send_on', { ...options, state: 'toggle' })], press: [act('matrix_send_on', { ...options, state: 'toggle' })],
adjust: ['matrix_send_level_adjust', options], adjust: ['matrix_send_level_adjust', options],
level: ['matrix_send_level_value', options], level: ['matrix_send_level_value', options],
@@ -316,6 +328,38 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
} }
} }
// ---- Output meters: one layered meter per buss and per matrix output
{
const bussIds = []
for (const n of s.named('buss')) {
presets[`buss_meter_${n}`] = layeredMeter({
name: `Buss meter: ${n} · ${s.displayName('buss', n)} (${s.bussMode(n)})`,
title: nameVar('buss', n),
posFeedback: ['buss_meter_pos', { buss: n }],
signal: ['buss_signal', { buss: n, thresholdDb: -50 }],
peak: ['buss_peak', { buss: n, peakDb: -1, hold: 2 }],
})
bussIds.push(`buss_meter_${n}`)
}
if (bussIds.length) {
structure.push({ id: 'buss_meters', name: 'Buss meters', description: 'Meters subscribe on the console when the button first renders.', definitions: [group('buss_meters_level', 'Level', bussIds)] })
}
const matrixIds = []
for (const n of s.numbers('matrix')) {
presets[`matrix_meter_${n}`] = layeredMeter({
name: `Matrix meter: ${n} · ${s.displayName('matrix', n)}`,
title: nameVar('matrix', n),
posFeedback: ['matrix_meter_pos', { matrix: n }],
signal: ['matrix_signal', { matrix: n, thresholdDb: -50 }],
peak: ['matrix_peak', { matrix: n, peakDb: -1, hold: 2 }],
})
matrixIds.push(`matrix_meter_${n}`)
}
if (matrixIds.length) {
structure.push({ id: 'matrix_meters', name: 'Matrix meters', description: 'Meters subscribe on the console when the button first renders.', definitions: [group('matrix_meters_level', 'Level', matrixIds)] })
}
}
// ---- Graphic EQs: in / out // ---- Graphic EQs: in / out
{ {
const ids = [] const ids = []
+8 -3
View File
@@ -5,8 +5,12 @@ const ENTITY_VARS = {
buss: ['name', 'mode', 'stereo', 'fader', 'mute'], buss: ['name', 'mode', 'stereo', 'fader', 'mute'],
matrix: ['name', 'fader', 'mute'], matrix: ['name', 'fader', 'mute'],
cg: ['name', 'fader', 'mute'], cg: ['name', 'fader', 'mute'],
minput: ['name'],
} }
/** Variable id prefix per entity kind (matrix inputs are matrix_in_N_...). */
export const VAR_PREFIX = { input: 'input', buss: 'buss', matrix: 'matrix', cg: 'cg', minput: 'matrix_in' }
const VAR_LABEL = { const VAR_LABEL = {
name: 'name', name: 'name',
fader: 'fader level (dB)', fader: 'fader level (dB)',
@@ -16,7 +20,7 @@ const VAR_LABEL = {
stereo: 'Stereo/Mono', stereo: 'Stereo/Mono',
} }
const ENT_LABEL = { input: 'Input', buss: 'Buss', matrix: 'Matrix output', cg: 'Control group' } const ENT_LABEL = { input: 'Input', buss: 'Buss', matrix: 'Matrix output', cg: 'Control group', minput: 'Matrix input' }
/** Variables exist only for entities the console reported (all possible ones until the first load finishes). */ /** Variables exist only for entities the console reported (all possible ones until the first load finishes). */
export function getVariableDefinitions(inst) { export function getVariableDefinitions(inst) {
@@ -34,7 +38,7 @@ export function getVariableDefinitions(inst) {
} }
for (const ent of Object.keys(ENTITY_VARS)) { for (const ent of Object.keys(ENTITY_VARS)) {
for (const n of inst.state.numbers(ent)) { 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]}` } for (const field of ENTITY_VARS[ent]) defs[`${VAR_PREFIX[ent]}_${n}_${field}`] = { name: `${ENT_LABEL[ent]} ${n} ${VAR_LABEL[field]}` }
} }
} }
return defs return defs
@@ -45,7 +49,8 @@ const muteText = (m) => (m === undefined ? '' : m ? 'Muted' : 'Unmuted')
/** All current values of one entity, keyed by variable id. */ /** All current values of one entity, keyed by variable id. */
export function entityVariableValues(inst, ent, n) { export function entityVariableValues(inst, ent, n) {
const s = inst.state const s = inst.state
const p = `${ent}_${n}_` const p = `${VAR_PREFIX[ent]}_${n}_`
if (ent === 'minput') return { [`${p}name`]: s.displayName(ent, n) }
const out = { [`${p}name`]: s.displayName(ent, n), [`${p}fader`]: fmtDb(s.getFader(ent, n)), [`${p}mute`]: muteText(s.getMute(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 === 'input') out[`${p}pan`] = fmtPan(s.getPan(n))
if (ent === 'buss') { if (ent === 'buss') {
+24 -8
View File
@@ -3,7 +3,7 @@ import { ConsoleLink } from './console-link.js'
import { Relay } from './relay.js' import { Relay } from './relay.js'
import { decode, describe, foldPattern } from './osc.js' import { decode, describe, foldPattern } from './osc.js'
import { State } from './state.js' import { State } from './state.js'
import { GEQ_RANGE, LEVEL, PARAMS, K, addr, adjustedLevel, clamp, indexProblem, meterId, meterSubscription, parse, query, sameValue } from './protocol.js' import { GEQ_RANGE, LEVEL, PARAMS, K, addr, adjustedLevel, clamp, indexProblem, meterId, meterSubscription, outputMeterId, outputMeterSubscription, parse, query, sameValue } from './protocol.js'
import { getActionDefinitions } from './definitions/actions.js' import { getActionDefinitions } from './definitions/actions.js'
import { getFeedbackDefinitions, feedbackIdsForEvent, watchKey, watchKeyForEvent } from './definitions/feedbacks.js' import { getFeedbackDefinitions, feedbackIdsForEvent, watchKey, watchKeyForEvent } from './definitions/feedbacks.js'
import { getVariableDefinitions, getAllVariableValues, getEventVariableValues, describeChange } from './definitions/variables.js' import { getVariableDefinitions, getAllVariableValues, getEventVariableValues, describeChange } from './definitions/variables.js'
@@ -216,7 +216,7 @@ export default class DigicoS31Instance extends InstanceBase {
this.lastDefs = {} // JSON of what was last pushed, per kind of definition this.lastDefs = {} // JSON of what was last pushed, per kind of definition
this.definedVariables = new Set() this.definedVariables = new Set()
this.presetStats = null this.presetStats = null
this.meterWanted = new Map() // meter id -> { n, meter } this.meterWanted = new Map() // meter id -> { ent: 'input' | 'buss' | 'matrix', n, meter }
this.meterDirtyIds = new Set() this.meterDirtyIds = new Set()
this.peakUntil = new Map() // meter id -> time until which its peak hold is re-checked this.peakUntil = new Map() // meter id -> time until which its peak hold is re-checked
this.meterTimer = null this.meterTimer = null
@@ -1048,7 +1048,7 @@ export default class DigicoS31Instance extends InstanceBase {
const s = this.state.summary() const s = this.state.summary()
this.log( this.log(
'info', 'info',
`load complete: ${s.inputs} inputs, ${s.busses} busses (${s.aux} aux and ${s.group} group named), ${s.matrix} matrix outputs, ` + `load complete: ${s.inputs} inputs, ${s.busses} busses (${s.aux} aux and ${s.group} group named), ${s.matrix} matrix outputs, ${s.matrixIn} matrix inputs, ` +
`${s.cgs} control groups, ${s.geqs} graphic EQs, ${s.snapshots} snapshots`, `${s.cgs} control groups, ${s.geqs} graphic EQs, ${s.snapshots} snapshots`,
) )
if (!this.connectedFlag || s.total === 0) this.reloadOnReconnect = true if (!this.connectedFlag || s.total === 0) this.reloadOnReconnect = true
@@ -1121,7 +1121,8 @@ export default class DigicoS31Instance extends InstanceBase {
for (const [id, value] of ev.pairs) { for (const [id, value] of ev.pairs) {
if (!this.watchers.has(watchKey.meter(id))) continue if (!this.watchers.has(watchKey.meter(id))) continue
this.meterDirtyIds.add(id) this.meterDirtyIds.add(id)
if (value >= 127) this.peakUntil.set(id, now + this.timing.peakWatchMs) // within 20 dB of full scale on a level meter: a peak hold may be running (gain reduction meters have none)
if (value <= 20 && this.meterWanted.get(id)?.meter === 'level') this.peakUntil.set(id, now + this.timing.peakWatchMs)
} }
return return
} }
@@ -1138,6 +1139,7 @@ export default class DigicoS31Instance extends InstanceBase {
onStructural(ev) { onStructural(ev) {
if (ev.kind === 'bussTypes' && this.state.loaded) { if (ev.kind === 'bussTypes' && this.state.loaded) {
for (const n of this.state.existing('buss')) this.enqueue(this.state.bussValueQueries(n)) for (const n of this.state.existing('buss')) this.enqueue(this.state.bussValueQueries(n))
this.resubscribeMeters() // a buss meter lives on the buss's aux or group side
} }
this.scheduleRebuild() this.scheduleRebuild()
} }
@@ -1225,20 +1227,34 @@ export default class DigicoS31Instance extends InstanceBase {
// ---------- meters ---------- // ---------- meters ----------
/** The [address, args] that subscribes one wanted meter; a buss meter follows the buss's current aux / group side. */
meterSubscriptionFor({ ent, n, meter }) {
if (ent === 'input') return meterSubscription(n, meter)
return outputMeterSubscription(ent, n, this.sideOf(ent, n))
}
/** Subscribe to an input meter ('level', 'gate' or 'comp' gain reduction) the first time a meter feedback for it runs. */ /** Subscribe to an input meter ('level', 'gate' or 'comp' gain reduction) the first time a meter feedback for it runs. */
subscribeInputMeter(n, meter = 'level') { subscribeInputMeter(n, meter = 'level') {
const id = meterId(n, meter) this.subscribeMeter(meterId(n, meter), { ent: 'input', n, meter })
}
/** Subscribe to a buss ('buss') or matrix output ('matrix') level meter, lazily like the input meters. */
subscribeOutputMeter(ent, n) {
this.subscribeMeter(outputMeterId(ent, n), { ent, n, meter: 'level' })
}
subscribeMeter(id, entry) {
if (this.meterWanted.has(id)) return if (this.meterWanted.has(id)) return
const sub = meterSubscription(n, meter) const sub = this.meterSubscriptionFor(entry)
if (!this.addressOk(sub[0])) return if (!this.addressOk(sub[0])) return
this.meterWanted.set(id, { n, meter }) this.meterWanted.set(id, entry)
this.enqueue([sub]) this.enqueue([sub])
this.ensureMeterTimer() this.ensureMeterTimer()
} }
resubscribeMeters() { resubscribeMeters() {
if (!this.meterWanted.size) return if (!this.meterWanted.size) return
this.enqueue([...this.meterWanted.values()].map(({ n, meter }) => meterSubscription(n, meter))) this.enqueue([...this.meterWanted.values()].map((entry) => this.meterSubscriptionFor(entry)))
} }
scheduleMeterResubscribe() { scheduleMeterResubscribe() {
+57 -16
View File
@@ -30,8 +30,13 @@ export const GEQ_RANGE = { min: -12, max: 12 }
export const METER_BASE_ID = 5000 export const METER_BASE_ID = 5000
/** /**
* Input meters, subscribed by sending `<address> ,ii [id, kind]`. The console then streams * Meters are subscribed by sending `<address> ,ii [id, kind]`; the console then streams /Meters/values [id, value, ...]
* /Meters/values [id, value, ...] (0..127, only changed ids). There is no known buss meter address. * (integers 0..127, only changed ids). The scale is inverted and approximate (verified on an S31, not calibrated against
* a reference tone): a level meter value is about the dB below full scale, so 127 = silence / -inf and 0 = full scale;
* a gain reduction meter value is about the dB of reduction, 127 = inactive / no data.
*
* Input meters: level (post-fader), gate and compressor gain reduction. Output meters (aux, group and matrix outputs)
* are level meters. Control groups and matrix inputs have no meter address.
*/ */
export const METERS = { export const METERS = {
level: { label: 'Level (post-fader)', offset: 0, kind: 1, path: 'Channel_Input/post_meter' }, level: { label: 'Level (post-fader)', offset: 0, kind: 1, path: 'Channel_Input/post_meter' },
@@ -39,20 +44,52 @@ export const METERS = {
comp: { label: 'Compressor gain reduction', offset: 200, kind: 4, path: 'Dynamics/GR_meter' }, comp: { label: 'Compressor gain reduction', offset: 200, kind: 4, path: 'Dynamics/GR_meter' },
} }
/** Output meter ids: busses 5400 + N, matrix outputs 5450 + N (the address follows a buss's aux / group side). */
export const OUTPUT_METER_BASE = { buss: 5400, matrix: 5450 }
export function meterId(n, meter = 'level') { export function meterId(n, meter = 'level') {
return METER_BASE_ID + (METERS[meter] || METERS.level).offset + Number(n) return METER_BASE_ID + (METERS[meter] || METERS.level).offset + Number(n)
} }
export const meterIdForInput = (n) => meterId(n, 'level') export const meterIdForInput = (n) => meterId(n, 'level')
export const outputMeterId = (ent, n) => OUTPUT_METER_BASE[ent] + Number(n)
/** [address, args] that subscribes this module to one input meter. */ /** [address, args] that subscribes this module to one input meter. */
export function meterSubscription(n, meter = 'level') { export function meterSubscription(n, meter = 'level') {
const m = METERS[meter] || METERS.level const m = METERS[meter] || METERS.level
return [`/Input_Channels/${n}/${m.path}`, [{ i: meterId(n, meter) }, { i: m.kind }]] return [`/Input_Channels/${n}/${m.path}`, [{ i: meterId(n, meter) }, { i: m.kind }]]
} }
/** [address, args] for an output meter: ent 'buss' (side 'aux' | 'group') or 'matrix'. */
export function outputMeterSubscription(ent, n, side = 'aux') {
const tree = ent === 'matrix' ? 'Matrix_Outputs' : side === 'group' ? 'Group_Outputs' : 'Aux_Outputs'
return [`/${tree}/${n}/Output/meter`, [{ i: outputMeterId(ent, n) }, { i: 1 }]]
}
const meterNumber = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 127)
/** Level meter value -> dB below full scale (approximate): 127 (or no data) = -Infinity, 0 = 0 dBFS. */
export const meterLevelDb = (v) => (meterNumber(v) >= 127 ? -Infinity : -meterNumber(v) || 0)
/** Gain reduction meter value -> dB of reduction (approximate): 127 (or no data) means none, 0. */
export const meterGrDb = (v) => (meterNumber(v) >= 127 ? 0 : meterNumber(v))
/** Gauge position 0..100: levels over -60..0 dBFS (-inf = 0), gain reduction over 0..20 dB. */
export function meterPosition(v, kind = 'level') {
if (kind === 'level') return clamp(((meterLevelDb(v) + 60) / 60) * 100, 0, 100)
return clamp((meterGrDb(v) / 20) * 100, 0, 100)
}
/** Level meter text: "-42" or "-inf" (dBFS, approximate). */
export const fmtMeterLevel = (v) => (meterNumber(v) >= 127 ? '-inf' : String(-meterNumber(v) || 0))
/** A name as shown anywhere: whitespace runs (the surface pads and wraps names) collapse to one space, ends trimmed. */
export const cleanName = (name) => String(name ?? '').replace(/\s+/g, ' ').trim()
/** /**
* Per-channel switches and values the console answers on inputs and busses (trim also on matrix outputs). They are loaded lazily * Per-channel switches and values the console answers on inputs and busses (trim also on matrix outputs; trim and phase
* also on matrix inputs). They are loaded lazily
* (the first time an action or feedback needs one), never bulk-loaded. `paths` is per entity kind; * (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. * a buss uses the Aux_Outputs or Group_Outputs tree according to its mode.
* *
@@ -63,7 +100,7 @@ export function meterSubscription(n, meter = 'level') {
*/ */
export const PARAMS = { export const PARAMS = {
phantom: { type: 'switch', label: 'Phantom power (+48 V)', paths: { input: 'Channel_Input/phantom' } }, 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' } }, phase: { type: 'switch', label: 'Phase invert', paths: { input: 'Channel_Input/phase', buss: 'Buss_Trim/phase', minput: 'Channel_Input/phase' } },
pad: { type: 'switch', label: 'Input pad', paths: { input: 'Channel_Input/input_pad' } }, 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' } }, 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' } }, comp_in: { type: 'switch', label: 'Compressor in', paths: { input: 'Dynamics/comp_in', buss: 'Dynamics/comp_in' } },
@@ -74,13 +111,13 @@ export const PARAMS = {
insert_a: { type: 'switch', label: 'Insert A in', paths: { input: 'Insert/insert_A_in', buss: 'Insert/insert_A_in' } }, 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' } }, 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' } }, 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', minput: 'Channel_Input/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' } }, 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' } }, 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' } }, 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: {} } const PARAM_BY_PATH = { input: {}, buss: {}, matrix: {}, minput: {} }
for (const [param, p] of Object.entries(PARAMS)) for (const [ent, path] of Object.entries(p.paths)) PARAM_BY_PATH[ent][path] = param 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 = { export const COUNT_FIELDS = {
@@ -95,7 +132,7 @@ export const COUNT_FIELDS = {
Multis: 'multis', Multis: 'multis',
} }
const TREE = { input: 'Input_Channels', aux: 'Aux_Outputs', group: 'Group_Outputs', matrix: 'Matrix_Outputs', cg: 'Control_Groups' } const TREE = { input: 'Input_Channels', minput: 'Matrix_Inputs', aux: 'Aux_Outputs', group: 'Group_Outputs', matrix: 'Matrix_Outputs', cg: 'Control_Groups' }
/** ent: 'input' | 'buss' | 'matrix' | 'cg'; side matters for 'buss' only ('aux' | 'group'). */ /** ent: 'input' | 'buss' | 'matrix' | 'cg'; side matters for 'buss' only ('aux' | 'group'). */
function tree(ent, side) { function tree(ent, side) {
@@ -114,6 +151,7 @@ export const addr = {
name(ent, n, side) { name(ent, n, side) {
if (ent === 'input') return `/Input_Channels/${n}/Channel_Input/name` if (ent === 'input') return `/Input_Channels/${n}/Channel_Input/name`
if (ent === 'minput') return `/Matrix_Inputs/${n}/Channel_Input/name`
if (ent === 'buss') return `/${tree(ent, side)}/${n}/Buss_Trim/name` if (ent === 'buss') return `/${tree(ent, side)}/${n}/Buss_Trim/name`
if (ent === 'matrix') return `/Matrix_Outputs/${n}/Buss_Trim/name` if (ent === 'matrix') return `/Matrix_Outputs/${n}/Buss_Trim/name`
if (ent === 'cg') return `/Control_Groups/${n}/name` if (ent === 'cg') return `/Control_Groups/${n}/name`
@@ -168,16 +206,16 @@ const num = (v) => toNumber(v)
const str = (v) => (v === undefined || v === null ? '' : String(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_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_NAME = /^\/(?:(Input_Channels|Matrix_Inputs)\/(\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_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_PAN = /^\/Input_Channels\/(\d+)\/Panner\/pan$/
const RE_AUX_SEND = /^\/Input_Channels\/(\d+)\/Aux_Send\/(\d+)\/(send_level|send_on|send_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_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_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_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 RE_PARAM = /^\/(Input_Channels|Aux_Outputs|Group_Outputs|Matrix_Outputs|Matrix_Inputs)\/(\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'] } const ENT_BY_TREE = { Input_Channels: ['input'], Matrix_Inputs: ['minput'], 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 * Turn an inbound message into a normalised event, or null when it is not something the
@@ -196,13 +234,16 @@ export function parse(address, args = []) {
if (address === addr.inputModes) return { kind: 'inputModes', values: args.map(num) } if (address === addr.inputModes) return { kind: 'inputModes', values: args.map(num) }
if ((m = RE_NAME.exec(address))) { 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[1]) {
if (m[2]) { const ent = m[1] === 'Matrix_Inputs' ? 'minput' : 'input'
const [ent, side] = ENT_BY_TREE[m[2]] return { kind: 'name', ent, n: +m[2], value: str(args[0]), key: K.value(ent, +m[2], 'name') }
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]] if (m[3]) {
return { kind: 'name', ent, n: +m[5], value: str(args[0]), key: K.value(ent, +m[5], 'name') } const [ent, side] = ENT_BY_TREE[m[3]]
return { kind: 'name', ent, side, n: +m[4], value: str(args[0]), key: K.value(ent, +m[4], 'name', side) }
}
const [ent] = ENT_BY_TREE[m[5]]
return { kind: 'name', ent, n: +m[6], value: str(args[0]), key: K.value(ent, +m[6], 'name') }
} }
if ((m = RE_LEVEL.exec(address))) { if ((m = RE_LEVEL.exec(address))) {
+28 -10
View File
@@ -3,8 +3,7 @@
* choice builders and the query plans used to load it. * 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 * 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 * on the side matching the buss mode), matrix (matrix outputs), minput (matrix inputs), cg (control groups), geq (graphic EQs). All numbers are 1-based.
* 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 * 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 * module's own optimistic writes): it returns the parsed event annotated with
@@ -13,7 +12,7 @@
* stereo/mono flags alone never are) * stereo/mono flags alone never are)
*/ */
import { LIMITS, K, addr, query, parse, fmtCue } from './protocol.js' import { LIMITS, K, addr, query, parse, fmtCue, cleanName } from './protocol.js'
const EPS = 1e-4 const EPS = 1e-4
@@ -25,6 +24,10 @@ function setField(obj, field, value) {
return old !== value return old !== value
} }
function clampInt(v, lo, hi) {
return Math.min(hi, Math.max(lo, v))
}
function rec(map, key) { function rec(map, key) {
let r = map.get(key) let r = map.get(key)
if (!r) { if (!r) {
@@ -77,6 +80,9 @@ export class Buss {
} }
} }
/** Peak tracking keeps, per meter, the last time a reading was at most k steps below full scale, for k = 0..LOUD_STEPS. */
const LOUD_STEPS = 20
const FALLBACK_NAME = { input: 'Input', matrix: 'Matrix', cg: 'CG', geq: 'Graphic EQ', minput: 'Matrix In' } const FALLBACK_NAME = { input: 'Input', matrix: 'Matrix', cg: 'CG', geq: 'Graphic EQ', minput: 'Matrix In' }
export class State { export class State {
@@ -93,6 +99,7 @@ export class State {
this.inputs = new Map() this.inputs = new Map()
this.busses = new Map() this.busses = new Map()
this.matrix = new Map() this.matrix = new Map()
this.minputs = new Map() // matrix inputs: { name }
this.cgs = new Map() this.cgs = new Map()
this.geqs = new Map() this.geqs = new Map()
this.sends = new Map() // "ch:buss" -> { level, on, pan } this.sends = new Map() // "ch:buss" -> { level, on, pan }
@@ -100,7 +107,7 @@ export class State {
this.matrixSends = new Map() // "in:out" -> { level, on } this.matrixSends = new Map() // "in:out" -> { level, on }
this.params = new Map() // K.param(...) -> boolean | number (per-channel switches and values, loaded lazily) 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.snapshots = { current: undefined, count: undefined, list: new Map() } // index -> { name, cue }
this.meters = new Map() // meter id -> { value, at, peakAt } this.meters = new Map() // meter id -> { value, at, loud } (loud[k]: last time a reading was k steps below full scale or louder)
} }
// ---------- inbound ---------- // ---------- inbound ----------
@@ -112,6 +119,7 @@ export class State {
ev.structural = false ev.structural = false
switch (ev.kind) { switch (ev.kind) {
case 'consoleName': case 'consoleName':
ev.value = cleanName(ev.value)
ev.changed = setField(this.console, 'name', ev.value) ev.changed = setField(this.console, 'name', ev.value)
break break
case 'session': case 'session':
@@ -143,6 +151,7 @@ export class State {
}) })
break break
case 'name': case 'name':
ev.value = cleanName(ev.value) // the surface pads and wraps names: trim and collapse whitespace once, here
ev.changed = this.#applyName(ev) ev.changed = this.#applyName(ev)
ev.structural = ev.changed ev.structural = ev.changed
break break
@@ -189,6 +198,7 @@ export class State {
this.snapshots.count = ev.value this.snapshots.count = ev.value
break break
case 'snapName': { case 'snapName': {
ev.name = cleanName(ev.name)
const old = this.snapshots.list.get(ev.index) const old = this.snapshots.list.get(ev.index)
this.snapshots.list.set(ev.index, { name: ev.name, cue: ev.cue }) this.snapshots.list.set(ev.index, { name: ev.name, cue: ev.cue })
ev.changed = !old || old.name !== ev.name || old.cue !== ev.cue ev.changed = !old || old.name !== ev.name || old.cue !== ev.cue
@@ -198,10 +208,10 @@ export class State {
case 'meters': { case 'meters': {
const now = Date.now() const now = Date.now()
for (const [id, value] of ev.pairs) { for (const [id, value] of ev.pairs) {
const m = this.meters.get(id) || { value: 0, at: 0, peakAt: 0 } const m = this.meters.get(id) || { value: 127, at: 0, loud: new Float64Array(LOUD_STEPS + 1) }
m.value = value m.value = value
m.at = now m.at = now
if (value >= 127) m.peakAt = now for (let k = Math.max(0, value); k <= LOUD_STEPS; k++) m.loud[k] = now
this.meters.set(id, m) this.meters.set(id, m)
} }
ev.changed = ev.pairs.length > 0 ev.changed = ev.pairs.length > 0
@@ -254,6 +264,8 @@ export class State {
return this.inputs return this.inputs
case 'matrix': case 'matrix':
return this.matrix return this.matrix
case 'minput':
return this.minputs
case 'cg': case 'cg':
return this.cgs return this.cgs
case 'geq': case 'geq':
@@ -286,7 +298,7 @@ export class State {
} }
} }
/** An entity exists when its name answered (matrix inputs have no name: they exist while matrix outputs do). */ /** An entity exists when its name answered. */
has(ent, n) { has(ent, n) {
if (!(n >= 1 && n <= this.limit(ent))) return false if (!(n >= 1 && n <= this.limit(ent))) return false
switch (ent) { switch (ent) {
@@ -294,8 +306,6 @@ export class State {
const b = this.busses.get(n) const b = this.busses.get(n)
return !!b && (b.auxName !== undefined || b.groupName !== undefined) return !!b && (b.auxName !== undefined || b.groupName !== undefined)
} }
case 'minput':
return this.limit('matrix') > 0 && (this.counts.matrixIn !== undefined || this.matrix.size > 0)
default: default:
return this.#map(ent).get(n)?.name !== undefined return this.#map(ent).get(n)?.name !== undefined
} }
@@ -319,7 +329,6 @@ export class State {
/** The name as the console reports it (may be ""), or undefined while unknown. */ /** The name as the console reports it (may be ""), or undefined while unknown. */
rawName(ent, n) { rawName(ent, n) {
if (ent === 'buss') return this.busses.get(n)?.name if (ent === 'buss') return this.busses.get(n)?.name
if (ent === 'minput') return undefined
return this.#map(ent).get(n)?.name return this.#map(ent).get(n)?.name
} }
@@ -391,6 +400,13 @@ export class State {
return this.meters.get(id) return this.meters.get(id)
} }
/** Last time meter `id` read at or above `thresholdDb` dBFS (0 = never; thresholds from -20 to 0 are tracked). */
meterPeakAt(id, thresholdDb) {
const m = this.meters.get(id)
if (!m || !Number.isFinite(thresholdDb)) return 0
return m.loud[clampInt(Math.floor(-thresholdDb), 0, LOUD_STEPS)]
}
snapshotName(index) { snapshotName(index) {
return this.snapshots.list.get(index)?.name return this.snapshots.list.get(index)?.name
} }
@@ -458,6 +474,7 @@ export class State {
if (want('buss', n, 'group')) q.push(query(addr.name('buss', n, 'group'))) 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('matrix'); n++) if (want('matrix', n)) q.push(query(addr.name('matrix', n)))
for (let n = 1; n <= this.limit('minput'); n++) if (want('minput', n)) q.push(query(addr.name('minput', 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('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))) for (let n = 1; n <= this.limit('geq'); n++) if (want('geq', n)) q.push(query(addr.name('geq', n)))
return q return q
@@ -493,6 +510,7 @@ export class State {
aux: this.namedBusses('aux').length, aux: this.namedBusses('aux').length,
group: this.namedBusses('group').length, group: this.namedBusses('group').length,
matrix: found('matrix'), matrix: found('matrix'),
matrixIn: found('minput'),
cgs: found('cg'), cgs: found('cg'),
geqs: found('geq'), geqs: found('geq'),
snapshots: this.snapshots.list.size, snapshots: this.snapshots.list.size,
+115 -23
View File
@@ -20,7 +20,7 @@ import { ConsoleLink } from '../src/console-link.js'
import { Relay } from '../src/relay.js' import { Relay } from '../src/relay.js'
import { encode, decode, foldPattern } from '../src/osc.js' import { encode, decode, foldPattern } from '../src/osc.js'
import { State } from '../src/state.js' import { State } from '../src/state.js'
import { LEVEL, PARAMS, adjustedLevel, faderPosition, fmtCue, fmtDb, fmtPan, indexProblem, meterId, meterIdForInput, parse } from '../src/protocol.js' import { LEVEL, PARAMS, adjustedLevel, cleanName, faderPosition, fmtCue, fmtDb, fmtMeterLevel, fmtPan, indexProblem, meterGrDb, meterId, meterIdForInput, meterLevelDb, meterPosition, outputMeterId, parse } from '../src/protocol.js'
import { BYTES_PER_PIXEL, horizontalBar, verticalMeter, thicknessFor } from '../src/image.js' import { BYTES_PER_PIXEL, horizontalBar, verticalMeter, thicknessFor } from '../src/image.js'
import { MAX_PRESETS, MAX_PRESET_BYTES } from '../src/definitions/presets.js' import { MAX_PRESETS, MAX_PRESET_BYTES } from '../src/definitions/presets.js'
import { COLORS } from '../src/definitions/feedbacks.js' import { COLORS } from '../src/definitions/feedbacks.js'
@@ -298,6 +298,22 @@ check(typeof Instance === 'function' && Array.isArray(mainMod.UpgradeScripts), '
s.applyMessage('/Console/Aux_Outputs/types', [1, 1, 1, 1, 0, 1]) 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') check(s.bussMode(5) === 'group' && s.bussMode(6) === 'aux', 'the types array (1 = aux, 0 = group) overrides the name heuristic')
} }
{
// names arrive padded and wrapped: trimmed, whitespace runs collapsed, once, in the state
const s = new State()
check(cleanName(' LEAD V RESI \t') === 'LEAD V RESI' && cleanName('') === '' && cleanName(' ') === '' && cleanName(undefined) === '', 'cleanName trims and collapses whitespace runs')
const e1 = s.applyMessage('/Input_Channels/3/Channel_Input/name', ['MD RESI '])
s.applyMessage('/Matrix_Inputs/8/Channel_Input/name', ['Dante In 63 '])
s.applyMessage('/Control_Groups/2/name', [' MC & SPEAKER'])
s.applyMessage('/Snapshots/name', [0, 100, 0, ' Pre Service '])
s.applyMessage('/Console/Name', ['S31 AB '])
s.loaded = true
check(s.rawName('input', 3) === 'MD RESI' && e1.value === 'MD RESI' && e1.changed === true && s.rawName('minput', 8) === 'Dante In 63' && s.rawName('cg', 2) === 'MC & SPEAKER' && s.snapshotName(0) === 'Pre Service' && s.console.name === 'S31 AB', 'input, matrix input, control group, snapshot and console names are cleaned when they arrive')
check(s.applyMessage('/Input_Channels/3/Channel_Input/name', ['MD RESI']).changed === false && s.applyMessage('/Input_Channels/3/Channel_Input/name', ['MD RESI']).structural === false, 'a name that only differs in padding is no change (no rebuild)')
check(s.choices.inputs()[0].label === '3 · MD RESI' && s.choices.matrixInputs()[0].label === '8 · Dante In 63' && s.displayName('minput', 8) === 'Dante In 63' && s.displayName('minput', 3) === 'Matrix In 3', 'dropdown labels use the cleaned names; an unnamed matrix input reads "Matrix In N"')
s.applyMessage('/Matrix_Inputs/4/Channel_Input/name', [' '])
check(s.has('minput', 4) && s.rawName('minput', 4) === '' && s.displayName('minput', 4) === 'Matrix In 4', 'a blank matrix input name still exists and falls back to "Matrix In N"')
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 3. Mock console, the real instance, a fake iPad // 3. Mock console, the real instance, a fake iPad
@@ -361,9 +377,12 @@ check(S.busses.get(1).stereo === true && S.busses.get(4).stereo === false, 'buss
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(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.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(!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})`) const mIn = S.choices.matrixInputs()
check(mIn.length === 10 && mIn[0].label === '1 · TB' && mIn[1].label === '2 · Studio L' && mIn[6].label === '7 · Studio TB' && mIn[7].label === '8 · Dante In 63' && mIn[9].label === '10 · Matrix In 10', `matrix inputs have names: dropdown labels "N · name", padding and runs of spaces cleaned, "Matrix In N" when empty (${mIn.map((c) => c.label).join(' | ')})`)
check(bussChoices.find((c) => c.id === 17)?.label === '17 · Studio TB (aux)' && S.rawName('buss', 17) === 'Studio TB', 'a padded buss name ("Studio TB") shows with single spaces in the dropdown')
check(sent.slice(0, loadSent).some((m) => m.address === '/Matrix_Inputs/10/Channel_Input/name/?') && !sent.some((m) => /^\/Matrix_Inputs\/\d+\/(Buss_Trim\/name|name)\/\?$/.test(m.address)), 'matrix input names are queried at /Matrix_Inputs/N/Channel_Input/name')
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(!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.matrix.get(1)?.name === 'MAINS L' && S.has('minput', 1) && S.minputs.get(10).name === '' && S.numbers('minput').length === 10, 'matrix outputs and matrix inputs are named (input 10 has an empty name and still exists)')
check(S.inputs.get(7).stereo === true && S.inputs.get(1).stereo === false, 'input stereo flags come from /Console/Input_Channels/modes') 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') check(S.console.name === 'S31 AB' && S.console.session === 'Mock Session.session', 'console and session name loaded')
@@ -375,6 +394,8 @@ check(S.console.name === 'S31 AB' && S.console.session === 'Mock Session.session
/^\/Console\/Aux_Outputs\/types$/, // design: seen in a live iPad session, not probed in the catalog /^\/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 /^\/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 /^\/Input_Channels\/N\/(Channel_Input\/post_meter|Dynamics\/GR_meter)$/, // meter subscriptions captured from an iPad session
/^\/(Aux_Outputs|Group_Outputs|Matrix_Outputs)\/N\/Output\/meter$/, // output meter subscriptions (iPad app, confirmed live)
/^\/Matrix_Inputs\/N\/Channel_Input\/(name|trim|phase)$/, // matrix input names and params, verified live after the catalog was written
] ]
const offenders = new Set() const offenders = new Set()
const check1 = (a) => { const check1 = (a) => {
@@ -390,6 +411,7 @@ check(S.console.name === 'S31 AB' && S.console.session === 'Mock Session.session
// Variables // Variables
await sleep(80) await sleep(80)
const V = ctx.variables const V = ctx.variables
check(V.matrix_in_1_name === 'TB' && V.matrix_in_7_name === 'Studio TB' && V.matrix_in_10_name === 'Matrix In 10' && 'matrix_in_10_name' in ctx.variableDefs, 'matrix input name variables matrix_in_N_name: cleaned, with the Matrix In N fallback')
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.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(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(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')
@@ -416,6 +438,7 @@ check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs &&
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(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)') 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'] 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']
fbExpected.push('buss_signal', 'buss_peak', 'buss_meter_value', 'buss_meter_pos', 'matrix_signal', 'matrix_peak', 'matrix_meter_value', 'matrix_meter_pos')
fbExpected.push('input_fader_pos', 'buss_fader_pos', 'matrix_fader_pos', 'cg_fader_pos', 'send_level_pos', 'matrix_send_level_pos', 'input_pan_num', 'send_pan_num', 'geq_band_db', 'input_meter_pos') fbExpected.push('input_fader_pos', 'buss_fader_pos', 'matrix_fader_pos', 'cg_fader_pos', 'send_level_pos', 'matrix_send_level_pos', 'input_pan_num', 'send_pan_num', 'geq_band_db', 'input_meter_pos')
fbExpected.push('matrix_solo', 'cg_solo', 'matrix_send_on', 'geq_on', 'send_pan_value', 'matrix_send_level_value', 'geq_band_value', 'matrix_fader_bar', 'cg_fader_bar', 'matrix_send_level_bar', 'geq_band_bar') fbExpected.push('matrix_solo', 'cg_solo', 'matrix_send_on', 'geq_on', 'send_pan_value', 'matrix_send_level_value', 'geq_band_value', 'matrix_fader_bar', 'cg_fader_bar', 'matrix_send_level_bar', 'geq_band_bar')
for (const [param, p] of Object.entries(PARAMS)) for (const ent of Object.keys(p.paths)) fbExpected.push(`${ent}_${param}_${p.type === 'switch' ? 'state' : 'value'}`) for (const [param, p] of Object.entries(PARAMS)) for (const ent of Object.keys(p.paths)) fbExpected.push(`${ent}_${param}_${p.type === 'switch' ? 'state' : 'value'}`)
@@ -514,14 +537,14 @@ check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs &&
check(msendSections.length === 8 && msendSections[7].name === `Matrix sends to ${S.displayName('matrix', 8)}` && msendOn.length === 80 && msendKnob.length === 80, `a "Matrix sends to <output>" section per matrix output (${msendSections.length}: "${msendSections[7]?.name}") with ${msendOn.length} on/off and ${msendKnob.length} knob presets`) check(msendSections.length === 8 && msendSections[7].name === `Matrix sends to ${S.displayName('matrix', 8)}` && msendOn.length === 80 && msendKnob.length === 80, `a "Matrix sends to <output>" section per matrix output (${msendSections.length}: "${msendSections[7]?.name}") with ${msendOn.length} on/off and ${msendKnob.length} knob presets`)
const mOn = P.msend_on_1_8 const mOn = P.msend_on_1_8
const mKnob = P.msend_knob_1_8 const mKnob = P.msend_knob_1_8
check(mOn.style.text === 'Matrix In 1\\n$(local:level) dB' && mOn.localVariables[0].feedbackId === 'matrix_send_level_value' && JSON.stringify(mOn.localVariables[0].options) === JSON.stringify({ matrixIn: 1, matrixOut: 8 }) && mOn.steps[0].down[0].actionId === 'matrix_send_on' && mOn.steps[0].down[0].options.state === 'toggle' && mOn.feedbacks.some((f) => f.feedbackId === 'matrix_send_on' && f.style.bgcolor === COLORS_GREEN), 'matrix send on/off preset: toggle matrix_send_on, green when on, text from the level value feedback') check(mOn.style.text === '$(S31:matrix_in_1_name)\\n$(local:level) dB' && mOn.name === 'Matrix send on/off: TB → 8 · Studio' && mOn.localVariables[0].feedbackId === 'matrix_send_level_value' && JSON.stringify(mOn.localVariables[0].options) === JSON.stringify({ matrixIn: 1, matrixOut: 8 }) && mOn.steps[0].down[0].actionId === 'matrix_send_on' && mOn.steps[0].down[0].options.state === 'toggle' && mOn.feedbacks.some((f) => f.feedbackId === 'matrix_send_on' && f.style.bgcolor === COLORS_GREEN), 'matrix send on/off preset: toggle matrix_send_on, green when on, text from the level value feedback')
check(mKnob.steps[0].rotate_left[0].actionId === 'matrix_send_level_adjust' && mKnob.steps[0].rotate_left[0].options.delta === -1 && mKnob.steps[0].rotate_right[0].options.delta === 1 && mKnob.steps[0].down[0].actionId === 'matrix_send_on' && mKnob.type === 'layered' && mKnob.feedbacks[0].feedbackId === 'matrix_send_on' && mKnob.feedbacks[0].styleOverrides[0].elementId === 'bg' && mKnob.localVariables.map((v) => `${v.variableName}=${v.feedbackId}`).join() === 'level=matrix_send_level_value,pos=matrix_send_level_pos', 'matrix send knob preset (layered): rotate = matrix_send_level_adjust -1/+1, press toggles on/off, on tints the background, level and gauge position local variables') check(mKnob.steps[0].rotate_left[0].actionId === 'matrix_send_level_adjust' && mKnob.steps[0].rotate_left[0].options.delta === -1 && mKnob.steps[0].rotate_right[0].options.delta === 1 && mKnob.steps[0].down[0].actionId === 'matrix_send_on' && mKnob.type === 'layered' && mKnob.feedbacks[0].feedbackId === 'matrix_send_on' && mKnob.feedbacks[0].styleOverrides[0].elementId === 'bg' && mKnob.localVariables.map((v) => `${v.variableName}=${v.feedbackId}`).join() === 'level=matrix_send_level_value,pos=matrix_send_level_pos', 'matrix send knob preset (layered): rotate = matrix_send_level_adjust -1/+1, press toggles on/off, on tints the background, level and gauge position local variables')
check(!!P.geq_in_1 && P.geq_in_1.steps[0].down[0].actionId === 'geq_on' && P.geq_in_1.feedbacks[0].feedbackId === 'geq_on' && ids.filter((id) => id.startsWith('geq_in_')).length === 16, 'a Graphic EQs section with an in/out toggle and a geq_on feedback per graphic EQ') check(!!P.geq_in_1 && P.geq_in_1.steps[0].down[0].actionId === 'geq_on' && P.geq_in_1.feedbacks[0].feedbackId === 'geq_on' && ids.filter((id) => id.startsWith('geq_in_')).length === 16, 'a Graphic EQs section with an in/out toggle and a geq_on feedback per graphic EQ')
// layered presets: element types and required fields from the Companion graphics definitions // layered presets: element types and required fields from the Companion graphics definitions
const layered = ids.filter((id) => P[id].type === 'layered') const layered = ids.filter((id) => P[id].type === 'layered')
const knobsLayered = layered.filter((id) => /_knob_/.test(id)) const knobsLayered = layered.filter((id) => /_knob_/.test(id))
const meterPresets = layered.filter((id) => /^input_(meter|gate_gr|comp_gr)_/.test(id)) const meterPresets = layered.filter((id) => /^input_(meter|gate_gr|comp_gr)_/.test(id))
check(knobsLayered.length === ids.filter((id) => /_knob_/.test(id)).length && meterPresets.length === 180 && layered.length === knobsLayered.length + 180 && !ids.some((id) => /_(mute|solo|on)_/.test(id) && P[id].type === 'layered'), `every knob (${knobsLayered.length}) and meter (${meterPresets.length}) preset is layered; plain toggles stay simple`) check(knobsLayered.length === ids.filter((id) => /_knob_/.test(id)).length && meterPresets.length === 180 && layered.filter((id) => /^(buss|matrix)_meter_/.test(id)).length === 32 && layered.length === knobsLayered.length + 180 + 32 && !ids.some((id) => /_(mute|solo|on)_/.test(id) && P[id].type === 'layered'), `every knob (${knobsLayered.length}) and meter (${meterPresets.length} input + 32 buss / matrix output) preset is layered; plain toggles stay simple`)
const ELEMENT_TYPES = new Set(['group', 'composite', 'text', 'image', 'box', 'line', 'circle', 'gauge']) const ELEMENT_TYPES = new Set(['group', 'composite', 'text', 'image', 'box', 'line', 'circle', 'gauge'])
const LAYERED_PROPS = { box: ['id', 'type', 'x', 'y', 'width', 'height', 'color'], text: ['id', 'type', 'x', 'y', 'width', 'height', 'text', 'fontsize', 'fontsizeAllowShrink', 'color', 'halign', 'valign'], gauge: ['id', 'type', 'x', 'y', 'width', 'height', 'value', 'min', 'max', 'orientation', 'trackStyle', 'fillEnabled', 'multiColour', 'stops'] } const LAYERED_PROPS = { box: ['id', 'type', 'x', 'y', 'width', 'height', 'color'], text: ['id', 'type', 'x', 'y', 'width', 'height', 'text', 'fontsize', 'fontsizeAllowShrink', 'color', 'halign', 'valign'], gauge: ['id', 'type', 'x', 'y', 'width', 'height', 'value', 'min', 'max', 'orientation', 'trackStyle', 'fillEnabled', 'multiColour', 'stops'] }
const isExpr = (v) => !!v && typeof v === 'object' && v.isExpression === true && typeof v.value === 'string' const isExpr = (v) => !!v && typeof v === 'object' && v.isExpression === true && typeof v.value === 'string'
@@ -756,6 +779,13 @@ function expectWrite(list, address, tags, value, label) {
expectWrite(r, '/Group_Outputs/24/Buss_Trim/trim', ',f', 1, 'buss_trim_adjust from the replied 0') 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 }) 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') expectWrite(r, '/Matrix_Outputs/2/Buss_Trim/trim', ',f', 1.5, 'matrix_trim_set')
r = await run('minput_trim_set', { matrixIn: 3, value: -2 })
expectWrite(r, '/Matrix_Inputs/3/Channel_Input/trim', ',f', -2, 'minput_trim_set (matrix input trim, dB)')
r = await run('minput_trim_adjust', { matrixIn: 3, delta: 0.5 })
expectWrite(r, '/Matrix_Inputs/3/Channel_Input/trim', ',f', -1.5, 'minput_trim_adjust')
r = await run('minput_phase', { matrixIn: 3, state: 'toggle' })
check(r[0]?.address === '/Matrix_Inputs/3/Channel_Input/phase/?', 'minput_phase toggle on an unloaded switch queries first')
expectWrite(r, '/Matrix_Inputs/3/Channel_Input/phase', ',f', 1, 'minput_phase toggle writes a float 1 (the replied state was 0)')
r = await run('buss_eq_in', { buss: 3, state: 'toggle' }) 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 === ',f'), 'buss_eq_in toggle on an unloaded aux buss queries first') check(r[0]?.address === '/Aux_Outputs/3/EQ/eq_in/?' && writes(r).some((m) => m.address === '/Aux_Outputs/3/EQ/eq_in' && m.tags === ',f'), 'buss_eq_in toggle on an unloaded aux buss queries first')
r = await run('buss_insert_a', { buss: 9, state: 'on' }) r = await run('buss_insert_a', { buss: 9, state: 'on' })
@@ -894,6 +924,7 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb
await sleep(150) await sleep(150)
check(typeof cb('send_level_pos', { channel: 45, buss: 9 }) === 'number' && S.getSend(45, 9).level !== undefined && S.getMatrixSend(7, 6).level === -90 && cb('matrix_send_level_pos', { matrixIn: 7, matrixOut: 6 }) === 0 && typeof cb('send_pan_num', { channel: 45, buss: 9 }) === 'number' && cb('geq_band_db', { geq: 4, band: 9 }) === 0, 'once the state arrives the position feedbacks return the real values (a matrix send at -90 stays 0)') check(typeof cb('send_level_pos', { channel: 45, buss: 9 }) === 'number' && S.getSend(45, 9).level !== undefined && S.getMatrixSend(7, 6).level === -90 && cb('matrix_send_level_pos', { matrixIn: 7, matrixOut: 6 }) === 0 && typeof cb('send_pan_num', { channel: 45, buss: 9 }) === 'number' && cb('geq_band_db', { geq: 4, band: 9 }) === 0, 'once the state arrives the position feedbacks return the real values (a matrix send at -90 stays 0)')
check(cb('input_trim_value', { channel: 14 }) === '-3' && cb('input_gain_value', { channel: 14 }) === '34.5' && cb('input_hpf_freq_value', { channel: 14 }) === '88' && cb('buss_trim_value', { buss: 1 }) === '-2' && cb('matrix_trim_value', { matrix: 2 }) === '1.5', 'channel-strip value feedbacks: trim, gain, filter frequency (whole Hz), buss and matrix trim') check(cb('input_trim_value', { channel: 14 }) === '-3' && cb('input_gain_value', { channel: 14 }) === '34.5' && cb('input_hpf_freq_value', { channel: 14 }) === '88' && cb('buss_trim_value', { buss: 1 }) === '-2' && cb('matrix_trim_value', { matrix: 2 }) === '1.5', 'channel-strip value feedbacks: trim, gain, filter frequency (whole Hz), buss and matrix trim')
check(cb('minput_trim_value', { matrixIn: 3 }) === '-1.5' && cb('minput_phase_state', { matrixIn: 3 }) === true && cb('minput_phase_state', { matrixIn: 4 }) === false, 'matrix input trim text and phase feedback follow the writes')
check(cb('input_lpf_freq_value', { channel: 60 }) === '--' && (await waitFor(() => S.getParam('input', 60, 'lpf_freq') !== undefined, 2500)) && cb('input_lpf_freq_value', { channel: 60 }) === '12000', 'an unloaded channel-strip value reads "--" and loads lazily') check(cb('input_lpf_freq_value', { channel: 60 }) === '--' && (await waitFor(() => S.getParam('input', 60, 'lpf_freq') !== undefined, 2500)) && cb('input_lpf_freq_value', { channel: 60 }) === '12000', 'an unloaded channel-strip value reads "--" and loads lazily')
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(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') 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')
@@ -926,53 +957,98 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 7. Meters // 7. Meters (scale: a level value is about the dB below full scale, 127 = silence; gain reduction is dB, 127 = inactive)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
{ {
const id7 = meterIdForInput(7) const id7 = meterIdForInput(7)
check(id7 >= 5000 && meterIdForInput(60) === 5060, `meter ids start at 5000 (input 7 -> ${id7})`) check(id7 >= 5000 && meterIdForInput(60) === 5060 && outputMeterId('buss', 3) === 5403 && outputMeterId('matrix', 2) === 5452, `meter ids start at 5000 (input 7 -> ${id7}); output meters use 5400 + buss and 5450 + matrix output`)
// the scale helpers
check(meterLevelDb(42) === -42 && meterLevelDb(0) === 0 && meterLevelDb(127) === -Infinity && meterLevelDb(undefined) === -Infinity && meterGrDb(3) === 3 && meterGrDb(127) === 0 && meterGrDb(undefined) === 0, 'meterLevelDb: 127 = -inf, v = -v dBFS; meterGrDb: 127 = no reduction, v = v dB')
check(fmtMeterLevel(42) === '-42' && fmtMeterLevel(127) === '-inf' && fmtMeterLevel(0) === '0' && meterPosition(127) === 0 && meterPosition(60) === 0 && near(meterPosition(42), 30) && meterPosition(0) === 100 && near(meterPosition(6, 'gr'), 30) && meterPosition(127, 'gr') === 0 && meterPosition(30, 'gr') === 100, 'meter text and positions: -42 -> "-42" and 30 %, 0 -> 100 %, 127 -> "-inf" and 0 %, GR 6 dB -> 30 %')
const mark = sent.length const mark = sent.length
cb('input_meter_value', { channel: 7, unit: 'raw' }) cb('input_meter_value', { channel: 7 })
await sleep(80) await sleep(80)
const sub = sent.slice(mark).find((m) => m.address === '/Input_Channels/7/Channel_Input/post_meter') const sub = sent.slice(mark).find((m) => m.address === '/Input_Channels/7/Channel_Input/post_meter')
const subWire = sub && decodeArgs(sub) 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)}`) 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' }) cb('input_meter_value', { channel: 7 })
check(sent.slice(mark).filter((m) => m.address === '/Input_Channels/7/Channel_Input/post_meter').length === 1, 'a meter is subscribed only once') 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') check(await waitFor(() => S.getMeter(id7), 3000), 'the console streams /Meters/values for the subscribed id')
check(S.getMeter(id7).value === 127 && cb('input_meter_value', { channel: 7 }) === '-inf' && cb('input_meter_pos', { channel: 7 }) === 0 && cb('input_signal', { channel: 7 }) === false && cb('input_peak', { channel: 7 }) === false, 'a silent channel (127) reads -inf, position 0, no signal, no peak')
// a live channel streams 20-60 (the mock has inputs 1-3 live)
cb('input_meter_value', { channel: 2 })
check(await waitFor(() => S.getMeter(meterIdForInput(2)), 3000) && S.getMeter(meterIdForInput(2)).value >= 20 && S.getMeter(meterIdForInput(2)).value <= 60 && /^-(2\d|[3-5]\d|60)$/.test(cb('input_meter_value', { channel: 2 })) && cb('input_signal', { channel: 2, thresholdDb: -70 }) === true, 'a live channel streams 20-60: the text reads -20 .. -60 dBFS and signal is present')
{ {
const m2 = sent.length const m2 = sent.length
cb('input_meter_value', { channel: 7, meter: 'gate', unit: 'raw' }) cb('input_meter_value', { channel: 7, meter: 'gate' })
cb('input_peak', { channel: 7, meter: 'comp', hold: 1 }) cb('input_meter_pos', { channel: 7, meter: 'comp' })
await sleep(80) await sleep(80)
const gate = sent.slice(m2).find((m) => m.address === '/Input_Channels/7/Dynamics/GR_meter' && m.args[1].i === 1) 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) 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(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') check(await waitFor(() => S.getMeter(meterId(7, 'gate')) && S.getMeter(meterId(7, 'comp')), 3000), 'gain-reduction meters stream too')
const g = S.getMeter(meterId(7, 'gate')).value
check(g === 127 || (g >= 0 && g <= 12), `gain reduction streams 0-12 dB, or 127 when inactive (${g})`)
} }
const m = S.getMeter(id7) const m = S.getMeter(id7)
check(m.value >= 0 && m.value <= 127, `meter values are 0..127 (${m.value})`) check(m.value >= 0 && m.value <= 127, `meter values are 0..127 (${m.value})`)
// deterministic values injected as if from the console // 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 }) const inject = (value, id = id7) => inst.onConsoleMessage(decode(encode('/Meters/values', [{ i: id }, { i: value }])), { address: '127.0.0.1', port: P.mock })
inject(127) inject(127)
check(cb('input_meter_pos', { channel: 7, meter: 'level' }) === 100, 'input_meter_pos is 100 at full scale (127)') check(cb('input_meter_value', { channel: 7 }) === '-inf' && cb('input_meter_pos', { channel: 7 }) === 0 && cb('input_signal', { channel: 7 }) === false && cb('input_peak', { channel: 7 }) === false, 'silence (127): -inf, position 0 %, no signal, no peak')
inject(64) inject(0)
check(near(cb('input_meter_pos', { channel: 7 }), (64 / 127) * 100) && cb('input_meter_pos', { channel: 7, meter: 'comp' }) >= 0, 'input_meter_pos is 0..100 from 0..127 and takes the level / gate / comp option') check(cb('input_meter_value', { channel: 7 }) === '0' && cb('input_meter_pos', { channel: 7 }) === 100 && cb('input_signal', { channel: 7 }) === true && cb('input_peak', { channel: 7, hold: 2 }) === true, 'full scale (0): 0 dBFS, position 100 %, signal and peak')
inject(127) inject(42)
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_meter_value', { channel: 7 }) === '-42' && near(cb('input_meter_pos', { channel: 7 }), 30), 'a value of 42 reads "-42" and sits at 30 % of the -60..0 scale')
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') check(cb('input_signal', { channel: 7, thresholdDb: -50 }) === true && cb('input_signal', { channel: 7, thresholdDb: -40 }) === false, 'signal is a dBFS threshold: -42 is above -50 and below -40')
inject(3) check(cb('input_peak', { channel: 7, hold: 2 }) === true, 'the peak from a moment ago is still held after the level fell back')
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) await sleep(30)
check(cb('input_peak', { channel: 7, hold: 0 }) === false, 'the peak hold expires') check(cb('input_peak', { channel: 7, hold: 0 }) === false, 'the peak hold expires')
// the peak threshold is an option: 4 steps below full scale is a peak at -5 dBFS but not at -1
const id8 = meterIdForInput(8)
cb('input_meter_value', { channel: 8 })
await sleep(60)
inject(4, id8)
check(cb('input_peak', { channel: 8, peakDb: -5, hold: 2 }) === true && cb('input_peak', { channel: 8, peakDb: -1, hold: 2 }) === false && cb('input_peak', { channel: 8, hold: 2 }) === false, 'peak at or above a dBFS option: a reading of 4 peaks at -5 dBFS, not at the default -1')
// gain reduction meters
const gateId = meterId(7, 'gate')
inject(6, gateId)
check(cb('input_meter_value', { channel: 7, meter: 'gate' }) === '6' && near(cb('input_meter_pos', { channel: 7, meter: 'gate' }), 30) && cb('input_signal', { channel: 7, meter: 'gate' }) === true && cb('input_peak', { channel: 7, meter: 'gate' }) === false, 'gain reduction 6 dB reads "6", sits at 30 % of 0..20 dB and counts as signal; there is no peak')
inject(127, gateId)
check(cb('input_meter_value', { channel: 7, meter: 'gate' }) === '0' && cb('input_meter_pos', { channel: 7, meter: 'gate' }) === 0 && cb('input_signal', { channel: 7, meter: 'gate' }) === false, 'gain reduction 127 (inactive) reads "0", 0 % and no signal')
inject(64) inject(64)
const bar = cb('input_meter_bar', { channel: 7 }, { width: 72, height: 72 }) 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') check(Buffer.from(bar.imageBuffer, 'base64').length === thicknessFor(72) * 72 * 4 && bar.imageBufferPosition.x === 72 - thicknessFor(72), 'input_meter_bar (legacy) returns a right-edge strip of the right size')
// output meters: buss (the address follows the aux / group side) and matrix output
{
const m3 = sent.length
cb('buss_meter_value', { buss: 3 })
cb('buss_meter_pos', { buss: 9 })
cb('matrix_meter_value', { matrix: 2 })
cb('buss_meter_value', { buss: 3 })
await sleep(100)
const subs = sent.slice(m3).filter((x) => /\/Output\/meter$/.test(x.address)).map((x) => `${x.address} ${JSON.stringify(decodeArgs(x).args)}`)
check(subs.join('|') === '/Aux_Outputs/3/Output/meter [5403,1]|/Group_Outputs/9/Output/meter [5409,1]|/Matrix_Outputs/2/Output/meter [5452,1]', `output meters subscribe lazily and once: aux buss 3, group buss 9 (Group_Outputs side), matrix output 2 (${subs.join(' | ')})`)
check(await waitFor(() => S.getMeter(5403) && S.getMeter(5409) && S.getMeter(5452), 3000) && S.getMeter(5403).value === 127, 'output meters stream; a silent output reads 127')
cb('buss_meter_value', { buss: 1 })
check(await waitFor(() => S.getMeter(5401), 3000) && S.getMeter(5401).value >= 20 && S.getMeter(5401).value <= 60 && /^-(2\d|[3-5]\d|60)$/.test(cb('buss_meter_value', { buss: 1 })), 'a live output (aux buss 1) reads -20 .. -60 dBFS')
inject(30, 5403)
check(cb('buss_meter_value', { buss: 3 }) === '-30' && near(cb('buss_meter_pos', { buss: 3 }), 50) && cb('buss_signal', { buss: 3 }) === true && cb('buss_peak', { buss: 3 }) === false, 'buss meter: 30 -> "-30", 50 %, signal, no peak')
inject(1, 5403)
check(cb('buss_peak', { buss: 3 }) === true && cb('buss_signal', { buss: 3, thresholdDb: -0.5 }) === false, 'buss meter: a reading of 1 (-1 dBFS) peaks; signal needs to exceed its threshold')
inject(127, 5403)
check(cb('buss_meter_value', { buss: 3 }) === '-inf' && cb('buss_meter_pos', { buss: 3 }) === 0 && cb('buss_signal', { buss: 3 }) === false, 'buss meter: 127 is -inf, 0 %, no signal')
inject(50, 5452)
check(cb('matrix_meter_value', { matrix: 2 }) === '-50' && near(cb('matrix_meter_pos', { matrix: 2 }), (10 / 60) * 100) && cb('matrix_signal', { matrix: 2, thresholdDb: -60 }) === true && cb('matrix_signal', { matrix: 2 }) === false && cb('matrix_peak', { matrix: 2 }) === false, 'matrix output meter: 50 -> "-50", position over -60..0, signal against the dBFS option')
inject(0, 5452)
check(cb('matrix_peak', { matrix: 2 }) === true && cb('matrix_meter_pos', { matrix: 2 }) === 100, 'matrix output meter: 0 is full scale, peak and 100 %')
}
// throttling: a flood of meter messages produces at most ~10 feedback checks per second, by instance id // 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 watching7 = inst.watchers.get('meter:' + id7)
const gateInstance = `fb-input_meter_value-${JSON.stringify({ channel: 7, meter: 'gate', unit: 'raw' })}` const gateInstance = `fb-input_meter_value-${JSON.stringify({ channel: 7, meter: 'gate' })}`
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)`) 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 // 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') const raw = dgram.createSocket('udp4')
@@ -989,7 +1065,9 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb
const spanMs = Date.now() - t0 const spanMs = Date.now() - t0
const meterChecks = ctx.byId 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.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') // level meters with a peak hold still running (a live channel near full scale, the injected ones) are re-checked too; only meter instances ever are
const meterInstances = new Set([...inst.watchers].filter(([key]) => key.startsWith('meter:')).flatMap(([, ids]) => [...ids]))
check(meterChecks.every(([, ids]) => ids.every((x) => meterInstances.has(x))) && !meterChecks.some(([, ids]) => ids.includes(gateInstance)), 'a meter update re-checks only meter instances: those watching the changed id (and meters with a peak hold running), never the gain reduction one that did not move')
check(!ctx.checks.some(([, types]) => types.some((t) => /meter|signal|peak/.test(t))), `meter updates never fall back to per-type checks (per-type checks in that window: ${JSON.stringify(ctx.checks.map(([, t]) => t))}, from the keepalive tick)`) check(!ctx.checks.some(([, types]) => types.some((t) => /meter|signal|peak/.test(t))), `meter updates never fall back to per-type checks (per-type checks in that window: ${JSON.stringify(ctx.checks.map(([, t]) => t))}, from the keepalive tick)`)
const rebuildsBefore = ctx.rebuilds const rebuildsBefore = ctx.rebuilds
await sleep(200) await sleep(200)
@@ -1030,6 +1108,9 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb
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') 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') 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(decodeArgs(resub).args[0] === meterIdForInput(7), `resubscribed with the same id (${meterIdForInput(7)})`)
await sleep(200)
const reOut = sent.slice(mark).filter((m) => /\/Output\/meter$/.test(m.address)).map((m) => `${m.address} ${decodeArgs(m).args[0]}`)
check(['/Aux_Outputs/3/Output/meter 5403', '/Group_Outputs/9/Output/meter 5409', '/Matrix_Outputs/2/Output/meter 5452', '/Aux_Outputs/1/Output/meter 5401'].every((x) => reOut.includes(x)), `the output meters are re-subscribed too after /Meters/clear (${reOut.length} output subscriptions re-sent)`)
check(await waitFor(() => (S.getMeter(meterIdForInput(7))?.at ?? 0) > t0 + 250, 3000), 'meter values flow again after the resubscribe') 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') check(!sent.some((m) => m.address === '/Meters/clear'), 'still never sent /Meters/clear ourselves')
@@ -1400,6 +1481,17 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb
inst.onConsoleMessage(decode(encode('/Snapshots/name', [{ i: 8 }, { i: 900 }, { i: 0 }, 'Extra'])), { address: '127.0.0.1', port: P.mock }) 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) 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') 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')
// a matrix input name pushed by the console rebuilds the matrix send dropdowns, preset names and titles
const pm = { actions: ctx.pushes.actions, presets: ctx.pushes.presets }
inst.onConsoleMessage(decode(encode('/Matrix_Inputs/2/Channel_Input/name', [' New Name '])), { address: '127.0.0.1', port: P.mock })
await sleep(inst.timing.rebuildDebounceMs + 400)
const knob2 = ctx.presets.msend_knob_2_8
check(S.minputs.get(2).name === 'New Name' && ctx.pushes.actions === pm.actions + 1 && ctx.pushes.presets === pm.presets + 1, 'a matrix input name change rebuilds the definitions once')
check(ctx.actions.matrix_send_on.options[0].choices.find((c) => c.id === 2)?.label === '2 · New Name' && ctx.actions.minput_trim_set.options[0].choices.find((c) => c.id === 2)?.label === '2 · New Name' && ctx.feedbacks.matrix_send_on.options[0].choices.find((c) => c.id === 2)?.label === '2 · New Name', 'the matrix send and matrix input param dropdowns show the new name, cleaned')
check(ctx.variables.matrix_in_2_name === 'New Name' && knob2.name === `Matrix send level (knob): New Name → 8 · ${S.displayName('matrix', 8)}` && ctx.presets.msend_on_2_8.name.includes('New Name') && knob2.elements[1].text === '$(S31:matrix_in_2_name)' && ctx.presets.msend_on_2_8.style.text.startsWith('$(S31:matrix_in_2_name)'), 'the matrix send preset names and titles carry the new name (titles read the matrix_in_N_name variable)')
inst.onConsoleMessage(decode(encode('/Matrix_Inputs/2/Channel_Input/name', ['Studio L'])), { address: '127.0.0.1', port: P.mock })
await sleep(inst.timing.rebuildDebounceMs + 400)
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+11 -7
View File
@@ -68,7 +68,7 @@ inputs, `--auxes N` the busses (default 25), and the matrix (8 outputs, 10 input
| `/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/{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/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) | | `/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/N/Channel_Input/name/?`, `/Matrix_Inputs/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. Names are padded with trailing spaces and runs of spaces, as the surface reports them (matrix input names are read-only here) |
| `/{Input_Channels,Aux_Outputs,Group_Outputs,Matrix_Outputs,Control_Groups}/N/fader/?` | `f`, dB, -150 = -inf, max +10 | | `/{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); a SET must be a float too, see below | | `/{...}/N/mute/?`, `/{...}/N/solo/?` | `f` 0/1 (a float, not an int); a SET must be a float too, see below |
| `/Input_Channels/N/Panner/pan/?` | `f`, 0..1, 0.5 = centre | | `/Input_Channels/N/Panner/pan/?` | `f`, 0..1, 0.5 = centre |
@@ -78,17 +78,21 @@ inputs, `--auxes N` the busses (default 25), and the matrix (8 outputs, 10 input
| `/Matrix_Inputs/N/Matrix_Send/M/{send_level,send_on}/?` | `f` (default -90 and 0, captured). A matrix send floors at -90 dB (verified on an S31): level writes are clamped to -90..10, never -150 as for faders and aux sends | | `/Matrix_Inputs/N/Matrix_Send/M/{send_level,send_on}/?` | `f` (default -90 and 0, captured). A matrix send floors at -90 dB (verified on an S31): level writes are clamped to -90..10, never -150 as for faders and aux sends |
| `/Graphic_EQ/N/{geq_in,geq_gain_1..32}/?` | `f` | | `/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) | | `/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` | | `/{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/?`, `/Matrix_Inputs/N/Channel_Input/{trim,phase}/?` | `f` |
| `/Snapshots/Current_Snapshot/?`, `/Snapshots/count/?` | `i` | | `/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/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` | | `/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) | | `/Macros/Buttons/press [i]` | logged only (no macro query is answered) |
**Meters.** Subscribe with `/Input_Channels/N/Channel_Input/post_meter ,ii [id, 1]` (post-fader level) or **Meters.** Subscribe with `/Input_Channels/N/Channel_Input/post_meter ,ii [id, 1]` (post-fader level),
`/Input_Channels/N/Dynamics/GR_meter ,ii [id, 1|4]` (gate / compressor gain reduction), where the client `/Input_Channels/N/Dynamics/GR_meter ,ii [id, 1|4]` (gate / compressor gain reduction) or
chooses `id`. Every subscribed id then gets a random value 0..127 about five times a second, as `/{Aux_Outputs,Group_Outputs,Matrix_Outputs}/N/Output/meter ,ii [id, 1]` (output level), where the client chooses `id`.
`/Meters/values ,ii... [id, v, id, v, ...]` (only ids whose value changed). `/Meters/clear` drops every The scale is inverted, as on the real console (verified on an S31, approximate): a level meter reads about the dB
subscription, like the real console. below full scale, so **silence is 127** and the mock's live channels (inputs 1-3, aux 1, group 9, matrix output 1) read
20-60; a gain reduction meter reads the dB of reduction, 0-12, now and then 127 (inactive). Every subscribed id gets
its value about five times a second, as `/Meters/values ,ii... [id, v, id, v, ...]` (only ids whose value changed, so a
silent channel reports 127 once). `/Meters/clear` drops every subscription, like the real console. Control groups and
matrix inputs have no meter address.
A 0/1 switch (mute, solo, send on, group assign, matrix send on, `geq_in` and every channel-strip switch) is both A 0/1 switch (mute, solo, send on, group assign, matrix send on, `geq_in` and every channel-strip switch) is both
reported **and written as a float** 0.0 / 1.0, verified on a real S31 on 2026-10-15 (the 2014 DiGiCo list says reported **and written as a float** 0.0 / 1.0, verified on a real S31 on 2026-10-15 (the 2014 DiGiCo list says
+39 -14
View File
@@ -584,6 +584,8 @@ const SD_GROUP_NAMES = [
'', '', '', '', '', '', '', '', 'BAND', 'VOCALS', 'MISC', 'DRUMS', 'DRUM CRUSH', '', '', '', '', 'Broadcast mix', '', '', '', '', '', '', '', '', 'BAND', 'VOCALS', 'MISC', 'DRUMS', 'DRUM CRUSH', '', '', '', '', 'Broadcast mix',
'', '', '', '', 'CAFE', 'Studio', 'Master', '', '', '', '', 'CAFE', 'Studio', 'Master',
]; ];
/** Matrix input names as the console reports them: padded with trailing spaces, with runs of spaces inside (captured). */
const SD_MATRIX_IN_NAMES = ['TB ', 'Studio L', 'Studio R', 'Master L', 'Master R', 'GUIDE ', 'Studio TB ', 'Dante In 63 ', 'Dante In 64 ', ''];
const SD_MATRIX_NAMES = ['MAINS L', 'MAINS R', 'SUB L', 'SUB R', 'ProP LR', 'Matrix 6', 'Matrix 7', 'Studio']; 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', '']; 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]). */ /** Snapshot list: index n has cue number (n+1)*100 (reported as iiis [index, cue*100, 0, name]). */
@@ -644,6 +646,10 @@ const SD_PARAM_SPECS = {
matrix: { matrix: {
'Buss_Trim/trim': { spec: sdValue(-40, 40), def: 0 }, 'Buss_Trim/trim': { spec: sdValue(-40, 40), def: 0 },
}, },
minput: {
'Channel_Input/trim': { spec: sdValue(-40, 40), def: 0 },
'Channel_Input/phase': { spec: SD_SWITCH, def: 0 },
},
}; };
const SD_SEND_PROPS = { send_level: 'level', send_on: 'on', send_pan: 'pan', 'send_pre-post': 'prepost' }; const SD_SEND_PROPS = { send_level: 'level', send_on: 'on', send_pan: 'pan', 'send_pre-post': 'prepost' };
@@ -655,11 +661,13 @@ const sdInputs = [null]; // 1-based: { name, fader, mute, solo, pan, sends:[null
const sdAuxes = [null]; // 1-based: { name, fader, mute, solo } const sdAuxes = [null]; // 1-based: { name, fader, mute, solo }
const sdGroups = [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 sdMatrix = [null]; // 1-based matrix outputs: { name, fader, mute, solo }
const sdMatrixIn = [null]; // 1-based matrix inputs: { sends:[null,{level,on}...] } const sdMatrixIn = [null]; // 1-based matrix inputs: { name, sends:[null,{level,on}...] }
const sdCgs = [null]; // 1-based control groups: { name, fader, mute, solo } const sdCgs = [null]; // 1-based control groups: { name, fader, mute, solo }
const sdGeqs = [null]; // 1-based graphic EQs: { name, on, gains:[null, ...] } const sdGeqs = [null]; // 1-based graphic EQs: { name, on, gains:[null, ...] }
let sdSnapshot = 3; // /Snapshots/Current_Snapshot let sdSnapshot = 3; // /Snapshots/Current_Snapshot
const sdMeterSubs = new Map(); // meter id -> last value sent /** meter id -> { last, kind: 'level' | 'gr', live }: what was last sent and what to send. Silence is 127; live channels read 20-60. */
const sdMeterSubs = new Map();
const SD_LIVE_INPUTS = 3; // inputs 1-3 carry signal; aux 1, group 9 and matrix output 1 do too
let sdMeterTimer = null; let sdMeterTimer = null;
const SD_METER_INTERVAL_MS = 200; // ~5 Hz const SD_METER_INTERVAL_MS = 200; // ~5 Hz
@@ -705,7 +713,7 @@ function buildStateSd() {
for (let k = 0; k < SD_MATRIX_IN; k++) { for (let k = 0; k < SD_MATRIX_IN; k++) {
const sends = [null]; const sends = [null];
for (let m = 1; m <= SD_MATRIX_OUT; m++) sends.push({ level: -90, on: 0 }); // captured: -90 / 0 for (let m = 1; m <= SD_MATRIX_OUT; m++) sends.push({ level: -90, on: 0 }); // captured: -90 / 0
sdMatrixIn.push({ sends }); sdMatrixIn.push({ name: SD_MATRIX_IN_NAMES[k], 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 = 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++) { for (let k = 1; k <= SD_GEQS; k++) {
@@ -738,8 +746,9 @@ const SD_RE_MATRIX_SEND = /^\/Matrix_Inputs\/(\d+)\/Matrix_Send\/(\d+)\/(send_le
const SD_RE_CG_NAME = /^\/Control_Groups\/(\d+)\/name$/; const SD_RE_CG_NAME = /^\/Control_Groups\/(\d+)\/name$/;
const SD_RE_CG_PROP = /^\/Control_Groups\/(\d+)\/(fader|mute|solo)$/; 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_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_MINPUT_NAME = /^\/Matrix_Inputs\/(\d+)\/Channel_Input\/name$/;
const SD_RE_METER_SUB = /^\/Input_Channels\/(\d+)\/(?:Channel_Input\/post_meter|Dynamics\/GR_meter)$/; const SD_RE_PARAM = /^\/(Input_Channels|Aux_Outputs|Group_Outputs|Matrix_Outputs|Matrix_Inputs)\/(\d+)\/((?:Channel_Input|Filters|Channel_Delay|EQ|Dynamics|Insert|Buss_Trim)\/\w+)$/;
const SD_RE_METER_SUB = /^\/(Input_Channels|Aux_Outputs|Group_Outputs|Matrix_Outputs)\/(\d+)\/(Channel_Input\/post_meter|Dynamics\/GR_meter|Output\/meter)$/;
/** Map an SD leaf address to { target, prop, spec } 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) { function sdResolve(address) {
@@ -776,11 +785,13 @@ function sdResolve(address) {
const prop = fixedProp || m[2]; const prop = fixedProp || m[2];
return { target, prop, spec: SD_PROPS[prop] }; return { target, prop, spec: SD_PROPS[prop] };
} }
m = SD_RE_MINPUT_NAME.exec(address);
if (m) return sdMatrixIn[Number(m[1])] ? { target: sdMatrixIn[Number(m[1])], prop: 'name', spec: SD_PROPS.name } : null;
m = SD_RE_PARAM.exec(address); m = SD_RE_PARAM.exec(address);
if (m) { if (m) {
const kind = { Input_Channels: 'input', Matrix_Outputs: 'matrix' }[m[1]] || 'buss'; const kind = { Input_Channels: 'input', Matrix_Outputs: 'matrix', Matrix_Inputs: 'minput' }[m[1]] || 'buss';
const entry = SD_PARAM_SPECS[kind][m[3]]; 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])]; const owner = { Input_Channels: sdInputs, Aux_Outputs: sdAuxes, Group_Outputs: sdGroups, Matrix_Outputs: sdMatrix, Matrix_Inputs: sdMatrixIn }[m[1]][Number(m[2])];
if (!entry || !owner) return null; if (!entry || !owner) return null;
if (!owner.params) owner.params = {}; if (!owner.params) owner.params = {};
if (!(m[3] in owner.params)) owner.params[m[3]] = entry.def; if (!(m[3] in owner.params)) owner.params[m[3]] = entry.def;
@@ -888,8 +899,16 @@ function sdSet(address, args, types) {
} }
const sub = SD_RE_METER_SUB.exec(address); const sub = SD_RE_METER_SUB.exec(address);
if (sub) { if (sub) {
if (!sdInputs[Number(sub[1])] || typeof args[0] !== 'number') return ignore(`meter subscription ${address} ${formatArgs(args)}`); const [, tree, numText, path] = sub;
sdMeterSubs.set(Math.trunc(args[0]), -1); const owner = { Input_Channels: sdInputs, Aux_Outputs: sdAuxes, Group_Outputs: sdGroups, Matrix_Outputs: sdMatrix }[tree];
const input = tree === 'Input_Channels';
if (!owner[Number(numText)] || (input && path === 'Output/meter') || (!input && path !== 'Output/meter') || typeof args[0] !== 'number') {
return ignore(`meter subscription ${address} ${formatArgs(args)}`);
}
const num = Number(numText);
const kind = path === 'Dynamics/GR_meter' ? 'gr' : 'level';
const live = kind === 'level' && ((input && num <= SD_LIVE_INPUTS) || (tree === 'Aux_Outputs' && num === 1) || (tree === 'Group_Outputs' && num === 9) || (tree === 'Matrix_Outputs' && num === 1));
sdMeterSubs.set(Math.trunc(args[0]), { last: -1, kind, live });
return undefined; return undefined;
} }
@@ -906,14 +925,20 @@ function sdSet(address, args, types) {
return undefined; return undefined;
} }
/** ~5 Hz: random 0..127 for every subscribed id whose value changed, as /Meters/values [id, v, id, v ...]. */ /**
* ~5 Hz: /Meters/values [id, v, id, v ...] for every subscribed id whose value changed. The scale is inverted, as on the
* real console: a level meter reads about the dB below full scale, so silence is 127 and live channels read 20-60;
* a gain reduction meter reads the dB of reduction (0-12, now and then 127 = inactive).
*/
function sdMeterTick() { function sdMeterTick() {
if (!sdMeterSubs.size || !replyTarget) return; if (!sdMeterSubs.size || !replyTarget) return;
const pairs = []; const pairs = [];
for (const [id, last] of sdMeterSubs) { for (const [id, meter] of sdMeterSubs) {
const v = Math.random() < 0.15 ? 0 : Math.floor(Math.random() * 128); let v;
if (v === last) continue; if (meter.kind === 'gr') v = Math.random() < 0.1 ? 127 : Math.floor(Math.random() * 13);
sdMeterSubs.set(id, v); else v = meter.live ? 20 + Math.floor(Math.random() * 41) : 127;
if (v === meter.last) continue;
meter.last = v;
pairs.push(int(id), int(v)); pairs.push(int(id), int(v));
} }
for (let i = 0; i < pairs.length; i += 40) send('/Meters/values', pairs.slice(i, i + 40)); for (let i = 0; i < pairs.length; i += 40) send('/Meters/values', pairs.slice(i, i + 40));