diff --git a/companion/HELP.md b/companion/HELP.md index c20a427..2a6a703 100644 --- a/companion/HELP.md +++ b/companion/HELP.md @@ -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) | diff --git a/src/definitions/actions.js b/src/definitions/actions.js index 130369d..de414c6 100644 --- a/src/definitions/actions.js +++ b/src/definitions/actions.js @@ -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: [], diff --git a/src/definitions/feedbacks.js b/src/definitions/feedbacks.js index e323d70..0532a9e 100644 --- a/src/definitions/feedbacks.js +++ b/src/definitions/feedbacks.js @@ -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': diff --git a/src/definitions/presets.js b/src/definitions/presets.js index 7e142c4..1eb7713 100644 --- a/src/definitions/presets.js +++ b/src/definitions/presets.js @@ -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}`) diff --git a/src/definitions/variables.js b/src/definitions/variables.js index ea4f4af..78c2ed2 100644 --- a/src/definitions/variables.js +++ b/src/definitions/variables.js @@ -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 } diff --git a/src/main.js b/src/main.js index 5a9a5ce..19aad4c 100644 --- a/src/main.js +++ b/src/main.js @@ -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 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) { diff --git a/src/protocol.js b/src/protocol.js index bc0e166..fc00af5 100644 --- a/src/protocol.js +++ b/src/protocol.js @@ -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 '' diff --git a/test/selftest.js b/test/selftest.js index ad3322d..f71fb8d 100644 --- a/test/selftest.js +++ b/test/selftest.js @@ -20,7 +20,7 @@ import { ConsoleLink } from '../src/console-link.js' import { Relay } from '../src/relay.js' import { encode, decode, foldPattern } from '../src/osc.js' import { State } from '../src/state.js' -import { LEVEL, PARAMS, adjustedLevel, 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 " 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') // ---------------------------------------------------------------------------