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:
Waters Church
2026-09-10 13:29:06 -04:00
co-authored by Claude Opus 4.8
parent 7e75146838
commit 1d46657e06
14 changed files with 386 additions and 1119 deletions
+15 -24
View File
@@ -175,31 +175,22 @@ below. Nothing is installed without someone clicking.
elapsed) and LTC timecode as auto-sized cards. The + picker lists every elapsed) and LTC timecode as auto-sized cards. The + picker lists every
discovered timer (and the LTC card) as its own tile, or add "All timers" discovered timer (and the LTC card) as its own tile, or add "All timers"
and pick a selection via the tile's "Timers ▾" menu; overruns are and pick a selection via the tile's "Timers ▾" menu; overruns are
unmistakably red. Read-only — no start/stop controls. Admin config: unmistakably red. Read-only — no start/stop controls. Admin config is in
ProPresenter endpoint (host : port), API password, the LTC listener two groups: **ProPresenter** (endpoint host : port, API password) and
switch + audio device + channel, LTC reader token, stage display **LTC Timecode** (the listener switch, and — when it's on — the audio
password. device and channel).
LTC lives behind the admin **LTC listener** switch — off means no LTC ProPresenter exposes no timecode over any API, so LTC is decoded from the
tile in the picker at all. On, the sources, best first (ProPresenter's audio signal, behind the admin **LTC listener** switch (off ⇒ no LTC tile
HTTP API has no timecode route): in the picker at all). On, the module captures the selected audio input
1. **Built-in listener** — the module captures the admin-selected audio device/channel on the ProdDash machine — a native `ltc-capture` helper,
input device/channel on the ProdDash machine (native `ltc-capture` compiled on first use — and decodes the SMPTE frames in-process:
helper, compiled on first use) and decodes the SMPTE frames running/stopped/no-signal, the timecode counting frame-accurately in real
in-process: running/stopped/no-signal, timecode, frame rate (incl. time, and the frame rate (incl. 29.97 drop-frame). macOS note: the
29.97 drop-frame). macOS note: the ProdDash server needs microphone ProdDash server needs microphone permission — launch it from a logged-in
permission — launch it from a logged-in session (Start session (Start ProdDash.command) and approve the one-time prompt; a server
ProdDash.command) and approve the one-time prompt; a server started started over SSH or by bare launchd silently captures zeros. (Engine
over SSH or by bare launchd silently captures zeros. self-test: `node modules/propresenter-timers/ltc.selftest.js`.)
2. **Remote reader** — when a *different* machine hears the LTC, run
`tools/ltc-reader.js` there; it decodes the same way from a PCM pipe
(`ltc-capture`, ffmpeg, or sox; `--channels 18 --ch 5` picks one
input of a multichannel interface — never downmix LTC into program
audio) and POSTs to the module's `/ltc` route. `--selftest` checks
the decoder; `--demo` feeds synthetic LTC end-to-end with no audio
hardware.
3. A stage-display layout field labeled "LTC", read over ProPresenter's
stage websocket — value and freshness only, no frame rate.
- **Clock** — a big booth clock; also the smallest possible module and the - **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors. reference for module authors.
+16 -2
View File
@@ -99,21 +99,35 @@ the app); its settings are kept for a later reinstall.
Both schemas map field names to specs: Both schemas map field names to specs:
``` ```
{ "type": "string" | "number" | "boolean" | "select" | "password" | "endpoint", { "type": "string" | "number" | "boolean" | "switch" | "select" | "password" | "endpoint",
"label": "Shown next to the field", "label": "Shown next to the field",
"default": <value>, "default": <value>,
"group": "Section heading", // optional; see below
"showWhen": "<other boolean/switch key>", // optional; see below
"options": [ { "value": "a", "label": "A" }, … ], // "select" only "options": [ { "value": "a", "label": "A" }, … ], // "select" only
"optionsRoute": "/devices" // "select" only, admin config only "optionsRoute": "/devices" // "select" only, admin config only
} }
``` ```
`boolean` renders as a checkbox; `switch` is the same true/false value shown
as a toggle (use it for a feature on/off that other fields depend on).
`group` gathers consecutive fields under a labeled subsection in the admin
page, so a module with several distinct concerns (e.g. a ProPresenter
connection and an LTC audio input) reads as clearly separated blocks. Fields
with no `group` render loose, before/after the grouped ones in schema order.
`showWhen: "<key>"` hides a field until the boolean/`switch` field named
`<key>` is on — e.g. the audio device and channel appear only once the LTC
listener switch is enabled. The hidden field still keeps its stored value.
A `select` whose choices are only knowable at runtime (audio devices, serial A `select` whose choices are only knowable at runtime (audio devices, serial
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
refresh of — static `options`: the admin page GETs refresh of — static `options`: the admin page GETs
`/api/modules/<id><optionsRoute>` and expects `{ "options": [ { "value", `/api/modules/<id><optionsRoute>` and expects `{ "options": [ { "value",
"label" }, … ] }` from one of the module's own server routes. The saved value "label" }, … ] }` from one of the module's own server routes. The saved value
stays selectable even when the route is down or the option has vanished. (See stays selectable even when the route is down or the option has vanished. (See
`propresenter-timers`' "LTC audio device" for a worked example.) `propresenter-timers`' "Audio device" for a worked example.)
`password` fields are admin-config only in practice: their values are stored `password` fields are admin-config only in practice: their values are stored
in the server's `modules.json` (in the per-machine data directory, see the in the server's `modules.json` (in the per-machine data directory, see the
+9 -16
View File
@@ -262,9 +262,8 @@ export default function create({ root, moduleApi }) {
function updateLtcAnchor() { function updateLtcAnchor() {
const l = state?.ltc; const l = state?.ltc;
// Only the audio sources are extrapolatable — they know the frame rate. // The listener is extrapolatable — it knows the frame rate.
const live = l && l.status === 'running' && l.fps > 0 const live = l && l.status === 'running' && l.fps > 0 && l.source === 'listener';
&& (l.source === 'listener' || l.source === 'reader');
const tc = live ? parseTc(l.time) : null; const tc = live ? parseTc(l.time) : null;
if (!tc) { if (!tc) {
ltcAnchor = null; ltcAnchor = null;
@@ -323,14 +322,9 @@ export default function create({ root, moduleApi }) {
if (ltc.supported === false) { if (ltc.supported === false) {
mode = 'unavailable'; mode = 'unavailable';
card.timeEl.textContent = '—'; card.timeEl.textContent = '—';
// The server says why when it knows (stage password rejected, no // The server says why when it knows (listener disabled, no device
// stage field labeled LTC, reader offline, …) — surface that over // selected, capture failed, starting up…) — surface that.
// the generic line. card.stateEl.textContent = ltc.note || 'LTC not available';
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…';
} else if (ltc.receiving) { } else if (ltc.receiving) {
mode = 'receiving'; mode = 'receiving';
// With an anchor active, paint the frame it implies for now (this // 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.timeEl.textContent = '—';
card.stateEl.textContent = 'No signal'; card.stateEl.textContent = 'No signal';
} }
// The built-in listener and the remote reader are independent of // The built-in listener is independent of ProPresenter, so its card
// ProPresenter, so their card must not gray out with the rest when // must not gray out with the rest when only ProPresenter is unreachable.
// only ProPresenter is unreachable. const live = ltc.source === 'listener' ? ' src-reader' : '';
const live = ltc.source === 'reader' || ltc.source === 'listener' ? ' src-reader' : '';
card.el.className = `tm-card tm-card-ltc is-ltc-${mode}${live}`; 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) { if (!state.enabled) {
bannerEl.hidden = true; 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. // LTC-only setup is working, not broken.
if (state.ltc?.supported) moduleApi.setStatus('ok', 'LTC only — no ProPresenter host configured'); 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'); 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 // streams the device's inputs as interleaved s16le to stdout, at the
// device's own sample rate and channel count (both printed to stderr // device's own sample rate and channel count (both printed to stderr
// at start). The name matches exactly first, then as a substring. // 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 // --device matches case-insensitively on a substring of the device name or
// UID; ambiguity is an error listing the candidates. Capture uses an // UID; ambiguity is an error listing the candidates. Capture uses an
+5 -7
View File
@@ -2,14 +2,12 @@
/** /**
* LTC engine for the propresenter-timers module — SMPTE LTC decode from PCM, * 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), * 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 * All design notes and the full protocol commentary live with the classes
* other than the ProdDash server. All design notes and the full protocol * below; the encoder exists so the decode path can be proven end-to-end
* commentary live with the classes below; the encoder exists so the decode * without timecode hardware (see ltc.selftest.js).
* path can be proven end-to-end without timecode hardware (see the reader's
* --selftest).
*/ */
/* ══ decoder ═══════════════════════════════════════════════════════════ */ /* ══ decoder ═══════════════════════════════════════════════════════════ */
+194
View File
@@ -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();
+13 -25
View File
@@ -7,53 +7,41 @@
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
"minSize": { "minSize": { "w": 2, "h": 1 },
"w": 2, "defaultSize": { "w": 4, "h": 2 },
"h": 1
},
"defaultSize": {
"w": 4,
"h": 2
},
"configSchema": { "configSchema": {
"propresenter": { "propresenter": {
"type": "endpoint", "type": "endpoint",
"label": "ProPresenter", "label": "ProPresenter",
"default": { "group": "ProPresenter",
"host": "", "default": { "host": "", "port": 1025 }
"port": 1025
}
}, },
"password": { "password": {
"type": "password", "type": "password",
"label": "API password (only if set in ProPresenter)", "label": "API password (only if set in ProPresenter)",
"group": "ProPresenter",
"default": "" "default": ""
}, },
"ltcEnabled": { "ltcEnabled": {
"type": "boolean", "type": "switch",
"label": "LTC listener — decode timecode from an audio input on this machine", "label": "LTC listener — decode timecode from an audio input on this machine",
"group": "LTC Timecode",
"default": false "default": false
}, },
"ltcDevice": { "ltcDevice": {
"type": "select", "type": "select",
"label": "LTC audio device", "label": "Audio device",
"group": "LTC Timecode",
"showWhen": "ltcEnabled",
"default": "", "default": "",
"optionsRoute": "/ltc/devices" "optionsRoute": "/ltc/devices"
}, },
"ltcChannel": { "ltcChannel": {
"type": "number", "type": "number",
"label": "LTC channel on that device (1-based)", "label": "Channel on that device (1-based)",
"group": "LTC Timecode",
"showWhen": "ltcEnabled",
"default": 1 "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": ""
} }
} }
} }
+43 -238
View File
@@ -3,15 +3,13 @@
/** /**
* ProPresenter Timers — server part. * ProPresenter Timers — server part.
* *
* Polls the ProPresenter openAPI for the configured timer list, the live * Polls the ProPresenter openAPI for the configured timer list and the live
* timer values, and (when the connected build exposes it) LTC timecode, and * timer values, decodes LTC timecode from an audio input, and fans one
* fans one merged state object out to every tile over SSE. Browsers never * merged state object out to every tile over SSE. Browsers never talk to
* talk to ProPresenter directly. * ProPresenter or the audio hardware directly.
* *
* GET /state → { state: <state> } (instant paint for new tiles) * GET /state → { state: <state> } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated) * 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 * GET /ltc/devices → this machine's audio input devices, for the admin
* page's device picker (select + optionsRoute). * page's device picker (select + optionsRoute).
* *
@@ -25,26 +23,14 @@
* `state` enum is stopped|running|complete|overrunning|overran — but the * `state` enum is stopped|running|complete|overrunning|overran — but the
* spec's own response example says "overrun", so treat anything containing * spec's own response example says "overrun", so treat anything containing
* "overr" as overrun. The API exposes NO per-timer color anywhere. * "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 * LTC: ProPresenter exposes no timecode over any API (the HTTP openAPI has
* itself. The whole LTC feature sits behind the admin "LTC listener" * no timecode route, and the classic stage-display websocket drops the
* switch — off means no LTC tile in the picker, no probes, and the ingest * field — both verified dead on ProPresenter 21.4), so LTC is read from the
* route answers 409. When on, the sources in precedence order: * audio signal itself. Behind the admin "LTC listener" switch: off means no
* 1. the built-in listener (ltc-listener.js — this machine captures the * LTC tile in the picker and nothing captured; on, the built-in listener
* admin-selected device/channel and decodes in-process) while it has * (ltc-listener.js) captures the admin-selected device/channel and decodes
* actual data; * in-process. It is the module's one and only LTC source.
* 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).
* *
* ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path * ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path
* notes in ../propresenter-now-next/propresenter-core). So: requests run * notes in ../propresenter-now-next/propresenter-core). So: requests run
@@ -64,12 +50,8 @@ const SLOW_EVERY = 4;
const REQUEST_TIMEOUT_MS = 4000; const REQUEST_TIMEOUT_MS = 4000;
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ /** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000; 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 { spawn } = require('child_process');
const { createStageLtcClient } = require('./stage-ws');
const { createLtcListener } = require('./ltc-listener'); const { createLtcListener } = require('./ltc-listener');
/** Set by init(), read by the routes — both are rebuilt together on remount. */ /** Set by init(), read by the routes — both are rebuilt together on remount. */
@@ -82,12 +64,11 @@ function clearedState(enabled, ltcEnabled = false) {
reachable: false, reachable: false,
lastError: '', lastError: '',
timers: [], timers: [],
// supported: null = no verdict yet (probing, or ProPresenter // supported true once the listener has a verdict; false when the
// unreachable); true once a timecode source is live; false when every // listener is off, has no device, or its capture failed (note says
// source came up empty (note says why, when we know). status is the // why). status is running|stopped|nosignal; while running, ageMs lets
// richer verdict (running|stopped|nosignal); fps/df only when the // clients count frames locally between updates.
// source knows them (the reader decodes both from the LTC bits). ltc: { supported: false, time: '', receiving: false, status: '', fps: 0, df: false, source: '', note: '' },
ltc: { supported: null, 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. */ /** uuid → { name, index, type, allowsOverrun } from /v1/timers, in list order. */
let timerConfigs = new Map(); let timerConfigs = new Map();
/** null = probe /v1/timecode/status on the next reachable tick. */ /** Built-in listener's latest word: { state, time, fps, df, ageMs } | { error }. */
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 }. */
let listenerLtc = null; let listenerLtc = null;
/** One LTC verdict from every source, in the precedence the header /** The LTC verdict — the built-in listener is the only source. */
comment explains. Text sources don't know fps — receiving/frozen/
empty maps onto the same status vocabulary the audio decode uses. */
function composeLtc() { function composeLtc() {
if (!ltcEnabled) { if (!ltcEnabled) {
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' }; 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, supported: true,
source: tag, source: 'listener',
time: src.time, time: listenerLtc.time,
receiving: src.state === 'running', receiving: running,
status: src.state, status: listenerLtc.state,
fps: src.fps, fps: listenerLtc.fps,
df: src.df, df: listenerLtc.df,
// While running: how old this timecode is, so clients can pin the // While running: how old this timecode is, so clients can pin the
// anchor to their own clock and count frames between updates. // 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: '', 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) { function url(target) {
@@ -283,61 +203,6 @@ module.exports = {
return out; 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() { function broadcast() {
latest = { latest = {
enabled, enabled,
@@ -377,43 +242,21 @@ module.exports = {
} }
// THE HOT PATH — live values every tick. // THE HOT PATH — live values every tick.
const currentTimes = await requestJson('/v1/timers/current'); 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.timers = mergeTimers(currentTimes);
latest.reachable = true; latest.reachable = true;
latest.lastError = ''; latest.lastError = '';
} catch (err) { } catch (err) {
latest.reachable = false; latest.reachable = false;
latest.lastError = err instanceof Error ? err.message : String(err); 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(); broadcast();
if (stopped) return; if (stopped) return;
pollTimer = setTimeout(pollTick, latest.reachable ? POLL_MS : RETRY_MS); pollTimer = setTimeout(pollTick, latest.reachable ? POLL_MS : RETRY_MS);
} }
let stageClient = null;
if (enabled) { if (enabled) {
pollTick(); pollTick();
log(`polling timers on ${host}:${port}`); 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 { } else {
log('no ProPresenter host configured — set one in /admin'); log('no ProPresenter host configured — set one in /admin');
} }
@@ -450,15 +293,13 @@ module.exports = {
}, HEARTBEAT_MS); }, HEARTBEAT_MS);
heartbeat.unref?.(); heartbeat.unref?.();
current = { streams, getLatest: () => latest, ingest: applyIngest }; current = { streams, getLatest: () => latest };
return { return {
stop() { stop() {
stopped = true; stopped = true;
clearTimeout(pollTimer); clearTimeout(pollTimer);
clearTimeout(ingestExpiry);
clearInterval(heartbeat); clearInterval(heartbeat);
stageClient?.stop();
ltcListener?.stop(); ltcListener?.stop();
for (const res of streams) { for (const res of streams) {
try { res.end(); } catch { /* already gone */ } try { res.end(); } catch { /* already gone */ }
@@ -467,20 +308,19 @@ module.exports = {
if (current && current.streams === streams) current = null; if (current && current.streams === streams) current = null;
}, },
health() { 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) { 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. // 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' }; return { status: 'error', message: 'No ProPresenter host configured' };
} }
if (latest.reachable) { 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 { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` };
} }
return { return {
@@ -531,7 +371,7 @@ module.exports = {
]; ];
}, },
routes({ config }) { routes() {
const sendJson = (res, code, body) => { const sendJson = (res, code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body)); 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) => { 'GET /stream': (req, res) => {
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8', 'Content-Type': 'text/event-stream; charset=utf-8',
-379
View File
@@ -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 };
-2
View File
@@ -215,8 +215,6 @@
text-align: center; 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) ── */ /* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */
[data-module="propresenter-timers"] .tm-menu-item { [data-module="propresenter-timers"] .tm-menu-item {
+33
View File
@@ -113,6 +113,39 @@ body { overflow: auto; }
.endpoint-row .ep-sep { color: var(--muted); font-weight: 600; } .endpoint-row .ep-sep { color: var(--muted); font-weight: 600; }
.module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; } .module-form-empty { color: var(--muted); font-size: 12px; margin-top: 10px; }
/* labeled config subsections (manifest field `group`) */
.field-group {
margin: 14px 0 4px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 9px;
}
.field-group-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted);
margin-bottom: 10px;
}
.field-group .field:last-child { margin-bottom: 0; }
/* showWhen: a hidden dependent field must actually collapse — `.field`'s
own `display:flex` (an author rule) otherwise outranks the [hidden] UA
default and the field stays visible. */
.module-form .field[hidden] { display: none; }
/* a boolean rendered as a toggle (manifest type `switch`) */
.field.switch-field {
flex-direction: row;
align-items: center;
gap: 10px;
color: var(--text);
cursor: pointer;
max-width: none;
}
.field.switch-field .switch-label { font-size: 13px; }
.module-actions { .module-actions {
display: flex; display: flex;
align-items: center; align-items: center;
+57 -5
View File
@@ -102,15 +102,35 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
const type = spec?.type || 'string'; const type = spec?.type || 'string';
const label = spec?.label || key; const label = spec?.label || key;
const field = document.createElement('label'); const field = document.createElement('label');
field.className = 'field' + (type === 'boolean' ? ' check' : ''); field.className = 'field' + (type === 'boolean' ? ' check' : '') + (type === 'switch' ? ' switch-field' : '');
let read; let read;
// For boolean/switch fields, the control other fields may depend on
// (showWhen) — returned so the caller can wire visibility.
let control = null;
if (type === 'boolean') { if (type === 'switch') {
// Same toggle as the module enable switch, inline with its label.
const toggle = document.createElement('span');
toggle.className = 'switch';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = Boolean(value);
const track = document.createElement('span');
track.className = 'track';
toggle.append(input, track);
const text = document.createElement('span');
text.className = 'switch-label';
text.textContent = label;
field.append(toggle, text);
read = () => input.checked;
control = input;
} else if (type === 'boolean') {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'checkbox'; input.type = 'checkbox';
input.checked = Boolean(value); input.checked = Boolean(value);
field.append(input, document.createTextNode(label)); field.append(input, document.createTextNode(label));
read = () => input.checked; read = () => input.checked;
control = input;
} else if (type === 'endpoint') { } else if (type === 'endpoint') {
// One consistent "Host / IP : Port" control for every module's upstream. // One consistent "Host / IP : Port" control for every module's upstream.
const span = document.createElement('span'); const span = document.createElement('span');
@@ -185,7 +205,7 @@ function fieldFor(key, spec, value, passwordSet, moduleId) {
field.append(span, input); field.append(span, input);
read = () => (type === 'number' ? Number(input.value) : input.value); read = () => (type === 'number' ? Number(input.value) : input.value);
} }
return { field, read }; return { field, read, control };
} }
function buildModuleCard(mod) { function buildModuleCard(mod) {
@@ -298,10 +318,42 @@ function buildModuleCard(mod) {
const form = document.createElement('div'); const form = document.createElement('div');
form.className = 'module-form'; form.className = 'module-form';
const readers = new Map(); const readers = new Map();
const controls = new Map(); // key → boolean/switch input (for showWhen)
const dependents = []; // { field, when } to show/hide by a control's state
// Fields render in schema order; a `group` starts a labeled subsection
// and consecutive same-group fields share it.
let groupName = null;
let container = form;
for (const [key, spec] of schemaKeys) { for (const [key, spec] of schemaKeys) {
const { field, read } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key], mod.id); const g = spec?.group || '';
if (g !== groupName) {
groupName = g;
if (g) {
const group = document.createElement('div');
group.className = 'field-group';
const title = document.createElement('div');
title.className = 'field-group-title';
title.textContent = g;
group.appendChild(title);
form.appendChild(group);
container = group;
} else {
container = form;
}
}
const { field, read, control } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key], mod.id);
readers.set(key, read); readers.set(key, read);
form.appendChild(field); if (control) controls.set(key, control);
if (spec?.showWhen) dependents.push({ field, when: spec.showWhen });
container.appendChild(field);
}
// A field with showWhen:"K" is visible only while K's toggle is on.
for (const dep of dependents) {
const ctl = controls.get(dep.when);
if (!ctl) continue;
const sync = () => { dep.field.hidden = !ctl.checked; };
ctl.addEventListener('change', sync);
sync();
} }
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'module-actions'; actions.className = 'module-actions';
+1 -1
View File
@@ -993,7 +993,7 @@ function applyConfigPatch(id, patch) {
value = current[key] || ''; value = current[key] || '';
} }
if (type === 'number') value = Number(value) || 0; if (type === 'number') value = Number(value) || 0;
else if (type === 'boolean') value = Boolean(value); else if (type === 'boolean' || type === 'switch') value = Boolean(value);
else if (type === 'endpoint') { else if (type === 'endpoint') {
const src = value && typeof value === 'object' ? value : {}; const src = value && typeof value === 'object' ? value : {};
value = { value = {
-419
View File
@@ -1,419 +0,0 @@
#!/usr/bin/env node
'use strict';
/**
* ltc-reader — decode SMPTE LTC from an audio feed and push it to ProdDash.
*
* ProPresenter's APIs expose no timecode (see modules/propresenter-timers),
* so this tool reads LTC where it actually lives: the audio signal. Pipe
* PCM (signed 16-bit little-endian) into stdin from any capture front-end;
* the decoded state is POSTed to the Timers module's /ltc ingest route —
* ~4×/s while running, 1×/s as a keepalive otherwise, and immediately on
* every state change.
*
* macOS (ffmpeg; list input devices with
* `ffmpeg -f avfoundation -list_devices true -i ""`):
* ffmpeg -loglevel error -f avfoundation -i ":0" -ac 1 -ar 48000 \
* -f s16le - | node tools/ltc-reader.js --url http://127.0.0.1:24500
* sox (default input device):
* sox -q -d -t raw -b 16 -e signed -c 1 -r 48000 - | node tools/ltc-reader.js
* Windows (ffmpeg/dshow):
* ffmpeg -loglevel error -f dshow -i audio="<device name>" -ac 1 \
* -ar 48000 -f s16le - | node tools/ltc-reader.js
*
* LTC on one input of a multichannel interface (a console bus, a Dante/
* MADI card, input 5 of a Scarlett 18i20…): do NOT downmix with -ac 1 —
* summing LTC with program audio corrupts the bit transitions. Capture the
* channels as they are and tell the reader which one carries LTC (1-based):
* ffmpeg -loglevel error -f avfoundation -i ":0" -ar 48000 -f s16le - \
* | node tools/ltc-reader.js --channels 18 --ch 5
* (--channels must match what the capture actually emits; ffmpeg keeps
* the device's native count unless -ac says otherwise, and prints it on
* stderr at startup. sox: match its -c flag.)
*
* Flags:
* --url <base> ProdDash base URL (default http://127.0.0.1:24500)
* --module <id> target module id (default propresenter-timers)
* --token <secret> matches the module's "LTC reader token" admin setting
* --sr <hz> stdin sample rate (default 48000)
* --channels <n> interleaved channels in the stdin stream (default 1)
* --ch <n> which channel carries LTC, 1-based (default 1)
* --source <label> reader label in reports (default this machine's hostname)
* --demo no audio needed: synthesize running 30 fps LTC (with a
* stop every ~30 s) through the real decoder, and POST it
* --selftest run the encoder→decoder test suite and exit
*
* Decoding: LTC is an 80-bit SMPTE frame, biphase-mark coded — a level
* transition at every bit boundary plus one mid-bit for a 1. The decoder
* tracks transitions with an envelope-scaled hysteresis comparator (so
* input level doesn't matter), classifies the transition gaps as full/half
* bit periods against an adaptive period estimate, shifts bits until the
* sync word appears, then reads the BCD fields. Frame rate locks from
* second-rollover consensus (bit clock until then); the drop-frame
* bit distinguishes 29.97 DF. Reverse play is not decoded (the sync word
* never matches backwards) — for booth status that reads as "stopped",
* which is the honest summary anyway.
*/
const os = require('os');
/* The LTC engine (decoder, encoder, channel extractor) lives with the
Timers module and is shared from there — one implementation, used both
by the module's built-in listener and by this standalone reader. */
const { LtcDecoder, LtcEncoder, ChannelExtractor, frameBits, fmtTc } =
require('../modules/propresenter-timers/ltc.js');
function log(msg) {
console.log(`[${new Date().toLocaleTimeString()}] ${msg}`);
}
function parseArgs(argv) {
const args = {
url: 'http://127.0.0.1:24500',
module: 'propresenter-timers',
token: '',
sr: 48000,
channels: 1,
ch: 1,
source: os.hostname(),
demo: false,
selftest: false,
};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a === '--demo') args.demo = true;
else if (a === '--selftest') args.selftest = true;
else if (a === '--url') args.url = String(argv[++i] || args.url).replace(/\/+$/, '');
else if (a === '--module') args.module = String(argv[++i] || args.module);
else if (a === '--token') args.token = String(argv[++i] || '');
else if (a === '--sr') args.sr = Number(argv[++i]) || args.sr;
else if (a === '--channels') args.channels = Number(argv[++i]);
else if (a === '--ch') args.ch = Number(argv[++i]);
else if (a === '--source') args.source = String(argv[++i] || args.source);
else {
console.error(`Unknown flag ${a} — see the header of tools/ltc-reader.js`);
process.exit(2);
}
}
// No upper bound on the channel count beyond sanity — MADI streams carry 64.
if (!Number.isInteger(args.channels) || args.channels < 1 || args.channels > 1024) {
console.error(`--channels must be a whole number of interleaved channels (got ${args.channels})`);
process.exit(2);
}
if (!Number.isInteger(args.ch) || args.ch < 1 || args.ch > args.channels) {
console.error(`--ch must be between 1 and ${args.channels} (the --channels count; got ${args.ch})`);
process.exit(2);
}
return args;
}
/* ══ 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);
}
/* ══ live modes: stdin decode / demo, plus the POST loop ═══════════════ */
function main(args) {
const endpoint = `${args.url}/api/modules/${args.module}/ltc`;
/* — decoded-state tracking — */
let lastTc = '';
let fps = 0;
let df = false;
let lastFrameWall = 0;
let lastChangeWall = 0;
const decoder = new LtcDecoder(args.sr, (f) => {
const tc = fmtTc(f);
const now = Date.now();
if (tc !== lastTc) lastChangeWall = now;
lastTc = tc;
fps = f.fps;
df = f.df;
lastFrameWall = now;
});
const snapshot = () => {
const now = Date.now();
// Running = frames decoding AND the address advancing. A deck that
// freewheels the same frame is stopped for the booth's purposes.
if (now - lastFrameWall < 400 && now - lastChangeWall < 400) return 'running';
if (lastTc) return 'stopped';
return 'nosignal';
};
/* — POST loop — */
let postedState = '';
let lastPostAt = 0;
let lastErrorAt = 0;
let unauthorizedAt = 0;
async function post(state) {
const body = {
state,
time: lastTc,
fps,
df,
source: args.source,
};
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(args.token ? { Authorization: `Bearer ${args.token}` } : {}),
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(2500),
});
if (res.status === 401) {
// A wrong token never fixes itself fast — complain once a minute.
if (Date.now() - unauthorizedAt > 60000) {
unauthorizedAt = Date.now();
log(`ProdDash rejected the token (401) — set --token to match the module's "LTC reader token"`);
}
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (state !== postedState) {
log(`→ ${state}${lastTc ? ` at ${lastTc}` : ''}${state === 'running' && fps ? ` (${df ? '29.97 DF' : `${fps} fps`})` : ''}`);
postedState = state;
}
} catch (err) {
if (Date.now() - lastErrorAt > 10000) {
lastErrorAt = Date.now();
log(`cannot reach ProdDash at ${endpoint}: ${err.message || err}`);
}
postedState = ''; // re-announce the state once we get through again
}
}
setInterval(() => {
const state = snapshot();
const due = state !== postedState ? 0 : state === 'running' ? 240 : 990;
if (Date.now() - lastPostAt >= due) {
lastPostAt = Date.now();
post(state);
}
}, 250).unref?.();
log(`posting LTC state to ${endpoint} as "${args.source}"`);
if (args.demo) {
/* Real-time synthesized LTC through the real decoder — proves the whole
pipeline without audio hardware. ~25 s running, ~6 s stopped, repeat. */
const now = new Date();
const enc = new LtcEncoder(args.sr, 30, false, {
h: now.getHours(), m: now.getMinutes(), s: now.getSeconds(), f: 0,
});
const silence = new Int16Array(args.sr / 10);
let phase = 'running';
let phaseUntil = Date.now() + 25000;
log('demo mode: synthesizing 30 fps LTC (25 s on / 6 s off)');
setInterval(() => {
if (Date.now() > phaseUntil) {
phase = phase === 'running' ? 'silent' : 'running';
phaseUntil = Date.now() + (phase === 'running' ? 25000 : 6000);
}
decoder.push(phase === 'running' ? enc.render(args.sr / 10) : silence);
}, 100);
return;
}
/* — stdin: s16le PCM from ffmpeg/sox/arecord — */
if (process.stdin.isTTY) {
console.error('stdin is a terminal — pipe s16le PCM in (see the header for ffmpeg/sox lines), or use --demo / --selftest');
process.exit(2);
}
log(`reading s16le PCM at ${args.sr} Hz from stdin`
+ (args.channels > 1 ? `, ${args.channels} channels — decoding channel ${args.ch}` : ' (mono)'));
const extractor = new ChannelExtractor(args.channels, args.ch);
process.stdin.on('data', (buf) => decoder.push(extractor.push(buf)));
process.stdin.on('end', () => {
// The capture front-end died. Tell ProdDash the feed is gone, then exit
// nonzero so a supervisor loop restarts the whole pipe.
log('stdin ended — audio capture stopped');
lastTc = '';
post('nosignal').finally(() => process.exit(1));
});
}
const args = parseArgs(process.argv.slice(2));
if (args.selftest) selftest();
else main(args);