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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 11:20:07 -04:00
co-authored by Claude Opus 5.5
parent 52777f3d7c
commit 205f91e7d2
8 changed files with 634 additions and 64 deletions
+118 -8
View File
@@ -3,7 +3,7 @@ import { ConsoleLink } from './console-link.js'
import { Relay } from './relay.js'
import { decode, describe, foldPattern } from './osc.js'
import { State } from './state.js'
import { GEQ_RANGE, LEVEL, PARAMS, K, addr, adjustedLevel, clamp, indexProblem, meterId, meterSubscription, outputMeterId, outputMeterSubscription, parse, query, sameValue } from './protocol.js'
import { FADE_CURVES, GEQ_RANGE, LEVEL, PARAMS, K, addr, adjustedLevel, clamp, fadeLevelAt, indexProblem, meterId, meterSubscription, outputMeterId, outputMeterSubscription, parse, query, sameValue } from './protocol.js'
import { getActionDefinitions } from './definitions/actions.js'
import { getFeedbackDefinitions, feedbackIdsForEvent, watchKey, watchKeyForEvent } from './definitions/feedbacks.js'
import { getVariableDefinitions, getAllVariableValues, getEventVariableValues, describeChange } from './definitions/variables.js'
@@ -11,6 +11,11 @@ import { getPresetDefinitions } from './definitions/presets.js'
const LEARN_CAP = 400
/** A fade writes a new level this often (20 steps per second at most) and skips steps that would change the level by less than FADE_MIN_DB. */
const FADE_STEP_MS = 50
const FADE_MIN_DB = 0.05
const FADE_MAX_MS = 60000
/** Timings in ms. An instance copies these to `this.timing` so tests can shorten them. */
const TIMING = {
keepaliveMs: 5000, // /Console/Name/? ping
@@ -196,6 +201,9 @@ export default class DigicoS31Instance extends InstanceBase {
this.requested = new Map() // lazily loaded pairs -> time requested, de-duplicated for a while
this.waiters = new Map() // event key -> Set<fn>
this.pendingWrites = new Map() // address -> { value, until, timer }
this.fades = new Map() // address -> the running fade (one per address)
this.fadeStarts = new Map() // address -> token of a fade still waiting for the level it starts from
this.fadeSeq = 0
this.errorLog = new Map() // error code + destination -> time last logged
this.connectedFlag = false
this.statusName = 'connecting'
@@ -532,6 +540,11 @@ export default class DigicoS31Instance extends InstanceBase {
for (const t of this.timers) clearTimeout(t)
this.timers.clear()
this.pendingWrites.clear()
this.fadeStarts.clear()
if (this.fades.size) {
this.fades.clear() // their step timers were cleared with the others above
this.fadesChanged()
}
if (this.rebuildTimer) clearTimeout(this.rebuildTimer)
this.rebuildTimer = null
this.resubscribeTimer = null
@@ -787,9 +800,10 @@ export default class DigicoS31Instance extends InstanceBase {
return this.write(address, [on ? 1 : 0], on ? 1 : 0)
}
/** Write a dB level, clamped to [lo, +10]; `lo` is -150 except for matrix sends (-90). */
/** Write a dB level, clamped to [lo, +10]; `lo` is -150 except for matrix sends (-90). A running fade on the address ends. */
writeLevel(address, dB, lo = LEVEL.min) {
if (!this.addressOk(address)) return false
this.cancelFade(address)
const v = clamp(Number(dB), lo, LEVEL.max)
if (!Number.isFinite(v)) return false
return this.write(address, [v], v)
@@ -797,6 +811,7 @@ export default class DigicoS31Instance extends InstanceBase {
async adjustLevel(address, key, get, delta, lo = LEVEL.min) {
if (!this.addressOk(address)) return false
this.cancelFade(address)
const cur = await this.current(key, get, address)
if (cur === undefined) {
this.log('warn', `${address}: current level unknown (no reply from the console), not adjusted`)
@@ -805,6 +820,93 @@ export default class DigicoS31Instance extends InstanceBase {
return this.writeLevel(address, adjustedLevel(cur, delta, lo), lo)
}
// ---------- fades ----------
/**
* Move a level from where it is now to `targetDb` over `durationMs`: a new level every FADE_STEP_MS (20 Hz), each
* a normal write(), the last one exactly the target. Curves are those of `fadeLevelAt`. There is one fade per
* address: a new fade, a plain set or an adjust on the same address ends the running one, and closing the console
* link ends them all. When the current level cannot be read, the target is written at once.
*/
async fadeLevel(address, key, get, targetDb, durationMs, curve = 'fader', lo = LEVEL.min) {
if (!this.addressOk(address)) return false
const target = clamp(Number(targetDb), lo, LEVEL.max)
if (!Number.isFinite(target)) return false
const ms = Math.min(Number(durationMs), FADE_MAX_MS)
if (!(ms > 0)) return this.writeLevel(address, target, lo)
this.cancelFade(address)
const token = ++this.fadeSeq
this.fadeStarts.set(address, token)
const from = await this.current(key, get, address)
if (this.fadeStarts.get(address) !== token) return false // another action took the address, fades were stopped or the link was closed meanwhile
this.fadeStarts.delete(address)
if (from === undefined || !Number.isFinite(from)) {
this.log('warn', `${address}: current level unknown (no reply from the console), set without a fade`)
return this.write(address, [target], target)
}
if (!this.link) return false
const steps = Math.max(1, Math.ceil(ms / FADE_STEP_MS))
const fade = { address, from, target, curve: FADE_CURVES.includes(curve) ? curve : 'fader', lo, steps, interval: ms / steps, k: 0, last: from, startedAt: Date.now(), timer: null }
this.fades.set(address, fade)
this.fadesChanged()
this.scheduleFadeStep(fade)
return true
}
scheduleFadeStep(fade) {
const due = fade.startedAt + Math.round((fade.k + 1) * fade.interval)
fade.timer = this.later(() => this.fadeStep(fade), Math.max(0, due - Date.now()))
}
fadeStep(fade) {
if (this.fades.get(fade.address) !== fade) return
fade.k++
const final = fade.k >= fade.steps
const db = fadeLevelAt(fade.curve, fade.from, fade.target, fade.k / fade.steps, fade.lo)
if (final ? db !== fade.last : Math.abs(db - fade.last) >= FADE_MIN_DB) {
if (!this.write(fade.address, [db], db)) {
this.cancelFade(fade.address)
return
}
fade.last = db
}
if (final) {
this.fades.delete(fade.address)
this.fadesChanged()
} else {
this.scheduleFadeStep(fade)
}
}
/** End the fade running on `address` (or waiting for its starting level) where the level is now. */
cancelFade(address) {
this.fadeStarts.delete(address)
const fade = this.fades.get(address)
if (!fade) return false
clearTimeout(fade.timer)
this.timers.delete(fade.timer)
this.fades.delete(address)
this.fadesChanged()
return true
}
/** End every fade; each level stays where it is. */
stopFades() {
this.fadeStarts.clear()
if (!this.fades.size) return
for (const fade of this.fades.values()) {
clearTimeout(fade.timer)
this.timers.delete(fade.timer)
}
this.fades.clear()
this.fadesChanged()
}
fadesChanged() {
this.queueVariables({ fades_running: this.fades.size })
this.touch(watchKey.fades, ['fade_running'])
}
writePan(address, pan) {
if (!this.addressOk(address)) return false
const v = clamp(Number(pan), 0, 1)
@@ -832,8 +934,12 @@ export default class DigicoS31Instance extends InstanceBase {
return this.writeFlag(addr[field](ent, n, side), K.value(ent, n, field, side), get, mode)
}
setFader(ent, n, dB) {
return this.writeLevel(addr.fader(ent, n, this.sideOf(ent, n)), dB)
/** Set a fader; with `durationMs` > 0 it moves there over that time (see fadeLevel). */
setFader(ent, n, dB, durationMs = 0, curve = 'fader') {
const side = this.sideOf(ent, n)
const address = addr.fader(ent, n, side)
if (!(durationMs > 0)) return this.writeLevel(address, dB)
return this.fadeLevel(address, K.value(ent, n, 'fader', side), () => this.state.getFader(ent, n), dB, durationMs, curve)
}
adjustFader(ent, n, delta) {
@@ -862,8 +968,10 @@ export default class DigicoS31Instance extends InstanceBase {
return this.writeFlag(addr.sendOn(ch, buss), K.send(ch, buss, 'on'), () => this.state.getSend(ch, buss).on, mode)
}
setSendLevel(ch, buss, dB) {
return this.writeLevel(addr.sendLevel(ch, buss), dB)
setSendLevel(ch, buss, dB, durationMs = 0, curve = 'fader') {
const address = addr.sendLevel(ch, buss)
if (!(durationMs > 0)) return this.writeLevel(address, dB)
return this.fadeLevel(address, K.send(ch, buss, 'level'), () => this.state.getSend(ch, buss).level, dB, durationMs, curve)
}
adjustSendLevel(ch, buss, delta) {
@@ -886,8 +994,10 @@ export default class DigicoS31Instance extends InstanceBase {
return this.writeFlag(addr.matrixSendOn(inN, outN), K.msend(inN, outN, 'on'), () => this.state.getMatrixSend(inN, outN).on, mode)
}
setMatrixSendLevel(inN, outN, dB) {
return this.writeLevel(addr.matrixSendLevel(inN, outN), dB, LEVEL.matrixSendMin)
setMatrixSendLevel(inN, outN, dB, durationMs = 0, curve = 'fader') {
const address = addr.matrixSendLevel(inN, outN)
if (!(durationMs > 0)) return this.writeLevel(address, dB, LEVEL.matrixSendMin)
return this.fadeLevel(address, K.msend(inN, outN, 'level'), () => this.state.getMatrixSend(inN, outN).level, dB, durationMs, curve, LEVEL.matrixSendMin)
}
adjustMatrixSendLevel(inN, outN, delta) {