v2: generic S-Series module with live discovery, dynamic options and meters

Rewrite of the module around what the console itself reports:

- Settings cut to console address/ports, the iPad relay and debug logging;
  nothing site-specific remains. An upgrade script carries v1 configs and
  remaps v1 actions/feedbacks whose ids were reused.
- On connect the module reads every name, buss type (aux/group), stereo
  mode, fader/mute/solo/pan and the snapshot list; entities that never
  answer are hidden. Definitions rebuild (debounced, skipped when
  identical) when names, buss types or snapshots change.
- Every entity option is a searchable dropdown of live names. 70 actions
  cover inputs, aux sends, group assigns, busses, matrix outputs and sends,
  control groups, graphic EQs, channel-strip switches, trim/gain/filters and
  snapshots, each level with set and encoder-friendly adjust variants.
- Boolean, value and bar feedbacks for every state, input level and
  gain-reduction meters via the console's meter subscription protocol, and
  about 2800 presets including rotary knob presets with live level text.
- A write guard keeps fast encoder turns from being undone by delayed
  echoes; feedbacks are re-checked per instance.
- Relay: the client table survives config changes, idle iPads are kept for
  an hour by default (0 = forever), bind failures retry and show in status.

npm test: 326 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-15 17:11:17 -04:00
co-authored by Claude Opus 5.5
parent 8693fe924a
commit dad8010239
20 changed files with 5102 additions and 1327 deletions
+324
View File
@@ -0,0 +1,324 @@
import { LEVEL, LIMITS, PARAMS } from '../protocol.js'
export const STATE_CHOICES = [
{ id: 'on', label: 'On' },
{ id: 'off', label: 'Off' },
{ id: 'toggle', label: 'Toggle' },
]
const firstId = (choices, fallback = 1) => (choices.length ? choices[0].id : fallback)
/** Entity option builders shared by actions and feedbacks. Choices are read from the live state when the definitions are built. */
export const opt = {
dropdown(id, label, choices, extra = {}) {
return { type: 'dropdown', id, label, choices, default: firstId(choices), minChoicesForSearch: 0, ...extra }
},
multi(id, label, choices) {
const def = choices.length ? [choices[0].id] : []
return { type: 'multidropdown', id, label, choices, default: def, minSelection: 1, minChoicesForSearch: 0 }
},
channel: (inst, id = 'channel', label = 'Input') => opt.dropdown(id, label, inst.state.choices.inputs()),
channels: (inst, id = 'channels', label = 'Inputs') => opt.multi(id, label, inst.state.choices.inputs()),
buss: (inst, kind = 'all', id = 'buss', label) => {
const choices = kind === 'aux' ? inst.state.choices.auxChoices() : kind === 'group' ? inst.state.choices.groupChoices() : inst.state.choices.busses()
return opt.dropdown(id, label || (kind === 'aux' ? 'Aux buss' : kind === 'group' ? 'Group buss' : 'Buss'), choices)
},
busses: (inst, id = 'busses', label = 'Busses') => opt.multi(id, label, inst.state.choices.busses()),
matrix: (inst, id = 'matrix', label = 'Matrix output') => opt.dropdown(id, label, inst.state.choices.matrix()),
matrixIn: (inst, id = 'matrixIn', label = 'Matrix input') => opt.dropdown(id, label, inst.state.choices.matrixInputs()),
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) }),
state: (label = 'State') => ({ type: 'dropdown', id: 'state', label, choices: STATE_CHOICES, default: 'toggle' }),
level: (id = 'level', label = 'Level (dB)') => ({
type: 'number',
id,
label,
default: 0,
min: LEVEL.min,
max: LEVEL.max,
step: 0.5,
showMinAsNegativeInfinity: true,
}),
delta: (id = 'delta', label = 'Change (dB, for encoders)') => ({ type: 'number', id, label, default: 1, min: -30, max: 30, step: 0.5 }),
pan: (id = 'pan', label = 'Pan (0 = left, 0.5 = centre, 1 = right)') => ({ type: 'number', id, label, default: 0.5, min: 0, max: 1, step: 0.01 }),
pdelta: (id = 'pdelta', label = 'Pan change (for encoders)') => ({ type: 'number', id, label, default: 0.05, min: -1, max: 1, step: 0.01 }),
}
/** Normalise a multidropdown / dropdown value to an array of numbers. */
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'. */
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'))
/** Switch toggles and value set/adjust actions for the lazily loaded per-channel parameters (PARAMS). */
function getParamActions(inst) {
const out = {}
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 target = (options) => Number(options[entOpt.id])
if (p.type === 'switch') {
out[`${ent}_${param}`] = {
name: `${who}: ${p.label} (on/off)`,
description: 'The current state is read from the console the first time it is needed.',
options: [paramEntityOption(inst, ent), opt.state()],
callback: async ({ options }) => inst.setParamSwitch(ent, target(options), param, options.state),
}
continue
}
const unit = p.adjust === 'percent' ? '%' : p.unit
out[`${ent}_${param}_set`] = {
name: `${who}: set ${p.label.toLowerCase()}`,
description: `Value in ${p.unit}, as the console reports it (not verified on hardware: read the value back first).`,
options: [paramEntityOption(inst, ent), { type: 'number', id: 'value', label: `${p.label} (${p.unit})`, default: p.min < 0 ? 0 : p.min, min: p.min, max: p.max, step: p.step }],
callback: async ({ options }) => inst.setParamValue(ent, target(options), param, Number(options.value)),
}
out[`${ent}_${param}_adjust`] = {
name: `${who}: adjust ${p.label.toLowerCase()} (relative)`,
description: p.adjust === 'percent' ? 'For rotary encoders: each step changes the frequency by this percentage.' : 'For rotary encoders.',
options: [
paramEntityOption(inst, ent),
{ type: 'number', id: 'delta', label: `Change (${unit})`, default: p.delta, min: -(p.adjust === 'percent' ? 100 : (p.max - p.min) / 2), max: p.adjust === 'percent' ? 100 : (p.max - p.min) / 2, step: p.adjust === 'percent' ? 1 : p.step },
],
callback: async ({ options }) => inst.adjustParamValue(ent, target(options), param, Number(options.delta)),
}
}
}
return out
}
/** Action callbacks return nothing, and an error inside one is logged instead of thrown into Companion. */
function quiet(inst, defs) {
for (const def of Object.values(defs)) {
const run = def.callback
def.callback = async (event, context) => {
try {
await run(event, context)
} catch (err) {
inst.log('error', `${event?.actionId || 'action'}: ${err.message}`)
}
}
}
return defs
}
export function getActionDefinitions(inst) {
const each = (list, fn) => Promise.all(numbers(list).map(fn))
const n = (v) => Number(v)
return quiet(inst, {
...getParamActions(inst),
input_mute: {
name: 'Input: mute',
description: 'Mute, unmute or toggle one or more inputs.',
options: [opt.channels(inst), opt.state()],
callback: async ({ options }) => void (await each(options.channels, (ch) => inst.setSwitch('input', 'mute', ch, options.state))),
},
input_solo: {
name: 'Input: solo',
options: [opt.channels(inst), opt.state()],
callback: async ({ options }) => void (await each(options.channels, (ch) => inst.setSwitch('input', 'solo', ch, options.state))),
},
input_fader_set: {
name: 'Input: set fader',
options: [opt.channel(inst), opt.level()],
callback: async ({ options }) => inst.setFader('input', n(options.channel), n(options.level)),
},
input_fader_adjust: {
name: 'Input: adjust fader (relative)',
description: 'For rotary encoders. From -inf the first step up lands at -60 dB.',
options: [opt.channel(inst), opt.delta()],
callback: async ({ options }) => inst.adjustFader('input', n(options.channel), n(options.delta)),
},
input_pan_set: {
name: 'Input: set pan',
options: [opt.channel(inst), opt.pan()],
callback: async ({ options }) => inst.setPan(n(options.channel), n(options.pan)),
},
input_pan_adjust: {
name: 'Input: adjust pan (relative)',
options: [opt.channel(inst), opt.pdelta()],
callback: async ({ options }) => inst.adjustPan(n(options.channel), n(options.pdelta)),
},
input_name_set: {
name: 'Input: set name',
options: [opt.channel(inst), { type: 'textinput', id: 'name', label: 'Name', default: '', useVariables: true }],
callback: async ({ options }) => inst.setInputName(n(options.channel), String(options.name ?? '')),
},
send_on: {
name: 'Aux send: on/off',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.state()],
callback: async ({ options }) => inst.setSendOn(n(options.channel), n(options.buss), options.state),
},
send_level_set: {
name: 'Aux send: set level',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.level()],
callback: async ({ options }) => inst.setSendLevel(n(options.channel), n(options.buss), n(options.level)),
},
send_level_adjust: {
name: 'Aux send: adjust level (relative)',
description: 'For rotary encoders. From -inf the first step up lands at -60 dB.',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.delta()],
callback: async ({ options }) => inst.adjustSendLevel(n(options.channel), n(options.buss), n(options.delta)),
},
send_pan_set: {
name: 'Aux send: set pan',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.pan()],
callback: async ({ options }) => inst.setSendPan(n(options.channel), n(options.buss), n(options.pan)),
},
send_pan_adjust: {
name: 'Aux send: adjust pan (relative)',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.pdelta()],
callback: async ({ options }) => inst.adjustSendPan(n(options.channel), n(options.buss), n(options.pdelta)),
},
group_assign: {
name: 'Group: assign input on/off',
options: [opt.channel(inst), opt.buss(inst, 'group'), opt.state()],
callback: async ({ options }) => inst.setGroup(n(options.channel), n(options.buss), options.state),
},
buss_mute: {
name: 'Buss: mute',
options: [opt.busses(inst), opt.state()],
callback: async ({ options }) => void (await each(options.busses, (b) => inst.setSwitch('buss', 'mute', b, options.state))),
},
buss_solo: {
name: 'Buss: solo',
options: [opt.busses(inst), opt.state()],
callback: async ({ options }) => void (await each(options.busses, (b) => inst.setSwitch('buss', 'solo', b, options.state))),
},
buss_fader_set: {
name: 'Buss: set fader',
options: [opt.buss(inst), opt.level()],
callback: async ({ options }) => inst.setFader('buss', n(options.buss), n(options.level)),
},
buss_fader_adjust: {
name: 'Buss: adjust fader (relative)',
options: [opt.buss(inst), opt.delta()],
callback: async ({ options }) => inst.adjustFader('buss', n(options.buss), n(options.delta)),
},
matrix_mute: {
name: 'Matrix output: mute',
options: [opt.matrix(inst), opt.state()],
callback: async ({ options }) => inst.setSwitch('matrix', 'mute', n(options.matrix), options.state),
},
matrix_fader_set: {
name: 'Matrix output: set fader',
options: [opt.matrix(inst), opt.level()],
callback: async ({ options }) => inst.setFader('matrix', n(options.matrix), n(options.level)),
},
matrix_fader_adjust: {
name: 'Matrix output: adjust fader (relative)',
options: [opt.matrix(inst), opt.delta()],
callback: async ({ options }) => inst.adjustFader('matrix', n(options.matrix), n(options.delta)),
},
matrix_send_on: {
name: 'Matrix send: on/off',
options: [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut'), opt.state()],
callback: async ({ options }) => inst.setMatrixSendOn(n(options.matrixIn), n(options.matrixOut), options.state),
},
matrix_send_level_set: {
name: 'Matrix send: set level',
options: [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut'), opt.level()],
callback: async ({ options }) => inst.setMatrixSendLevel(n(options.matrixIn), n(options.matrixOut), n(options.level)),
},
matrix_send_level_adjust: {
name: 'Matrix send: adjust level (relative)',
options: [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut'), opt.delta()],
callback: async ({ options }) => inst.adjustMatrixSendLevel(n(options.matrixIn), n(options.matrixOut), n(options.delta)),
},
cg_mute: {
name: 'Control group: mute',
options: [opt.cg(inst), opt.state()],
callback: async ({ options }) => inst.setSwitch('cg', 'mute', n(options.cg), options.state),
},
cg_solo: {
name: 'Control group: solo',
options: [opt.cg(inst), opt.state()],
callback: async ({ options }) => inst.setSwitch('cg', 'solo', n(options.cg), options.state),
},
cg_fader_set: {
name: 'Control group: set fader',
options: [opt.cg(inst), opt.level()],
callback: async ({ options }) => inst.setFader('cg', n(options.cg), n(options.level)),
},
cg_fader_adjust: {
name: 'Control group: adjust fader (relative)',
options: [opt.cg(inst), opt.delta()],
callback: async ({ options }) => inst.adjustFader('cg', n(options.cg), n(options.delta)),
},
geq_on: {
name: 'Graphic EQ: in/out',
options: [opt.geq(inst), opt.state('State')],
callback: async ({ options }) => inst.setGeqOn(n(options.geq), options.state),
},
geq_band_set: {
name: 'Graphic EQ: set band gain',
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 },
{ 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)),
},
geq_band_adjust: {
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 },
{ 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)),
},
snapshot_fire: {
name: 'Snapshot: fire',
description: 'Pick a snapshot, or type a snapshot index. Fires by snapshot list position (0 = first). Verify once on your console before using live.',
options: [opt.snapshot(inst)],
callback: async ({ options }) => inst.fireSnapshot(n(options.snapshot)),
},
snapshot_next: { name: 'Snapshot: next', options: [], callback: async () => inst.fireNextSnapshot() },
snapshot_prev: { name: 'Snapshot: previous', options: [], callback: async () => inst.firePrevSnapshot() },
reload: {
name: 'Console: reload names and levels',
options: [],
callback: async () => inst.reload(),
},
raw_osc: {
name: 'Send raw OSC message',
description: 'For experiments: any address, optional single argument. Append /? to an address to query it.',
options: [
{ type: 'textinput', id: 'address', label: 'Address', default: '/Console/Name/?', useVariables: true },
{
type: 'dropdown',
id: 'argType',
label: 'Argument type',
default: 'none',
choices: [
{ id: 'none', label: 'None' },
{ id: 'int', label: 'Integer' },
{ id: 'float', label: 'Float' },
{ id: 'string', label: 'String' },
{ id: 'bool', label: 'Boolean (T/F)' },
],
},
{ type: 'textinput', id: 'value', label: 'Value', default: '', useVariables: true },
],
callback: async ({ options }) => inst.sendRaw(options),
},
relay_forget: {
name: 'Relay: forget all iPad clients',
options: [],
callback: async () => inst.forgetRelayClients(),
},
})
}
+321
View File
@@ -0,0 +1,321 @@
import { combineRgb } from '@companion-module/base'
import { LEVEL, METERS, PARAMS, clamp, fmtDb, fmtPan, meterId } from '../protocol.js'
import { horizontalBar, verticalMeter } from '../image.js'
import { opt, paramEntities, paramEntityOption } from './actions.js'
export const COLORS = {
white: combineRgb(255, 255, 255),
black: combineRgb(0, 0, 0),
red: combineRgb(190, 30, 30),
green: combineRgb(0, 150, 55),
amber: combineRgb(220, 150, 0),
dark: combineRgb(30, 30, 30),
bar: combineRgb(0, 200, 90),
}
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)
const colorOption = { type: 'colorpicker', id: 'color', label: 'Bar colour', default: COLORS.bar, returnType: 'number' }
const KIND_VISIBLE = (kind) => `$(options:kind) == '${kind}'`
/**
* Feedback instances are tracked per thing they watch (an entity, a send pair, a meter id, the snapshot, the
* link): each callback registers `fb.id` under a watch key, and a state change re-checks just those instances
* (checkFeedbacksById). The per-type check is only a fallback for keys nobody is known to watch.
*/
export const watchKey = {
entity: (ent, num) => `${ent}:${num}`,
send: (ch, buss) => `send:${ch}:${buss}`,
group: (ch, buss) => `group:${ch}:${buss}`,
meter: (id) => `meter:${id}`,
snapshot: 'snapshot',
connected: 'connected',
relay: 'relay',
}
export function getFeedbackDefinitions(inst) {
const s = inst.state
/** Wrap a callback so the instance is registered under its watch key (key(options) may be null) before it runs. */
const tracked = (key, callback) => (fb, context) => {
if (key) inst.watch(fb, key(fb.options))
return callback(fb, context)
}
const unsubscribe = (fb) => inst.unwatch(fb)
const bool = (name, style, options, key, callback) => ({ type: 'boolean', name, defaultStyle: style, options, callback: tracked(key, callback), unsubscribe })
const value = (name, options, key, callback) => ({ type: 'value', name, options, callback: tracked(key, callback), unsubscribe })
const bar = (name, options, key, make) => ({
type: 'advanced',
name,
affectedProperties: ['imageBuffer'],
options: [...options, colorOption],
callback: tracked(key, (fb) => {
if (!fb.image) return {}
return make(fb, fb.image)
}),
unsubscribe,
})
const RED = { bgcolor: COLORS.red, color: COLORS.white }
const GREEN = { bgcolor: COLORS.green, color: COLORS.white }
const AMBER = { bgcolor: COLORS.amber, color: COLORS.black }
const faderOf = (o) => {
switch (o.kind) {
case 'buss':
return s.getFader('buss', n(o.buss))
case 'matrix':
return s.getFader('matrix', n(o.matrix))
case 'cg':
return s.getFader('cg', n(o.cg))
default:
return s.getFader('input', n(o.channel))
}
}
const compareKey = (o) => {
const kind = ['buss', 'matrix', 'cg'].includes(o.kind) ? o.kind : 'input'
return watchKey.entity(kind, n(o[{ input: 'channel', buss: 'buss', matrix: 'matrix', cg: 'cg' }[kind]]))
}
const compare = (v, o) => v !== undefined && (o.comparison === 'below' ? v < n(o.threshold) : v > n(o.threshold))
const comparisonOption = {
type: 'dropdown',
id: 'comparison',
label: 'Is',
default: 'above',
choices: [
{ id: 'above', label: 'Above' },
{ id: 'below', label: 'Below' },
],
}
const thresholdOption = { type: 'number', id: 'threshold', label: 'Threshold (dB)', default: -20, min: LEVEL.min, max: LEVEL.max, step: 0.5, showMinAsNegativeInfinity: true }
const meterOption = {
type: 'dropdown',
id: 'meter',
label: 'Meter',
default: 'level',
choices: Object.entries(METERS).map(([id, m]) => ({ id, label: m.label })),
}
const meterName = (o) => (METERS[o.meter] ? o.meter : 'level')
const meterKey = (o) => watchKey.meter(meterId(n(o.channel), meterName(o)))
const meterOf = (options) => {
const ch = n(options.channel)
const meter = meterName(options)
inst.subscribeInputMeter(ch, meter)
return s.getMeter(meterId(ch, meter))
}
const entityKey = (ent, id) => (o) => watchKey.entity(ent, n(o[id]))
const sendKey = (o) => watchKey.send(n(o.channel), n(o.buss))
/** 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
for (const ent of paramEntities(param)) {
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`,
style,
[entOption],
entityKey(ent, entOption.id),
({ options }) => {
const target = n(options[entOption.id])
inst.ensureParam(ent, target, param)
return s.getParam(ent, target, param) === true
},
)
}
}
return {
...paramFeedbacks,
input_muted: bool('Input: muted', RED, [opt.channel(inst)], entityKey('input', 'channel'), ({ options }) => s.getMute('input', n(options.channel)) === true),
input_solo: bool('Input: solo on', AMBER, [opt.channel(inst)], entityKey('input', 'channel'), ({ options }) => s.getSolo('input', n(options.channel)) === true),
send_on: bool('Aux send: on', GREEN, [opt.channel(inst), opt.buss(inst, 'aux')], sendKey, ({ options }) => {
inst.ensureSend(n(options.channel), n(options.buss))
return s.getSend(n(options.channel), n(options.buss)).on === true
}),
group_assigned: bool('Group: input assigned', GREEN, [opt.channel(inst), opt.buss(inst, 'group')], (o) => watchKey.group(n(o.channel), n(o.buss)), ({ options }) => {
inst.ensureGroup(n(options.channel), n(options.buss))
return s.getGroup(n(options.channel), n(options.buss)) === true
}),
buss_muted: bool('Buss: muted', RED, [opt.buss(inst)], entityKey('buss', 'buss'), ({ options }) => s.getMute('buss', n(options.buss)) === true),
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),
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',
AMBER,
[
{
type: 'dropdown',
id: 'kind',
label: 'What',
default: 'input',
// the entity dropdowns below show or hide on this option, so it must not turn into an expression
disableAutoExpression: true,
choices: [
{ id: 'input', label: 'Input' },
{ id: 'buss', label: 'Buss' },
{ id: 'matrix', label: 'Matrix output' },
{ id: 'cg', label: 'Control group' },
],
},
{ ...opt.channel(inst), isVisibleExpression: KIND_VISIBLE('input') },
{ ...opt.buss(inst), isVisibleExpression: KIND_VISIBLE('buss') },
{ ...opt.matrix(inst), isVisibleExpression: KIND_VISIBLE('matrix') },
{ ...opt.cg(inst), isVisibleExpression: KIND_VISIBLE('cg') },
comparisonOption,
thresholdOption,
],
compareKey,
({ options }) => compare(faderOf(options), options),
),
send_level_compare: bool('Aux send level: above/below', AMBER, [opt.channel(inst), opt.buss(inst, 'aux'), comparisonOption, thresholdOption], sendKey, ({ options }) => {
inst.ensureSend(n(options.channel), n(options.buss))
return compare(s.getSend(n(options.channel), n(options.buss)).level, options)
}),
connected: bool('Console link is alive', GREEN, [], () => watchKey.connected, () => inst.connectedFlag === true),
relay_clients: bool('Relay has at least one iPad', GREEN, [], () => watchKey.relay, () => (inst.relay?.clientCount || 0) > 0),
input_signal: bool(
'Input meter: signal present',
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 }],
meterKey,
({ options }) => {
const m = meterOf(options)
return !!m && m.peakAt > 0 && Date.now() - m.peakAt <= n(options.hold) * 1000
},
),
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))) || '--'),
send_level_value: value('Aux send: level (dB text)', [opt.channel(inst), opt.buss(inst, 'aux')], sendKey, ({ options }) => {
inst.ensureSend(n(options.channel), n(options.buss))
return fmtDb(s.getSend(n(options.channel), n(options.buss)).level) || '--'
}),
send_on_value: value('Aux send: On/Off (text)', [opt.channel(inst), opt.buss(inst, 'aux')], sendKey, ({ options }) => {
inst.ensureSend(n(options.channel), n(options.buss))
const on = s.getSend(n(options.channel), n(options.buss)).on
return on === undefined ? '--' : on ? 'On' : 'Off'
}),
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))) || '--'),
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)' },
],
},
],
meterKey,
({ options }) => {
const v = meterOf(options)?.value ?? 0
return options.unit === 'percent' ? Math.round((v / 127) * 100) : v
},
),
input_fader_bar: bar('Input: fader bar', [opt.channel(inst)], entityKey('input', 'channel'), (fb, image) => {
const v = s.getFader('input', n(fb.options.channel))
return v === undefined ? {} : horizontalBar({ ...image, fraction: levelFraction(v), color: fb.options.color })
}),
send_level_bar: bar('Aux send: level bar', [opt.channel(inst), opt.buss(inst, 'aux')], sendKey, (fb, image) => {
inst.ensureSend(n(fb.options.channel), n(fb.options.buss))
const v = s.getSend(n(fb.options.channel), n(fb.options.buss)).level
return v === undefined ? {} : horizontalBar({ ...image, fraction: levelFraction(v), color: fb.options.color })
}),
buss_fader_bar: bar('Buss: fader bar', [opt.buss(inst)], entityKey('buss', 'buss'), (fb, image) => {
const v = s.getFader('buss', n(fb.options.buss))
return v === undefined ? {} : horizontalBar({ ...image, fraction: levelFraction(v), color: fb.options.color })
}),
input_meter_bar: {
type: 'advanced',
name: 'Input meter: bar (right edge)',
affectedProperties: ['imageBuffer'],
options: [opt.channel(inst), meterOption],
callback: tracked(meterKey, (fb) => {
const m = meterOf(fb.options)
if (!fb.image) return {}
return verticalMeter({ ...fb.image, fraction: (m?.value ?? 0) / 127 })
}),
unsubscribe,
},
}
}
/** The watch key an event concerns, or null (then only the per-type fallback applies). */
export function watchKeyForEvent(ev) {
switch (ev.kind) {
case 'mute':
case 'solo':
case 'fader':
case 'pan':
case 'param':
return watchKey.entity(ev.ent, ev.n)
case 'send':
return watchKey.send(ev.ch, ev.buss)
case 'group':
return watchKey.group(ev.ch, ev.buss)
case 'snapCurrent':
case 'snapName':
return watchKey.snapshot
default:
return null
}
}
/** Fallback: the feedback types to re-check by type when nobody is known to watch the event's key. */
export function feedbackIdsForEvent(ev) {
switch (ev.kind) {
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] || []
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'],
}[ev.ent] || []
)
case 'pan':
return ['input_pan_value']
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']
return []
case 'group':
return ['group_assigned']
case 'param':
return PARAMS[ev.param].type === 'switch' ? [`${ev.ent}_${ev.param}_state`] : []
case 'snapCurrent':
case 'snapName':
return ['snapshot_current', 'snapshot_name_value']
default:
return []
}
}
+265
View File
@@ -0,0 +1,265 @@
import { COLORS } from './feedbacks.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_PRESET_BYTES = 4 * 1024 * 1024
const { white: WHITE, dark: DARK, red: RED, green: GREEN, amber: AMBER, bar: BAR } = COLORS
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) {
const s = inst.state
const L = inst.label
const presets = {}
const structure = []
const nameVar = (ent, n) => `$(${L}:${ent}_${n}_name)`
const localFeedback = (variableName, feedbackId, options) => ({ variableName, variableType: 'feedback', feedbackId, options })
const simple = (name, text, down, feedbacks = [], extra = {}) => ({
type: 'simple',
name,
style: { text, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down, up: [] }],
feedbacks,
...extra,
})
/** Rotary preset: press = `press`, turning = `adjust` with ∓1 (the action's delta option). */
const knob = (name, text, press, adjustActionId, adjustOptions, localVariables, feedbacks) => ({
type: 'simple',
name,
style: { text, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [
{
down: press,
up: [],
rotate_left: [act(adjustActionId, { ...adjustOptions, delta: -1 })],
rotate_right: [act(adjustActionId, { ...adjustOptions, delta: 1 })],
},
],
feedbacks,
localVariables,
})
const group = (id, name, ids) => ({ id, type: 'simple', name, presets: ids })
// ---- Inputs
{
const muteIds = []
const knobIds = []
const soloIds = []
const meterIds = { level: [], gate: [], comp: [] }
for (const n of s.numbers('input')) {
const name = s.displayName('input', n)
const tag = `${n} · ${name}`
presets[`input_mute_${n}`] = simple(`Mute: ${tag}`, nameVar('input', n), [act('input_mute', { channels: [n], state: 'toggle' })], [fb('input_muted', { channel: n }, RED)])
presets[`input_knob_${n}`] = knob(
`Fader knob: ${tag}`,
`${nameVar('input', n)}\\n$(local:level) dB`,
[act('input_mute', { channels: [n], state: 'toggle' })],
'input_fader_adjust',
{ channel: n },
[localFeedback('level', 'input_fader_value', { channel: n })],
[fb('input_muted', { channel: n }, RED), { feedbackId: 'input_fader_bar', options: { channel: n, color: BAR } }],
)
presets[`input_solo_${n}`] = simple(`Solo: ${tag}`, `${nameVar('input', n)}\\nSOLO`, [act('input_solo', { channels: [n], state: 'toggle' })], [fb('input_solo', { channel: n }, AMBER, COLORS.black)])
for (const [meter, prefix, label] of [
['level', 'input_meter', 'Meter'],
['gate', 'input_gate_gr', 'Gate reduction'],
['comp', 'input_comp_gr', 'Compressor reduction'],
]) {
presets[`${prefix}_${n}`] = simple(
`${label}: ${tag}`,
nameVar('input', n),
[],
[
{ feedbackId: 'input_meter_bar', options: { channel: n, meter } },
fb('input_signal', { channel: n, meter, threshold: 5 }, GREEN),
fb('input_peak', { channel: n, meter, hold: 2 }, RED),
],
)
meterIds[meter].push(`${prefix}_${n}`)
}
muteIds.push(`input_mute_${n}`)
knobIds.push(`input_knob_${n}`)
soloIds.push(`input_solo_${n}`)
}
if (muteIds.length) {
structure.push({
id: 'inputs',
name: 'Inputs',
definitions: [group('inputs_mute', 'Mute', muteIds), group('inputs_knob', 'Fader (knob)', knobIds), group('inputs_solo', 'Solo', soloIds)],
})
structure.push({
id: 'meters',
name: 'Input meters',
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),
],
})
}
}
// ---- Busses
{
const muteIds = []
const knobIds = []
for (const n of s.named('buss')) {
const tag = `${n} · ${s.displayName('buss', n)} (${s.bussMode(n)})`
presets[`buss_mute_${n}`] = simple(`Master mute: ${tag}`, nameVar('buss', n), [act('buss_mute', { busses: [n], state: 'toggle' })], [fb('buss_muted', { buss: n }, RED)])
presets[`buss_knob_${n}`] = knob(
`Master fader (knob): ${tag}`,
`${nameVar('buss', n)}\\n$(local:level) dB`,
[act('buss_mute', { busses: [n], state: 'toggle' })],
'buss_fader_adjust',
{ buss: n },
[localFeedback('level', 'buss_fader_value', { buss: n })],
[fb('buss_muted', { buss: n }, RED), { feedbackId: 'buss_fader_bar', options: { buss: n, color: BAR } }],
)
muteIds.push(`buss_mute_${n}`)
knobIds.push(`buss_knob_${n}`)
}
if (muteIds.length) {
structure.push({ id: 'busses', name: 'Busses', definitions: [group('busses_mute', 'Master mute', muteIds), group('busses_knob', 'Master fader (knob)', knobIds)] })
}
}
// ---- Sends to each aux buss
const inputs = s.numbers('input')
for (const b of s.namedBusses('aux')) {
const bname = s.displayName('buss', b)
const onIds = []
const knobIds = []
for (const ch of inputs) {
const cname = s.displayName('input', ch)
const tag = `${ch} · ${cname} → ${b} · ${bname}`
const text = `${nameVar('input', ch)}\\n$(local:level) dB`
const lv = [localFeedback('level', 'send_level_value', { channel: ch, buss: b })]
presets[`send_on_${ch}_${b}`] = simple(`Send on/off: ${tag}`, text, [act('send_on', { channel: ch, buss: b, state: 'toggle' })], [fb('send_on', { channel: ch, buss: b }, GREEN)], { localVariables: lv })
onIds.push(`send_on_${ch}_${b}`)
if (withSendKnobs) {
presets[`send_knob_${ch}_${b}`] = knob(
`Send level (knob): ${tag}`,
text,
[act('send_on', { channel: ch, buss: b, state: 'toggle' })],
'send_level_adjust',
{ channel: ch, buss: b },
lv,
[fb('send_on', { channel: ch, buss: b }, GREEN), { feedbackId: 'send_level_bar', options: { channel: ch, buss: b, color: BAR } }],
)
knobIds.push(`send_knob_${ch}_${b}`)
}
}
const defs = [group(`sends_${b}_on`, 'On/off', onIds)]
if (withSendKnobs) defs.push(group(`sends_${b}_knob`, 'Level (knob)', knobIds))
if (onIds.length) structure.push({ id: `sends_${b}`, name: `Sends to ${bname}`, definitions: defs })
}
// ---- Assign to each group buss
for (const b of s.namedBusses('group')) {
const bname = s.displayName('buss', b)
const ids = []
for (const ch of inputs) {
presets[`group_${ch}_${b}`] = simple(
`Assign: ${ch} · ${s.displayName('input', ch)} → ${b} · ${bname}`,
nameVar('input', ch),
[act('group_assign', { channel: ch, buss: b, state: 'toggle' })],
[fb('group_assigned', { channel: ch, buss: b }, GREEN)],
)
ids.push(`group_${ch}_${b}`)
}
if (ids.length) structure.push({ id: `group_${b}`, name: `Group ${bname}`, definitions: [group(`group_${b}_assign`, 'Assign', ids)] })
}
// ---- Matrix and control groups
{
const muteIds = []
const knobIds = []
for (const n of s.named('matrix')) {
const tag = `${n} · ${s.displayName('matrix', n)}`
presets[`matrix_mute_${n}`] = simple(`Mute: ${tag}`, nameVar('matrix', n), [act('matrix_mute', { matrix: n, state: 'toggle' })], [fb('matrix_muted', { matrix: n }, RED)])
presets[`matrix_knob_${n}`] = knob(
`Fader (knob): ${tag}`,
`${nameVar('matrix', n)}\\n$(local:level) dB`,
[act('matrix_mute', { matrix: n, state: 'toggle' })],
'matrix_fader_adjust',
{ matrix: n },
[localFeedback('level', 'matrix_fader_value', { matrix: n })],
[fb('matrix_muted', { matrix: n }, RED)],
)
muteIds.push(`matrix_mute_${n}`)
knobIds.push(`matrix_knob_${n}`)
}
if (muteIds.length) structure.push({ id: 'matrix', name: 'Matrix', definitions: [group('matrix_mute', 'Mute', muteIds), group('matrix_knob', 'Fader (knob)', knobIds)] })
}
{
const muteIds = []
const knobIds = []
for (const n of s.named('cg')) {
const tag = `${n} · ${s.displayName('cg', n)}`
presets[`cg_mute_${n}`] = simple(`Mute: ${tag}`, nameVar('cg', n), [act('cg_mute', { cg: n, state: 'toggle' })], [fb('cg_muted', { cg: n }, RED)])
presets[`cg_knob_${n}`] = knob(
`Fader (knob): ${tag}`,
`${nameVar('cg', n)}\\n$(local:level) dB`,
[act('cg_mute', { cg: n, state: 'toggle' })],
'cg_fader_adjust',
{ cg: n },
[localFeedback('level', 'cg_fader_value', { cg: n })],
[fb('cg_muted', { cg: n }, RED)],
)
muteIds.push(`cg_mute_${n}`)
knobIds.push(`cg_knob_${n}`)
}
if (muteIds.length) structure.push({ id: 'cgs', name: 'Control groups', definitions: [group('cgs_mute', 'Mute', muteIds), group('cgs_knob', 'Fader (knob)', knobIds)] })
}
// ---- Snapshots
{
const fireIds = []
for (const [index, snap] of [...s.snapshots.list].sort((a, b) => a[0] - b[0])) {
presets[`snap_fire_${index}`] = simple(`Fire ${index} · ${snap.name}`, `${index}\\n${snap.name}`, [act('snapshot_fire', { snapshot: index })], [fb('snapshot_current', { snapshot: index }, GREEN)])
fireIds.push(`snap_fire_${index}`)
}
presets.snap_next = simple('Next snapshot', 'NEXT ▶', [act('snapshot_next', {})])
presets.snap_prev = simple('Previous snapshot', '◀ PREV', [act('snapshot_prev', {})])
presets.snap_current = simple('Current snapshot (display)', `$(${L}:snapshot_index)\\n$(local:snap)`, [], [], {
localVariables: [localFeedback('snap', 'snapshot_name_value', {})],
})
const defs = []
if (fireIds.length) defs.push(group('snapshots_fire', 'Fire', fireIds))
defs.push(group('snapshots_nav', 'Next / previous / current', ['snap_next', 'snap_prev', 'snap_current']))
structure.push({ id: 'snapshots', name: 'Snapshots', definitions: defs })
}
// ---- Console
{
presets.console_reload = simple('Reload from console', `$(${L}:console_name)\\nReload`, [act('reload', {})], [fb('connected', {}, GREEN)])
presets.console_status = simple('Connection status', 'Console', [], [fb('connected', {}, GREEN), { ...fb('connected', {}, RED), isInverted: true }])
presets.console_relay = simple('Relay status', `iPads\\n$(${L}:relay_clients)`, [], [fb('relay_clients', {}, GREEN)])
structure.push({ id: 'console', name: 'Console', definitions: [group('console_all', 'Console', ['console_reload', 'console_status', 'console_relay'])] })
}
return { structure, presets }
}
/**
* 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.
*/
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
count = Object.keys(built.presets).length
bytes = JSON.stringify(built.presets).length
}
return { ...built, stats: { count, bytes, droppedSendKnobs } }
}
+128
View File
@@ -0,0 +1,128 @@
import { fmtCue, fmtDb, fmtPan } from '../protocol.js'
const ENTITY_VARS = {
input: ['name', 'fader', 'mute', 'pan'],
buss: ['name', 'mode', 'stereo', 'fader', 'mute'],
matrix: ['name', 'fader', 'mute'],
cg: ['name', 'fader', 'mute'],
}
const VAR_LABEL = {
name: 'name',
fader: 'fader level (dB)',
mute: 'mute state',
pan: 'pan',
mode: 'mode (Aux/Group)',
stereo: 'Stereo/Mono',
}
const ENT_LABEL = { input: 'Input', buss: 'Buss', matrix: 'Matrix output', cg: 'Control group' }
/** Variables exist only for entities the console reported (all possible ones until the first load finishes). */
export function getVariableDefinitions(inst) {
const defs = {
console_name: { name: 'Console name' },
session_name: { name: 'Session file name' },
connected: { name: 'Console link alive (Yes/No)' },
relay_clients: { name: 'iPad apps connected through the relay' },
relay_client_ips: { name: 'iPad client IPs' },
last_change: { name: 'Last change reported by the console' },
snapshot_index: { name: 'Current snapshot index' },
snapshot_name: { name: 'Current snapshot name' },
snapshot_cue: { name: 'Current snapshot cue number' },
snapshot_count: { name: 'Number of snapshots' },
}
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]}` }
}
}
return defs
}
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 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') {
out[`${p}mode`] = s.bussMode(n) === 'group' ? 'Group' : 'Aux'
const st = s.busses.get(n)?.stereo
out[`${p}stereo`] = st === undefined ? '' : st ? 'Stereo' : 'Mono'
}
return out
}
export function snapshotVariableValues(inst) {
const s = inst.state.snapshots
const cur = s.list.get(s.current)
return {
snapshot_index: s.current ?? '',
snapshot_name: cur?.name ?? '',
snapshot_cue: cur ? fmtCue(cur.cue) : '',
snapshot_count: s.count ?? s.list.size,
}
}
/** Everything, after the definitions were rebuilt. */
export function getAllVariableValues(inst) {
const s = inst.state
const out = { console_name: s.console.name, session_name: s.console.session, connected: inst.connectedFlag ? 'Yes' : 'No', ...snapshotVariableValues(inst) }
for (const ent of Object.keys(ENTITY_VARS)) for (const n of s.numbers(ent)) Object.assign(out, entityVariableValues(inst, ent, n))
return out
}
/** Variable values affected by one event; {} when none. */
export function getEventVariableValues(inst, ev) {
switch (ev.kind) {
case 'consoleName':
return { console_name: ev.value }
case 'session':
return { session_name: ev.value }
case 'name':
case 'fader':
case 'mute':
case 'pan':
return entityVariableValues(inst, ev.ent, ev.n)
case 'snapCurrent':
case 'snapName':
case 'snapCount':
return snapshotVariableValues(inst)
case 'bussTypes':
case 'bussModes': {
// stereo / mono (and aux / group) for every buss; no definition rebuild for a stereo flag alone
const out = {}
for (const n of inst.state.existing('buss')) Object.assign(out, entityVariableValues(inst, 'buss', n))
return out
}
default:
return {}
}
}
/** Short text for last_change, or '' when the event is not worth showing. */
export function describeChange(inst, ev) {
const s = inst.state
const who = (ent, n) => `${ENT_LABEL[ent]} ${n} (${s.displayName(ent, n)})`
switch (ev.kind) {
case 'fader':
return `${who(ev.ent, ev.n)} fader ${fmtDb(ev.value)}`
case 'mute':
return `${who(ev.ent, ev.n)} ${ev.value ? 'muted' : 'unmuted'}`
case 'solo':
return `${who(ev.ent, ev.n)} solo ${ev.value ? 'on' : 'off'}`
case 'pan':
return `${who(ev.ent, ev.n)} pan ${fmtPan(ev.value)}`
case 'send':
return `Input ${ev.ch} send ${ev.buss} ${ev.field} ${ev.field === 'level' ? fmtDb(ev.value) : ev.value}`
case 'group':
return `Input ${ev.ch} group ${ev.buss} ${ev.value ? 'assigned' : 'unassigned'}`
case 'snapCurrent':
return `Snapshot ${ev.value}`
default:
return ''
}
}