Use Companion's native Gauge element for level displays

Companion 5.1 marks image-buffer feedbacks as deprecated, and its layered
buttons have a Gauge element that follows a number directly. So:

- New numeric value feedbacks for gauges: fader/level position 0-100 for
  inputs, busses, matrix outputs, control groups, aux sends and matrix
  sends (one shared fader taper: 0 dB at 75 %, -inf at 0), raw pan 0..1
  for a symmetric gauge, GEQ band dB, and input meter position.
- Every knob and meter preset is now a layered preset: background box,
  channel name, live dB text and a Gauge bound to $(local:pos); on/off and
  mute tint the background. Meters use a vertical green/amber/red gauge.
  Text uses size 100 with shrink-to-fit (14 rendered tiny on 5.1) and the
  level gauge is white so it stays visible on a green background.
- The old image-bar feedbacks keep working and are labelled "(legacy
  image)"; HELP.md explains how to add a gauge to your own button.
- A sleeping iPad no longer logs a relay send error every few seconds:
  one warning per iPad per 10 minutes.

Checked on the booth Companion 5.1: the layered knob previews show live
names, levels and gauge fills. npm test: 392 checks pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 09:50:31 -04:00
co-authored by Claude Opus 5.5
parent 90dee14444
commit 9f64d20a24
6 changed files with 374 additions and 111 deletions
+39 -10
View File
@@ -109,7 +109,7 @@ snapshot's list position (0 = the first); verify it once on your console before
## Feedbacks
Every action that changes something the console reports has a feedback for it: a boolean for every switch, a value
(text for a button's local variable) and a bar for every level. The state a feedback needs (sends, matrix sends,
(text for a button's local variable) and a numeric value for a gauge for every level. The state a feedback needs (sends, matrix sends,
graphic EQ bands, channel-strip values) is loaded the first time one is shown, and each button re-checks only when its
own value changes.
@@ -122,13 +122,40 @@ own value changes.
and pan; buss / matrix / control group fader; **matrix send level**; **graphic EQ band gain**; trim, analog gain and
filter frequency per input and buss (trim also per matrix output); current snapshot name; input meter value. Levels
read `-inf` at or below -90 dB.
* Advanced bars: input fader, aux send level, buss / matrix output / control group fader, **matrix send level** (all
-60 to +10 dB) and **graphic EQ band gain** (-12 to +12 dB) draw a horizontal bar along the bottom of the button;
the input meter draws a vertical bar at the right edge (green, amber, red).
* Numeric, for gauges (see **Gauges** below): the `*_pos` position feedbacks, `input_pan_num` / `send_pan_num` and
`geq_band_db`.
* Advanced image bars (**legacy image**, deprecated by Companion 5.1, kept so existing buttons keep working): input
fader, aux send level, buss / matrix output / control group fader, matrix send level (-60 to +10 dB) and graphic EQ
band gain (-12 to +12 dB) draw a horizontal bar along the bottom of the button; the input meter draws a vertical bar at
the right edge. Use a gauge on new buttons.
Not covered by a feedback: free-form *Send raw OSC*, and *Set name* (the name is the variable `input_N_name`). There is
no matrix-output solo action, only its feedback.
## Gauges
Companion 5.x has a native **Gauge** element, and every knob and meter preset uses one. Each position feedback is a
number you feed to a gauge through a button local variable:
| Feedback | Range | Meaning |
| --- | --- | --- |
| `input_fader_pos`, `buss_fader_pos`, `matrix_fader_pos`, `cg_fader_pos`, `send_level_pos`, `matrix_send_level_pos` | 0-100 | fader / send position on a fader taper; unknown reads 0 |
| `input_pan_num`, `send_pan_num` | 0-1 | the console's pan, 0.5 = centre (unknown reads 0.5) |
| `geq_band_db` | -12 to +12 | graphic EQ band gain in dB (unknown reads 0) |
| `input_meter_pos` | 0-100 | input meter (0-127 scaled); has the *Meter* option level / gate / compressor reduction |
The fader taper maps +10 dB = 100, +5 = 87.5, 0 = 75, -5 = 65, -10 = 55, -20 = 40, -30 = 28, -40 = 18, -50 = 11 and
-60 = 6 (straight lines in between), falls to about 1 just above -90 dB, and reads 0 at or below -90 dB (off).
To add a gauge to your own button in Companion 5.1:
1. **Local Variables** tab: add a feedback variable, name it `pos`, and pick the position feedback (for example
`input_fader_pos` with its input).
2. **Style** tab: **+**, then **Gauge**. Set *Value* to the expression `$(local:pos)`, *Min* 0, *Max* 100
(fader and meter positions).
3. For pan use `input_pan_num` / `send_pan_num` with *Min* 0, *Max* 1, **Symmetric** on and *Origin* 0.5. For a graphic EQ
band use `geq_band_db` with *Min* -12, *Max* 12, symmetric, *Origin* 0.
## Variables
`input_N_name|fader|mute|pan`, `buss_N_name|mode|stereo|fader|mute`, `matrix_N_name|fader|mute`,
@@ -138,21 +165,23 @@ no matrix-output solo action, only its feedback.
## Presets
Presets are named with the live entity names and rebuilt when names load.
Presets are named with the live entity names and rebuilt when names load. The knob and meter presets are *layered*
(black background, name, dB text, gauge) and a muted / on state tints their background; the plain toggles are simple
buttons.
| Preset group | What the buttons do |
| --- | --- |
| Inputs: Mute | One button per input; red when muted |
| Inputs: Fader (knob) | Rotary: turn = fader -/+1 dB, press = mute; shows name and dB, with a level bar |
| Inputs: Fader (knob) | Rotary: turn = fader -/+1 dB, press = mute; shows name and dB with a gauge along the bottom |
| Inputs: Solo | One button per input |
| Busses | Master mute, and master fader knob (turn = fader, press = mute) per named buss |
| Sends to *aux name* | One section per named aux buss: per input an **On/off** button (green when on, shows the level) and a **Level (knob)** (turn = send level, press = on/off, with a bar) |
| Sends to *aux name* | One section per named aux buss: per input an **On/off** button (green when on, shows the level) and a **Level (knob)** (turn = send level, press = on/off, with a gauge) |
| Group *group name* | One section per named group buss: per input an **Assign** toggle, green when assigned |
| Matrix, Control groups | Mute and fader knob per output / group |
| Matrix sends to *output name* | One section per matrix output: per matrix input an **On/off** button (green when on; shows `Matrix In N` and the level) and a **Level (knob)** (turn = send level, press = on/off, with a bar) |
| Matrix, Control groups | Mute and fader knob (with a gauge) per output / group |
| Matrix sends to *output name* | One section per matrix output: per matrix input an **On/off** button (green when on; shows `Matrix In N` and the level) and a **Level (knob)** (turn = send level, press = on/off, with a gauge) |
| Graphic EQs | One **In/out** toggle per graphic EQ, green when in |
| Snapshots | **Fire** per snapshot (green when current), next, previous, current (display) |
| Input meters | Level, gate gain reduction and compressor gain reduction buttons per input |
| Input meters | Level, gate gain reduction and compressor gain reduction buttons per input, each with a vertical gauge at the right edge (green below 70, amber 70-90, red above 90) |
| Console | Reload, connection status, relay status |
Busses and control groups with no name are unused slots and get no presets. If the preset list would exceed
+36 -18
View File
@@ -1,5 +1,5 @@
import { combineRgb } from '@companion-module/base'
import { GEQ_RANGE, LEVEL, METERS, PARAMS, clamp, fmtDb, fmtPan, fmtValue, meterId } from '../protocol.js'
import { GEQ_RANGE, LEVEL, METERS, PARAMS, clamp, faderPosition, fmtDb, fmtPan, fmtValue, meterId } from '../protocol.js'
import { horizontalBar, verticalMeter } from '../image.js'
import { opt, paramEntities, paramEntityOption } from './actions.js'
@@ -259,6 +259,24 @@ export function getFeedbackDefinitions(inst) {
}),
matrix_send_level_value: value('Matrix send: level (dB text)', msendOptions(), msendKey, ({ options }) => fmtDb(msendOf(options).level) || '--'),
geq_band_value: value('Graphic EQ: band gain (dB text)', [opt.geq(inst), opt.band()], geqGainKey, ({ options }) => fmtDb(geqGainOf(options)) || '--'),
// Numbers for Companion's native Gauge element (use as a button local variable, see the Gauges section of the help)
input_fader_pos: value('Input: fader position (0-100, for gauges)', [opt.channel(inst)], entityKey('input', 'channel'), ({ options }) => faderPosition(s.getFader('input', n(options.channel)))),
buss_fader_pos: value('Buss: fader position (0-100, for gauges)', [opt.buss(inst)], entityKey('buss', 'buss'), ({ options }) => faderPosition(s.getFader('buss', n(options.buss)))),
matrix_fader_pos: value('Matrix output: fader position (0-100, for gauges)', [opt.matrix(inst)], entityKey('matrix', 'matrix'), ({ options }) => faderPosition(s.getFader('matrix', n(options.matrix)))),
cg_fader_pos: value('Control group: fader position (0-100, for gauges)', [opt.cg(inst)], entityKey('cg', 'cg'), ({ options }) => faderPosition(s.getFader('cg', n(options.cg)))),
send_level_pos: value('Aux send: level position (0-100, for gauges)', [opt.channel(inst), opt.buss(inst, 'aux')], sendKey, ({ options }) => {
inst.ensureSend(n(options.channel), n(options.buss))
return faderPosition(s.getSend(n(options.channel), n(options.buss)).level)
}),
matrix_send_level_pos: value('Matrix send: level position (0-100, for gauges)', msendOptions(), msendKey, ({ options }) => faderPosition(msendOf(options).level)),
// pan is the raw 0..1 value (0.5 = centre) for a symmetric gauge with origin 0.5; unknown reads as centre
input_pan_num: value('Input: pan (0-1, 0.5 = centre, for gauges)', [opt.channel(inst)], entityKey('input', 'channel'), ({ options }) => s.getPan(n(options.channel)) ?? 0.5),
send_pan_num: value('Aux send: pan (0-1, 0.5 = centre, for gauges)', [opt.channel(inst), opt.buss(inst, 'aux')], sendKey, ({ options }) => {
inst.ensureSendPan(n(options.channel), n(options.buss))
return s.getSend(n(options.channel), n(options.buss)).pan ?? 0.5
}),
geq_band_db: value('Graphic EQ: band gain (dB number -12..12, for gauges)', [opt.geq(inst), opt.band()], geqGainKey, ({ options }) => geqGainOf(options) ?? 0),
input_meter_pos: value('Input meter: position (0-100, for gauges)', [opt.channel(inst), meterOption], meterKey, ({ options }) => ((meterOf(options)?.value ?? 0) / 127) * 100),
snapshot_name_value: value('Snapshot: current name', [], () => watchKey.snapshot, () => s.snapshotName(s.snapshots.current) ?? '--'),
input_meter_value: value(
'Input meter: value',
@@ -283,38 +301,38 @@ export function getFeedbackDefinitions(inst) {
},
),
input_fader_bar: bar('Input: fader bar', [opt.channel(inst)], entityKey('input', 'channel'), (fb, image) => {
input_fader_bar: bar('Input: fader bar (legacy image)', [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) => {
send_level_bar: bar('Aux send: level bar (legacy image)', [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) => {
buss_fader_bar: bar('Buss: fader bar (legacy image)', [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 })
}),
matrix_fader_bar: bar('Matrix output: fader bar', [opt.matrix(inst)], entityKey('matrix', 'matrix'), (fb, image) => {
matrix_fader_bar: bar('Matrix output: fader bar (legacy image)', [opt.matrix(inst)], entityKey('matrix', 'matrix'), (fb, image) => {
const v = s.getFader('matrix', n(fb.options.matrix))
return v === undefined ? {} : horizontalBar({ ...image, fraction: levelFraction(v), color: fb.options.color })
}),
cg_fader_bar: bar('Control group: fader bar', [opt.cg(inst)], entityKey('cg', 'cg'), (fb, image) => {
cg_fader_bar: bar('Control group: fader bar (legacy image)', [opt.cg(inst)], entityKey('cg', 'cg'), (fb, image) => {
const v = s.getFader('cg', n(fb.options.cg))
return v === undefined ? {} : horizontalBar({ ...image, fraction: levelFraction(v), color: fb.options.color })
}),
matrix_send_level_bar: bar('Matrix send: level bar', msendOptions(), msendKey, (fb, image) => {
matrix_send_level_bar: bar('Matrix send: level bar (legacy image)', msendOptions(), msendKey, (fb, image) => {
const v = msendOf(fb.options).level
return v === undefined ? {} : horizontalBar({ ...image, fraction: levelFraction(v), color: fb.options.color })
}),
geq_band_bar: bar('Graphic EQ: band gain bar', [opt.geq(inst), opt.band()], geqGainKey, (fb, image) => {
geq_band_bar: bar('Graphic EQ: band gain bar (legacy image)', [opt.geq(inst), opt.band()], geqGainKey, (fb, image) => {
const v = geqGainOf(fb.options)
return v === undefined ? {} : horizontalBar({ ...image, fraction: geqFraction(v), color: fb.options.color })
}),
input_meter_bar: {
type: 'advanced',
name: 'Input meter: bar (right edge)',
name: 'Input meter: bar at the right edge (legacy image)',
affectedProperties: ['imageBuffer'],
options: [opt.channel(inst), meterOption],
callback: tracked(meterKey, (fb) => {
@@ -362,23 +380,23 @@ export function feedbackIdsForEvent(ev) {
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', 'matrix_fader_bar', 'level_compare'],
cg: ['cg_fader_value', 'cg_fader_bar', 'level_compare'],
input: ['input_fader_value', 'input_fader_pos', 'input_fader_bar', 'level_compare'],
buss: ['buss_fader_value', 'buss_fader_pos', 'buss_fader_bar', 'level_compare'],
matrix: ['matrix_fader_value', 'matrix_fader_pos', 'matrix_fader_bar', 'level_compare'],
cg: ['cg_fader_value', 'cg_fader_pos', 'cg_fader_bar', 'level_compare'],
}[ev.ent] || []
)
case 'pan':
return ['input_pan_value']
return ['input_pan_value', 'input_pan_num']
case 'send':
if (ev.field === 'level') return ['send_level_value', 'send_level_bar', 'send_level_compare']
if (ev.field === 'level') return ['send_level_value', 'send_level_pos', 'send_level_bar', 'send_level_compare']
if (ev.field === 'on') return ['send_on', 'send_on_value']
if (ev.field === 'pan') return ['send_pan_value']
if (ev.field === 'pan') return ['send_pan_value', 'send_pan_num']
return []
case 'msend':
return ev.field === 'level' ? ['matrix_send_level_value', 'matrix_send_level_bar'] : ['matrix_send_on']
return ev.field === 'level' ? ['matrix_send_level_value', 'matrix_send_level_pos', 'matrix_send_level_bar'] : ['matrix_send_on']
case 'geq':
return ev.field === 'on' ? ['geq_on'] : ['geq_band_value', 'geq_band_bar']
return ev.field === 'on' ? ['geq_on'] : ['geq_band_value', 'geq_band_db', 'geq_band_bar']
case 'group':
return ['group_assigned']
case 'param':
+136 -72
View File
@@ -4,7 +4,19 @@ import { COLORS } from './feedbacks.js'
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 { 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
const fb = (feedbackId, options, bg, color = WHITE) => ({ feedbackId, options, style: { bgcolor: bg, color } })
const act = (actionId, options) => ({ actionId, options })
@@ -29,21 +41,82 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
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: [
/** 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 }] })
/**
* 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.
*/
const layeredKnob = ({ name, title, press, adjust: [adjustActionId, adjustOptions], level: [levelId, levelOptions], pos: [posId, posOptions], feedbacks }) => ({
type: 'layered',
name,
elements: [
{ type: 'box', id: 'bg', x: 0, y: 0, width: 100, height: 100, color: BLACK },
text('title', 0, 0, 100, 55, title),
text('level', 0, 52, 100, 32, '$(local:level) dB'),
{
type: 'gauge',
id: 'gauge',
x: 0,
y: 84,
width: 100,
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 }],
},
],
steps: knobSteps(press, adjustActionId, adjustOptions),
feedbacks,
localVariables,
localVariables: [localFeedback('level', levelId, levelOptions), localFeedback('pos', posId, posOptions)],
})
/** Layered input meter: name on the left, a vertical gauge (green / amber / red zones) at the right edge. */
const layeredMeter = ({ name, title, options }) => ({
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,
stops: [
{ value: 0, color: GAUGE_GREEN, gradient: false },
{ value: 70, color: METER_AMBER, gradient: false },
{ value: 90, color: METER_RED, gradient: false },
],
},
],
steps: [{ down: [], up: [] }],
feedbacks: [tint('input_signal', { ...options, threshold: 5 }, SIGNAL_BG), tint('input_peak', { ...options, hold: 2 }, MUTED_RED)],
localVariables: [localFeedback('pos', 'input_meter_pos', options)],
})
const group = (id, name, ids) => ({ id, type: 'simple', name, presets: ids })
@@ -57,15 +130,15 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
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_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'],
@@ -73,16 +146,7 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
['comp', 'input_comp_gr', 'Compressor reduction'],
]) {
if (meter !== 'level' && !grMeters) continue
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),
],
)
presets[`${prefix}_${n}`] = layeredMeter({ name: `${label}: ${tag}`, title: nameVar('input', n), options: { channel: n, meter } })
meterIds[meter].push(`${prefix}_${n}`)
}
muteIds.push(`input_mute_${n}`)
@@ -114,15 +178,15 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
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 } }],
)
presets[`buss_knob_${n}`] = layeredKnob({
name: `Master fader (knob): ${tag}`,
title: nameVar('buss', n),
press: [act('buss_mute', { busses: [n], state: 'toggle' })],
adjust: ['buss_fader_adjust', { buss: n }],
level: ['buss_fader_value', { buss: n }],
pos: ['buss_fader_pos', { buss: n }],
feedbacks: [tint('buss_muted', { buss: n }, MUTED_RED)],
})
muteIds.push(`buss_mute_${n}`)
knobIds.push(`buss_knob_${n}`)
}
@@ -145,15 +209,15 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
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 } }],
)
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}`)
}
}
@@ -185,15 +249,15 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
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)],
)
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}`)
}
@@ -205,15 +269,15 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
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)],
)
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}`)
}
@@ -231,15 +295,15 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
const options = { matrixIn: inN, matrixOut: out }
const lv = [localFeedback('level', 'matrix_send_level_value', options)]
presets[`msend_on_${inN}_${out}`] = simple(`Matrix send on/off: ${tag}`, text, [act('matrix_send_on', { ...options, state: 'toggle' })], [fb('matrix_send_on', options, GREEN)], { localVariables: lv })
presets[`msend_knob_${inN}_${out}`] = knob(
`Matrix send level (knob): ${tag}`,
text,
[act('matrix_send_on', { ...options, state: 'toggle' })],
'matrix_send_level_adjust',
options,
lv,
[fb('matrix_send_on', options, GREEN), { feedbackId: 'matrix_send_level_bar', options: { ...options, color: BAR } }],
)
presets[`msend_knob_${inN}_${out}`] = layeredKnob({
name: `Matrix send level (knob): ${tag}`,
title: `Matrix In ${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],
feedbacks: [tint('matrix_send_on', options, ON_GREEN)],
})
onIds.push(`msend_on_${inN}_${out}`)
knobIds.push(`msend_knob_${inN}_${out}`)
}
+12
View File
@@ -367,6 +367,18 @@ export default class DigicoS31Instance extends InstanceBase {
if (last !== undefined && now - last < this.timing.errorLogMs) return false
if (this.errorLog.size > 200) this.errorLog.clear()
this.errorLog.set(key, now)
// An iPad that went to sleep makes every fan-out fail with "host down/unreachable" until the relay forgets it
// (up to the idle timeout): say so once per iPad as a warning instead of an error every few seconds.
const asleep = source === 'Relay socket' && err.dest && ['EHOSTDOWN', 'EHOSTUNREACH', 'ENETUNREACH'].includes(err.code)
if (asleep) {
const ip = String(err.dest).split(':')[0]
const sleepKey = `asleep|${ip}`
const lastSleep = this.errorLog.get(sleepKey)
if (lastSleep !== undefined && now - lastSleep < 10 * 60 * 1000) return false
this.errorLog.set(sleepKey, now)
this.log('warn', `Relay: iPad ${ip} is not reachable (asleep or off the network); it stays on the list until it sends again or the idle timeout passes`)
return true
}
this.log('error', `${source}: ${err.message}${err.dest ? ` (${err.dest})` : ''}`)
return true
}
+31
View File
@@ -277,6 +277,37 @@ export function fmtPan(p) {
return pct < 0 ? `L${-pct}` : `R${pct}`
}
/** Fader taper for gauges: dB -> position 0..100, piecewise linear through these anchor points. */
const FADER_TAPER = [
[10, 100],
[5, 87.5],
[0, 75],
[-5, 65],
[-10, 55],
[-20, 40],
[-30, 28],
[-40, 18],
[-50, 11],
[-60, 6],
]
/**
* Position 0..100 of a level on a fader-like gauge (the shared taper above). Anything at or below `floor`, the
* level the kind treats as off (-90 dB for every kind in this module), is 0; just above it the position is about 1
* (a sliver stays visible for a send that is on), rising through -60 dB = 6 up to +10 dB = 100. Pass -150 as `floor`
* for a kind whose off level is -150. Non-numbers give 0.
*/
export function faderPosition(dB, floor = LEVEL.floor) {
if (typeof dB !== 'number' || !Number.isFinite(dB) || dB <= floor) return 0
if (dB >= FADER_TAPER[0][0]) return 100
let prev = FADER_TAPER[0]
for (const point of FADER_TAPER.slice(1)) {
if (dB >= point[0]) return point[1] + ((dB - point[0]) / (prev[0] - point[0])) * (prev[1] - point[1])
prev = point
}
return 1 + ((dB - floor) / (prev[0] - floor)) * (prev[1] - 1)
}
/** 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 ''
+117 -8
View File
@@ -20,7 +20,7 @@ import { ConsoleLink } from '../src/console-link.js'
import { Relay } from '../src/relay.js'
import { encode, decode, foldPattern } from '../src/osc.js'
import { State } from '../src/state.js'
import { LEVEL, PARAMS, adjustedLevel, fmtCue, fmtDb, fmtPan, indexProblem, meterId, meterIdForInput, parse } from '../src/protocol.js'
import { LEVEL, PARAMS, adjustedLevel, faderPosition, fmtCue, fmtDb, fmtPan, indexProblem, meterId, meterIdForInput, parse } from '../src/protocol.js'
import { BYTES_PER_PIXEL, horizontalBar, verticalMeter, thicknessFor } from '../src/image.js'
import { MAX_PRESETS, MAX_PRESET_BYTES } from '../src/definitions/presets.js'
import { COLORS } from '../src/definitions/feedbacks.js'
@@ -246,6 +246,26 @@ check(typeof Instance === 'function' && Array.isArray(mainMod.UpgradeScripts), '
check(parse('/Control_Groups/3/name', ['KEYS']).n === 3 && parse('/Snapshots/name', [3, 400, 0, 'X']).cue === 400 && parse('/Meters/values', [5001, 10, 5002, 20]).pairs.length === 2, 'parse: control group name, snapshot name, meters')
check(parse('/Console/Aux_Outputs', [25]).field === 'busses' && parse('/Nonexistent/thing', [1]) === null, 'parse: counts, unknown address -> null')
}
{
// the fader taper used by every position feedback
const anchors = [[10, 100], [5, 87.5], [0, 75], [-5, 65], [-10, 55], [-20, 40], [-30, 28], [-40, 18], [-50, 11], [-60, 6]]
check(anchors.every(([db, pos]) => near(faderPosition(db), pos, 1e-9)) && faderPosition(25) === 100, 'faderPosition hits every anchor: +10 = 100, +5 = 87.5, 0 = 75, -5 = 65, -10 = 55, -20 = 40, -30 = 28, -40 = 18, -50 = 11, -60 = 6')
check(near(faderPosition(-7.5), 60) && near(faderPosition(2.5), 81.25) && near(faderPosition(-15), 47.5) && near(faderPosition(-75), 3.5), 'faderPosition interpolates linearly between the anchors')
check(faderPosition(-90) === 0 && faderPosition(-120) === 0 && faderPosition(-150) === 0 && near(faderPosition(-89.99), 1, 0.01), 'at or below the -90 floor the position is 0, just above it about 1')
check(faderPosition(-150, -150) === 0 && near(faderPosition(-149.99, -150), 1, 0.01) && near(faderPosition(-60, -150), 6) && faderPosition(-100, -150) > 1, 'with a -150 floor the taper runs down to -150')
check(faderPosition(NaN) === 0 && faderPosition(undefined) === 0 && faderPosition('x') === 0 && faderPosition(null) === 0, 'non-numbers read 0')
let monotone = true
for (const floor of [-90, -150]) {
let last = -1
for (let db = floor; db <= 12; db += 0.25) {
const v = faderPosition(db, floor)
if (v < last || v < 0 || v > 100) monotone = false
last = v
}
for (let db = floor + 0.25; db < 10; db += 0.25) if (!(faderPosition(db, floor) < faderPosition(db + 0.25, floor))) monotone = false
}
check(monotone, 'faderPosition is monotonic (strictly rising up to +10) and stays within 0..100')
}
{
const h = horizontalBar({ width: 72, height: 58, fraction: 0.5, color: 0x00c85a })
const raw = Buffer.from(h.imageBuffer, 'base64')
@@ -396,6 +416,7 @@ check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs &&
check(idsExpected.every((id) => ctx.actions[id]) && Object.keys(ctx.actions).length === idsExpected.length, `all ${idsExpected.length} designed actions exist (${Object.keys(ctx.actions).length} registered)`)
check(!('macro_press' in ctx.actions), 'no first-class macro action (the console never answered a macro query)')
const fbExpected = ['input_muted', 'input_solo', 'send_on', 'group_assigned', 'buss_muted', 'buss_solo', 'matrix_muted', 'cg_muted', 'snapshot_current', 'level_compare', 'send_level_compare', 'connected', 'relay_clients', 'input_signal', 'input_peak', 'input_fader_value', 'input_pan_value', 'send_level_value', 'send_on_value', 'buss_fader_value', 'matrix_fader_value', 'cg_fader_value', 'snapshot_name_value', 'input_meter_value', 'input_fader_bar', 'send_level_bar', 'buss_fader_bar', 'input_meter_bar']
fbExpected.push('input_fader_pos', 'buss_fader_pos', 'matrix_fader_pos', 'cg_fader_pos', 'send_level_pos', 'matrix_send_level_pos', 'input_pan_num', 'send_pan_num', 'geq_band_db', 'input_meter_pos')
fbExpected.push('matrix_solo', 'cg_solo', 'matrix_send_on', 'geq_on', 'send_pan_value', 'matrix_send_level_value', 'geq_band_value', 'matrix_fader_bar', 'cg_fader_bar', 'matrix_send_level_bar', 'geq_band_bar')
for (const [param, p] of Object.entries(PARAMS)) for (const ent of Object.keys(p.paths)) fbExpected.push(`${ent}_${param}_${p.type === 'switch' ? 'state' : 'value'}`)
check(fbExpected.every((id) => ctx.feedbacks[id]) && Object.keys(ctx.feedbacks).length === fbExpected.length, `all ${fbExpected.length} designed feedbacks exist (${Object.keys(ctx.feedbacks).length} registered)`)
@@ -421,6 +442,13 @@ check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs &&
else PARITY[`${ent}_${param}_set`] = PARITY[`${ent}_${param}_adjust`] = [`${ent}_${param}_value`]
}
}
const GAUGE = { input_fader: 'input_fader_pos', send_level: 'send_level_pos', buss_fader: 'buss_fader_pos', matrix_fader: 'matrix_fader_pos', matrix_send_level: 'matrix_send_level_pos', cg_fader: 'cg_fader_pos', input_pan: 'input_pan_num', send_pan: 'send_pan_num', geq_band: 'geq_band_db' }
for (const id of Object.keys(PARITY)) {
const g = GAUGE[id.replace(/_(set|adjust)$/, '')]
if (g && /_(set|adjust)$/.test(id)) PARITY[id] = [...PARITY[id], g]
}
const noGauge = Object.keys(ctx.actions).filter((id) => /^(input_fader|send_level|buss_fader|matrix_fader|matrix_send_level|cg_fader|input_pan|send_pan|geq_band)_(set|adjust)$/.test(id) && !(PARITY[id] || []).some((f) => /_(pos|num|db)$/.test(f)))
check(noGauge.length === 0 && Object.values(GAUGE).every((f) => ctx.feedbacks[f]?.type === 'value'), `every level and pan action has a numeric value feedback for gauges (${Object.values(GAUGE).join(', ')})`)
const noFeedback = Object.keys(ctx.actions).filter((id) => !(id in PARITY))
const missing = Object.entries(PARITY).filter(([id, fbs]) => id in ctx.actions && fbs.some((f) => !ctx.feedbacks[f])).map(([id]) => id)
const levelActions = Object.keys(ctx.actions).filter((id) => /_(set|adjust)$/.test(id) && /(fader|level|pan|band|trim|gain|freq)/.test(id))
@@ -447,7 +475,7 @@ check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs &&
if (optionKeys(def) !== Object.keys(a.options).sort().join()) problems.push(`${id}: ${a.actionId} options ${Object.keys(a.options)} vs ${optionKeys(def)}`)
}
for (const [id, p] of Object.entries(P)) {
if (p.type !== 'simple') problems.push(`${id}: type ${p.type}`)
if (p.type !== 'simple' && p.type !== 'layered') problems.push(`${id}: type ${p.type}`)
for (const step of p.steps) {
for (const key of ['down', 'up', 'rotate_left', 'rotate_right']) for (const a of step[key] || []) checkAction(id, a)
if ((step.rotate_left && !step.rotate_right) || (!step.rotate_left && step.rotate_right)) problems.push(`${id}: only one rotate array`)
@@ -457,8 +485,9 @@ check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs &&
if (!def) problems.push(`${id}: unknown feedback ${f.feedbackId}`)
else {
if (optionKeys(def) !== Object.keys(f.options).sort().join()) problems.push(`${id}: ${f.feedbackId} options ${Object.keys(f.options)} vs ${optionKeys(def)}`)
if (def.type === 'boolean' && !f.style) problems.push(`${id}: boolean feedback ${f.feedbackId} without style`)
if (def.type === 'advanced' && f.style) problems.push(`${id}: advanced feedback ${f.feedbackId} with style`)
if (p.type === 'simple' && def.type === 'boolean' && !f.style) problems.push(`${id}: boolean feedback ${f.feedbackId} without style`)
if (p.type === 'simple' && def.type === 'advanced' && f.style) problems.push(`${id}: advanced feedback ${f.feedbackId} with style`)
if (p.type === 'layered' && (def.type !== 'boolean' || f.style || !Array.isArray(f.styleOverrides) || !f.styleOverrides.length)) problems.push(`${id}: layered feedback ${f.feedbackId} must be boolean with styleOverrides`)
if (def.type === 'value') problems.push(`${id}: value feedback ${f.feedbackId} used as a button feedback`)
}
}
@@ -469,8 +498,9 @@ check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs &&
if (lv.variableType !== 'feedback' || !def || def.type !== 'value') problems.push(`${id}: local variable ${lv.variableName} is not backed by a value feedback`)
else if (optionKeys(def) !== Object.keys(lv.options).sort().join()) problems.push(`${id}: local ${lv.feedbackId} options`)
}
for (const m of p.style.text.matchAll(/\$\(local:(\w+)\)/g)) if (!locals.has(m[1])) problems.push(`${id}: text uses undeclared local ${m[1]}`)
for (const m of p.style.text.matchAll(/\$\(S31:(\w+)\)/g)) if (!(m[1] in ctx.variableDefs)) problems.push(`${id}: text uses undefined variable ${m[1]}`)
const used = p.type === 'layered' ? JSON.stringify(p.elements) : p.style.text
for (const m of used.matchAll(/\$\(local:(\w+)\)/g)) if (!locals.has(m[1])) problems.push(`${id}: uses undeclared local ${m[1]}`)
for (const m of used.matchAll(/\$\(S31:(\w+)\)/g)) if (!(m[1] in ctx.variableDefs)) problems.push(`${id}: uses undefined variable ${m[1]}`)
}
check(problems.length === 0, `preset actions, feedbacks, options and local variables are valid (${problems.length ? problems.slice(0, 5).join('; ') : 'no problems'})`)
const knobs = ids.filter((id) => /_knob_/.test(id))
@@ -485,8 +515,50 @@ check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs &&
const mOn = P.msend_on_1_8
const mKnob = P.msend_knob_1_8
check(mOn.style.text === 'Matrix In 1\\n$(local:level) dB' && mOn.localVariables[0].feedbackId === 'matrix_send_level_value' && JSON.stringify(mOn.localVariables[0].options) === JSON.stringify({ matrixIn: 1, matrixOut: 8 }) && mOn.steps[0].down[0].actionId === 'matrix_send_on' && mOn.steps[0].down[0].options.state === 'toggle' && mOn.feedbacks.some((f) => f.feedbackId === 'matrix_send_on' && f.style.bgcolor === COLORS_GREEN), 'matrix send on/off preset: toggle matrix_send_on, green when on, text from the level value feedback')
check(mKnob.steps[0].rotate_left[0].actionId === 'matrix_send_level_adjust' && mKnob.steps[0].rotate_left[0].options.delta === -1 && mKnob.steps[0].rotate_right[0].options.delta === 1 && mKnob.steps[0].down[0].actionId === 'matrix_send_on' && mKnob.feedbacks.some((f) => f.feedbackId === 'matrix_send_on') && mKnob.feedbacks.some((f) => f.feedbackId === 'matrix_send_level_bar'), 'matrix send knob preset: rotate = matrix_send_level_adjust -1/+1, press toggles on/off, on feedback and level bar')
check(mKnob.steps[0].rotate_left[0].actionId === 'matrix_send_level_adjust' && mKnob.steps[0].rotate_left[0].options.delta === -1 && mKnob.steps[0].rotate_right[0].options.delta === 1 && mKnob.steps[0].down[0].actionId === 'matrix_send_on' && mKnob.type === 'layered' && mKnob.feedbacks[0].feedbackId === 'matrix_send_on' && mKnob.feedbacks[0].styleOverrides[0].elementId === 'bg' && mKnob.localVariables.map((v) => `${v.variableName}=${v.feedbackId}`).join() === 'level=matrix_send_level_value,pos=matrix_send_level_pos', 'matrix send knob preset (layered): rotate = matrix_send_level_adjust -1/+1, press toggles on/off, on tints the background, level and gauge position local variables')
check(!!P.geq_in_1 && P.geq_in_1.steps[0].down[0].actionId === 'geq_on' && P.geq_in_1.feedbacks[0].feedbackId === 'geq_on' && ids.filter((id) => id.startsWith('geq_in_')).length === 16, 'a Graphic EQs section with an in/out toggle and a geq_on feedback per graphic EQ')
// layered presets: element types and required fields from the Companion graphics definitions
const layered = ids.filter((id) => P[id].type === 'layered')
const knobsLayered = layered.filter((id) => /_knob_/.test(id))
const meterPresets = layered.filter((id) => /^input_(meter|gate_gr|comp_gr)_/.test(id))
check(knobsLayered.length === ids.filter((id) => /_knob_/.test(id)).length && meterPresets.length === 180 && layered.length === knobsLayered.length + 180 && !ids.some((id) => /_(mute|solo|on)_/.test(id) && P[id].type === 'layered'), `every knob (${knobsLayered.length}) and meter (${meterPresets.length}) preset is layered; plain toggles stay simple`)
const ELEMENT_TYPES = new Set(['group', 'composite', 'text', 'image', 'box', 'line', 'circle', 'gauge'])
const LAYERED_PROPS = { box: ['id', 'type', 'x', 'y', 'width', 'height', 'color'], text: ['id', 'type', 'x', 'y', 'width', 'height', 'text', 'fontsize', 'fontsizeAllowShrink', 'color', 'halign', 'valign'], gauge: ['id', 'type', 'x', 'y', 'width', 'height', 'value', 'min', 'max', 'orientation', 'trackStyle', 'fillEnabled', 'multiColour', 'stops'] }
const isExpr = (v) => !!v && typeof v === 'object' && v.isExpression === true && typeof v.value === 'string'
const lp = []
for (const id of layered) {
const p = P[id]
if (!Array.isArray(p.elements) || !p.elements.length || typeof p.name !== 'string' || !Array.isArray(p.steps) || !Array.isArray(p.feedbacks) || p.style !== undefined) lp.push(`${id}: shape`)
const elementIds = new Set()
for (const e of p.elements) {
if (!ELEMENT_TYPES.has(e.type) || typeof e.id !== 'string' || elementIds.has(e.id)) lp.push(`${id}: element ${e.type}/${e.id}`)
elementIds.add(e.id)
if (!LAYERED_PROPS[e.type]) lp.push(`${id}: unexpected element type ${e.type}`)
else for (const k of Object.keys(e)) if (!LAYERED_PROPS[e.type].includes(k)) lp.push(`${id}: ${e.type} ${e.id} has unknown property ${k}`)
for (const k of ['x', 'y', 'width', 'height']) if (typeof e[k] !== 'number' || e[k] < 0 || e[k] > 100) lp.push(`${id}: ${e.id}.${k}`)
if (e.type === 'text' && (typeof e.text !== 'string' || typeof e.fontsize !== 'number')) lp.push(`${id}: text ${e.id}`)
if (e.type === 'box' && typeof e.color !== 'number') lp.push(`${id}: box color`)
if (e.type === 'gauge') {
const okStops = Array.isArray(e.stops) && e.stops.length > 0 && e.stops.every((st) => typeof st.value === 'number' && typeof st.color === 'number' && typeof st.gradient === 'boolean')
if (!isExpr(e.value) || e.value.value !== '$(local:pos)' || e.min !== 0 || e.max !== 100 || !['horizontal', 'vertical', 'ring'].includes(e.orientation) || !['transparent', 'dimmed'].includes(e.trackStyle) || !okStops) lp.push(`${id}: gauge`)
}
}
for (const f of p.feedbacks) for (const o of f.styleOverrides || []) {
const el = p.elements.find((e) => e.id === o.elementId)
if (!el || !(o.elementProperty in el) || typeof o.override !== 'number') lp.push(`${id}: styleOverride ${o.elementId}.${o.elementProperty}`)
}
const posVar = (p.localVariables || []).find((v) => v.variableName === 'pos')
if (!posVar || !/_(pos)$/.test(posVar.feedbackId) || ctx.feedbacks[posVar.feedbackId]?.type !== 'value') lp.push(`${id}: pos local variable`)
}
check(lp.length === 0, `layered presets are valid: element types and fields, unique element ids, style overrides name existing elements, every $(local:x) is declared and backed by a value feedback (${lp.length ? lp.slice(0, 4).join('; ') : `${layered.length} presets, no problems`})`)
const iK = P.input_knob_3
check(iK.elements.map((e) => `${e.type}:${e.id}`).join() === 'box:bg,text:title,text:level,gauge:gauge' && iK.elements[1].text === `$(S31:input_3_name)` && iK.elements[2].text === '$(local:level) dB' && iK.elements[3].orientation === 'horizontal' && iK.elements[3].y === 84 && iK.elements[3].height === 16 && iK.elements[3].stops.length === 1 && iK.elements[3].stops[0].color === 0xffffff && iK.elements[1].fontsize === 100 && iK.elements[1].fontsizeAllowShrink === true && iK.elements[3].trackStyle === 'dimmed' && iK.feedbacks[0].feedbackId === 'input_muted' && iK.feedbacks[0].styleOverrides[0].override === 0xb01010 && iK.localVariables.map((v) => `${v.variableName}=${v.feedbackId}`).join() === 'level=input_fader_value,pos=input_fader_pos', 'input knob: black box, name text, "$(local:level) dB", a white horizontal bottom gauge fed by $(local:pos), text size 100 with shrink, muted tints the background red')
const sK = P.send_knob_3_24 || P[ids.find((id) => id.startsWith('send_knob_'))]
check(sK.feedbacks[0].styleOverrides[0].override === 0x00a050 && sK.localVariables[1].feedbackId === 'send_level_pos', 'aux send knob: send on tints the background green, gauge from send_level_pos')
const mt = P.input_meter_7
const g = mt.elements.find((e) => e.id === 'gauge')
check(g.orientation === 'vertical' && g.x === 86 && g.width === 14 && g.multiColour === true && g.stops.map((st) => `${st.value}:${st.color.toString(16)}`).join() === '0:b050,70:f0a000,90:e00000' && mt.localVariables[0].feedbackId === 'input_meter_pos' && mt.feedbacks.map((f) => f.feedbackId).join() === 'input_signal,input_peak', 'meter preset: vertical gauge on the right edge, green below 70, amber 70-90, red above 90, signal and peak tint the background')
check(Object.entries(ctx.feedbacks).filter(([id]) => /_bar$/.test(id)).every(([, f]) => /\(legacy image\)$/.test(f.name)) && Object.keys(ctx.feedbacks).filter((id) => /_bar$/.test(id)).length === 8, 'the 8 advanced image bar feedbacks keep their ids and are named "(legacy image)"')
const bytes = JSON.stringify(P).length
console.log(`info presets: ${ids.length} presets, ${bytes} bytes serialized, ${inst.presetStats?.dropped?.length ? `dropped ${inst.presetStats.dropped.join(' and ')}` : 'nothing dropped'}`)
check(ids.length <= MAX_PRESETS && bytes <= MAX_PRESET_BYTES, `preset count ${ids.length} <= ${MAX_PRESETS} and size ${bytes} <= ${MAX_PRESET_BYTES} bytes`)
@@ -804,6 +876,23 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb
check(cb('geq_on', { geq: 3 }) === false && cb('geq_band_value', { geq: 3, band: 7 }) === '--', 'an unloaded graphic EQ reads false / "--"')
check(await waitFor(() => S.getGeq(3).on !== undefined && S.getGeq(3).gains?.get(7) !== undefined, 2500) && cb('geq_on', { geq: 3 }) === true && cb('geq_band_value', { geq: 3, band: 7 }) === '0', 'the graphic EQ state loads lazily (mock default: in, flat)')
check(cb('send_pan_value', { channel: 2, buss: 3 }) === 'L80', 'send_pan_value (pan 0.1 -> L80)')
// numeric position feedbacks for native gauges: known state gives the taper position, unknown gives 0 (and starts the lazy load)
check(near(cb('input_fader_pos', { channel: 5 }), 51.25) && near(cb('buss_fader_pos', { buss: 24 }), 69) && near(cb('matrix_fader_pos', { matrix: 2 }), 46) && near(cb('cg_fader_pos', { cg: 2 }), 59), 'input / buss / matrix / control group fader position follow the fader taper (-12.5 dB -> 51.25, -3 -> 69, -16 -> 46, -8 -> 59)')
check(near(cb('send_level_pos', { channel: 2, buss: 3 }), 61) && near(cb('matrix_send_level_pos', { matrixIn: 2, matrixOut: 3 }), 28) && near(cb('matrix_send_level_pos', { matrixIn: 3, matrixOut: 2 }), 6), 'aux send and matrix send position (-7 dB -> 61, -30 -> 28, -60 -> 6)')
check(cb('input_pan_num', { channel: 13 }) === 1 && near(cb('send_pan_num', { channel: 2, buss: 3 }), 0.1) && cb('geq_band_db', { geq: 1, band: 5 }) === 4, 'pan is the raw 0..1 number and the graphic EQ band is the dB number')
check(cb('input_fader_pos', { channel: 99 }) === 0 && cb('buss_fader_pos', { buss: 99 }) === 0 && cb('matrix_fader_pos', { matrix: 99 }) === 0 && cb('cg_fader_pos', { cg: 99 }) === 0 && cb('input_pan_num', { channel: 99 }) === 0.5, 'unknown fader state reads 0 (pan reads the 0.5 centre)')
const lazy = [
['send_level_pos', { channel: 45, buss: 9 }, '/Input_Channels/45/Aux_Send/9/send_level/?', 0],
['matrix_send_level_pos', { matrixIn: 7, matrixOut: 6 }, '/Matrix_Inputs/7/Matrix_Send/6/send_level/?', 0],
['send_pan_num', { channel: 45, buss: 9 }, '/Input_Channels/45/Aux_Send/9/send_pan/?', 0.5],
['geq_band_db', { geq: 4, band: 9 }, '/Graphic_EQ/4/geq_gain_9/?', 0],
]
const firsts = lazy.map(([id, options]) => cb(id, options))
check(lazy.every(([, , , want], i) => firsts[i] === want), `unloaded send / matrix send / send pan / GEQ band read ${firsts.join(', ')} on first sight`)
check(await waitFor(() => lazy.every(([, , address]) => sent.some((m) => m.address === address)), 2500), 'and each starts its lazy load')
await sleep(150)
check(typeof cb('send_level_pos', { channel: 45, buss: 9 }) === 'number' && S.getSend(45, 9).level !== undefined && S.getMatrixSend(7, 6).level === -90 && cb('matrix_send_level_pos', { matrixIn: 7, matrixOut: 6 }) === 0 && typeof cb('send_pan_num', { channel: 45, buss: 9 }) === 'number' && cb('geq_band_db', { geq: 4, band: 9 }) === 0, 'once the state arrives the position feedbacks return the real values (a matrix send at -90 stays 0)')
check(cb('input_trim_value', { channel: 14 }) === '-3' && cb('input_gain_value', { channel: 14 }) === '34.5' && cb('input_hpf_freq_value', { channel: 14 }) === '88' && cb('buss_trim_value', { buss: 1 }) === '-2' && cb('matrix_trim_value', { matrix: 2 }) === '1.5', 'channel-strip value feedbacks: trim, gain, filter frequency (whole Hz), buss and matrix trim')
check(cb('input_lpf_freq_value', { channel: 60 }) === '--' && (await waitFor(() => S.getParam('input', 60, 'lpf_freq') !== undefined, 2500)) && cb('input_lpf_freq_value', { channel: 60 }) === '12000', 'an unloaded channel-strip value reads "--" and loads lazily')
check(cb('send_level_value', { channel: 40, buss: 8 }) === '--' && cb('send_on', { channel: 40, buss: 8 }) === false, 'an unloaded send pair reads as unknown ("--" / false) and is queued for loading')
@@ -867,6 +956,10 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb
// deterministic values injected as if from the console
const inject = (value) => inst.onConsoleMessage(decode(encode('/Meters/values', [{ i: id7 }, { i: value }])), { address: '127.0.0.1', port: P.mock })
inject(127)
check(cb('input_meter_pos', { channel: 7, meter: 'level' }) === 100, 'input_meter_pos is 100 at full scale (127)')
inject(64)
check(near(cb('input_meter_pos', { channel: 7 }), (64 / 127) * 100) && cb('input_meter_pos', { channel: 7, meter: 'comp' }) >= 0, 'input_meter_pos is 0..100 from 0..127 and takes the level / gate / comp option')
inject(127)
check(cb('input_meter_value', { channel: 7, unit: 'raw' }) === 127 && cb('input_meter_value', { channel: 7, unit: 'percent' }) === 100, 'input_meter_value raw and percent')
check(cb('input_signal', { channel: 7, threshold: 5 }) === true && cb('input_peak', { channel: 7, hold: 2 }) === true, 'input_signal and input_peak at full scale')
inject(3)
@@ -897,7 +990,7 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb
const meterChecks = ctx.byId
check(meterChecks.length > 0 && meterChecks.length <= Math.ceil((spanMs / 1000) * 10) + 1, `meter feedback checks throttled to <= 10 Hz (${meterChecks.length} checks in ${spanMs} ms for 100 messages)`)
check(meterChecks.every(([, ids]) => ids.every((x) => watching7.has(x))) && !meterChecks.some(([, ids]) => ids.includes(gateInstance)), 'a meter update re-checks only the instances watching the changed meter id')
check(ctx.checks.length === 0, 'meter updates never fall back to per-type checks')
check(!ctx.checks.some(([, types]) => types.some((t) => /meter|signal|peak/.test(t))), `meter updates never fall back to per-type checks (per-type checks in that window: ${JSON.stringify(ctx.checks.map(([, t]) => t))}, from the keepalive tick)`)
const rebuildsBefore = ctx.rebuilds
await sleep(200)
check(ctx.rebuilds === rebuildsBefore, 'meter traffic never rebuilds the definitions')
@@ -1544,6 +1637,22 @@ mockB.kill('SIGTERM')
check(off.length === 0, `every address sent during the whole run is answered by the real console, a documented trigger node or a meter subscription${off.length ? `: ${off.join(', ')}` : ''}`)
}
// ---------------------------------------------------------------------------
// 11. A sleeping iPad is reported once as a warning, not as an error on every fan-out
// ---------------------------------------------------------------------------
{
const before = logs.length
const asleep = (code) => Object.assign(new Error(`send ${code} 192.0.2.50:9100`), { code, dest: '192.0.2.50:9100' })
inst.reportError('Relay socket', asleep('EHOSTDOWN'))
inst.reportError('Relay socket', asleep('EHOSTUNREACH'))
inst.reportError('Relay socket', asleep('EHOSTDOWN'))
const fresh = logs.slice(before).filter((l) => /192\.0\.2\.50/.test(l.message))
check(fresh.length === 1 && fresh[0].level === 'warn' && /not reachable/.test(fresh[0].message), `a sleeping iPad logs one warning (${fresh.map((l) => l.level).join(',') || 'none'})`)
const other = Object.assign(new Error('send EPERM 192.0.2.51:9100'), { code: 'EPERM', dest: '192.0.2.51:9100' })
inst.reportError('Relay socket', other)
check(logs.slice(before).some((l) => l.level === 'error' && /EPERM/.test(l.message)), 'other relay send errors are still logged as errors')
}
// ---------------------------------------------------------------------------
// Shutdown
// ---------------------------------------------------------------------------