Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8843339f11 | ||
|
|
5f60cd563c | ||
|
|
de32046553 | ||
|
|
2f4c357bf5 |
@@ -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). |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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`. |
|
| `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. |
|
| `detail` | Optional second line. |
|
||||||
|
|||||||
@@ -123,10 +123,13 @@ export function framesToTc(n, nominal, df) {
|
|||||||
* elapsed ms otherwise, or null when the snapshot can't say. The values as
|
* elapsed ms otherwise, or null when the snapshot can't say. The values as
|
||||||
* of the snapshot (`elapsedMs` / `remainingMs`) are preferred and carried
|
* of the snapshot (`elapsedMs` / `remainingMs`) are preferred and carried
|
||||||
* forward while running; `startedAt` is the fallback when they are absent.
|
* 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) {
|
export function valueAt(timer, sourceUpdatedAt, now) {
|
||||||
const running = timer.state === 'running';
|
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 dt = running ? Math.max(0, now - at) : 0;
|
||||||
const has = (v) => v !== null && v !== undefined && Number.isFinite(v);
|
const has = (v) => v !== null && v !== undefined && Number.isFinite(v);
|
||||||
if (timer.kind === 'countdown') {
|
if (timer.kind === 'countdown') {
|
||||||
@@ -647,7 +650,21 @@ export default function create({ root, moduleApi }) {
|
|||||||
statusTone = timer.status?.tone || 'muted';
|
statusTone = timer.status?.tone || 'muted';
|
||||||
}
|
}
|
||||||
} else {
|
} 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' });
|
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
|
||||||
tone = toneFor(timer, value);
|
tone = toneFor(timer, value);
|
||||||
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
statusText = timer.status?.text || defaultStatus(timer, value, tone);
|
||||||
|
|||||||
@@ -10,13 +10,23 @@
|
|||||||
* onUpdate roughly 4×/s:
|
* onUpdate roughly 4×/s:
|
||||||
*
|
*
|
||||||
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
|
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
|
||||||
* { error: '<what is wrong and how to fix it>' } not capturing
|
* { error: '<short, for the tiles>', detail: '<for admin/log>' } not capturing
|
||||||
*
|
*
|
||||||
* The capture binary is per-machine (gitignored): if it is missing this
|
* Error strings are two-tier on purpose: `error` is what a dashboard tile
|
||||||
* tries one swiftc compile of the source next to it, and otherwise says to
|
* shows (short — "Not configured — see Admin"), `detail` is the full story
|
||||||
* build it on any same-architecture Mac and copy it over. A dead child is
|
* for the admin health line and the log.
|
||||||
* restarted with backoff — fast for a crash, slow for config-shaped
|
*
|
||||||
* failures (unknown device, ambiguous name) that never fix themselves.
|
* The capture binary is per-machine (gitignored), and a catalog install or
|
||||||
|
* update ships source only — the update even wipes a previously built
|
||||||
|
* binary. ensureCaptureBinary() therefore rebuilds it with swiftc in the
|
||||||
|
* background as part of coming up: kicked when the LTC source starts and by
|
||||||
|
* the admin device picker, never on the event loop (a blocking build once
|
||||||
|
* froze the whole server — see spl-meter's history), built to a temp name
|
||||||
|
* and renamed so a killed build can't leave a half-written "binary", and
|
||||||
|
* rebuilt when a module update leaves the source newer than the binary.
|
||||||
|
* A dead capture child is restarted with backoff — fast for a crash, slow
|
||||||
|
* for config-shaped failures (unknown device, ambiguity) that never fix
|
||||||
|
* themselves.
|
||||||
*
|
*
|
||||||
* macOS gotcha this file can't fix: microphone permission. A ProdDash
|
* macOS gotcha this file can't fix: microphone permission. A ProdDash
|
||||||
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
||||||
@@ -27,7 +37,7 @@
|
|||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { spawn, spawnSync } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
|
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
|
||||||
|
|
||||||
const RESTART_MS = 5000;
|
const RESTART_MS = 5000;
|
||||||
@@ -41,6 +51,100 @@ const RUNNING_WINDOW_MS = 400;
|
|||||||
/** stderr line the capture prints once the device is open. */
|
/** stderr line the capture prints once the device is open. */
|
||||||
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
|
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
|
||||||
|
|
||||||
|
/* ── the capture tool: build it when the install didn't bring one ──────── */
|
||||||
|
|
||||||
|
/** Tile-facing words (short); the detail goes to admin health and the log. */
|
||||||
|
const TOOL_BUILDING = 'Capture tool building…';
|
||||||
|
const TOOL_MISSING = 'Capture tool unavailable — see Admin';
|
||||||
|
const BUILD_HELP = 'LTC capture tool could not be built here — build it on any Mac of the '
|
||||||
|
+ 'same architecture with `swiftc -O -o ltc-capture ltc-capture.swift` (both files are in '
|
||||||
|
+ 'the module folder) and copy the binary next to ltc-capture.swift';
|
||||||
|
|
||||||
|
let building = false;
|
||||||
|
let buildFailed = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make sure the capture tool exists, building it in the background when it
|
||||||
|
* does not — module scope, so the LTC source can kick it the moment it
|
||||||
|
* starts and the admin device picker can kick it before any device is
|
||||||
|
* configured (a listener-only build would deadlock: no tool → no device
|
||||||
|
* list → no device → no listener). A catalog install ships source only and
|
||||||
|
* a catalog update deletes the module folder (old binary included), so this
|
||||||
|
* IS the install step for the tool.
|
||||||
|
*
|
||||||
|
* Never blocks (a synchronous build once held the whole server's event loop
|
||||||
|
* — every module — for the duration). Builds to a temp name and renames, so
|
||||||
|
* a killed or crashed build can't leave a truncated file that looks like a
|
||||||
|
* ready binary. A binary older than the source (a module update over an
|
||||||
|
* existing folder) keeps serving while a replacement builds behind it.
|
||||||
|
*
|
||||||
|
* @returns {{ready:boolean, error:string, detail:string}} `ready` = a binary
|
||||||
|
* is usable right now; otherwise `error` is the short tile text and
|
||||||
|
* `detail` the full one.
|
||||||
|
*/
|
||||||
|
function ensureCaptureBinary({ moduleDir, log, onReady }) {
|
||||||
|
const bin = path.join(moduleDir, 'ltc-capture');
|
||||||
|
const src = bin + '.swift';
|
||||||
|
const ok = { ready: true, error: '', detail: '' };
|
||||||
|
let haveBin = false;
|
||||||
|
let stale = false;
|
||||||
|
try {
|
||||||
|
haveBin = fs.existsSync(bin);
|
||||||
|
if (haveBin && fs.existsSync(src)) stale = fs.statSync(src).mtimeMs > fs.statSync(bin).mtimeMs;
|
||||||
|
} catch { /* treat as missing */ }
|
||||||
|
if (haveBin && !stale) return ok;
|
||||||
|
if (!fs.existsSync(src)) {
|
||||||
|
return haveBin ? ok : { ready: false, error: TOOL_MISSING, detail: 'ltc-capture.swift is missing from the module folder' };
|
||||||
|
}
|
||||||
|
const notYet = haveBin ? ok : {
|
||||||
|
ready: false,
|
||||||
|
error: building || !buildFailed ? TOOL_BUILDING : TOOL_MISSING,
|
||||||
|
detail: building || !buildFailed ? 'Building the LTC capture tool — ready in a moment' : BUILD_HELP,
|
||||||
|
};
|
||||||
|
if (building || buildFailed) return notYet;
|
||||||
|
|
||||||
|
building = true;
|
||||||
|
const tmp = bin + '.building';
|
||||||
|
log(stale ? 'ltc-capture is older than its source — rebuilding it in the background'
|
||||||
|
: 'ltc-capture binary missing — building it in the background');
|
||||||
|
const build = spawn('swiftc', ['-O', '-o', tmp, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
let buildErr = '';
|
||||||
|
build.stderr.setEncoding('utf8');
|
||||||
|
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||||
|
const giveUp = setTimeout(() => {
|
||||||
|
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||||
|
}, 300000);
|
||||||
|
giveUp.unref?.();
|
||||||
|
build.on('error', (err) => {
|
||||||
|
clearTimeout(giveUp);
|
||||||
|
building = false;
|
||||||
|
buildFailed = true;
|
||||||
|
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
||||||
|
log(`swiftc could not be run: ${err.message}`);
|
||||||
|
});
|
||||||
|
build.on('exit', (code) => {
|
||||||
|
clearTimeout(giveUp);
|
||||||
|
building = false;
|
||||||
|
if (code === 0 && fs.existsSync(tmp)) {
|
||||||
|
try {
|
||||||
|
fs.renameSync(tmp, bin);
|
||||||
|
} catch (err) {
|
||||||
|
buildFailed = true;
|
||||||
|
log(`ltc-capture built but could not be moved into place: ${err.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buildFailed = false;
|
||||||
|
log('ltc-capture built');
|
||||||
|
if (typeof onReady === 'function') onReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buildFailed = true;
|
||||||
|
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
|
||||||
|
log(`ltc-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||||
|
});
|
||||||
|
return notYet;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{device:string, channel:number, moduleDir:string, log:Function,
|
* @param {{device:string, channel:number, moduleDir:string, log:Function,
|
||||||
* onUpdate:(u:object)=>void}} opts
|
* onUpdate:(u:object)=>void}} opts
|
||||||
@@ -75,26 +179,21 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
return 'nosignal';
|
return 'nosignal';
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureBinary() {
|
|
||||||
if (fs.existsSync(bin)) return '';
|
|
||||||
const src = bin + '.swift';
|
|
||||||
log('ltc-capture binary missing — trying a one-time swiftc build');
|
|
||||||
const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 120000 });
|
|
||||||
if (built.status === 0 && fs.existsSync(bin)) {
|
|
||||||
log('ltc-capture compiled');
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return 'LTC capture tool missing — build it on any Apple Silicon Mac with '
|
|
||||||
+ '`swiftc -O -o modules/propresenter-timers/ltc-capture modules/propresenter-timers/ltc-capture.swift` '
|
|
||||||
+ 'and copy it here';
|
|
||||||
}
|
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
const missing = ensureBinary();
|
const tool = ensureCaptureBinary({
|
||||||
if (missing) {
|
moduleDir,
|
||||||
report({ error: missing });
|
log,
|
||||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
onReady() {
|
||||||
|
// The build this start() was waiting on landed — go now.
|
||||||
|
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!tool.ready) {
|
||||||
|
report({ error: tool.error, detail: tool.detail });
|
||||||
|
// A build in flight restarts us itself when it lands; anything else is
|
||||||
|
// config-shaped and should back off hard rather than spin.
|
||||||
|
if (tool.error !== TOOL_BUILDING) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +212,10 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
const channels = Number(m[2]);
|
const channels = Number(m[2]);
|
||||||
const rate = Number(m[3]);
|
const rate = Number(m[3]);
|
||||||
if (channel > channels) {
|
if (channel > channels) {
|
||||||
report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` });
|
report({
|
||||||
|
error: 'Channel not on this device — see Admin',
|
||||||
|
detail: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.`,
|
||||||
|
});
|
||||||
try { child.kill(); } catch { /* exiting anyway */ }
|
try { child.kill(); } catch { /* exiting anyway */ }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -135,17 +237,21 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
report({ error: `LTC capture failed to start: ${err.message}` });
|
report({ error: TOOL_MISSING, detail: `LTC capture failed to start: ${err.message}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('exit', (code) => {
|
child.on('exit', (code) => {
|
||||||
child = null;
|
child = null;
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
// The capture's own failure text (unknown device, ambiguous name,
|
// The capture's own failure text (unknown device, ambiguous name,
|
||||||
// permission hint) is the most useful thing we can show.
|
// permission hint) is the most useful DETAIL we have; the tile just
|
||||||
|
// needs to know the input is down and where to fix it.
|
||||||
const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
|
const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
|
||||||
const quickDeath = Date.now() - startedAt < 3000;
|
const quickDeath = Date.now() - startedAt < 3000;
|
||||||
report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') });
|
report({
|
||||||
|
error: quickDeath ? 'Audio device unavailable — see Admin' : 'Audio capture restarting…',
|
||||||
|
detail: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting'),
|
||||||
|
});
|
||||||
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
|
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -188,4 +294,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createLtcListener };
|
module.exports = { createLtcListener, ensureCaptureBinary };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.4.1",
|
"version": "1.5.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||||
"provides": [
|
"provides": [
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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`);
|
||||||
@@ -81,7 +81,8 @@
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
const { createLtcListener } = require('./ltc-listener');
|
const { createLtcListener, ensureCaptureBinary } = require('./ltc-listener');
|
||||||
|
const { createTimerPhase } = require('./phase');
|
||||||
const manifest = require('./module.json');
|
const manifest = require('./module.json');
|
||||||
|
|
||||||
/** This module's id — left out of provider discovery (we don't consume ourselves). */
|
/** 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),
|
label: String(t.label || id).slice(0, 120),
|
||||||
kind: KINDS.has(t.kind) ? t.kind : 'elapsed',
|
kind: KINDS.has(t.kind) ? t.kind : 'elapsed',
|
||||||
state: STATES.has(t.state) ? t.state : 'stopped',
|
state: STATES.has(t.state) ? t.state : 'stopped',
|
||||||
|
asOf: numOrNull(t.asOf),
|
||||||
startedAt: numOrNull(t.startedAt),
|
startedAt: numOrNull(t.startedAt),
|
||||||
elapsedMs: numOrNull(t.elapsedMs),
|
elapsedMs: numOrNull(t.elapsedMs),
|
||||||
remainingMs: numOrNull(t.remainingMs),
|
remainingMs: numOrNull(t.remainingMs),
|
||||||
@@ -250,6 +252,8 @@ function createProPresenterSource({ config, log }) {
|
|||||||
let updatedAt = 0;
|
let updatedAt = 0;
|
||||||
/** uuid → { name, index, type, allowsOverrun, targetMs, detail } from /v1/timers, in list order. */
|
/** uuid → { name, index, type, allowsOverrun, targetMs, detail } from /v1/timers, in list order. */
|
||||||
let timerConfigs = new Map();
|
let timerConfigs = new Map();
|
||||||
|
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
|
||||||
|
const phases = new Map();
|
||||||
|
|
||||||
function url(target) {
|
function url(target) {
|
||||||
const built = new URL(`http://${host}:${port}${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);
|
const atStart = value === null || (kind === 'countdown' ? targetMs !== null && value === targetMs : value === 0);
|
||||||
state = atStart ? 'idle' : 'paused';
|
state = atStart ? 'idle' : 'paused';
|
||||||
}
|
}
|
||||||
const elapsedMs = kind === 'elapsed' ? value : targetMs !== null && value !== null ? targetMs - value : null;
|
// ProPresenter reports whole (truncated) seconds. Re-anchoring the tiles'
|
||||||
const remainingMs = kind === 'countdown' ? value : targetMs !== null && value !== null ? targetMs - value : null;
|
// local count at the bottom of that second every poll made the digits run
|
||||||
const startedAt = state === 'running' && elapsedMs !== null ? now - elapsedMs : null;
|
// 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 text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped';
|
||||||
const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted';
|
const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted';
|
||||||
return {
|
return {
|
||||||
@@ -335,6 +351,7 @@ function createProPresenterSource({ config, log }) {
|
|||||||
label: (live && live.name) || (cfg && cfg.name) || 'Timer',
|
label: (live && live.name) || (cfg && cfg.name) || 'Timer',
|
||||||
kind,
|
kind,
|
||||||
state,
|
state,
|
||||||
|
asOf,
|
||||||
startedAt,
|
startedAt,
|
||||||
elapsedMs,
|
elapsedMs,
|
||||||
remainingMs,
|
remainingMs,
|
||||||
@@ -367,6 +384,9 @@ function createProPresenterSource({ config, log }) {
|
|||||||
// Timers reported live but missing from the (possibly stale) config
|
// Timers reported live but missing from the (possibly stale) config
|
||||||
// list still deserve a card — type unknown until the slow lane catches up.
|
// 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, 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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +396,16 @@ function createProPresenterSource({ config, log }) {
|
|||||||
return lastError ? 'error' : 'connecting';
|
return lastError ? 'error' : 'connecting';
|
||||||
}
|
}
|
||||||
|
|
||||||
function message() {
|
/** Short, for the tiles — a dashboard warns, the admin page explains.
|
||||||
|
The client prefixes the source label, so no "ProPresenter" here. */
|
||||||
|
function tileMessage() {
|
||||||
|
if (!configured) return 'Not configured — see Admin';
|
||||||
|
if (reachable) return `${timers.length} timer${timers.length === 1 ? '' : 's'}`;
|
||||||
|
return 'Unreachable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full story, for the admin health line. */
|
||||||
|
function adminMessage() {
|
||||||
if (!configured) return 'No ProPresenter host configured — set one in Admin, or switch ProPresenter timers off';
|
if (!configured) return 'No ProPresenter host configured — set one in Admin, or switch ProPresenter timers off';
|
||||||
if (reachable) return `Polling ${host}:${port}, ${timers.length} timer${timers.length === 1 ? '' : 's'}`;
|
if (reachable) return `Polling ${host}:${port}, ${timers.length} timer${timers.length === 1 ? '' : 's'}`;
|
||||||
return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`;
|
return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`;
|
||||||
@@ -384,7 +413,7 @@ function createProPresenterSource({ config, log }) {
|
|||||||
|
|
||||||
function publish() {
|
function publish() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
emit({ status: status(), message: message(), timers, updatedAt });
|
emit({ status: status(), message: tileMessage(), timers, updatedAt });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollTick() {
|
async function pollTick() {
|
||||||
@@ -430,7 +459,7 @@ function createProPresenterSource({ config, log }) {
|
|||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
return { status: status(), message: message() };
|
return { status: status(), message: adminMessage() };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -444,7 +473,9 @@ function createLtcSource({ config, log, moduleDir }) {
|
|||||||
let emit = () => {};
|
let emit = () => {};
|
||||||
let listener = null;
|
let listener = null;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
/** The listener's latest word: { state, time, fps, df, ageMs } | { error } | null while starting. */
|
/** The listener's latest word: { state, time, fps, df, ageMs } |
|
||||||
|
{ error, detail } (error = short tile text, detail = the admin/log
|
||||||
|
story) | null while starting. */
|
||||||
let word = null;
|
let word = null;
|
||||||
|
|
||||||
function rateText() {
|
function rateText() {
|
||||||
@@ -496,8 +527,16 @@ function createLtcSource({ config, log, moduleDir }) {
|
|||||||
kind: 'ltc',
|
kind: 'ltc',
|
||||||
start(e) {
|
start(e) {
|
||||||
emit = e;
|
emit = e;
|
||||||
|
// Kick the capture-tool build right away: a catalog install ships
|
||||||
|
// source only and a catalog update wipes any previously built binary,
|
||||||
|
// so coming up after an install/update must rebuild it without
|
||||||
|
// waiting for a device to be picked or a listener to need it.
|
||||||
|
ensureCaptureBinary({ moduleDir, log });
|
||||||
if (!device) {
|
if (!device) {
|
||||||
word = { error: 'LTC timecode is on but no audio device is selected — pick one in Admin' };
|
word = {
|
||||||
|
error: 'Not configured — see Admin',
|
||||||
|
detail: 'LTC timecode is on but no audio device is selected — pick one in Admin',
|
||||||
|
};
|
||||||
publish();
|
publish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -519,7 +558,8 @@ function createLtcSource({ config, log, moduleDir }) {
|
|||||||
listener = null;
|
listener = null;
|
||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
return { status: status(), message: message() };
|
// The admin page gets the full story; the tiles got the short one.
|
||||||
|
return { status: status(), message: (word && word.error && word.detail) || message() };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -568,7 +608,15 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
|||||||
let silenceTimer = null;
|
let silenceTimer = null;
|
||||||
let backoff = BACKOFF_MIN_MS;
|
let backoff = BACKOFF_MIN_MS;
|
||||||
|
|
||||||
function message() {
|
/** Short, for the tiles; the lastError detail belongs to the admin line.
|
||||||
|
The client prefixes the source label, so it isn't repeated here. */
|
||||||
|
function tileMessage() {
|
||||||
|
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}`;
|
||||||
|
if (state === 'connecting') return 'Connecting…';
|
||||||
|
return 'Unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminMessage() {
|
||||||
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`;
|
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`;
|
||||||
if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…';
|
if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…';
|
||||||
return `unreachable — ${lastError}`;
|
return `unreachable — ${lastError}`;
|
||||||
@@ -576,7 +624,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
|||||||
|
|
||||||
function publish() {
|
function publish() {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
emit({ label, status: state, message: message(), timers, updatedAt });
|
emit({ label, status: state, message: tileMessage(), timers, updatedAt });
|
||||||
}
|
}
|
||||||
|
|
||||||
function accept(raw) {
|
function accept(raw) {
|
||||||
@@ -754,7 +802,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
|
|||||||
if (!stopped && !stream && via === 'poll') connectStream();
|
if (!stopped && !stream && via === 'poll') connectStream();
|
||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
return { status: state, message: message() };
|
return { status: state, message: adminMessage() };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -992,12 +1040,17 @@ module.exports = {
|
|||||||
for (const t of v.timers) {
|
for (const t of v.timers) {
|
||||||
const out = { ...t, id: `${v.id}:${t.id}`, group: v.label };
|
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 (v.id === 'clock') out.status = { text: new Date(now).toISOString(), tone: 'ok' };
|
||||||
if (t.state === 'running' && v.updatedAt > 0) {
|
// A timer measured at its own instant (asOf — a phase-locked
|
||||||
const dt = Math.max(0, now - v.updatedAt);
|
// 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.elapsedMs !== null && out.elapsedMs !== undefined) out.elapsedMs += dt;
|
||||||
if (out.remainingMs !== null && out.remainingMs !== undefined) out.remainingMs -= 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 };
|
if (out.ltc && Number.isFinite(out.ltc.ageMs)) out.ltc = { ...out.ltc, ageMs: out.ltc.ageMs + dt };
|
||||||
}
|
}
|
||||||
|
delete out.asOf;
|
||||||
timers.push(out);
|
timers.push(out);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1147,7 +1200,7 @@ module.exports = {
|
|||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
|
|
||||||
routes() {
|
routes({ log }) {
|
||||||
return {
|
return {
|
||||||
'GET /state': (req, res) => {
|
'GET /state': (req, res) => {
|
||||||
sendJson(res, 200, { state: current ? current.getState() : emptyState() });
|
sendJson(res, 200, { state: current ? current.getState() : emptyState() });
|
||||||
@@ -1170,7 +1223,15 @@ module.exports = {
|
|||||||
|
|
||||||
// The admin device picker asks this machine what it can hear. The
|
// The admin device picker asks this machine what it can hear. The
|
||||||
// options shape matches the admin select's optionsRoute contract.
|
// options shape matches the admin select's optionsRoute contract.
|
||||||
|
// Opening the picker also kicks the capture-tool build when a catalog
|
||||||
|
// install/update left only the source — without this, no tool meant
|
||||||
|
// no device list, and no device meant nothing would ever build it.
|
||||||
'GET /ltc/devices': (req, res) => {
|
'GET /ltc/devices': (req, res) => {
|
||||||
|
const tool = ensureCaptureBinary({ moduleDir: __dirname, log });
|
||||||
|
if (!tool.ready) {
|
||||||
|
sendJson(res, 200, { options: [], error: tool.detail || tool.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const bin = path.join(__dirname, 'ltc-capture');
|
const bin = path.join(__dirname, 'ltc-capture');
|
||||||
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
||||||
let err = '';
|
let err = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user