114 lines
5.0 KiB
JavaScript
114 lines
5.0 KiB
JavaScript
'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 };
|