From 1d46657e063ab56465f4efd439ab3787a0a6d362 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 10 Sep 2026 13:29:06 -0400 Subject: [PATCH] Timers: LTC is listener-only; admin switch, device/channel, grouped settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ""`: 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 --- README.md | 39 +- docs/MODULE-GUIDE.md | 18 +- modules/propresenter-timers/client.js | 25 +- modules/propresenter-timers/ltc-capture.swift | 1 - modules/propresenter-timers/ltc.js | 12 +- modules/propresenter-timers/ltc.selftest.js | 194 ++++++++ modules/propresenter-timers/module.json | 38 +- modules/propresenter-timers/server.js | 281 ++---------- modules/propresenter-timers/stage-ws.js | 379 ---------------- modules/propresenter-timers/style.css | 2 - public/admin/admin.css | 33 ++ public/admin/admin.js | 62 ++- server.js | 2 +- tools/ltc-reader.js | 419 ------------------ 14 files changed, 386 insertions(+), 1119 deletions(-) create mode 100644 modules/propresenter-timers/ltc.selftest.js delete mode 100644 modules/propresenter-timers/stage-ws.js delete mode 100644 tools/ltc-reader.js diff --git a/README.md b/README.md index c5c1ff6..37d4bd2 100644 --- a/README.md +++ b/README.md @@ -175,31 +175,22 @@ below. Nothing is installed without someone clicking. 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" and pick a selection via the tile's "Timers ▾" menu; overruns are - unmistakably red. Read-only — no start/stop controls. Admin config: - ProPresenter endpoint (host : port), API password, the LTC listener - switch + audio device + channel, LTC reader token, stage display - password. + unmistakably red. Read-only — no start/stop controls. Admin config is in + two groups: **ProPresenter** (endpoint host : port, API password) and + **LTC Timecode** (the listener switch, and — when it's on — the audio + device and channel). - LTC lives behind the admin **LTC listener** switch — off means no LTC - tile in the picker at all. On, the sources, best first (ProPresenter's - HTTP API has no timecode route): - 1. **Built-in listener** — the module captures the admin-selected audio - input device/channel on the ProdDash machine (native `ltc-capture` - helper, compiled on first use) and decodes the SMPTE frames - in-process: running/stopped/no-signal, timecode, frame rate (incl. - 29.97 drop-frame). macOS note: the ProdDash server needs microphone - permission — launch it from a logged-in session (Start - ProdDash.command) and approve the one-time prompt; a server started - over SSH or by bare launchd silently captures zeros. - 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. + ProPresenter exposes no timecode over any API, so LTC is decoded from the + audio signal, behind the admin **LTC listener** switch (off ⇒ no LTC tile + in the picker at all). On, the module captures the selected audio input + device/channel on the ProdDash machine — a native `ltc-capture` helper, + compiled on first use — and decodes the SMPTE frames in-process: + running/stopped/no-signal, the timecode counting frame-accurately in real + time, and the frame rate (incl. 29.97 drop-frame). macOS note: the + ProdDash server needs microphone permission — launch it from a logged-in + session (Start ProdDash.command) and approve the one-time prompt; a server + started over SSH or by bare launchd silently captures zeros. (Engine + self-test: `node modules/propresenter-timers/ltc.selftest.js`.) - **Clock** — a big booth clock; also the smallest possible module and the reference for module authors. diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index 04abf37..a545320 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -99,21 +99,35 @@ the app); its settings are kept for a later reinstall. 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", "default": , + "group": "Section heading", // optional; see below + "showWhen": "", // optional; see below "options": [ { "value": "a", "label": "A" }, … ], // "select" 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: ""` hides a field until the boolean/`switch` field named +`` 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 ports, discovered sources) sets `optionsRoute` instead of — or as a fallback refresh of — static `options`: the admin page GETs `/api/modules/` and expects `{ "options": [ { "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 -`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 in the server's `modules.json` (in the per-machine data directory, see the diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index aac8816..c88bad5 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -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'); diff --git a/modules/propresenter-timers/ltc-capture.swift b/modules/propresenter-timers/ltc-capture.swift index c5d3c86..ff1f2d6 100644 --- a/modules/propresenter-timers/ltc-capture.swift +++ b/modules/propresenter-timers/ltc-capture.swift @@ -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 diff --git a/modules/propresenter-timers/ltc.js b/modules/propresenter-timers/ltc.js index 409fe46..d815ff7 100644 --- a/modules/propresenter-timers/ltc.js +++ b/modules/propresenter-timers/ltc.js @@ -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 ═══════════════════════════════════════════════════════════ */ diff --git a/modules/propresenter-timers/ltc.selftest.js b/modules/propresenter-timers/ltc.selftest.js new file mode 100644 index 0000000..57974a5 --- /dev/null +++ b/modules/propresenter-timers/ltc.selftest.js @@ -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(); diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index abe5435..f8db865 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -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": "" } } } diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index e0e0233..a2c0fc8 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -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: } (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', diff --git a/modules/propresenter-timers/stage-ws.js b/modules/propresenter-timers/stage-ws.js deleted file mode 100644 index bebcdca..0000000 --- a/modules/propresenter-timers/stage-ws.js +++ /dev/null @@ -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":"","ptl":610,"acn":"ath"} - * ← {"acn":"ath","ath":true|false,"err":"…"} - * → {"acn":"psl"} ← {"acn":"psl","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 }; diff --git a/modules/propresenter-timers/style.css b/modules/propresenter-timers/style.css index d9092b2..6ea1828 100644 --- a/modules/propresenter-timers/style.css +++ b/modules/propresenter-timers/style.css @@ -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 { diff --git a/public/admin/admin.css b/public/admin/admin.css index 60e4d3d..e3dae18 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -113,6 +113,39 @@ body { overflow: auto; } .endpoint-row .ep-sep { color: var(--muted); font-weight: 600; } .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 { display: flex; align-items: center; diff --git a/public/admin/admin.js b/public/admin/admin.js index e75a3e1..ac58407 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -102,15 +102,35 @@ function fieldFor(key, spec, value, passwordSet, moduleId) { const type = spec?.type || 'string'; const label = spec?.label || key; const field = document.createElement('label'); - field.className = 'field' + (type === 'boolean' ? ' check' : ''); + field.className = 'field' + (type === 'boolean' ? ' check' : '') + (type === 'switch' ? ' switch-field' : ''); 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'); input.type = 'checkbox'; input.checked = Boolean(value); field.append(input, document.createTextNode(label)); read = () => input.checked; + control = input; } else if (type === 'endpoint') { // One consistent "Host / IP : Port" control for every module's upstream. const span = document.createElement('span'); @@ -185,7 +205,7 @@ function fieldFor(key, spec, value, passwordSet, moduleId) { field.append(span, input); read = () => (type === 'number' ? Number(input.value) : input.value); } - return { field, read }; + return { field, read, control }; } function buildModuleCard(mod) { @@ -298,10 +318,42 @@ function buildModuleCard(mod) { const form = document.createElement('div'); form.className = 'module-form'; 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) { - 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); - 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'); actions.className = 'module-actions'; diff --git a/server.js b/server.js index 3ba8340..c30e9f9 100644 --- a/server.js +++ b/server.js @@ -993,7 +993,7 @@ function applyConfigPatch(id, patch) { value = current[key] || ''; } 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') { const src = value && typeof value === 'object' ? value : {}; value = { diff --git a/tools/ltc-reader.js b/tools/ltc-reader.js deleted file mode 100644 index 9d33606..0000000 --- a/tools/ltc-reader.js +++ /dev/null @@ -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="" -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 ProdDash base URL (default http://127.0.0.1:24500) - * --module target module id (default propresenter-timers) - * --token matches the module's "LTC reader token" admin setting - * --sr stdin sample rate (default 48000) - * --channels interleaved channels in the stdin stream (default 1) - * --ch which channel carries LTC, 1-based (default 1) - * --source