Fade at the iPad app's rate; relay rate report in debug mode

Measured on the booth relay with debug logging on: while dragging a
fader, the DiGiCo iPad app sends /Input_Channels/N/fader at most every
45 ms (about 22 per second; 16/s on average while moving). Fades now
step every 45 ms to match (was 50 ms). Verified on the S31 with a
switched-off matrix send: 23 steps in 1 s, landing exactly on target.

New debug aid: with "Debug logging" on, the module logs every 5 s, per
iPad, the busiest address patterns with their message rate and the
shortest gap between two messages to the same address.

The fade tests now derive their step counts from the exported
FADE_STEP_MS. npm test: 542 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 15:54:07 -04:00
co-authored by Claude Opus 5.5
parent f81d581bf2
commit 13092672f7
4 changed files with 71 additions and 15 deletions
+1 -1
View File
@@ -110,7 +110,7 @@ never verified.
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 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 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 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 second at most (every 45 ms, about 22 per second, the same rate the DiGiCo iPad app sends a fader drag at; 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 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* 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 (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
+1 -1
View File
@@ -77,7 +77,7 @@ export const opt = {
const ADJUST_NOTE = 'From −∞ 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. */ /** 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.' const FADE_NOTE = 'With a fade time above 0 the level moves from where it is now to the target in steps of 45 ms (about 22 per second, the rate the DiGiCo iPad app sends fader moves at); 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. */ /** [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'] const fadeArgs = (options) => [Number(options?.duration) > 0 ? Math.min(60000, Math.round(Number(options.duration))) : 0, FADE_CURVES.includes(options?.curve) ? options.curve : 'fader']
+58 -3
View File
@@ -12,7 +12,10 @@ import { getPresetDefinitions } from './definitions/presets.js'
const LEARN_CAP = 400 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. */ /** 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 /** Fade step: 45 ms (about 22 Hz), the fastest rate the DiGiCo iPad app sends fader moves at (measured 2026-10-20). */
export const FADE_STEP_MS = 45
/** Debug aid: how often the per-iPad relay rate report is logged (only while debug logging is on). */
const RATE_REPORT_MS = 5000
const FADE_MIN_DB = 0.05 const FADE_MIN_DB = 0.05
const FADE_MAX_MS = 60000 const FADE_MAX_MS = 60000
@@ -368,6 +371,55 @@ export default class DigicoS31Instance extends InstanceBase {
} }
/** One log line per error code + destination per `errorLogMs`, whatever the rate the errors come at. */ /** One log line per error code + destination per `errorLogMs`, whatever the rate the errors come at. */
/**
* Debug aid: how fast each iPad sends. While debug logging is on, every relayed iPad message is counted per
* address pattern, and every RATE_REPORT_MS an info line per iPad reports the busiest patterns with their message
* rate and the shortest gap between two messages to the same address (how granular a fader drag is).
*/
noteRelayRate(ip, msg) {
if (!msg?.address) return
const now = Date.now()
if (!this.relayRates) this.relayRates = new Map()
const key = `${ip}|${foldPattern(msg.address)}`
let r = this.relayRates.get(key)
if (!r) {
r = { ip, pattern: foldPattern(msg.address), count: 0, first: now, minGap: Infinity, last: new Map() }
this.relayRates.set(key, r)
}
r.count++
const prev = r.last.get(msg.address)
if (prev !== undefined) r.minGap = Math.min(r.minGap, now - prev)
r.last.set(msg.address, now)
if (!this.rateTimer) {
this.rateTimer = setInterval(() => this.reportRelayRates(), RATE_REPORT_MS)
}
}
reportRelayRates() {
const rates = this.relayRates
this.relayRates = null
if (!rates || !rates.size || !this.config.debugLog) {
if (this.rateTimer && !this.config.debugLog) {
clearInterval(this.rateTimer)
this.rateTimer = null
}
return
}
const byIp = new Map()
for (const r of rates.values()) {
if (!byIp.has(r.ip)) byIp.set(r.ip, [])
byIp.get(r.ip).push(r)
}
for (const [ip, list] of byIp) {
const total = list.reduce((n, r) => n + r.count, 0)
const top = list
.sort((a, b) => b.count - a.count)
.slice(0, 5)
.map((r) => `${r.pattern} ${r.count} (${(r.count / (RATE_REPORT_MS / 1000)).toFixed(1)}/s${Number.isFinite(r.minGap) ? `, min gap ${r.minGap} ms` : ''})`)
this.log('info', `Relay rate ${ip}: ${total} msgs in ${RATE_REPORT_MS / 1000} s; ${top.join('; ')}`)
}
}
reportError(source, err) { reportError(source, err) {
const key = `${source}|${err.code || err.message}|${err.dest || ''}` const key = `${source}|${err.code || err.message}|${err.dest || ''}`
const now = Date.now() const now = Date.now()
@@ -488,6 +540,7 @@ export default class DigicoS31Instance extends InstanceBase {
if (this.config.debugLog) { if (this.config.debugLog) {
this.learn('ipad', msg, rinfo) this.learn('ipad', msg, rinfo)
this.log('debug', `ipad ${rinfo.address} -> console: ${describe(msg)}`) this.log('debug', `ipad ${rinfo.address} -> console: ${describe(msg)}`)
this.noteRelayRate(rinfo.address, msg)
} }
}) })
try { try {
@@ -530,7 +583,9 @@ export default class DigicoS31Instance extends InstanceBase {
async disconnect() { async disconnect() {
this.loadToken++ this.loadToken++
this.loading = false this.loading = false
for (const t of [this.keepaliveTimer, this.txTimer, this.meterTimer]) if (t) clearInterval(t) for (const t of [this.keepaliveTimer, this.txTimer, this.meterTimer, this.rateTimer]) if (t) clearInterval(t)
this.rateTimer = null
this.relayRates = null
for (const t of [this.linkRetryTimer, this.relayRetryTimer]) if (t) clearTimeout(t) for (const t of [this.linkRetryTimer, this.relayRetryTimer]) if (t) clearTimeout(t)
this.keepaliveTimer = null this.keepaliveTimer = null
this.linkRetryTimer = null this.linkRetryTimer = null
@@ -823,7 +878,7 @@ export default class DigicoS31Instance extends InstanceBase {
// ---------- fades ---------- // ---------- fades ----------
/** /**
* Move a level from where it is now to `targetDb` over `durationMs`: a new level every FADE_STEP_MS (20 Hz), each * Move a level from where it is now to `targetDb` over `durationMs`: a new level every FADE_STEP_MS (about 22 Hz, like the iPad app), each
* a normal write(), the last one exactly the target. Curves are those of `fadeLevelAt`. There is one fade per * 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 * 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. * link ends them all. When the current level cannot be read, the target is written at once.
+11 -10
View File
@@ -115,6 +115,7 @@ function makeContext() {
// 0. Imports, config fields, upgrade script // 0. Imports, config fields, upgrade script
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const mainMod = await import('../src/main.js') const mainMod = await import('../src/main.js')
const FADE_STEP = mainMod.FADE_STEP_MS
const Instance = mainMod.default const Instance = mainMod.default
check(typeof Instance === 'function' && Array.isArray(mainMod.UpgradeScripts), 'src/main.js imports and exports a default class + UpgradeScripts') check(typeof Instance === 'function' && Array.isArray(mainMod.UpgradeScripts), 'src/main.js imports and exports a default class + UpgradeScripts')
@@ -1735,10 +1736,10 @@ const idle = () => waitFor(() => inst.fades.size === 0, 3000)
const dbs = w.map((x) => x.db) const dbs = w.map((x) => x.db)
const pos = dbs.map((v) => faderPosition(v)) 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) 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(FADE_STEP === 45 && w.length === Math.ceil(500 / FADE_STEP), `500 ms at one step per ${FADE_STEP} ms is ${Math.ceil(500 / FADE_STEP)} 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(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(pos.every((p, k) => near(p - (k ? pos[k - 1] : 0), 75 / Math.ceil(500 / FADE_STEP), 0.01)), `the steps are even in fader position (${75 / Math.ceil(500 / FADE_STEP)} 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(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 ${FADE_STEP} 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') 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) await sleep(80)
check(ctx.variables.fades_running === 0, 'fades_running reads 0 again') check(ctx.variables.fades_running === 0, 'fades_running reads 0 again')
@@ -1751,7 +1752,7 @@ const idle = () => waitFor(() => inst.fades.size === 0, 3000)
await go('input_fader_set', { channel: 12, level: 0, duration: 500, curve: 'db' }) await go('input_fader_set', { channel: 12, level: 0, duration: 500, curve: 'db' })
check(await idle(), 'the linear fade finishes') check(await idle(), 'the linear fade finishes')
const dbs = writesTo(faderAddr(12), mark).map((x) => x.db) 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(', ')})`) check(dbs.length === Math.ceil(500 / FADE_STEP) && dbs.every((v, k) => near(v, -60 + (60 / Math.ceil(500 / FADE_STEP)) * (k + 1), 0.01)) && dbs.at(-1) === 0, `'Linear in dB' moves the same dB every 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 // the curve option is optional: a button made before fades existed (no duration, no curve) is an instant set
const m2 = sent.length const m2 = sent.length
await go('input_fader_set', { channel: 12, level: -10 }) await go('input_fader_set', { channel: 12, level: -10 })
@@ -1797,7 +1798,7 @@ const idle = () => waitFor(() => inst.fades.size === 0, 3000)
check(inst.fades.size === 1, 'a second fade on the same fader replaces the first (one fade per address)') 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') check(await idle(), 'the second fade finishes')
w = writesTo(faderAddr(14), markB) 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(', ')})`) check(w.length === Math.ceil(300 / FADE_STEP) && 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 mark = sent.length
await go('input_fader_set', { channel: 14, level: -90 }) await go('input_fader_set', { channel: 14, level: -90 })
@@ -1831,7 +1832,7 @@ const idle = () => waitFor(() => inst.fades.size === 0, 3000)
await go('send_level_set', { channel: 16, buss: 2, level: -10, duration: 200, curve: 'db' }) await go('send_level_set', { channel: 16, buss: 2, level: -10, duration: 200, curve: 'db' })
check(await idle(), 'an aux send level fades') check(await idle(), 'an aux send level fades')
const sw = writesTo('/Input_Channels/16/Aux_Send/2/send_level', mark).map((x) => x.db) 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(', ')})`) check(sw.length === Math.ceil(200 / FADE_STEP) && sw.at(-1) === -10 && sw.every((v, k) => near(v, -30 + (20 / Math.ceil(200 / FADE_STEP)) * (k + 1), 0.01)), `aux send fade: ${Math.ceil(200 / FADE_STEP)} even steps (${sw.map((v) => Math.round(v * 100) / 100).join(', ')})`)
await go('buss_fader_set', { buss: 4, level: -30 }) await go('buss_fader_set', { buss: 4, level: -30 })
await go('matrix_fader_set', { matrix: 2, level: -30 }) await go('matrix_fader_set', { matrix: 2, level: -30 })
await go('cg_fader_set', { cg: 2, level: -30 }) await go('cg_fader_set', { cg: 2, level: -30 })
@@ -1853,12 +1854,12 @@ const idle = () => waitFor(() => inst.fades.size === 0, 3000)
await go('matrix_send_level_set', { matrixIn: 4, matrixOut: 4, level: -90, duration: 300, curve: 'db' }) await go('matrix_send_level_set', { matrixIn: 4, matrixOut: 4, level: -90, duration: 300, curve: 'db' })
check(await idle(), 'a matrix send fades down') check(await idle(), 'a matrix send fades down')
let m = writesTo(addrM, mark).map((x) => x.db) 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)`) check(m.length === Math.ceil(300 / FADE_STEP) && 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 mark = sent.length
await go('matrix_send_level_set', { matrixIn: 4, matrixOut: 4, level: 0, duration: 500, curve: 'fader' }) await go('matrix_send_level_set', { matrixIn: 4, matrixOut: 4, level: 0, duration: 500, curve: 'fader' })
check(await idle(), 'and up from -90 again') check(await idle(), 'and up from -90 again')
m = writesTo(addrM, mark).map((x) => x.db) 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(', ')})`) check(m.length === Math.ceil(500 / FADE_STEP) && 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 // a fade to a target below the floor stops at -90 for a matrix send, at -150 (-inf) for a fader
mark = sent.length mark = sent.length
await inst.setMatrixSendLevel(4, 4, -150, 200, 'db') await inst.setMatrixSendLevel(4, 4, -150, 200, 'db')
@@ -1959,13 +1960,13 @@ check(inst.fades.size === 0 && inst.fadeStarts.size === 0, 'no fade or fade star
check(inst.fades.size === 1, 'a group fader fade is running') check(inst.fades.size === 1, 'a group fader fade is running')
check(await idle(), 'the group fade finishes') check(await idle(), 'the group fade finishes')
const gw = writesTo('/Group_Outputs/25/fader', m2).map((x) => x.db) const gw = writesTo('/Group_Outputs/25/fader', m2).map((x) => x.db)
check(gw.length === 6 && gw.at(-1) === -10 && gw.every((v, k) => near(v, -40 + 5 * (k + 1), 0.01)), `group_fader_set fades on /Group_Outputs/25/fader (${gw.map((v) => Math.round(v * 100) / 100).join(', ')})`) check(gw.length === Math.ceil(300 / FADE_STEP) && gw.at(-1) === -10 && gw.every((v, k) => near(v, -40 + (30 / Math.ceil(300 / FADE_STEP)) * (k + 1), 0.01)), `group_fader_set fades on /Group_Outputs/25/fader (${gw.map((v) => Math.round(v * 100) / 100).join(', ')})`)
await go('aux_fader_set', { buss: 5, level: -40 }) await go('aux_fader_set', { buss: 5, level: -40 })
const m3 = sent.length const m3 = sent.length
await go('aux_fader_set', { buss: 5, level: -10, duration: 200, curve: 'db' }) await go('aux_fader_set', { buss: 5, level: -10, duration: 200, curve: 'db' })
check(await idle(), 'the aux master fade finishes') check(await idle(), 'the aux master fade finishes')
const aw = writesTo('/Aux_Outputs/5/fader', m3).map((x) => x.db) const aw = writesTo('/Aux_Outputs/5/fader', m3).map((x) => x.db)
check(aw.length === 4 && aw.at(-1) === -10 && !sent.slice(m3).some((m) => m.address.startsWith('/Group_Outputs/5/')), `aux_fader_set fades on /Aux_Outputs/5/fader (${aw.map((v) => Math.round(v * 100) / 100).join(', ')})`) check(aw.length === Math.ceil(200 / FADE_STEP) && aw.at(-1) === -10 && !sent.slice(m3).some((m) => m.address.startsWith('/Group_Outputs/5/')), `aux_fader_set fades on /Aux_Outputs/5/fader (${aw.map((v) => Math.round(v * 100) / 100).join(', ')})`)
void mark void mark
// presets: Groups and Aux masters sections, group / aux meter sections, no "Busses" section // presets: Groups and Aux masters sections, group / aux meter sections, no "Busses" section