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