Files
companion-module-digico-s31/src/definitions/actions.js
T
michaelandClaude Opus 5.5 f81d581bf2 Add Group and Aux master actions, feedbacks and preset sections
Groups were only reachable through the generic "Buss:" actions, whose
dropdown mixes all 25 busses, so users could not find group fader
control.

- New "Group:" and "Aux master:" mute, solo, set fader (with fade time)
  and adjust fader actions; their dropdowns list only busses of that
  type. Matching muted, solo, fader text/position, meter, signal and
  peak feedbacks.
- The old buss_* actions and feedbacks keep working for existing
  buttons and are now labelled "Any buss (aux or group)".
- Presets: "Busses" is split into "Groups" and "Aux masters", and the
  bus meters into "Group meters" and "Aux meters". A bus that changes
  type on the console moves section on the next rebuild.

npm test: 542 checks pass on Node 25 and on Companion's Node 22.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:57:01 -04:00

418 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FADE_CURVES, 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)
},
/** Several busses; `kind` 'aux' or 'group' lists only busses of that type (without the suffix), 'all' lists every buss. */
busses: (inst, id = 'busses', label = 'Busses', kind = 'all') =>
opt.multi(id, label, kind === 'aux' ? inst.state.choices.auxChoices() : kind === 'group' ? inst.state.choices.groupChoices() : 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) }),
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)', min = LEVEL.min) => ({
type: 'number',
id,
label,
default: 0,
min,
max: LEVEL.max,
step: 0.5,
showMinAsNegativeInfinity: true,
}),
/** Fade time of a "set level" action; the curve choice shows only while it is above 0 (so `duration` cannot be an expression). */
duration: (id = 'duration') => ({
type: 'number',
id,
label: 'Fade time (ms, 0 = instant)',
default: 0,
min: 0,
max: 60000,
step: 100,
asInteger: true,
disableAutoExpression: true,
}),
curve: (id = 'curve') => ({
type: 'dropdown',
id,
label: 'Fade curve',
default: 'fader',
choices: [
{ id: 'fader', label: 'Fader travel (natural)' },
{ id: 'ease', label: 'Fader travel, ease in/out' },
{ id: 'db', label: 'Linear in dB' },
],
isVisibleExpression: '$(options:duration) > 0',
}),
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. */
/** 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.'
/** Said in every level set action: the optional fade. */
const FADE_NOTE = 'With a fade time above 0 the level moves from where it is now to the target in steps of 50 ms; another set, adjust or fade on the same level ends it.'
/** [fade time in ms (0 = instant), curve] from a set-level action's options; missing options (buttons made before fades existed) mean instant. */
const fadeArgs = (options) => [Number(options?.duration) > 0 ? Math.min(60000, Math.round(Number(options.duration))) : 0, FADE_CURVES.includes(options?.curve) ? options.curve : 'fader']
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', 'matrix' and/or 'minput' (matrix input). */
export const paramEntities = (param) => Object.keys(PARAMS[param].paths)
/** Entity dropdown for a parameter's entity kind. */
export const paramEntityOption = (inst, ent) => (ent === 'input' ? opt.channel(inst) : ent === 'matrix' ? opt.matrix(inst) : ent === 'minput' ? opt.matrixIn(inst) : opt.buss(inst, 'all'))
/** Display name of an entity kind in action and feedback names. */
export const ENTITY_TITLE = { input: 'Input', buss: 'Buss', matrix: 'Matrix output', minput: 'Matrix input' }
/** Switch toggles and value set/adjust actions for the lazily loaded per-channel parameters (PARAMS). */
function getParamActions(inst) {
const out = {}
for (const [param, p] of Object.entries(PARAMS)) {
for (const ent of paramEntities(param)) {
const entOpt = paramEntityOption(inst, ent)
const who = ENTITY_TITLE[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)
/**
* Mute, solo and fader actions for one type of buss (`prefix` 'group' or 'aux'). They are thin wrappers over the same
* buss operations as the buss_* actions (the address follows the buss's aux / group side), with the dropdowns
* filtered by type. The option ids are the same as the buss_* ones.
*/
const bussActions = (prefix, title, one, many) => ({
[`${prefix}_mute`]: {
name: `${title}: mute`,
description: 'Mute, unmute or toggle one or more.',
options: [opt.busses(inst, 'busses', many, prefix), opt.state()],
callback: async ({ options }) => void (await each(options.busses, (b) => inst.setSwitch('buss', 'mute', b, options.state))),
},
[`${prefix}_solo`]: {
name: `${title}: solo`,
options: [opt.busses(inst, 'busses', many, prefix), opt.state()],
callback: async ({ options }) => void (await each(options.busses, (b) => inst.setSwitch('buss', 'solo', b, options.state))),
},
[`${prefix}_fader_set`]: {
name: `${title}: set fader`,
description: FADE_NOTE,
options: [opt.buss(inst, prefix, 'buss', one), opt.level(), opt.duration(), opt.curve()],
callback: async ({ options }) => inst.setFader('buss', n(options.buss), n(options.level), ...fadeArgs(options)),
},
[`${prefix}_fader_adjust`]: {
name: `${title}: adjust fader (relative)`,
description: `For rotary encoders. ${ADJUST_NOTE}`,
options: [opt.buss(inst, prefix, 'buss', one), opt.delta()],
callback: async ({ options }) => inst.adjustFader('buss', n(options.buss), n(options.delta)),
},
})
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',
description: FADE_NOTE,
options: [opt.channel(inst), opt.level(), opt.duration(), opt.curve()],
callback: async ({ options }) => inst.setFader('input', n(options.channel), n(options.level), ...fadeArgs(options)),
},
input_fader_adjust: {
name: 'Input: adjust fader (relative)',
description: `For rotary encoders. ${ADJUST_NOTE}`,
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',
description: FADE_NOTE,
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.level(), opt.duration(), opt.curve()],
callback: async ({ options }) => inst.setSendLevel(n(options.channel), n(options.buss), n(options.level), ...fadeArgs(options)),
},
send_level_adjust: {
name: 'Aux send: adjust level (relative)',
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)),
},
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),
},
...bussActions('group', 'Group', 'Group', 'Groups'),
...bussActions('aux', 'Aux master', 'Aux master', 'Aux masters'),
buss_mute: {
name: 'Any buss (aux or group): mute',
description: 'Mute, unmute or toggle one or more busses, aux and group alike (one list of all busses).',
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: 'Any buss (aux or group): 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: 'Any buss (aux or group): set fader',
description: FADE_NOTE,
options: [opt.buss(inst), opt.level(), opt.duration(), opt.curve()],
callback: async ({ options }) => inst.setFader('buss', n(options.buss), n(options.level), ...fadeArgs(options)),
},
buss_fader_adjust: {
name: 'Any buss (aux or group): 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)),
},
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_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',
description: FADE_NOTE,
options: [opt.matrix(inst), opt.level(), opt.duration(), opt.curve()],
callback: async ({ options }) => inst.setFader('matrix', n(options.matrix), n(options.level), ...fadeArgs(options)),
},
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)),
},
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',
description: `A matrix send goes down to −90 dB (−∞). ${FADE_NOTE}`,
options: [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut'), opt.level('level', 'Level (dB)', LEVEL.matrixSendMin), opt.duration(), opt.curve()],
callback: async ({ options }) => inst.setMatrixSendLevel(n(options.matrixIn), n(options.matrixOut), n(options.level), ...fadeArgs(options)),
},
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)),
},
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',
description: FADE_NOTE,
options: [opt.cg(inst), opt.level(), opt.duration(), opt.curve()],
callback: async ({ options }) => inst.setFader('cg', n(options.cg), n(options.level), ...fadeArgs(options)),
},
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)),
},
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),
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)),
},
geq_band_adjust: {
name: 'Graphic EQ: adjust band gain (relative)',
options: [
opt.geq(inst),
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)),
},
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() },
fade_stop: {
name: 'Stop all fades',
description: 'Ends every running level fade where it is.',
options: [],
callback: async () => inst.stopFades(),
},
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(),
},
})
}