From 90dee1444480174d65ca7c0a0bd34a69db01251a Mon Sep 17 00:00:00 2001 From: Waters Church Date: Mon, 19 Oct 2026 15:44:43 -0400 Subject: [PATCH] Add a feedback for every controllable state, matrix send presets A matrix-send knob looked dead on the booth Companion although the console followed it (verified through Companion's rotate API: -90 -> -60 -> -59): the module had matrix send actions but no matrix send feedbacks. - Every action with a readable state now has a matching feedback: matrix send on/level text/level bar, matrix and control group solo and fader bars, graphic EQ in and band gain text/bar, aux send pan text, and value text for trim, gain and filter frequencies. A test checks the parity. - New action: matrix output solo. - Matrix sends use the console's -90 dB floor: adjusting down from -inf no longer writes -150 there. - Presets: "Matrix sends to " (on/off and knob per matrix input) and graphic EQ in/out. 2970 presets, 1.8 MB. npm test: 368 checks pass on Node 25 and on Companion's Node 22. Co-Authored-By: Claude Opus 5.5 --- companion/HELP.md | 37 ++++++++--- src/definitions/actions.js | 28 ++++++--- src/definitions/feedbacks.js | 83 +++++++++++++++++++++++-- src/definitions/presets.js | 82 ++++++++++++++++++++----- src/main.js | 38 +++++++++--- src/protocol.js | 19 +++++- test/selftest.js | 116 +++++++++++++++++++++++++++++++++-- tools/README.md | 2 +- tools/mock-s31.js | 3 +- 9 files changed, 351 insertions(+), 57 deletions(-) diff --git a/companion/HELP.md b/companion/HELP.md index b74e9fe..a1932e7 100644 --- a/companion/HELP.md +++ b/companion/HELP.md @@ -75,8 +75,10 @@ fader, mute and solo are on the Aux_Outputs or Group_Outputs side that matches i ## Actions Level actions take dB (`-inf` at the bottom of the number field). Every level has a **set** and a -relative **adjust** version for rotary encoders; adjusting from -inf lands at -60 dB on the first step up. -Toggles read the current state first when it is not known yet. +relative **adjust** version for rotary encoders. A relative step cannot start from -inf: from −∞ the first +step up jumps to −60 dB. A matrix send has its floor at **-90 dB** (the console reports off matrix sends as -90, and +the module never writes lower); faders and aux sends go down to -150 dB. Toggles read the current state first when it +is not known yet. | Group | Actions | | --- | --- | @@ -106,14 +108,26 @@ snapshot's list position (0 = the first); verify it once on your console before ## Feedbacks -* Boolean (button colour): input muted / solo, aux send on, group assigned, buss muted / - solo, matrix muted, control group muted, snapshot is current, fader level above/below a threshold, send - level above/below, console link alive, relay has an iPad, every channel-strip switch above, and the meter +Every action that changes something the console reports has a feedback for it: a boolean for every switch, a value +(text for a button's local variable) and a bar for every level. The state a feedback needs (sends, matrix sends, +graphic EQ bands, channel-strip values) is loaded the first time one is shown, and each button re-checks only when its +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. -* Value (use with a preset's local variable to put live numbers in button text): input fader, input pan, - send level, send On/Off, buss / matrix / control group fader, current snapshot name, input meter value. -* Advanced bars: input fader, aux send level and buss fader draw a horizontal bar along the bottom of the - button (-60 to +10 dB); the input meter draws a vertical bar at the right edge (green, amber, red). +* 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. +* Advanced bars: input fader, aux send level, buss / matrix output / control group fader, **matrix send level** (all + -60 to +10 dB) and **graphic EQ band gain** (-12 to +12 dB) draw a horizontal bar along the bottom of the button; + the input meter draws a vertical bar at the right edge (green, amber, red). + +Not covered by a feedback: free-form *Send raw OSC*, and *Set name* (the name is the variable `input_N_name`). There is +no matrix-output solo action, only its feedback. ## Variables @@ -135,12 +149,15 @@ Presets are named with the live entity names and rebuilt when names load. | Sends to *aux name* | One section per named aux buss: per input an **On/off** button (green when on, shows the level) and a **Level (knob)** (turn = send level, press = on/off, with a bar) | | Group *group name* | One section per named group buss: per input an **Assign** toggle, green when assigned | | Matrix, Control groups | Mute and fader knob per output / group | +| 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 bar) | +| 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 | | Console | Reload, connection status, relay status | Busses and control groups with no name are unused slots and get no presets. If the preset list would exceed -3000 entries or 4 MB, the per-input send level knobs are left out and a warning is logged. +3000 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 diff --git a/src/definitions/actions.js b/src/definitions/actions.js index 0bd3c05..accaee8 100644 --- a/src/definitions/actions.js +++ b/src/definitions/actions.js @@ -29,13 +29,14 @@ export const opt = { cg: (inst, id = 'cg', label = 'Control group') => opt.dropdown(id, label, inst.state.choices.cgs()), geq: (inst, id = 'geq', label = 'Graphic EQ') => opt.dropdown(id, label, inst.state.choices.geqs()), snapshot: (inst, id = 'snapshot', label = 'Snapshot') => opt.dropdown(id, label, inst.state.choices.snapshots(), { allowCustom: true, default: firstId(inst.state.choices.snapshots(), 0) }), + band: (id = 'band', label = 'Band') => ({ type: 'number', id, label, default: 1, min: 1, max: LIMITS.geqBands, step: 1 }), state: (label = 'State') => ({ type: 'dropdown', id: 'state', label, choices: STATE_CHOICES, default: 'toggle' }), - level: (id = 'level', label = 'Level (dB)') => ({ + level: (id = 'level', label = 'Level (dB)', min = LEVEL.min) => ({ type: 'number', id, label, default: 0, - min: LEVEL.min, + min, max: LEVEL.max, step: 0.5, showMinAsNegativeInfinity: true, @@ -46,6 +47,9 @@ export const opt = { } /** Normalise a multidropdown / dropdown value to an array of numbers. */ +/** Said in every level adjust action: a relative step cannot start from -inf, so the first step up jumps to -60 dB. */ +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'. */ @@ -130,7 +134,7 @@ export function getActionDefinitions(inst) { }, input_fader_adjust: { name: 'Input: adjust fader (relative)', - description: 'For rotary encoders. From -inf the first step up lands at -60 dB.', + description: `For rotary encoders. ${ADJUST_NOTE}`, options: [opt.channel(inst), opt.delta()], callback: async ({ options }) => inst.adjustFader('input', n(options.channel), n(options.delta)), }, @@ -162,7 +166,7 @@ export function getActionDefinitions(inst) { }, send_level_adjust: { name: 'Aux send: adjust level (relative)', - description: 'For rotary encoders. From -inf the first step up lands at -60 dB.', + description: `For rotary encoders. ${ADJUST_NOTE}`, options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.delta()], callback: async ({ options }) => inst.adjustSendLevel(n(options.channel), n(options.buss), n(options.delta)), }, @@ -199,6 +203,7 @@ export function getActionDefinitions(inst) { }, buss_fader_adjust: { name: 'Buss: adjust fader (relative)', + description: `For rotary encoders. ${ADJUST_NOTE}`, options: [opt.buss(inst), opt.delta()], callback: async ({ options }) => inst.adjustFader('buss', n(options.buss), n(options.delta)), }, @@ -208,6 +213,11 @@ export function getActionDefinitions(inst) { options: [opt.matrix(inst), opt.state()], callback: async ({ options }) => inst.setSwitch('matrix', 'mute', n(options.matrix), options.state), }, + matrix_solo: { + name: 'Matrix output: solo', + options: [opt.matrix(inst), opt.state()], + callback: async ({ options }) => inst.setSwitch('matrix', 'solo', n(options.matrix), options.state), + }, matrix_fader_set: { name: 'Matrix output: set fader', options: [opt.matrix(inst), opt.level()], @@ -215,6 +225,7 @@ export function getActionDefinitions(inst) { }, matrix_fader_adjust: { name: 'Matrix output: adjust fader (relative)', + description: `For rotary encoders. ${ADJUST_NOTE}`, options: [opt.matrix(inst), opt.delta()], callback: async ({ options }) => inst.adjustFader('matrix', n(options.matrix), n(options.delta)), }, @@ -225,11 +236,13 @@ export function getActionDefinitions(inst) { }, matrix_send_level_set: { name: 'Matrix send: set level', - options: [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut'), opt.level()], + description: 'A matrix send goes down to −90 dB (−∞).', + options: [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut'), opt.level('level', 'Level (dB)', LEVEL.matrixSendMin)], callback: async ({ options }) => inst.setMatrixSendLevel(n(options.matrixIn), n(options.matrixOut), n(options.level)), }, matrix_send_level_adjust: { name: 'Matrix send: adjust level (relative)', + description: `For rotary encoders. ${ADJUST_NOTE}`, options: [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut'), opt.delta()], callback: async ({ options }) => inst.adjustMatrixSendLevel(n(options.matrixIn), n(options.matrixOut), n(options.delta)), }, @@ -251,6 +264,7 @@ export function getActionDefinitions(inst) { }, cg_fader_adjust: { name: 'Control group: adjust fader (relative)', + description: `For rotary encoders. ${ADJUST_NOTE}`, options: [opt.cg(inst), opt.delta()], callback: async ({ options }) => inst.adjustFader('cg', n(options.cg), n(options.delta)), }, @@ -265,7 +279,7 @@ export function getActionDefinitions(inst) { description: 'Band 1-32, gain in dB.', options: [ opt.geq(inst), - { type: 'number', id: 'band', label: 'Band', default: 1, min: 1, max: LIMITS.geqBands, step: 1 }, + opt.band(), { type: 'number', id: 'gain', label: 'Gain (dB)', default: 0, min: -12, max: 12, step: 0.5 }, ], callback: async ({ options }) => inst.setGeqBand(n(options.geq), n(options.band), n(options.gain)), @@ -274,7 +288,7 @@ export function getActionDefinitions(inst) { name: 'Graphic EQ: adjust band gain (relative)', options: [ opt.geq(inst), - { type: 'number', id: 'band', label: 'Band', default: 1, min: 1, max: LIMITS.geqBands, step: 1 }, + opt.band(), { type: 'number', id: 'delta', label: 'Change (dB, for encoders)', default: 1, min: -12, max: 12, step: 0.5 }, ], callback: async ({ options }) => inst.adjustGeqBand(n(options.geq), n(options.band), n(options.delta)), diff --git a/src/definitions/feedbacks.js b/src/definitions/feedbacks.js index 6ee6323..9568314 100644 --- a/src/definitions/feedbacks.js +++ b/src/definitions/feedbacks.js @@ -1,5 +1,5 @@ import { combineRgb } from '@companion-module/base' -import { LEVEL, METERS, PARAMS, clamp, fmtDb, fmtPan, meterId } from '../protocol.js' +import { GEQ_RANGE, LEVEL, METERS, PARAMS, clamp, fmtDb, fmtPan, fmtValue, meterId } from '../protocol.js' import { horizontalBar, verticalMeter } from '../image.js' import { opt, paramEntities, paramEntityOption } from './actions.js' @@ -18,6 +18,9 @@ const n = (v) => Number(v) /** -60 dB .. +10 dB mapped to 0..1 for the level bars. */ const levelFraction = (db) => clamp((db + 60) / (LEVEL.max + 60), 0, 1) +/** GEQ band gain -12 .. +12 dB mapped to 0..1 for the band bar. */ +const geqFraction = (db) => clamp((db - GEQ_RANGE.min) / (GEQ_RANGE.max - GEQ_RANGE.min), 0, 1) + const colorOption = { type: 'colorpicker', id: 'color', label: 'Bar colour', default: COLORS.bar, returnType: 'number' } const KIND_VISIBLE = (kind) => `$(options:kind) == '${kind}'` @@ -30,6 +33,9 @@ const KIND_VISIBLE = (kind) => `$(options:kind) == '${kind}'` export const watchKey = { entity: (ent, num) => `${ent}:${num}`, send: (ch, buss) => `send:${ch}:${buss}`, + msend: (inN, outN) => `msend:${inN}:${outN}`, + geqOn: (n) => `geq:${n}:on`, + geqGain: (n, band) => `geq:${n}:gain:${band}`, group: (ch, buss) => `group:${ch}:${buss}`, meter: (id) => `meter:${id}`, snapshot: 'snapshot', @@ -107,11 +113,38 @@ export function getFeedbackDefinitions(inst) { } 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)) + const msendOf = (o) => { + inst.ensureMatrixSend(n(o.matrixIn), n(o.matrixOut)) + return s.getMatrixSend(n(o.matrixIn), n(o.matrixOut)) + } + const msendOptions = () => [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut')] + const geqOnKey = (o) => watchKey.geqOn(n(o.geq)) + const geqGainKey = (o) => watchKey.geqGain(n(o.geq), n(o.band)) + const geqGainOf = (o) => { + inst.ensureGeqBand(n(o.geq), n(o.band)) + return s.getGeq(n(o.geq)).gains?.get(n(o.band)) + } /** Boolean feedback for every switch in PARAMS, on the entity kinds that have it. */ const paramFeedbacks = {} for (const [param, p] of Object.entries(PARAMS)) { - if (p.type !== 'switch') continue + if (p.type === 'value') { + 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)`, + [entOption], + entityKey(ent, entOption.id), + ({ options }) => { + const target = n(options[entOption.id]) + inst.ensureParam(ent, target, param) + return fmtValue(s.getParam(ent, target, param), p.unit) || '--' + }, + ) + } + continue + } for (const ent of paramEntities(param)) { const style = param === 'phantom' ? RED : GREEN const entOption = paramEntityOption(inst, ent) @@ -145,6 +178,13 @@ export function getFeedbackDefinitions(inst) { buss_solo: bool('Buss: solo on', AMBER, [opt.buss(inst)], entityKey('buss', 'buss'), ({ options }) => s.getSolo('buss', n(options.buss)) === true), matrix_muted: bool('Matrix output: muted', RED, [opt.matrix(inst)], entityKey('matrix', 'matrix'), ({ options }) => s.getMute('matrix', n(options.matrix)) === true), cg_muted: bool('Control group: muted', RED, [opt.cg(inst)], entityKey('cg', 'cg'), ({ options }) => s.getMute('cg', n(options.cg)) === true), + matrix_solo: bool('Matrix output: solo on', AMBER, [opt.matrix(inst)], entityKey('matrix', 'matrix'), ({ options }) => s.getSolo('matrix', n(options.matrix)) === true), + cg_solo: bool('Control group: solo on', AMBER, [opt.cg(inst)], entityKey('cg', 'cg'), ({ options }) => s.getSolo('cg', n(options.cg)) === true), + matrix_send_on: bool('Matrix send: on', GREEN, msendOptions(), msendKey, ({ options }) => msendOf(options).on === true), + geq_on: bool('Graphic EQ: in', GREEN, [opt.geq(inst)], geqOnKey, ({ options }) => { + inst.ensureGeqOn(n(options.geq)) + return s.getGeq(n(options.geq)).on === true + }), snapshot_current: bool('Snapshot: is the current one', GREEN, [opt.snapshot(inst)], () => watchKey.snapshot, ({ options }) => s.snapshots.current === Math.trunc(n(options.snapshot))), level_compare: bool( 'Fader level: above/below', @@ -213,6 +253,12 @@ export function getFeedbackDefinitions(inst) { buss_fader_value: value('Buss: fader level (dB text)', [opt.buss(inst)], entityKey('buss', 'buss'), ({ options }) => fmtDb(s.getFader('buss', n(options.buss))) || '--'), matrix_fader_value: value('Matrix output: fader level (dB text)', [opt.matrix(inst)], entityKey('matrix', 'matrix'), ({ options }) => fmtDb(s.getFader('matrix', n(options.matrix))) || '--'), cg_fader_value: value('Control group: fader level (dB text)', [opt.cg(inst)], entityKey('cg', 'cg'), ({ options }) => fmtDb(s.getFader('cg', n(options.cg))) || '--'), + send_pan_value: value('Aux send: pan (text)', [opt.channel(inst), opt.buss(inst, 'aux')], sendKey, ({ options }) => { + inst.ensureSendPan(n(options.channel), n(options.buss)) + return fmtPan(s.getSend(n(options.channel), n(options.buss)).pan) || '--' + }), + matrix_send_level_value: value('Matrix send: level (dB text)', msendOptions(), msendKey, ({ options }) => fmtDb(msendOf(options).level) || '--'), + geq_band_value: value('Graphic EQ: band gain (dB text)', [opt.geq(inst), opt.band()], geqGainKey, ({ options }) => fmtDb(geqGainOf(options)) || '--'), snapshot_name_value: value('Snapshot: current name', [], () => watchKey.snapshot, () => s.snapshotName(s.snapshots.current) ?? '--'), input_meter_value: value( 'Input meter: value', @@ -250,6 +296,22 @@ export function getFeedbackDefinitions(inst) { const v = s.getFader('buss', n(fb.options.buss)) return v === undefined ? {} : horizontalBar({ ...image, fraction: levelFraction(v), color: fb.options.color }) }), + matrix_fader_bar: bar('Matrix output: fader bar', [opt.matrix(inst)], entityKey('matrix', 'matrix'), (fb, image) => { + const v = s.getFader('matrix', n(fb.options.matrix)) + return v === undefined ? {} : horizontalBar({ ...image, fraction: levelFraction(v), color: fb.options.color }) + }), + cg_fader_bar: bar('Control group: fader bar', [opt.cg(inst)], entityKey('cg', 'cg'), (fb, image) => { + const v = s.getFader('cg', n(fb.options.cg)) + return v === undefined ? {} : horizontalBar({ ...image, fraction: levelFraction(v), color: fb.options.color }) + }), + matrix_send_level_bar: bar('Matrix send: level bar', msendOptions(), msendKey, (fb, image) => { + const v = msendOf(fb.options).level + return v === undefined ? {} : horizontalBar({ ...image, fraction: levelFraction(v), color: fb.options.color }) + }), + geq_band_bar: bar('Graphic EQ: band gain bar', [opt.geq(inst), opt.band()], geqGainKey, (fb, image) => { + const v = geqGainOf(fb.options) + return v === undefined ? {} : horizontalBar({ ...image, fraction: geqFraction(v), color: fb.options.color }) + }), input_meter_bar: { type: 'advanced', name: 'Input meter: bar (right edge)', @@ -278,6 +340,10 @@ export function watchKeyForEvent(ev) { return watchKey.send(ev.ch, ev.buss) case 'group': return watchKey.group(ev.ch, ev.buss) + case 'msend': + return watchKey.msend(ev.input, ev.out) + case 'geq': + return ev.field === 'on' ? watchKey.geqOn(ev.n) : watchKey.geqGain(ev.n, ev.band) case 'snapCurrent': case 'snapName': return watchKey.snapshot @@ -292,14 +358,14 @@ export function feedbackIdsForEvent(ev) { case 'mute': return { input: ['input_muted'], buss: ['buss_muted'], matrix: ['matrix_muted'], cg: ['cg_muted'] }[ev.ent] || [] case 'solo': - return { input: ['input_solo'], buss: ['buss_solo'] }[ev.ent] || [] + return { input: ['input_solo'], buss: ['buss_solo'], matrix: ['matrix_solo'], cg: ['cg_solo'] }[ev.ent] || [] case 'fader': return ( { input: ['input_fader_value', 'input_fader_bar', 'level_compare'], buss: ['buss_fader_value', 'buss_fader_bar', 'level_compare'], - matrix: ['matrix_fader_value', 'level_compare'], - cg: ['cg_fader_value', 'level_compare'], + matrix: ['matrix_fader_value', 'matrix_fader_bar', 'level_compare'], + cg: ['cg_fader_value', 'cg_fader_bar', 'level_compare'], }[ev.ent] || [] ) case 'pan': @@ -307,11 +373,16 @@ export function feedbackIdsForEvent(ev) { case 'send': if (ev.field === 'level') return ['send_level_value', 'send_level_bar', 'send_level_compare'] if (ev.field === 'on') return ['send_on', 'send_on_value'] + if (ev.field === 'pan') return ['send_pan_value'] return [] + case 'msend': + return ev.field === 'level' ? ['matrix_send_level_value', 'matrix_send_level_bar'] : ['matrix_send_on'] + case 'geq': + return ev.field === 'on' ? ['geq_on'] : ['geq_band_value', 'geq_band_bar'] case 'group': return ['group_assigned'] case 'param': - return PARAMS[ev.param].type === 'switch' ? [`${ev.ent}_${ev.param}_state`] : [] + return [`${ev.ent}_${ev.param}_${PARAMS[ev.param].type === 'switch' ? 'state' : 'value'}`] case 'snapCurrent': case 'snapName': return ['snapshot_current', 'snapshot_name_value'] diff --git a/src/definitions/presets.js b/src/definitions/presets.js index e1b7af7..faf5a8e 100644 --- a/src/definitions/presets.js +++ b/src/definitions/presets.js @@ -9,8 +9,11 @@ const { white: WHITE, dark: DARK, red: RED, green: GREEN, amber: AMBER, bar: BAR const fb = (feedbackId, options, bg, color = WHITE) => ({ feedbackId, options, style: { bgcolor: bg, color } }) const act = (actionId, options) => ({ actionId, options }) -/** Build every preset from the live state. `withSendKnobs` is the first thing dropped when the list is too big. */ -function build(inst, withSendKnobs) { +/** + * Build every preset from the live state. When the list is too big `grMeters` (the gate / compressor reduction + * meter presets) is dropped first, then `sendKnobs` (the per-input aux send level knobs). + */ +function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {}) { const s = inst.state const L = inst.label const presets = {} @@ -69,6 +72,7 @@ function build(inst, withSendKnobs) { ['gate', 'input_gate_gr', 'Gate reduction'], ['comp', 'input_comp_gr', 'Compressor reduction'], ]) { + if (meter !== 'level' && !grMeters) continue presets[`${prefix}_${n}`] = simple( `${label}: ${tag}`, nameVar('input', n), @@ -97,8 +101,7 @@ function build(inst, withSendKnobs) { description: 'Meters subscribe on the console when the button first renders.', definitions: [ group('meters_input', 'Level', meterIds.level), - group('meters_gate', 'Gate gain reduction', meterIds.gate), - group('meters_comp', 'Compressor gain reduction', meterIds.comp), + ...(grMeters ? [group('meters_gate', 'Gate gain reduction', meterIds.gate), group('meters_comp', 'Compressor gain reduction', meterIds.comp)] : []), ], }) } @@ -217,6 +220,48 @@ function build(inst, withSendKnobs) { if (muteIds.length) structure.push({ id: 'cgs', name: 'Control groups', definitions: [group('cgs_mute', 'Mute', muteIds), group('cgs_knob', 'Fader (knob)', knobIds)] }) } + // ---- Matrix sends: one section per matrix output, per matrix input an on/off button and a level knob + for (const out of s.numbers('matrix')) { + const oname = s.displayName('matrix', out) + 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 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}`] = knob( + `Matrix send level (knob): ${tag}`, + text, + [act('matrix_send_on', { ...options, state: 'toggle' })], + 'matrix_send_level_adjust', + options, + lv, + [fb('matrix_send_on', options, GREEN), { feedbackId: 'matrix_send_level_bar', options: { ...options, color: BAR } }], + ) + onIds.push(`msend_on_${inN}_${out}`) + knobIds.push(`msend_knob_${inN}_${out}`) + } + if (onIds.length) { + structure.push({ + id: `msends_${out}`, + name: `Matrix sends to ${oname}`, + definitions: [group(`msends_${out}_on`, 'On/off', onIds), group(`msends_${out}_knob`, 'Level (knob)', knobIds)], + }) + } + } + + // ---- Graphic EQs: in / out + { + const ids = [] + for (const n of s.numbers('geq')) { + presets[`geq_in_${n}`] = simple(`In/out: ${n} · ${s.displayName('geq', n)}`, `${s.displayName('geq', n)}\\nIN`, [act('geq_on', { geq: n, state: 'toggle' })], [fb('geq_on', { geq: n }, GREEN)]) + ids.push(`geq_in_${n}`) + } + if (ids.length) structure.push({ id: 'geqs', name: 'Graphic EQs', definitions: [group('geqs_in', 'In/out', ids)] }) + } + // ---- Snapshots { const fireIds = [] @@ -247,19 +292,26 @@ function build(inst, withSendKnobs) { } /** - * Presets for the current state: { structure, presets, stats }. If the list would exceed the agreed - * limits the per-input send level knobs (the largest group) are left out and stats.droppedSendKnobs is set. + * Presets for the current state: { structure, presets, stats }. If the list would exceed the agreed limits the + * gate / compressor reduction meter presets are left out first, then the per-input aux send level knobs; + * stats.dropped says which. */ -export function getPresetDefinitions(inst) { - let built = build(inst, true) - let count = Object.keys(built.presets).length - let bytes = JSON.stringify(built.presets).length - let droppedSendKnobs = false - if (count > MAX_PRESETS || bytes > MAX_PRESET_BYTES) { - built = build(inst, false) - droppedSendKnobs = true +export function getPresetDefinitions(inst, limits = { count: MAX_PRESETS, bytes: MAX_PRESET_BYTES }) { + const tiers = [ + { options: { grMeters: true, sendKnobs: true }, dropped: [] }, + { options: { grMeters: false, sendKnobs: true }, dropped: ['the gate / compressor reduction meter presets'] }, + { options: { grMeters: false, sendKnobs: false }, dropped: ['the gate / compressor reduction meter presets', 'the per-input send level knobs'] }, + ] + let built + let count = 0 + let bytes = 0 + let dropped = [] + for (const tier of tiers) { + built = build(inst, tier.options) count = Object.keys(built.presets).length bytes = JSON.stringify(built.presets).length + dropped = tier.dropped + if (count <= limits.count && bytes <= limits.bytes) break } - return { ...built, stats: { count, bytes, droppedSendKnobs } } + return { ...built, stats: { count, bytes, dropped } } } diff --git a/src/main.js b/src/main.js index 8ec36ca..0f845f4 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 { 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, 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' @@ -730,10 +730,27 @@ export default class DigicoS31Instance extends InstanceBase { if (s.level === undefined || s.on === undefined) this.ensure(`send:${ch}:${buss}`, [addr.sendLevel(ch, buss), addr.sendOn(ch, buss)]) } + ensureSendPan(ch, buss) { + if (this.state.getSend(ch, buss).pan === undefined) this.ensure(`pan:${ch}:${buss}`, [addr.sendPan(ch, buss)]) + } + ensureGroup(ch, buss) { if (this.state.getGroup(ch, buss) === undefined) this.ensure(`group:${ch}:${buss}`, [addr.groupAssign(ch, buss)]) } + ensureMatrixSend(inN, outN) { + const m = this.state.getMatrixSend(inN, outN) + if (m.level === undefined || m.on === undefined) this.ensure(`msend:${inN}:${outN}`, [addr.matrixSendLevel(inN, outN), addr.matrixSendOn(inN, outN)]) + } + + ensureGeqOn(n) { + if (this.state.getGeq(n).on === undefined) this.ensure(`geq:${n}:on`, [addr.geqIn(n)]) + } + + ensureGeqBand(n, band) { + if (this.state.getGeq(n).gains?.get(Number(band)) === undefined) this.ensure(`geq:${n}:gain:${band}`, [addr.geqGain(n, band)]) + } + ensureParam(ent, n, param) { const address = addr.param(ent, n, this.sideOf(ent, n), param) if (address && this.state.getParam(ent, n, param) === undefined) this.ensure(`param:${ent}:${n}:${param}`, [address]) @@ -758,21 +775,22 @@ export default class DigicoS31Instance extends InstanceBase { return this.write(address, [on ? 1 : 0], on ? 1 : 0) } - writeLevel(address, dB) { + /** Write a dB level, clamped to [lo, +10]; `lo` is -150 except for matrix sends (-90). */ + writeLevel(address, dB, lo = LEVEL.min) { if (!this.addressOk(address)) return false - const v = clamp(Number(dB), LEVEL.min, LEVEL.max) + const v = clamp(Number(dB), lo, LEVEL.max) if (!Number.isFinite(v)) return false return this.write(address, [v], v) } - async adjustLevel(address, key, get, delta) { + async adjustLevel(address, key, get, delta, lo = LEVEL.min) { if (!this.addressOk(address)) return false const cur = await this.current(key, get, address) if (cur === undefined) { this.log('warn', `${address}: current level unknown (no reply from the console), not adjusted`) return false } - return this.writeLevel(address, adjustedLevel(cur, delta)) + return this.writeLevel(address, adjustedLevel(cur, delta, lo), lo) } writePan(address, pan) { @@ -857,11 +875,11 @@ export default class DigicoS31Instance extends InstanceBase { } setMatrixSendLevel(inN, outN, dB) { - return this.writeLevel(addr.matrixSendLevel(inN, outN), dB) + return this.writeLevel(addr.matrixSendLevel(inN, outN), dB, LEVEL.matrixSendMin) } adjustMatrixSendLevel(inN, outN, delta) { - return this.adjustLevel(addr.matrixSendLevel(inN, outN), K.msend(inN, outN, 'level'), () => this.state.getMatrixSend(inN, outN).level, delta) + return this.adjustLevel(addr.matrixSendLevel(inN, outN), K.msend(inN, outN, 'level'), () => this.state.getMatrixSend(inN, outN).level, delta, LEVEL.matrixSendMin) } setGeqOn(n, mode) { @@ -871,7 +889,7 @@ export default class DigicoS31Instance extends InstanceBase { setGeqBand(n, band, gain) { const address = addr.geqGain(n, band) if (!this.addressOk(address)) return false - const v = clamp(Number(gain), -12, 12) + const v = clamp(Number(gain), GEQ_RANGE.min, GEQ_RANGE.max) if (!Number.isFinite(v)) return false return this.write(address, [v], v) } @@ -1153,8 +1171,8 @@ export default class DigicoS31Instance extends InstanceBase { this.definedVariables = new Set(Object.keys(vars)) this.presetStats = stats const changedPresets = this.pushIfChanged('presets', { structure, presets }, () => this.setPresetDefinitions(structure, presets)) - if (changedPresets && stats.droppedSendKnobs) { - this.log('warn', `Too many presets (limit reached): the per-input send level knobs were left out (${stats.count} presets, ${stats.bytes} bytes)`) + if (changedPresets && stats.dropped.length) { + this.log('warn', `Too many presets (limit reached): left out ${stats.dropped.join(' and ')} (${stats.count} presets, ${stats.bytes} bytes)`) } this.queueVariables(getAllVariableValues(this)) this.publishRelay() diff --git a/src/protocol.js b/src/protocol.js index ed16a79..e9cedfd 100644 --- a/src/protocol.js +++ b/src/protocol.js @@ -16,8 +16,15 @@ import { toBool, toNumber } from './osc.js' /** Maxima probed on an S31; an S21 simply answers fewer. */ export const LIMITS = { inputs: 60, busses: 25, matrix: 8, matrixIn: 10, cgs: 10, geqs: 16, geqBands: 32 } -/** dB values. -150 is the console's "off" (-inf); anything at or below FLOOR displays as -inf. */ -export const LEVEL = { min: -150, max: 10, floor: -90, restart: -60 } +/** + * dB values. Faders and aux sends have -150 as their "off" (-inf); anything at or below `floor` displays as -inf. + * A matrix send's floor is -90 (verified on an S31: off sends read -90): its writes never go below `matrixSendMin`. + * A relative step up from -inf lands at `restart`. + */ +export const LEVEL = { min: -150, max: 10, floor: -90, restart: -60, matrixSendMin: -90 } + +/** Graphic EQ band gain range in dB (assumed, the console reports plain dB floats). */ +export const GEQ_RANGE = { min: -12, max: 12 } /** Meter ids chosen by this module start here so they never collide with an iPad's (0-19, 200+, 300+). */ export const METER_BASE_ID = 5000 @@ -254,6 +261,14 @@ export function fmtDb(v) { return String(Object.is(r, -0) ? 0 : r) } +/** A channel-strip value as text: whole Hz for frequencies, one decimal otherwise. */ +export function fmtValue(v, unit) { + if (typeof v !== 'number' || !Number.isFinite(v)) return '' + if (unit === 'Hz') return String(Math.round(v)) + const r = Math.round(v * 10) / 10 + return String(Object.is(r, -0) ? 0 : r) +} + /** Pan 0..1 (0.5 centre) as "C", "L50", "R30". */ export function fmtPan(p) { if (typeof p !== 'number' || !Number.isFinite(p)) return '' diff --git a/test/selftest.js b/test/selftest.js index c5cffd6..8cb7263 100644 --- a/test/selftest.js +++ b/test/selftest.js @@ -23,6 +23,8 @@ import { State } from '../src/state.js' import { LEVEL, PARAMS, adjustedLevel, fmtCue, fmtDb, fmtPan, indexProblem, meterId, meterIdForInput, parse } from '../src/protocol.js' import { BYTES_PER_PIXEL, horizontalBar, verticalMeter, thicknessFor } from '../src/image.js' import { MAX_PRESETS, MAX_PRESET_BYTES } from '../src/definitions/presets.js' +import { COLORS } from '../src/definitions/feedbacks.js' +const COLORS_GREEN = COLORS.green const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const BASE = Number(process.env.S31_TEST_PORT_BASE) || 18000 @@ -381,9 +383,12 @@ check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs && check(entityOptions.length > 40 && entityOptions.every((o) => (o.type === 'dropdown' || o.type === 'multidropdown') && o.minChoicesForSearch === 0 && o.choices.every((c) => typeof c.id === 'number')), `all ${entityOptions.length} entity options are searchable dropdowns with numeric ids`) const levels = all.flatMap((d) => d.options).filter((o) => o.id === 'level') const thresholds = all.flatMap((d) => d.options).filter((o) => o.id === 'threshold' && o.min === -150) - check(levels.length === 6 && levels.every((o) => o.type === 'number' && o.showMinAsNegativeInfinity === true && o.min === -150) && thresholds.every((o) => o.showMinAsNegativeInfinity === true), `level options use showMinAsNegativeInfinity (${levels.length} level + ${thresholds.length} threshold options)`) + check(levels.length === 6 && levels.every((o) => o.type === 'number' && o.showMinAsNegativeInfinity === true && (o.min === -150 || o.min === -90)) && thresholds.every((o) => o.showMinAsNegativeInfinity === true), `level options use showMinAsNegativeInfinity (${levels.length} level + ${thresholds.length} threshold options)`) + check(ctx.actions.matrix_send_level_set.options[2].min === -90 && ctx.actions.matrix_send_level_set.options[2].showMinAsNegativeInfinity === true && ctx.actions.send_level_set.options[2].min === -150 && ctx.actions.input_fader_set.options[1].min === -150 && ctx.actions.cg_fader_set.options[1].min === -150, 'per-kind floor: a matrix send level goes down to -90, aux sends and faders to -150') + const adjusts = Object.entries(ctx.actions).filter(([id]) => /(fader|send_level)_adjust$/.test(id)) + check(adjusts.length === 6 && adjusts.every(([, a]) => a.description.includes('From \u2212\u221e the first step up jumps to \u221260 dB')), `all ${adjusts.length} level adjust actions say that the first step up from -inf jumps to -60 dB`) check(ctx.actions.input_mute.options[0].choices[0].label === '1 · KICK In' && ctx.actions.send_on.options[1].choices.every((c) => S.bussMode(c.id) === 'aux'), 'action dropdowns show the live labels; send actions list aux busses only') - const idsExpected = ['input_mute', 'input_solo', 'input_fader_set', 'input_fader_adjust', 'input_pan_set', 'input_pan_adjust', 'input_name_set', 'send_on', 'send_level_set', 'send_level_adjust', 'send_pan_set', 'send_pan_adjust', 'group_assign', 'buss_mute', 'buss_solo', 'buss_fader_set', 'buss_fader_adjust', 'matrix_mute', 'matrix_fader_set', 'matrix_fader_adjust', 'matrix_send_on', 'matrix_send_level_set', 'matrix_send_level_adjust', 'cg_mute', 'cg_solo', 'cg_fader_set', 'cg_fader_adjust', 'geq_band_set', 'geq_band_adjust', 'geq_on', 'snapshot_fire', 'snapshot_next', 'snapshot_prev', 'reload', 'raw_osc', 'relay_forget'] + const idsExpected = ['input_mute', 'input_solo', 'input_fader_set', 'input_fader_adjust', 'input_pan_set', 'input_pan_adjust', 'input_name_set', 'send_on', 'send_level_set', 'send_level_adjust', 'send_pan_set', 'send_pan_adjust', 'group_assign', 'buss_mute', 'buss_solo', 'buss_fader_set', 'buss_fader_adjust', 'matrix_mute', 'matrix_solo', 'matrix_fader_set', 'matrix_fader_adjust', 'matrix_send_on', 'matrix_send_level_set', 'matrix_send_level_adjust', 'cg_mute', 'cg_solo', 'cg_fader_set', 'cg_fader_adjust', 'geq_band_set', 'geq_band_adjust', 'geq_on', 'snapshot_fire', 'snapshot_next', 'snapshot_prev', 'reload', 'raw_osc', 'relay_forget'] // per-channel switches and values, generated from the PARAMS table for (const [param, p] of Object.entries(PARAMS)) { for (const ent of Object.keys(p.paths)) idsExpected.push(...(p.type === 'switch' ? [`${ent}_${param}`] : [`${ent}_${param}_set`, `${ent}_${param}_adjust`])) @@ -391,8 +396,37 @@ 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'] - for (const [param, p] of Object.entries(PARAMS)) if (p.type === 'switch') for (const ent of Object.keys(p.paths)) fbExpected.push(`${ent}_${param}_state`) + 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'}`) check(fbExpected.every((id) => ctx.feedbacks[id]) && Object.keys(ctx.feedbacks).length === fbExpected.length, `all ${fbExpected.length} designed feedbacks exist (${Object.keys(ctx.feedbacks).length} registered)`) + // Feedback parity: every action that changes readable state has a feedback for that state + const PARITY = { + input_mute: ['input_muted'], input_solo: ['input_solo'], input_fader_set: ['input_fader_value', 'input_fader_bar'], input_fader_adjust: ['input_fader_value', 'input_fader_bar'], + input_pan_set: ['input_pan_value'], input_pan_adjust: ['input_pan_value'], + send_on: ['send_on', 'send_on_value'], send_level_set: ['send_level_value', 'send_level_bar'], send_level_adjust: ['send_level_value', 'send_level_bar'], + send_pan_set: ['send_pan_value'], send_pan_adjust: ['send_pan_value'], group_assign: ['group_assigned'], + buss_mute: ['buss_muted'], buss_solo: ['buss_solo'], buss_fader_set: ['buss_fader_value', 'buss_fader_bar'], buss_fader_adjust: ['buss_fader_value', 'buss_fader_bar'], + matrix_mute: ['matrix_muted'], matrix_solo: ['matrix_solo'], matrix_fader_set: ['matrix_fader_value', 'matrix_fader_bar'], matrix_fader_adjust: ['matrix_fader_value', 'matrix_fader_bar'], + matrix_send_on: ['matrix_send_on'], matrix_send_level_set: ['matrix_send_level_value', 'matrix_send_level_bar'], matrix_send_level_adjust: ['matrix_send_level_value', 'matrix_send_level_bar'], + cg_mute: ['cg_muted'], cg_solo: ['cg_solo'], cg_fader_set: ['cg_fader_value', 'cg_fader_bar'], cg_fader_adjust: ['cg_fader_value', 'cg_fader_bar'], + geq_on: ['geq_on'], geq_band_set: ['geq_band_value', 'geq_band_bar'], geq_band_adjust: ['geq_band_value', 'geq_band_bar'], + snapshot_fire: ['snapshot_current', 'snapshot_name_value'], snapshot_next: ['snapshot_current', 'snapshot_name_value'], snapshot_prev: ['snapshot_current', 'snapshot_name_value'], + reload: ['connected'], relay_forget: ['relay_clients'], + input_name_set: [], // the name is the variable input_N_name + raw_osc: [], // free-form + } + for (const [param, p] of Object.entries(PARAMS)) { + for (const ent of Object.keys(p.paths)) { + if (p.type === 'switch') PARITY[`${ent}_${param}`] = [`${ent}_${param}_state`] + else PARITY[`${ent}_${param}_set`] = PARITY[`${ent}_${param}_adjust`] = [`${ent}_${param}_value`] + } + } + const noFeedback = Object.keys(ctx.actions).filter((id) => !(id in PARITY)) + const missing = Object.entries(PARITY).filter(([id, fbs]) => id in ctx.actions && fbs.some((f) => !ctx.feedbacks[f])).map(([id]) => id) + const levelActions = Object.keys(ctx.actions).filter((id) => /_(set|adjust)$/.test(id) && /(fader|level|pan|band|trim|gain|freq)/.test(id)) + const lacksValue = levelActions.filter((id) => !(PARITY[id] || []).some((f) => /_value$/.test(f))) + const lacksBar = levelActions.filter((id) => /(fader|send_level)/.test(id) && !(PARITY[id] || []).some((f) => /_bar$/.test(f))) + check(noFeedback.length === 0 && missing.length === 0 && lacksValue.length === 0 && lacksBar.length === 0, `every action has its feedback (${Object.keys(ctx.actions).length} actions; no feedback listed: ${noFeedback.join(', ') || 'none'}; unknown feedback: ${missing.join(', ') || 'none'}; level without value: ${lacksValue.join(', ') || 'none'}; fader/send level without bar: ${lacksBar.join(', ') || 'none'})`) check(Object.values(ctx.feedbacks).filter((f) => f.type === 'advanced').every((f) => Array.isArray(f.affectedProperties)), 'advanced feedbacks declare affectedProperties') } @@ -442,10 +476,26 @@ check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs && const knobs = ids.filter((id) => /_knob_/.test(id)) check(knobs.length > 100 && knobs.every((id) => P[id].steps[0].rotate_left?.length && P[id].steps[0].rotate_right?.length), `all ${knobs.length} knob presets have rotate_left and rotate_right`) const sections = ctx.presetStructure.map((s) => s.id) - check(['inputs', 'busses', 'cgs', 'matrix', 'snapshots', 'console', 'meters'].every((s) => sections.includes(s)) && sections.some((s) => s.startsWith('sends_')) && sections.some((s) => s.startsWith('group_')), `preset sections cover inputs, busses, sends, groups, matrix, control groups, snapshots, meters, console (${sections.length} sections)`) + check(['inputs', 'busses', 'cgs', 'matrix', 'snapshots', 'console', 'meters', 'geqs'].every((s) => sections.includes(s)) && sections.some((s) => s.startsWith('sends_')) && sections.some((s) => s.startsWith('group_')) && sections.some((s) => s.startsWith('msends_')), `preset sections cover inputs, busses, sends, groups, matrix, matrix sends, graphic EQs, control groups, snapshots, meters, console (${sections.length} sections)`) + // matrix sends: one section per matrix output, per matrix input an on/off button and a knob + const msendSections = ctx.presetStructure.filter((x) => x.id.startsWith('msends_')) + const msendOn = ids.filter((id) => id.startsWith('msend_on_')) + const msendKnob = ids.filter((id) => id.startsWith('msend_knob_')) + 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(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.feedbacks.some((f) => f.feedbackId === 'matrix_send_on') && mKnob.feedbacks.some((f) => f.feedbackId === 'matrix_send_level_bar'), 'matrix send knob preset: rotate = matrix_send_level_adjust -1/+1, press toggles on/off, on feedback and level bar') + 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') const bytes = JSON.stringify(P).length - console.log(`info presets: ${ids.length} presets, ${bytes} bytes serialized, ${inst.presetStats?.droppedSendKnobs ? 'send knobs dropped' : 'nothing dropped'}`) + console.log(`info presets: ${ids.length} presets, ${bytes} bytes serialized, ${inst.presetStats?.dropped?.length ? `dropped ${inst.presetStats.dropped.join(' and ')}` : 'nothing dropped'}`) check(ids.length <= MAX_PRESETS && bytes <= MAX_PRESET_BYTES, `preset count ${ids.length} <= ${MAX_PRESETS} and size ${bytes} <= ${MAX_PRESET_BYTES} bytes`) + // over the limit: the gate / compressor reduction meter presets go first, then the per-input send knobs + const { getPresetDefinitions } = await import('../src/definitions/presets.js') + const tier1 = getPresetDefinitions(inst, { count: 2900, bytes: MAX_PRESET_BYTES }) + const tier2 = getPresetDefinitions(inst, { count: 2500, bytes: MAX_PRESET_BYTES }) + check(tier1.stats.dropped.length === 1 && /reduction meter/.test(tier1.stats.dropped[0]) && tier1.stats.count === ids.length - 120 && !Object.keys(tier1.presets).some((id) => /_gr_/.test(id)) && Object.keys(tier1.presets).some((id) => id.startsWith('send_knob_')), `over the limit the 120 gate / compressor reduction meter presets are dropped first (${tier1.stats.count} left)`) + check(tier2.stats.dropped.length === 2 && !Object.keys(tier2.presets).some((id) => id.startsWith('send_knob_')) && tier2.stats.count === ids.length - 120 - 900, `still over: the 900 per-input send level knobs go next (${tier2.stats.count} left)`) check(!/studio mon/i.test(JSON.stringify([P, ctx.presetStructure, ctx.variableDefs])) && !JSON.stringify(Object.values(ctx.actions).map((a) => a.name)).match(/studio mon/i), 'no site-specific wording in the generated definitions') } @@ -542,6 +592,10 @@ function expectWrite(list, address, tags, value, label) { // matrix r = await run('matrix_mute', { matrix: 2, state: 'toggle' }) check(writes(r).some((m) => m.address === '/Matrix_Outputs/2/mute' && m.tags === ',f'), 'matrix_mute toggle') + r = await run('matrix_solo', { matrix: 2, state: 'on' }) + check(writes(r).some((m) => m.address === '/Matrix_Outputs/2/solo' && m.tags === ',f' && m.args[0] === 1), 'matrix_solo on (float 1)') + r = await run('matrix_solo', { matrix: 2, state: 'off' }) + check(writes(r).some((m) => m.address === '/Matrix_Outputs/2/solo' && m.tags === ',f' && m.args[0] === 0), 'matrix_solo off (float 0)') r = await run('matrix_fader_set', { matrix: 2, level: -20 }) expectWrite(r, '/Matrix_Outputs/2/fader', ',f', -20, 'matrix_fader_set') r = await run('matrix_fader_adjust', { matrix: 2, delta: 4 }) @@ -553,6 +607,23 @@ function expectWrite(list, address, tags, value, label) { r = await run('matrix_send_level_adjust', { matrixIn: 3, matrixOut: 2, delta: 1 }) check(r[0]?.address === '/Matrix_Inputs/3/Matrix_Send/2/send_level/?', 'matrix_send_level_adjust on an unknown level queries first') expectWrite(r, '/Matrix_Inputs/3/Matrix_Send/2/send_level', ',f', -60, 'matrix_send_level_adjust from the -90 floor lands at -60') + // a matrix send floors at -90: stepping down from -inf stays there, a set below it is clamped, -150 is never written + r = await run('matrix_send_level_adjust', { matrixIn: 4, matrixOut: 2, delta: -1 }) + check(r[0]?.address === '/Matrix_Inputs/4/Matrix_Send/2/send_level/?', 'matrix_send_level_adjust -1 on an unknown level queries first') + expectWrite(r, '/Matrix_Inputs/4/Matrix_Send/2/send_level', ',f', -90, 'matrix_send_level_adjust -1 from -90 stays at -90 (not -150)') + r = await run('matrix_send_level_set', { matrixIn: 4, matrixOut: 2, level: -150 }) + expectWrite(r, '/Matrix_Inputs/4/Matrix_Send/2/send_level', ',f', -90, 'matrix_send_level_set -150 is clamped to -90') + r = await run('matrix_send_level_adjust', { matrixIn: 4, matrixOut: 2, delta: 1 }) + expectWrite(r, '/Matrix_Inputs/4/Matrix_Send/2/send_level', ',f', -60, 'matrix_send_level_adjust +1 from -90 jumps to -60') + r = await run('matrix_send_level_adjust', { matrixIn: 4, matrixOut: 2, delta: -35 }) + expectWrite(r, '/Matrix_Inputs/4/Matrix_Send/2/send_level', ',f', -90, 'matrix_send_level_adjust below -90 lands on -90') + check(!sent.some((m) => /^\/Matrix_Inputs\/\d+\/Matrix_Send\/\d+\/send_level$/.test(m.address) && m.args[0] === -150), 'no matrix send write ever carries -150') + r = await run('send_level_set', { channel: 6, buss: 3, level: -150 }) + expectWrite(r, '/Input_Channels/6/Aux_Send/3/send_level', ',f', -150, 'an aux send still goes down to -150') + r = await run('send_level_adjust', { channel: 6, buss: 3, delta: -1 }) + expectWrite(r, '/Input_Channels/6/Aux_Send/3/send_level', ',f', -150, 'aux send adjust -1 from -inf stays at -150') + r = await run('send_level_adjust', { channel: 6, buss: 3, delta: 1 }) + expectWrite(r, '/Input_Channels/6/Aux_Send/3/send_level', ',f', -60, 'aux send adjust +1 from -inf jumps to -60') // control groups (1-based) r = await run('cg_mute', { cg: 1, state: 'on' }) @@ -720,6 +791,21 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb check(S.getParam('input', 33, 'comp_in') === true && cb('input_comp_in_state', { channel: 33 }) === true, 'the replied switch value now drives the feedback (mock default: comp in)') check(cb('input_fader_value', { channel: 5 }) === '-12.5' && cb('input_pan_value', { channel: 13 }) === 'R100' && cb('buss_fader_value', { buss: 24 }) === '-3' && cb('matrix_fader_value', { matrix: 2 }) === '-16' && cb('cg_fader_value', { cg: 2 }) === '-8', 'value feedbacks return dB / pan text') check(cb('snapshot_name_value', {}) === 'Offering' || cb('snapshot_name_value', {}) === S.snapshotName(S.snapshots.current), 'snapshot_name_value follows the current snapshot') + // matrix sends, solo, graphic EQs, channel-strip values and send pan + check(cb('matrix_send_on', { matrixIn: 2, matrixOut: 3 }) === true && cb('matrix_send_level_value', { matrixIn: 2, matrixOut: 3 }) === '-30' && cb('matrix_send_level_value', { matrixIn: 3, matrixOut: 2 }) === '-60', 'matrix_send_on and matrix_send_level_value follow the written matrix sends') + check(cb('matrix_send_level_value', { matrixIn: 5, matrixOut: 5 }) === '--' && cb('matrix_send_on', { matrixIn: 5, matrixOut: 5 }) === false, 'an unloaded matrix send reads "--" / false and is queued for loading') + check(await waitFor(() => sent.some((m) => m.address === '/Matrix_Inputs/5/Matrix_Send/5/send_level/?') && S.getMatrixSend(5, 5).level !== undefined, 2500), 'the unloaded matrix send is queried once and its state arrives') + check(cb('matrix_send_level_value', { matrixIn: 5, matrixOut: 5 }) === '-inf' && cb('matrix_send_on', { matrixIn: 5, matrixOut: 5 }) === false, 'a matrix send at its -90 floor reads "-inf"') + check(cb('matrix_solo', { matrix: 2 }) === false && cb('cg_solo', { cg: 10 }) === true && cb('cg_solo', { cg: 1 }) === false, 'matrix_solo / cg_solo follow the state') + inst.onConsoleMessage(decode(encode('/Matrix_Outputs/2/solo', [1.0])), { address: '127.0.0.1', port: P.mock }) + check(cb('matrix_solo', { matrix: 2 }) === true, 'matrix_solo goes true when the console reports solo') + inst.onConsoleMessage(decode(encode('/Matrix_Outputs/2/solo', [0.0])), { address: '127.0.0.1', port: P.mock }) + check(cb('geq_on', { geq: 1 }) === false && cb('geq_band_value', { geq: 1, band: 5 }) === '4', 'geq_on (switched off earlier) and geq_band_value (band 5 at +4 dB)') + check(cb('geq_on', { geq: 3 }) === false && cb('geq_band_value', { geq: 3, band: 7 }) === '--', 'an unloaded graphic EQ reads false / "--"') + check(await waitFor(() => S.getGeq(3).on !== undefined && S.getGeq(3).gains?.get(7) !== undefined, 2500) && cb('geq_on', { geq: 3 }) === true && cb('geq_band_value', { geq: 3, band: 7 }) === '0', 'the graphic EQ state loads lazily (mock default: in, flat)') + check(cb('send_pan_value', { channel: 2, buss: 3 }) === 'L80', 'send_pan_value (pan 0.1 -> L80)') + check(cb('input_trim_value', { channel: 14 }) === '-3' && cb('input_gain_value', { channel: 14 }) === '34.5' && cb('input_hpf_freq_value', { channel: 14 }) === '88' && cb('buss_trim_value', { buss: 1 }) === '-2' && cb('matrix_trim_value', { matrix: 2 }) === '1.5', 'channel-strip value feedbacks: trim, gain, filter frequency (whole Hz), buss and matrix trim') + check(cb('input_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') await sleep(120) @@ -736,6 +822,10 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb ['input_fader_bar', { channel: 5 }, (-12.5 + 60) / 70], ['buss_fader_bar', { buss: 24 }, (-3 + 60) / 70], ['send_level_bar', { channel: 2, buss: 3 }, (-7 + 60) / 70], + ['matrix_send_level_bar', { matrixIn: 2, matrixOut: 3 }, (-30 + 60) / 70], + ['matrix_fader_bar', { matrix: 2 }, (-16 + 60) / 70], + ['cg_fader_bar', { cg: 2 }, (-8 + 60) / 70], + ['geq_band_bar', { geq: 1, band: 5 }, (4 + 12) / 24], ]) { const res = barOf(id, options) const raw = Buffer.from(res.imageBuffer || '', 'base64') @@ -1261,6 +1351,22 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb const ids2 = ctx.byId.flatMap(([, x]) => x) check(ids2.includes('inst-S1') && !ids2.includes('inst-S2') && ids2.includes('inst-N'), 'a send change re-checks only that send pair; a snapshot change re-checks the snapshot feedbacks') for (const f of [s1, s2, sn]) ctx.feedbacks[f.feedbackId].unsubscribe(f, {}) + // matrix sends and graphic EQ bands are tracked per send pair / per band + const m1 = mk('matrix_send_on', { matrixIn: 2, matrixOut: 3 }, 'inst-M1') + const m2 = mk('matrix_send_level_value', { matrixIn: 3, matrixOut: 2 }, 'inst-M2') + const g1 = mk('geq_band_value', { geq: 1, band: 5 }, 'inst-G1') + const g2 = mk('geq_band_value', { geq: 1, band: 6 }, 'inst-G2') + const g3 = mk('geq_on', { geq: 1 }, 'inst-G3') + for (const f of [m1, m2, g1, g2, g3]) run1(f) + check(inst.watchers.get('msend:2:3')?.has('inst-M1') && inst.watchers.get('msend:3:2')?.has('inst-M2') && inst.watchers.get('geq:1:gain:5')?.has('inst-G1') && inst.watchers.get('geq:1:gain:6')?.has('inst-G2') && inst.watchers.get('geq:1:on')?.has('inst-G3'), 'matrix send pairs and graphic EQ bands register under msend:in:out and geq:n:gain:band keys') + await sleep(150) + ctx.byId.length = 0 + inst.onConsoleMessage(decode(encode('/Matrix_Inputs/2/Matrix_Send/3/send_level', [-25])), { address: '127.0.0.1', port: P.mock }) + inst.onConsoleMessage(decode(encode('/Graphic_EQ/1/geq_gain_5', [3.5])), { address: '127.0.0.1', port: P.mock }) + await sleep(80) + const ids3 = ctx.byId.flatMap(([, x]) => x) + check(ids3.includes('inst-M1') && !ids3.includes('inst-M2') && ids3.includes('inst-G1') && !ids3.includes('inst-G2') && !ids3.includes('inst-G3'), 'a matrix send change re-checks only that send pair, a band change only that band') + for (const f of [m1, m2, g1, g2, g3]) ctx.feedbacks[f.feedbackId].unsubscribe(f, {}) // level_compare follows the kind option const lc = mk('level_compare', { kind: 'cg', cg: 2, comparison: 'above', threshold: -50 }, 'inst-L') run1(lc) diff --git a/tools/README.md b/tools/README.md index a05f4f3..1c1393d 100644 --- a/tools/README.md +++ b/tools/README.md @@ -75,7 +75,7 @@ inputs, `--auxes N` the busses (default 25), and the matrix (8 outputs, 10 input | `/Input_Channels/N/Aux_Send/M/{send_level,send_on,send_pan}/?` | `f` (level dB -150..10, on 0/1, pan 0..1) | | `/Input_Channels/N/Aux_Send/M/send_pre-post/?` | `i` (captured as an int, default 0; settable 0..1, which value means "pre" is an assumption; the Companion module does not use it) | | `/Input_Channels/N/Group_Send/M/group/?` | `f` 0/1 group assign. Default: inputs 1-8 and 18-21 on group 24. The wire type is the constant `SD_GROUP_ASSIGN_WIRE_TYPE` | -| `/Matrix_Inputs/N/Matrix_Send/M/{send_level,send_on}/?` | `f` (default -90 and 0, captured) | +| `/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` | diff --git a/tools/mock-s31.js b/tools/mock-s31.js index 47a7a16..e8a166f 100755 --- a/tools/mock-s31.js +++ b/tools/mock-s31.js @@ -603,6 +603,7 @@ const SD_PROPS = { prepost: { kind: 'int', min: 0, max: 1 }, // send_pre-post; captured as an int, range assumed group: { kind: 'flag', wire: SD_GROUP_ASSIGN_WIRE_TYPE }, // Group_Send/M/group assign 0/1 geqGain: { kind: 'f', min: -12, max: 12 }, + matrixLevel: { kind: 'f', min: -90, max: 10 }, // a matrix send floors at -90 dB (S31: off sends read -90), unlike -150 elsewhere }; /** * Per-channel switches and values answered on inputs and busses (Aux_Outputs and Group_Outputs), @@ -790,7 +791,7 @@ function sdResolve(address) { const input = sdMatrixIn[Number(m[1])]; const snd = input && input.sends[Number(m[2])]; const prop = m[3] === 'send_on' ? 'on' : 'level'; - return snd ? { target: snd, prop, spec: SD_PROPS[prop] } : null; + return snd ? { target: snd, prop, spec: prop === 'level' ? SD_PROPS.matrixLevel : SD_PROPS.on } : null; } m = SD_RE_GEQ.exec(address); if (m) {