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:
+117
-8
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user