Timers: LTC from the audio signal itself via an external reader

ProPresenter 21.4 exposes no timecode through any API, so read the LTC
where it actually lives — the audio feed:

- tools/ltc-reader.js: zero-dependency SMPTE LTC decoder (biphase-mark,
  adaptive bit clock, envelope-scaled comparator) fed s16le PCM on stdin
  from ffmpeg/sox; POSTs running/stopped/nosignal + timecode + frame rate
  (incl. 29.97 drop-frame) to the module. --selftest runs an encoder→
  decoder suite (24/25/30 fps, DF minute counting, low level + noise + DC,
  dropout recovery); --demo synthesizes live LTC through the real decoder
  and ingest path with no audio hardware.
- Module: POST /ltc ingest route (optional bearer token, validated body,
  6 s staleness with automatic fallback to the API probe / stage source),
  richer LTC state (status, fps, df, reader source), health messages.
- Client: LTC card shows Running · <rate> / Stopped (held frame) /
  No signal; works LTC-only with no ProPresenter host configured; the
  reader-fed card no longer grays out when only ProPresenter is down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-01 14:52:20 -04:00
co-authored by Claude Fable 5
parent d5cb6b6cd9
commit cb77030eec
6 changed files with 744 additions and 34 deletions
+24 -6
View File
@@ -207,11 +207,15 @@ export default function create({ root, moduleApi }) {
function updateLtcCard(card, ltc) {
let mode;
// Only the reader source knows 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) {
mode = 'unavailable';
card.timeEl.textContent = '—';
// The server says why when it knows (stage password rejected, no
// stage field labeled LTC, …) — surface that over the generic line.
// stage field labeled LTC, reader offline, …) — surface that over
// the generic line.
card.stateEl.textContent = ltc.note || 'Timecode not available on this ProPresenter';
} else if (ltc.supported === null) {
mode = 'waiting';
@@ -220,19 +224,30 @@ export default function create({ root, moduleApi }) {
} else if (ltc.receiving) {
mode = 'receiving';
card.timeEl.textContent = displayTime(ltc.time);
card.stateEl.textContent = 'Receiving';
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.stateEl.textContent = 'Stopped';
} else {
mode = 'nosignal';
card.timeEl.textContent = displayTime(ltc.time);
card.timeEl.textContent = '—';
card.stateEl.textContent = 'No signal';
}
card.el.className = `tm-card tm-card-ltc is-ltc-${mode}`;
// The reader posts independently of ProPresenter, so its card must not
// gray out with the rest when only ProPresenter is unreachable.
const live = ltc.source === 'reader' ? ' src-reader' : '';
card.el.className = `tm-card tm-card-ltc is-ltc-${mode}${live}`;
}
/* ── status dot + degraded banners ──────────────────────────────── */
function renderStatus() {
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) {
bannerEl.hidden = false;
bannerEl.textContent = 'Not receiving updates from the ProdDash server.';
@@ -246,7 +261,10 @@ export default function create({ root, moduleApi }) {
}
if (!state.enabled) {
bannerEl.hidden = true;
moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
// The LTC reader feeds us with or without ProPresenter — an LTC-only
// setup is working, not broken.
if (state.ltc?.supported) moduleApi.setStatus('ok', 'LTC via reader — no ProPresenter host configured');
else moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
return;
}
if (!state.reachable) {
@@ -266,7 +284,7 @@ export default function create({ root, moduleApi }) {
rebuildMenu();
let hint = '';
if (state && !state.enabled) {
if (state && !state.enabled && !state.ltc?.supported) {
hint = 'No ProPresenter connection — set the host and port in Admin.';
}
const items = hint ? [] : visibleItems();
+6 -1
View File
@@ -1,7 +1,7 @@
{
"id": "propresenter-timers",
"name": "Timers",
"version": "1.0.0",
"version": "1.1.0",
"description": "Live ProPresenter timers and LTC timecode",
"client": "client.js",
"server": "server.js",
@@ -23,6 +23,11 @@
"type": "password",
"label": "Stage display password (for LTC — ProPresenter Settings → Network)",
"default": ""
},
"ingestToken": {
"type": "password",
"label": "LTC reader token (optional — must match tools/ltc-reader.js --token)",
"default": ""
}
}
}
+123 -20
View File
@@ -10,6 +10,8 @@
*
* GET /state → { state: <state> } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated)
* POST /ltc → LTC ingest: tools/ltc-reader.js decodes the actual LTC
* audio signal and reports { state, time, fps, df } here.
*
* Endpoints used, verified against openapi.propresenter.com (API v1):
* GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun,
@@ -28,10 +30,13 @@
* 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
* stage-display websocket (see stage-ws.js): a stage layout field labeled
* "LTC" streams its text to us. Source precedence: the HTTP route if it ever
* answers (authoritative), else the stage field, else "not available".
* Because the HTTP API has no timecode, LTC comes from two real sources:
* the external reader (POST /ltc — tools/ltc-reader.js decoding the LTC
* audio feed itself, ground truth while its reports are fresh) and the
* classic stage-display websocket (see stage-ws.js): a stage layout field
* labeled "LTC" streams its text to us. Source precedence: fresh reader
* ingest, else the HTTP route if it ever answers, else the stage field,
* else "not available".
*
* ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path
* notes in ../propresenter-now-next/propresenter-core). So: requests run
@@ -51,6 +56,9 @@ const SLOW_EVERY = 4;
const REQUEST_TIMEOUT_MS = 4000;
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000;
/** A reader report older than this no longer speaks for the LTC feed —
the reader posts at least 1×/s, so 6s of silence means it died. */
const INGEST_STALE_MS = 6000;
const { createStageLtcClient } = require('./stage-ws');
@@ -64,9 +72,11 @@ function clearedState(enabled) {
lastError: '',
timers: [],
// supported: null = no verdict yet (probing, or ProPresenter
// unreachable); true once a timecode source is live; false when both
// sources came up empty (note says why, when we know).
ltc: { supported: null, time: '', receiving: false, source: '', note: '' },
// unreachable); true once a timecode source is live; false when every
// source came up empty (note says why, when we know). status is the
// richer verdict (running|stopped|nosignal); fps/df only when the
// source knows them (the reader decodes both from the LTC bits).
ltc: { supported: null, time: '', receiving: false, status: '', fps: 0, df: false, source: '', note: '' },
};
}
@@ -95,20 +105,72 @@ module.exports = {
let ltcLast = { time: '', receiving: false };
/** Live view of the stage-display websocket's LTC field (stage-ws.js). */
let stageLtc = { bound: false, time: '', receiving: false, note: '' };
/** Latest POST /ltc report from the external reader, null until one lands. */
let ingest = null; // { state, time, fps, df, source, at }
let ingestExpiry = null;
/** One LTC verdict from both sources: HTTP route wins if it ever
answers (authoritative), else the stage-display field. */
/** One LTC verdict from all three sources. The external reader decodes
the LTC audio itself, so a fresh report from it is ground truth;
then the HTTP route if it ever answers; then the stage-display
field. Text sources don't know fps — receiving/frozen/empty maps
onto the same status vocabulary the reader uses. */
function composeLtc() {
if (ingest && Date.now() - ingest.at < INGEST_STALE_MS) {
return {
supported: true,
source: 'reader',
time: ingest.time,
receiving: ingest.state === 'running',
status: ingest.state,
fps: ingest.fps,
df: ingest.df,
note: '',
};
}
const textStatus = (src) => (src.receiving ? 'running' : src.time ? 'stopped' : 'nosignal');
if (ltcSupported === true) {
return { supported: true, source: 'api', time: ltcLast.time, receiving: ltcLast.receiving, note: '' };
return { supported: true, source: 'api', time: ltcLast.time, receiving: ltcLast.receiving, status: textStatus(ltcLast), fps: 0, df: false, note: '' };
}
if (stageLtc.bound) {
return { supported: true, source: 'stage', time: stageLtc.time, receiving: stageLtc.receiving, note: '' };
return { supported: true, source: 'stage', time: stageLtc.time, receiving: stageLtc.receiving, status: textStatus(stageLtc), fps: 0, df: false, note: '' };
}
if (ltcSupported === null && !stageLtc.note) {
return { supported: null, source: '', time: '', receiving: false, note: '' };
if (ingest) {
// A reader reported once but went silent — likelier to be the
// story than anything the stage feed has to say.
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC reader offline' };
}
return { supported: false, source: '', time: '', receiving: false, note: stageLtc.note || '' };
if (ltcSupported === null && !stageLtc.note && enabled) {
return { supported: null, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: '' };
}
return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: stageLtc.note || '' };
}
/** Timecode-shaped text — same shape rule as the other sources. */
const TC_SHAPE = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
const INGEST_STATES = new Set(['running', 'stopped', 'nosignal']);
/** Validated apply of one POST /ltc body. Returns '' or a client error. */
function applyIngest(body) {
if (!body || typeof body !== 'object') return 'body must be a JSON object';
const state = INGEST_STATES.has(body.state) ? body.state : '';
if (!state) return 'state must be one of running|stopped|nosignal';
const time = typeof body.time === 'string' && TC_SHAPE.test(body.time.trim()) ? body.time.trim() : '';
if (state === 'running' && !time) return 'a running report needs a timecode-shaped time';
ingest = {
state,
time,
fps: Number.isFinite(body.fps) && body.fps > 0 && body.fps <= 120 ? Number(body.fps) : 0,
df: body.df === true,
source: String(body.source || '').slice(0, 60),
at: Date.now(),
};
// When the reader dies mid-run the card must not stay green forever —
// re-broadcast once this report goes stale so the fallbacks take over.
clearTimeout(ingestExpiry);
ingestExpiry = setTimeout(broadcast, INGEST_STALE_MS + 100);
ingestExpiry.unref?.();
broadcast();
return '';
}
function url(target) {
@@ -331,12 +393,13 @@ module.exports = {
}, HEARTBEAT_MS);
heartbeat.unref?.();
current = { streams, getLatest: () => latest };
current = { streams, getLatest: () => latest, ingest: applyIngest };
return {
stop() {
stopped = true;
clearTimeout(pollTimer);
clearTimeout(ingestExpiry);
clearInterval(heartbeat);
stageClient?.stop();
for (const res of streams) {
@@ -346,11 +409,17 @@ module.exports = {
if (current && current.streams === streams) current = null;
},
health() {
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
const ltcSource = { reader: ', LTC via reader', api: ', LTC via API', stage: ', LTC via stage display' };
if (!enabled) {
// The LTC reader posts to us regardless of ProPresenter — an
// LTC-only setup is half-configured but working.
if (latest.ltc.source === 'reader') return { status: 'ok', message: 'No ProPresenter host configured — LTC via reader only' };
return { status: 'error', message: 'No ProPresenter host configured' };
}
if (latest.reachable) {
const ltc = latest.ltc;
const ltcNote = ltc.supported
? (ltc.source === 'stage' ? ', LTC via stage display' : ', LTC via API')
? (ltcSource[ltc.source] || '')
: (ltc.note ? ` (${ltc.note})` : '');
return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` };
}
@@ -393,11 +462,45 @@ module.exports = {
];
},
routes() {
routes({ config }) {
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 {
'GET /state': (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ state: current ? current.getLatest() : clearedState(false) }));
sendJson(res, 200, { state: current ? current.getLatest() : clearedState(false) });
},
// The external LTC reader (tools/ltc-reader.js) reports here — the
// one route on this module that accepts writes, so it optionally
// carries a shared token (Admin → "LTC reader token").
'POST /ltc': (req, res) => {
const token = String(config.ingestToken || '');
if (token) {
const given = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
if (given !== token) return void sendJson(res, 401, { error: 'bad or missing token' });
}
let body = '';
req.on('data', (chunk) => {
body += chunk;
if (body.length > 4096) {
sendJson(res, 413, { error: 'body too large' });
req.destroy();
}
});
req.on('end', () => {
if (!current) return void sendJson(res, 503, { error: 'module not mounted' });
let parsed;
try {
parsed = JSON.parse(body);
} catch {
return void sendJson(res, 400, { error: 'invalid JSON' });
}
const problem = current.ingest(parsed);
if (problem) return void sendJson(res, 400, { error: problem });
sendJson(res, 200, { ok: true });
});
},
'GET /stream': (req, res) => {
+11 -3
View File
@@ -30,8 +30,12 @@
}
[data-module="propresenter-timers"] .tm-banner[hidden] { display: none; }
/* 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; }
/* Values still on screen while the feed is down are stale — say so visually.
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 {
flex: 1;
@@ -54,7 +58,6 @@
grid-auto-rows: 1fr;
gap: var(--tm-gap, 8px);
padding: var(--tm-pad, 10px);
transition: opacity 0.3s ease;
}
[data-module="propresenter-timers"] .tm-grid[hidden] { display: none; }
@@ -80,6 +83,7 @@
border-radius: 10px;
background: color-mix(in srgb, var(--bg) 60%, transparent);
overflow: hidden;
transition: opacity 0.3s ease;
}
[data-module="propresenter-timers"] .tm-name {
@@ -198,6 +202,10 @@
@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. */
[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 {