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
+58 -26
View File
@@ -10,6 +10,9 @@ import { encode, decode } from './osc.js'
* port, so we bind a fixed local port (the console's "Send Port") and send to
* the console's "Receive Port".
*
* Nothing here throws on the send path: a synchronous failure (bad port, closed socket, an argument
* that cannot be encoded) is reported as an 'error' event with `err.dest` set and send() returns false.
*
* Events: 'raw' (buf, rinfo) for every datagram from the console,
* 'message' (decoded, buf, rinfo), 'stray' (buf, rinfo) for packets from
* other hosts, 'error' (err), 'listening' (addressInfo).
@@ -29,46 +32,75 @@ export class ConsoleLink extends EventEmitter {
open() {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: false })
let socket
let settled = false
socket.on('error', (err) => {
if (!settled) {
settled = true
reject(err)
} else this.emit('error', err)
})
socket.on('message', (buf, rinfo) => {
if (this.host && rinfo.address !== this.host) {
this.emit('stray', buf, rinfo)
return
}
this.lastRx = Date.now()
this.rxCount++
this.emit('raw', buf, rinfo)
this.emit('message', decode(buf), buf, rinfo)
})
socket.bind(this.localPort, this.bindAddress, () => {
const fail = (err) => {
settled = true
this.socket = socket
this.emit('listening', socket.address())
resolve(socket.address())
})
try {
socket?.close()
} catch (_e) {
/* never opened */
}
reject(err)
}
try {
socket = dgram.createSocket({ type: 'udp4', reuseAddr: false })
socket.on('error', (err) => {
if (!settled) fail(err)
else this.emitError(err)
})
socket.on('message', (buf, rinfo) => {
if (this.host && rinfo.address !== this.host) {
this.emit('stray', buf, rinfo)
return
}
this.lastRx = Date.now()
this.rxCount++
this.emit('raw', buf, rinfo)
this.emit('message', decode(buf), buf, rinfo)
})
socket.bind(this.localPort, this.bindAddress, () => {
settled = true
this.socket = socket
this.emit('listening', socket.address())
resolve(socket.address())
})
} catch (err) {
fail(err)
}
})
}
send(address, args = []) {
return this.sendRaw(encode(address, args))
let buf
try {
buf = encode(address, args)
} catch (err) {
this.emitError(err)
return false
}
return this.sendRaw(buf)
}
sendRaw(buf) {
if (!this.socket || !this.host) return false
this.txCount++
this.socket.send(buf, this.port, this.host, (err) => {
if (err) this.emit('error', err)
})
try {
this.socket.send(buf, this.port, this.host, (err) => {
if (err) this.emitError(err)
})
} catch (err) {
this.emitError(err)
return false
}
return true
}
emitError(err) {
err.dest = `${this.host}:${this.port}`
if (this.listenerCount('error')) this.emit('error', err)
}
close() {
const s = this.socket
this.socket = null
-345
View File
@@ -1,345 +0,0 @@
import { combineRgb } from '@companion-module/base'
export const MAX_INPUTS = 60
export const MAX_BUSSES = 25
export const BUS_FIRST = 70
export const BUS_LAST = 93
const GREEN = combineRgb(0, 160, 60)
const RED = combineRgb(190, 30, 30)
const AMBER = combineRgb(200, 140, 0)
const WHITE = combineRgb(255, 255, 255)
const BLACK = combineRgb(0, 0, 0)
const DARK = combineRgb(30, 30, 30)
const STATE_CHOICES = [
{ id: 'on', label: 'On' },
{ id: 'off', label: 'Off' },
{ id: 'toggle', label: 'Toggle' },
]
export const CHANNEL_HELP = 'inputs 1-60; with the S-Series OSC Control set busses are 70-93 and master 120'
function channelOption(id = 'channel', label = 'Input channel (1-60)') {
return { type: 'number', id, label, default: 1, min: 1, max: 120, step: 1 }
}
function bussOption(inst, id = 'send', label = 'Buss number (1-25)') {
return { type: 'number', id, label, default: inst.monSend(), min: 1, max: MAX_BUSSES, step: 1 }
}
function levelOption(inst, id = 'level', label) {
const d = inst.dialect
return {
type: 'number',
id,
label: label || `Level (dB, ${d.levelMin} = off)`,
default: 0,
min: d.levelMin,
max: d.levelMax,
step: 0.5,
required: true,
}
}
function deltaOption() {
return { type: 'number', id: 'delta', label: 'Change (dB)', default: 1, min: -30, max: 30, step: 0.5 }
}
function stateOption(label = 'State') {
return { type: 'dropdown', id: 'state', label, default: 'toggle', choices: STATE_CHOICES }
}
export function getActionDefinitions(inst) {
const mon = inst.monLabel()
const d = inst.dialect
return {
mon_on: {
name: `${mon}: channel on/off (aux send on, or group assign)`,
description: 'Uses the Studio Mon buss from the connection settings; works whether that buss is an aux (send on/off) or a group (assign).',
options: [channelOption(), stateOption()],
callback: async ({ options }) => inst.setMonOn(options.channel, options.state),
},
mon_level: {
name: `${mon}: set send level (aux mode only)`,
options: [channelOption(), levelOption(inst)],
callback: async ({ options }) => inst.setSendLevel(options.channel, inst.monSend(), options.level),
},
mon_nudge: {
name: `${mon}: nudge send level (aux mode only)`,
options: [channelOption(), deltaOption()],
callback: async ({ options }) => inst.nudgeSendLevel(options.channel, inst.monSend(), options.delta),
},
send_on: {
name: 'Aux send: on/off',
options: [channelOption(), bussOption(inst, 'send', 'Aux buss number (1-25)'), stateOption()],
callback: async ({ options }) => inst.setSendEnabled(options.channel, options.send, options.state),
},
send_level: {
name: 'Aux send: set level',
options: [channelOption(), bussOption(inst, 'send', 'Aux buss number (1-25)'), levelOption(inst)],
callback: async ({ options }) => inst.setSendLevel(options.channel, options.send, options.level),
},
send_nudge: {
name: 'Aux send: nudge level',
options: [channelOption(), bussOption(inst, 'send', 'Aux buss number (1-25)'), deltaOption()],
callback: async ({ options }) => inst.nudgeSendLevel(options.channel, options.send, options.delta),
},
send_pan: {
name: 'Aux send: set pan',
options: [
channelOption(),
bussOption(inst, 'send', 'Aux buss number (1-25)'),
{ type: 'number', id: 'pan', label: `Pan (${d.panMin} L … ${d.panMax} R, centre ${d.panCentre})`, default: d.panCentre, min: d.panMin, max: d.panMax, step: 0.05 },
],
callback: async ({ options }) => inst.sendDialect('sendPan', options.channel, options.send, options.pan),
},
group_assign: {
name: 'Group: assign channel on/off',
options: [channelOption(), bussOption(inst, 'group', 'Group buss number (1-25)'), stateOption()],
callback: async ({ options }) => inst.setGroupAssign(options.channel, options.group, options.state),
},
fader: {
name: 'Channel fader (dB)',
options: [channelOption('channel', `Channel number (${CHANNEL_HELP})`), { type: 'number', id: 'level', label: 'Level (dB)', default: 0, min: -150, max: 10, step: 0.5 }],
callback: async ({ options }) => inst.sendDialect('fader', options.channel, options.level),
},
mute: {
name: 'Channel mute',
options: [channelOption('channel', `Channel number (${CHANNEL_HELP})`), stateOption()],
callback: async ({ options }) => inst.setMute(options.channel, options.state),
},
buss_fader: {
name: 'Aux buss master fader (dB)',
options: [bussOption(inst, 'buss', 'Aux buss number (1-25)'), { type: 'number', id: 'level', label: 'Level (dB)', default: 0, min: -150, max: 10, step: 0.5 }],
callback: async ({ options }) => inst.sendDialect('auxFader', options.buss, options.level),
},
buss_mute: {
name: 'Aux buss master mute',
options: [bussOption(inst, 'buss', 'Aux buss number (1-25)'), stateOption()],
callback: async ({ options }) => inst.setBussMute(options.buss, options.state),
},
snapshot_fire: {
name: 'Snapshot: fire number',
options: [{ type: 'number', id: 'number', label: 'Snapshot number', default: 1, min: 0, max: 9999, step: 1 }],
callback: async ({ options }) => inst.sendDialect('snapshotFire', Math.trunc(options.number)),
},
snapshot_next: { name: 'Snapshot: next', options: [], callback: async () => inst.sendDialect('snapshotNext') },
snapshot_prev: { name: 'Snapshot: previous', options: [], callback: async () => inst.sendDialect('snapshotPrev') },
resend: {
name: 'Console: reload names, levels and sends',
options: [],
callback: async () => inst.requestResend(),
},
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.sendRawAction(options),
},
relay_forget: {
name: 'Relay: forget all iPad clients',
options: [],
callback: async () => inst.relay?.forgetClients(),
},
}
}
export function getFeedbackDefinitions(inst) {
const mon = inst.monLabel()
const d = inst.dialect
return {
mon_on: {
type: 'boolean',
name: `${mon}: channel is on (send on / assigned)`,
defaultStyle: { bgcolor: GREEN, color: WHITE },
options: [channelOption()],
callback: ({ options }) => inst.getMonOn(options.channel) === true,
},
send_on: {
type: 'boolean',
name: 'Aux send: is on',
defaultStyle: { bgcolor: GREEN, color: WHITE },
options: [channelOption(), bussOption(inst, 'send', 'Aux buss number (1-25)')],
callback: ({ options }) => inst.getSend(options.channel, options.send).enabled === true,
},
group_assigned: {
type: 'boolean',
name: 'Group: channel is assigned',
defaultStyle: { bgcolor: GREEN, color: WHITE },
options: [channelOption(), bussOption(inst, 'group', 'Group buss number (1-25)')],
callback: ({ options }) => inst.getGroup(options.channel, options.group) === true,
},
mon_level_above: {
type: 'boolean',
name: `${mon}: send level is above`,
defaultStyle: { bgcolor: AMBER, color: BLACK },
options: [channelOption(), { type: 'number', id: 'threshold', label: 'Threshold (dB)', default: -60, min: d.levelMin, max: d.levelMax, step: 0.5 }],
callback: ({ options }) => {
const v = inst.getSend(options.channel, inst.monSend()).level
return typeof v === 'number' && v > options.threshold
},
},
muted: {
type: 'boolean',
name: 'Channel is muted',
defaultStyle: { bgcolor: RED, color: WHITE },
options: [channelOption('channel', `Channel number (${CHANNEL_HELP})`)],
callback: ({ options }) => inst.state.mutes.get(Number(options.channel)) === true,
},
buss_muted: {
type: 'boolean',
name: 'Aux buss master is muted',
defaultStyle: { bgcolor: RED, color: WHITE },
options: [bussOption(inst, 'buss', 'Aux buss number (1-25)')],
callback: ({ options }) => inst.state.bussMutes.get(Number(options.buss)) === true,
},
connected: {
type: 'boolean',
name: 'Console link is alive',
defaultStyle: { bgcolor: GREEN, color: WHITE },
options: [],
callback: () => inst.isConnected(),
},
relay_clients: {
type: 'boolean',
name: 'Relay has at least one iPad connected',
defaultStyle: { bgcolor: GREEN, color: WHITE },
options: [],
callback: () => (inst.relay?.clientCount || 0) > 0,
},
}
}
export function getVariableDefinitions(inst) {
const mon = inst.monLabel()
const defs = {
connected: { name: 'Console link alive (Yes/No)' },
console_name: { name: 'Console name' },
console_host: { name: 'Console IP' },
relay_clients: { name: 'iPad apps connected through the relay' },
relay_client_ips: { name: 'iPad client IPs' },
last_rx: { name: 'Last message received from the console' },
last_send_touched: { name: 'Last send/assign that changed (ch/buss/field)' },
input_count: { name: 'Input channel count reported by the console' },
aux_count: { name: 'Buss count reported by the console' },
snapshot_current: { name: 'Current snapshot index' },
snapshot_name: { name: 'Current snapshot name' },
mon_send: { name: `${mon} buss number (from config)` },
mon_name: { name: `${mon} buss name (from the console)` },
mon_mode: { name: `${mon} buss mode (aux / group)` },
}
for (let n = 1; n <= MAX_INPUTS; n++) defs[`ch_${n}_name`] = { name: `Input ${n} name` }
for (let n = 1; n <= MAX_BUSSES; n++) {
defs[`aux_${n}_name`] = { name: `Buss ${n} name (aux mode)` }
defs[`group_${n}_name`] = { name: `Buss ${n} name (group mode)` }
defs[`buss_${n}_mode`] = { name: `Buss ${n} Stereo/Mono` }
}
for (let n = BUS_FIRST; n <= BUS_LAST; n++) defs[`bus_${n}_name`] = { name: `S-Series OSC channel ${n} name` }
for (let n = 1; n <= MAX_INPUTS; n++) {
defs[`mon_${n}_level`] = { name: `${mon} send level, input ${n} (dB)` }
defs[`mon_${n}_on`] = { name: `${mon} on/off (send on or assigned), input ${n}` }
}
return defs
}
export function getPresetDefinitions(inst) {
const label = inst.label
const mon = inst.monLabel()
const count = Math.min(MAX_INPUTS, Math.max(1, Number(inst.config.channels) || 48))
const groupMode = inst.monMode() === 'group'
const presets = {}
const onIds = []
const upIds = []
const downIds = []
for (let n = 1; n <= count; n++) {
presets[`mon_on_${n}`] = {
type: 'simple',
name: `${mon} ch ${n} on/off`,
style: {
text: groupMode ? `$(${label}:ch_${n}_name)\\n$(${label}:mon_${n}_on)` : `$(${label}:ch_${n}_name)\\n$(${label}:mon_${n}_level)dB`,
size: 'auto',
color: WHITE,
bgcolor: DARK,
},
steps: [{ down: [{ actionId: 'mon_on', options: { channel: n, state: 'toggle' } }], up: [] }],
feedbacks: [{ feedbackId: 'mon_on', options: { channel: n }, style: { bgcolor: GREEN, color: WHITE } }],
}
onIds.push(`mon_on_${n}`)
if (!groupMode) {
presets[`mon_up_${n}`] = {
type: 'simple',
name: `${mon} ch ${n} +1 dB`,
style: { text: `$(${label}:ch_${n}_name)\\n▲ +1`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [{ actionId: 'mon_nudge', options: { channel: n, delta: 1 } }], up: [] }],
feedbacks: [],
}
presets[`mon_down_${n}`] = {
type: 'simple',
name: `${mon} ch ${n} -1 dB`,
style: { text: `$(${label}:ch_${n}_name)\\n▼ -1`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [{ actionId: 'mon_nudge', options: { channel: n, delta: -1 } }], up: [] }],
feedbacks: [],
}
upIds.push(`mon_up_${n}`)
downIds.push(`mon_down_${n}`)
}
}
presets.resend = {
type: 'simple',
name: 'Reload from console',
style: { text: `$(${label}:console_name)\\nReload`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [{ actionId: 'resend', options: {} }], up: [] }],
feedbacks: [{ feedbackId: 'connected', options: {}, style: { bgcolor: GREEN, color: WHITE } }],
}
presets.snapshot_next = {
type: 'simple',
name: 'Snapshot next',
style: { text: `$(${label}:snapshot_name)\\nNEXT ▶`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [{ actionId: 'snapshot_next', options: {} }], up: [] }],
feedbacks: [],
}
presets.snapshot_prev = {
type: 'simple',
name: 'Snapshot previous',
style: { text: `◀ PREV\\n$(${label}:snapshot_name)`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [{ actionId: 'snapshot_prev', options: {} }], up: [] }],
feedbacks: [],
}
presets.relay_status = {
type: 'simple',
name: 'Relay status',
style: { text: `iPads\\n$(${label}:relay_clients)`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [], up: [] }],
feedbacks: [{ feedbackId: 'relay_clients', options: {}, style: { bgcolor: GREEN, color: WHITE } }],
}
const monDefs = [{ id: 'mon_on', type: 'simple', name: `${mon} on/off per input`, presets: onIds }]
if (!groupMode) {
monDefs.push({ id: 'mon_up', type: 'simple', name: `${mon} +1 dB per input`, presets: upIds })
monDefs.push({ id: 'mon_down', type: 'simple', name: `${mon} -1 dB per input`, presets: downIds })
}
const structure = [
{ id: 'mon', name: `${mon} (buss ${inst.monSend()}, ${groupMode ? 'group assign' : 'aux send'})`, definitions: monDefs },
{
id: 'console',
name: 'Console',
definitions: [{ id: 'console', type: 'simple', name: 'Console', presets: ['resend', 'snapshot_next', 'snapshot_prev', 'relay_status'] }],
},
]
return { structure, presets }
}
+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 ''
}
}
-200
View File
@@ -1,200 +0,0 @@
/**
* Address dialects the console link can speak.
*
* sd The "DiGiCo Pad" tree (/Input_Channels/N/Aux_Send/M/send_level …). This is
* what the S31's *iPad* extension speaks — verified live on 2026-10-15 against
* console "S31 AB" (10.3.4.221): queries are the address + "/?", replies come
* back on the same address without "/?", and every reply goes to the controller
* IP:port configured on the console. Levels are dB floats (−150 = off, +10 max),
* send_on / mute are reported as float 0/1 (DiGiCo's SD list says write ints),
* pan is 0..1 with 0.5 centre. The SD iPad app and the stock companion
* digico-osc "IPAD" command set use this same tree.
*
* sseries The S21/S31 *OSC Control* extension (Extensions > OSC Control): lower-case,
* channel-number based (/channel/N/send/M/level, /console/resend …). Inputs 1-60,
* aux busses 70-79, groups 80-93, matrix 100-107, CGs 110-119, master 120. The
* S31's iPad extension does NOT answer these.
*
* raw No parsing, no keepalive: the module is only a transparent relay.
*
* Builders return [address, args] or null when the dialect has no equivalent.
* parse() turns an inbound message into a normalised event or null.
*/
import { toBool, toNumber } from './osc.js'
export const BOOL_STYLES = [
{ id: 'int', label: 'Integer 0/1 (DiGiCo Pad default)' },
{ id: 'float', label: 'Float 0.0/1.0 (as the S31 reports them)' },
{ id: 'tf', label: 'OSC True/False tags (S-Series OSC Control default)' },
]
function boolArg(on, style) {
if (style === 'tf') return !!on
if (style === 'float') return on ? 1 : 0 // plain numbers encode as float32
return { i: on ? 1 : 0 }
}
const sd = {
id: 'sd',
label: 'DiGiCo Pad / iPad extension (/Input_Channels/N/…)',
pongAddress: '/Console/Name',
levelMin: -150,
levelMax: 10,
panMin: 0,
panMax: 1,
panCentre: 0.5,
ping: () => ['/Console/Name/?', []],
/** Everything the module wants after (re)connecting; sent paced by the queue. */
resendQueries({ inputs, auxes, monSend }) {
const q = [
['/Console/Name/?', []],
['/Console/Input_Channels/?', []],
['/Console/Aux_Outputs/?', []],
['/Console/Aux_Outputs/modes/?', []],
['/Snapshots/Current_Snapshot/?', []],
['/Snapshots/names/?', []],
]
for (let a = 1; a <= auxes; a++) {
q.push([`/Aux_Outputs/${a}/Buss_Trim/name/?`, []])
q.push([`/Group_Outputs/${a}/Buss_Trim/name/?`, []])
}
for (let n = 1; n <= inputs; n++) {
q.push([`/Input_Channels/${n}/Channel_Input/name/?`, []])
q.push([`/Input_Channels/${n}/mute/?`, []])
q.push([`/Input_Channels/${n}/Aux_Send/${monSend}/send_level/?`, []])
q.push([`/Input_Channels/${n}/Aux_Send/${monSend}/send_on/?`, []])
q.push([`/Input_Channels/${n}/Group_Send/${monSend}/group/?`, []])
}
// The console has been seen to drop queries sent in the first few ms after a
// connection opens, so ask the cheap global ones again at the end.
q.push(['/Console/Input_Channels/?', []], ['/Console/Aux_Outputs/?', []], ['/Snapshots/Current_Snapshot/?', []])
return q
},
querySend: (ch, send) => [
[`/Input_Channels/${ch}/Aux_Send/${send}/send_level/?`, []],
[`/Input_Channels/${ch}/Aux_Send/${send}/send_on/?`, []],
],
queryGroup: (ch, group) => [[`/Input_Channels/${ch}/Group_Send/${group}/group/?`, []]],
sendLevel: (ch, send, dB) => [`/Input_Channels/${ch}/Aux_Send/${send}/send_level`, [Number(dB)]],
sendEnabled: (ch, send, on, style) => [`/Input_Channels/${ch}/Aux_Send/${send}/send_on`, [boolArg(on, style)]],
sendPan: (ch, send, pan) => [`/Input_Channels/${ch}/Aux_Send/${send}/send_pan`, [Number(pan)]],
groupAssign: (ch, group, on, style) => [`/Input_Channels/${ch}/Group_Send/${group}/group`, [boolArg(on, style)]],
fader: (ch, dB) => [`/Input_Channels/${ch}/fader`, [Number(dB)]],
mute: (ch, on, style) => [`/Input_Channels/${ch}/mute`, [boolArg(on, style)]],
auxFader: (aux, dB) => [`/Aux_Outputs/${aux}/fader`, [Number(dB)]],
auxMute: (aux, on, style) => [`/Aux_Outputs/${aux}/mute`, [boolArg(on, style)]],
snapshotFire: (n) => ['/Snapshots/Fire_Snapshot_number', [{ i: n }]],
snapshotNext: () => ['/Snapshots/Fire_Next_Snapshot', [{ i: 0 }]],
snapshotPrev: () => ['/Snapshots/Fire_Prev_Snapshot', [{ i: 0 }]],
parse(address, args) {
let m
if (address === '/Console/Name') return { kind: 'pong', name: String(args[0] ?? '') }
if (address === '/Console/Input_Channels') return { kind: 'count', field: 'inputs', value: toNumber(args[0]) }
if (address === '/Console/Aux_Outputs') return { kind: 'count', field: 'auxes', value: toNumber(args[0]) }
if (address === '/Console/Group_Outputs') return { kind: 'count', field: 'groups', value: toNumber(args[0]) }
if (address === '/Console/Aux_Outputs/modes') return { kind: 'auxModes', values: args.map(toNumber) }
if ((m = /^\/Input_Channels\/(\d+)\/Aux_Send\/(\d+)\/send_(level|on|pan)$/.exec(address))) {
const field = m[3] === 'on' ? 'enabled' : m[3]
const value = field === 'enabled' ? toBool(args[0]) : toNumber(args[0])
return { kind: 'send', ch: +m[1], send: +m[2], field, value }
}
if ((m = /^\/Input_Channels\/(\d+)\/Group_Send\/(\d+)\/group$/.exec(address))) return { kind: 'group', ch: +m[1], group: +m[2], value: toBool(args[0]) }
if ((m = /^\/Input_Channels\/(\d+)\/Channel_Input\/name$/.exec(address))) return { kind: 'name', ch: +m[1], value: String(args[0] ?? '') }
if ((m = /^\/Input_Channels\/(\d+)\/fader$/.exec(address))) return { kind: 'fader', ch: +m[1], value: toNumber(args[0]) }
if ((m = /^\/Input_Channels\/(\d+)\/mute$/.exec(address))) return { kind: 'mute', ch: +m[1], value: toBool(args[0]) }
if ((m = /^\/Aux_Outputs\/(\d+)\/Buss_Trim\/name$/.exec(address))) return { kind: 'auxName', aux: +m[1], value: String(args[0] ?? '') }
if ((m = /^\/Group_Outputs\/(\d+)\/Buss_Trim\/name$/.exec(address))) return { kind: 'groupName', group: +m[1], value: String(args[0] ?? '') }
if ((m = /^\/Aux_Outputs\/(\d+)\/fader$/.exec(address))) return { kind: 'auxFader', aux: +m[1], value: toNumber(args[0]) }
if ((m = /^\/Aux_Outputs\/(\d+)\/mute$/.exec(address))) return { kind: 'auxMute', aux: +m[1], value: toBool(args[0]) }
if (address === '/Snapshots/Current_Snapshot') return { kind: 'snapshot', index: toNumber(args[0]) }
if ((m = /^\/Snapshots\/Recall_Snapshot\/(\d+)$/.exec(address))) return { kind: 'snapshot', index: +m[1] }
if (address === '/Snapshots/name') {
// [index, cueNumber*100, ?, name]
return { kind: 'snapshotName', index: toNumber(args[0]), cue: toNumber(args[1]) / 100, name: String(args[args.length - 1] ?? '') }
}
return null
},
}
const sseries = {
id: 'sseries',
label: 'S-Series OSC Control extension (/channel/N/…)',
pongAddress: '/console/pong',
levelMin: -90,
levelMax: 10,
panMin: -1,
panMax: 1,
panCentre: 0,
ping: () => ['/console/ping', []],
resendQueries: () => [['/console/resend', []]],
querySend: (ch, send) => [
[`/channel/${ch}/send/${send}/level`, []],
[`/channel/${ch}/send/${send}/enabled`, []],
],
sendLevel: (ch, send, dB) => [`/channel/${ch}/send/${send}/level`, [Number(dB)]],
sendEnabled: (ch, send, on, style) => [`/channel/${ch}/send/${send}/enabled`, [boolArg(on, style === 'int' ? 'tf' : style)]],
sendPan: (ch, send, pan) => [`/channel/${ch}/send/${send}/pan`, [Number(pan)]],
queryGroup: () => [],
groupAssign: (ch, group, on, style) => [`/channel/${ch}/send/${group}/enabled`, [boolArg(on, style === 'int' ? 'tf' : style)]],
fader: (ch, dB) => [`/channel/${ch}/fader`, [Number(dB)]],
mute: (ch, on, style) => [`/channel/${ch}/mute`, [boolArg(on, style === 'int' ? 'tf' : style)]],
auxFader: (aux, dB) => [`/channel/${69 + aux}/fader`, [Number(dB)]],
auxMute: (aux, on, style) => [`/channel/${69 + aux}/mute`, [boolArg(on, style === 'int' ? 'tf' : style)]],
snapshotFire: (n) => ['/digico/snapshots/fire', [{ i: n }]],
snapshotNext: () => ['/digico/snapshots/fire/next', []],
snapshotPrev: () => ['/digico/snapshots/fire/previous', []],
parse(address, args) {
let m
if (address === '/console/pong') return { kind: 'pong' }
if (address === '/console/channel/counts') return { kind: 'count', field: 'inputs', value: toNumber(args[0]), extra: args.map(toNumber) }
if ((m = /^\/channel\/(\d+)\/send\/(\d+)\/(level|enabled|pan)$/.exec(address))) {
const field = m[3]
const value = field === 'enabled' ? toBool(args[0]) : toNumber(args[0])
return { kind: 'send', ch: +m[1], send: +m[2], field, value }
}
if ((m = /^\/channel\/(\d+)\/name$/.exec(address))) {
const ch = +m[1]
if (ch >= 70 && ch <= 79) return { kind: 'auxName', aux: ch - 69, value: String(args[0] ?? '') }
return { kind: 'name', ch, value: String(args[0] ?? '') }
}
if ((m = /^\/channel\/(\d+)\/fader$/.exec(address))) return { kind: 'fader', ch: +m[1], value: toNumber(args[0]) }
if ((m = /^\/channel\/(\d+)\/mute$/.exec(address))) return { kind: 'mute', ch: +m[1], value: toBool(args[0]) }
return null
},
}
const raw = {
id: 'raw',
label: 'Relay only (no parsing, no keepalive)',
pongAddress: null,
levelMin: -150,
levelMax: 10,
panMin: 0,
panMax: 1,
panCentre: 0.5,
ping: () => null,
resendQueries: () => [],
querySend: () => [],
queryGroup: () => [],
sendLevel: () => null,
sendEnabled: () => null,
sendPan: () => null,
groupAssign: () => null,
fader: () => null,
mute: () => null,
auxFader: () => null,
auxMute: () => null,
snapshotFire: () => null,
snapshotNext: () => null,
snapshotPrev: () => null,
parse: () => null,
}
export const DIALECTS = { sd, sseries, raw }
export function getDialect(id) {
return DIALECTS[id] || sd
}
export const DIALECT_CHOICES = Object.values(DIALECTS).map((d) => ({ id: d.id, label: d.label }))
+90
View File
@@ -0,0 +1,90 @@
/**
* Tiny RGBA drawing helpers for the advanced "bar" feedbacks. A bar is drawn as a thin strip
* along one edge of the button (not a full-button image), so the strip's pixel buffer is
* returned together with the position Companion should draw it at:
*
* { imageBuffer: <base64 RGBA>, imageBufferEncoding: { pixelFormat: 'RGBA' },
* imageBufferPosition: { x, y, width, height } }
*
* Buffer length is always width * height * 4 of the strip (4 bytes per RGBA pixel).
*/
export const BYTES_PER_PIXEL = 4
/** 0xRRGGBB number (Companion colour) -> [r, g, b] */
export function rgbOf(color) {
const c = Number(color) || 0
return [(c >> 16) & 255, (c >> 8) & 255, c & 255]
}
export class Canvas {
constructor(width, height) {
this.width = width
this.height = height
this.data = Buffer.alloc(width * height * BYTES_PER_PIXEL)
}
fillRect(x, y, w, h, [r, g, b], a = 255) {
const x0 = Math.max(0, x)
const y0 = Math.max(0, y)
const x1 = Math.min(this.width, x + w)
const y1 = Math.min(this.height, y + h)
for (let py = y0; py < y1; py++) {
for (let px = x0; px < x1; px++) {
const o = (py * this.width + px) * BYTES_PER_PIXEL
this.data[o] = r
this.data[o + 1] = g
this.data[o + 2] = b
this.data[o + 3] = a
}
}
}
toBase64() {
return this.data.toString('base64')
}
}
const TRACK = [24, 24, 24]
function result(canvas, x, y) {
return {
imageBuffer: canvas.toBase64(),
imageBufferEncoding: { pixelFormat: 'RGBA' },
imageBufferPosition: { x, y, width: canvas.width, height: canvas.height },
}
}
/** Strip thickness: about 12 % of the button, never thinner than 4 px. */
export function thicknessFor(size) {
return Math.max(4, Math.round(size * 0.12))
}
/** Horizontal bar along the bottom edge, filled left to right to `fraction` (0..1). */
export function horizontalBar({ width, height, fraction, color }) {
const t = Math.min(height, thicknessFor(height))
const canvas = new Canvas(width, t)
const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width)
canvas.fillRect(0, 0, width, t, TRACK)
canvas.fillRect(0, 0, filled, t, rgbOf(color))
return result(canvas, 0, height - t)
}
const ZONES = [
{ upTo: 0.6, rgb: [0, 190, 60] },
{ upTo: 0.85, rgb: [230, 170, 0] },
{ upTo: 1.01, rgb: [220, 30, 30] },
]
/** Vertical meter at the right edge, filled bottom to top; green / amber / red zones by height. */
export function verticalMeter({ width, height, fraction }) {
const t = Math.min(width, thicknessFor(width))
const canvas = new Canvas(t, height)
const filled = Math.round(Math.min(1, Math.max(0, fraction)) * height)
for (let row = 0; row < height; row++) {
const fromBottom = height - 1 - row
const zone = ZONES.find((z) => (fromBottom + 0.5) / height <= z.upTo) || ZONES[ZONES.length - 1]
canvas.fillRect(0, row, t, 1, fromBottom < filled ? zone.rgb : TRACK)
}
return result(canvas, width - t, 0)
}
+1088 -430
View File
File diff suppressed because it is too large Load Diff
+321
View File
@@ -0,0 +1,321 @@
/**
* The DiGiCo S-Series iPad-extension OSC tree ("DiGiCo Pad" / SD style), as the real
* S31 answers it (tools/catalog/s31-catalog.md). Only addresses the console has been seen
* to answer are built here, plus the write-only trigger nodes.
*
* A query is the address plus "/?" with no arguments; the reply comes back on the same
* address without "/?" (see tools/README.md). Entity numbers are 1-based everywhere
* (inputs, busses, matrix outputs, control groups, graphic EQs); snapshot indexes are
* 0-based, as the console reports them.
*
* Event objects produced by parse() are plain data, no Companion types.
*/
import { toBool, toNumber } from './osc.js'
/** Maxima probed on an S31; an S21 simply answers fewer. */
export const LIMITS = { inputs: 60, busses: 25, matrix: 8, matrixIn: 10, cgs: 10, geqs: 16, geqBands: 32 }
/** dB values. -150 is the console's "off" (-inf); anything at or below FLOOR displays as -inf. */
export const LEVEL = { min: -150, max: 10, floor: -90, restart: -60 }
/** Meter ids chosen by this module start here so they never collide with an iPad's (0-19, 200+, 300+). */
export const METER_BASE_ID = 5000
/**
* Input meters, subscribed by sending `<address> ,ii [id, kind]`. The console then streams
* /Meters/values [id, value, ...] (0..127, only changed ids). There is no known buss meter address.
*/
export const METERS = {
level: { label: 'Level (post-fader)', offset: 0, kind: 1, path: 'Channel_Input/post_meter' },
gate: { label: 'Gate gain reduction', offset: 100, kind: 1, path: 'Dynamics/GR_meter' },
comp: { label: 'Compressor gain reduction', offset: 200, kind: 4, path: 'Dynamics/GR_meter' },
}
export function meterId(n, meter = 'level') {
return METER_BASE_ID + (METERS[meter] || METERS.level).offset + Number(n)
}
export const meterIdForInput = (n) => meterId(n, 'level')
/** [address, args] that subscribes this module to one input meter. */
export function meterSubscription(n, meter = 'level') {
const m = METERS[meter] || METERS.level
return [`/Input_Channels/${n}/${m.path}`, [{ i: meterId(n, meter) }, { i: m.kind }]]
}
/**
* Per-channel switches and values the console answers on inputs and busses (trim also on matrix outputs). They are loaded lazily
* (the first time an action or feedback needs one), never bulk-loaded. `paths` is per entity kind;
* a buss uses the Aux_Outputs or Group_Outputs tree according to its mode.
*
* lo_filter_* is the high-pass filter and hi_filter_* the low-pass filter: judged from the example
* values (45.6 Hz and 12831 Hz), the console's own naming is the other way round.
* `phase` is documented 0..3 (stereo inputs have one bit per side); it is treated as on/off here.
* Delay values and send pre/post are deliberately absent: their units / meaning were never verified.
*/
export const PARAMS = {
phantom: { type: 'switch', label: 'Phantom power (+48 V)', paths: { input: 'Channel_Input/phantom' } },
phase: { type: 'switch', label: 'Phase invert', paths: { input: 'Channel_Input/phase', buss: 'Buss_Trim/phase' } },
pad: { type: 'switch', label: 'Input pad', paths: { input: 'Channel_Input/input_pad' } },
eq_in: { type: 'switch', label: 'EQ in', paths: { input: 'EQ/eq_in', buss: 'EQ/eq_in' } },
comp_in: { type: 'switch', label: 'Compressor in', paths: { input: 'Dynamics/comp_in', buss: 'Dynamics/comp_in' } },
gate_in: { type: 'switch', label: 'Gate in', paths: { input: 'Dynamics/gate_in', buss: 'Dynamics/gate_in' } },
hpf_in: { type: 'switch', label: 'High-pass filter in', paths: { input: 'Filters/lo_filter_in' } },
lpf_in: { type: 'switch', label: 'Low-pass filter in', paths: { input: 'Filters/hi_filter_in' } },
delay_on: { type: 'switch', label: 'Delay in', paths: { input: 'Channel_Delay/delay_on', buss: 'Channel_Delay/delay_on' } },
insert_a: { type: 'switch', label: 'Insert A in', paths: { input: 'Insert/insert_A_in', buss: 'Insert/insert_A_in' } },
insert_b: { type: 'switch', label: 'Insert B in', paths: { input: 'Insert/insert_B_in', buss: 'Insert/insert_B_in' } },
trim: { type: 'value', label: 'Trim', unit: 'dB', min: -40, max: 40, step: 0.5, delta: 1, adjust: 'linear', paths: { input: 'Channel_Input/trim', buss: 'Buss_Trim/trim', matrix: 'Buss_Trim/trim' } },
gain: { type: 'value', label: 'Analog gain', unit: 'dB', min: 0, max: 60, step: 0.5, delta: 1, adjust: 'linear', paths: { input: 'Channel_Input/analog_gain' } },
hpf_freq: { type: 'value', label: 'High-pass filter frequency', unit: 'Hz', min: 20, max: 20000, step: 1, delta: 5, adjust: 'percent', paths: { input: 'Filters/lo_filter_freq' } },
lpf_freq: { type: 'value', label: 'Low-pass filter frequency', unit: 'Hz', min: 20, max: 20000, step: 1, delta: 5, adjust: 'percent', paths: { input: 'Filters/hi_filter_freq' } },
}
const PARAM_BY_PATH = { input: {}, buss: {}, matrix: {} }
for (const [param, p] of Object.entries(PARAMS)) for (const [ent, path] of Object.entries(p.paths)) PARAM_BY_PATH[ent][path] = param
export const COUNT_FIELDS = {
Input_Channels: 'inputs',
Aux_Outputs: 'busses',
Group_Outputs: 'busses',
Control_Groups: 'cgs',
Matrix_Outputs: 'matrix',
Matrix_Inputs: 'matrixIn',
Graphic_EQ: 'geqs',
Talkback_Outputs: 'talkback',
Multis: 'multis',
}
const TREE = { input: 'Input_Channels', aux: 'Aux_Outputs', group: 'Group_Outputs', matrix: 'Matrix_Outputs', cg: 'Control_Groups' }
/** ent: 'input' | 'buss' | 'matrix' | 'cg'; side matters for 'buss' only ('aux' | 'group'). */
function tree(ent, side) {
if (ent === 'buss') return side === 'group' ? TREE.group : TREE.aux
return TREE[ent]
}
export const addr = {
consoleName: '/Console/Name',
session: '/Console/Session/Filename',
count: (field) => `/Console/${Object.keys(COUNT_FIELDS).find((k) => COUNT_FIELDS[k] === field)}`,
bussTypes: '/Console/Aux_Outputs/types',
auxModes: '/Console/Aux_Outputs/modes',
groupModes: '/Console/Group_Outputs/modes',
inputModes: '/Console/Input_Channels/modes',
name(ent, n, side) {
if (ent === 'input') return `/Input_Channels/${n}/Channel_Input/name`
if (ent === 'buss') return `/${tree(ent, side)}/${n}/Buss_Trim/name`
if (ent === 'matrix') return `/Matrix_Outputs/${n}/Buss_Trim/name`
if (ent === 'cg') return `/Control_Groups/${n}/name`
if (ent === 'geq') return `/Graphic_EQ/${n}/name`
throw new Error(`no name address for ${ent}`)
},
fader: (ent, n, side) => `/${tree(ent, side)}/${n}/fader`,
mute: (ent, n, side) => `/${tree(ent, side)}/${n}/mute`,
solo: (ent, n, side) => `/${tree(ent, side)}/${n}/solo`,
pan: (n) => `/Input_Channels/${n}/Panner/pan`,
sendLevel: (ch, buss) => `/Input_Channels/${ch}/Aux_Send/${buss}/send_level`,
sendOn: (ch, buss) => `/Input_Channels/${ch}/Aux_Send/${buss}/send_on`,
sendPan: (ch, buss) => `/Input_Channels/${ch}/Aux_Send/${buss}/send_pan`,
groupAssign: (ch, buss) => `/Input_Channels/${ch}/Group_Send/${buss}/group`,
matrixSendLevel: (inN, outN) => `/Matrix_Inputs/${inN}/Matrix_Send/${outN}/send_level`,
matrixSendOn: (inN, outN) => `/Matrix_Inputs/${inN}/Matrix_Send/${outN}/send_on`,
geqIn: (n) => `/Graphic_EQ/${n}/geq_in`,
geqGain: (n, band) => `/Graphic_EQ/${n}/geq_gain_${band}`,
currentSnapshot: '/Snapshots/Current_Snapshot',
snapshotNames: '/Snapshots/names',
snapshotCount: '/Snapshots/count',
fireSnapshot: '/Snapshots/Fire_Snapshot_number',
fireNext: '/Snapshots/Fire_Next_Snapshot',
firePrev: '/Snapshots/Fire_Prev_Snapshot',
meterValues: '/Meters/values',
meterClear: '/Meters/clear',
inputMeter: (n) => `/Input_Channels/${n}/Channel_Input/post_meter`,
/** A per-channel switch or value from PARAMS; undefined when this entity kind does not have it. */
param(ent, n, side, param) {
const path = PARAMS[param]?.paths[ent]
return path ? `/${tree(ent, side)}/${n}/${path}` : undefined
},
}
/** [address + "/?", []] */
export const query = (address) => [`${address}/?`, []]
/** Canonical keys for "something about this value changed" (used for waiting on a reply). */
export const K = {
value: (ent, n, field, side) => (ent === 'buss' ? `buss:${side}:${n}:${field}` : `${ent}:${n}:${field}`),
send: (ch, buss, field) => `send:${ch}:${buss}:${field}`,
group: (ch, buss) => `group:${ch}:${buss}`,
msend: (inN, outN, field) => `msend:${inN}:${outN}:${field}`,
geq: (n, field, band) => (band ? `geq:${n}:${field}:${band}` : `geq:${n}:${field}`),
param: (ent, n, param, side) => `param:${ent}:${side || '-'}:${n}:${param}`,
}
const num = (v) => toNumber(v)
const str = (v) => (v === undefined || v === null ? '' : String(v))
const RE_COUNT = /^\/Console\/(Input_Channels|Aux_Outputs|Group_Outputs|Control_Groups|Matrix_Outputs|Matrix_Inputs|Graphic_EQ|Talkback_Outputs|Multis)$/
const RE_NAME = /^\/(?:Input_Channels\/(\d+)\/Channel_Input|(Aux_Outputs|Group_Outputs|Matrix_Outputs)\/(\d+)\/Buss_Trim|(Control_Groups|Graphic_EQ)\/(\d+))\/name$/
const RE_LEVEL = /^\/(Input_Channels|Aux_Outputs|Group_Outputs|Matrix_Outputs|Control_Groups)\/(\d+)\/(fader|mute|solo)$/
const RE_PAN = /^\/Input_Channels\/(\d+)\/Panner\/pan$/
const RE_AUX_SEND = /^\/Input_Channels\/(\d+)\/Aux_Send\/(\d+)\/(send_level|send_on|send_pan)$/
const RE_GROUP_SEND = /^\/Input_Channels\/(\d+)\/Group_Send\/(\d+)\/group$/
const RE_MATRIX_SEND = /^\/Matrix_Inputs\/(\d+)\/Matrix_Send\/(\d+)\/(send_level|send_on)$/
const RE_GEQ = /^\/Graphic_EQ\/(\d+)\/(geq_in|geq_gain_(\d+))$/
const RE_PARAM = /^\/(Input_Channels|Aux_Outputs|Group_Outputs|Matrix_Outputs)\/(\d+)\/((?:Channel_Input|Filters|Channel_Delay|EQ|Dynamics|Insert|Buss_Trim)\/[\w-]+)$/
const ENT_BY_TREE = { Input_Channels: ['input'], Aux_Outputs: ['buss', 'aux'], Group_Outputs: ['buss', 'group'], Matrix_Outputs: ['matrix'], Control_Groups: ['cg'], Graphic_EQ: ['geq'] }
/**
* Turn an inbound message into a normalised event, or null when it is not something the
* module uses. Events: { kind, key?, ... } where kind is one of
* consoleName session count bussTypes bussModes inputModes name fader mute solo pan
* send group msend geq param snapCurrent snapName snapCount meters
*/
export function parse(address, args = []) {
let m
if (address === addr.consoleName) return { kind: 'consoleName', value: str(args[0]) }
if (address === addr.session) return { kind: 'session', value: str(args[0]) }
if ((m = RE_COUNT.exec(address))) return { kind: 'count', field: COUNT_FIELDS[m[1]], source: m[1], value: num(args[0]) }
if (address === addr.bussTypes) return { kind: 'bussTypes', values: args.map(num) }
if (address === addr.auxModes) return { kind: 'bussModes', side: 'aux', values: args.map(num) }
if (address === addr.groupModes) return { kind: 'bussModes', side: 'group', values: args.map(num) }
if (address === addr.inputModes) return { kind: 'inputModes', values: args.map(num) }
if ((m = RE_NAME.exec(address))) {
if (m[1]) return { kind: 'name', ent: 'input', n: +m[1], value: str(args[0]), key: K.value('input', +m[1], 'name') }
if (m[2]) {
const [ent, side] = ENT_BY_TREE[m[2]]
return { kind: 'name', ent, side, n: +m[3], value: str(args[0]), key: K.value(ent, +m[3], 'name', side) }
}
const [ent] = ENT_BY_TREE[m[4]]
return { kind: 'name', ent, n: +m[5], value: str(args[0]), key: K.value(ent, +m[5], 'name') }
}
if ((m = RE_LEVEL.exec(address))) {
const [ent, side] = ENT_BY_TREE[m[1]]
const field = m[3]
const value = field === 'fader' ? num(args[0]) : toBool(args[0])
return { kind: field, ent, side, n: +m[2], value, key: K.value(ent, +m[2], field, side) }
}
if ((m = RE_PAN.exec(address))) return { kind: 'pan', ent: 'input', n: +m[1], value: num(args[0]), key: K.value('input', +m[1], 'pan') }
if ((m = RE_AUX_SEND.exec(address))) {
const field = { send_level: 'level', send_on: 'on', send_pan: 'pan' }[m[3]]
const value = field === 'on' ? toBool(args[0]) : num(args[0])
return { kind: 'send', ch: +m[1], buss: +m[2], field, value, key: K.send(+m[1], +m[2], field) }
}
if ((m = RE_GROUP_SEND.exec(address))) return { kind: 'group', ch: +m[1], buss: +m[2], value: toBool(args[0]), key: K.group(+m[1], +m[2]) }
if ((m = RE_MATRIX_SEND.exec(address))) {
const field = m[3] === 'send_on' ? 'on' : 'level'
const value = field === 'on' ? toBool(args[0]) : num(args[0])
return { kind: 'msend', input: +m[1], out: +m[2], field, value, key: K.msend(+m[1], +m[2], field) }
}
if ((m = RE_GEQ.exec(address))) {
if (m[2] === 'geq_in') return { kind: 'geq', n: +m[1], field: 'on', value: toBool(args[0]), key: K.geq(+m[1], 'on') }
return { kind: 'geq', n: +m[1], field: 'gain', band: +m[3], value: num(args[0]), key: K.geq(+m[1], 'gain', +m[3]) }
}
if ((m = RE_PARAM.exec(address))) {
const [ent, side] = ENT_BY_TREE[m[1]]
const param = PARAM_BY_PATH[ent]?.[m[3]]
if (param) {
const raw = num(args[0])
const value = PARAMS[param].type === 'switch' ? raw !== 0 : raw
return { kind: 'param', ent, side, n: +m[2], param, value, key: K.param(ent, +m[2], param, side) }
}
}
if (address === addr.currentSnapshot) return { kind: 'snapCurrent', value: num(args[0]) }
if (address === addr.snapshotCount) return { kind: 'snapCount', value: num(args[0]) }
if (address === '/Snapshots/name') {
// one reply per snapshot to /Snapshots/names/?: [index, cue number x 100, ?, name]
return { kind: 'snapName', index: num(args[0]), cue: num(args[1]), name: str(args[args.length - 1]) }
}
if (address === addr.meterValues) {
const pairs = []
for (let i = 0; i + 1 < args.length; i += 2) pairs.push([num(args[i]), num(args[i + 1])])
return { kind: 'meters', pairs }
}
return null
}
/** dB text for a level: "-inf" at/below the floor, one decimal otherwise. */
export function fmtDb(v) {
if (typeof v !== 'number' || !Number.isFinite(v)) return ''
if (v <= LEVEL.floor) return '-inf'
const r = Math.round(v * 10) / 10
return String(Object.is(r, -0) ? 0 : r)
}
/** Pan 0..1 (0.5 centre) as "C", "L50", "R30". */
export function fmtPan(p) {
if (typeof p !== 'number' || !Number.isFinite(p)) return ''
const pct = Math.round((p - 0.5) * 200)
if (pct === 0) return 'C'
return pct < 0 ? `L${-pct}` : `R${pct}`
}
/** Snapshot cue number as the surface shows it: raw 100 -> "1", 250 -> "2.5". */
export function fmtCue(raw) {
if (typeof raw !== 'number' || !Number.isFinite(raw)) return ''
return String(Number((raw / 100).toFixed(2)))
}
export const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v))
/**
* New dB value for a relative move. From -inf the first upward step lands at LEVEL.restart,
* downward steps stop at -inf, everything else clamps to the console range.
*/
export function adjustedLevel(current, delta, lo = LEVEL.min, hi = LEVEL.max) {
const d = Number(delta)
if (current === undefined || !Number.isFinite(current) || current <= LEVEL.floor) {
return d > 0 ? clamp(LEVEL.restart, lo, hi) : lo
}
const next = clamp(current + d, lo, hi)
return next <= LEVEL.floor ? lo : next
}
const MAX_BY_TREE = {
Input_Channels: LIMITS.inputs,
Aux_Outputs: LIMITS.busses,
Group_Outputs: LIMITS.busses,
Matrix_Outputs: LIMITS.matrix,
Matrix_Inputs: LIMITS.matrixIn,
Control_Groups: LIMITS.cgs,
Graphic_EQ: LIMITS.geqs,
}
const MAX_BY_SEND = { Aux_Send: LIMITS.busses, Group_Send: LIMITS.busses, Matrix_Send: LIMITS.matrix }
const isIndex = (text, max) => /^[1-9]\d*$/.test(String(text)) && Number(text) <= max
/**
* Central index check for an entity address: null when fine (or not an entity address at all, e.g. /Console/...
* or a raw OSC address), otherwise a short reason. Entity numbers must be whole numbers from 1 up to the
* S31 maximum (inputs 60, busses 25, matrix 8 / 10, control groups 10, graphic EQs 16, send 1-25, band 1-32).
*/
export function indexProblem(address) {
if (typeof address !== 'string' || !address.startsWith('/')) return 'not an OSC address'
const seg = address.split('/')
const max = MAX_BY_TREE[seg[1]]
if (max === undefined) return null
if (!isIndex(seg[2], max)) return `${seg[1]} number "${seg[2]}" is not a whole number from 1 to ${max}`
const subMax = MAX_BY_SEND[seg[3]]
if (subMax !== undefined && !isIndex(seg[4], subMax)) return `${seg[3]} number "${seg[4]}" is not a whole number from 1 to ${subMax}`
const band = /^geq_gain_(.*)$/.exec(seg[3] || '')
if (band && !isIndex(band[1], LIMITS.geqBands)) return `band "${band[1]}" is not a whole number from 1 to ${LIMITS.geqBands}`
return null
}
/** Two parsed event values are the same (numbers within float32 noise). */
export function sameValue(a, b) {
if (typeof a === 'number' && typeof b === 'number') return Math.abs(a - b) < 1e-3
return a === b
}
+90 -35
View File
@@ -11,69 +11,117 @@ import { EventEmitter } from 'node:events'
* fanned out to every app seen recently. Nothing is rewritten, so the relay
* does not care which dialect the iPad extension speaks.
*
* The client table can be passed in (`clients`) so it survives closing and reopening the relay
* on another port. It holds at most `maxClients` entries (the least recently seen is evicted),
* and an iPad that has been quiet for `timeoutMs` is forgotten, except when `timeoutMs` is 0
* (never forget): an iPad that only watches sends nothing.
*
* Events: 'fromClient' (buf, rinfo), 'client' (rinfo) on first sight,
* 'clientGone' (ip), 'error' (err), 'listening' (addressInfo).
*/
export class Relay extends EventEmitter {
constructor({ port, clientPort = 0, timeoutMs = 180000, bindAddress = '0.0.0.0' }) {
constructor({ port, clientPort = 0, timeoutMs = 3600000, maxClients = 16, clients = new Map(), bindAddress = '0.0.0.0' }) {
super()
this.port = Number(port)
this.clientPort = Number(clientPort) || 0
this.timeoutMs = Number(timeoutMs) || 180000
this.timeoutMs = Number(timeoutMs) || 0 // 0 = never forget
this.maxClients = Number(maxClients) || 16
this.bindAddress = bindAddress
this.socket = null
this.clients = new Map() // ip -> { port, lastSeen, rx, tx }
this.clients = clients // ip -> { port, lastSeen, rx, tx }
this.rxCount = 0
this.txCount = 0
}
open() {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: false })
let socket
let settled = false
socket.on('error', (err) => {
if (!settled) {
settled = true
reject(err)
} else this.emit('error', err)
})
socket.on('message', (buf, rinfo) => {
this.rxCount++
let client = this.clients.get(rinfo.address)
if (!client) {
client = { port: rinfo.port, lastSeen: 0, rx: 0, tx: 0 }
this.clients.set(rinfo.address, client)
this.emit('client', rinfo)
}
client.port = rinfo.port
client.lastSeen = Date.now()
client.rx++
this.emit('fromClient', buf, rinfo)
})
socket.bind(this.port, this.bindAddress, () => {
const fail = (err) => {
settled = true
this.socket = socket
this.emit('listening', socket.address())
resolve(socket.address())
})
try {
socket?.close()
} catch (_e) {
/* never opened */
}
reject(err)
}
try {
socket = dgram.createSocket({ type: 'udp4', reuseAddr: false })
socket.on('error', (err) => {
if (!settled) fail(err)
else if (this.listenerCount('error')) this.emit('error', err)
})
socket.on('message', (buf, rinfo) => {
this.rxCount++
this.touch(rinfo)
this.emit('fromClient', buf, rinfo)
})
socket.bind(this.port, this.bindAddress, () => {
settled = true
this.socket = socket
this.emit('listening', socket.address())
resolve(socket.address())
})
} catch (err) {
fail(err)
}
})
}
/** Note a datagram from an iPad: register or refresh its entry, evicting the least recently seen when the table is full. */
touch(rinfo) {
const now = Date.now()
let client = this.clients.get(rinfo.address)
if (!client) {
while (this.clients.size >= this.maxClients) this.evictOldest()
client = { port: rinfo.port, lastSeen: now, rx: 0, tx: 0 }
this.clients.set(rinfo.address, client)
this.emit('client', rinfo)
}
client.port = rinfo.port
client.lastSeen = now
client.rx++
}
evictOldest() {
let oldest = null
for (const [ip, c] of this.clients) if (oldest === null || c.lastSeen < this.clients.get(oldest).lastSeen) oldest = ip
if (oldest === null) return
this.clients.delete(oldest)
this.emit('clientGone', oldest)
}
isLive(client, now = Date.now()) {
return this.timeoutMs === 0 || now - client.lastSeen <= this.timeoutMs
}
/** Apply a changed iPad receive port / idle timeout to the running relay. */
update({ clientPort, timeoutMs }) {
if (clientPort !== undefined) this.clientPort = Number(clientPort) || 0
if (timeoutMs !== undefined) this.timeoutMs = Number(timeoutMs) || 0
}
/** Send one datagram from the console to every live client. */
fanout(buf) {
if (!this.socket) return 0
const now = Date.now()
let n = 0
for (const [ip, client] of this.clients) {
if (now - client.lastSeen > this.timeoutMs) {
if (!this.isLive(client, now)) {
this.clients.delete(ip)
this.emit('clientGone', ip)
continue
}
const port = this.clientPort || client.port
this.socket.send(buf, port, ip, (err) => {
if (err) this.emit('error', err)
})
try {
this.socket.send(buf, port, ip, (err) => {
if (err) this.emitError(err, `${ip}:${port}`)
})
} catch (err) {
this.emitError(err, `${ip}:${port}`)
continue
}
client.tx++
this.txCount++
n++
@@ -81,11 +129,18 @@ export class Relay extends EventEmitter {
return n
}
emitError(err, dest) {
err.dest = dest
if (this.listenerCount('error')) this.emit('error', err)
}
get clientCount() {
return this.liveClients().length
}
liveClients() {
const now = Date.now()
let n = 0
for (const c of this.clients.values()) if (now - c.lastSeen <= this.timeoutMs) n++
return n
return [...this.clients].filter(([, c]) => this.isLive(c, now)).map(([ip, c]) => ({ ip, ...c }))
}
clientList() {
+503
View File
@@ -0,0 +1,503 @@
/**
* Everything the module knows about the console, keyed by entity number, plus the dropdown
* choice builders and the query plans used to load it.
*
* Entities: input, buss (aux and group are two views of the same 25 busses, named and levelled
* on the side matching the buss mode), matrix (matrix outputs), minput (matrix inputs, no
* name address), cg (control groups), geq (graphic EQs). All numbers are 1-based.
*
* applyMessage(address, args) is the single entry point for console traffic (and for the
* module's own optimistic writes): it returns the parsed event annotated with
* changed the stored value differs from before
* structural names, buss modes (aux/group), counts or the snapshot list changed (definitions need a rebuild;
* stereo/mono flags alone never are)
*/
import { LIMITS, K, addr, query, parse, fmtCue } from './protocol.js'
const EPS = 1e-4
/** Assign and report whether the value actually changed. */
function setField(obj, field, value) {
const old = obj[field]
obj[field] = value
if (typeof value === 'number' && typeof old === 'number') return Math.abs(value - old) > EPS
return old !== value
}
function rec(map, key) {
let r = map.get(key)
if (!r) {
r = {}
map.set(key, r)
}
return r
}
/** One of the 25 busses. aux / group hold the fader, mute and solo of each side. */
export class Buss {
constructor(n) {
this.n = n
this.auxName = undefined
this.groupName = undefined
this.type = undefined // 'aux' | 'group' once /Console/Aux_Outputs/types answered
this.modeAux = undefined // 1 mono / 2 stereo, from /Console/Aux_Outputs/modes
this.modeGroup = undefined // from /Console/Group_Outputs/modes
this.aux = {}
this.group = {}
}
/** The authoritative `types` array wins; without it, a buss named only on the Group side is a group. */
get mode() {
if (this.type) return this.type
const g = (this.groupName || '').trim()
const a = (this.auxName || '').trim()
return g && !a ? 'group' : 'aux'
}
get name() {
return this.mode === 'group' ? this.groupName : this.auxName
}
get stereo() {
const m = this.mode === 'group' ? (this.modeGroup ?? this.modeAux) : (this.modeAux ?? this.modeGroup)
return m === undefined ? undefined : m === 2
}
get fader() {
return this[this.mode].fader
}
get mute() {
return this[this.mode].mute
}
get solo() {
return this[this.mode].solo
}
}
const FALLBACK_NAME = { input: 'Input', matrix: 'Matrix', cg: 'CG', geq: 'Graphic EQ', minput: 'Matrix In' }
export class State {
constructor() {
this.loaded = false
this.reset()
this.choices = this.#makeChoices()
}
reset() {
this.loaded = false
this.console = { name: '', session: '' }
this.counts = {}
this.inputs = new Map()
this.busses = new Map()
this.matrix = new Map()
this.cgs = new Map()
this.geqs = new Map()
this.sends = new Map() // "ch:buss" -> { level, on, pan }
this.groups = new Map() // "ch:buss" -> boolean
this.matrixSends = new Map() // "in:out" -> { level, on }
this.params = new Map() // K.param(...) -> boolean | number (per-channel switches and values, loaded lazily)
this.snapshots = { current: undefined, count: undefined, list: new Map() } // index -> { name, cue }
this.meters = new Map() // meter id -> { value, at, peakAt }
}
// ---------- inbound ----------
applyMessage(address, args = []) {
const ev = parse(address, args)
if (!ev) return null
ev.changed = false
ev.structural = false
switch (ev.kind) {
case 'consoleName':
ev.changed = setField(this.console, 'name', ev.value)
break
case 'session':
ev.changed = setField(this.console, 'session', ev.value)
break
case 'count':
if (Number.isFinite(ev.value)) {
ev.changed = this.counts[ev.field] !== ev.value
this.counts[ev.field] = ev.value
ev.structural = ev.changed
}
break
case 'bussTypes': {
const r = this.#applyBussArray(ev.values, (b, v) => (b.type = v === 1 ? 'aux' : v === 0 ? 'group' : undefined))
ev.structural = r.mode // the buss mode shows in labels and decides which presets exist
ev.changed = r.mode || r.stereo
break
}
case 'bussModes': {
// stereo / mono is shown in a variable only: it never rebuilds the definitions
const field = ev.side === 'group' ? 'modeGroup' : 'modeAux'
const r = this.#applyBussArray(ev.values, (b, v) => (b[field] = v))
ev.changed = r.mode || r.stereo
break
}
case 'inputModes':
ev.values.forEach((v, i) => {
if (setField(rec(this.inputs, i + 1), 'stereo', v === 2)) ev.changed = true
})
break
case 'name':
ev.changed = this.#applyName(ev)
ev.structural = ev.changed
break
case 'fader':
case 'mute':
case 'solo':
case 'pan':
ev.changed = setField(this.#levelTarget(ev), ev.kind, ev.value)
break
case 'send':
ev.changed = setField(rec(this.sends, `${ev.ch}:${ev.buss}`), ev.field, ev.value)
break
case 'group': {
const key = `${ev.ch}:${ev.buss}`
ev.changed = this.groups.get(key) !== ev.value
this.groups.set(key, ev.value)
break
}
case 'msend':
ev.changed = setField(rec(this.matrixSends, `${ev.input}:${ev.out}`), ev.field, ev.value)
break
case 'param': {
const old = this.params.get(ev.key)
this.params.set(ev.key, ev.value)
ev.changed = typeof old === 'number' && typeof ev.value === 'number' ? Math.abs(old - ev.value) > EPS : old !== ev.value
break
}
case 'geq': {
const g = rec(this.geqs, ev.n)
if (ev.field === 'on') ev.changed = setField(g, 'on', ev.value)
else {
if (!g.gains) g.gains = new Map()
ev.changed = g.gains.get(ev.band) !== ev.value
g.gains.set(ev.band, ev.value)
}
break
}
case 'snapCurrent':
ev.changed = this.snapshots.current !== ev.value
this.snapshots.current = ev.value
break
case 'snapCount':
ev.changed = this.snapshots.count !== ev.value
this.snapshots.count = ev.value
break
case 'snapName': {
const old = this.snapshots.list.get(ev.index)
this.snapshots.list.set(ev.index, { name: ev.name, cue: ev.cue })
ev.changed = !old || old.name !== ev.name || old.cue !== ev.cue
ev.structural = ev.changed
break
}
case 'meters': {
const now = Date.now()
for (const [id, value] of ev.pairs) {
const m = this.meters.get(id) || { value: 0, at: 0, peakAt: 0 }
m.value = value
m.at = now
if (value >= 127) m.peakAt = now
this.meters.set(id, m)
}
ev.changed = ev.pairs.length > 0
break
}
default:
break
}
return ev
}
/** Apply `fn(buss, value)` over a per-buss array; reports whether any buss mode or stereo flag changed. */
#applyBussArray(values, fn) {
const r = { mode: false, stereo: false }
values.forEach((v, i) => {
const b = this.#buss(i + 1)
const before = [b.mode, b.stereo]
fn(b, v)
if (b.mode !== before[0]) r.mode = true
if (b.stereo !== before[1]) r.stereo = true
})
return r
}
#buss(n) {
let b = this.busses.get(n)
if (!b) {
b = new Buss(n)
this.busses.set(n, b)
}
return b
}
#applyName(ev) {
if (ev.ent === 'buss') {
const b = this.#buss(ev.n)
return setField(b, ev.side === 'group' ? 'groupName' : 'auxName', ev.value)
}
return setField(rec(this.#map(ev.ent), ev.n), 'name', ev.value)
}
#levelTarget(ev) {
if (ev.ent === 'buss') return this.#buss(ev.n)[ev.side === 'group' ? 'group' : 'aux']
return rec(this.#map(ev.ent), ev.n)
}
#map(ent) {
switch (ent) {
case 'input':
return this.inputs
case 'matrix':
return this.matrix
case 'cg':
return this.cgs
case 'geq':
return this.geqs
default:
throw new Error(`no entity map for ${ent}`)
}
}
// ---------- existence, names ----------
/** Highest number worth asking about: the console's own count when it told us, else the S31 maximum. */
limit(ent) {
const c = this.counts
switch (ent) {
case 'input':
return c.inputs ?? LIMITS.inputs
case 'buss':
return c.busses ?? LIMITS.busses
case 'matrix':
return c.matrix ?? LIMITS.matrix
case 'minput':
return c.matrixIn ?? LIMITS.matrixIn
case 'cg':
return c.cgs ?? LIMITS.cgs
case 'geq':
return c.geqs ?? LIMITS.geqs
default:
return 0
}
}
/** An entity exists when its name answered (matrix inputs have no name: they exist while matrix outputs do). */
has(ent, n) {
if (!(n >= 1 && n <= this.limit(ent))) return false
switch (ent) {
case 'buss': {
const b = this.busses.get(n)
return !!b && (b.auxName !== undefined || b.groupName !== undefined)
}
case 'minput':
return this.limit('matrix') > 0 && (this.counts.matrixIn !== undefined || this.matrix.size > 0)
default:
return this.#map(ent).get(n)?.name !== undefined
}
}
/** Numbers of the entities that exist (their name answered). */
existing(ent) {
const out = []
const max = this.limit(ent)
for (let n = 1; n <= max; n++) if (this.has(ent, n)) out.push(n)
return out
}
/** Entity numbers for dropdowns and variables: those that exist; before the first load finishes and while nothing has answered, every possible number. */
numbers(ent) {
const out = this.existing(ent)
if (!out.length && !this.loaded) for (let n = 1; n <= this.limit(ent); n++) out.push(n)
return out
}
/** The name as the console reports it (may be ""), or undefined while unknown. */
rawName(ent, n) {
if (ent === 'buss') return this.busses.get(n)?.name
if (ent === 'minput') return undefined
return this.#map(ent).get(n)?.name
}
/** Name for labels and variables: the console's, or "Input 7" / "Aux 3" / "Group 24" style fallbacks. */
displayName(ent, n) {
const raw = this.rawName(ent, n)
if (raw && raw.trim()) return raw
if (ent === 'buss') return `${this.bussMode(n) === 'group' ? 'Group' : 'Aux'} ${n}`
return `${FALLBACK_NAME[ent]} ${n}`
}
bussMode(n) {
return this.busses.get(n)?.mode ?? 'aux'
}
/** Existing entities that carry a name; unnamed busses and control groups are unused slots and get no presets. */
named(ent) {
return this.numbers(ent).filter((n) => (this.rawName(ent, n) || '').trim())
}
/** Named busses whose active side is `mode` ('aux' | 'group'). */
namedBusses(mode) {
return this.named('buss').filter((n) => !mode || this.bussMode(n) === mode)
}
// ---------- levels and switches ----------
getFader(ent, n) {
if (ent === 'buss') return this.busses.get(n)?.fader
return this.#map(ent).get(n)?.fader
}
getMute(ent, n) {
if (ent === 'buss') return this.busses.get(n)?.mute
return this.#map(ent).get(n)?.mute
}
getSolo(ent, n) {
if (ent === 'buss') return this.busses.get(n)?.solo
return this.#map(ent).get(n)?.solo
}
getPan(n) {
return this.inputs.get(n)?.pan
}
getSend(ch, buss) {
return this.sends.get(`${ch}:${buss}`) || {}
}
getGroup(ch, buss) {
return this.groups.get(`${ch}:${buss}`)
}
getMatrixSend(inN, outN) {
return this.matrixSends.get(`${inN}:${outN}`) || {}
}
getGeq(n) {
return this.geqs.get(n) || {}
}
/** A per-channel switch (boolean) or value from PARAMS; a buss reads the side matching its mode. */
getParam(ent, n, param) {
return this.params.get(K.param(ent, n, param, ent === 'buss' ? this.bussMode(n) : undefined))
}
getMeter(id) {
return this.meters.get(id)
}
snapshotName(index) {
return this.snapshots.list.get(index)?.name
}
// ---------- dropdown choices ----------
#makeChoices() {
const entity = (ent, suffix) => () => this.numbers(ent).map((n) => ({ id: n, label: `${n} · ${this.displayName(ent, n)}${suffix ? suffix(n) : ''}` }))
const bussSuffix = (n) => ` (${this.bussMode(n)})`
const busses = entity('buss', bussSuffix)
return {
inputs: entity('input'),
busses,
auxChoices: () => busses().filter((c) => this.bussMode(c.id) === 'aux'),
groupChoices: () => busses().filter((c) => this.bussMode(c.id) === 'group'),
matrix: entity('matrix'),
matrixInputs: entity('minput'),
cgs: entity('cg'),
geqs: entity('geq'),
snapshots: () =>
[...this.snapshots.list]
.sort((a, b) => a[0] - b[0])
.map(([index, s]) => ({ id: index, label: `${index} · ${s.name}${s.cue > 0 ? ` · cue ${fmtCue(s.cue)}` : ''}` })),
}
}
// ---------- load plans ----------
/**
* The queries every (re)load starts with. The console never answers the Input_Channels, Aux_Outputs and
* Group_Outputs counts (it pushes all nine once, shortly after first contact), so topology comes from the
* name queries; the four counts that do answer are asked for here.
*/
headerQueries() {
return [
query(addr.consoleName),
query(addr.count('matrix')),
query(addr.count('matrixIn')),
query(addr.count('cgs')),
query(addr.count('geqs')),
query(addr.bussTypes),
query(addr.auxModes),
query(addr.groupModes),
query(addr.inputModes),
query(addr.session),
query(addr.currentSnapshot),
query(addr.snapshotCount),
query(addr.snapshotNames),
]
}
nameQueries(onlyMissing = false) {
const q = []
const want = (ent, n, side) => {
if (!onlyMissing) return true
if (ent === 'buss') {
const b = this.busses.get(n)
return (side === 'group' ? b?.groupName : b?.auxName) === undefined
}
return this.#map(ent).get(n)?.name === undefined
}
for (let n = 1; n <= this.limit('input'); n++) if (want('input', n)) q.push(query(addr.name('input', n)))
for (let n = 1; n <= this.limit('buss'); n++) {
if (want('buss', n, 'aux')) q.push(query(addr.name('buss', n, 'aux')))
if (want('buss', n, 'group')) q.push(query(addr.name('buss', n, 'group')))
}
for (let n = 1; n <= this.limit('matrix'); n++) if (want('matrix', n)) q.push(query(addr.name('matrix', n)))
for (let n = 1; n <= this.limit('cg'); n++) if (want('cg', n)) q.push(query(addr.name('cg', n)))
for (let n = 1; n <= this.limit('geq'); n++) if (want('geq', n)) q.push(query(addr.name('geq', n)))
return q
}
bussValueQueries(n) {
const side = this.bussMode(n)
return [query(addr.fader('buss', n, side)), query(addr.mute('buss', n, side)), query(addr.solo('buss', n, side))]
}
/** Fader / mute / solo (/ pan) for every entity that exists. */
valueQueries() {
const q = []
for (const n of this.existing('input')) {
q.push(query(addr.fader('input', n)), query(addr.mute('input', n)), query(addr.solo('input', n)), query(addr.pan(n)))
}
for (const n of this.existing('buss')) q.push(...this.bussValueQueries(n))
for (const n of this.existing('matrix')) q.push(query(addr.fader('matrix', n)), query(addr.mute('matrix', n)), query(addr.solo('matrix', n)))
for (const n of this.existing('cg')) q.push(query(addr.fader('cg', n)), query(addr.mute('cg', n)), query(addr.solo('cg', n)))
return q
}
/** Counts of what was found, for the "load complete" log line. */
summary() {
const found = (ent) => {
let k = 0
for (let n = 1; n <= this.limit(ent); n++) if (this.has(ent, n)) k++
return k
}
const out = {
inputs: found('input'),
busses: found('buss'),
aux: this.namedBusses('aux').length,
group: this.namedBusses('group').length,
matrix: found('matrix'),
cgs: found('cg'),
geqs: found('geq'),
snapshots: this.snapshots.list.size,
}
out.total = out.inputs + out.busses + out.matrix + out.cgs + out.geqs
return out
}
}