Timed fades, mute-style matrix sends, meters on matrix send knobs

- Every set-level action (input, bus, matrix and control group faders,
  aux and matrix sends) has a fade time and a curve: fader travel
  (natural), fader travel with ease in/out, or linear in dB. Fades step
  at 20 Hz, land exactly on the target, never overshoot, and are
  cancelled by any new move on the same fader; new "Stop all fades"
  action, fades_running variable and fade_running feedback. Verified on
  the S31 with a switched-off matrix send: 20 writes in 1 s, ending
  exactly on target, then restored.
- Matrix sends now look like mutes in the presets: red when the send is
  off, black when it is on (new matrix_send_off feedback, true only when
  the send is known to be off).
- Matrix send knobs carry a meter column on the right. The S31 has no
  meter for matrix inputs, so it shows the destination output's meter.
  Meter gauges use a faint track so an empty meter reads as empty.
- Test fixes: the meter-resubscribe check no longer depends on a silent
  channel changing value, plus back-to-back /Meters/clear coverage.

npm test: 504 checks pass on Node 25 and on Companion's Node 22.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 11:20:07 -04:00
co-authored by Claude Opus 5.5
parent 52777f3d7c
commit 205f91e7d2
8 changed files with 634 additions and 64 deletions
+21 -5
View File
@@ -94,13 +94,29 @@ is not known yet.
| Graphic EQ | in/out, band gain set/adjust (bands 1-32) |
| Channel strip | per input and per buss: phantom, phase, pad, EQ in, compressor in, gate in, high-pass in, low-pass in, delay in, insert A/B in (on/off); trim (-40 to +40 dB), analog gain (0 to 60 dB), filter frequencies (20 to 20000 Hz) (set/adjust; trim also on matrix outputs and matrix inputs, phase also on matrix inputs) |
| Snapshots | fire (pick from the list or type an index), next, previous |
| Console | reload, raw OSC, relay: forget all iPads |
| Console | reload, raw OSC, relay: forget all iPads, stop all fades |
The channel-strip values are what the console reports (trim and gain in dB, filters in Hz). In the console's
own naming `lo_filter` is the high-pass filter and `hi_filter` the low-pass filter; the actions are named for
what they do. Delay times and the send pre/post fader switch are not offered: their units and meaning were
never verified.
**Fades.** Every *set level* action (input, buss, matrix output and control group faders, aux send level, matrix send
level) has a **Fade time (ms, 0 = instant)**, 0 to 60000 ms. With 0 the level jumps to the target at once, as before; above 0
the module moves the level there smoothly, starting from where it is now (it reads the level first when it is not known
yet; if the console does not answer, the target is set at once and a warning is logged). A new level is written 20 times a
second at most (every 50 ms, steps that would change the level by less than 0.05 dB are left out), and the last write is
exactly the target. The **Fade curve** (shown once the time is above 0) is *Fader travel (natural)*, which moves the way a
fader's travel does (the same taper as the gauges, so the level change sounds even; the default), *Fader travel, ease in/out*
(the same with a slow start and a slow end), or *Linear in dB* (the same number of dB every step; a fade from or to −∞ runs between −90 dB, where sound
stops, and the target, so no part of it is a silent stretch; a fade to −∞ still ends at exactly −∞). A matrix send never goes below −90 dB.
There is one fade per level: starting another fade, a plain *set* or an *adjust* on the same level ends the running one
where it is, and the new one takes over from that level. The action returns at once and the fade runs on in the
background; the **Stop all fades** action ends every fade where it is. Fades end too when the connection to the console is
closed or reconfigured, and while one runs the module keeps writing its level, so a fader moved at the console in the
meantime is overwritten. The variable `fades_running` is the number of fades running and the feedback **A fade is
running** is on while there is one.
**Encoders and writes.** Every write updates the module's state at once, so a fast encoder turn accumulates. For
0.4 seconds after the last write to a value, a different value arriving for it is treated as a late echo of an
earlier step and ignored; then one query confirms what the console has. The snapshot *Fire* action sends the
@@ -117,8 +133,8 @@ graphic EQ bands, channel-strip values) is loaded the first time one is shown, a
own value changes.
* Boolean (button colour): input muted / solo; aux send on; group assigned; buss muted / solo; matrix output muted /
solo; control group muted / solo; **matrix send on**; **graphic EQ in**; snapshot is current; fader level above/below
a threshold; aux send level above/below; console link alive; relay has an iPad; every channel-strip switch
solo; control group muted / solo; **matrix send on**; **matrix send is off** (red like a mute; false while the state is not yet known); **graphic EQ in**; snapshot is current; fader level above/below
a threshold; aux send level above/below; console link alive; relay has an iPad; a level fade is running; every channel-strip switch
(phantom, phase, pad, EQ in, compressor in, gate in, high-pass in, low-pass in, delay in, insert A/B in; phase also
per matrix input); and the meter feedbacks (signal present, peak hold) for inputs, busses and matrix outputs.
* Value (put live numbers in button text with a preset's local variable): input fader and pan; aux send level, On/Off
@@ -163,7 +179,7 @@ To add a gauge to your own button in Companion 5.1:
`input_N_name|fader|mute|pan`, `buss_N_name|mode|stereo|fader|mute`, `matrix_N_name|fader|mute`, `matrix_in_N_name`,
`cg_N_name|fader|mute` (only for entities that exist), `snapshot_index|name|cue|count`, `console_name`,
`session_name`, `connected`, `relay_clients`, `relay_client_ips` and `last_change`. Use them as
`session_name`, `connected`, `relay_clients`, `relay_client_ips`, `fades_running` (number of level fades running) and `last_change`. Use them as
`$(label:input_1_name)` with the connection's label. There are no per-send variables: use the value feedbacks.
## Presets
@@ -181,7 +197,7 @@ buttons.
| 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 (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 the matrix input's name and the level) and a **Level (knob)** (turn = send level, press = on/off, with a gauge) |
| Matrix sends to *output name* | One section per matrix output: per matrix input an **On/off** button (black when the send is on, red when it is off, like a mute; shows the matrix input's name and the level) and a **Level (knob)** (turn = send level, press = on/off, turns red when the send is off, with a level gauge along the bottom and a meter column on the right; the S31 has no meter for matrix inputs, so that column shows the destination matrix output's meter) |
| 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, each with a vertical gauge at the right edge (green below 70, amber 70-90, red above 90) |
+55 -14
View File
@@ -1,4 +1,4 @@
import { LEVEL, LIMITS, PARAMS } from '../protocol.js'
import { FADE_CURVES, LEVEL, LIMITS, PARAMS } from '../protocol.js'
export const STATE_CHOICES = [
{ id: 'on', label: 'On' },
@@ -41,6 +41,30 @@ export const opt = {
step: 0.5,
showMinAsNegativeInfinity: true,
}),
/** Fade time of a "set level" action; the curve choice shows only while it is above 0 (so `duration` cannot be an expression). */
duration: (id = 'duration') => ({
type: 'number',
id,
label: 'Fade time (ms, 0 = instant)',
default: 0,
min: 0,
max: 60000,
step: 100,
asInteger: true,
disableAutoExpression: true,
}),
curve: (id = 'curve') => ({
type: 'dropdown',
id,
label: 'Fade curve',
default: 'fader',
choices: [
{ id: 'fader', label: 'Fader travel (natural)' },
{ id: 'ease', label: 'Fader travel, ease in/out' },
{ id: 'db', label: 'Linear in dB' },
],
isVisibleExpression: '$(options:duration) > 0',
}),
delta: (id = 'delta', label = 'Change (dB, for encoders)') => ({ type: 'number', id, label, default: 1, min: -30, max: 30, step: 0.5 }),
pan: (id = 'pan', label = 'Pan (0 = left, 0.5 = centre, 1 = right)') => ({ type: 'number', id, label, default: 0.5, min: 0, max: 1, step: 0.01 }),
pdelta: (id = 'pdelta', label = 'Pan change (for encoders)') => ({ type: 'number', id, label, default: 0.05, min: -1, max: 1, step: 0.01 }),
@@ -50,6 +74,12 @@ export const opt = {
/** Said in every level adjust action: a relative step cannot start from -inf, so the first step up jumps to -60 dB. */
const ADJUST_NOTE = 'From −∞ the first step up jumps to −60 dB.'
/** Said in every level set action: the optional fade. */
const FADE_NOTE = 'With a fade time above 0 the level moves from where it is now to the target in steps of 50 ms; another set, adjust or fade on the same level ends it.'
/** [fade time in ms (0 = instant), curve] from a set-level action's options; missing options (buttons made before fades existed) mean instant. */
const fadeArgs = (options) => [Number(options?.duration) > 0 ? Math.min(60000, Math.round(Number(options.duration))) : 0, FADE_CURVES.includes(options?.curve) ? options.curve : 'fader']
export const numbers = (v) => (Array.isArray(v) ? v : v === undefined || v === '' ? [] : [v]).map(Number).filter(Number.isFinite)
/** Which entity kinds get actions / feedbacks for a parameter: 'input', 'buss', 'matrix' and/or 'minput' (matrix input). */
@@ -132,8 +162,9 @@ export function getActionDefinitions(inst) {
},
input_fader_set: {
name: 'Input: set fader',
options: [opt.channel(inst), opt.level()],
callback: async ({ options }) => inst.setFader('input', n(options.channel), n(options.level)),
description: FADE_NOTE,
options: [opt.channel(inst), opt.level(), opt.duration(), opt.curve()],
callback: async ({ options }) => inst.setFader('input', n(options.channel), n(options.level), ...fadeArgs(options)),
},
input_fader_adjust: {
name: 'Input: adjust fader (relative)',
@@ -164,8 +195,9 @@ export function getActionDefinitions(inst) {
},
send_level_set: {
name: 'Aux send: set level',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.level()],
callback: async ({ options }) => inst.setSendLevel(n(options.channel), n(options.buss), n(options.level)),
description: FADE_NOTE,
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.level(), opt.duration(), opt.curve()],
callback: async ({ options }) => inst.setSendLevel(n(options.channel), n(options.buss), n(options.level), ...fadeArgs(options)),
},
send_level_adjust: {
name: 'Aux send: adjust level (relative)',
@@ -201,8 +233,9 @@ export function getActionDefinitions(inst) {
},
buss_fader_set: {
name: 'Buss: set fader',
options: [opt.buss(inst), opt.level()],
callback: async ({ options }) => inst.setFader('buss', n(options.buss), n(options.level)),
description: FADE_NOTE,
options: [opt.buss(inst), opt.level(), opt.duration(), opt.curve()],
callback: async ({ options }) => inst.setFader('buss', n(options.buss), n(options.level), ...fadeArgs(options)),
},
buss_fader_adjust: {
name: 'Buss: adjust fader (relative)',
@@ -223,8 +256,9 @@ export function getActionDefinitions(inst) {
},
matrix_fader_set: {
name: 'Matrix output: set fader',
options: [opt.matrix(inst), opt.level()],
callback: async ({ options }) => inst.setFader('matrix', n(options.matrix), n(options.level)),
description: FADE_NOTE,
options: [opt.matrix(inst), opt.level(), opt.duration(), opt.curve()],
callback: async ({ options }) => inst.setFader('matrix', n(options.matrix), n(options.level), ...fadeArgs(options)),
},
matrix_fader_adjust: {
name: 'Matrix output: adjust fader (relative)',
@@ -239,9 +273,9 @@ export function getActionDefinitions(inst) {
},
matrix_send_level_set: {
name: 'Matrix send: set level',
description: 'A matrix send goes down to −90 dB (−∞).',
options: [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut'), opt.level('level', 'Level (dB)', LEVEL.matrixSendMin)],
callback: async ({ options }) => inst.setMatrixSendLevel(n(options.matrixIn), n(options.matrixOut), n(options.level)),
description: `A matrix send goes down to −90 dB (−∞). ${FADE_NOTE}`,
options: [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut'), opt.level('level', 'Level (dB)', LEVEL.matrixSendMin), opt.duration(), opt.curve()],
callback: async ({ options }) => inst.setMatrixSendLevel(n(options.matrixIn), n(options.matrixOut), n(options.level), ...fadeArgs(options)),
},
matrix_send_level_adjust: {
name: 'Matrix send: adjust level (relative)',
@@ -262,8 +296,9 @@ export function getActionDefinitions(inst) {
},
cg_fader_set: {
name: 'Control group: set fader',
options: [opt.cg(inst), opt.level()],
callback: async ({ options }) => inst.setFader('cg', n(options.cg), n(options.level)),
description: FADE_NOTE,
options: [opt.cg(inst), opt.level(), opt.duration(), opt.curve()],
callback: async ({ options }) => inst.setFader('cg', n(options.cg), n(options.level), ...fadeArgs(options)),
},
cg_fader_adjust: {
name: 'Control group: adjust fader (relative)',
@@ -305,6 +340,12 @@ export function getActionDefinitions(inst) {
},
snapshot_next: { name: 'Snapshot: next', options: [], callback: async () => inst.fireNextSnapshot() },
snapshot_prev: { name: 'Snapshot: previous', options: [], callback: async () => inst.firePrevSnapshot() },
fade_stop: {
name: 'Stop all fades',
description: 'Ends every running level fade where it is.',
options: [],
callback: async () => inst.stopFades(),
},
reload: {
name: 'Console: reload names and levels',
options: [],
+4 -1
View File
@@ -41,6 +41,7 @@ export const watchKey = {
snapshot: 'snapshot',
connected: 'connected',
relay: 'relay',
fades: 'fades',
}
export function getFeedbackDefinitions(inst) {
@@ -207,6 +208,7 @@ export function getFeedbackDefinitions(inst) {
matrix_solo: bool('Matrix output: solo on', AMBER, [opt.matrix(inst)], entityKey('matrix', 'matrix'), ({ options }) => s.getSolo('matrix', n(options.matrix)) === true),
cg_solo: bool('Control group: solo on', AMBER, [opt.cg(inst)], entityKey('cg', 'cg'), ({ options }) => s.getSolo('cg', n(options.cg)) === true),
matrix_send_on: bool('Matrix send: on', GREEN, msendOptions(), msendKey, ({ options }) => msendOf(options).on === true),
matrix_send_off: bool('Matrix send: is off (shows like a mute)', RED, msendOptions(), msendKey, ({ options }) => msendOf(options).on === false),
geq_on: bool('Graphic EQ: in', GREEN, [opt.geq(inst)], geqOnKey, ({ options }) => {
inst.ensureGeqOn(n(options.geq))
return s.getGeq(n(options.geq)).on === true
@@ -246,6 +248,7 @@ export function getFeedbackDefinitions(inst) {
}),
connected: bool('Console link is alive', GREEN, [], () => watchKey.connected, () => inst.connectedFlag === true),
relay_clients: bool('Relay has at least one iPad', GREEN, [], () => watchKey.relay, () => (inst.relay?.clientCount || 0) > 0),
fade_running: bool('A fade is running', AMBER, [], () => watchKey.fades, () => inst.fades.size > 0),
input_signal: bool(
'Input meter: signal present (level above a dBFS threshold; gain reduction meters: any reduction)',
@@ -409,7 +412,7 @@ export function feedbackIdsForEvent(ev) {
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_pos', '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', 'matrix_send_off']
case 'geq':
return ev.field === 'on' ? ['geq_on'] : ['geq_band_value', 'geq_band_db', 'geq_band_bar']
case 'group':
+51 -19
View File
@@ -54,24 +54,34 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
const text = (id, x, y, width, height, value) => ({ type: 'text', id, x, y, width, height, text: value, fontsize: 100, fontsizeAllowShrink: true, color: TEXT, halign: 'center', valign: 'center' })
/** Boolean feedback on a layered preset: it colours the background box. */
const tint = (feedbackId, options, color) => ({ feedbackId, options, styleOverrides: [{ elementId: 'bg', elementProperty: 'color', override: color }] })
/** Green / amber / red zones for a meter gauge on the -60..0 dBFS scale. */
const meterStops = () => [
{ value: 0, color: GAUGE_GREEN, gradient: false },
{ value: 70, color: METER_AMBER, gradient: false },
{ value: 90, color: METER_RED, gradient: false },
]
/** Width (%) of the optional meter column at the right edge of a knob preset. */
const KNOB_METER_WIDTH = 13
/** Track brightness (%) behind multi-colour meter gauges: faint, so an empty meter reads as empty. */
const METER_TRACK_AMOUNT = 20
/**
* Layered rotary preset with a native Companion gauge along the bottom: black background, the entity name on top, the
* dB text below it, and a gauge fed by the `pos` local variable. `level` and `pos` are the text and the numeric value
* feedbacks for the same thing.
* feedbacks for the same thing. With `meter: [feedbackId, options]` a vertical meter column (fed by the `meter` local
* variable, a meter position feedback) runs down the right edge and the rest of the button narrows to make room.
*/
const layeredKnob = ({ name, title, press, adjust: [adjustActionId, adjustOptions], level: [levelId, levelOptions], pos: [posId, posOptions], feedbacks }) => ({
type: 'layered',
name,
elements: [
const layeredKnob = ({ name, title, press, adjust: [adjustActionId, adjustOptions], level: [levelId, levelOptions], pos: [posId, posOptions], meter, feedbacks }) => {
const w = meter ? 100 - KNOB_METER_WIDTH : 100
const elements = [
{ type: 'box', id: 'bg', x: 0, y: 0, width: 100, height: 100, color: BLACK },
text('title', 0, 0, 100, 55, title),
text('level', 0, 52, 100, 32, '$(local:level) dB'),
text('title', 0, 0, w, 55, title),
text('level', 0, 52, w, 32, '$(local:level) dB'),
{
type: 'gauge',
id: 'gauge',
x: 0,
y: 84,
width: 100,
width: w,
height: 16,
value: { isExpression: true, value: '$(local:pos)' },
min: 0,
@@ -82,11 +92,30 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
multiColour: false,
stops: [{ value: 0, color: GAUGE_LEVEL, gradient: false }],
},
],
steps: knobSteps(press, adjustActionId, adjustOptions),
feedbacks,
localVariables: [localFeedback('level', levelId, levelOptions), localFeedback('pos', posId, posOptions)],
]
const localVariables = [localFeedback('level', levelId, levelOptions), localFeedback('pos', posId, posOptions)]
if (meter) {
elements.push({
type: 'gauge',
id: 'meter',
x: w + 1,
y: 0,
width: KNOB_METER_WIDTH - 1,
height: 100,
value: { isExpression: true, value: '$(local:meter)' },
min: 0,
max: 100,
orientation: 'vertical',
trackStyle: 'dimmed',
fillEnabled: true,
multiColour: true,
trackAmount: METER_TRACK_AMOUNT,
stops: meterStops(),
})
localVariables.push(localFeedback('meter', meter[0], meter[1]))
}
return { type: 'layered', name, elements, steps: knobSteps(press, adjustActionId, adjustOptions), feedbacks, localVariables }
}
/**
* Layered meter: name on the left, a vertical gauge (green / amber / red zones) at the right edge; the position comes
* from `posFeedback`. A signal feedback tints the background dark green and, for level meters, a peak feedback red.
@@ -111,11 +140,8 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
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 },
],
trackAmount: METER_TRACK_AMOUNT,
stops: meterStops(),
},
],
steps: [{ down: [], up: [] }],
@@ -306,7 +332,11 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
const text = `${nameVar('minput', inN)}\\n$(local:level) dB`
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 })
// A send that is off looks like a mute (red); an enabled send keeps the black background.
presets[`msend_on_${inN}_${out}`] = simple(`Matrix send on/off: ${tag}`, text, [act('matrix_send_on', { ...options, state: 'toggle' })], [fb('matrix_send_off', options, MUTED_RED)], {
style: { text, size: 'auto', color: WHITE, bgcolor: BLACK },
localVariables: lv,
})
presets[`msend_knob_${inN}_${out}`] = layeredKnob({
name: `Matrix send level (knob): ${tag}`,
title: nameVar('minput', inN),
@@ -314,7 +344,9 @@ function build(inst, { grMeters = true, sendKnobs: withSendKnobs = true } = {})
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)],
// The console has no meter for matrix inputs (no address answers), so the column shows the destination output.
meter: ['matrix_meter_pos', { matrix: out }],
feedbacks: [tint('matrix_send_off', options, MUTED_RED)],
})
onIds.push(`msend_on_${inN}_${out}`)
knobIds.push(`msend_knob_${inN}_${out}`)
+2 -1
View File
@@ -30,6 +30,7 @@ export function getVariableDefinitions(inst) {
connected: { name: 'Console link alive (Yes/No)' },
relay_clients: { name: 'iPad apps connected through the relay' },
relay_client_ips: { name: 'iPad client IPs' },
fades_running: { name: 'Number of level fades running' },
last_change: { name: 'Last change reported by the console' },
snapshot_index: { name: 'Current snapshot index' },
snapshot_name: { name: 'Current snapshot name' },
@@ -75,7 +76,7 @@ export function snapshotVariableValues(inst) {
/** Everything, after the definitions were rebuilt. */
export function getAllVariableValues(inst) {
const s = inst.state
const out = { console_name: s.console.name, session_name: s.console.session, connected: inst.connectedFlag ? 'Yes' : 'No', ...snapshotVariableValues(inst) }
const out = { console_name: s.console.name, session_name: s.console.session, connected: inst.connectedFlag ? 'Yes' : 'No', fades_running: inst.fades?.size ?? 0, ...snapshotVariableValues(inst) }
for (const ent of Object.keys(ENTITY_VARS)) for (const n of s.numbers(ent)) Object.assign(out, entityVariableValues(inst, ent, n))
return out
}
+118 -8
View File
@@ -3,7 +3,7 @@ import { ConsoleLink } from './console-link.js'
import { Relay } from './relay.js'
import { decode, describe, foldPattern } from './osc.js'
import { State } from './state.js'
import { GEQ_RANGE, LEVEL, PARAMS, K, addr, adjustedLevel, clamp, indexProblem, meterId, meterSubscription, outputMeterId, outputMeterSubscription, parse, query, sameValue } from './protocol.js'
import { FADE_CURVES, GEQ_RANGE, LEVEL, PARAMS, K, addr, adjustedLevel, clamp, fadeLevelAt, indexProblem, meterId, meterSubscription, outputMeterId, outputMeterSubscription, parse, query, sameValue } from './protocol.js'
import { getActionDefinitions } from './definitions/actions.js'
import { getFeedbackDefinitions, feedbackIdsForEvent, watchKey, watchKeyForEvent } from './definitions/feedbacks.js'
import { getVariableDefinitions, getAllVariableValues, getEventVariableValues, describeChange } from './definitions/variables.js'
@@ -11,6 +11,11 @@ import { getPresetDefinitions } from './definitions/presets.js'
const LEARN_CAP = 400
/** A fade writes a new level this often (20 steps per second at most) and skips steps that would change the level by less than FADE_MIN_DB. */
const FADE_STEP_MS = 50
const FADE_MIN_DB = 0.05
const FADE_MAX_MS = 60000
/** Timings in ms. An instance copies these to `this.timing` so tests can shorten them. */
const TIMING = {
keepaliveMs: 5000, // /Console/Name/? ping
@@ -196,6 +201,9 @@ export default class DigicoS31Instance extends InstanceBase {
this.requested = new Map() // lazily loaded pairs -> time requested, de-duplicated for a while
this.waiters = new Map() // event key -> Set<fn>
this.pendingWrites = new Map() // address -> { value, until, timer }
this.fades = new Map() // address -> the running fade (one per address)
this.fadeStarts = new Map() // address -> token of a fade still waiting for the level it starts from
this.fadeSeq = 0
this.errorLog = new Map() // error code + destination -> time last logged
this.connectedFlag = false
this.statusName = 'connecting'
@@ -532,6 +540,11 @@ export default class DigicoS31Instance extends InstanceBase {
for (const t of this.timers) clearTimeout(t)
this.timers.clear()
this.pendingWrites.clear()
this.fadeStarts.clear()
if (this.fades.size) {
this.fades.clear() // their step timers were cleared with the others above
this.fadesChanged()
}
if (this.rebuildTimer) clearTimeout(this.rebuildTimer)
this.rebuildTimer = null
this.resubscribeTimer = null
@@ -787,9 +800,10 @@ export default class DigicoS31Instance extends InstanceBase {
return this.write(address, [on ? 1 : 0], on ? 1 : 0)
}
/** Write a dB level, clamped to [lo, +10]; `lo` is -150 except for matrix sends (-90). */
/** Write a dB level, clamped to [lo, +10]; `lo` is -150 except for matrix sends (-90). A running fade on the address ends. */
writeLevel(address, dB, lo = LEVEL.min) {
if (!this.addressOk(address)) return false
this.cancelFade(address)
const v = clamp(Number(dB), lo, LEVEL.max)
if (!Number.isFinite(v)) return false
return this.write(address, [v], v)
@@ -797,6 +811,7 @@ export default class DigicoS31Instance extends InstanceBase {
async adjustLevel(address, key, get, delta, lo = LEVEL.min) {
if (!this.addressOk(address)) return false
this.cancelFade(address)
const cur = await this.current(key, get, address)
if (cur === undefined) {
this.log('warn', `${address}: current level unknown (no reply from the console), not adjusted`)
@@ -805,6 +820,93 @@ export default class DigicoS31Instance extends InstanceBase {
return this.writeLevel(address, adjustedLevel(cur, delta, lo), lo)
}
// ---------- fades ----------
/**
* Move a level from where it is now to `targetDb` over `durationMs`: a new level every FADE_STEP_MS (20 Hz), each
* a normal write(), the last one exactly the target. Curves are those of `fadeLevelAt`. There is one fade per
* address: a new fade, a plain set or an adjust on the same address ends the running one, and closing the console
* link ends them all. When the current level cannot be read, the target is written at once.
*/
async fadeLevel(address, key, get, targetDb, durationMs, curve = 'fader', lo = LEVEL.min) {
if (!this.addressOk(address)) return false
const target = clamp(Number(targetDb), lo, LEVEL.max)
if (!Number.isFinite(target)) return false
const ms = Math.min(Number(durationMs), FADE_MAX_MS)
if (!(ms > 0)) return this.writeLevel(address, target, lo)
this.cancelFade(address)
const token = ++this.fadeSeq
this.fadeStarts.set(address, token)
const from = await this.current(key, get, address)
if (this.fadeStarts.get(address) !== token) return false // another action took the address, fades were stopped or the link was closed meanwhile
this.fadeStarts.delete(address)
if (from === undefined || !Number.isFinite(from)) {
this.log('warn', `${address}: current level unknown (no reply from the console), set without a fade`)
return this.write(address, [target], target)
}
if (!this.link) return false
const steps = Math.max(1, Math.ceil(ms / FADE_STEP_MS))
const fade = { address, from, target, curve: FADE_CURVES.includes(curve) ? curve : 'fader', lo, steps, interval: ms / steps, k: 0, last: from, startedAt: Date.now(), timer: null }
this.fades.set(address, fade)
this.fadesChanged()
this.scheduleFadeStep(fade)
return true
}
scheduleFadeStep(fade) {
const due = fade.startedAt + Math.round((fade.k + 1) * fade.interval)
fade.timer = this.later(() => this.fadeStep(fade), Math.max(0, due - Date.now()))
}
fadeStep(fade) {
if (this.fades.get(fade.address) !== fade) return
fade.k++
const final = fade.k >= fade.steps
const db = fadeLevelAt(fade.curve, fade.from, fade.target, fade.k / fade.steps, fade.lo)
if (final ? db !== fade.last : Math.abs(db - fade.last) >= FADE_MIN_DB) {
if (!this.write(fade.address, [db], db)) {
this.cancelFade(fade.address)
return
}
fade.last = db
}
if (final) {
this.fades.delete(fade.address)
this.fadesChanged()
} else {
this.scheduleFadeStep(fade)
}
}
/** End the fade running on `address` (or waiting for its starting level) where the level is now. */
cancelFade(address) {
this.fadeStarts.delete(address)
const fade = this.fades.get(address)
if (!fade) return false
clearTimeout(fade.timer)
this.timers.delete(fade.timer)
this.fades.delete(address)
this.fadesChanged()
return true
}
/** End every fade; each level stays where it is. */
stopFades() {
this.fadeStarts.clear()
if (!this.fades.size) return
for (const fade of this.fades.values()) {
clearTimeout(fade.timer)
this.timers.delete(fade.timer)
}
this.fades.clear()
this.fadesChanged()
}
fadesChanged() {
this.queueVariables({ fades_running: this.fades.size })
this.touch(watchKey.fades, ['fade_running'])
}
writePan(address, pan) {
if (!this.addressOk(address)) return false
const v = clamp(Number(pan), 0, 1)
@@ -832,8 +934,12 @@ export default class DigicoS31Instance extends InstanceBase {
return this.writeFlag(addr[field](ent, n, side), K.value(ent, n, field, side), get, mode)
}
setFader(ent, n, dB) {
return this.writeLevel(addr.fader(ent, n, this.sideOf(ent, n)), dB)
/** Set a fader; with `durationMs` > 0 it moves there over that time (see fadeLevel). */
setFader(ent, n, dB, durationMs = 0, curve = 'fader') {
const side = this.sideOf(ent, n)
const address = addr.fader(ent, n, side)
if (!(durationMs > 0)) return this.writeLevel(address, dB)
return this.fadeLevel(address, K.value(ent, n, 'fader', side), () => this.state.getFader(ent, n), dB, durationMs, curve)
}
adjustFader(ent, n, delta) {
@@ -862,8 +968,10 @@ export default class DigicoS31Instance extends InstanceBase {
return this.writeFlag(addr.sendOn(ch, buss), K.send(ch, buss, 'on'), () => this.state.getSend(ch, buss).on, mode)
}
setSendLevel(ch, buss, dB) {
return this.writeLevel(addr.sendLevel(ch, buss), dB)
setSendLevel(ch, buss, dB, durationMs = 0, curve = 'fader') {
const address = addr.sendLevel(ch, buss)
if (!(durationMs > 0)) return this.writeLevel(address, dB)
return this.fadeLevel(address, K.send(ch, buss, 'level'), () => this.state.getSend(ch, buss).level, dB, durationMs, curve)
}
adjustSendLevel(ch, buss, delta) {
@@ -886,8 +994,10 @@ export default class DigicoS31Instance extends InstanceBase {
return this.writeFlag(addr.matrixSendOn(inN, outN), K.msend(inN, outN, 'on'), () => this.state.getMatrixSend(inN, outN).on, mode)
}
setMatrixSendLevel(inN, outN, dB) {
return this.writeLevel(addr.matrixSendLevel(inN, outN), dB, LEVEL.matrixSendMin)
setMatrixSendLevel(inN, outN, dB, durationMs = 0, curve = 'fader') {
const address = addr.matrixSendLevel(inN, outN)
if (!(durationMs > 0)) return this.writeLevel(address, dB, LEVEL.matrixSendMin)
return this.fadeLevel(address, K.msend(inN, outN, 'level'), () => this.state.getMatrixSend(inN, outN).level, dB, durationMs, curve, LEVEL.matrixSendMin)
}
adjustMatrixSendLevel(inN, outN, delta) {
+48
View File
@@ -349,6 +349,54 @@ export function faderPosition(dB, floor = LEVEL.floor) {
return 1 + ((dB - floor) / (prev[0] - floor)) * (prev[1] - 1)
}
/**
* The exact inverse of `faderPosition` (same anchor table, same `floor`): position 0..100 -> dB. Positions up to 1 (the
* "off" end, which `faderPosition` gives only to levels at or below `floor`) read as `floor`; 100 or more is +10 dB.
* Non-numbers give `floor`.
*/
export function positionToDb(pos, floor = LEVEL.floor) {
if (typeof pos !== 'number' || !Number.isFinite(pos) || pos <= 1) return floor
if (pos >= FADER_TAPER[0][1]) return FADER_TAPER[0][0]
let prev = FADER_TAPER[0]
for (const point of FADER_TAPER.slice(1)) {
if (pos >= point[1]) return point[0] + ((pos - point[1]) / (prev[1] - point[1])) * (prev[0] - point[0])
prev = point
}
return floor + ((pos - 1) / (prev[1] - 1)) * (prev[0] - floor)
}
/** Fade curves of the "set level" actions (see `fadeLevelAt`). */
export const FADE_CURVES = ['fader', 'ease', 'db']
/**
* The dB value a fade has reached at progress `t` (0..1, by time) when it runs from `from` to `to` dB. Curves:
* 'fader' moves in fader position (the taper of a physical fader, so it sounds even), 'ease' is the same with a
* smoothstep on the time (slow start and end), 'db' moves linearly in dB. The result stays within [lo, +10]; t >= 1 is
* exactly the (clamped) target. `floor` is the level the taper treats as off.
*/
export function fadeLevelAt(curve, from, to, t, lo = LEVEL.min, floor = LEVEL.floor) {
const a = clamp(from, lo, LEVEL.max)
const b = clamp(to, lo, LEVEL.max)
if (!(t < 1)) return b
const u = t > 0 ? t : 0
if (curve === 'db') {
// Everything below the floor (-90) is inaudible, so a dB line from -inf starts at the floor and one to -inf
// ends there (the last step, t = 1 above, still writes the exact target). A fade that stays below the floor
// simply runs between its two ends.
if (a <= floor && b <= floor) return clamp(a + (b - a) * u, lo, LEVEL.max)
const a2 = Math.max(a, floor)
const b2 = Math.max(b, floor)
return clamp(a2 + (b2 - a2) * u, lo, LEVEL.max)
}
const e = curve === 'ease' ? u * u * (3 - 2 * u) : u
// Below the floor the fader taper is flat (all of it reads as -inf), so a fade that stays down there runs in dB.
if (a <= floor && b <= floor) return clamp(a + (b - a) * e, lo, LEVEL.max)
const p0 = faderPosition(a, floor)
const p1 = faderPosition(b, floor)
// Never step outside the start..target range (the taper's floor step could otherwise land above a target below -90).
return clamp(positionToDb(p0 + (p1 - p0) * e, floor), Math.max(lo, Math.min(a, b)), Math.min(LEVEL.max, Math.max(a, b)))
}
/** 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 ''
+334 -15
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, cleanName, faderPosition, fmtCue, fmtDb, fmtMeterLevel, fmtPan, indexProblem, meterGrDb, meterId, meterIdForInput, meterLevelDb, meterPosition, outputMeterId, parse } from '../src/protocol.js'
import { FADE_CURVES, LEVEL, PARAMS, adjustedLevel, cleanName, fadeLevelAt, faderPosition, fmtCue, fmtDb, fmtMeterLevel, fmtPan, indexProblem, meterGrDb, meterId, meterIdForInput, meterLevelDb, meterPosition, outputMeterId, parse, positionToDb } 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'
@@ -44,7 +44,7 @@ const near = (a, b, eps = 1e-3) => typeof a === 'number' && Math.abs(a - b) < ep
const sent = []
const originalSend = ConsoleLink.prototype.send
ConsoleLink.prototype.send = function (address, args = []) {
if (!this.noRecord) sent.push({ address, args })
if (!this.noRecord) sent.push({ address, args, at: Date.now() })
return originalSend.call(this, address, args)
}
const logs = []
@@ -430,17 +430,17 @@ check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs &&
const adjusts = Object.entries(ctx.actions).filter(([id]) => /(fader|send_level)_adjust$/.test(id))
check(adjusts.length === 6 && adjusts.every(([, a]) => a.description.includes('From \u2212\u221e the first step up jumps to \u221260 dB')), `all ${adjusts.length} level adjust actions say that the first step up from -inf jumps to -60 dB`)
check(ctx.actions.input_mute.options[0].choices[0].label === '1 · KICK In' && ctx.actions.send_on.options[1].choices.every((c) => S.bussMode(c.id) === 'aux'), 'action dropdowns show the live labels; send actions list aux busses only')
const idsExpected = ['input_mute', 'input_solo', 'input_fader_set', 'input_fader_adjust', 'input_pan_set', 'input_pan_adjust', 'input_name_set', 'send_on', 'send_level_set', 'send_level_adjust', 'send_pan_set', 'send_pan_adjust', 'group_assign', 'buss_mute', 'buss_solo', 'buss_fader_set', 'buss_fader_adjust', 'matrix_mute', 'matrix_solo', 'matrix_fader_set', 'matrix_fader_adjust', 'matrix_send_on', 'matrix_send_level_set', 'matrix_send_level_adjust', 'cg_mute', 'cg_solo', 'cg_fader_set', 'cg_fader_adjust', 'geq_band_set', 'geq_band_adjust', 'geq_on', 'snapshot_fire', 'snapshot_next', 'snapshot_prev', 'reload', 'raw_osc', 'relay_forget']
const idsExpected = ['input_mute', 'input_solo', 'input_fader_set', 'input_fader_adjust', 'input_pan_set', 'input_pan_adjust', 'input_name_set', 'send_on', 'send_level_set', 'send_level_adjust', 'send_pan_set', 'send_pan_adjust', 'group_assign', 'buss_mute', 'buss_solo', 'buss_fader_set', 'buss_fader_adjust', 'matrix_mute', 'matrix_solo', 'matrix_fader_set', 'matrix_fader_adjust', 'matrix_send_on', 'matrix_send_level_set', 'matrix_send_level_adjust', 'cg_mute', 'cg_solo', 'cg_fader_set', 'cg_fader_adjust', 'geq_band_set', 'geq_band_adjust', 'geq_on', 'snapshot_fire', 'snapshot_next', 'snapshot_prev', 'reload', 'raw_osc', 'relay_forget', 'fade_stop']
// per-channel switches and values, generated from the PARAMS table
for (const [param, p] of Object.entries(PARAMS)) {
for (const ent of Object.keys(p.paths)) idsExpected.push(...(p.type === 'switch' ? [`${ent}_${param}`] : [`${ent}_${param}_set`, `${ent}_${param}_adjust`]))
}
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']
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', 'fade_running', '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('buss_signal', 'buss_peak', 'buss_meter_value', 'buss_meter_pos', 'matrix_signal', 'matrix_peak', 'matrix_meter_value', 'matrix_meter_pos')
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')
fbExpected.push('matrix_solo', 'cg_solo', 'matrix_send_on', 'matrix_send_off', '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)`)
// Feedback parity: every action that changes readable state has a feedback for that state
@@ -451,11 +451,11 @@ check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs &&
send_pan_set: ['send_pan_value'], send_pan_adjust: ['send_pan_value'], group_assign: ['group_assigned'],
buss_mute: ['buss_muted'], buss_solo: ['buss_solo'], buss_fader_set: ['buss_fader_value', 'buss_fader_bar'], buss_fader_adjust: ['buss_fader_value', 'buss_fader_bar'],
matrix_mute: ['matrix_muted'], matrix_solo: ['matrix_solo'], matrix_fader_set: ['matrix_fader_value', 'matrix_fader_bar'], matrix_fader_adjust: ['matrix_fader_value', 'matrix_fader_bar'],
matrix_send_on: ['matrix_send_on'], matrix_send_level_set: ['matrix_send_level_value', 'matrix_send_level_bar'], matrix_send_level_adjust: ['matrix_send_level_value', 'matrix_send_level_bar'],
matrix_send_on: ['matrix_send_on', 'matrix_send_off'], matrix_send_level_set: ['matrix_send_level_value', 'matrix_send_level_bar'], matrix_send_level_adjust: ['matrix_send_level_value', 'matrix_send_level_bar'],
cg_mute: ['cg_muted'], cg_solo: ['cg_solo'], cg_fader_set: ['cg_fader_value', 'cg_fader_bar'], cg_fader_adjust: ['cg_fader_value', 'cg_fader_bar'],
geq_on: ['geq_on'], geq_band_set: ['geq_band_value', 'geq_band_bar'], geq_band_adjust: ['geq_band_value', 'geq_band_bar'],
snapshot_fire: ['snapshot_current', 'snapshot_name_value'], snapshot_next: ['snapshot_current', 'snapshot_name_value'], snapshot_prev: ['snapshot_current', 'snapshot_name_value'],
reload: ['connected'], relay_forget: ['relay_clients'],
reload: ['connected'], relay_forget: ['relay_clients'], fade_stop: ['fade_running'],
input_name_set: [], // the name is the variable input_N_name
raw_osc: [], // free-form
}
@@ -537,8 +537,17 @@ check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs &&
check(msendSections.length === 8 && msendSections[7].name === `Matrix sends to ${S.displayName('matrix', 8)}` && msendOn.length === 80 && msendKnob.length === 80, `a "Matrix sends to <output>" section per matrix output (${msendSections.length}: "${msendSections[7]?.name}") with ${msendOn.length} on/off and ${msendKnob.length} knob presets`)
const mOn = P.msend_on_1_8
const mKnob = P.msend_knob_1_8
check(mOn.style.text === '$(S31:matrix_in_1_name)\\n$(local:level) dB' && mOn.name === 'Matrix send on/off: TB → 8 · Studio' && 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.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(mOn.style.text === '$(S31:matrix_in_1_name)\\n$(local:level) dB' && mOn.name === 'Matrix send on/off: TB → 8 · Studio' && 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.style.bgcolor === 0x000000 && mOn.feedbacks.length === 1 && mOn.feedbacks[0].feedbackId === 'matrix_send_off' && mOn.feedbacks[0].style.bgcolor === 0xb01010 && mOn.feedbacks[0].style.color === 0xffffff && JSON.stringify(mOn.feedbacks[0].options) === JSON.stringify({ matrixIn: 1, matrixOut: 8 }), 'matrix send on/off preset: toggle matrix_send_on, black when the send is on, red with white text when it is off (like a mute), text from the level value feedback')
check(!JSON.stringify(mOn.feedbacks).includes('matrix_send_on') && !JSON.stringify(P.msend_knob_1_8.feedbacks).includes('matrix_send_on') && !ids.filter((id) => id.startsWith('msend_')).some((id) => P[id].feedbacks.some((f) => f.style?.bgcolor === COLORS_GREEN || f.styleOverrides?.some((o) => o.override === 0x00a050))), 'no matrix send preset carries a green (send on) feedback any more')
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.length === 1 && mKnob.feedbacks[0].feedbackId === 'matrix_send_off' && mKnob.feedbacks[0].styleOverrides[0].elementId === 'bg' && mKnob.feedbacks[0].styleOverrides[0].elementProperty === 'color' && mKnob.feedbacks[0].styleOverrides[0].override === 0xb01010 && mKnob.localVariables.map((v) => `${v.variableName}=${v.feedbackId}`).join() === 'level=matrix_send_level_value,pos=matrix_send_level_pos,meter=matrix_meter_pos', 'matrix send knob preset (layered): rotate = matrix_send_level_adjust -1/+1, press toggles on/off, off tints the background red, level, gauge position and meter local variables')
{
const meter = mKnob.elements.find((e) => e.id === 'meter')
const title = mKnob.elements.find((e) => e.id === 'title')
const mv = mKnob.localVariables.find((v) => v.variableName === 'meter')
check(meter && meter.type === 'gauge' && meter.orientation === 'vertical' && meter.x + meter.width <= 100 && meter.x >= title.width && meter.height === 100 && meter.multiColour === true && meter.stops.length === 3 && meter.value.value === '$(local:meter)' && JSON.stringify(mv.options) === JSON.stringify({ matrix: 8 }), `matrix send knob has a vertical meter column on the right fed by the destination output's meter (x ${meter?.x}, width ${meter?.width}, title width ${title?.width})`)
const iKnob = P.input_knob_3 || Object.values(P).find((p) => p.type === 'layered' && p.localVariables?.some((v) => v.feedbackId === 'input_fader_pos'))
check(iKnob && !iKnob.elements.some((e) => e.id === 'meter') && iKnob.elements.find((e) => e.id === 'title').width === 100, 'other knob presets keep the full-width layout without a meter column')
}
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')
@@ -546,7 +555,7 @@ check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs &&
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.filter((id) => /^(buss|matrix)_meter_/.test(id)).length === 32 && layered.length === knobsLayered.length + 180 + 32 && !ids.some((id) => /_(mute|solo|on)_/.test(id) && P[id].type === 'layered'), `every knob (${knobsLayered.length}) and meter (${meterPresets.length} input + 32 buss / matrix output) 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 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', 'trackAmount'] }
const isExpr = (v) => !!v && typeof v === 'object' && v.isExpression === true && typeof v.value === 'string'
const lp = []
for (const id of layered) {
@@ -563,7 +572,7 @@ check('input_60_pan' in ctx.variableDefs && 'cg_10_mute' in ctx.variableDefs &&
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`)
if (!isExpr(e.value) || e.value.value !== `$(local:${e.id === 'meter' ? 'meter' : '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 || []) {
@@ -898,6 +907,17 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb
check(cb('matrix_send_level_value', { matrixIn: 5, matrixOut: 5 }) === '--' && cb('matrix_send_on', { matrixIn: 5, matrixOut: 5 }) === false, 'an unloaded matrix send reads "--" / false and is queued for loading')
check(await waitFor(() => sent.some((m) => m.address === '/Matrix_Inputs/5/Matrix_Send/5/send_level/?') && S.getMatrixSend(5, 5).level !== undefined, 2500), 'the unloaded matrix send is queried once and its state arrives')
check(cb('matrix_send_level_value', { matrixIn: 5, matrixOut: 5 }) === '-inf' && cb('matrix_send_on', { matrixIn: 5, matrixOut: 5 }) === false, 'a matrix send at its -90 floor reads "-inf"')
// matrix_send_off: true only when the console says the send is off (like a mute); unknown is not red, and starts the lazy load
check(cb('matrix_send_on', { matrixIn: 2, matrixOut: 3 }) === true && cb('matrix_send_off', { matrixIn: 2, matrixOut: 3 }) === false, 'matrix_send_off is false for a send that is on')
check(cb('matrix_send_off', { matrixIn: 6, matrixOut: 6 }) === false && cb('matrix_send_on', { matrixIn: 6, matrixOut: 6 }) === false, 'matrix_send_off is false for an unknown send (not red until the console says off) and the send is queued for loading')
check(await waitFor(() => sent.some((m) => m.address === '/Matrix_Inputs/6/Matrix_Send/6/send_on/?') && S.getMatrixSend(6, 6).on !== undefined, 2500), 'the unknown send is queried lazily and its on/off arrives')
{
const sendOn = (v) => inst.onConsoleMessage(decode(encode('/Matrix_Inputs/6/Matrix_Send/6/send_on', [v])), { address: '127.0.0.1', port: P.mock })
sendOn(0.0)
check(S.getMatrixSend(6, 6).on === false && cb('matrix_send_off', { matrixIn: 6, matrixOut: 6 }) === true && cb('matrix_send_on', { matrixIn: 6, matrixOut: 6 }) === false, 'matrix_send_off is true for a send the console reports off')
sendOn(1.0)
check(cb('matrix_send_off', { matrixIn: 6, matrixOut: 6 }) === false && cb('matrix_send_on', { matrixIn: 6, matrixOut: 6 }) === true, 'and false again when it is switched on')
}
check(cb('matrix_solo', { matrix: 2 }) === false && cb('cg_solo', { cg: 10 }) === true && cb('cg_solo', { cg: 1 }) === false, 'matrix_solo / cg_solo follow the state')
inst.onConsoleMessage(decode(encode('/Matrix_Outputs/2/solo', [1.0])), { address: '127.0.0.1', port: P.mock })
check(cb('matrix_solo', { matrix: 2 }) === true, 'matrix_solo goes true when the console reports solo')
@@ -1099,7 +1119,11 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb
await sleep(200)
check(ipadRx.some((m) => m.address === '/Input_Channels/21/fader'), 'a change made by the module reaches the iPad too')
// /Meters/clear from the iPad: forwarded verbatim, then the module resubscribes
// /Meters/clear from the iPad: forwarded verbatim, then the module resubscribes. The silent input 7 reports its 127 once per
// subscription, so to see the subscription come back its state is first moved off 127 (a timestamp would race the mock's tick).
const injectMeter = (id, value) => inst.onConsoleMessage(decode(encode('/Meters/values', [{ i: id }, { i: value }])), { address: '127.0.0.1', port: P.mock })
injectMeter(meterIdForInput(7), 42)
check(S.getMeter(meterIdForInput(7)).value === 42, 'input 7 meter state is parked at 42 before the clear')
const mark = sent.length
const t0 = Date.now()
ipad.send(encode('/Meters/clear', []), P.relay, '127.0.0.1')
@@ -1111,7 +1135,38 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb
await sleep(200)
const reOut = sent.slice(mark).filter((m) => /\/Output\/meter$/.test(m.address)).map((m) => `${m.address} ${decodeArgs(m).args[0]}`)
check(['/Aux_Outputs/3/Output/meter 5403', '/Group_Outputs/9/Output/meter 5409', '/Matrix_Outputs/2/Output/meter 5452', '/Aux_Outputs/1/Output/meter 5401'].every((x) => reOut.includes(x)), `the output meters are re-subscribed too after /Meters/clear (${reOut.length} output subscriptions re-sent)`)
check(await waitFor(() => (S.getMeter(meterIdForInput(7))?.at ?? 0) > t0 + 250, 3000), 'meter values flow again after the resubscribe')
check(await waitFor(() => S.getMeter(meterIdForInput(7))?.value === 127, 3000), 'the silent meter reports its 127 again after the resubscribe: the console really has the subscription back')
check(await waitFor(() => (S.getMeter(meterIdForInput(2))?.at ?? 0) > t0, 3000), 'a live meter streams again after the resubscribe (it had stopped at the first /Meters/clear)')
// back-to-back clears: every clear pushes the resubscribe back, and the newest one always gets a resubscribe after it
{
const id7 = meterIdForInput(7)
const subsSince = (m) => sent.slice(m).filter((x) => x.address === '/Input_Channels/7/Channel_Input/post_meter').length
injectMeter(id7, 42)
ipad.send(encode('/Meters/clear', []), P.relay, '127.0.0.1') // clear 1
await sleep(110)
const m2 = sent.length
ipad.send(encode('/Meters/clear', []), P.relay, '127.0.0.1') // clear 2, while the resubscribe for clear 1 is still waiting
await sleep(150)
check(subsSince(m2) === 0, 'a second clear while the resubscribe is pending moves it back: nothing was re-sent yet, so nothing can land before the newest clear')
check(await waitFor(() => subsSince(m2) === 1, 2000), 'the resubscribe follows the newest clear')
check(await waitFor(() => S.getMeter(id7).value === 127, 3000), 'and the console reports again after it (clear 2 did not leave the meters unsubscribed)')
// a clear right after a resubscribe went out: it needs its own resubscribe
injectMeter(id7, 42)
const m3 = sent.length
const t3 = Date.now()
ipad.send(encode('/Meters/clear', []), P.relay, '127.0.0.1') // clear 3
check(await waitFor(() => subsSince(m3) === 1, 2000), 'a clear after the previous resubscribe schedules another one')
check(await waitFor(() => S.getMeter(id7).value === 127, 3000) && (await waitFor(() => (S.getMeter(meterIdForInput(2))?.at ?? 0) > t3, 3000)), 'the third clear is followed by working subscriptions too (silent and live meters report again)')
// clears in quick succession all collapse into one resubscribe after the last of them
injectMeter(id7, 42)
const m4 = sent.length
for (let k = 0; k < 4; k++) {
ipad.send(encode('/Meters/clear', []), P.relay, '127.0.0.1')
await sleep(60)
}
check(await waitFor(() => S.getMeter(id7).value === 127, 3000) && subsSince(m4) === 1, `four clears 60 ms apart give exactly one resubscribe, after the last (${subsSince(m4)})`)
}
check(!sent.some((m) => m.address === '/Meters/clear'), 'still never sent /Meters/clear ourselves')
// unpadded datagram from an iPad is forwarded verbatim
@@ -1539,10 +1594,11 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb
// matrix sends and graphic EQ bands are tracked per send pair / per band
const m1 = mk('matrix_send_on', { matrixIn: 2, matrixOut: 3 }, 'inst-M1')
const m2 = mk('matrix_send_level_value', { matrixIn: 3, matrixOut: 2 }, 'inst-M2')
const m3 = mk('matrix_send_off', { matrixIn: 2, matrixOut: 3 }, 'inst-M3')
const g1 = mk('geq_band_value', { geq: 1, band: 5 }, 'inst-G1')
const g2 = mk('geq_band_value', { geq: 1, band: 6 }, 'inst-G2')
const g3 = mk('geq_on', { geq: 1 }, 'inst-G3')
for (const f of [m1, m2, g1, g2, g3]) run1(f)
for (const f of [m1, m2, m3, g1, g2, g3]) run1(f)
check(inst.watchers.get('msend:2:3')?.has('inst-M1') && inst.watchers.get('msend:3:2')?.has('inst-M2') && inst.watchers.get('geq:1:gain:5')?.has('inst-G1') && inst.watchers.get('geq:1:gain:6')?.has('inst-G2') && inst.watchers.get('geq:1:on')?.has('inst-G3'), 'matrix send pairs and graphic EQ bands register under msend:in:out and geq:n:gain:band keys')
await sleep(150)
ctx.byId.length = 0
@@ -1551,7 +1607,12 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb
await sleep(80)
const ids3 = ctx.byId.flatMap(([, x]) => x)
check(ids3.includes('inst-M1') && !ids3.includes('inst-M2') && ids3.includes('inst-G1') && !ids3.includes('inst-G2') && !ids3.includes('inst-G3'), 'a matrix send change re-checks only that send pair, a band change only that band')
for (const f of [m1, m2, g1, g2, g3]) ctx.feedbacks[f.feedbackId].unsubscribe(f, {})
ctx.byId.length = 0
inst.onConsoleMessage(decode(encode('/Matrix_Inputs/2/Matrix_Send/3/send_on', [0.0])), { address: '127.0.0.1', port: P.mock })
await sleep(80)
const ids3b = ctx.byId.flatMap(([, x]) => x)
check(ids3b.includes('inst-M1') && ids3b.includes('inst-M3') && !ids3b.includes('inst-M2') && cb('matrix_send_off', { matrixIn: 2, matrixOut: 3 }) === true, 'a matrix send on/off change re-checks matrix_send_on and matrix_send_off of that send pair only, and the off feedback turns true')
for (const f of [m1, m2, m3, g1, g2, g3]) ctx.feedbacks[f.feedbackId].unsubscribe(f, {})
// level_compare follows the kind option
const lc = mk('level_compare', { kind: 'cg', cg: 2, comparison: 'above', threshold: -50 }, 'inst-L')
run1(lc)
@@ -1596,6 +1657,222 @@ const cb = (feedbackId, options, image) => ctx.feedbacks[feedbackId].callback(fb
expectWrite(r, '/Input_Channels/14/Filters/lo_filter_freq', ',f', 20, 'a filter frequency below the range is clamped to 20 Hz')
}
// ---------------------------------------------------------------------------
// 16b. Level fades: the duration / curve options of the set-level actions
// ---------------------------------------------------------------------------
{
// pure helpers: positionToDb is the exact inverse of faderPosition, fadeLevelAt walks a curve
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(positionToDb(pos), db, 1e-9) && near(faderPosition(db), pos, 1e-9)), 'positionToDb returns every anchor of the fader taper exactly')
check(positionToDb(0) === -90 && positionToDb(1) === -90 && positionToDb(0, -150) === -150 && positionToDb(100) === 10 && positionToDb(250) === 10 && positionToDb(-5) === -90 && positionToDb(NaN) === -90 && positionToDb(undefined, -150) === -150, 'positionToDb: position 0 is the floor (-90, or -150 when asked), 100 and more is +10, junk is the floor')
let worst = 0
for (let db = -89.9; db <= 10; db += 0.1) worst = Math.max(worst, Math.abs(positionToDb(faderPosition(db)) - db))
let worst150 = 0
for (let db = -149.5; db <= 10; db += 0.5) worst150 = Math.max(worst150, Math.abs(positionToDb(faderPosition(db, -150), -150) - db))
check(worst < 0.01 && worst150 < 0.01, `positionToDb(faderPosition(dB)) returns dB within 0.01 dB over the whole range, for floor -90 and -150 (worst ${worst.toExponential(1)} / ${worst150.toExponential(1)})`)
check(FADE_CURVES.join() === 'fader,ease,db' && fadeLevelAt('fader', -90, 0, 1) === 0 && fadeLevelAt('db', -20, 5, 1.5) === 5 && fadeLevelAt('ease', -50, -10, 0) === -50 && fadeLevelAt('db', -150, 0, 0.5, -90) === -45 && fadeLevelAt('db', -30, -200, 0.5, -90) === -60 && fadeLevelAt('fader', -30, -150, 1, -90) === -90 && fadeLevelAt('fader', 0, 40, 1) === 10, 'fadeLevelAt: t >= 1 is exactly the clamped target, t = 0 the start, the lower limit holds')
check(fadeLevelAt('db', -150, 0, 0) === -90 && fadeLevelAt('db', -150, 0, 0.5) === -45 && fadeLevelAt('db', 0, -150, 0.5) === -45 && fadeLevelAt('db', 0, -150, 0.999) > -90.1 && fadeLevelAt('db', 0, -150, 1) === -150 && fadeLevelAt('db', -60, 0, 0.5) === -30, 'a dB-linear fade from or to -inf runs between the -90 floor and the target (no silent stretch below -90); the last step is the exact target')
{
const mono = (c, a, b) => { const v = [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1].map((t) => fadeLevelAt(c, a, b, t)); return v.every((x, i) => i === 0 || (b >= a ? x >= v[i - 1] - 1e-9 : x <= v[i - 1] + 1e-9)) && v.every((x) => x >= Math.min(a, b) - 1e-9 && x <= Math.max(a, b) + 1e-9) }
check(['fader', 'ease', 'db'].every((c) => mono(c, -150, -100) && mono(c, -100, -150) && mono(c, -150, 0) && mono(c, -95, -20) && mono(c, -20, -95) && mono(c, 5, -150)), 'no fade curve ever steps outside its start..target range or reverses (including fades that stay below -90 dB)')
check(fadeLevelAt('fader', -150, -100, 0.5) === -125, 'a fade that stays below the -90 floor runs linearly in dB (-150 -> -100 is -125 halfway)')
}
}
const go = (actionId, options) => ctx.actions[actionId].callback({ id: 'f1', controlId: 'c', actionId, options, surfaceId: undefined }, {})
const faderAddr = (n) => `/Input_Channels/${n}/fader`
const writesTo = (address, mark) => sent.slice(mark).filter((m) => m.address === address).map((m) => ({ db: m.args[0], at: m.at }))
const idle = () => waitFor(() => inst.fades.size === 0, 3000)
{
// option definitions
const setIds = ['input_fader_set', 'buss_fader_set', 'matrix_fader_set', 'cg_fader_set', 'send_level_set', 'matrix_send_level_set']
const good = setIds.every((id) => {
const o = ctx.actions[id].options
const d = o.find((x) => x.id === 'duration')
const c = o.find((x) => x.id === 'curve')
return d?.type === 'number' && d.default === 0 && d.min === 0 && d.max === 60000 && d.step === 100 && d.asInteger === true && d.disableAutoExpression === true && d.label === 'Fade time (ms, 0 = instant)' &&
c?.type === 'dropdown' && c.default === 'fader' && c.isVisibleExpression === '$(options:duration) > 0' && c.choices.map((x) => `${x.id}=${x.label}`).join() === 'fader=Fader travel (natural),ease=Fader travel, ease in/out,db=Linear in dB' &&
o.indexOf(d) === o.findIndex((x) => x.id === 'level') + 1 && o.indexOf(c) === o.indexOf(d) + 1
})
check(good, 'all six set-level actions end with a "Fade time" option (0..60000 ms, step 100, integer, no expression) and a curve dropdown shown only above 0 ms')
check(Object.keys(ctx.actions).filter((id) => /_adjust$/.test(id)).every((id) => !ctx.actions[id].options.some((o) => o.id === 'duration')) && !ctx.actions.input_mute.options.some((o) => o.id === 'duration'), 'adjust and switch actions have no fade option')
check(ctx.actions.fade_stop?.name === 'Stop all fades' && ctx.actions.fade_stop.options.length === 0 && ctx.feedbacks.fade_running?.type === 'boolean' && ctx.feedbacks.fade_running.name === 'A fade is running' && 'fades_running' in ctx.variableDefs, 'fade_stop action, fade_running feedback and fades_running variable are defined')
check(setIds.every((id) => /fade time above 0/.test(ctx.actions[id].description)), 'every set-level action describes the fade')
}
{
// the 'fader' curve: -90 -> 0 in 500 ms is ten evenly spaced steps in fader position, ending exactly on 0
await go('input_fader_set', { channel: 11, level: -90 })
const mark = sent.length
const t0 = Date.now()
await go('input_fader_set', { channel: 11, level: 0, duration: 500, curve: 'fader' })
check(inst.fades.size === 1 && cb('fade_running', {}) === true, 'a fade is running right after the action (fade_running is true)')
await sleep(80)
check(ctx.variables.fades_running === 1, 'fades_running reads 1 while it runs')
check(await idle(), 'the fade finishes')
const w = writesTo(faderAddr(11), mark)
const dbs = w.map((x) => x.db)
const pos = dbs.map((v) => faderPosition(v))
const gaps = w.slice(1).map((x, k) => x.at - w[k].at).sort((a, b) => a - b)
check(w.length === 10, `500 ms at 20 Hz is ten writes (${w.length})`)
check(dbs.every((v, k) => k === 0 || v > dbs[k - 1]) && dbs[0] > -90 && dbs[0] < 0 && dbs.at(-1) === 0, `monotonic, the first write is above -90 and below the target, the last is exactly 0 (${dbs.map((v) => Math.round(v * 10) / 10).join(', ')})`)
check(pos.every((p, k) => near(p - (k ? pos[k - 1] : 0), 7.5, 0.01)), `the steps are even in fader position (7.5 each: ${pos.map((p) => Math.round(p * 10) / 10).join(', ')})`)
check(w.at(-1).at - t0 >= 495 && w.at(-1).at - t0 <= 700 && gaps[Math.floor(gaps.length / 2)] >= 35 && gaps[Math.floor(gaps.length / 2)] <= 75, `it ends between 500 and 700 ms after the action, one step about every 50 ms (ended after ${w.at(-1).at - t0} ms, median step ${gaps[Math.floor(gaps.length / 2)]} ms)`)
check(S.getFader('input', 11) === 0 && inst.fades.size === 0 && cb('fade_running', {}) === false, 'the state ends at 0 and fade_running is false again')
await sleep(80)
check(ctx.variables.fades_running === 0, 'fades_running reads 0 again')
check(await waitFor(() => sent.slice(mark).some((m) => m.address === `${faderAddr(11)}/?`), 2000), 'a confirm query follows the last step like after any write')
}
{
// 'db': linear in dB
await go('input_fader_set', { channel: 12, level: -60 })
const mark = sent.length
await go('input_fader_set', { channel: 12, level: 0, duration: 500, curve: 'db' })
check(await idle(), 'the linear fade finishes')
const dbs = writesTo(faderAddr(12), mark).map((x) => x.db)
check(dbs.length === 10 && dbs.every((v, k) => near(v, -60 + 6 * (k + 1), 0.01)) && dbs.at(-1) === 0, `'Linear in dB' moves 6 dB per step (${dbs.map((v) => Math.round(v * 100) / 100).join(', ')})`)
// the curve option is optional: a button made before fades existed (no duration, no curve) is an instant set
const m2 = sent.length
await go('input_fader_set', { channel: 12, level: -10 })
check(writesTo(faderAddr(12), m2).length === 1 && inst.fades.size === 0, 'no duration option at all (an old button) is one instant write')
const m3 = sent.length
await go('input_fader_set', { channel: 12, level: -12, duration: 0, curve: 'ease' })
await sleep(200)
const w3 = writesTo(faderAddr(12), m3)
check(w3.length === 1 && w3[0].db === -12 && inst.fades.size === 0, 'duration 0 is exactly one write, whatever the curve')
}
{
// 'ease': slow at both ends
await go('input_fader_set', { channel: 13, level: -90 })
const mark = sent.length
await go('input_fader_set', { channel: 13, level: 0, duration: 1000, curve: 'ease' })
check(await waitFor(() => inst.fades.size === 0, 4000), 'the eased fade finishes')
const dbs = writesTo(faderAddr(13), mark).map((x) => x.db)
const pos = dbs.map((v) => faderPosition(v))
const d = pos.map((p, k) => p - (k ? pos[k - 1] : 0))
const top = Math.max(...d)
const at = d.indexOf(top)
check(dbs.length >= 15 && dbs.every((v, k) => k === 0 || v > dbs[k - 1]) && dbs.at(-1) === 0 && d[0] < top * 0.5 && d.at(-1) < top * 0.25 && at > d.length * 0.3 && at < d.length * 0.7, `'ease' is slow at the start and the end and fastest in the middle (steps in fader position: first ${d[0].toFixed(1)}, fastest ${top.toFixed(1)} at step ${at + 1}, last ${d.at(-1).toFixed(1)}; ${dbs.length} writes)`)
}
{
// another set, a fade, an adjust, fade_stop: each ends a running fade where it is
let mark = sent.length
await go('input_fader_set', { channel: 14, level: -90 })
await go('input_fader_set', { channel: 14, level: 0, duration: 1000, curve: 'fader' })
await sleep(230)
const before = writesTo(faderAddr(14), mark).length
await go('input_fader_set', { channel: 14, level: -20 })
await sleep(350)
let w = writesTo(faderAddr(14), mark)
check(before >= 3 && w.length === before + 1 && w.at(-1).db === -20 && inst.fades.size === 0 && near(S.getFader('input', 14), -20), `a plain set ends the running fade: nothing is written after it but the set itself (${before} fade writes, then -20)`)
mark = sent.length
await go('input_fader_set', { channel: 14, level: -90 })
await go('input_fader_set', { channel: 14, level: 0, duration: 1000, curve: 'fader' })
await sleep(230)
const lastOfA = writesTo(faderAddr(14), mark).at(-1).db
const markB = sent.length
await go('input_fader_set', { channel: 14, level: -70, duration: 300, curve: 'db' })
check(inst.fades.size === 1, 'a second fade on the same fader replaces the first (one fade per address)')
check(await idle(), 'the second fade finishes')
w = writesTo(faderAddr(14), markB)
check(w.length === 6 && w.at(-1).db === -70 && w.every((x, k) => k === 0 || x.db < w[k - 1].db) && w[0].db < lastOfA && w[0].db > -70, `the second fade takes over from where the first one was (${Math.round(lastOfA)} dB, rising) and runs down to -70 without a write from the first (${w.map((x) => Math.round(x.db)).join(', ')})`)
mark = sent.length
await go('input_fader_set', { channel: 14, level: -90 })
await go('input_fader_set', { channel: 14, level: 0, duration: 1000, curve: 'fader' })
await sleep(230)
const markC = sent.length
await go('input_fader_adjust', { channel: 14, delta: 1 })
await sleep(350)
w = writesTo(faderAddr(14), markC)
check(w.length === 1 && inst.fades.size === 0, `an adjust ends the running fade and writes once (${w.length} write)`)
// fade_stop: both fades stop, levels stay where they are
await go('input_fader_set', { channel: 15, level: -90 })
await go('send_level_set', { channel: 15, buss: 1, level: -90 })
await go('input_fader_set', { channel: 15, level: 0, duration: 2000, curve: 'fader' })
await go('send_level_set', { channel: 15, buss: 1, level: 0, duration: 2000, curve: 'fader' })
await sleep(250)
check(inst.fades.size === 2 && ctx.variables.fades_running === 2, 'two fades on different levels run at the same time (fades_running 2)')
await go('fade_stop', {})
const markD = sent.length
const atStop = S.getFader('input', 15)
await sleep(400)
check(inst.fades.size === 0 && !sent.slice(markD).some((m) => m.address === faderAddr(15) || m.address === '/Input_Channels/15/Aux_Send/1/send_level') && S.getFader('input', 15) === atStop && atStop > -90 && atStop < 0 && ctx.variables.fades_running === 0 && cb('fade_running', {}) === false, `fade_stop ends every fade, nothing more is written, the levels stay where they were (${Math.round(atStop)} dB) and fades_running reads 0`)
await go('fade_stop', {})
check(inst.fades.size === 0, 'fade_stop with nothing running does nothing')
}
{
// aux send and the other fader kinds go through the same engine
await go('send_level_set', { channel: 16, buss: 2, level: -30 })
let mark = sent.length
await go('send_level_set', { channel: 16, buss: 2, level: -10, duration: 200, curve: 'db' })
check(await idle(), 'an aux send level fades')
const sw = writesTo('/Input_Channels/16/Aux_Send/2/send_level', mark).map((x) => x.db)
check(sw.length === 4 && sw.at(-1) === -10 && sw.every((v, k) => near(v, -30 + 5 * (k + 1), 0.01)), `aux send fade: four steps of 5 dB (${sw.join(', ')})`)
await go('buss_fader_set', { buss: 4, level: -30 })
await go('matrix_fader_set', { matrix: 2, level: -30 })
await go('cg_fader_set', { cg: 2, level: -30 })
mark = sent.length
await go('buss_fader_set', { buss: 4, level: -20, duration: 100, curve: 'db' })
await go('matrix_fader_set', { matrix: 2, level: -20, duration: 100, curve: 'db' })
await go('cg_fader_set', { cg: 2, level: -20, duration: 100, curve: 'db' })
check(inst.fades.size === 3, 'buss, matrix output and control group faders fade too (three at once)')
check(await idle(), 'they finish')
const sides = [['buss', 4], ['matrix', 2], ['cg', 2]].map(([ent, n]) => [S.getFader(ent, n)])
check(sides.every(([v]) => v === -20), 'and every one ends exactly at the target')
await sleep(100)
}
{
// a matrix send never goes below -90
await go('matrix_send_level_set', { matrixIn: 4, matrixOut: 4, level: -30 })
const addrM = '/Matrix_Inputs/4/Matrix_Send/4/send_level'
let mark = sent.length
await go('matrix_send_level_set', { matrixIn: 4, matrixOut: 4, level: -90, duration: 300, curve: 'db' })
check(await idle(), 'a matrix send fades down')
let m = writesTo(addrM, mark).map((x) => x.db)
check(m.length === 6 && m.at(-1) === -90 && m.every((v) => v >= -90) && m.every((v, k) => k === 0 || v < m[k - 1]), `down to -90: ${m.map((v) => Math.round(v * 10) / 10).join(', ')} (never below -90)`)
mark = sent.length
await go('matrix_send_level_set', { matrixIn: 4, matrixOut: 4, level: 0, duration: 500, curve: 'fader' })
check(await idle(), 'and up from -90 again')
m = writesTo(addrM, mark).map((x) => x.db)
check(m.length === 10 && m[0] > -90 && m.at(-1) === 0 && m.every((v) => v >= -90 && v <= 10), `from the -90 floor on the fader taper (${m.map((v) => Math.round(v)).join(', ')})`)
// a fade to a target below the floor stops at -90 for a matrix send, at -150 (-inf) for a fader
mark = sent.length
await inst.setMatrixSendLevel(4, 4, -150, 200, 'db')
check(await idle(), 'a matrix send fade to a too low target finishes')
m = writesTo(addrM, mark).map((x) => x.db)
check(m.length > 0 && m.every((v) => v >= -90) && m.at(-1) === -90, `a matrix send fade clamps its target to -90 (${m.map((v) => Math.round(v)).join(', ')})`)
await go('input_fader_set', { channel: 17, level: -60 })
mark = sent.length
await go('input_fader_set', { channel: 17, level: -150, duration: 300, curve: 'fader' })
check(await idle(), 'a fader fades out to -inf')
const f = writesTo(faderAddr(17), mark).map((x) => x.db)
check(f.length >= 3 && f.at(-1) === -150 && f.slice(0, -1).every((v) => v >= -90 && v < -60) && f.every((v, k) => k === 0 || v <= f[k - 1]), `a fader fade to -inf ends exactly on -150 (${f.map((v) => Math.round(v)).join(', ')})`)
}
{
// a start level the console never gives: the target is written at once, with one warning
const saved = inst.timing.replyWaitMs
inst.timing.replyWaitMs = 250
const warns = logCount(/set without a fade/)
const mark = sent.length
await inst.fadeLevel(faderAddr(18), 'test:unknown-start', () => undefined, -5, 400, 'fader')
inst.timing.replyWaitMs = saved
const w = writesTo(faderAddr(18), mark)
check(logCount(/set without a fade/) === warns + 1 && w.length === 1 && w[0].db === -5 && inst.fades.size === 0, 'an unknown starting level writes the target at once and logs one warning')
// a fade waiting for its starting level is replaced by a later set
inst.timing.replyWaitMs = 250
const m2 = sent.length
const pending = inst.fadeLevel(faderAddr(18), 'test:unknown-start2', () => undefined, -5, 400, 'fader')
await sleep(40)
await go('input_fader_set', { channel: 18, level: -33 })
await pending
inst.timing.replyWaitMs = saved
const w2 = writesTo(faderAddr(18), m2)
check(w2.length === 1 && w2[0].db === -33 && inst.fades.size === 0, 'a set while a fade still waits for its starting level wins; the waiting fade does nothing')
}
check(inst.fades.size === 0 && inst.fadeStarts.size === 0, 'no fade or fade start is left over after the fade tests')
// ---------------------------------------------------------------------------
// 17. A slow console (every answer and echo 150 ms late): bind failures, encoder clicks, lazy loads
// ---------------------------------------------------------------------------
@@ -1718,6 +1995,48 @@ const occupy = async (port) => {
check(r.relay === null && r.relayRetryTimer === null && st()[0] === 'ok', 'disabling the relay clears the problem')
await r.destroy()
}
{
// Fades end with the instance: a config change that reopens the console link, and destroy
const fctx = makeContext()
const f = new Instance(fctx)
Object.assign(f.timing, fastTiming)
const loads0 = logCount(/load complete/)
await f.init(cfgB)
check(await waitFor(() => logCount(/load complete/) > loads0 && f.state.getFader('input', 3) !== undefined, 20000), 'an instance on the slow console has loaded')
const fAddr = '/Input_Channels/3/fader'
const fadeWrites = (mark) => sent.slice(mark).filter((m) => m.address === fAddr).length
const act = (actionId, options) => fctx.actions[actionId].callback(evOf(actionId, options), {})
await act('input_fader_set', { channel: 3, level: -90 })
await act('input_fader_set', { channel: 3, level: 0, duration: 4000, curve: 'fader' })
await sleep(250)
check(f.fades.size === 1 && fctx.variables.fades_running === 1 && f.timers.size > 0, 'a fade is running on the third instance')
const m1 = sent.length
await f.configUpdated({ ...cfgB, localPort: P.linkB + 2 })
check(f.fades.size === 0 && f.fadeStarts.size === 0 && f.link?.localPort === P.linkB + 2, 'a config change that reopens the console link ends the fade')
await sleep(500)
check(fadeWrites(m1) === 0 && fctx.variables.fades_running === 0, 'nothing more is written to the fader and fades_running reads 0')
await f.configUpdated(cfgB)
check(await waitFor(() => logCount(/load complete/) > loads0 + 1 && f.state.getFader('input', 3) !== undefined, 20000), 'the instance reconnects and loads again')
await act('input_fader_set', { channel: 3, level: 0, duration: 4000, curve: 'ease' })
await sleep(250)
check(f.fades.size === 1, 'a new fade runs after the reconnect')
const m2 = sent.length
await f.destroy()
check(f.fades.size === 0 && f.timers.size === 0, 'destroy ends the fade and leaves no timer behind')
await sleep(500)
check(fadeWrites(m2) === 0, 'nothing is written after destroy')
// a fade that was still waiting for its starting level when the instance went away does not start afterwards
const g = new Instance(makeContext())
Object.assign(g.timing, fastTiming)
await g.init(cfgB)
check(await waitFor(() => !!g.link, 3000), 'a fourth instance binds the console link')
const waiting = g.fadeLevel(fAddr, 'test:never', () => undefined, 0, 1000, 'fader')
await sleep(50)
check(g.fadeStarts.size === 1, 'a fade waits for its starting level')
await g.destroy()
await waiting
check(g.fades.size === 0 && g.fadeStarts.size === 0, 'destroy while a fade waits for its starting level: it never starts')
}
mockB.kill('SIGTERM')
// ---------------------------------------------------------------------------