import { COLORS } from './feedbacks.js' import { VAR_PREFIX } from './variables.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 = 3200 export const MAX_PRESET_BYTES = 4 * 1024 * 1024 const { white: WHITE, dark: DARK, red: RED, green: GREEN, amber: AMBER } = COLORS // Layered presets (native gauges). Colours are 0xRRGGBB numbers. const BLACK = 0x000000 const TEXT = 0xffffff const GAUGE_GREEN = 0x00b050 /** Level gauge fill: white, so it stays visible on a green (send on) or red (muted) background. */ const GAUGE_LEVEL = 0xffffff const METER_AMBER = 0xf0a000 const METER_RED = 0xe00000 const ON_GREEN = 0x00a050 // a send is on / an input is assigned const MUTED_RED = 0xb01010 // muted, peak const SIGNAL_BG = 0x00501e // signal present on a meter button /** The two kinds of buss, each with its own actions, feedbacks and preset sections (the console's aux or group side of a buss). */ const BUSS_KINDS = [ { kind: 'group', prefix: 'group', section: 'groups', meterSection: 'meters_groups', title: 'Group', meterTitle: 'Group' }, { kind: 'aux', prefix: 'aux', section: 'aux_masters', meterSection: 'meters_aux', title: 'Aux master', meterTitle: 'Aux' }, ] 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. When the list is too big `grMeters` (the gate / compressor reduction * meter presets) is dropped first, then `sendKnobs` (the per-input aux send level knobs). */ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {}) { const s = inst.state const L = inst.label const presets = {} const structure = [] const nameVar = (ent, n) => `$(${L}:${VAR_PREFIX[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, }) /** A rotary step: press = `press`, turning = `adjust` with the action's delta option set to -1 / +1. */ const knobSteps = (press, adjustActionId, adjustOptions) => [ { down: press, up: [], rotate_left: [act(adjustActionId, { ...adjustOptions, delta: -1 })], rotate_right: [act(adjustActionId, { ...adjustOptions, delta: 1 })], }, ] const text = (id, x, y, width, height, value) => ({ type: 'text', id, x, y, width, height, text: value, fontsize: 100, fontsizeAllowShrink: true, color: TEXT, halign: 'center', valign: 'center' }) /** Boolean feedback on a layered preset: it colours the background box. */ const tint = (feedbackId, options, color) => ({ feedbackId, options, styleOverrides: [{ elementId: 'bg', elementProperty: 'color', override: color }] }) /** Green / amber / red zones for a meter gauge on the -60..0 dBFS scale. */ const meterStops = () => [ { value: 0, color: GAUGE_GREEN, gradient: false }, { value: 70, color: METER_AMBER, gradient: false }, { value: 90, color: METER_RED, gradient: false }, ] /** Width (%) of the optional meter column at the right edge of a knob preset. */ const KNOB_METER_WIDTH = 13 /** Track brightness (%) behind multi-colour meter gauges: faint, so an empty meter reads as empty. */ const METER_TRACK_AMOUNT = 20 /** * Layered rotary preset with a native Companion gauge along the bottom: black background, the entity name on top, the * dB text below it, and a gauge fed by the `pos` local variable. `level` and `pos` are the text and the numeric value * feedbacks for the same thing. With `meter: [feedbackId, options]` a vertical meter column (fed by the `meter` local * variable, a meter position feedback) runs down the right edge and the rest of the button narrows to make room. */ const layeredKnob = ({ name, title, press, adjust: [adjustActionId, adjustOptions], level: [levelId, levelOptions], pos: [posId, posOptions], meter, feedbacks }) => { const w = meter ? 100 - KNOB_METER_WIDTH : 100 const elements = [ { type: 'box', id: 'bg', x: 0, y: 0, width: 100, height: 100, color: BLACK }, text('title', 0, 0, w, 55, title), text('level', 0, 52, w, 32, '$(local:level) dB'), { type: 'gauge', id: 'gauge', x: 0, y: 84, width: w, height: 16, value: { isExpression: true, value: '$(local:pos)' }, min: 0, max: 100, orientation: 'horizontal', trackStyle: 'dimmed', fillEnabled: true, multiColour: false, stops: [{ value: 0, color: GAUGE_LEVEL, gradient: false }], }, ] const localVariables = [localFeedback('level', levelId, levelOptions), localFeedback('pos', posId, posOptions)] if (meter) { elements.push({ type: 'gauge', id: 'meter', x: w + 1, y: 0, width: KNOB_METER_WIDTH - 1, height: 100, value: { isExpression: true, value: '$(local:meter)' }, min: 0, max: 100, orientation: 'vertical', trackStyle: 'dimmed', fillEnabled: true, multiColour: true, trackAmount: METER_TRACK_AMOUNT, stops: meterStops(), }) localVariables.push(localFeedback('meter', meter[0], meter[1])) } return { type: 'layered', name, elements, steps: knobSteps(press, adjustActionId, adjustOptions), feedbacks, localVariables } } /** * Layered meter: name on the left, a vertical gauge (green / amber / red zones) at the right edge; the position comes * from `posFeedback`. A signal feedback tints the background dark green and, for level meters, a peak feedback red. */ const layeredMeter = ({ name, title, posFeedback, signal, peak }) => ({ type: 'layered', name, elements: [ { type: 'box', id: 'bg', x: 0, y: 0, width: 100, height: 100, color: BLACK }, text('title', 0, 0, 86, 100, title), { type: 'gauge', id: 'gauge', x: 86, y: 0, width: 14, height: 100, value: { isExpression: true, value: '$(local:pos)' }, min: 0, max: 100, orientation: 'vertical', trackStyle: 'dimmed', fillEnabled: true, multiColour: true, trackAmount: METER_TRACK_AMOUNT, stops: meterStops(), }, ], steps: [{ down: [], up: [] }], feedbacks: [tint(signal[0], signal[1], SIGNAL_BG), ...(peak ? [tint(peak[0], peak[1], MUTED_RED)] : [])], localVariables: [localFeedback('pos', posFeedback[0], posFeedback[1])], }) 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}`] = layeredKnob({ name: `Fader knob: ${tag}`, title: nameVar('input', n), press: [act('input_mute', { channels: [n], state: 'toggle' })], adjust: ['input_fader_adjust', { channel: n }], level: ['input_fader_value', { channel: n }], pos: ['input_fader_pos', { channel: n }], feedbacks: [tint('input_muted', { channel: n }, MUTED_RED)], }) 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'], ]) { if (meter !== 'level' && !grMeters) continue const options = { channel: n, meter } presets[`${prefix}_${n}`] = layeredMeter({ name: `${label}: ${tag}`, title: nameVar('input', n), posFeedback: ['input_meter_pos', options], signal: ['input_signal', { ...options, thresholdDb: -50 }], // a gain reduction meter has no peak peak: meter === 'level' ? ['input_peak', { ...options, peakDb: -1, hold: 2 }] : null, }) 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), ...(grMeters ? [group('meters_gate', 'Gate gain reduction', meterIds.gate), group('meters_comp', 'Compressor gain reduction', meterIds.comp)] : []), ], }) } } // ---- Groups and aux masters: per named buss of that type, a mute button and a fader knob for (const { kind, prefix, section, title } of BUSS_KINDS) { const muteIds = [] const knobIds = [] for (const n of s.namedBusses(kind)) { const tag = `${n} · ${s.displayName('buss', n)}` presets[`${prefix}_mute_${n}`] = simple(`${title} mute: ${tag}`, nameVar('buss', n), [act(`${prefix}_mute`, { busses: [n], state: 'toggle' })], [fb(`${prefix}_muted`, { buss: n }, RED)]) presets[`${prefix}_knob_${n}`] = layeredKnob({ name: `${title} fader (knob): ${tag}`, title: nameVar('buss', n), press: [act(`${prefix}_mute`, { busses: [n], state: 'toggle' })], adjust: [`${prefix}_fader_adjust`, { buss: n }], level: [`${prefix}_fader_value`, { buss: n }], pos: [`${prefix}_fader_pos`, { buss: n }], feedbacks: [tint(`${prefix}_muted`, { buss: n }, MUTED_RED)], }) muteIds.push(`${prefix}_mute_${n}`) knobIds.push(`${prefix}_knob_${n}`) } if (muteIds.length) { structure.push({ id: section, name: `${title}s`, definitions: [group(`${section}_mute`, 'Mute', muteIds), group(`${section}_knob`, '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}`] = layeredKnob({ name: `Send level (knob): ${tag}`, title: nameVar('input', ch), press: [act('send_on', { channel: ch, buss: b, state: 'toggle' })], adjust: ['send_level_adjust', { channel: ch, buss: b }], level: ['send_level_value', { channel: ch, buss: b }], pos: ['send_level_pos', { channel: ch, buss: b }], feedbacks: [tint('send_on', { channel: ch, buss: b }, ON_GREEN)], }) 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}`] = layeredKnob({ name: `Fader (knob): ${tag}`, title: nameVar('matrix', n), press: [act('matrix_mute', { matrix: n, state: 'toggle' })], adjust: ['matrix_fader_adjust', { matrix: n }], level: ['matrix_fader_value', { matrix: n }], pos: ['matrix_fader_pos', { matrix: n }], feedbacks: [tint('matrix_muted', { matrix: n }, MUTED_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}`] = layeredKnob({ name: `Fader (knob): ${tag}`, title: nameVar('cg', n), press: [act('cg_mute', { cg: n, state: 'toggle' })], adjust: ['cg_fader_adjust', { cg: n }], level: ['cg_fader_value', { cg: n }], pos: ['cg_fader_pos', { cg: n }], feedbacks: [tint('cg_muted', { cg: n }, MUTED_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)] }) } // ---- Matrix sends: one section per matrix output, per matrix input an on/off button and a level knob for (const out of s.numbers('matrix')) { const oname = s.displayName('matrix', out) const onIds = [] const knobIds = [] for (const inN of s.numbers('minput')) { const tag = `${s.displayName('minput', inN)} → ${out} · ${oname}` const text = `${nameVar('minput', inN)}\\n$(local:level) dB` const options = { matrixIn: inN, matrixOut: out } const lv = [localFeedback('level', 'matrix_send_level_value', options)] // A send that is off looks like a mute (red); an enabled send keeps the black background. presets[`msend_on_${inN}_${out}`] = simple(`Matrix send on/off: ${tag}`, text, [act('matrix_send_on', { ...options, state: 'toggle' })], [fb('matrix_send_off', options, MUTED_RED)], { style: { text, size: 'auto', color: WHITE, bgcolor: BLACK }, localVariables: lv, }) presets[`msend_knob_${inN}_${out}`] = layeredKnob({ name: `Matrix send level (knob): ${tag}`, title: nameVar('minput', inN), press: [act('matrix_send_on', { ...options, state: 'toggle' })], adjust: ['matrix_send_level_adjust', options], level: ['matrix_send_level_value', options], pos: ['matrix_send_level_pos', options], // The console has no meter for matrix inputs (no address answers), so the column shows the destination output. meter: ['matrix_meter_pos', { matrix: out }], feedbacks: [tint('matrix_send_off', options, MUTED_RED)], }) onIds.push(`msend_on_${inN}_${out}`) knobIds.push(`msend_knob_${inN}_${out}`) } if (onIds.length) { structure.push({ id: `msends_${out}`, name: `Matrix sends to ${oname}`, definitions: [group(`msends_${out}_on`, 'On/off', onIds), group(`msends_${out}_knob`, 'Level (knob)', knobIds)], }) } } // ---- Output meters: one layered meter per buss and per matrix output { for (const { kind, prefix, meterSection, meterTitle } of BUSS_KINDS) { const ids = [] for (const n of s.namedBusses(kind)) { presets[`${prefix}_meter_${n}`] = layeredMeter({ name: `${meterTitle} meter: ${n} · ${s.displayName('buss', n)}`, title: nameVar('buss', n), posFeedback: [`${prefix}_meter_pos`, { buss: n }], signal: [`${prefix}_signal`, { buss: n, thresholdDb: -50 }], peak: [`${prefix}_peak`, { buss: n, peakDb: -1, hold: 2 }], }) ids.push(`${prefix}_meter_${n}`) } if (ids.length) { structure.push({ id: meterSection, name: `${meterTitle} meters`, description: 'Meters subscribe on the console when the button first renders.', definitions: [group(`${meterSection}_level`, 'Level', ids)] }) } } const matrixIds = [] for (const n of s.numbers('matrix')) { presets[`matrix_meter_${n}`] = layeredMeter({ name: `Matrix meter: ${n} · ${s.displayName('matrix', n)}`, title: nameVar('matrix', n), posFeedback: ['matrix_meter_pos', { matrix: n }], signal: ['matrix_signal', { matrix: n, thresholdDb: -50 }], peak: ['matrix_peak', { matrix: n, peakDb: -1, hold: 2 }], }) matrixIds.push(`matrix_meter_${n}`) } if (matrixIds.length) { structure.push({ id: 'matrix_meters', name: 'Matrix meters', description: 'Meters subscribe on the console when the button first renders.', definitions: [group('matrix_meters_level', 'Level', matrixIds)] }) } } // ---- Graphic EQs: in / out { const ids = [] for (const n of s.numbers('geq')) { presets[`geq_in_${n}`] = simple(`In/out: ${n} · ${s.displayName('geq', n)}`, `${s.displayName('geq', n)}\\nIN`, [act('geq_on', { geq: n, state: 'toggle' })], [fb('geq_on', { geq: n }, GREEN)]) ids.push(`geq_in_${n}`) } if (ids.length) structure.push({ id: 'geqs', name: 'Graphic EQs', definitions: [group('geqs_in', 'In/out', ids)] }) } // ---- 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 * gate / compressor reduction meter presets are left out first, then the per-input aux send level knobs; * stats.dropped says which. */ export function getPresetDefinitions(inst, limits = { count: MAX_PRESETS, bytes: MAX_PRESET_BYTES }) { const tiers = [ { options: { grMeters: true, sendKnobs: true }, dropped: [] }, { options: { grMeters: false, sendKnobs: true }, dropped: ['the gate / compressor reduction meter presets'] }, { options: { grMeters: false, sendKnobs: false }, dropped: ['the gate / compressor reduction meter presets', 'the per-input send level knobs'] }, ] let built let count = 0 let bytes = 0 let dropped = [] for (const tier of tiers) { built = build(inst, tier.options) count = Object.keys(built.presets).length bytes = JSON.stringify(built.presets).length dropped = tier.dropped if (count <= limits.count && bytes <= limits.bytes) break } return { ...built, stats: { count, bytes, dropped } } }