From 9f64d20a243566be4fbaf7e771b3f171edcc1b1b Mon Sep 17 00:00:00 2001 From: Waters Church Date: Tue, 20 Oct 2026 09:50:31 -0400 Subject: [PATCH] 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 --- companion/HELP.md | 49 ++++++-- src/definitions/feedbacks.js | 54 ++++++--- src/definitions/presets.js | 214 +++++++++++++++++++++++------------ src/main.js | 12 ++ src/protocol.js | 31 +++++ test/selftest.js | 125 ++++++++++++++++++-- 6 files changed, 374 insertions(+), 111 deletions(-) diff --git a/companion/HELP.md b/companion/HELP.md index a1932e7..dbbf5eb 100644 --- a/companion/HELP.md +++ b/companion/HELP.md @@ -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 diff --git a/src/definitions/feedbacks.js b/src/definitions/feedbacks.js index 9568314..1cbf553 100644 --- a/src/definitions/feedbacks.js +++ b/src/definitions/feedbacks.js @@ -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': diff --git a/src/definitions/presets.js b/src/definitions/presets.js index faf5a8e..174724c 100644 --- a/src/definitions/presets.js +++ b/src/definitions/presets.js @@ -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', + /** 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, - style: { text, size: 'auto', color: WHITE, bgcolor: DARK }, - steps: [ + 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'), { - down: press, - up: [], - rotate_left: [act(adjustActionId, { ...adjustOptions, delta: -1 })], - rotate_right: [act(adjustActionId, { ...adjustOptions, delta: 1 })], + 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}`) } diff --git a/src/main.js b/src/main.js index 0f845f4..bd5f50d 100644 --- a/src/main.js +++ b/src/main.js @@ -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 } diff --git a/src/protocol.js b/src/protocol.js index e9cedfd..24e09ef 100644 --- a/src/protocol.js +++ b/src/protocol.js @@ -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 '' diff --git a/test/selftest.js b/test/selftest.js index 8cb7263..4a318c6 100644 --- a/test/selftest.js +++ b/test/selftest.js @@ -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 // ---------------------------------------------------------------------------