Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5c539ba05 |
@@ -14,6 +14,8 @@ modules/propresenter-timers/ltc-capture
|
||||
# the launcher's build output, and its cache of the Node runtime it embeds
|
||||
launcher/macos/build/
|
||||
launcher/macos/.cache/
|
||||
launcher/windows/build/
|
||||
launcher/windows/.cache/
|
||||
|
||||
# Compiled per-machine, source sits next to it (see spl-capture.swift).
|
||||
modules/spl-meter/spl-capture
|
||||
|
||||
@@ -30,9 +30,19 @@ and a clear degraded state, then recover on their own.
|
||||
|
||||
## Run it
|
||||
|
||||
Ready-made builds are on the
|
||||
[Releases page](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash/releases);
|
||||
the files themselves live in
|
||||
[ProdDash-releases](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash-releases).
|
||||
There is a disk image for Apple silicon Macs, one for Intel Macs, and a
|
||||
bundle for 64-bit Windows. Every build updates itself afterwards from the
|
||||
admin page, so the download only has to be recent, not current.
|
||||
|
||||
### macOS — the app
|
||||
|
||||
Open the disk image and drag **ProdDash** to Applications. That's the whole
|
||||
Download the disk image for your Mac — `ProdDash-<version>-arm64.dmg` for
|
||||
Apple silicon, `ProdDash-<version>-x86_64.dmg` for Intel — open it and drag
|
||||
**ProdDash** to Applications. That's the whole
|
||||
installation: the app carries the dashboard and its own Node runtime, so
|
||||
nothing else has to be installed and nothing can be missing. It lives in the
|
||||
menu bar, starts and stops the server, can open at login, and holds the
|
||||
@@ -43,12 +53,26 @@ a launchd job) simply doesn't have them, and an LTC input goes silently to
|
||||
zeroes.
|
||||
|
||||
Build the disk image from a checkout with
|
||||
[launcher/macos](launcher/macos)/`build.sh`:
|
||||
[launcher/macos](launcher/macos)/`build.sh` (`ARCH=x86_64` in front of it
|
||||
for Intel):
|
||||
|
||||
```bash
|
||||
cd launcher/macos && ./build.sh --dmg
|
||||
```
|
||||
|
||||
### Windows — the bundle
|
||||
|
||||
Download `ProdDash-<version>-win-x64.zip`, unzip it somewhere you can write
|
||||
to — `C:\ProdDash`, say, not Program Files, because updates write into the
|
||||
folder — and double-click **ProdDash.bat**. The window it opens is the
|
||||
dashboard running: it stays open, and it starts the server again when the
|
||||
admin page applies an update. Allow `node.exe` on private networks when the
|
||||
firewall asks, so other screens can reach it. Settings and layouts live in
|
||||
`%APPDATA%\ProdDash`, so a new bundle never loses them. To start with
|
||||
Windows, put a shortcut to `ProdDash.bat` in `shell:startup`. There is no
|
||||
menu-bar-style launcher for Windows yet; the bundle is built with
|
||||
[launcher/windows](launcher/windows)/`build.sh`.
|
||||
|
||||
### Anywhere else, or from a checkout
|
||||
|
||||
Requires [Node.js](https://nodejs.org) 18 or newer.
|
||||
|
||||
@@ -684,7 +684,6 @@ 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. |
|
||||
|
||||
@@ -51,6 +51,7 @@ the cycle rather than spinning forever.
|
||||
```bash
|
||||
./build.sh # build/ProdDash.app — the whole application
|
||||
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg
|
||||
ARCH=x86_64 ./build.sh --dmg # the same for Intel Macs (…-x86_64.dmg)
|
||||
./build.sh --install # …and copy it to /Applications
|
||||
./build.sh --run # …and open it
|
||||
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
|
||||
|
||||
+10
-3
@@ -11,6 +11,8 @@
|
||||
# ./build.sh --dev launcher only — no Node, no server payload, so
|
||||
# it runs against the checkout. Seconds, not
|
||||
# minutes, while working on the launcher itself.
|
||||
# ARCH=x86_64 ./build.sh the same for Intel Macs (default: arm64, Apple
|
||||
# silicon); the Node it embeds follows.
|
||||
#
|
||||
# Signing: a "Developer ID Application" certificate is used when one is
|
||||
# installed (override with CODESIGN_IDENTITY). Without one the app is signed
|
||||
@@ -27,8 +29,13 @@ LAUNCHER_VERSION="1.0.0"
|
||||
# The Node the app ships. Bump it here; the download is checksum-verified
|
||||
# against nodejs.org's own SHASUMS256.txt and cached in .cache/.
|
||||
NODE_VERSION="v24.21.0"
|
||||
ARCH="arm64"
|
||||
ARCH="${ARCH:-arm64}" # arm64 (Apple silicon) or x86_64 (Intel)
|
||||
DEPLOYMENT_TARGET="13.0"
|
||||
case "$ARCH" in
|
||||
arm64) NODE_ARCH="arm64" ;;
|
||||
x86_64) NODE_ARCH="x64" ;; # nodejs.org's name for it
|
||||
*) echo "build.sh: ARCH must be arm64 or x86_64, not $ARCH" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
REPO_ROOT="../.."
|
||||
OUT_DIR="build"
|
||||
@@ -106,7 +113,7 @@ fi
|
||||
|
||||
# ── the server, and the Node that runs it ────────────────────────────────
|
||||
if [[ $DEV -eq 0 ]]; then
|
||||
TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz"
|
||||
TARBALL="node-$NODE_VERSION-darwin-$NODE_ARCH.tar.gz"
|
||||
mkdir -p "$CACHE_DIR"
|
||||
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
|
||||
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
|
||||
@@ -125,7 +132,7 @@ if [[ $DEV -eq 0 ]]; then
|
||||
echo " embedding Node $NODE_VERSION"
|
||||
mkdir -p "$CONTENTS/Resources/node/bin"
|
||||
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
|
||||
"node-$NODE_VERSION-darwin-$ARCH/bin/node"
|
||||
"node-$NODE_VERSION-darwin-$NODE_ARCH/bin/node"
|
||||
|
||||
echo " embedding ProdDash $VERSION"
|
||||
mkdir -p "$PAYLOAD/config"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
#!/bin/zsh
|
||||
# Build the Windows bundle — ProdDash-<version>-win-x64.zip: the dashboard,
|
||||
# its own Node runtime and a start script, so nothing has to be installed on
|
||||
# the Windows machine. Unzip, double-click ProdDash.bat, done.
|
||||
#
|
||||
# ./build.sh build/ProdDash-<version>-win-x64.zip
|
||||
#
|
||||
# Builds on macOS or Linux (zsh, curl, shasum, unzip, zip). Node for Windows
|
||||
# comes from nodejs.org, checksum-verified against its SHASUMS256.txt and
|
||||
# cached in .cache/. There is no Windows launcher app yet: ProdDash.bat keeps
|
||||
# the server running and starts it again when the admin page applies an
|
||||
# update (the server exits with code 75 to ask for that).
|
||||
set -e -u
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
APP_NAME="ProdDash"
|
||||
# The Node the bundle ships — keep in step with launcher/macos/build.sh.
|
||||
NODE_VERSION="v24.21.0"
|
||||
NODE_DIST="node-$NODE_VERSION-win-x64"
|
||||
|
||||
REPO_ROOT="../.."
|
||||
OUT_DIR="build"
|
||||
CACHE_DIR=".cache"
|
||||
|
||||
for tool in curl shasum unzip zip; do
|
||||
command -v "$tool" >/dev/null || { echo "build.sh: $tool not found" >&2; exit 1; }
|
||||
done
|
||||
|
||||
VERSION=$(sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p' "$REPO_ROOT/package.json" | head -1)
|
||||
[[ -n "$VERSION" ]] || { echo "build.sh: can't read the version from package.json" >&2; exit 1; }
|
||||
NAME="$APP_NAME-$VERSION-win-x64"
|
||||
STAGE="$OUT_DIR/$NAME"
|
||||
|
||||
echo "ProdDash $VERSION — building the Windows bundle"
|
||||
rm -rf "$OUT_DIR"
|
||||
mkdir -p "$STAGE/node" "$STAGE/config" "$CACHE_DIR"
|
||||
|
||||
# ── Node for Windows ─────────────────────────────────────────────────────
|
||||
ZIPFILE="$NODE_DIST.zip"
|
||||
if [[ ! -f "$CACHE_DIR/$ZIPFILE" ]]; then
|
||||
echo " fetching Node $NODE_VERSION for Windows (~30 MB, cached for next time)"
|
||||
curl -fsSL --retry 3 -o "$CACHE_DIR/$ZIPFILE.part" "https://nodejs.org/dist/$NODE_VERSION/$ZIPFILE"
|
||||
mv "$CACHE_DIR/$ZIPFILE.part" "$CACHE_DIR/$ZIPFILE"
|
||||
fi
|
||||
SUMS="$CACHE_DIR/SHASUMS256-$NODE_VERSION.txt"
|
||||
[[ -f "$SUMS" ]] || curl -fsSL --retry 3 -o "$SUMS" "https://nodejs.org/dist/$NODE_VERSION/SHASUMS256.txt"
|
||||
# This binary ships under ProdDash's name — check it every build.
|
||||
WANT=$(awk -v f="$ZIPFILE" '$2 == f {print $1}' "$SUMS")
|
||||
GOT=$(shasum -a 256 "$CACHE_DIR/$ZIPFILE" | awk '{print $1}')
|
||||
if [[ -z "$WANT" || "$WANT" != "$GOT" ]]; then
|
||||
echo "build.sh: $ZIPFILE does not match nodejs.org's checksum — refusing to ship it" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " embedding Node $NODE_VERSION (node.exe and its licence)"
|
||||
unzip -q -j "$CACHE_DIR/$ZIPFILE" "$NODE_DIST/node.exe" "$NODE_DIST/LICENSE" -d "$STAGE/node"
|
||||
|
||||
# ── ProdDash ─────────────────────────────────────────────────────────────
|
||||
echo " embedding ProdDash $VERSION"
|
||||
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$STAGE/"
|
||||
cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$STAGE/"
|
||||
# Only the checked-in defaults: config/ also holds this machine's own
|
||||
# modules.json and layouts when ProdDash has ever run from the checkout.
|
||||
cp "$REPO_ROOT/config/proddash.json" "$STAGE/config/"
|
||||
find "$STAGE" -name '.DS_Store' -delete
|
||||
|
||||
# ── the start script and the read-me, with Windows line endings ──────────
|
||||
crlf() { awk '{ printf "%s\r\n", $0 }' > "$1"; }
|
||||
|
||||
crlf "$STAGE/ProdDash.bat" <<'BAT'
|
||||
@echo off
|
||||
rem ProdDash - double-click to start. Keep this window open: closing it stops the dashboard.
|
||||
title ProdDash
|
||||
cd /d "%~dp0"
|
||||
rem The server exits with code 75 after applying an update from the admin page; start it again.
|
||||
set PRODDASH_LAUNCHER=1
|
||||
:run
|
||||
"%~dp0node\node.exe" server.js
|
||||
set CODE=%ERRORLEVEL%
|
||||
if %CODE% EQU 75 goto run
|
||||
echo.
|
||||
echo ProdDash stopped (exit code %CODE%). Press any key to close this window.
|
||||
pause >nul
|
||||
BAT
|
||||
|
||||
crlf "$STAGE/README.txt" <<README
|
||||
ProdDash $VERSION for Windows (64-bit)
|
||||
=======================================
|
||||
|
||||
Nothing to install. This folder holds the dashboard and its own copy of Node.
|
||||
|
||||
1. Move this folder somewhere you can write to, for example C:\ProdDash
|
||||
(not Program Files: updates write into this folder).
|
||||
2. Double-click ProdDash.bat. A window opens and stays open while the
|
||||
dashboard runs; closing it stops the dashboard.
|
||||
- If Windows warns about the file, choose More info, then Run anyway.
|
||||
- When Windows Defender Firewall asks about node.exe, allow it on
|
||||
private networks so other screens can reach the dashboard.
|
||||
3. Open http://localhost:24500 on this PC. Other devices on the network use
|
||||
http://<this PC's IP address>:24500. Settings are on the admin page,
|
||||
http://localhost:24500/admin.
|
||||
|
||||
Your settings and layouts live in %APPDATA%\ProdDash, not in this folder,
|
||||
so unzipping a new version or updating never loses them.
|
||||
|
||||
Start with Windows: press Win+R, type shell:startup, press Enter, and put a
|
||||
shortcut to ProdDash.bat in the folder that opens.
|
||||
|
||||
Updates: the admin page's Update now fetches the new version into this
|
||||
folder and restarts the dashboard; the window stays open. A copy of the
|
||||
version it replaced is kept for a short while in case the new one will not
|
||||
start.
|
||||
README
|
||||
|
||||
# ── zip ──────────────────────────────────────────────────────────────────
|
||||
echo " zipping"
|
||||
(cd "$OUT_DIR" && zip -q -r -X "$NAME.zip" "$NAME")
|
||||
echo "built $OUT_DIR/$NAME.zip ($(du -sh "$OUT_DIR/$NAME.zip" | awk '{print $1}'))"
|
||||
@@ -123,13 +123,10 @@ 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(timer.asOf) || Number(sourceUpdatedAt) || now;
|
||||
const at = 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') {
|
||||
@@ -650,21 +647,7 @@ export default function create({ root, moduleApi }) {
|
||||
statusTone = timer.status?.tone || 'muted';
|
||||
}
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
const value = valueAt(timer, source.updatedAt, now);
|
||||
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
||||
tone = toneFor(timer, value);
|
||||
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "propresenter-timers",
|
||||
"name": "Timers",
|
||||
"version": "1.4.2",
|
||||
"version": "1.4.1",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||
"provides": [
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
'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 };
|
||||
@@ -1,205 +0,0 @@
|
||||
'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`);
|
||||
@@ -82,7 +82,6 @@ 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). */
|
||||
@@ -210,7 +209,6 @@ 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),
|
||||
@@ -252,8 +250,6 @@ 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}`);
|
||||
@@ -329,21 +325,9 @@ function createProPresenterSource({ config, log }) {
|
||||
const atStart = value === null || (kind === 'countdown' ? targetMs !== null && value === targetMs : value === 0);
|
||||
state = atStart ? 'idle' : 'paused';
|
||||
}
|
||||
// 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 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;
|
||||
const text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped';
|
||||
const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted';
|
||||
return {
|
||||
@@ -351,7 +335,6 @@ function createProPresenterSource({ config, log }) {
|
||||
label: (live && live.name) || (cfg && cfg.name) || 'Timer',
|
||||
kind,
|
||||
state,
|
||||
asOf,
|
||||
startedAt,
|
||||
elapsedMs,
|
||||
remainingMs,
|
||||
@@ -384,9 +367,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1012,17 +992,12 @@ 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' };
|
||||
// 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 (t.state === 'running' && v.updatedAt > 0) {
|
||||
const dt = Math.max(0, now - v.updatedAt);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user