diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 9227194..4660e0e 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -684,6 +684,7 @@ clock, and any module that offers some. A module offers timers by: | `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). | | `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. | | `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. | +| `asOf` | Optional, per timer: ms epoch this timer's durations were measured, when that differs from the snapshot's `updatedAt` (e.g. a value phase-locked to an upstream's second flips). Consumers extrapolate from it instead. | | `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. | | `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. | | `detail` | Optional second line. | diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index 44b975e..7b48798 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -123,10 +123,13 @@ export function framesToTc(n, nominal, df) { * elapsed ms otherwise, or null when the snapshot can't say. The values as * of the snapshot (`elapsedMs` / `remainingMs`) are preferred and carried * forward while running; `startedAt` is the fallback when they are absent. + * A timer measured at its own instant carries `asOf` (a phase-locked + * ProPresenter value — see the server's phase.js) which beats the source's + * snapshot time. */ export function valueAt(timer, sourceUpdatedAt, now) { const running = timer.state === 'running'; - const at = Number(sourceUpdatedAt) || now; + const at = Number(timer.asOf) || Number(sourceUpdatedAt) || now; const dt = running ? Math.max(0, now - at) : 0; const has = (v) => v !== null && v !== undefined && Number.isFinite(v); if (timer.kind === 'countdown') { @@ -647,7 +650,21 @@ export default function create({ root, moduleApi }) { statusTone = timer.status?.tone || 'muted'; } } else { - const value = valueAt(timer, source.updatedAt, now); + let value = valueAt(timer, source.updatedAt, now); + // A running card must never tick backwards: an anchor correction or + // clock-offset wobble can re-place a second boundary by a few + // hundred ms, and a digit that regresses reads as broken. A small + // step against the direction of travel holds the shown value (time + // re-passes it within the jitter bound); a big one is a real + // reset/seek and snaps. + if (timer.state === 'running' && value !== null) { + const dir = timer.kind === 'countdown' ? -1 : 1; + const prev = card.mono; + if (prev && (value - prev) * dir < 0 && Math.abs(value - prev) < 1500) value = prev; + card.mono = value; + } else { + card.mono = null; + } text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' }); tone = toneFor(timer, value); statusText = timer.status?.text || defaultStatus(timer, value, tone); diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index 5e70595..2feb0d7 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -1,7 +1,7 @@ { "id": "propresenter-timers", "name": "Timers", - "version": "1.4.1", + "version": "1.4.2", "proddash": ">=1.4.0", "description": "Timers, Clocks, and LTC From configured Modules", "provides": [ diff --git a/modules/propresenter-timers/phase.js b/modules/propresenter-timers/phase.js new file mode 100644 index 0000000..b5f2ba1 --- /dev/null +++ b/modules/propresenter-timers/phase.js @@ -0,0 +1,113 @@ +'use strict'; + +/** + * Phase lock for whole-second timer reports (the ProPresenter poller). + * + * ProPresenter reports a running timer as whole seconds — trunc(value): + * "12:34" means the true remaining time is anywhere in [754 s, 755 s). The + * old poller re-anchored a tile's local count at the bottom of that interval + * on every poll, so the displayed digit ran up to a second ahead and then + * got yanked back a step whenever a fresh poll happened to sample the next + * flip later than the last one did — the poll clock and ProPresenter's + * second boundaries drift through each other, so tiles flickered + * "…12:33 → 12:34 → 12:33…" about once a second. + * + * This tracker turns the quantized reports into ONE stable anchor + * { valueMs, at } — the continuous value at a fixed instant — which the + * client extrapolates from. Rules: + * + * - The only real information in a poll is a FLIP: the report stepping one + * second in the direction of travel. At the flip instant the true value + * equals the interval boundary just crossed, and the flip happened between + * the last two polls, so their midpoint dates it to within half a poll. + * - While a poll's report matches what the anchor predicts (within TOL_MS + * around the reported second's interval), the anchor HOLDS — the same + * numbers are republished, so consumers count along one steady clock + * instead of a freshly re-phased one per sample. + * - A report the anchor can't explain re-anchors: at the flip boundary when + * this poll saw a clean one-second flip, else mid-interval (first sight, + * an operator reset/seek, a resume, or clock drift past TOL_MS). + * - Before the first flip the anchor sits mid-interval: at most ±½ s off, + * and the first flip (≤1 s away) locks it — instead of the old guaranteed + * whole-second bias one way. + * + * trunc() semantics also cover overrun: a countdown at −12.4 s reports + * "-12", so a negative countdown's interval is (W−1 s, W] and its flip + * boundary is W itself, where a positive countdown's are [W, W+1 s) and + * W+1 s. Around zero ("00:00:00" covers (−1 s, 1 s)) the flip in from above + * crosses +1 s. + */ + +/** How far a report may sit outside the anchor's prediction before it means + something real. A flip is dated to the midpoint of two polls, so its own + error bound is ±½ poll (~±300 ms at the 500 ms cadence, more when a poll + runs long); the tolerance must sit ABOVE that or consecutive flips + re-anchor each other forever, and safely below a full second so a real + one-second seek still registers. */ +const TOL_MS = 450; + +/** The continuous-value interval implied by a whole-second report. */ +function intervalFor(kind, wholeMs) { + if (kind === 'countdown') { + if (wholeMs === 0) return { lo: -1000, hi: 1000 }; + if (wholeMs < 0) return { lo: wholeMs - 1000, hi: wholeMs }; + } + return { lo: wholeMs, hi: wholeMs + 1000 }; +} + +/** The continuous value at the instant a running timer's report flips TO wholeMs. */ +function boundaryFor(kind, wholeMs) { + if (kind === 'countdown') return wholeMs <= 0 ? intervalFor(kind, wholeMs).hi : wholeMs + 1000; + return wholeMs; +} + +/** + * One tracker per timer. Feed one observation per poll; get back what to + * publish: `{ valueMs, asOf }` — a continuous value at a fixed instant, + * stable across polls while the reports stay consistent with it. Not + * running (or no value): the report is returned verbatim, dated at the poll. + */ +function createTimerPhase() { + let anchor = null; // { valueMs, at } + let kind = ''; + let lastWhole = null; + let lastAt = 0; + let wasRunning = false; + + function observe({ kind: rawKind, running, wholeMs, pollAt }) { + const k = rawKind === 'countdown' ? 'countdown' : 'elapsed'; + const dir = k === 'countdown' ? -1 : 1; + const flip = Boolean(running) && wasRunning && k === kind && lastAt > 0 + && lastWhole !== null && wholeMs !== null + && wholeMs - lastWhole === dir * 1000; + const prevAt = lastAt; + lastWhole = wholeMs; + lastAt = pollAt; + wasRunning = Boolean(running); + if (k !== kind) anchor = null; + kind = k; + if (!running || wholeMs === null) { + anchor = null; + return { valueMs: wholeMs, asOf: pollAt }; + } + const { lo, hi } = intervalFor(k, wholeMs); + // A mid-interval anchor is provisional — a guess up to ±½ s off that the + // tolerance check may fail to catch for a long time (its error only + // shows at a poll landing more than TOL_MS past a flip). The first + // clean flip is strictly better evidence (±½ poll), so it always wins. + if (anchor && !(anchor.provisional && flip)) { + const predicted = anchor.valueMs + dir * (pollAt - anchor.at); + if (predicted >= lo - TOL_MS && predicted <= hi + TOL_MS) { + return { valueMs: anchor.valueMs, asOf: anchor.at }; + } + } + anchor = flip + ? { valueMs: boundaryFor(k, wholeMs), at: (prevAt + pollAt) / 2, provisional: false } + : { valueMs: (lo + hi) / 2, at: pollAt, provisional: true }; + return { valueMs: anchor.valueMs, asOf: anchor.at }; + } + + return { observe }; +} + +module.exports = { createTimerPhase, TOL_MS }; diff --git a/modules/propresenter-timers/phase.selftest.js b/modules/propresenter-timers/phase.selftest.js new file mode 100644 index 0000000..f434113 --- /dev/null +++ b/modules/propresenter-timers/phase.selftest.js @@ -0,0 +1,205 @@ +'use strict'; + +/** + * Self-test for phase.js — the whole-second phase lock — plus the client's + * never-tick-backwards guard, driven by a simulated ProPresenter: + * + * node modules/propresenter-timers/phase.selftest.js + * + * The simulation runs a true continuous timer, reports it the way + * ProPresenter does (truncated whole seconds, sampled every ~500 ms with + * jitter), feeds the reports through createTimerPhase(), then renders the + * displayed value at 25 ms steps exactly the way client.js does (extrapolate + * from { valueMs, asOf }, clamp regressions under 1.5 s). Asserts: + * + * - the displayed digits NEVER step against the direction of travel + * (the reported bug: countdown tiles flicking back to the previous + * second about once a second), + * - each displayed second dwells ~1 s, + * - the published anchor stays within tolerance of the true value, + * - operator resets/seeks snap within a poll or two, + * - pause → resume re-locks, + * - a ProPresenter whose clock drifts against the server's stays locked. + */ + +const { createTimerPhase, TOL_MS } = require('./phase'); + +let checks = 0; +let failures = 0; +function ok(name, cond, detail) { + checks += 1; + if (!cond) { + failures += 1; + console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`); + } +} + +/** Deterministic pseudo-random (so a failure reproduces). */ +function makeRand(seed) { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 2 ** 32; + }; +} + +/** ProPresenter's report: whole seconds, truncated toward zero. */ +const trunc = (ms) => Math.trunc(ms / 1000) * 1000; + +/** + * Run one scenario. `trueValueAt(t)` is the continuous value (countdown + * remaining or elapsed) on the SERVER's clock; null = not reported (paused + * timers report a frozen value via `frozen`). + */ +function simulate({ name, kind, seed, durationMs, trueValueAt, runningAt = () => true, pollMs = 500, jitterMs = 120 }) { + const rand = makeRand(seed); + const phase = createTimerPhase(); + const dir = kind === 'countdown' ? -1 : 1; + + const polls = []; + for (let t = 0; t <= durationMs;) { + const running = runningAt(t); + const value = trueValueAt(t); + polls.push({ t, out: phase.observe({ kind, running, wholeMs: value === null ? null : trunc(value), pollAt: t }), running }); + t += pollMs + Math.floor(rand() * jitterMs); + } + + // Render like client.js: between polls, extrapolate from the latest + // anchor; clamp sub-1.5 s regressions while running. + const shown = []; // { t, sec, err } + let mono = null; + let p = 0; + for (let t = polls[0].t; t <= durationMs; t += 25) { + while (p + 1 < polls.length && polls[p + 1].t <= t) p += 1; + const { out, running } = polls[p]; + if (!running || out.valueMs === null) { + mono = null; + continue; + } + let value = out.valueMs + dir * (t - out.asOf); + if (mono !== null && (value - mono) * dir < 0 && Math.abs(value - mono) < 1500) value = mono; + mono = value; + const truth = trueValueAt(t); + shown.push({ t, sec: Math.trunc(value / 1000), value, err: truth === null ? 0 : value - truth }); + } + return { name, dir, shown }; +} + +/** Assertions shared by the steady scenarios. `allowOdd` admits dwell pairs + from legitimate corrections (absorbing clock drift re-places a boundary + by up to TOL_MS once in a while). */ +function assertSteady({ name, dir, shown }, { settleMs = 1600, maxErr = 700, allowOdd = 0 } = {}) { + let regressions = 0; + let maxAbsErr = 0; + const changes = []; + for (let i = 1; i < shown.length; i += 1) { + const step = shown[i].sec - shown[i - 1].sec; + if (step !== 0 && Math.sign(step) !== dir) regressions += 1; + if (step !== 0) changes.push(shown[i].t); + if (shown[i].t - shown[0].t > settleMs) maxAbsErr = Math.max(maxAbsErr, Math.abs(shown[i].err)); + } + // Whole dwells only (drop the partial first and trailing segments), after + // the lock settles — the first observed flip corrects the provisional + // mid-interval anchor once, stretching or squeezing a boundary in the + // timer's first moments, which no one can see on a wall. A countdown + // crossing zero legitimately shows "0" for ~2 s — trunc keeps the digit + // for the whole (−1 s, +1 s) span, like ProPresenter's own display — so + // allow one ~2 s dwell; everything else must be ~1 s. + const settled = shown.length ? shown[0].t + 2500 : 0; + const dwells = changes.slice(1).map((t, i) => t - changes[i]).filter((d, i) => changes[i + 1] > settled + d); + const near = (d, target) => Math.abs(d - target) <= 200; + const long = dwells.filter((d) => near(d, 2000)).length; + const bad = dwells.filter((d) => !near(d, 1000) && !near(d, 2000)).length; + ok(`${name}: no digit ever steps backwards`, regressions === 0, `${regressions} regressions`); + ok(`${name}: seconds dwell ~1 s (${dwells.length} sampled)`, dwells.length > 5 && bad <= allowOdd && long <= 1, + `${bad} odd dwells, ${long} ~2 s dwells [${dwells.join(',')}]`); + ok(`${name}: locked within ${maxErr} ms of the true value`, maxAbsErr <= maxErr, `max ${Math.round(maxAbsErr)} ms`); +} + +/* 1. Plain countdown, many sub-second phases and poll seeds. */ +for (let i = 0; i < 12; i += 1) { + const phase0 = (i * 83) % 1000; + const run = simulate({ + name: `countdown q=${phase0}ms seed=${i}`, + kind: 'countdown', + seed: 1000 + i, + durationMs: 30000, + trueValueAt: (t) => 20000 + phase0 - t, // runs through zero into overrun + }); + assertSteady(run); +} + +/* 2. Elapsed timer (counts up). */ +for (let i = 0; i < 6; i += 1) { + const run = simulate({ + name: `elapsed seed=${i}`, + kind: 'elapsed', + seed: 2000 + i, + durationMs: 25000, + trueValueAt: (t) => 400 + i * 137 + t, + }); + assertSteady(run); +} + +/* 3. Operator reset mid-run: value jumps back up to full length. */ +{ + const run = simulate({ + name: 'reset', + kind: 'countdown', + seed: 7, + durationMs: 24000, + trueValueAt: (t) => (t < 12000 ? 15000 - t : 60000 - (t - 12000)), + }); + // After the reset the shown value must reach the new region within ~2 polls. + const after = run.shown.filter((s) => s.t >= 13400); + ok('reset: snaps to the new value', after.length && after.every((s) => Math.abs(s.err) <= 1300), after.length ? `err ${Math.round(Math.max(...after.map((s) => Math.abs(s.err))))} ms` : 'no samples'); + // Before and after the jump, digits stay monotonic within each phase. + const pre = { ...run, shown: run.shown.filter((s) => s.t < 12000) }; + assertSteady(pre, { maxErr: 700 }); +} + +/* 4. Pause, then resume: no stale-anchor explosion. */ +{ + const frozen = 8000; + const run = simulate({ + name: 'pause/resume', + kind: 'countdown', + seed: 11, + durationMs: 26000, + trueValueAt: (t) => (t < 6000 ? 14000 - t : t < 14000 ? frozen : frozen - (t - 14000)), + runningAt: (t) => t < 6000 || t >= 14000, + }); + const resumed = { ...run, shown: run.shown.filter((s) => s.t >= 14000) }; + assertSteady(resumed); +} + +/* 5. ProPresenter's clock drifts 0.2% against the server's: the anchor + re-locks over time instead of accumulating a visible error. */ +{ + const run = simulate({ + name: 'clock drift', + kind: 'countdown', + seed: 13, + durationMs: 120000, + trueValueAt: (t) => 130000 - t * 1.002, + }); + // Drift accumulates ~2 ms/s; every ~4 min the anchor re-places a boundary + // by up to TOL_MS to absorb it — a slightly long/short dwell pair, never + // a backwards step (the separate regression check still demands zero). + assertSteady(run, { maxErr: 700 + TOL_MS, allowOdd: 2 }); +} + +/* 6. Idle timers pass through untouched. */ +{ + const phase = createTimerPhase(); + const a = phase.observe({ kind: 'countdown', running: false, wholeMs: 300000, pollAt: 1000 }); + ok('idle passes through', a.valueMs === 300000 && a.asOf === 1000); + const b = phase.observe({ kind: 'countdown', running: false, wholeMs: null, pollAt: 1500 }); + ok('missing value passes through', b.valueMs === null && b.asOf === 1500); +} + +if (failures) { + console.error(`\n${failures} of ${checks} checks FAILED`); + process.exit(1); +} +console.log(`phase.selftest: all ${checks} checks passed`); diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index 5b1dba6..b41eb88 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -82,6 +82,7 @@ const http = require('http'); const path = require('path'); const { spawn } = require('child_process'); const { createLtcListener } = require('./ltc-listener'); +const { createTimerPhase } = require('./phase'); const manifest = require('./module.json'); /** This module's id — left out of provider discovery (we don't consume ourselves). */ @@ -209,6 +210,7 @@ function sanitizeTimer(t) { label: String(t.label || id).slice(0, 120), kind: KINDS.has(t.kind) ? t.kind : 'elapsed', state: STATES.has(t.state) ? t.state : 'stopped', + asOf: numOrNull(t.asOf), startedAt: numOrNull(t.startedAt), elapsedMs: numOrNull(t.elapsedMs), remainingMs: numOrNull(t.remainingMs), @@ -250,6 +252,8 @@ function createProPresenterSource({ config, log }) { let updatedAt = 0; /** uuid → { name, index, type, allowsOverrun, targetMs, detail } from /v1/timers, in list order. */ let timerConfigs = new Map(); + /** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */ + const phases = new Map(); function url(target) { const built = new URL(`http://${host}:${port}${target}`); @@ -325,9 +329,21 @@ function createProPresenterSource({ config, log }) { const atStart = value === null || (kind === 'countdown' ? targetMs !== null && value === targetMs : value === 0); state = atStart ? 'idle' : 'paused'; } - const elapsedMs = kind === 'elapsed' ? value : targetMs !== null && value !== null ? targetMs - value : null; - const remainingMs = kind === 'countdown' ? value : targetMs !== null && value !== null ? targetMs - value : null; - const startedAt = state === 'running' && elapsedMs !== null ? now - elapsedMs : null; + // ProPresenter reports whole (truncated) seconds. Re-anchoring the tiles' + // local count at the bottom of that second every poll made the digits run + // ahead and flick back a step about once a second — phase.js turns the + // reports into one stable anchor { valueMs, asOf } the tiles count from. + let phase = phases.get(uuid); + if (!phase) { + phase = createTimerPhase(); + phases.set(uuid, phase); + } + const anchored = phase.observe({ kind, running: state === 'running', wholeMs: value, pollAt: now }); + const v = anchored.valueMs; + const asOf = anchored.asOf; + const elapsedMs = kind === 'elapsed' ? v : targetMs !== null && v !== null ? targetMs - v : null; + const remainingMs = kind === 'countdown' ? v : targetMs !== null && v !== null ? targetMs - v : null; + const startedAt = state === 'running' && elapsedMs !== null ? asOf - elapsedMs : null; const text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped'; const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted'; return { @@ -335,6 +351,7 @@ function createProPresenterSource({ config, log }) { label: (live && live.name) || (cfg && cfg.name) || 'Timer', kind, state, + asOf, startedAt, elapsedMs, remainingMs, @@ -367,6 +384,9 @@ function createProPresenterSource({ config, log }) { // Timers reported live but missing from the (possibly stale) config // list still deserve a card — type unknown until the slow lane catches up. for (const [uuid, now_] of live) out.push(toTimer(uuid, null, now_, now)); + for (const uuid of phases.keys()) { + if (!out.some((t) => t.id === uuid)) phases.delete(uuid); // timer deleted in ProPresenter + } return out; } @@ -992,12 +1012,17 @@ module.exports = { for (const t of v.timers) { const out = { ...t, id: `${v.id}:${t.id}`, group: v.label }; if (v.id === 'clock') out.status = { text: new Date(now).toISOString(), tone: 'ok' }; - if (t.state === 'running' && v.updatedAt > 0) { - const dt = Math.max(0, now - v.updatedAt); + // A timer measured at its own instant (asOf — a phase-locked + // ProPresenter value) re-anchors from there; the rest from the + // source snapshot. Consumers see plain contract values as of `now`. + const at = Number(out.asOf) || v.updatedAt; + if (t.state === 'running' && at > 0) { + const dt = Math.max(0, now - at); if (out.elapsedMs !== null && out.elapsedMs !== undefined) out.elapsedMs += dt; if (out.remainingMs !== null && out.remainingMs !== undefined) out.remainingMs -= dt; if (out.ltc && Number.isFinite(out.ltc.ageMs)) out.ltc = { ...out.ltc, ageMs: out.ltc.ageMs + dt }; } + delete out.asOf; timers.push(out); } }