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
+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 {