From cb77030eec5cb43b759d74f34504335dbcb600f6 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Tue, 1 Sep 2026 14:52:20 -0400 Subject: [PATCH] Timers: LTC from the audio signal itself via an external reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 · / 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 --- README.md | 18 +- modules/propresenter-timers/client.js | 30 +- modules/propresenter-timers/module.json | 7 +- modules/propresenter-timers/server.js | 143 +++++- modules/propresenter-timers/style.css | 14 +- tools/ltc-reader.js | 566 ++++++++++++++++++++++++ 6 files changed, 744 insertions(+), 34 deletions(-) create mode 100644 tools/ltc-reader.js diff --git a/README.md b/README.md index f3473f6..c207401 100644 --- a/README.md +++ b/README.md @@ -91,10 +91,20 @@ are written by the app itself. elapsed) and LTC timecode as auto-sized cards. The + picker lists every discovered timer (and the LTC card) as its own tile, or add "All timers" and pick a selection via the tile's "Timers ▾" menu; overruns are - unmistakably red. LTC is read from a stage-display layout field labeled - "LTC" over ProPresenter's stage websocket (its HTTP API has no timecode - route). Read-only — no start/stop controls. Admin config: ProPresenter - endpoint (host : port), API password, stage display password. + unmistakably red. Read-only — no start/stop controls. Admin config: + ProPresenter endpoint (host : port), API password, stage display + password, LTC reader token. + + LTC sources, best first (ProPresenter's HTTP API has no timecode route): + 1. **The LTC audio feed itself** — run `tools/ltc-reader.js` on any + machine that can hear the LTC signal; it decodes the SMPTE frames from + a PCM pipe (ffmpeg/sox one-liners in its header) and POSTs + running/stopped/no-signal, the timecode, and the frame rate (incl. + 29.97 drop-frame) to the module's `/ltc` ingest route. `--selftest` + checks the decoder; `--demo` feeds synthetic LTC end-to-end with no + audio hardware. + 2. A stage-display layout field labeled "LTC", read over ProPresenter's + stage websocket — value and freshness only, no frame rate. - **Clock** — a big booth clock; also the smallest possible module and the reference for module authors. diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index 0a6f826..c239214 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -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(); diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index 34f94c0..00153b4 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -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": "" } } } diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index f90aeaa..685b71a 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -10,6 +10,8 @@ * * GET /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) => { diff --git a/modules/propresenter-timers/style.css b/modules/propresenter-timers/style.css index 7ef4f61..d9092b2 100644 --- a/modules/propresenter-timers/style.css +++ b/modules/propresenter-timers/style.css @@ -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 { diff --git a/tools/ltc-reader.js b/tools/ltc-reader.js new file mode 100644 index 0000000..b1bf21f --- /dev/null +++ b/tools/ltc-reader.js @@ -0,0 +1,566 @@ +#!/usr/bin/env node +'use strict'; + +/** + * ltc-reader — decode SMPTE LTC from an audio feed and push it to ProdDash. + * + * ProPresenter's APIs expose no timecode (see modules/propresenter-timers), + * so this tool reads LTC where it actually lives: the audio signal. Pipe + * mono PCM (signed 16-bit little-endian) into stdin from any capture + * front-end; the decoded state is POSTed to the Timers module's /ltc + * ingest route — ~4×/s while running, 1×/s as a keepalive otherwise, and + * immediately on every state change. + * + * macOS (ffmpeg; list input devices with + * `ffmpeg -f avfoundation -list_devices true -i ""`): + * ffmpeg -loglevel error -f avfoundation -i ":0" -ac 1 -ar 48000 \ + * -f s16le - | node tools/ltc-reader.js --url http://127.0.0.1:24500 + * sox (default input device): + * sox -q -d -t raw -b 16 -e signed -c 1 -r 48000 - | node tools/ltc-reader.js + * Windows (ffmpeg/dshow): + * ffmpeg -loglevel error -f dshow -i audio="" -ac 1 \ + * -ar 48000 -f s16le - | node tools/ltc-reader.js + * + * Flags: + * --url ProdDash base URL (default http://127.0.0.1:24500) + * --module target module id (default propresenter-timers) + * --token matches the module's "LTC reader token" admin setting + * --sr stdin sample rate (default 48000) + * --source