diff --git a/companion/HELP.md b/companion/HELP.md index 6fd1257..b90d81b 100644 --- a/companion/HELP.md +++ b/companion/HELP.md @@ -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 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 +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 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 diff --git a/src/definitions/actions.js b/src/definitions/actions.js index 24cd5a4..1e62364 100644 --- a/src/definitions/actions.js +++ b/src/definitions/actions.js @@ -77,7 +77,7 @@ export const opt = { 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.' +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. */ const fadeArgs = (options) => [Number(options?.duration) > 0 ? Math.min(60000, Math.round(Number(options.duration))) : 0, FADE_CURVES.includes(options?.curve) ? options.curve : 'fader'] diff --git a/src/main.js b/src/main.js index 19aad4c..001211f 100644 --- a/src/main.js +++ b/src/main.js @@ -12,7 +12,10 @@ 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 +/** 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_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. */ + /** + * 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) { const key = `${source}|${err.code || err.message}|${err.dest || ''}` const now = Date.now() @@ -488,6 +540,7 @@ export default class DigicoS31Instance extends InstanceBase { if (this.config.debugLog) { this.learn('ipad', msg, rinfo) this.log('debug', `ipad ${rinfo.address} -> console: ${describe(msg)}`) + this.noteRelayRate(rinfo.address, msg) } }) try { @@ -530,7 +583,9 @@ export default class DigicoS31Instance extends InstanceBase { async disconnect() { this.loadToken++ 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) this.keepaliveTimer = null this.linkRetryTimer = null @@ -823,7 +878,7 @@ export default class DigicoS31Instance extends InstanceBase { // ---------- 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 * 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. diff --git a/test/selftest.js b/test/selftest.js index 64d215e..db50f26 100644 --- a/test/selftest.js +++ b/test/selftest.js @@ -115,6 +115,7 @@ function makeContext() { // 0. Imports, config fields, upgrade script // --------------------------------------------------------------------------- const mainMod = await import('../src/main.js') +const FADE_STEP = mainMod.FADE_STEP_MS const Instance = mainMod.default 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 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(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(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(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 ${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') await sleep(80) 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' }) 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(', ')})`) + 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 const m2 = sent.length 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(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(', ')})`) + 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 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' }) 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(', ')})`) + 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('matrix_fader_set', { matrix: 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' }) 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)`) + 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 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(', ')})`) + 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 mark = sent.length 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(await idle(), 'the group fade finishes') 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 }) const m3 = sent.length await go('aux_fader_set', { buss: 5, level: -10, duration: 200, curve: 'db' }) check(await idle(), 'the aux master fade finishes') 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 // presets: Groups and Aux masters sections, group / aux meter sections, no "Busses" section