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:
+334
-15
@@ -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')
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user