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 <output>" (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 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 15:44:43 -04:00
co-authored by Claude Opus 5.5
parent 57645be1e9
commit 90dee14444
9 changed files with 351 additions and 57 deletions
+21 -7
View File
@@ -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)),
+77 -6
View File
@@ -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']
+67 -15
View File
@@ -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 } }
}