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:
co-authored by
Claude Fable 5
parent
d5cb6b6cd9
commit
cb77030eec
@@ -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();
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user