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:
@@ -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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user