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