From 52777f3d7c34e606b11e70f3e10c984f4ed963da Mon Sep 17 00:00:00 2001 From: Waters Church Date: Tue, 20 Oct 2026 10:25:39 -0400 Subject: [PATCH] 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 --- companion/HELP.md | 67 +++++++++++------ src/definitions/actions.js | 9 ++- src/definitions/feedbacks.js | 85 ++++++++++++--------- src/definitions/presets.js | 64 +++++++++++++--- src/definitions/variables.js | 11 ++- src/main.js | 32 ++++++-- src/protocol.js | 73 ++++++++++++++---- src/state.js | 38 +++++++--- test/selftest.js | 138 +++++++++++++++++++++++++++++------ tools/README.md | 18 +++-- tools/mock-s31.js | 53 ++++++++++---- 11 files changed, 436 insertions(+), 152 deletions(-) diff --git a/companion/HELP.md b/companion/HELP.md index dbbf5eb..c20a427 100644 --- a/companion/HELP.md +++ b/companion/HELP.md @@ -6,7 +6,7 @@ This connection does two things: matrix outputs, control groups, graphic EQs, snapshots) and builds its dropdowns, variables and presets from the live names. Faders, mutes, solos, pans, aux sends, group assigns, matrix sends, control groups, graphic EQs, per-channel switches (phantom, EQ in, filters, inserts ...) and - snapshots are covered, with feedbacks, variables, rotary-encoder presets and input meters. + 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 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 @@ -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 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 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. 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. @@ -89,7 +92,7 @@ is not known yet. | Matrix | output mute, fader set/adjust; matrix send on/off, level set/adjust | | Control groups | mute, solo, fader set/adjust | | Graphic EQ | in/out, band gain set/adjust (bands 1-32) | -| Channel strip | per input and per buss: phantom, phase, pad, EQ in, compressor in, gate in, high-pass in, low-pass in, delay in, insert A/B in (on/off); trim (-40 to +40 dB), analog gain (0 to 60 dB), filter frequencies (20 to 20000 Hz) (set/adjust; trim also on matrix outputs) | +| 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 | | 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 / 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 - (phantom, phase, pad, EQ in, compressor in, gate in, high-pass in, low-pass in, delay in, insert A/B in); and the meter - feedbacks below. + (phantom, phase, pad, EQ in, compressor in, gate in, high-pass in, low-pass in, delay in, insert A/B in; phase also + 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 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 - read `-inf` at or below -90 dB. + filter frequency per input and buss (trim also per matrix output and matrix input); current snapshot name; meter level + 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 `geq_band_db`. * 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_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) | -| `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 -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 -`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`, `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. @@ -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) | | 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 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 | | 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) | +| Buss meters, Matrix meters | One level meter button per named buss and per matrix output, same vertical gauge | | Console | Reload, connection status, relay status | Busses and control groups with no name are unused slots and get no presets. If the preset list would exceed -3000 entries or 4 MB, the per-input 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. ## Meters -Input meters are subscribed on the console the first time a meter feedback renders (ids start at 5000 so -they never collide with an iPad's). The console then streams values 0-127 several times a second; the -module re-checks the meter feedbacks at most 10 times a second. Level is the post-fader input meter; gate and -compressor gain reduction are the two reduction meters. There is no known buss meter address, so busses -have no meters. The console keeps one subscription table: an iPad connecting clears it (`/Meters/clear`), -and the module re-subscribes half a second after it sees that pass through the relay. Two iPads watching -different banks both receive the mixed stream. The module never sends `/Meters/clear` itself. +Meters are subscribed on the console the first time a meter feedback renders (ids start at 5000, so they never collide +with an iPad's: input meters 5000-5260, buss meters 5400 + N, matrix output meters 5450 + N). The console then streams +integer values 0-127 several times a second, only for the ones that changed, and the module re-checks the meter +feedbacks at most 10 times a second, and only the buttons showing the meter that moved. + +**The scale is inverted and approximate.** A level meter value is about the **dB below full scale**: 0 = full scale, +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 @@ -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,Group_Outputs,Input_Channels}/modes` | i per buss / input | 2 = stereo, 1 = mono | | `/Console/Session/Filename` | s | | -| `/Input_Channels/N/Channel_Input/name`, `/Aux_Outputs/N/Buss_Trim/name`, `/Group_Outputs/N/Buss_Trim/name`, `/Matrix_Outputs/N/Buss_Trim/name`, `/Control_Groups/N/name`, `/Graphic_EQ/N/name` | s | matrix inputs have no name address | +| `/Input_Channels/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/N/Panner/pan` | f | 0..1, 0.5 = centre | | `/Input_Channels/N/Aux_Send/M/{send_level,send_on,send_pan}` | f | | | `/Input_Channels/N/Group_Send/M/group` | f | | -| `/Matrix_Inputs/N/Matrix_Send/M/{send_level,send_on}` | f | | +| `/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 | | | `/Input_Channels/N/{Channel_Input,Filters,Channel_Delay,EQ,Dynamics,Insert}/...` and the buss equivalents | f | the channel-strip switches and values above | | `/Snapshots/Current_Snapshot`, `/Snapshots/count` | i | | | `/Snapshots/names` | one `/Snapshots/name` `iiis` per snapshot: index, cue x 100, ?, name | | | `/Snapshots/Fire_Snapshot_number`, `/Snapshots/Fire_Next_Snapshot`, `/Snapshots/Fire_Prev_Snapshot` | none | write-only int | -| `/Input_Channels/N/Channel_Input/post_meter`, `/Input_Channels/N/Dynamics/GR_meter` | none | send `,ii [id, kind]` (kind 1 level and gate, 4 compressor) to subscribe | -| `/Meters/values` | `ii...` | pushed pairs `[id, value]`, value 0..127 | +| `/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: inverted scale, 127 = silence / inactive | | `/Meters/clear` | none | clears every subscription on the console; sent by iPad apps, never by this module | diff --git a/src/definitions/actions.js b/src/definitions/actions.js index accaee8..130369d 100644 --- a/src/definitions/actions.js +++ b/src/definitions/actions.js @@ -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) -/** 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) /** 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). */ function getParamActions(inst) { @@ -64,7 +67,7 @@ function getParamActions(inst) { for (const [param, p] of Object.entries(PARAMS)) { for (const ent of paramEntities(param)) { const entOpt = paramEntityOption(inst, ent) - const who = { input: 'Input', buss: 'Buss', matrix: 'Matrix output' }[ent] + const who = ENTITY_TITLE[ent] const target = (options) => Number(options[entOpt.id]) if (p.type === 'switch') { out[`${ent}_${param}`] = { diff --git a/src/definitions/feedbacks.js b/src/definitions/feedbacks.js index 1cbf553..e323d70 100644 --- a/src/definitions/feedbacks.js +++ b/src/definitions/feedbacks.js @@ -1,7 +1,7 @@ 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 { opt, paramEntities, paramEntityOption } from './actions.js' +import { ENTITY_TITLE, opt, paramEntities, paramEntityOption } from './actions.js' export const COLORS = { white: combineRgb(255, 255, 255), @@ -111,6 +111,32 @@ export function getFeedbackDefinitions(inst) { inst.subscribeInputMeter(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 sendKey = (o) => watchKey.send(n(o.channel), n(o.buss)) 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)) { const entOption = paramEntityOption(inst, ent) 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], entityKey(ent, entOption.id), ({ options }) => { @@ -149,7 +175,7 @@ export function getFeedbackDefinitions(inst) { const style = param === 'phantom' ? RED : GREEN const entOption = paramEntityOption(inst, ent) paramFeedbacks[`${ent}_${param}_state`] = bool( - `${{ input: 'Input', buss: 'Buss', matrix: 'Matrix output' }[ent]}: ${p.label} is on`, + `${ENTITY_TITLE[ent]}: ${p.label} is on`, style, [entOption], 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), input_signal: bool( - 'Input meter: signal present', + 'Input meter: signal present (level above a dBFS threshold; gain reduction meters: any reduction)', GREEN, - [opt.channel(inst), meterOption, { type: 'number', id: 'threshold', label: 'Signal above (0-127)', default: 5, min: 0, max: 127, step: 1 }], - meterKey, - ({ options }) => (meterOf(options)?.value ?? 0) > n(options.threshold), - ), - input_peak: bool( - 'Input meter: peak (full scale) hold', - RED, - [opt.channel(inst), meterOption, { type: 'number', id: 'hold', label: 'Hold (seconds)', default: 2, min: 0, max: 10, step: 0.5 }], + [opt.channel(inst), meterOption, signalOption], meterKey, ({ 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_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 }), 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) ?? '--'), input_meter_value: value( - 'Input meter: value', - [ - opt.channel(inst), - meterOption, - { - type: 'dropdown', - id: 'unit', - label: 'Unit', - default: 'raw', - choices: [ - { id: 'raw', label: 'Raw (0-127)' }, - { id: 'percent', label: 'Percent (0-100)' }, - ], - }, - ], + 'Input meter: value text (level in dBFS, approx., -inf = silence; gain reduction in dB)', + [opt.channel(inst), meterOption], meterKey, - ({ options }) => { - const v = meterOf(options)?.value ?? 0 - return options.unit === 'percent' ? Math.round((v / 127) * 100) : v - }, + ({ options }) => meterText(meterOf(options), isGr(options)), ), 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) => { const m = meterOf(fb.options) 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, }, diff --git a/src/definitions/presets.js b/src/definitions/presets.js index 174724c..7e142c4 100644 --- a/src/definitions/presets.js +++ b/src/definitions/presets.js @@ -1,7 +1,8 @@ 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). */ -export const MAX_PRESETS = 3000 +export const MAX_PRESETS = 3200 export const MAX_PRESET_BYTES = 4 * 1024 * 1024 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 presets = {} 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 simple = (name, text, down, feedbacks = [], extra = {}) => ({ @@ -86,8 +87,11 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {}) feedbacks, 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', name, elements: [ @@ -115,8 +119,8 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {}) }, ], steps: [{ down: [], up: [] }], - feedbacks: [tint('input_signal', { ...options, threshold: 5 }, SIGNAL_BG), tint('input_peak', { ...options, hold: 2 }, MUTED_RED)], - localVariables: [localFeedback('pos', 'input_meter_pos', options)], + feedbacks: [tint(signal[0], signal[1], SIGNAL_BG), ...(peak ? [tint(peak[0], peak[1], MUTED_RED)] : [])], + localVariables: [localFeedback('pos', posFeedback[0], posFeedback[1])], }) 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'], ]) { 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}`) } muteIds.push(`input_mute_${n}`) @@ -290,14 +302,14 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {}) const onIds = [] const knobIds = [] for (const inN of s.numbers('minput')) { - const tag = `Matrix In ${inN} → ${out} · ${oname}` - const text = `Matrix In ${inN}\\n$(local:level) dB` + const tag = `${s.displayName('minput', inN)} → ${out} · ${oname}` + const text = `${nameVar('minput', inN)}\\n$(local:level) dB` const options = { matrixIn: inN, matrixOut: out } 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_knob_${inN}_${out}`] = layeredKnob({ name: `Matrix send level (knob): ${tag}`, - title: `Matrix In ${inN}`, + title: nameVar('minput', inN), press: [act('matrix_send_on', { ...options, state: 'toggle' })], adjust: ['matrix_send_level_adjust', 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 { const ids = [] diff --git a/src/definitions/variables.js b/src/definitions/variables.js index 456e623..ea4f4af 100644 --- a/src/definitions/variables.js +++ b/src/definitions/variables.js @@ -5,8 +5,12 @@ const ENTITY_VARS = { buss: ['name', 'mode', 'stereo', 'fader', 'mute'], matrix: ['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 = { name: 'name', fader: 'fader level (dB)', @@ -16,7 +20,7 @@ const VAR_LABEL = { 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). */ export function getVariableDefinitions(inst) { @@ -34,7 +38,7 @@ export function getVariableDefinitions(inst) { } for (const ent of Object.keys(ENTITY_VARS)) { for (const n of inst.state.numbers(ent)) { - for (const field of ENTITY_VARS[ent]) defs[`${ent}_${n}_${field}`] = { name: `${ENT_LABEL[ent]} ${n} ${VAR_LABEL[field]}` } + for (const field of ENTITY_VARS[ent]) defs[`${VAR_PREFIX[ent]}_${n}_${field}`] = { name: `${ENT_LABEL[ent]} ${n} ${VAR_LABEL[field]}` } } } return defs @@ -45,7 +49,8 @@ const muteText = (m) => (m === undefined ? '' : m ? 'Muted' : 'Unmuted') /** All current values of one entity, keyed by variable id. */ export function entityVariableValues(inst, ent, n) { const s = inst.state - const p = `${ent}_${n}_` + const 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)) } if (ent === 'input') out[`${p}pan`] = fmtPan(s.getPan(n)) if (ent === 'buss') { diff --git a/src/main.js b/src/main.js index bd5f50d..5a9a5ce 100644 --- a/src/main.js +++ b/src/main.js @@ -3,7 +3,7 @@ import { ConsoleLink } from './console-link.js' import { Relay } from './relay.js' import { decode, describe, foldPattern } from './osc.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 { getFeedbackDefinitions, feedbackIdsForEvent, watchKey, watchKeyForEvent } from './definitions/feedbacks.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.definedVariables = new Set() 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.peakUntil = new Map() // meter id -> time until which its peak hold is re-checked this.meterTimer = null @@ -1048,7 +1048,7 @@ export default class DigicoS31Instance extends InstanceBase { const s = this.state.summary() this.log( 'info', - `load complete: ${s.inputs} inputs, ${s.busses} busses (${s.aux} aux and ${s.group} group named), ${s.matrix} matrix outputs, ` + + `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`, ) 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) { if (!this.watchers.has(watchKey.meter(id))) continue 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 } @@ -1138,6 +1139,7 @@ export default class DigicoS31Instance extends InstanceBase { onStructural(ev) { if (ev.kind === 'bussTypes' && this.state.loaded) { 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() } @@ -1225,20 +1227,34 @@ export default class DigicoS31Instance extends InstanceBase { // ---------- 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. */ 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 - const sub = meterSubscription(n, meter) + const sub = this.meterSubscriptionFor(entry) if (!this.addressOk(sub[0])) return - this.meterWanted.set(id, { n, meter }) + this.meterWanted.set(id, entry) this.enqueue([sub]) this.ensureMeterTimer() } resubscribeMeters() { 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() { diff --git a/src/protocol.js b/src/protocol.js index 24e09ef..bc0e166 100644 --- a/src/protocol.js +++ b/src/protocol.js @@ -30,8 +30,13 @@ export const GEQ_RANGE = { min: -12, max: 12 } export const METER_BASE_ID = 5000 /** - * Input meters, subscribed by sending `
,ii [id, kind]`. The console then streams - * /Meters/values [id, value, ...] (0..127, only changed ids). There is no known buss meter address. + * Meters are subscribed by sending `
,ii [id, kind]`; the console then streams /Meters/values [id, value, ...] + * (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 = { 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' }, } +/** 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') { return METER_BASE_ID + (METERS[meter] || METERS.level).offset + Number(n) } 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. */ export function meterSubscription(n, meter = 'level') { const m = METERS[meter] || METERS.level return [`/Input_Channels/${n}/${m.path}`, [{ i: meterId(n, meter) }, { i: m.kind }]] } +/** [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; * 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 = { 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' } }, 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' } }, @@ -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_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' } }, hpf_freq: { type: 'value', label: 'High-pass filter frequency', unit: 'Hz', min: 20, max: 20000, step: 1, delta: 5, adjust: 'percent', paths: { input: 'Filters/lo_filter_freq' } }, lpf_freq: { type: 'value', label: 'Low-pass filter frequency', unit: 'Hz', min: 20, max: 20000, step: 1, delta: 5, adjust: 'percent', paths: { input: 'Filters/hi_filter_freq' } }, } -const PARAM_BY_PATH = { input: {}, buss: {}, matrix: {} } +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 export const COUNT_FIELDS = { @@ -95,7 +132,7 @@ export const COUNT_FIELDS = { 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'). */ function tree(ent, side) { @@ -114,6 +151,7 @@ export const addr = { name(ent, n, side) { 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 === 'matrix') return `/Matrix_Outputs/${n}/Buss_Trim/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 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_PAN = /^\/Input_Channels\/(\d+)\/Panner\/pan$/ const RE_AUX_SEND = /^\/Input_Channels\/(\d+)\/Aux_Send\/(\d+)\/(send_level|send_on|send_pan)$/ const RE_GROUP_SEND = /^\/Input_Channels\/(\d+)\/Group_Send\/(\d+)\/group$/ const RE_MATRIX_SEND = /^\/Matrix_Inputs\/(\d+)\/Matrix_Send\/(\d+)\/(send_level|send_on)$/ const RE_GEQ = /^\/Graphic_EQ\/(\d+)\/(geq_in|geq_gain_(\d+))$/ -const RE_PARAM = /^\/(Input_Channels|Aux_Outputs|Group_Outputs|Matrix_Outputs)\/(\d+)\/((?:Channel_Input|Filters|Channel_Delay|EQ|Dynamics|Insert|Buss_Trim)\/[\w-]+)$/ +const 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 @@ -196,13 +234,16 @@ export function parse(address, args = []) { if (address === addr.inputModes) return { kind: 'inputModes', values: args.map(num) } if ((m = RE_NAME.exec(address))) { - if (m[1]) return { kind: 'name', ent: 'input', n: +m[1], value: str(args[0]), key: K.value('input', +m[1], 'name') } - if (m[2]) { - const [ent, side] = ENT_BY_TREE[m[2]] - return { kind: 'name', ent, side, n: +m[3], value: str(args[0]), key: K.value(ent, +m[3], 'name', side) } + if (m[1]) { + const ent = m[1] === 'Matrix_Inputs' ? 'minput' : 'input' + return { kind: 'name', ent, n: +m[2], value: str(args[0]), key: K.value(ent, +m[2], 'name') } } - const [ent] = ENT_BY_TREE[m[4]] - return { kind: 'name', ent, n: +m[5], value: str(args[0]), key: K.value(ent, +m[5], 'name') } + if (m[3]) { + 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))) { diff --git a/src/state.js b/src/state.js index 6083a21..bd4319c 100644 --- a/src/state.js +++ b/src/state.js @@ -3,8 +3,7 @@ * choice builders and the query plans used to load it. * * Entities: input, buss (aux and group are two views of the same 25 busses, named and levelled - * on the side matching the buss mode), matrix (matrix outputs), minput (matrix inputs, no - * name address), cg (control groups), geq (graphic EQs). All numbers are 1-based. + * on the side matching the buss mode), matrix (matrix outputs), minput (matrix inputs), cg (control groups), geq (graphic EQs). All numbers are 1-based. * * applyMessage(address, args) is the single entry point for console traffic (and for the * module's own optimistic writes): it returns the parsed event annotated with @@ -13,7 +12,7 @@ * 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 @@ -25,6 +24,10 @@ function setField(obj, field, value) { return old !== value } +function clampInt(v, lo, hi) { + return Math.min(hi, Math.max(lo, v)) +} + function rec(map, key) { let r = map.get(key) 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' } export class State { @@ -93,6 +99,7 @@ export class State { this.inputs = new Map() this.busses = new Map() this.matrix = new Map() + this.minputs = new Map() // matrix inputs: { name } this.cgs = new Map() this.geqs = new Map() 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.params = new Map() // K.param(...) -> boolean | number (per-channel switches and values, loaded lazily) this.snapshots = { current: undefined, count: undefined, list: new Map() } // index -> { name, cue } - this.meters = new Map() // meter id -> { value, at, peakAt } + this.meters = new Map() // meter id -> { value, at, loud } (loud[k]: last time a reading was k steps below full scale or louder) } // ---------- inbound ---------- @@ -112,6 +119,7 @@ export class State { ev.structural = false switch (ev.kind) { case 'consoleName': + ev.value = cleanName(ev.value) ev.changed = setField(this.console, 'name', ev.value) break case 'session': @@ -143,6 +151,7 @@ export class State { }) break 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.structural = ev.changed break @@ -189,6 +198,7 @@ export class State { this.snapshots.count = ev.value break case 'snapName': { + ev.name = cleanName(ev.name) const old = this.snapshots.list.get(ev.index) this.snapshots.list.set(ev.index, { name: ev.name, cue: ev.cue }) ev.changed = !old || old.name !== ev.name || old.cue !== ev.cue @@ -198,10 +208,10 @@ export class State { case 'meters': { const now = Date.now() for (const [id, value] of ev.pairs) { - const m = this.meters.get(id) || { value: 0, at: 0, peakAt: 0 } + const m = this.meters.get(id) || { value: 127, at: 0, loud: new Float64Array(LOUD_STEPS + 1) } m.value = value 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) } ev.changed = ev.pairs.length > 0 @@ -254,6 +264,8 @@ export class State { return this.inputs case 'matrix': return this.matrix + case 'minput': + return this.minputs case 'cg': return this.cgs 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) { if (!(n >= 1 && n <= this.limit(ent))) return false switch (ent) { @@ -294,8 +306,6 @@ export class State { const b = this.busses.get(n) return !!b && (b.auxName !== undefined || b.groupName !== undefined) } - case 'minput': - return this.limit('matrix') > 0 && (this.counts.matrixIn !== undefined || this.matrix.size > 0) default: return this.#map(ent).get(n)?.name !== undefined } @@ -319,7 +329,6 @@ export class State { /** The name as the console reports it (may be ""), or undefined while unknown. */ rawName(ent, n) { if (ent === 'buss') return this.busses.get(n)?.name - if (ent === 'minput') return undefined return this.#map(ent).get(n)?.name } @@ -391,6 +400,13 @@ export class State { 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) { 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'))) } 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('geq'); n++) if (want('geq', n)) q.push(query(addr.name('geq', n))) return q @@ -493,6 +510,7 @@ export class State { aux: this.namedBusses('aux').length, group: this.namedBusses('group').length, matrix: found('matrix'), + matrixIn: found('minput'), cgs: found('cg'), geqs: found('geq'), snapshots: this.snapshots.list.size, diff --git a/test/selftest.js b/test/selftest.js index 4a318c6..ad3322d 100644 --- a/test/selftest.js +++ b/test/selftest.js @@ -20,7 +20,7 @@ import { ConsoleLink } from '../src/console-link.js' import { Relay } from '../src/relay.js' import { encode, decode, foldPattern } from '../src/osc.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 { MAX_PRESETS, MAX_PRESET_BYTES } from '../src/definitions/presets.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]) 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 @@ -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(!sent.some((m) => /Control_Groups\/0\//.test(m.address)), 'the module never addresses control group 0') check(!sent.slice(0, loadSent).some((m) => /^\/Console\/(Input_Channels|Aux_Outputs|Group_Outputs)\/\?$/.test(m.address)), 'the unanswered Input_Channels / Aux_Outputs / Group_Outputs count queries are never sent') -check(S.choices.matrixInputs()[0].label === '1 · Matrix In 1', `matrix inputs (no name address) are labelled "Matrix In N" (${S.choices.matrixInputs()[0].label})`) +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(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.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 /^\/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 + /^\/(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 check1 = (a) => { @@ -390,6 +411,7 @@ check(S.console.name === 'S31 AB' && S.console.session === 'Mock Session.session // Variables await sleep(80) 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.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') @@ -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(!('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'] + 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('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'}`) @@ -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 " 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 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(!!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 const layered = ids.filter((id) => P[id].type === 'layered') const knobsLayered = layered.filter((id) => /_knob_/.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 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' @@ -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') r = await run('matrix_trim_set', { matrix: 2, value: 1.5 }) expectWrite(r, '/Matrix_Outputs/2/Buss_Trim/trim', ',f', 1.5, 'matrix_trim_set') + r = await run('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' }) 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' }) @@ -894,6 +924,7 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb 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(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('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') @@ -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) - 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 - cb('input_meter_value', { channel: 7, unit: 'raw' }) + cb('input_meter_value', { channel: 7 }) await sleep(80) const sub = sent.slice(mark).find((m) => m.address === '/Input_Channels/7/Channel_Input/post_meter') const subWire = sub && decodeArgs(sub) check(!!subWire && subWire.tags === ',ii' && subWire.args[0] === id7 && subWire.args[1] === 1, `the first meter feedback subscribes lazily: ${JSON.stringify(subWire)}`) - cb('input_meter_value', { channel: 7, unit: 'raw' }) + 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(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 - cb('input_meter_value', { channel: 7, meter: 'gate', unit: 'raw' }) - cb('input_peak', { channel: 7, meter: 'comp', hold: 1 }) + cb('input_meter_value', { channel: 7, meter: 'gate' }) + cb('input_meter_pos', { channel: 7, meter: 'comp' }) await sleep(80) const gate = sent.slice(m2).find((m) => m.address === '/Input_Channels/7/Dynamics/GR_meter' && m.args[1].i === 1) const comp = sent.slice(m2).find((m) => m.address === '/Input_Channels/7/Dynamics/GR_meter' && m.args[1].i === 4) check(gate?.args[0].i === meterId(7, 'gate') && comp?.args[0].i === meterId(7, 'comp') && meterId(7, 'gate') >= 5000 && meterId(7, 'comp') !== meterId(7, 'gate'), `gate and compressor gain-reduction meters subscribe with kinds 1 and 4 (ids ${meterId(7, 'gate')} / ${meterId(7, 'comp')})`) check(await waitFor(() => S.getMeter(meterId(7, 'gate')) && S.getMeter(meterId(7, 'comp')), 3000), 'gain-reduction meters stream too') + const 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) check(m.value >= 0 && m.value <= 127, `meter values are 0..127 (${m.value})`) // deterministic values injected as if from the console - const inject = (value) => inst.onConsoleMessage(decode(encode('/Meters/values', [{ i: id7 }, { i: value }])), { address: '127.0.0.1', port: P.mock }) + 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) - check(cb('input_meter_pos', { channel: 7, meter: 'level' }) === 100, 'input_meter_pos is 100 at full scale (127)') - inject(64) - 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') - inject(127) - check(cb('input_meter_value', { channel: 7, unit: 'raw' }) === 127 && cb('input_meter_value', { channel: 7, unit: 'percent' }) === 100, 'input_meter_value raw and percent') - check(cb('input_signal', { channel: 7, threshold: 5 }) === true && cb('input_peak', { channel: 7, hold: 2 }) === true, 'input_signal and input_peak at full scale') - inject(3) - check(cb('input_signal', { channel: 7, threshold: 5 }) === false && cb('input_peak', { channel: 7, hold: 2 }) === true, 'signal drops below the threshold while the peak is still held') + 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(0) + 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(42) + 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, thresholdDb: -50 }) === true && cb('input_signal', { channel: 7, thresholdDb: -40 }) === false, 'signal is a dBFS threshold: -42 is above -50 and below -40') + check(cb('input_peak', { channel: 7, hold: 2 }) === true, 'the peak from a moment ago is still held after the level fell back') await sleep(30) 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) 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 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)`) // 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') @@ -989,7 +1065,9 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb const spanMs = Date.now() - t0 const meterChecks = ctx.byId check(meterChecks.length > 0 && meterChecks.length <= Math.ceil((spanMs / 1000) * 10) + 1, `meter feedback checks throttled to <= 10 Hz (${meterChecks.length} checks in ${spanMs} ms for 100 messages)`) - check(meterChecks.every(([, ids]) => ids.every((x) => watching7.has(x))) && !meterChecks.some(([, ids]) => ids.includes(gateInstance)), 'a meter update re-checks only the instances watching the changed meter id') + // 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)`) const rebuildsBefore = ctx.rebuilds 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') 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)})`) + 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(!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 }) 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') + + // 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) } // --------------------------------------------------------------------------- diff --git a/tools/README.md b/tools/README.md index 1c1393d..4223b48 100644 --- a/tools/README.md +++ b/tools/README.md @@ -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/Aux_Outputs/types/?` | one `i` per buss, 1 = aux, 0 = group (a buss named only on the Group side is a group) | | `/Console/{Aux_Outputs,Group_Outputs}/modes/?`, `/Console/Input_Channels/modes/?` | one `i` per buss / input (2 = stereo, 1 = mono, captured) | -| `/Input_Channels/N/Channel_Input/name/?`, `/Aux_Outputs/N/Buss_Trim/name/?`, `/Group_Outputs/N/Buss_Trim/name/?`, `/Matrix_Outputs/N/Buss_Trim/name/?`, `/Control_Groups/N/name/?`, `/Graphic_EQ/N/name/?` | `s`. Only input names accept a write here (not verified on the real console); the others are read-only. The 25 busses appear under both Aux_Outputs and Group_Outputs; a buss reports its name on the side matching its mode and `""` on the other. Matrix inputs have no name address | +| `/Input_Channels/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 | | `/{...}/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 | @@ -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 | | `/Graphic_EQ/N/{geq_in,geq_gain_1..32}/?` | `f` | | `/Input_Channels/N/{Channel_Input/{trim,phase,phantom,analog_gain,input_pad},Filters/{hi,lo}_filter_{in,freq},Channel_Delay/{delay_on,delay},EQ/eq_in,Dynamics/{comp_in,gate_in},Insert/insert_{A,B}_in}/?` | `f` (switches 0/1) | -| `/{Aux_Outputs,Group_Outputs}/N/{Buss_Trim/{trim,phase},EQ/eq_in,Dynamics/{comp_in,gate_in},Channel_Delay/{delay_on,delay},Insert/insert_{A,B}_in}/?`, `/Matrix_Outputs/N/Buss_Trim/trim/?` | `f` | +| `/{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/names/?` | one `/Snapshots/name ,iiis [index, cue*100, 0, "name"]` per snapshot. `/Snapshots/name/?` without an argument stays silent (unreliable on the real console) | | `/Snapshots/Fire_Snapshot_number [i]`, `/Snapshots/Fire_Next_Snapshot`, `/Snapshots/Fire_Prev_Snapshot` | write-only triggers; the mock moves its current snapshot and pushes `/Snapshots/Current_Snapshot` | | `/Macros/Buttons/press [i]` | logged only (no macro query is answered) | -**Meters.** Subscribe with `/Input_Channels/N/Channel_Input/post_meter ,ii [id, 1]` (post-fader level) or -`/Input_Channels/N/Dynamics/GR_meter ,ii [id, 1|4]` (gate / compressor gain reduction), where the client -chooses `id`. Every subscribed id then gets a random value 0..127 about five times a second, as -`/Meters/values ,ii... [id, v, id, v, ...]` (only ids whose value changed). `/Meters/clear` drops every -subscription, like the real console. +**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) or +`/{Aux_Outputs,Group_Outputs,Matrix_Outputs}/N/Output/meter ,ii [id, 1]` (output level), where the client chooses `id`. +The scale is inverted, as on the real console (verified on an S31, approximate): a level meter reads about the dB +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 reported **and written as a float** 0.0 / 1.0, verified on a real S31 on 2026-10-15 (the 2014 DiGiCo list says diff --git a/tools/mock-s31.js b/tools/mock-s31.js index e8a166f..5c36af3 100755 --- a/tools/mock-s31.js +++ b/tools/mock-s31.js @@ -584,6 +584,8 @@ const SD_GROUP_NAMES = [ '', '', '', '', '', '', '', '', 'BAND', 'VOCALS', 'MISC', 'DRUMS', 'DRUM CRUSH', '', '', '', '', 'Broadcast mix', '', '', '', '', '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_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]). */ @@ -644,6 +646,10 @@ const SD_PARAM_SPECS = { matrix: { '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' }; @@ -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 sdGroups = [null]; // 1-based: { name, fader, mute, solo } const sdMatrix = [null]; // 1-based matrix outputs: { name, fader, mute, solo } -const sdMatrixIn = [null]; // 1-based matrix inputs: { sends:[null,{level,on}...] } +const sdMatrixIn = [null]; // 1-based matrix inputs: { name, sends:[null,{level,on}...] } const sdCgs = [null]; // 1-based control groups: { name, fader, mute, solo } const sdGeqs = [null]; // 1-based graphic EQs: { name, on, gains:[null, ...] } let sdSnapshot = 3; // /Snapshots/Current_Snapshot -const sdMeterSubs = new Map(); // meter id -> last value sent +/** 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; const SD_METER_INTERVAL_MS = 200; // ~5 Hz @@ -705,7 +713,7 @@ function buildStateSd() { for (let k = 0; k < SD_MATRIX_IN; k++) { const sends = [null]; for (let m = 1; m <= SD_MATRIX_OUT; m++) sends.push({ level: -90, on: 0 }); // captured: -90 / 0 - sdMatrixIn.push({ sends }); + 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 = 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_PROP = /^\/Control_Groups\/(\d+)\/(fader|mute|solo)$/; const SD_RE_GEQ = /^\/Graphic_EQ\/(\d+)\/(name|geq_in|geq_gain_(\d+))$/; -const SD_RE_PARAM = /^\/(Input_Channels|Aux_Outputs|Group_Outputs|Matrix_Outputs)\/(\d+)\/((?:Channel_Input|Filters|Channel_Delay|EQ|Dynamics|Insert|Buss_Trim)\/\w+)$/; -const SD_RE_METER_SUB = /^\/Input_Channels\/(\d+)\/(?:Channel_Input\/post_meter|Dynamics\/GR_meter)$/; +const SD_RE_MINPUT_NAME = /^\/Matrix_Inputs\/(\d+)\/Channel_Input\/name$/; +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. */ function sdResolve(address) { @@ -776,11 +785,13 @@ function sdResolve(address) { const prop = fixedProp || m[2]; 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); 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 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 (!owner.params) owner.params = {}; 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); if (sub) { - if (!sdInputs[Number(sub[1])] || typeof args[0] !== 'number') return ignore(`meter subscription ${address} ${formatArgs(args)}`); - sdMeterSubs.set(Math.trunc(args[0]), -1); + const [, tree, numText, path] = sub; + 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; } @@ -906,14 +925,20 @@ function sdSet(address, args, types) { 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() { if (!sdMeterSubs.size || !replyTarget) return; const pairs = []; - for (const [id, last] of sdMeterSubs) { - const v = Math.random() < 0.15 ? 0 : Math.floor(Math.random() * 128); - if (v === last) continue; - sdMeterSubs.set(id, v); + for (const [id, meter] of sdMeterSubs) { + let v; + if (meter.kind === 'gr') v = Math.random() < 0.1 ? 127 : Math.floor(Math.random() * 13); + 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)); } for (let i = 0; i < pairs.length; i += 40) send('/Meters/values', pairs.slice(i, i + 40));