Timers: LTC is listener-only; admin switch, device/channel, grouped settings
Simplify LTC to its one working source — the built-in audio listener — and make the admin UI reflect it. Module: - Remove the stage-display websocket source (stage-ws.js, stagePassword) and the /v1/timecode/status API probe — both dead on ProPresenter 21.4. - Remove the remote-reader ingest (POST /ltc, ingestToken, tools/ ltc-reader.js): the module owns LTC end to end now. The decoder selftest moves to modules/propresenter-timers/ltc.selftest.js so the engine stays covered. - composeLtc collapses to the listener: running/stopped/nosignal, or a clear note when disabled / no device / capture failed / starting. - Manifest: ltcEnabled is now a `switch`; ltcDevice and ltcChannel carry showWhen:"ltcEnabled"; ProPresenter and LTC fields carry `group` labels. Shell (admin) — backward-compatible additions, coordinate with the shell lane: - New `switch` field type (boolean shown as the enable-toggle control). - `group`: consecutive fields render under a labeled subsection. - `showWhen: "<key>"`: a field is shown only while that boolean/switch is on (and actually collapses — .field[hidden] override, since .field's display:flex outranks the [hidden] UA default). - applyConfigPatch coerces `switch` as boolean. Documented in MODULE-GUIDE. Verified: engine selftest (29 checks); listener decodes BlackHole loopback (running 30fps, ageMs); device picker lists inputs; POST /ltc now 404s; admin shows two labeled groups, the switch, and device/channel appearing only when the switch is on (computed display none↔flex confirmed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7e75146838
commit
1d46657e06
@@ -262,9 +262,8 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
function updateLtcAnchor() {
|
||||
const l = state?.ltc;
|
||||
// Only the audio sources are extrapolatable — they know the frame rate.
|
||||
const live = l && l.status === 'running' && l.fps > 0
|
||||
&& (l.source === 'listener' || l.source === 'reader');
|
||||
// The listener is extrapolatable — it knows the frame rate.
|
||||
const live = l && l.status === 'running' && l.fps > 0 && l.source === 'listener';
|
||||
const tc = live ? parseTc(l.time) : null;
|
||||
if (!tc) {
|
||||
ltcAnchor = null;
|
||||
@@ -323,14 +322,9 @@ export default function create({ root, moduleApi }) {
|
||||
if (ltc.supported === false) {
|
||||
mode = 'unavailable';
|
||||
card.timeEl.textContent = '—';
|
||||
// The server says why when it knows (stage password rejected, no
|
||||
// stage field labeled LTC, reader offline, …) — surface that over
|
||||
// the generic line.
|
||||
card.stateEl.textContent = ltc.note || 'Timecode not available on this ProPresenter';
|
||||
} else if (ltc.supported === null) {
|
||||
mode = 'waiting';
|
||||
card.timeEl.textContent = displayTime(ltc.time);
|
||||
card.stateEl.textContent = 'Waiting for ProPresenter…';
|
||||
// The server says why when it knows (listener disabled, no device
|
||||
// selected, capture failed, starting up…) — surface that.
|
||||
card.stateEl.textContent = ltc.note || 'LTC not available';
|
||||
} else if (ltc.receiving) {
|
||||
mode = 'receiving';
|
||||
// With an anchor active, paint the frame it implies for now (this
|
||||
@@ -350,10 +344,9 @@ export default function create({ root, moduleApi }) {
|
||||
card.timeEl.textContent = '—';
|
||||
card.stateEl.textContent = 'No signal';
|
||||
}
|
||||
// The built-in listener and the remote reader are independent of
|
||||
// ProPresenter, so their card must not gray out with the rest when
|
||||
// only ProPresenter is unreachable.
|
||||
const live = ltc.source === 'reader' || ltc.source === 'listener' ? ' src-reader' : '';
|
||||
// The built-in listener is independent of ProPresenter, so its card
|
||||
// must not gray out with the rest when only ProPresenter is unreachable.
|
||||
const live = ltc.source === 'listener' ? ' src-reader' : '';
|
||||
card.el.className = `tm-card tm-card-ltc is-ltc-${mode}${live}`;
|
||||
}
|
||||
|
||||
@@ -377,7 +370,7 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
if (!state.enabled) {
|
||||
bannerEl.hidden = true;
|
||||
// The LTC listener/reader feed us with or without ProPresenter — an
|
||||
// The LTC listener feeds us with or without ProPresenter — an
|
||||
// LTC-only setup is working, not broken.
|
||||
if (state.ltc?.supported) moduleApi.setStatus('ok', 'LTC only — no ProPresenter host configured');
|
||||
else moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
// streams the device's inputs as interleaved s16le to stdout, at the
|
||||
// device's own sample rate and channel count (both printed to stderr
|
||||
// at start). The name matches exactly first, then as a substring.
|
||||
// The remote reader (tools/ltc-reader.js) pipes from it the same way.
|
||||
//
|
||||
// --device matches case-insensitively on a substring of the device name or
|
||||
// UID; ambiguity is an error listing the candidates. Capture uses an
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
|
||||
/**
|
||||
* LTC engine for the propresenter-timers module — SMPTE LTC decode from PCM,
|
||||
* interleaved-channel extraction, and an encoder for tests and demos.
|
||||
* interleaved-channel extraction, and an encoder for tests.
|
||||
*
|
||||
* Used in-process by the module's built-in LTC listener (ltc-listener.js),
|
||||
* and by the standalone remote reader (tools/ltc-reader.js) for machines
|
||||
* other than the ProdDash server. All design notes and the full protocol
|
||||
* commentary live with the classes below; the encoder exists so the decode
|
||||
* path can be proven end-to-end without timecode hardware (see the reader's
|
||||
* --selftest).
|
||||
* Used in-process by the module's built-in LTC listener (ltc-listener.js).
|
||||
* All design notes and the full protocol commentary live with the classes
|
||||
* below; the encoder exists so the decode path can be proven end-to-end
|
||||
* without timecode hardware (see ltc.selftest.js).
|
||||
*/
|
||||
|
||||
/* ══ decoder ═══════════════════════════════════════════════════════════ */
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* LTC engine self-test — encoder→decoder round trips for the module's LTC
|
||||
* decoder (ltc.js), which the built-in listener (ltc-listener.js) uses to
|
||||
* decode timecode from the captured audio. No audio hardware needed.
|
||||
*
|
||||
* node modules/propresenter-timers/ltc.selftest.js
|
||||
*
|
||||
* Exits non-zero if any check fails, so it doubles as a CI smoke test.
|
||||
*/
|
||||
|
||||
const { LtcDecoder, LtcEncoder, ChannelExtractor, frameBits, fmtTc } = require("./ltc");
|
||||
|
||||
/* ══ selftest ══════════════════════════════════════════════════════════ */
|
||||
|
||||
/** Deterministic PRNG so a failure reproduces. */
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let x = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
|
||||
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function selftest() {
|
||||
const SR = 48000;
|
||||
let failures = 0;
|
||||
const check = (name, ok, detail = '') => {
|
||||
console.log(` ${ok ? '✓' : '✗'} ${name}${ok || !detail ? '' : ` — ${detail}`}`);
|
||||
if (!ok) failures += 1;
|
||||
};
|
||||
|
||||
/** Encode frames, mangle optionally, decode in awkward chunk sizes. */
|
||||
const roundTrip = (fps, df, start, frames, mangle) => {
|
||||
const enc = new LtcEncoder(SR, fps, df, start);
|
||||
let pcm = enc.render(Math.ceil((frames * 80 * SR) / (fps * 80)) + SR / 10);
|
||||
if (mangle) pcm = mangle(pcm);
|
||||
const out = [];
|
||||
const dec = new LtcDecoder(SR, (f) => out.push(f));
|
||||
for (let off = 0; off < pcm.length; off += 997) {
|
||||
dec.push(pcm.subarray(off, Math.min(off + 997, pcm.length)));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const sequential = (out) =>
|
||||
out.every((f, i) => i === 0 || fmtTc(f) !== fmtTc(out[i - 1]));
|
||||
|
||||
console.log('30 fps:');
|
||||
{
|
||||
const out = roundTrip(30, false, { h: 1, m: 0, s: 0, f: 0 }, 60);
|
||||
check('decodes most frames', out.length >= 55, `${out.length}/60`);
|
||||
check('starts at 01:00:00:0x', out.length > 0 && out[0].h === 1 && out[0].m === 0 && out[0].s === 0);
|
||||
check('frames advance', sequential(out));
|
||||
check('fps snaps to 30', out.length > 30 && out[out.length - 1].fps === 30);
|
||||
check('df clear', out.every((f) => !f.df));
|
||||
}
|
||||
|
||||
console.log('25 fps:');
|
||||
{
|
||||
const out = roundTrip(25, false, { h: 10, m: 59, s: 59, f: 20 }, 50);
|
||||
check('decodes most frames', out.length >= 45, `${out.length}/50`);
|
||||
check('rolls over the hour', out.some((f) => f.h === 11 && f.m === 0 && f.s === 0));
|
||||
check('fps snaps to 25', out.length > 26 && out[out.length - 1].fps === 25);
|
||||
}
|
||||
|
||||
console.log('24 fps:');
|
||||
{
|
||||
const out = roundTrip(24, false, { h: 0, m: 5, s: 0, f: 0 }, 50);
|
||||
check('decodes most frames', out.length >= 45, `${out.length}/50`);
|
||||
check('fps snaps to 24', out.length > 25 && out[out.length - 1].fps === 24);
|
||||
}
|
||||
|
||||
console.log('29.97 drop-frame:');
|
||||
{
|
||||
const out = roundTrip(29.97, true, { h: 0, m: 0, s: 59, f: 20 }, 90);
|
||||
check('decodes most frames', out.length >= 80, `${out.length}/90`);
|
||||
// hysteresis: the very first frame legitimately reports df=false
|
||||
check('df flag set', out.length > 1 && out.slice(1).every((f) => f.df));
|
||||
check('skips :00:00 and :00:01', !out.some((f) => f.m === 1 && f.s === 0 && f.f < 2));
|
||||
check('lands on :00:02', out.some((f) => f.m === 1 && f.s === 0 && f.f === 2));
|
||||
check('fps reads 29.97', out.length > 0 && out[out.length - 1].fps === 29.97);
|
||||
}
|
||||
|
||||
console.log('hostile audio (low level, noise, DC offset):');
|
||||
{
|
||||
const rand = mulberry32(1234);
|
||||
const out = roundTrip(30, false, { h: 2, m: 0, s: 0, f: 0 }, 60, (pcm) => {
|
||||
const mangled = new Int16Array(pcm.length);
|
||||
for (let i = 0; i < pcm.length; i += 1) {
|
||||
// ~10% of the encoder's level (≈ -29 dBFS), ±300 of noise, +3000 of
|
||||
// DC. Quieter than that sits under the decoder's noise floor by
|
||||
// design — silence must not decode as bits.
|
||||
mangled[i] = Math.round(pcm[i] * 0.1 + (rand() - 0.5) * 600 + 3000);
|
||||
}
|
||||
return mangled;
|
||||
});
|
||||
check('still decodes', out.length >= 50, `${out.length}/60`);
|
||||
check('frames advance', sequential(out));
|
||||
}
|
||||
|
||||
console.log('signal dropout and recovery:');
|
||||
{
|
||||
const enc = new LtcEncoder(SR, 30, false, { h: 3, m: 0, s: 0, f: 0 });
|
||||
const a = enc.render(SR); // 1 s ≈ 30 frames
|
||||
const gap = new Int16Array(SR / 2); // 0.5 s of silence
|
||||
const enc2 = new LtcEncoder(SR, 30, false, { h: 3, m: 0, s: 10, f: 0 });
|
||||
const b = enc2.render(SR);
|
||||
const out = [];
|
||||
const dec = new LtcDecoder(SR, (f) => out.push(f));
|
||||
for (const part of [a, gap, b]) {
|
||||
for (let off = 0; off < part.length; off += 1024) {
|
||||
dec.push(part.subarray(off, Math.min(off + 1024, part.length)));
|
||||
}
|
||||
}
|
||||
const before = out.filter((f) => f.s < 5).length;
|
||||
const after = out.filter((f) => f.s >= 10).length;
|
||||
check('decodes before the gap', before >= 25, `${before}`);
|
||||
check('recovers after the gap', after >= 25, `${after}`);
|
||||
check('no signal during the gap detected via signalPresent', !dec.signalPresent() || true); // informational
|
||||
}
|
||||
|
||||
console.log('corrupt frames must not poison the frame rate:');
|
||||
{
|
||||
// Reproduces a field bug: a Dante glitch produced a frame whose frames
|
||||
// field read 35 yet passed the old lax validation, and max-frame-based
|
||||
// fps logic reported "36 fps" to the dashboard forever after. Encode a
|
||||
// clean run, splice in a frame forged to carry f=35 (frames-tens bits
|
||||
// forced to 3), add PCM-level glitches, and demand the rate holds 30.
|
||||
const enc = new LtcEncoder(SR, 30, false, { h: 6, m: 0, s: 0, f: 0 });
|
||||
const clean1 = enc.render(SR); // ~30 valid frames
|
||||
enc.bits = frameBits({ h: 6, m: 0, s: 1, f: 5 }, false);
|
||||
enc.bits[8] = 1; // frames-tens bit 0
|
||||
enc.bits[9] = 1; // frames-tens bit 1 → tens=3 → frames field reads 35
|
||||
enc.bitIdx = 0;
|
||||
const forged = enc.render(Math.ceil(SR / 30)); // one poisoned frame
|
||||
const clean2 = enc.render(SR * 2);
|
||||
const rand = mulberry32(77);
|
||||
const glitched = new Int16Array(clean2.length);
|
||||
glitched.set(clean2);
|
||||
for (let at = SR / 4; at < glitched.length - 100; at += Math.floor(SR / 3)) {
|
||||
for (let i = 0; i < 90; i += 1) glitched[at + i] = Math.round((rand() - 0.5) * 24000);
|
||||
}
|
||||
const out = [];
|
||||
const dec = new LtcDecoder(SR, (fr) => out.push(fr));
|
||||
for (const part of [clean1, forged, glitched]) {
|
||||
for (let off = 0; off < part.length; off += 997) {
|
||||
dec.push(part.subarray(off, Math.min(off + 997, part.length)));
|
||||
}
|
||||
}
|
||||
check('still decodes through the damage', out.length >= 70, `${out.length}`);
|
||||
check('no frame number above 29 survives', out.every((fr) => fr.f <= 29));
|
||||
check('fps never exceeds 30', out.every((fr) => fr.fps <= 30), `saw ${Math.max(...out.map((fr) => fr.fps))}`);
|
||||
check('fps ends locked at 30', out.length > 0 && out[out.length - 1].fps === 30);
|
||||
check('df never flashes on', out.every((fr) => !fr.df));
|
||||
}
|
||||
|
||||
console.log('multichannel extraction (LTC on channel 5 of 8):');
|
||||
{
|
||||
// Interleave LTC into one channel of eight, with hot program audio on
|
||||
// every other channel, and feed the byte stream in 997-byte chunks —
|
||||
// odd on purpose, so chunk splits land mid-sample and mid-frame and
|
||||
// the extractor's carry has to keep frame alignment.
|
||||
const CH = 8;
|
||||
const PICK = 5;
|
||||
const enc = new LtcEncoder(SR, 30, false, { h: 4, m: 0, s: 0, f: 0 });
|
||||
const ltc = enc.render(SR * 2); // 2 s ≈ 60 frames
|
||||
const bytes = Buffer.alloc(ltc.length * CH * 2);
|
||||
for (let i = 0; i < ltc.length; i += 1) {
|
||||
for (let c = 0; c < CH; c += 1) {
|
||||
const v = c === PICK - 1 ? ltc[i] : Math.round(20000 * Math.sin(i * (0.02 + c * 0.05)));
|
||||
bytes.writeInt16LE(v, (i * CH + c) * 2);
|
||||
}
|
||||
}
|
||||
const extractor = new ChannelExtractor(CH, PICK);
|
||||
const out = [];
|
||||
const dec = new LtcDecoder(SR, (f) => out.push(f));
|
||||
for (let off = 0; off < bytes.length; off += 997) {
|
||||
dec.push(extractor.push(bytes.subarray(off, Math.min(off + 997, bytes.length))));
|
||||
}
|
||||
check('decodes most frames', out.length >= 55, `${out.length}/60`);
|
||||
check('starts at 04:00:00:0x', out.length > 0 && out[0].h === 4 && out[0].m === 0 && out[0].s === 0);
|
||||
check('frames advance', sequential(out));
|
||||
}
|
||||
|
||||
console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed');
|
||||
process.exit(failures ? 1 : 0);
|
||||
}
|
||||
|
||||
selftest();
|
||||
@@ -7,53 +7,41 @@
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 1
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 4,
|
||||
"h": 2
|
||||
},
|
||||
"minSize": { "w": 2, "h": 1 },
|
||||
"defaultSize": { "w": 4, "h": 2 },
|
||||
"configSchema": {
|
||||
"propresenter": {
|
||||
"type": "endpoint",
|
||||
"label": "ProPresenter",
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 1025
|
||||
}
|
||||
"group": "ProPresenter",
|
||||
"default": { "host": "", "port": 1025 }
|
||||
},
|
||||
"password": {
|
||||
"type": "password",
|
||||
"label": "API password (only if set in ProPresenter)",
|
||||
"group": "ProPresenter",
|
||||
"default": ""
|
||||
},
|
||||
"ltcEnabled": {
|
||||
"type": "boolean",
|
||||
"type": "switch",
|
||||
"label": "LTC listener — decode timecode from an audio input on this machine",
|
||||
"group": "LTC Timecode",
|
||||
"default": false
|
||||
},
|
||||
"ltcDevice": {
|
||||
"type": "select",
|
||||
"label": "LTC audio device",
|
||||
"label": "Audio device",
|
||||
"group": "LTC Timecode",
|
||||
"showWhen": "ltcEnabled",
|
||||
"default": "",
|
||||
"optionsRoute": "/ltc/devices"
|
||||
},
|
||||
"ltcChannel": {
|
||||
"type": "number",
|
||||
"label": "LTC channel on that device (1-based)",
|
||||
"label": "Channel on that device (1-based)",
|
||||
"group": "LTC Timecode",
|
||||
"showWhen": "ltcEnabled",
|
||||
"default": 1
|
||||
},
|
||||
"ingestToken": {
|
||||
"type": "password",
|
||||
"label": "LTC reader token (optional — for tools/ltc-reader.js on another machine)",
|
||||
"default": ""
|
||||
},
|
||||
"stagePassword": {
|
||||
"type": "password",
|
||||
"label": "Stage display password (LTC fallback via a stage field labeled \"LTC\")",
|
||||
"default": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,13 @@
|
||||
/**
|
||||
* ProPresenter Timers — server part.
|
||||
*
|
||||
* Polls the ProPresenter openAPI for the configured timer list, the live
|
||||
* timer values, and (when the connected build exposes it) LTC timecode, and
|
||||
* fans one merged state object out to every tile over SSE. Browsers never
|
||||
* talk to ProPresenter directly.
|
||||
* Polls the ProPresenter openAPI for the configured timer list and the live
|
||||
* timer values, decodes LTC timecode from an audio input, and fans one
|
||||
* merged state object out to every tile over SSE. Browsers never talk to
|
||||
* ProPresenter or the audio hardware directly.
|
||||
*
|
||||
* GET /state → { state: <state> } (instant paint for new tiles)
|
||||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||
* POST /ltc → remote LTC ingest: tools/ltc-reader.js on another
|
||||
* machine reports { state, time, fps, df } here.
|
||||
* GET /ltc/devices → this machine's audio input devices, for the admin
|
||||
* page's device picker (select + optionsRoute).
|
||||
*
|
||||
@@ -25,26 +23,14 @@
|
||||
* `state` enum is stopped|running|complete|overrunning|overran — but the
|
||||
* spec's own response example says "overrun", so treat anything containing
|
||||
* "overr" as overrun. The API exposes NO per-timer color anywhere.
|
||||
* GET /v1/timecode/status → NOT in the published spec. The spec (fetched
|
||||
* 2026-08, 0 mentions of "timecode", not in the /v1/status/updates
|
||||
* allowlist either) documents no timecode/LTC route at all — verified
|
||||
* live: ProPresenter 21.4 returns 404. We probe this candidate route
|
||||
* once per connection anyway so a future ProPresenter that adds it
|
||||
* lights up without a module update.
|
||||
*
|
||||
* Because the HTTP API has no timecode, LTC comes from the audio signal
|
||||
* itself. The whole LTC feature sits behind the admin "LTC listener"
|
||||
* switch — off means no LTC tile in the picker, no probes, and the ingest
|
||||
* route answers 409. When on, the sources in precedence order:
|
||||
* 1. the built-in listener (ltc-listener.js — this machine captures the
|
||||
* admin-selected device/channel and decodes in-process) while it has
|
||||
* actual data;
|
||||
* 2. a fresh remote reader report (POST /ltc, tools/ltc-reader.js on the
|
||||
* machine that can hear the LTC when this one can't);
|
||||
* 3. the listener's own "no signal" verdict (healthy capture, silent
|
||||
* feed — believed over the indirect sources below);
|
||||
* 4. the HTTP timecode route if some future ProPresenter answers it;
|
||||
* 5. a stage-display field labeled "LTC" (see stage-ws.js).
|
||||
* LTC: ProPresenter exposes no timecode over any API (the HTTP openAPI has
|
||||
* no timecode route, and the classic stage-display websocket drops the
|
||||
* field — both verified dead on ProPresenter 21.4), so LTC is read from the
|
||||
* audio signal itself. Behind the admin "LTC listener" switch: off means no
|
||||
* LTC tile in the picker and nothing captured; on, the built-in listener
|
||||
* (ltc-listener.js) captures the admin-selected device/channel and decodes
|
||||
* in-process. It is the module's one and only LTC source.
|
||||
*
|
||||
* ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path
|
||||
* notes in ../propresenter-now-next/propresenter-core). So: requests run
|
||||
@@ -64,12 +50,8 @@ const SLOW_EVERY = 4;
|
||||
const REQUEST_TIMEOUT_MS = 4000;
|
||||
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||
const HEARTBEAT_MS = 15000;
|
||||
/** A reader report older than this no longer speaks for the LTC feed —
|
||||
the reader posts at least 1×/s, so 6s of silence means it died. */
|
||||
const INGEST_STALE_MS = 6000;
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const { createStageLtcClient } = require('./stage-ws');
|
||||
const { createLtcListener } = require('./ltc-listener');
|
||||
|
||||
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||
@@ -82,12 +64,11 @@ function clearedState(enabled, ltcEnabled = false) {
|
||||
reachable: false,
|
||||
lastError: '',
|
||||
timers: [],
|
||||
// supported: null = no verdict yet (probing, or ProPresenter
|
||||
// unreachable); true once a timecode source is live; false when every
|
||||
// source came up empty (note says why, when we know). status is the
|
||||
// richer verdict (running|stopped|nosignal); fps/df only when the
|
||||
// source knows them (the reader decodes both from the LTC bits).
|
||||
ltc: { supported: null, time: '', receiving: false, status: '', fps: 0, df: false, source: '', note: '' },
|
||||
// supported true once the listener has a verdict; false when the
|
||||
// listener is off, has no device, or its capture failed (note says
|
||||
// why). status is running|stopped|nosignal; while running, ageMs lets
|
||||
// clients count frames locally between updates.
|
||||
ltc: { supported: false, time: '', receiving: false, status: '', fps: 0, df: false, source: '', note: '' },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,95 +95,34 @@ module.exports = {
|
||||
|
||||
/** uuid → { name, index, type, allowsOverrun } from /v1/timers, in list order. */
|
||||
let timerConfigs = new Map();
|
||||
/** null = probe /v1/timecode/status on the next reachable tick. */
|
||||
let ltcSupported = null;
|
||||
let ltcLast = { time: '', receiving: false };
|
||||
/** Live view of the stage-display websocket's LTC field (stage-ws.js). */
|
||||
let stageLtc = { bound: false, time: '', receiving: false, note: '' };
|
||||
/** Latest POST /ltc report from a remote reader, null until one lands. */
|
||||
let ingest = null; // { state, time, fps, df, source, at }
|
||||
let ingestExpiry = null;
|
||||
/** Built-in listener's latest word: { state, time, fps, df } | { error }. */
|
||||
/** Built-in listener's latest word: { state, time, fps, df, ageMs } | { error }. */
|
||||
let listenerLtc = null;
|
||||
|
||||
/** One LTC verdict from every source, in the precedence the header
|
||||
comment explains. Text sources don't know fps — receiving/frozen/
|
||||
empty maps onto the same status vocabulary the audio decode uses. */
|
||||
/** The LTC verdict — the built-in listener is the only source. */
|
||||
function composeLtc() {
|
||||
if (!ltcEnabled) {
|
||||
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' };
|
||||
}
|
||||
const audioVerdict = (src, tag, ageMs) => ({
|
||||
if (!listenerLtc) {
|
||||
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'Starting LTC listener…' };
|
||||
}
|
||||
if (listenerLtc.error) {
|
||||
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: listenerLtc.error };
|
||||
}
|
||||
const running = listenerLtc.state === 'running';
|
||||
return {
|
||||
supported: true,
|
||||
source: tag,
|
||||
time: src.time,
|
||||
receiving: src.state === 'running',
|
||||
status: src.state,
|
||||
fps: src.fps,
|
||||
df: src.df,
|
||||
source: 'listener',
|
||||
time: listenerLtc.time,
|
||||
receiving: running,
|
||||
status: listenerLtc.state,
|
||||
fps: listenerLtc.fps,
|
||||
df: listenerLtc.df,
|
||||
// While running: how old this timecode is, so clients can pin the
|
||||
// anchor to their own clock and count frames between updates.
|
||||
...(src.state === 'running' && Number.isFinite(ageMs) ? { ageMs: Math.max(0, ageMs) } : {}),
|
||||
...(running && Number.isFinite(listenerLtc.ageMs) ? { ageMs: Math.max(0, listenerLtc.ageMs) } : {}),
|
||||
note: '',
|
||||
});
|
||||
// The built-in listener while it has actual data …
|
||||
if (listenerLtc && !listenerLtc.error && listenerLtc.state !== 'nosignal') {
|
||||
return audioVerdict(listenerLtc, 'listener', listenerLtc.ageMs);
|
||||
}
|
||||
// … else a fresh remote reader (during migrations both may run —
|
||||
// real data beats no data) …
|
||||
if (ingest && Date.now() - ingest.at < INGEST_STALE_MS) {
|
||||
return audioVerdict(ingest, 'reader', Date.now() - ingest.at);
|
||||
}
|
||||
// … else a healthy-but-silent listener is believed over the indirect
|
||||
// sources: no audio means the LTC really stopped.
|
||||
if (listenerLtc && !listenerLtc.error) {
|
||||
return audioVerdict(listenerLtc, 'listener');
|
||||
}
|
||||
const textStatus = (src) => (src.receiving ? 'running' : src.time ? 'stopped' : 'nosignal');
|
||||
if (ltcSupported === true) {
|
||||
return { supported: true, source: 'api', time: ltcLast.time, receiving: ltcLast.receiving, status: textStatus(ltcLast), fps: 0, df: false, note: '' };
|
||||
}
|
||||
if (stageLtc.bound) {
|
||||
return { supported: true, source: 'stage', time: stageLtc.time, receiving: stageLtc.receiving, status: textStatus(stageLtc), fps: 0, df: false, note: '' };
|
||||
}
|
||||
if (ingest) {
|
||||
// A reader reported once but went silent — likelier to be the
|
||||
// story than anything the stage feed has to say.
|
||||
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC reader offline' };
|
||||
}
|
||||
if (!listenerLtc?.error && ltcSupported === null && !stageLtc.note && enabled) {
|
||||
return { supported: null, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: '' };
|
||||
}
|
||||
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: listenerLtc?.error || stageLtc.note || '' };
|
||||
}
|
||||
|
||||
/** Timecode-shaped text — same shape rule as the other sources. */
|
||||
const TC_SHAPE = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
|
||||
const INGEST_STATES = new Set(['running', 'stopped', 'nosignal']);
|
||||
|
||||
/** Validated apply of one POST /ltc body. Returns '' or a client error. */
|
||||
function applyIngest(body) {
|
||||
if (!body || typeof body !== 'object') return 'body must be a JSON object';
|
||||
const state = INGEST_STATES.has(body.state) ? body.state : '';
|
||||
if (!state) return 'state must be one of running|stopped|nosignal';
|
||||
const time = typeof body.time === 'string' && TC_SHAPE.test(body.time.trim()) ? body.time.trim() : '';
|
||||
if (state === 'running' && !time) return 'a running report needs a timecode-shaped time';
|
||||
ingest = {
|
||||
state,
|
||||
time,
|
||||
fps: Number.isFinite(body.fps) && body.fps > 0 && body.fps <= 120 ? Number(body.fps) : 0,
|
||||
df: body.df === true,
|
||||
source: String(body.source || '').slice(0, 60),
|
||||
at: Date.now(),
|
||||
};
|
||||
// When the reader dies mid-run the card must not stay green forever —
|
||||
// re-broadcast once this report goes stale so the fallbacks take over.
|
||||
clearTimeout(ingestExpiry);
|
||||
ingestExpiry = setTimeout(broadcast, INGEST_STALE_MS + 100);
|
||||
ingestExpiry.unref?.();
|
||||
broadcast();
|
||||
return '';
|
||||
}
|
||||
|
||||
function url(target) {
|
||||
@@ -283,61 +203,6 @@ module.exports = {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liberal parse of a hypothetical timecode-status body (no spec documents
|
||||
* its shape — see the header comment). Looks for an "HH:MM:SS:FF"-shaped
|
||||
* string and a receiving/running boolean under likely key names.
|
||||
*/
|
||||
function parseLtc(body) {
|
||||
const out = { time: '', receiving: false };
|
||||
if (!body || typeof body !== 'object') return out;
|
||||
const TC = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
|
||||
for (const key of ['time', 'timecode', 'current_time', 'value']) {
|
||||
if (typeof body[key] === 'string' && TC.test(body[key])) {
|
||||
out.time = body[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!out.time) {
|
||||
for (const value of Object.values(body)) {
|
||||
if (typeof value === 'string' && TC.test(value)) {
|
||||
out.time = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const key of ['receiving', 'running', 'is_running', 'active']) {
|
||||
if (typeof body[key] === 'boolean') {
|
||||
out.receiving = body[key];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function pollLtc() {
|
||||
if (!ltcEnabled) return;
|
||||
// A 404 verdict is retried once a minute — an endpoint appearing
|
||||
// without an unreachable spell (ProPresenter hot-swapped in testing,
|
||||
// or some future in-place upgrade) must not stay invisible forever.
|
||||
if (ltcSupported === false && tick % 120 !== 0) return;
|
||||
try {
|
||||
const body = await requestJson('/v1/timecode/status');
|
||||
if (ltcSupported === null) log('timecode endpoint answered — LTC card is live');
|
||||
ltcSupported = true;
|
||||
ltcLast = parseLtc(body);
|
||||
} catch (err) {
|
||||
// 404 = this ProPresenter has no timecode API (the expected case, as
|
||||
// of API v1). Anything else is a real failure — let the tick handle it.
|
||||
if (err && err.status === 404) {
|
||||
ltcSupported = false;
|
||||
ltcLast = { time: '', receiving: false };
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
latest = {
|
||||
enabled,
|
||||
@@ -377,43 +242,21 @@ module.exports = {
|
||||
}
|
||||
// THE HOT PATH — live values every tick.
|
||||
const currentTimes = await requestJson('/v1/timers/current');
|
||||
// Probe timecode once per connection; poll it only when it exists.
|
||||
await pollLtc();
|
||||
latest.timers = mergeTimers(currentTimes);
|
||||
latest.reachable = true;
|
||||
latest.lastError = '';
|
||||
} catch (err) {
|
||||
latest.reachable = false;
|
||||
latest.lastError = err instanceof Error ? err.message : String(err);
|
||||
// Re-probe timecode after a reconnect — the unreachable spell may
|
||||
// have been a ProPresenter upgrade or a different machine.
|
||||
ltcSupported = null;
|
||||
ltcLast = { time: '', receiving: false };
|
||||
}
|
||||
broadcast();
|
||||
if (stopped) return;
|
||||
pollTimer = setTimeout(pollTick, latest.reachable ? POLL_MS : RETRY_MS);
|
||||
}
|
||||
|
||||
let stageClient = null;
|
||||
if (enabled) {
|
||||
pollTick();
|
||||
log(`polling timers on ${host}:${port}`);
|
||||
if (ltcEnabled) {
|
||||
stageClient = createStageLtcClient({
|
||||
host,
|
||||
port,
|
||||
password: String(config.stagePassword || ''),
|
||||
log,
|
||||
// The HTTP poller knows every configured timer's uuid — anything
|
||||
// else streaming timecode-shaped text can be heuristically bound.
|
||||
isTimerUid: (uid) => timerConfigs.has(uid),
|
||||
onUpdate(update) {
|
||||
stageLtc = update;
|
||||
broadcast();
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log('no ProPresenter host configured — set one in /admin');
|
||||
}
|
||||
@@ -450,15 +293,13 @@ module.exports = {
|
||||
}, HEARTBEAT_MS);
|
||||
heartbeat.unref?.();
|
||||
|
||||
current = { streams, getLatest: () => latest, ingest: applyIngest };
|
||||
current = { streams, getLatest: () => latest };
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(pollTimer);
|
||||
clearTimeout(ingestExpiry);
|
||||
clearInterval(heartbeat);
|
||||
stageClient?.stop();
|
||||
ltcListener?.stop();
|
||||
for (const res of streams) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
@@ -467,20 +308,19 @@ module.exports = {
|
||||
if (current && current.streams === streams) current = null;
|
||||
},
|
||||
health() {
|
||||
const ltcSource = { listener: ', LTC via listener', reader: ', LTC via remote reader', api: ', LTC via API', stage: ', LTC via stage display' };
|
||||
// LTC status suffix for the admin health line.
|
||||
const ltcNote = !ltcEnabled
|
||||
? ''
|
||||
: latest.ltc.supported
|
||||
? `, LTC ${latest.ltc.status}`
|
||||
: (latest.ltc.note ? ` (LTC: ${latest.ltc.note})` : '');
|
||||
if (!enabled) {
|
||||
// The LTC listener/reader work regardless of ProPresenter — an
|
||||
// The LTC listener works regardless of ProPresenter — an
|
||||
// LTC-only setup is half-configured but working.
|
||||
if (latest.ltc.supported) return { status: 'ok', message: `No ProPresenter host configured — LTC only${ltcSource[latest.ltc.source] || ''}` };
|
||||
if (latest.ltc.supported) return { status: 'ok', message: `No ProPresenter host configured — LTC only, ${latest.ltc.status}` };
|
||||
return { status: 'error', message: 'No ProPresenter host configured' };
|
||||
}
|
||||
if (latest.reachable) {
|
||||
const ltc = latest.ltc;
|
||||
const ltcNote = !ltcEnabled
|
||||
? ''
|
||||
: ltc.supported
|
||||
? (ltcSource[ltc.source] || '')
|
||||
: (ltc.note ? ` (LTC: ${ltc.note})` : '');
|
||||
return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` };
|
||||
}
|
||||
return {
|
||||
@@ -531,7 +371,7 @@ module.exports = {
|
||||
];
|
||||
},
|
||||
|
||||
routes({ config }) {
|
||||
routes() {
|
||||
const sendJson = (res, code, body) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify(body));
|
||||
@@ -563,41 +403,6 @@ module.exports = {
|
||||
});
|
||||
},
|
||||
|
||||
// A remote LTC reader (tools/ltc-reader.js, on the machine that can
|
||||
// hear the LTC when this one can't) reports here — the one route on
|
||||
// this module that accepts writes, so it optionally carries a shared
|
||||
// token (Admin → "LTC reader token").
|
||||
'POST /ltc': (req, res) => {
|
||||
if (!config.ltcEnabled) {
|
||||
return void sendJson(res, 409, { error: 'LTC listener is disabled in Admin — enable it to accept reader reports' });
|
||||
}
|
||||
const token = String(config.ingestToken || '');
|
||||
if (token) {
|
||||
const given = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
|
||||
if (given !== token) return void sendJson(res, 401, { error: 'bad or missing token' });
|
||||
}
|
||||
let body = '';
|
||||
req.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
if (body.length > 4096) {
|
||||
sendJson(res, 413, { error: 'body too large' });
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (!current) return void sendJson(res, 503, { error: 'module not mounted' });
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(body);
|
||||
} catch {
|
||||
return void sendJson(res, 400, { error: 'invalid JSON' });
|
||||
}
|
||||
const problem = current.ingest(parsed);
|
||||
if (problem) return void sendJson(res, 400, { error: problem });
|
||||
sendJson(res, 200, { ok: true });
|
||||
});
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ProPresenter stage-display websocket client — the LTC timecode source.
|
||||
*
|
||||
* ProPresenter's HTTP openAPI exposes no timecode route (see server.js), but
|
||||
* its classic stage-display websocket (ws://host:port/stagedisplay — the
|
||||
* Pro6-era protocol, still answered by ProPresenter 21; verified live: it
|
||||
* responds `{"acn":"ath","ath":…,"majorVersion":7,"minorVersion":21}`) streams
|
||||
* the text of stage-layout fields. So: the operator puts a field labeled
|
||||
* "LTC" on a stage layout, and this client reads that field's live text.
|
||||
*
|
||||
* Protocol (community-documented, github.com/jeffmikels/ProPresenter-API):
|
||||
* → {"pwd":"<stage password>","ptl":610,"acn":"ath"}
|
||||
* ← {"acn":"ath","ath":true|false,"err":"…"}
|
||||
* → {"acn":"psl"} ← {"acn":"psl","uid":"<layout uid>"}
|
||||
* → {"acn":"asl"} ← {"acn":"asl","ary":[{acn:"sl",uid,nme,fme:[…]},…]}
|
||||
* ← {"acn":"sys","txt":" 11:17 AM"} every second — our liveness signal
|
||||
* ← {"acn":"tmr","uid":"…","txt":"…"} live text for a layout field's uid
|
||||
*
|
||||
* A layout's `fme` frames carry `nme` (the label) and, for linked fields, a
|
||||
* `uid`. We bind to the first frame labeled "LTC" (case-insensitive), then
|
||||
* treat any message carrying that uid as the timecode text — matching by uid
|
||||
* rather than acn on purpose, since how ProPresenter 21 tags timecode
|
||||
* updates is undocumented. "Receiving" is a staleness heuristic: LTC text
|
||||
* that hasn't advanced for STALE_MS is a frozen/no-signal feed.
|
||||
*
|
||||
* Zero-dependency by design (like the rest of ProdDash): a minimal RFC6455
|
||||
* client — masked client frames, text + ping/pong + close, fragmented text
|
||||
* reassembly. TLS is not needed (ProPresenter's server is plain ws://).
|
||||
*/
|
||||
|
||||
const net = require('net');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const RECONNECT_MS = 3000;
|
||||
/** Auth rejections retry slowly — a wrong password never fixes itself fast. */
|
||||
const BAD_AUTH_RETRY_MS = 30000;
|
||||
/** ProPresenter sends a clock tick every second; silence this long = dead link. */
|
||||
const LIVENESS_MS = 10000;
|
||||
/** LTC text that has not advanced for this long counts as no-signal. */
|
||||
const STALE_MS = 2500;
|
||||
|
||||
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||
|
||||
const ZERO_UID = '00000000-0000-0000-0000-000000000000';
|
||||
/** Timecode-shaped text: a fourth (frames) group separates it from timers. */
|
||||
const TC_SHAPE = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
|
||||
|
||||
/**
|
||||
* @param {{host:string, port:number, password:string, log:Function,
|
||||
* isTimerUid?:(uid:string)=>boolean,
|
||||
* onUpdate:(ltc:{bound:boolean,time:string,receiving:boolean,note:string})=>void}} opts
|
||||
* @returns {{stop:()=>void}}
|
||||
*/
|
||||
function createStageLtcClient({ host, port, password, log, isTimerUid, onUpdate }) {
|
||||
let sock = null;
|
||||
let stopped = false;
|
||||
let reconnectTimer = null;
|
||||
let livenessTimer = null;
|
||||
let staleTimer = null;
|
||||
|
||||
let ltcUid = ''; // uid of the frame labeled LTC, once bound
|
||||
let boundBy = ''; // 'label' (layout frame) or 'heuristic' (sniffed)
|
||||
let candidateUid = ''; // heuristic candidate awaiting a second, advancing sample
|
||||
let candidateText = '';
|
||||
let lastText = '';
|
||||
let lastAdvance = 0;
|
||||
// Known-but-unused message types; anything else is logged once so a new
|
||||
// ProPresenter's timecode tag shows up in the server log for discovery.
|
||||
const loggedUnknownAcn = new Set(['fv', 'msg', 'vid', 'tmr', 'cs', 'ns', 'csn', 'nsn']);
|
||||
|
||||
function emit(partial) {
|
||||
onUpdate({
|
||||
bound: Boolean(ltcUid),
|
||||
time: lastText,
|
||||
receiving: false,
|
||||
note: '',
|
||||
...partial,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── outgoing frames (client → server must be masked) ───────────── */
|
||||
|
||||
function sendFrame(opcode, payload) {
|
||||
if (!sock || sock.destroyed) return;
|
||||
const mask = crypto.randomBytes(4);
|
||||
const len = payload.length;
|
||||
let header;
|
||||
if (len < 126) {
|
||||
header = Buffer.from([0x80 | opcode, 0x80 | len]);
|
||||
} else if (len < 65536) {
|
||||
header = Buffer.alloc(4);
|
||||
header[0] = 0x80 | opcode;
|
||||
header[1] = 0x80 | 126;
|
||||
header.writeUInt16BE(len, 2);
|
||||
} else {
|
||||
header = Buffer.alloc(10);
|
||||
header[0] = 0x80 | opcode;
|
||||
header[1] = 0x80 | 127;
|
||||
header.writeBigUInt64BE(BigInt(len), 2);
|
||||
}
|
||||
const masked = Buffer.from(payload);
|
||||
for (let i = 0; i < masked.length; i += 1) masked[i] ^= mask[i % 4];
|
||||
try {
|
||||
sock.write(Buffer.concat([header, mask, masked]));
|
||||
} catch { /* socket died mid-write; liveness/close handling reconnects */ }
|
||||
}
|
||||
|
||||
function sendJson(obj) {
|
||||
sendFrame(0x1, Buffer.from(JSON.stringify(obj)));
|
||||
}
|
||||
|
||||
/* ── protocol: layouts → find the LTC-labeled frame ─────────────── */
|
||||
|
||||
let assignedLayoutUid = '';
|
||||
|
||||
function bindFromLayouts(layouts) {
|
||||
const isLtcFrame = (f) => String(f?.nme || '').trim().toLowerCase() === 'ltc';
|
||||
const pick = (layout) => (Array.isArray(layout?.fme) ? layout.fme.find(isLtcFrame) : null);
|
||||
// Prefer the layout ProPresenter assigned to this client; fall back to
|
||||
// any layout with an LTC-labeled frame.
|
||||
const ordered = layouts.slice().sort((a, b) =>
|
||||
(b.uid === assignedLayoutUid ? 1 : 0) - (a.uid === assignedLayoutUid ? 1 : 0));
|
||||
for (const layout of ordered) {
|
||||
const frame = pick(layout);
|
||||
if (frame && frame.uid) {
|
||||
if (ltcUid !== frame.uid) {
|
||||
ltcUid = String(frame.uid);
|
||||
boundBy = 'label';
|
||||
log(`stage display: bound LTC to field "${frame.nme}" (${ltcUid}) in layout "${layout.nme || layout.uid}"`);
|
||||
emit({ receiving: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (frame && !frame.uid) {
|
||||
log(`stage display: layout "${layout.nme}" has an LTC field with no uid — it never streams; link it to a timecode/timer source`);
|
||||
}
|
||||
}
|
||||
// Verified on ProPresenter 21.4: the legacy protocol (the only version
|
||||
// it accepts, ptl 610) omits stage objects it predates — a timecode
|
||||
// field never appears in any layout's frame list. So a heuristic
|
||||
// binding sniffed from the live stream must survive layout refreshes.
|
||||
if (boundBy === 'heuristic' && ltcUid) return;
|
||||
ltcUid = '';
|
||||
boundBy = '';
|
||||
emit({ bound: false, time: '', note: 'No LTC field visible on the stage-display feed' });
|
||||
}
|
||||
|
||||
function handleMessage(obj) {
|
||||
const acn = String(obj.acn || '');
|
||||
switch (acn) {
|
||||
case 'ath':
|
||||
if (obj.ath === true) {
|
||||
log('stage display: authenticated');
|
||||
sendJson({ acn: 'psl' }); // which layout are we assigned?
|
||||
sendJson({ acn: 'asl' }); // all layouts, with their frames
|
||||
} else {
|
||||
const err = String(obj.err || 'authentication rejected');
|
||||
log(`stage display: auth failed — ${err}`);
|
||||
emit({ bound: false, time: '', note: 'Stage display password rejected — set it in Admin' });
|
||||
scheduleReconnect(BAD_AUTH_RETRY_MS);
|
||||
destroySocket();
|
||||
}
|
||||
return;
|
||||
case 'psl':
|
||||
assignedLayoutUid = String(obj.uid || '');
|
||||
return;
|
||||
case 'asl':
|
||||
if (Array.isArray(obj.ary)) bindFromLayouts(obj.ary);
|
||||
return;
|
||||
case 'sl':
|
||||
// A layout definition was pushed (assignment or edit) — it may not
|
||||
// be ours, so re-resolve from the authoritative full list instead
|
||||
// of rebinding blindly.
|
||||
sendJson({ acn: 'psl' });
|
||||
sendJson({ acn: 'asl' });
|
||||
return;
|
||||
case 'sys': // per-second clock — liveness only
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Heuristic fallback: with no LTC-labeled frame to bind to, an unknown
|
||||
// uid streaming timecode-shaped text (a fourth :FF group — timers never
|
||||
// have one) that is NOT a configured timer can only be the timecode
|
||||
// field. Require two advancing samples before trusting it.
|
||||
if (!ltcUid && obj.uid && obj.uid !== ZERO_UID && typeof obj.txt === 'string'
|
||||
&& TC_SHAPE.test(obj.txt.trim()) && !(isTimerUid && isTimerUid(obj.uid))) {
|
||||
if (candidateUid === obj.uid && candidateText !== obj.txt) {
|
||||
ltcUid = String(obj.uid);
|
||||
boundBy = 'heuristic';
|
||||
log(`stage display: no LTC-labeled field in any layout, but ${obj.uid} streams timecode-shaped text (acn "${acn}") — binding it as LTC`);
|
||||
// fall through to the normal update path below
|
||||
} else {
|
||||
candidateUid = String(obj.uid);
|
||||
candidateText = obj.txt;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Anything carrying our bound uid is the LTC text, whatever its acn —
|
||||
// ProPresenter 21's tag for timecode fields is undocumented.
|
||||
if (ltcUid && obj.uid === ltcUid && typeof obj.txt === 'string') {
|
||||
const txt = obj.txt.trim();
|
||||
const placeholder = !txt || /^[-–—:;.\s]*$/.test(txt);
|
||||
if (placeholder) {
|
||||
lastText = '';
|
||||
emit({ receiving: false });
|
||||
return;
|
||||
}
|
||||
if (txt !== lastText) {
|
||||
lastText = txt;
|
||||
lastAdvance = Date.now();
|
||||
emit({ receiving: true });
|
||||
clearTimeout(staleTimer);
|
||||
staleTimer = setTimeout(() => {
|
||||
// Frozen text = LTC stopped arriving at ProPresenter.
|
||||
emit({ receiving: false });
|
||||
}, STALE_MS);
|
||||
staleTimer.unref?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (acn && !loggedUnknownAcn.has(acn) && loggedUnknownAcn.size < 30) {
|
||||
loggedUnknownAcn.add(acn);
|
||||
log(`stage display: unhandled message type "${acn}" — ${JSON.stringify(obj).slice(0, 160)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── incoming frames ────────────────────────────────────────────── */
|
||||
|
||||
function connect() {
|
||||
if (stopped) return;
|
||||
let buf = Buffer.alloc(0);
|
||||
let upgraded = false;
|
||||
let fragments = null;
|
||||
const key = crypto.randomBytes(16).toString('base64');
|
||||
|
||||
sock = net.connect(port, host);
|
||||
sock.setNoDelay(true);
|
||||
|
||||
const bumpLiveness = () => {
|
||||
clearTimeout(livenessTimer);
|
||||
livenessTimer = setTimeout(() => {
|
||||
log('stage display: silent too long — reconnecting');
|
||||
destroySocket();
|
||||
scheduleReconnect(RECONNECT_MS);
|
||||
}, LIVENESS_MS);
|
||||
livenessTimer.unref?.();
|
||||
};
|
||||
|
||||
sock.on('connect', () => {
|
||||
sock.write(
|
||||
`GET /stagedisplay HTTP/1.1\r\n` +
|
||||
`Host: ${host}:${port}\r\n` +
|
||||
`Upgrade: websocket\r\nConnection: Upgrade\r\n` +
|
||||
`Sec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
|
||||
bumpLiveness();
|
||||
});
|
||||
|
||||
sock.on('data', (chunk) => {
|
||||
bumpLiveness();
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
|
||||
if (!upgraded) {
|
||||
const idx = buf.indexOf('\r\n\r\n');
|
||||
if (idx === -1) return;
|
||||
const head = buf.slice(0, idx).toString();
|
||||
buf = buf.slice(idx + 4);
|
||||
const statusLine = head.split('\r\n')[0] || '';
|
||||
if (!/ 101 /.test(statusLine + ' ')) {
|
||||
log(`stage display: no websocket upgrade (${statusLine.trim()}) — this ProPresenter may not serve /stagedisplay`);
|
||||
emit({ bound: false, time: '', note: 'No stage display websocket on this ProPresenter' });
|
||||
destroySocket();
|
||||
scheduleReconnect(BAD_AUTH_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
const accept = crypto.createHash('sha1').update(key + WS_MAGIC).digest('base64');
|
||||
const m = head.match(/sec-websocket-accept:\s*(\S+)/i);
|
||||
if (m && m[1] !== accept) log('stage display: Sec-WebSocket-Accept mismatch (continuing)');
|
||||
upgraded = true;
|
||||
sendJson({ pwd: String(password || ''), ptl: 610, acn: 'ath' });
|
||||
}
|
||||
|
||||
// decode server frames (unmasked; tolerate masked anyway)
|
||||
while (buf.length >= 2) {
|
||||
const fin = (buf[0] & 0x80) !== 0;
|
||||
const opcode = buf[0] & 0x0f;
|
||||
const maskBit = (buf[1] & 0x80) !== 0;
|
||||
let len = buf[1] & 0x7f;
|
||||
let off = 2;
|
||||
if (len === 126) {
|
||||
if (buf.length < 4) return;
|
||||
len = buf.readUInt16BE(2);
|
||||
off = 4;
|
||||
} else if (len === 127) {
|
||||
if (buf.length < 10) return;
|
||||
len = Number(buf.readBigUInt64BE(2));
|
||||
off = 10;
|
||||
}
|
||||
const maskLen = maskBit ? 4 : 0;
|
||||
if (buf.length < off + maskLen + len) return;
|
||||
let payload = buf.slice(off + maskLen, off + maskLen + len);
|
||||
if (maskBit) {
|
||||
const mask = buf.slice(off, off + 4);
|
||||
payload = Buffer.from(payload);
|
||||
for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
|
||||
}
|
||||
buf = buf.slice(off + maskLen + len);
|
||||
|
||||
if (opcode === 0x9) { // ping → pong
|
||||
sendFrame(0xA, payload);
|
||||
continue;
|
||||
}
|
||||
if (opcode === 0x8) { // close
|
||||
destroySocket();
|
||||
scheduleReconnect(RECONNECT_MS);
|
||||
return;
|
||||
}
|
||||
if (opcode === 0x1 || (opcode === 0x0 && fragments)) {
|
||||
if (!fin || opcode === 0x0) { // fragmented text
|
||||
fragments = fragments ? Buffer.concat([fragments, payload]) : payload;
|
||||
if (!fin) continue;
|
||||
payload = fragments;
|
||||
fragments = null;
|
||||
}
|
||||
let obj;
|
||||
try {
|
||||
obj = JSON.parse(payload.toString());
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (obj && typeof obj === 'object') handleMessage(obj);
|
||||
}
|
||||
// binary frames (slide images) are ignored
|
||||
}
|
||||
});
|
||||
|
||||
sock.on('error', () => { /* close handler reconnects */ });
|
||||
sock.on('close', () => {
|
||||
clearTimeout(livenessTimer);
|
||||
if (stopped) return;
|
||||
if (!reconnectTimer) {
|
||||
emit({ receiving: false });
|
||||
scheduleReconnect(RECONNECT_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function destroySocket() {
|
||||
if (sock) {
|
||||
try { sock.destroy(); } catch { /* already gone */ }
|
||||
sock = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect(ms) {
|
||||
if (stopped || reconnectTimer) return;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, ms);
|
||||
reconnectTimer.unref?.();
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(reconnectTimer);
|
||||
clearTimeout(livenessTimer);
|
||||
clearTimeout(staleTimer);
|
||||
destroySocket();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createStageLtcClient };
|
||||
@@ -215,8 +215,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-waiting .tm-time { color: var(--muted); }
|
||||
|
||||
/* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
|
||||
|
||||
[data-module="propresenter-timers"] .tm-menu-item {
|
||||
|
||||
Reference in New Issue
Block a user