Merge pull request #7 from michaelmcgary2008/claude/prodash-propresenter-timers-cea871
Timers: LTC decoded from the audio signal via an external reader
This commit is contained in:
@@ -7,3 +7,6 @@ dist/
|
|||||||
# runtime state written by the admin page / layout saves
|
# runtime state written by the admin page / layout saves
|
||||||
config/modules.json
|
config/modules.json
|
||||||
config/layouts/
|
config/layouts/
|
||||||
|
|
||||||
|
# compiled per-machine, source sits next to it
|
||||||
|
modules/propresenter-timers/ltc-capture
|
||||||
|
|||||||
@@ -177,10 +177,23 @@ 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. LTC is read from a stage-display layout field labeled
|
unmistakably red. Read-only — no start/stop controls. Admin config is in
|
||||||
"LTC" over ProPresenter's stage websocket (its HTTP API has no timecode
|
three groups: **ProPresenter** (endpoint host : port, API password),
|
||||||
route). Read-only — no start/stop controls. Admin config: ProPresenter
|
**LTC Timecode** (the listener switch, and — when it's on — the audio
|
||||||
endpoint (host : port), API password, stage display password.
|
device and channel), and **Display** (Timer font — Default or Monospace,
|
||||||
|
which keeps the digits from shifting as the numbers change).
|
||||||
|
|
||||||
|
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
|
- **Clock** — a big booth clock; also the smallest possible module and the
|
||||||
reference for module authors.
|
reference for module authors.
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+7
@@ -1,4 +1,11 @@
|
|||||||
#!/bin/zsh
|
#!/bin/zsh
|
||||||
# Double-click to start ProdDash (macOS)
|
# Double-click to start ProdDash (macOS)
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
# One dashboard per machine: take the port over from any prior instance
|
||||||
|
# (an SSH- or launchd-started one has no microphone access for the LTC
|
||||||
|
# listener — launching from here fixes that).
|
||||||
|
OLD=$(lsof -t -iTCP:24500 -sTCP:LISTEN 2>/dev/null)
|
||||||
|
[[ -n "$OLD" ]] && kill $OLD 2>/dev/null && sleep 1
|
||||||
|
# Prefer the bundled runtime (production machines have no system Node)
|
||||||
|
if [[ -x ./runtime/bin/node ]]; then exec ./runtime/bin/node server.js; fi
|
||||||
exec node server.js
|
exec node server.js
|
||||||
|
|||||||
+25
-2
@@ -99,13 +99,36 @@ 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>,
|
||||||
"options": [ { "value": "a", "label": "A" }, … ] // "select" only
|
"group": "Section heading", // optional; see below
|
||||||
|
"showWhen": "<other boolean/switch key>", // 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: "<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
|
||||||
|
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
|
||||||
|
refresh of — static `options`: the admin page GETs
|
||||||
|
`/api/modules/<id><optionsRoute>` 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`' "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
|
||||||
README), handed to your *server* entry, and **never sent to browsers** — not in `/api/modules`, and never echoed back into the admin
|
README), handed to your *server* entry, and **never sent to browsers** — not in `/api/modules`, and never echoed back into the admin
|
||||||
|
|||||||
@@ -11,6 +11,45 @@
|
|||||||
|
|
||||||
const LTC_KEY = '__ltc__';
|
const LTC_KEY = '__ltc__';
|
||||||
|
|
||||||
|
/* ── SMPTE timecode math (module-scope, exported for tests) ─────────
|
||||||
|
The server anchors us ~4×/s with { time, fps, df, ageMs }; the card
|
||||||
|
counts every frame in between locally. That needs exact frame↔timecode
|
||||||
|
conversion, including 29.97 drop-frame (frames 00 and 01 don't exist at
|
||||||
|
the top of a minute, except every tenth minute). */
|
||||||
|
|
||||||
|
export function parseTc(str) {
|
||||||
|
const m = /^(\d{1,2}):(\d{2}):(\d{2})[:;.](\d{1,2})$/.exec(String(str || '').trim());
|
||||||
|
if (!m) return null;
|
||||||
|
return { h: Number(m[1]), m: Number(m[2]), s: Number(m[3]), f: Number(m[4]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tcToFrames(tc, nominal, df) {
|
||||||
|
let n = (tc.h * 3600 + tc.m * 60 + tc.s) * nominal + tc.f;
|
||||||
|
if (df) {
|
||||||
|
const mins = tc.h * 60 + tc.m;
|
||||||
|
n -= 2 * (mins - Math.floor(mins / 10));
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function framesToTc(n, nominal, df) {
|
||||||
|
// 10 DF minutes hold 17982 frames (the first minute 1800, the rest 1798).
|
||||||
|
const perDay = df ? 24 * 6 * 17982 : 24 * 3600 * nominal;
|
||||||
|
n = ((n % perDay) + perDay) % perDay;
|
||||||
|
if (df) {
|
||||||
|
const d = Math.floor(n / 17982);
|
||||||
|
const m10 = n % 17982;
|
||||||
|
const extra = m10 < 1800 ? 0 : Math.floor((m10 - 1800) / 1798) + 1;
|
||||||
|
const totalMin = d * 10 + extra;
|
||||||
|
// Re-add the two dropped frames per dropped minute, then read as 30fps.
|
||||||
|
n += 2 * (totalMin - Math.floor(totalMin / 10));
|
||||||
|
}
|
||||||
|
const f = n % nominal;
|
||||||
|
const secs = Math.floor(n / nominal);
|
||||||
|
const pad = (v) => String(v).padStart(2, '0');
|
||||||
|
return `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}:${pad(f)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function create({ root, moduleApi }) {
|
export default function create({ root, moduleApi }) {
|
||||||
let state = null;
|
let state = null;
|
||||||
let feedOffline = false;
|
let feedOffline = false;
|
||||||
@@ -61,7 +100,9 @@ export default function create({ root, moduleApi }) {
|
|||||||
|
|
||||||
function menuRows() {
|
function menuRows() {
|
||||||
const rows = (state?.timers || []).map((t) => ({ key: t.uuid, name: t.name }));
|
const rows = (state?.timers || []).map((t) => ({ key: t.uuid, name: t.name }));
|
||||||
rows.push({ key: LTC_KEY, name: 'LTC timecode' });
|
// No LTC row while the listener is off in Admin — the item shouldn't
|
||||||
|
// even be offerable.
|
||||||
|
if (state?.ltcEnabled) rows.push({ key: LTC_KEY, name: 'LTC timecode' });
|
||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +200,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
for (const t of state?.timers || []) {
|
for (const t of state?.timers || []) {
|
||||||
if (!hiddenItems.has(t.uuid)) items.push({ key: t.uuid, timer: t });
|
if (!hiddenItems.has(t.uuid)) items.push({ key: t.uuid, timer: t });
|
||||||
}
|
}
|
||||||
if (!hiddenItems.has(LTC_KEY)) items.push({ key: LTC_KEY, ltc: ltcState });
|
if (state?.ltcEnabled && !hiddenItems.has(LTC_KEY)) items.push({ key: LTC_KEY, ltc: ltcState });
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,34 +246,117 @@ export default function create({ root, moduleApi }) {
|
|||||||
card.el.className = `tm-card is-${kind}`;
|
card.el.className = `tm-card is-${kind}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── real-time LTC: count every frame between server anchors ─────── */
|
||||||
|
|
||||||
|
// The server anchors us ~4×/s with the decoded timecode, its rate and
|
||||||
|
// how old it is (ageMs). Between anchors a requestAnimationFrame loop
|
||||||
|
// advances the display at the LTC's own rate, so every frame paints —
|
||||||
|
// without pushing 30 SSE messages a second at every dashboard.
|
||||||
|
let ltcAnchor = null; // { frames, at, recvAt, rate, nominal, df }
|
||||||
|
let ltcRaf = 0;
|
||||||
|
let ltcShownFrames = -1;
|
||||||
|
/** Anchors arrive ~every 250 ms while running. None for this long means
|
||||||
|
the feed state is changing — hold rather than freewheel into phantom
|
||||||
|
frames the next update would visibly rewind. */
|
||||||
|
const ANCHOR_FRESH_MS = 350;
|
||||||
|
|
||||||
|
function updateLtcAnchor() {
|
||||||
|
const l = state?.ltc;
|
||||||
|
// 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;
|
||||||
|
ltcShownFrames = -1;
|
||||||
|
if (ltcRaf) {
|
||||||
|
cancelAnimationFrame(ltcRaf);
|
||||||
|
ltcRaf = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nominal = l.df ? 30 : Math.round(l.fps);
|
||||||
|
ltcAnchor = {
|
||||||
|
frames: tcToFrames(tc, nominal, l.df),
|
||||||
|
at: performance.now() - (Number(l.ageMs) || 0),
|
||||||
|
recvAt: performance.now(),
|
||||||
|
rate: l.df ? 30000 / 1001 : nominal,
|
||||||
|
nominal,
|
||||||
|
df: l.df,
|
||||||
|
};
|
||||||
|
if (!ltcRaf) ltcRaf = requestAnimationFrame(ltcTick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Paint the frame the anchor implies for right now. Called both by the
|
||||||
|
rAF loop (smooth, per-frame, on visible tabs) and by render() at the
|
||||||
|
4×/s anchor rate — so a throttled/backgrounded tab, where rAF is
|
||||||
|
paused, still advances at the anchor rate instead of freezing. */
|
||||||
|
function paintLtcFrame() {
|
||||||
|
const a = ltcAnchor;
|
||||||
|
const card = cards.get(LTC_KEY);
|
||||||
|
if (!a || !card) return;
|
||||||
|
const limit = Math.min(performance.now(), a.recvAt + ANCHOR_FRESH_MS);
|
||||||
|
let n = a.frames + Math.round(((limit - a.at) / 1000) * a.rate);
|
||||||
|
// Anchor jitter (±1 frame) must never tick the display backwards;
|
||||||
|
// a genuine jump back (re-strike, seek) is far larger and passes.
|
||||||
|
if (ltcShownFrames >= 0 && n < ltcShownFrames && ltcShownFrames - n <= 2) n = ltcShownFrames;
|
||||||
|
if (n !== ltcShownFrames) {
|
||||||
|
ltcShownFrames = n;
|
||||||
|
card.timeEl.textContent = framesToTc(n, a.nominal, a.df);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ltcTick() {
|
||||||
|
ltcRaf = 0;
|
||||||
|
// No anchor, or the LTC card is hidden in this tile: stop — the next
|
||||||
|
// server anchor (4×/s while running) re-arms the loop via render().
|
||||||
|
if (!ltcAnchor || !cards.get(LTC_KEY)) return;
|
||||||
|
paintLtcFrame();
|
||||||
|
ltcRaf = requestAnimationFrame(ltcTick);
|
||||||
|
}
|
||||||
|
|
||||||
function updateLtcCard(card, ltc) {
|
function updateLtcCard(card, ltc) {
|
||||||
let mode;
|
let mode;
|
||||||
|
// Only the audio sources know the frame rate (decoded from the LTC
|
||||||
|
// bits); a set drop-frame flag implies 29.97 whatever fps rounds to.
|
||||||
|
const rate = ltc.df ? '29.97 DF' : ltc.fps ? `${ltc.fps} fps` : '';
|
||||||
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, …) — surface that over the generic line.
|
// selected, capture failed, starting up…) — surface that.
|
||||||
card.stateEl.textContent = ltc.note || 'Timecode not available on this ProPresenter';
|
card.stateEl.textContent = ltc.note || 'LTC not available';
|
||||||
} 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
|
||||||
|
// keeps a throttled tab advancing at the 4×/s anchor rate); the rAF
|
||||||
|
// loop refines it to every frame when the tab is visible. Without an
|
||||||
|
// anchor (rate unknown), fall back to the raw anchor timecode.
|
||||||
|
if (ltcAnchor) paintLtcFrame();
|
||||||
|
else card.timeEl.textContent = displayTime(ltc.time);
|
||||||
|
card.stateEl.textContent = rate ? `Running · ${rate}` : 'Running';
|
||||||
|
} else if (ltc.status === 'stopped' || ltc.time) {
|
||||||
|
// LTC ceased but we know where it stopped — hold the last frame.
|
||||||
|
mode = 'stopped';
|
||||||
card.timeEl.textContent = displayTime(ltc.time);
|
card.timeEl.textContent = displayTime(ltc.time);
|
||||||
card.stateEl.textContent = 'Receiving';
|
card.stateEl.textContent = 'Stopped';
|
||||||
} else {
|
} else {
|
||||||
mode = 'nosignal';
|
mode = 'nosignal';
|
||||||
card.timeEl.textContent = displayTime(ltc.time);
|
card.timeEl.textContent = '—';
|
||||||
card.stateEl.textContent = 'No signal';
|
card.stateEl.textContent = 'No signal';
|
||||||
}
|
}
|
||||||
card.el.className = `tm-card tm-card-ltc is-ltc-${mode}`;
|
// 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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── status dot + degraded banners ──────────────────────────────── */
|
/* ── status dot + degraded banners ──────────────────────────────── */
|
||||||
|
|
||||||
function renderStatus() {
|
function renderStatus() {
|
||||||
wrap.classList.toggle('is-stale', feedOffline || (state ? !state.reachable && state.enabled : false));
|
wrap.classList.toggle('is-stale', feedOffline || (state ? !state.reachable && state.enabled : false));
|
||||||
|
// The stale gray-out spares the reader-fed LTC card — unless the feed
|
||||||
|
// from the ProdDash server itself is down, when everything is stale.
|
||||||
|
wrap.classList.toggle('is-feed-down', feedOffline);
|
||||||
if (feedOffline) {
|
if (feedOffline) {
|
||||||
bannerEl.hidden = false;
|
bannerEl.hidden = false;
|
||||||
bannerEl.textContent = 'Not receiving updates from the ProdDash server.';
|
bannerEl.textContent = 'Not receiving updates from the ProdDash server.';
|
||||||
@@ -246,7 +370,10 @@ export default function create({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
if (!state.enabled) {
|
if (!state.enabled) {
|
||||||
bannerEl.hidden = true;
|
bannerEl.hidden = true;
|
||||||
moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
|
// 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');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!state.reachable) {
|
if (!state.reachable) {
|
||||||
@@ -264,9 +391,18 @@ export default function create({ root, moduleApi }) {
|
|||||||
function render() {
|
function render() {
|
||||||
renderStatus();
|
renderStatus();
|
||||||
rebuildMenu();
|
rebuildMenu();
|
||||||
|
updateLtcAnchor();
|
||||||
|
|
||||||
|
// Admin-chosen digit font. A font swap changes glyph metrics, so re-fit
|
||||||
|
// the digit sizing when it actually changes.
|
||||||
|
const mono = state?.timerFont === 'monospace';
|
||||||
|
if (mono !== wrap.classList.contains('tm-mono')) {
|
||||||
|
wrap.classList.toggle('tm-mono', mono);
|
||||||
|
fit();
|
||||||
|
}
|
||||||
|
|
||||||
let hint = '';
|
let hint = '';
|
||||||
if (state && !state.enabled) {
|
if (state && !state.enabled && !state.ltc?.supported) {
|
||||||
hint = 'No ProPresenter connection — set the host and port in Admin.';
|
hint = 'No ProPresenter connection — set the host and port in Admin.';
|
||||||
}
|
}
|
||||||
const items = hint ? [] : visibleItems();
|
const items = hint ? [] : visibleItems();
|
||||||
@@ -413,6 +549,10 @@ export default function create({ root, moduleApi }) {
|
|||||||
stream = null;
|
stream = null;
|
||||||
resizeObserver?.disconnect();
|
resizeObserver?.disconnect();
|
||||||
resizeObserver = null;
|
resizeObserver = null;
|
||||||
|
if (ltcRaf) {
|
||||||
|
cancelAnimationFrame(ltcRaf);
|
||||||
|
ltcRaf = 0;
|
||||||
|
}
|
||||||
root.innerHTML = ''; // header controls are removed by the shell
|
root.innerHTML = ''; // header controls are removed by the shell
|
||||||
},
|
},
|
||||||
onResize() {
|
onResize() {
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// ltc-capture — minimal CoreAudio input capture for the Timers module's
|
||||||
|
// LTC listener.
|
||||||
|
//
|
||||||
|
// The module decodes LTC in-process (ltc.js) and spawns this to move
|
||||||
|
// samples from an input device to a pipe. Production machines rarely have
|
||||||
|
// ffmpeg/sox, and shipping a third-party binary for that one job is
|
||||||
|
// overkill — this is the whole job, natively. The module compiles it on
|
||||||
|
// first use when swiftc is available; otherwise build it once on any
|
||||||
|
// same-architecture Mac and copy the binary next to this source:
|
||||||
|
//
|
||||||
|
// swiftc -O -o modules/propresenter-timers/ltc-capture \
|
||||||
|
// modules/propresenter-timers/ltc-capture.swift (once, ~5 s)
|
||||||
|
//
|
||||||
|
// ltc-capture --list
|
||||||
|
// every input-capable device: name, input channels, sample rate
|
||||||
|
// ltc-capture --device "Dante Via 16 Channel"
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// --device matches case-insensitively on a substring of the device name or
|
||||||
|
// UID; ambiguity is an error listing the candidates. Capture uses an
|
||||||
|
// AVAudioEngine input tap pinned to the chosen device, converting Float32
|
||||||
|
// to clamped Int16 little-endian. stdout is samples only; every message
|
||||||
|
// goes to stderr.
|
||||||
|
//
|
||||||
|
// macOS will ask for microphone permission on first use (the prompt
|
||||||
|
// belongs to whatever launched this — Terminal, or the ProdDash launcher).
|
||||||
|
// Over SSH there is no prompt to answer: grant it once at the console, or
|
||||||
|
// approve the terminal app under System Settings → Privacy → Microphone.
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import CoreAudio
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
func fail(_ msg: String) -> Never {
|
||||||
|
FileHandle.standardError.write(("ltc-capture: " + msg + "\n").data(using: .utf8)!)
|
||||||
|
exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func note(_ msg: String) {
|
||||||
|
FileHandle.standardError.write(("ltc-capture: " + msg + "\n").data(using: .utf8)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CoreAudio device enumeration ───────────────────────────────────── */
|
||||||
|
|
||||||
|
struct InputDevice {
|
||||||
|
let id: AudioDeviceID
|
||||||
|
let name: String
|
||||||
|
let uid: String
|
||||||
|
let channels: Int
|
||||||
|
let sampleRate: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringProperty(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var ref: Unmanaged<CFString>? = nil
|
||||||
|
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
||||||
|
let err = withUnsafeMutablePointer(to: &ref) {
|
||||||
|
AudioObjectGetPropertyData(id, &addr, 0, nil, &size, $0)
|
||||||
|
}
|
||||||
|
guard err == noErr, let value = ref?.takeRetainedValue() else { return "" }
|
||||||
|
return value as String
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleRateProperty(_ id: AudioObjectID) -> Double {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyNominalSampleRate,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var rate: Float64 = 0
|
||||||
|
var size = UInt32(MemoryLayout<Float64>.size)
|
||||||
|
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, &rate) == noErr else { return 0 }
|
||||||
|
return rate
|
||||||
|
}
|
||||||
|
|
||||||
|
func inputChannelCount(_ id: AudioDeviceID) -> Int {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||||
|
mScope: kAudioDevicePropertyScopeInput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(id, &addr, 0, nil, &size) == noErr, size > 0 else { return 0 }
|
||||||
|
let listPtr = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||||
|
defer { listPtr.deallocate() }
|
||||||
|
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, listPtr) == noErr else { return 0 }
|
||||||
|
let buffers = UnsafeMutableAudioBufferListPointer(listPtr.assumingMemoryBound(to: AudioBufferList.self))
|
||||||
|
return buffers.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func listInputDevices() -> [InputDevice] {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size) == noErr
|
||||||
|
else { return [] }
|
||||||
|
var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
|
||||||
|
guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids) == noErr
|
||||||
|
else { return [] }
|
||||||
|
|
||||||
|
var out: [InputDevice] = []
|
||||||
|
for id in ids {
|
||||||
|
let channels = inputChannelCount(id)
|
||||||
|
if channels == 0 { continue }
|
||||||
|
out.append(InputDevice(id: id,
|
||||||
|
name: stringProperty(id, kAudioObjectPropertyName),
|
||||||
|
uid: stringProperty(id, kAudioDevicePropertyDeviceUID),
|
||||||
|
channels: channels,
|
||||||
|
sampleRate: sampleRateProperty(id)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── args ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
var wantList = false
|
||||||
|
var wantDevice: String? = nil
|
||||||
|
var argIdx = 1
|
||||||
|
let argv = CommandLine.arguments
|
||||||
|
while argIdx < argv.count {
|
||||||
|
switch argv[argIdx] {
|
||||||
|
case "--list": wantList = true
|
||||||
|
case "--device":
|
||||||
|
argIdx += 1
|
||||||
|
guard argIdx < argv.count else { fail("--device needs a name (see --list)") }
|
||||||
|
wantDevice = argv[argIdx]
|
||||||
|
default: fail("unknown flag \(argv[argIdx]) — use --list or --device <name>")
|
||||||
|
}
|
||||||
|
argIdx += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let devices = listInputDevices()
|
||||||
|
|
||||||
|
if wantList || wantDevice == nil {
|
||||||
|
if devices.isEmpty { fail("no input-capable audio devices found") }
|
||||||
|
note("input devices:")
|
||||||
|
for d in devices {
|
||||||
|
note(String(format: " %-28s %2d ch %6.0f Hz uid=%@", (d.name as NSString).utf8String!,
|
||||||
|
d.channels, d.sampleRate, d.uid))
|
||||||
|
}
|
||||||
|
if wantDevice == nil && !wantList { note("pick one with --device <name substring>") }
|
||||||
|
exit(wantList ? 0 : 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = wantDevice!.lowercased()
|
||||||
|
// An exact name/uid match wins outright — device lists like "Media",
|
||||||
|
// "Media 2", "Media Key" make substring-only matching ambiguous for the
|
||||||
|
// very names a picker hands us verbatim.
|
||||||
|
let exact = devices.filter { $0.name.lowercased() == query || $0.uid.lowercased() == query }
|
||||||
|
let matches = exact.count == 1 ? exact
|
||||||
|
: devices.filter { $0.name.lowercased().contains(query) || $0.uid.lowercased().contains(query) }
|
||||||
|
guard matches.count == 1 else {
|
||||||
|
if matches.isEmpty { fail("no input device matches \"\(wantDevice!)\" — see --list") }
|
||||||
|
fail("\"\(wantDevice!)\" is ambiguous: " + matches.map { $0.name }.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
let device = matches[0]
|
||||||
|
|
||||||
|
/* ── capture: pin the engine's input to the device, tap, s16le out ──── */
|
||||||
|
|
||||||
|
let engine = AVAudioEngine()
|
||||||
|
guard let unit = engine.inputNode.audioUnit else { fail("no input audio unit") }
|
||||||
|
var deviceID = device.id
|
||||||
|
let setErr = AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice,
|
||||||
|
kAudioUnitScope_Global, 0, &deviceID,
|
||||||
|
UInt32(MemoryLayout<AudioDeviceID>.size))
|
||||||
|
guard setErr == noErr else { fail("cannot select \"\(device.name)\" (error \(setErr))") }
|
||||||
|
|
||||||
|
let format = engine.inputNode.inputFormat(forBus: 0)
|
||||||
|
let channels = Int(format.channelCount)
|
||||||
|
let rate = Int(format.sampleRate)
|
||||||
|
guard channels > 0, rate > 0 else {
|
||||||
|
fail("\"\(device.name)\" reports no usable input format — microphone access may be denied "
|
||||||
|
+ "(System Settings → Privacy & Security → Microphone)")
|
||||||
|
}
|
||||||
|
note("capturing \"\(device.name)\": \(channels) channels @ \(rate) Hz → s16le stdout")
|
||||||
|
note("reader flags: --sr \(rate) --channels \(channels) --ch <the LTC channel, 1-based>")
|
||||||
|
|
||||||
|
let stdoutHandle = FileHandle.standardOutput
|
||||||
|
engine.inputNode.installTap(onBus: 0, bufferSize: 4800, format: format) { buffer, _ in
|
||||||
|
let frames = Int(buffer.frameLength)
|
||||||
|
guard frames > 0, let floats = buffer.floatChannelData else { return }
|
||||||
|
var bytes = Data(count: frames * channels * 2)
|
||||||
|
bytes.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in
|
||||||
|
let out = raw.bindMemory(to: Int16.self)
|
||||||
|
for f in 0..<frames {
|
||||||
|
for c in 0..<channels {
|
||||||
|
let v = floats[c][f]
|
||||||
|
let clamped = max(-1.0, min(1.0, v))
|
||||||
|
out[f * channels + c] = Int16(clamped * 32767.0) // arm64 is little-endian
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do { try stdoutHandle.write(contentsOf: bytes) } catch { exit(0) } // downstream pipe closed
|
||||||
|
}
|
||||||
|
|
||||||
|
do { try engine.start() } catch {
|
||||||
|
fail("engine start failed: \(error.localizedDescription) — if this is a permission problem, "
|
||||||
|
+ "approve microphone access for the launching app and retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
signal(SIGINT) { _ in exit(0) }
|
||||||
|
signal(SIGTERM) { _ in exit(0) }
|
||||||
|
RunLoop.main.run()
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in LTC listener — capture child + in-process decode.
|
||||||
|
*
|
||||||
|
* Spawns the module's native capture tool (ltc-capture, compiled from
|
||||||
|
* ltc-capture.swift beside it) for the admin-configured audio device, reads
|
||||||
|
* the interleaved s16le stream, extracts the configured channel, and decodes
|
||||||
|
* SMPTE LTC with ltc.js — all inside the module server. Reports upward via
|
||||||
|
* onUpdate roughly 4×/s:
|
||||||
|
*
|
||||||
|
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
|
||||||
|
* { error: '<what is wrong and how to fix it>' } not capturing
|
||||||
|
*
|
||||||
|
* The capture binary is per-machine (gitignored): if it is missing this
|
||||||
|
* tries one swiftc compile of the source next to it, and otherwise says to
|
||||||
|
* build it on any same-architecture Mac and copy it over. A dead child is
|
||||||
|
* restarted with backoff — fast for a crash, slow for config-shaped
|
||||||
|
* failures (unknown device, ambiguous name) that never fix themselves.
|
||||||
|
*
|
||||||
|
* macOS gotcha this file can't fix: microphone permission. A ProdDash
|
||||||
|
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
|
||||||
|
* from every input (no error!), which shows up here as endless 'nosignal'.
|
||||||
|
* Launch ProdDash from a logged-in GUI context (Start ProdDash.command)
|
||||||
|
* and approve the mic prompt once.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { spawn, spawnSync } = require('child_process');
|
||||||
|
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
|
||||||
|
|
||||||
|
const RESTART_MS = 5000;
|
||||||
|
/** An immediate exit is config-shaped (bad device name, ambiguity) — a fast
|
||||||
|
retry loop would just spam the log. */
|
||||||
|
const BAD_CONFIG_RETRY_MS = 30000;
|
||||||
|
const EVAL_MS = 250;
|
||||||
|
/** Frames must decode AND advance this recently to count as running. */
|
||||||
|
const RUNNING_WINDOW_MS = 400;
|
||||||
|
|
||||||
|
/** stderr line the capture prints once the device is open. */
|
||||||
|
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{device:string, channel:number, moduleDir:string, log:Function,
|
||||||
|
* onUpdate:(u:object)=>void}} opts
|
||||||
|
* @returns {{stop:()=>void}}
|
||||||
|
*/
|
||||||
|
function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
|
||||||
|
const bin = path.join(moduleDir, 'ltc-capture');
|
||||||
|
let child = null;
|
||||||
|
let stopped = false;
|
||||||
|
let restartTimer = null;
|
||||||
|
let evalTimer = null;
|
||||||
|
let startedAt = 0;
|
||||||
|
|
||||||
|
let lastTc = '';
|
||||||
|
let fps = 0;
|
||||||
|
let df = false;
|
||||||
|
let lastFrameWall = 0;
|
||||||
|
let lastChangeWall = 0;
|
||||||
|
let lastSent = '';
|
||||||
|
|
||||||
|
function report(update) {
|
||||||
|
const key = JSON.stringify(update);
|
||||||
|
if (key === lastSent) return;
|
||||||
|
lastSent = key;
|
||||||
|
onUpdate(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotState() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastFrameWall < RUNNING_WINDOW_MS && now - lastChangeWall < RUNNING_WINDOW_MS) return 'running';
|
||||||
|
if (lastTc) return 'stopped';
|
||||||
|
return 'nosignal';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureBinary() {
|
||||||
|
if (fs.existsSync(bin)) return '';
|
||||||
|
const src = bin + '.swift';
|
||||||
|
log('ltc-capture binary missing — trying a one-time swiftc build');
|
||||||
|
const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 120000 });
|
||||||
|
if (built.status === 0 && fs.existsSync(bin)) {
|
||||||
|
log('ltc-capture compiled');
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return 'LTC capture tool missing — build it on any Apple Silicon Mac with '
|
||||||
|
+ '`swiftc -O -o modules/propresenter-timers/ltc-capture modules/propresenter-timers/ltc-capture.swift` '
|
||||||
|
+ 'and copy it here';
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (stopped) return;
|
||||||
|
const missing = ensureBinary();
|
||||||
|
if (missing) {
|
||||||
|
report({ error: missing });
|
||||||
|
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startedAt = Date.now();
|
||||||
|
let extractor = null;
|
||||||
|
let decoder = null;
|
||||||
|
let stderrTail = '';
|
||||||
|
|
||||||
|
child = spawn(bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
|
child.stderr.setEncoding('utf8');
|
||||||
|
child.stderr.on('data', (text) => {
|
||||||
|
stderrTail = (stderrTail + text).slice(-500);
|
||||||
|
const m = text.match(CAPTURING_RE);
|
||||||
|
if (!m) return;
|
||||||
|
const channels = Number(m[2]);
|
||||||
|
const rate = Number(m[3]);
|
||||||
|
if (channel > channels) {
|
||||||
|
report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` });
|
||||||
|
try { child.kill(); } catch { /* exiting anyway */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log(`LTC listener: capturing "${m[1]}" (${channels} ch @ ${rate} Hz), decoding channel ${channel}`);
|
||||||
|
extractor = new ChannelExtractor(channels, channel);
|
||||||
|
decoder = new LtcDecoder(rate, (frame) => {
|
||||||
|
const tc = fmtTc(frame);
|
||||||
|
const now = Date.now();
|
||||||
|
if (tc !== lastTc) lastChangeWall = now;
|
||||||
|
lastTc = tc;
|
||||||
|
fps = frame.fps;
|
||||||
|
df = frame.df;
|
||||||
|
lastFrameWall = now;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout.on('data', (buf) => {
|
||||||
|
if (extractor && decoder) decoder.push(extractor.push(buf));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
report({ error: `LTC capture failed to start: ${err.message}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
child = null;
|
||||||
|
if (stopped) return;
|
||||||
|
// The capture's own failure text (unknown device, ambiguous name,
|
||||||
|
// permission hint) is the most useful thing we can show.
|
||||||
|
const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
|
||||||
|
const quickDeath = Date.now() - startedAt < 3000;
|
||||||
|
report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') });
|
||||||
|
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRestart(ms) {
|
||||||
|
if (stopped || restartTimer) return;
|
||||||
|
restartTimer = setTimeout(() => {
|
||||||
|
restartTimer = null;
|
||||||
|
start();
|
||||||
|
}, ms);
|
||||||
|
restartTimer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
evalTimer = setInterval(() => {
|
||||||
|
if (stopped || !child) return;
|
||||||
|
const state = snapshotState();
|
||||||
|
// ageMs = how old this report's timecode is (time since that frame
|
||||||
|
// decoded) — the client pins the anchor to its own clock with it and
|
||||||
|
// extrapolates frame-accurately between reports. Only while running:
|
||||||
|
// a held frame has no meaningful age, and a varying field would defeat
|
||||||
|
// the report dedupe while stopped.
|
||||||
|
const update = { state, time: lastTc, fps, df };
|
||||||
|
if (state === 'running') update.ageMs = Date.now() - lastFrameWall;
|
||||||
|
report(update);
|
||||||
|
}, EVAL_MS);
|
||||||
|
evalTimer.unref?.();
|
||||||
|
|
||||||
|
start();
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(restartTimer);
|
||||||
|
clearInterval(evalTimer);
|
||||||
|
if (child) {
|
||||||
|
try { child.kill(); } catch { /* already gone */ }
|
||||||
|
child = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createLtcListener };
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LTC engine for the propresenter-timers module — SMPTE LTC decode from PCM,
|
||||||
|
* interleaved-channel extraction, and an encoder for tests.
|
||||||
|
*
|
||||||
|
* 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 ═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/** Sync word as bits 64–79 (0011111111111101) shift into a 16-bit register. */
|
||||||
|
const SYNC_WORD = 0x3ffd;
|
||||||
|
|
||||||
|
class LtcDecoder {
|
||||||
|
/**
|
||||||
|
* @param {number} sampleRate stdin PCM rate in Hz
|
||||||
|
* @param {(f:{h:number,m:number,s:number,f:number,df:boolean,fps:number})=>void} onFrame
|
||||||
|
*/
|
||||||
|
constructor(sampleRate, onFrame) {
|
||||||
|
this.sr = sampleRate;
|
||||||
|
this.onFrame = onFrame;
|
||||||
|
this.mean = 0; // DC offset tracker
|
||||||
|
this.env = 0; // decaying |sample| envelope, scales the comparator
|
||||||
|
this.level = 0; // comparator state: 1 / -1 (0 = not seen a crossing yet)
|
||||||
|
this.n = 0; // absolute sample clock
|
||||||
|
this.lastEdge = 0;
|
||||||
|
this.lastEdgeSeen = 0; // for signal-presence checks
|
||||||
|
// Samples per LTC bit. Start at the 30 fps value (the shortest possible
|
||||||
|
// bit) and let the interval EMA walk it up to the true rate.
|
||||||
|
this.T = sampleRate / (80 * 30);
|
||||||
|
this.halfAt = 0; // first half-interval of a suspected 1 bit, else 0
|
||||||
|
this.bits = new Uint8Array(80); // last 80 decoded bits, newest at [79]
|
||||||
|
this.sync = 0; // last 16 bits as an integer, newest at bit 0
|
||||||
|
this.fresh = 0; // bits decoded since the last reset — see bit()
|
||||||
|
this.prev = null; // previous valid frame, for rollover observation
|
||||||
|
this.nominal = 0; // nominal fps locked by two agreeing rollovers
|
||||||
|
this.rollCandidate = 0;
|
||||||
|
this.df = false; // drop-frame with two-frame hysteresis
|
||||||
|
this.dfPending = null;
|
||||||
|
this.dfRun = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Int16Array} samples */
|
||||||
|
push(samples) {
|
||||||
|
for (let i = 0; i < samples.length; i += 1) {
|
||||||
|
this.n += 1;
|
||||||
|
const raw = samples[i];
|
||||||
|
// Seed the DC tracker from the first sample — a real offset would
|
||||||
|
// otherwise take ~0.5 s to settle and cost the opening frames.
|
||||||
|
if (this.n === 1) this.mean = raw;
|
||||||
|
this.mean += (raw - this.mean) / 8192;
|
||||||
|
const v = raw - this.mean;
|
||||||
|
const mag = v < 0 ? -v : v;
|
||||||
|
this.env = mag > this.env ? mag : this.env * 0.9996;
|
||||||
|
// Floor keeps line noise from registering as signal during silence.
|
||||||
|
const th = Math.max(this.env * 0.3, 500);
|
||||||
|
let crossed = false;
|
||||||
|
if (this.level >= 0 && v < -th) {
|
||||||
|
this.level = -1;
|
||||||
|
crossed = true;
|
||||||
|
} else if (this.level <= 0 && v > th) {
|
||||||
|
this.level = 1;
|
||||||
|
crossed = true;
|
||||||
|
}
|
||||||
|
if (!crossed) continue;
|
||||||
|
const t = this.n - this.lastEdge;
|
||||||
|
this.lastEdge = this.n;
|
||||||
|
this.lastEdgeSeen = this.n;
|
||||||
|
this.interval(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One transition-to-transition gap, in samples. */
|
||||||
|
interval(t) {
|
||||||
|
if (t > 1.9 * this.T) {
|
||||||
|
// Silence gap or garbage — drop bit state, keep the period estimate
|
||||||
|
// (the stream that resumes is usually the same deck). Frame
|
||||||
|
// continuity broke, so rollover observation restarts too; the locked
|
||||||
|
// nominal survives — two agreeing rollovers re-lock a changed rate.
|
||||||
|
this.halfAt = 0;
|
||||||
|
this.sync = 0;
|
||||||
|
this.fresh = 0;
|
||||||
|
this.prev = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (t < 0.75 * this.T) {
|
||||||
|
// Half period: one of a 1-bit's pair.
|
||||||
|
if (this.halfAt) {
|
||||||
|
this.T += (this.halfAt + t - this.T) * 0.05;
|
||||||
|
this.halfAt = 0;
|
||||||
|
this.bit(1);
|
||||||
|
} else {
|
||||||
|
this.halfAt = t;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Full period: a 0 bit. A lone preceding half means we slipped —
|
||||||
|
// shed it and let the sync-word search re-align.
|
||||||
|
this.halfAt = 0;
|
||||||
|
this.T += (t - this.T) * 0.05;
|
||||||
|
this.bit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bit(b) {
|
||||||
|
this.bits.copyWithin(0, 1);
|
||||||
|
this.bits[79] = b;
|
||||||
|
this.sync = ((this.sync << 1) | b) & 0xffff;
|
||||||
|
if (this.fresh < 80) this.fresh += 1;
|
||||||
|
// Sync word just completed → bits[0..63] are one whole frame's data —
|
||||||
|
// but only once 80 real bits have displaced the cold-start/pre-gap
|
||||||
|
// buffer, or a phantom frame decodes from the padding.
|
||||||
|
if (this.sync === SYNC_WORD && this.fresh >= 80) this.frame();
|
||||||
|
}
|
||||||
|
|
||||||
|
frame() {
|
||||||
|
const bits = this.bits;
|
||||||
|
// Every LTC field is BCD, transmitted LSB-first.
|
||||||
|
const bcd = (start, len) => {
|
||||||
|
let v = 0;
|
||||||
|
for (let i = 0; i < len; i += 1) v |= bits[start + i] << i;
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
// Strict validation: LTC has no parity worth the name, so a bit error
|
||||||
|
// can produce a frame that "parses" — every field must be a legal BCD
|
||||||
|
// digit AND inside its hard ceiling, or the frame is dropped whole.
|
||||||
|
// (A lax check here once let a corrupt frames field of 35 through, and
|
||||||
|
// downstream fps logic reported 36 fps to the dashboard.)
|
||||||
|
const fu = bcd(0, 4);
|
||||||
|
const su = bcd(16, 4);
|
||||||
|
const mu = bcd(32, 4);
|
||||||
|
const hu = bcd(48, 4);
|
||||||
|
if (fu > 9 || su > 9 || mu > 9 || hu > 9) return;
|
||||||
|
const f = fu + bcd(8, 2) * 10;
|
||||||
|
const s = su + bcd(24, 3) * 10;
|
||||||
|
const m = mu + bcd(40, 3) * 10;
|
||||||
|
const h = hu + bcd(56, 2) * 10;
|
||||||
|
if (f > 29 || s > 59 || m > 59 || h > 23) return;
|
||||||
|
|
||||||
|
// Drop-frame flag with two-frame hysteresis — one corrupt-but-legal
|
||||||
|
// frame must not flash 29.97 DF across the dashboard.
|
||||||
|
const dfBit = bits[10] === 1;
|
||||||
|
this.dfRun = dfBit === this.dfPending ? this.dfRun + 1 : 1;
|
||||||
|
this.dfPending = dfBit;
|
||||||
|
if (this.dfRun >= 2) this.df = dfBit;
|
||||||
|
|
||||||
|
// Nominal rate from observed rollovers: the frame number that precedes
|
||||||
|
// an f=0-with-seconds-advance IS the rate minus one. Two consecutive
|
||||||
|
// agreeing rollovers lock it — a single corrupt frame can't fake two
|
||||||
|
// valid, correctly-successive frames twice, and a genuine rate change
|
||||||
|
// re-locks itself within two seconds.
|
||||||
|
if (this.prev && f === 0 && s === (this.prev.s + 1) % 60) {
|
||||||
|
const cand = this.prev.f + 1;
|
||||||
|
if (cand === 24 || cand === 25 || cand === 30) {
|
||||||
|
if (cand === this.rollCandidate) this.nominal = cand;
|
||||||
|
this.rollCandidate = cand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.prev = { h, m, s, f };
|
||||||
|
this.onFrame({ h, m, s, f, df: this.df, fps: this.fps(this.df) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nominal rate: locked by rollover consensus once two second-boundaries
|
||||||
|
* agree; until then snap the measured bit clock to a standard rate.
|
||||||
|
* 29.97 vs 30 non-drop differ by 0.1% — beyond the bit-clock estimate's
|
||||||
|
* accuracy — so non-drop 29.97 reads as 30. Drop-frame is exact: the DF
|
||||||
|
* bit means 29.97.
|
||||||
|
*/
|
||||||
|
fps(df) {
|
||||||
|
if (df) return 29.97;
|
||||||
|
if (this.nominal) return this.nominal;
|
||||||
|
const measured = this.sr / (this.T * 80);
|
||||||
|
return [24, 25, 30].reduce((a, b) => (Math.abs(b - measured) < Math.abs(a - measured) ? b : a));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while transitions are actually arriving (sample-clock based). */
|
||||||
|
signalPresent() {
|
||||||
|
return this.n - this.lastEdgeSeen < this.sr * 0.05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ══ encoder (selftest + demo — and pipeable test audio) ═══════════════ */
|
||||||
|
|
||||||
|
/** Advance one frame under the given counting rules (fps = nominal integer). */
|
||||||
|
function nextTc(tc, fps, df) {
|
||||||
|
let { h, m, s, f } = tc;
|
||||||
|
f += 1;
|
||||||
|
if (f >= fps) {
|
||||||
|
f = 0;
|
||||||
|
s += 1;
|
||||||
|
if (s >= 60) {
|
||||||
|
s = 0;
|
||||||
|
m += 1;
|
||||||
|
if (m >= 60) {
|
||||||
|
m = 0;
|
||||||
|
h = (h + 1) % 24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Drop-frame: frames 00 and 01 don't exist at the top of a minute,
|
||||||
|
// except every tenth minute.
|
||||||
|
if (df && f === 0 && s === 0 && m % 10 !== 0) f = 2;
|
||||||
|
return { h, m, s, f };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 80 bits of one frame, in transmission order. User bits stay zero;
|
||||||
|
the polarity-correction/parity bit is legal to leave clear. */
|
||||||
|
function frameBits(tc, df) {
|
||||||
|
const bits = new Uint8Array(80);
|
||||||
|
const put = (start, len, v) => {
|
||||||
|
for (let i = 0; i < len; i += 1) bits[start + i] = (v >> i) & 1;
|
||||||
|
};
|
||||||
|
put(0, 4, tc.f % 10);
|
||||||
|
put(8, 2, Math.floor(tc.f / 10));
|
||||||
|
bits[10] = df ? 1 : 0;
|
||||||
|
put(16, 4, tc.s % 10);
|
||||||
|
put(24, 3, Math.floor(tc.s / 10));
|
||||||
|
put(32, 4, tc.m % 10);
|
||||||
|
put(40, 3, Math.floor(tc.m / 10));
|
||||||
|
put(48, 4, tc.h % 10);
|
||||||
|
put(56, 2, Math.floor(tc.h / 10));
|
||||||
|
const sync = [0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1];
|
||||||
|
for (let i = 0; i < 16; i += 1) bits[64 + i] = sync[i];
|
||||||
|
return bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
class LtcEncoder {
|
||||||
|
/** @param {number} fps true rate (29.97 allowed); counting uses its rounding */
|
||||||
|
constructor(sampleRate, fps, df, startTc, amplitude = 12000) {
|
||||||
|
this.nominal = Math.round(fps);
|
||||||
|
this.df = df;
|
||||||
|
this.amp = amplitude;
|
||||||
|
this.spb = sampleRate / (fps * 80); // samples per bit, fractional
|
||||||
|
this.tc = { ...startTc };
|
||||||
|
this.bits = frameBits(this.tc, df);
|
||||||
|
this.bitIdx = 0;
|
||||||
|
this.halfPending = false;
|
||||||
|
this.level = 1;
|
||||||
|
this.t = 0; // sample clock
|
||||||
|
this.nextEdge = 0; // fractional sample time of the next transition
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Time between this transition and the next (biphase mark). */
|
||||||
|
nextInterval() {
|
||||||
|
if (this.halfPending) {
|
||||||
|
this.halfPending = false;
|
||||||
|
return this.spb / 2;
|
||||||
|
}
|
||||||
|
if (this.bitIdx >= 80) {
|
||||||
|
this.tc = nextTc(this.tc, this.nominal, this.df);
|
||||||
|
this.bits = frameBits(this.tc, this.df);
|
||||||
|
this.bitIdx = 0;
|
||||||
|
}
|
||||||
|
const bit = this.bits[this.bitIdx];
|
||||||
|
this.bitIdx += 1;
|
||||||
|
if (bit) {
|
||||||
|
this.halfPending = true;
|
||||||
|
return this.spb / 2;
|
||||||
|
}
|
||||||
|
return this.spb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {Int16Array} the next n samples of the running LTC stream */
|
||||||
|
render(n) {
|
||||||
|
const out = new Int16Array(n);
|
||||||
|
for (let i = 0; i < n; i += 1) {
|
||||||
|
while (this.t >= this.nextEdge) {
|
||||||
|
this.level = -this.level;
|
||||||
|
this.nextEdge += this.nextInterval();
|
||||||
|
}
|
||||||
|
out[i] = this.level * this.amp;
|
||||||
|
this.t += 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ══ shared bits ═══════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a raw s16le byte stream of interleaved channels into one channel's
|
||||||
|
* samples. Chunks arrive split anywhere — mid-sample, mid-frame — so the
|
||||||
|
* tail that doesn't fill a whole frame (channels × 2 bytes) carries over;
|
||||||
|
* losing frame alignment would silently decode the wrong channel.
|
||||||
|
*/
|
||||||
|
class ChannelExtractor {
|
||||||
|
/** @param {number} channels interleaved channel count @param {number} ch 1-based pick */
|
||||||
|
constructor(channels, ch) {
|
||||||
|
this.frameBytes = channels * 2;
|
||||||
|
this.offset = (ch - 1) * 2;
|
||||||
|
this.carry = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {Buffer} buf @returns {Int16Array} the picked channel's samples */
|
||||||
|
push(buf) {
|
||||||
|
if (this.carry) buf = Buffer.concat([this.carry, buf]);
|
||||||
|
const usable = buf.length - (buf.length % this.frameBytes);
|
||||||
|
this.carry = buf.length > usable ? buf.subarray(usable) : null;
|
||||||
|
const out = new Int16Array(usable / this.frameBytes);
|
||||||
|
for (let i = 0, off = this.offset; i < out.length; i += 1, off += this.frameBytes) {
|
||||||
|
out[i] = buf.readInt16LE(off);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad2 = (v) => String(v).padStart(2, '0');
|
||||||
|
const fmtTc = (tc) => `${pad2(tc.h)}:${pad2(tc.m)}:${pad2(tc.s)}:${pad2(tc.f)}`;
|
||||||
|
|
||||||
|
module.exports = { LtcDecoder, LtcEncoder, ChannelExtractor, frameBits, nextTc, fmtTc, SYNC_WORD };
|
||||||
@@ -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();
|
||||||
@@ -1,38 +1,57 @@
|
|||||||
{
|
{
|
||||||
"id": "propresenter-timers",
|
"id": "propresenter-timers",
|
||||||
"name": "Timers",
|
"name": "Timers",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"proddash": ">=1.1.0",
|
"proddash": ">=1.2.0",
|
||||||
"description": "Live ProPresenter timers and LTC timecode",
|
"description": "Live ProPresenter timers and LTC timecode",
|
||||||
"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": ""
|
||||||
},
|
},
|
||||||
"stagePassword": {
|
"ltcEnabled": {
|
||||||
"type": "password",
|
"type": "switch",
|
||||||
"label": "Stage display password (for LTC — ProPresenter Settings → Network)",
|
"label": "LTC listener — decode timecode from an audio input on this machine",
|
||||||
"default": ""
|
"group": "LTC Timecode",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"ltcDevice": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Audio device",
|
||||||
|
"group": "LTC Timecode",
|
||||||
|
"showWhen": "ltcEnabled",
|
||||||
|
"default": "",
|
||||||
|
"optionsRoute": "/ltc/devices"
|
||||||
|
},
|
||||||
|
"ltcChannel": {
|
||||||
|
"type": "number",
|
||||||
|
"label": "Channel on that device (1-based)",
|
||||||
|
"group": "LTC Timecode",
|
||||||
|
"showWhen": "ltcEnabled",
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"timerFont": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Timer font",
|
||||||
|
"group": "Display",
|
||||||
|
"default": "default",
|
||||||
|
"options": [
|
||||||
|
{ "value": "default", "label": "Default (system)" },
|
||||||
|
{ "value": "monospace", "label": "Monospace — steadier changing digits" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
/**
|
/**
|
||||||
* 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)
|
||||||
|
* GET /ltc/devices → this machine's audio input devices, for the admin
|
||||||
|
* page's device picker (select + optionsRoute).
|
||||||
*
|
*
|
||||||
* Endpoints used, verified against openapi.propresenter.com (API v1):
|
* Endpoints used, verified against openapi.propresenter.com (API v1):
|
||||||
* GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun,
|
* GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun,
|
||||||
@@ -21,17 +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 actually comes from the classic
|
* LTC: ProPresenter exposes no timecode over any API (the HTTP openAPI has
|
||||||
* stage-display websocket (see stage-ws.js): a stage layout field labeled
|
* no timecode route, and the classic stage-display websocket drops the
|
||||||
* "LTC" streams its text to us. Source precedence: the HTTP route if it ever
|
* field — both verified dead on ProPresenter 21.4), so LTC is read from the
|
||||||
* answers (authoritative), else the stage field, else "not available".
|
* 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
|
* 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
|
||||||
@@ -52,21 +51,25 @@ 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;
|
||||||
|
|
||||||
const { createStageLtcClient } = require('./stage-ws');
|
const { spawn } = require('child_process');
|
||||||
|
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. */
|
||||||
let current = null;
|
let current = null;
|
||||||
|
|
||||||
function clearedState(enabled) {
|
function clearedState(enabled, ltcEnabled = false, timerFont = 'default') {
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
|
ltcEnabled,
|
||||||
|
timerFont,
|
||||||
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 both
|
// listener is off, has no device, or its capture failed (note says
|
||||||
// sources came up empty (note says why, when we know).
|
// why). status is running|stopped|nosignal; while running, ageMs lets
|
||||||
ltc: { supported: null, time: '', receiving: false, source: '', note: '' },
|
// clients count frames locally between updates.
|
||||||
|
ltc: { supported: false, time: '', receiving: false, status: '', fps: 0, df: false, source: '', note: '' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +84,12 @@ module.exports = {
|
|||||||
const port = Number(config.port ?? ep.port) || 1025;
|
const port = Number(config.port ?? ep.port) || 1025;
|
||||||
const password = String(config.password || '');
|
const password = String(config.password || '');
|
||||||
const enabled = Boolean(host && port);
|
const enabled = Boolean(host && port);
|
||||||
|
const ltcEnabled = Boolean(config.ltcEnabled);
|
||||||
|
const ltcDevice = String(config.ltcDevice || '');
|
||||||
|
const ltcChannel = Math.max(1, Math.trunc(Number(config.ltcChannel)) || 1);
|
||||||
|
const timerFont = config.timerFont === 'monospace' ? 'monospace' : 'default';
|
||||||
|
|
||||||
let latest = clearedState(enabled);
|
let latest = clearedState(enabled, ltcEnabled, timerFont);
|
||||||
let lastFrame = '';
|
let lastFrame = '';
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let pollTimer = null;
|
let pollTimer = null;
|
||||||
@@ -90,25 +97,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 listenerLtc = 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: '' };
|
|
||||||
|
|
||||||
/** One LTC verdict from both sources: HTTP route wins if it ever
|
/** The LTC verdict — the built-in listener is the only source. */
|
||||||
answers (authoritative), else the stage-display field. */
|
|
||||||
function composeLtc() {
|
function composeLtc() {
|
||||||
if (ltcSupported === true) {
|
if (!ltcEnabled) {
|
||||||
return { supported: true, source: 'api', time: ltcLast.time, receiving: ltcLast.receiving, note: '' };
|
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' };
|
||||||
}
|
}
|
||||||
if (stageLtc.bound) {
|
if (!listenerLtc) {
|
||||||
return { supported: true, source: 'stage', time: stageLtc.time, receiving: stageLtc.receiving, note: '' };
|
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'Starting LTC listener…' };
|
||||||
}
|
}
|
||||||
if (ltcSupported === null && !stageLtc.note) {
|
if (listenerLtc.error) {
|
||||||
return { supported: null, source: '', time: '', receiving: false, note: '' };
|
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: listenerLtc.error };
|
||||||
}
|
}
|
||||||
return { supported: false, source: '', time: '', receiving: false, note: stageLtc.note || '' };
|
const running = listenerLtc.state === 'running';
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
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.
|
||||||
|
...(running && Number.isFinite(listenerLtc.ageMs) ? { ageMs: Math.max(0, listenerLtc.ageMs) } : {}),
|
||||||
|
note: '',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function url(target) {
|
function url(target) {
|
||||||
@@ -189,63 +205,11 @@ 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() {
|
|
||||||
// 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,
|
||||||
|
ltcEnabled,
|
||||||
|
timerFont,
|
||||||
reachable: latest.reachable,
|
reachable: latest.reachable,
|
||||||
lastError: latest.lastError,
|
lastError: latest.lastError,
|
||||||
timers: latest.timers,
|
timers: latest.timers,
|
||||||
@@ -281,45 +245,46 @@ 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}`);
|
||||||
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The built-in listener is independent of ProPresenter — LTC-only
|
||||||
|
// setups (no host configured) still get the LTC card.
|
||||||
|
let ltcListener = null;
|
||||||
|
if (ltcEnabled) {
|
||||||
|
if (ltcDevice) {
|
||||||
|
ltcListener = createLtcListener({
|
||||||
|
device: ltcDevice,
|
||||||
|
channel: ltcChannel,
|
||||||
|
moduleDir: __dirname,
|
||||||
|
log,
|
||||||
|
onUpdate(update) {
|
||||||
|
listenerLtc = update;
|
||||||
|
broadcast();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
listenerLtc = { error: 'LTC listener is on but no audio device is selected — pick one in Admin' };
|
||||||
|
broadcast();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
for (const res of streams) {
|
for (const res of streams) {
|
||||||
try {
|
try {
|
||||||
@@ -338,7 +303,7 @@ module.exports = {
|
|||||||
stopped = true;
|
stopped = true;
|
||||||
clearTimeout(pollTimer);
|
clearTimeout(pollTimer);
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
stageClient?.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 */ }
|
||||||
}
|
}
|
||||||
@@ -346,12 +311,19 @@ module.exports = {
|
|||||||
if (current && current.streams === streams) current = null;
|
if (current && current.streams === streams) current = null;
|
||||||
},
|
},
|
||||||
health() {
|
health() {
|
||||||
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
|
// 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 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, ${latest.ltc.status}` };
|
||||||
|
return { status: 'error', message: 'No ProPresenter host configured' };
|
||||||
|
}
|
||||||
if (latest.reachable) {
|
if (latest.reachable) {
|
||||||
const ltc = latest.ltc;
|
|
||||||
const ltcNote = ltc.supported
|
|
||||||
? (ltc.source === 'stage' ? ', LTC via stage display' : ', LTC via API')
|
|
||||||
: (ltc.note ? ` (${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 {
|
||||||
@@ -373,7 +345,7 @@ module.exports = {
|
|||||||
*/
|
*/
|
||||||
tiles() {
|
tiles() {
|
||||||
const state = current ? current.getLatest() : null;
|
const state = current ? current.getLatest() : null;
|
||||||
if (!state || !state.enabled) return []; // unconfigured → classic single entry
|
if (!state || (!state.enabled && !state.ltcEnabled)) return []; // unconfigured → classic single entry
|
||||||
const TYPE_LABEL = {
|
const TYPE_LABEL = {
|
||||||
countdown: 'Countdown timer',
|
countdown: 'Countdown timer',
|
||||||
countdown_to_time: 'Countdown to a time of day',
|
countdown_to_time: 'Countdown to a time of day',
|
||||||
@@ -382,22 +354,56 @@ module.exports = {
|
|||||||
};
|
};
|
||||||
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
|
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
|
||||||
return [
|
return [
|
||||||
{ id: 'all', name: 'All timers', description: 'Every timer and the LTC timecode, selectable per tile' },
|
{
|
||||||
|
id: 'all',
|
||||||
|
name: 'All timers',
|
||||||
|
description: state.ltcEnabled
|
||||||
|
? 'Every timer and the LTC timecode, selectable per tile'
|
||||||
|
: 'Every timer, selectable per tile',
|
||||||
|
},
|
||||||
...state.timers.map((t) => ({
|
...state.timers.map((t) => ({
|
||||||
id: `timer:${t.uuid}`,
|
id: `timer:${t.uuid}`,
|
||||||
name: t.name,
|
name: t.name,
|
||||||
description: TYPE_LABEL[t.type] || 'Timer',
|
description: TYPE_LABEL[t.type] || 'Timer',
|
||||||
...oneCard,
|
...oneCard,
|
||||||
})),
|
})),
|
||||||
{ id: 'ltc', name: 'LTC timecode', description: 'Incoming timecode (HH:MM:SS:FF)', ...oneCard },
|
// The LTC entry exists only while the admin switch is on.
|
||||||
|
...(state.ltcEnabled
|
||||||
|
? [{ id: 'ltc', name: 'LTC timecode', description: 'Incoming timecode (HH:MM:SS:FF)', ...oneCard }]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
routes() {
|
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));
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
'GET /state': (req, res) => {
|
'GET /state': (req, res) => {
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
sendJson(res, 200, { state: current ? current.getLatest() : clearedState(false) });
|
||||||
res.end(JSON.stringify({ state: current ? current.getLatest() : clearedState(false) }));
|
},
|
||||||
|
|
||||||
|
// The admin device picker asks this machine what it can hear. The
|
||||||
|
// options shape matches the admin select's optionsRoute contract.
|
||||||
|
'GET /ltc/devices': (req, res) => {
|
||||||
|
const bin = require('path').join(__dirname, 'ltc-capture');
|
||||||
|
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
|
||||||
|
let err = '';
|
||||||
|
child.stderr.setEncoding('utf8');
|
||||||
|
child.stderr.on('data', (t) => { err += t; });
|
||||||
|
child.on('error', () => {
|
||||||
|
sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' });
|
||||||
|
});
|
||||||
|
child.on('exit', () => {
|
||||||
|
if (res.writableEnded) return;
|
||||||
|
const options = [];
|
||||||
|
for (const line of err.split('\n')) {
|
||||||
|
const m = line.match(/^ltc-capture:\s+(.*?)\s+(\d+) ch\s+(\d+) Hz\s+uid=/);
|
||||||
|
if (m) options.push({ value: m[1], label: `${m[1]} — ${m[2]} ch @ ${(Number(m[3]) / 1000)} kHz` });
|
||||||
|
}
|
||||||
|
sendJson(res, 200, { options });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /stream': (req, res) => {
|
'GET /stream': (req, res) => {
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -30,8 +30,12 @@
|
|||||||
}
|
}
|
||||||
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
|
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
|
||||||
|
|
||||||
/* Values still on screen while the feed is down are stale — say so visually. */
|
/* Values still on screen while the feed is down are stale — say so visually.
|
||||||
[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-grid { opacity: 0.45; }
|
Per-card, because the reader-fed LTC card is not stale when only
|
||||||
|
ProPresenter is unreachable; it dims only when the ProdDash feed itself
|
||||||
|
is down (is-feed-down) and everything on screen really is old. */
|
||||||
|
[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-card { opacity: 0.45; }
|
||||||
|
[data-module="propresenter-timers"] .tm-wrap.is-stale:not(.is-feed-down) .tm-card-ltc.src-reader { opacity: 1; }
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-hint {
|
[data-module="propresenter-timers"] .tm-hint {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -54,7 +58,6 @@
|
|||||||
grid-auto-rows: 1fr;
|
grid-auto-rows: 1fr;
|
||||||
gap: var(--tm-gap, 8px);
|
gap: var(--tm-gap, 8px);
|
||||||
padding: var(--tm-pad, 10px);
|
padding: var(--tm-pad, 10px);
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
}
|
||||||
[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; }
|
[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; }
|
||||||
|
|
||||||
@@ -80,6 +83,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: color-mix(in srgb, var(--bg) 60%, transparent);
|
background: color-mix(in srgb, var(--bg) 60%, transparent);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-name {
|
[data-module="propresenter-timers"] .tm-name {
|
||||||
@@ -109,6 +113,13 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Admin "Timer font: Monospace" — steadier digits as the numbers change.
|
||||||
|
Applies to timer and LTC values alike (both are .tm-time). */
|
||||||
|
[data-module="propresenter-timers"] .tm-mono .tm-time {
|
||||||
|
font-family: ui-monospace, "SF Mono", "Menlo", "Cascadia Mono", "Consolas", monospace;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
[data-module="propresenter-timers"] .tm-state {
|
[data-module="propresenter-timers"] .tm-state {
|
||||||
flex: none;
|
flex: none;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -198,6 +209,10 @@
|
|||||||
|
|
||||||
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
|
@keyframes tm-nosignal-blink { 50% { opacity: 0.35; } }
|
||||||
|
|
||||||
|
/* LTC ceased mid-show: hold the last frame, quietly — no alarm colors,
|
||||||
|
stopping the clock is usually somebody doing their job. */
|
||||||
|
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-stopped .tm-time { color: var(--muted); }
|
||||||
|
|
||||||
/* No timecode API on this ProPresenter: say so, quietly. */
|
/* No timecode API on this ProPresenter: say so, quietly. */
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-time { color: var(--muted); }
|
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-time { color: var(--muted); }
|
||||||
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state {
|
[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state {
|
||||||
@@ -207,8 +222,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 {
|
||||||
|
|||||||
+7
-3
@@ -1,9 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "proddash",
|
"name": "proddash",
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
|
"description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"engines": { "node": ">=18" },
|
"engines": {
|
||||||
"scripts": { "start": "node server.js" }
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+86
-13
@@ -98,19 +98,39 @@ function healthText(mod) {
|
|||||||
return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).';
|
return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).';
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldFor(key, spec, value, passwordSet) {
|
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');
|
||||||
@@ -137,17 +157,38 @@ function fieldFor(key, spec, value, passwordSet) {
|
|||||||
row.append(host, sep, port);
|
row.append(host, sep, port);
|
||||||
field.append(span, row);
|
field.append(span, row);
|
||||||
read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 });
|
read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 });
|
||||||
} else if (type === 'select' && Array.isArray(spec.options)) {
|
} else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = label;
|
span.textContent = label;
|
||||||
const select = document.createElement('select');
|
const select = document.createElement('select');
|
||||||
for (const opt of spec.options) {
|
const fill = (options) => {
|
||||||
const o = document.createElement('option');
|
select.innerHTML = '';
|
||||||
o.value = String(typeof opt === 'object' ? opt.value : opt);
|
// The saved value must stay selectable even when it isn't among the
|
||||||
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
// live options (device unplugged, module route down).
|
||||||
select.appendChild(o);
|
const saved = String(value ?? '');
|
||||||
|
if (saved && !options.some((opt) => String(typeof opt === 'object' ? opt.value : opt) === saved)) {
|
||||||
|
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
|
||||||
|
}
|
||||||
|
if (!saved) options = [{ value: '', label: '— choose —' }, ...options];
|
||||||
|
for (const opt of options) {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = String(typeof opt === 'object' ? opt.value : opt);
|
||||||
|
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
|
||||||
|
select.appendChild(o);
|
||||||
|
}
|
||||||
|
select.value = saved;
|
||||||
|
};
|
||||||
|
fill(Array.isArray(spec.options) ? spec.options : []);
|
||||||
|
if (spec.optionsRoute && moduleId) {
|
||||||
|
// Options discovered at runtime (audio devices, ports, sources…):
|
||||||
|
// the module serves them from one of its own routes.
|
||||||
|
fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}`)
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((body) => {
|
||||||
|
if (Array.isArray(body?.options)) fill(body.options);
|
||||||
|
})
|
||||||
|
.catch(() => { /* keep the static/saved options */ });
|
||||||
}
|
}
|
||||||
select.value = String(value ?? '');
|
|
||||||
field.append(span, select);
|
field.append(span, select);
|
||||||
read = () => select.value;
|
read = () => select.value;
|
||||||
} else {
|
} else {
|
||||||
@@ -164,7 +205,7 @@ function fieldFor(key, spec, value, passwordSet) {
|
|||||||
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) {
|
||||||
@@ -277,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]);
|
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';
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user