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
+14 -4
View File
@@ -91,10 +91,20 @@ are written by the app itself.
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:
"LTC" over ProPresenter's stage websocket (its HTTP API has no timecode ProPresenter endpoint (host : port), API password, stage display
route). Read-only — no start/stop controls. Admin config: ProPresenter password, LTC reader token.
endpoint (host : port), API password, stage display password.
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 - **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors. reference for module authors.
+24 -6
View File
@@ -207,11 +207,15 @@ export default function create({ root, moduleApi }) {
function updateLtcCard(card, ltc) { function updateLtcCard(card, ltc) {
let mode; 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) { 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 (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'; card.stateEl.textContent = ltc.note || 'Timecode not available on this ProPresenter';
} else if (ltc.supported === null) { } else if (ltc.supported === null) {
mode = 'waiting'; mode = 'waiting';
@@ -220,19 +224,30 @@ export default function create({ root, moduleApi }) {
} else if (ltc.receiving) { } else if (ltc.receiving) {
mode = 'receiving'; mode = 'receiving';
card.timeEl.textContent = displayTime(ltc.time); 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 { } 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 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 ──────────────────────────────── */ /* ── 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 +261,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 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; return;
} }
if (!state.reachable) { if (!state.reachable) {
@@ -266,7 +284,7 @@ export default function create({ root, moduleApi }) {
rebuildMenu(); rebuildMenu();
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();
+6 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "propresenter-timers", "id": "propresenter-timers",
"name": "Timers", "name": "Timers",
"version": "1.0.0", "version": "1.1.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",
@@ -23,6 +23,11 @@
"type": "password", "type": "password",
"label": "Stage display password (for LTC — ProPresenter Settings → Network)", "label": "Stage display password (for LTC — ProPresenter Settings → Network)",
"default": "" "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 /state → { state: <state> } (instant paint for new tiles)
* GET /stream → SSE, `state` events (change-detected, heartbeated) * 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): * 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,
@@ -28,10 +30,13 @@
* once per connection anyway so a future ProPresenter that adds it * once per connection anyway so a future ProPresenter that adds it
* lights up without a module update. * lights up without a module update.
* *
* Because the HTTP API has no timecode, LTC actually comes from the classic * Because the HTTP API has no timecode, LTC comes from two real sources:
* stage-display websocket (see stage-ws.js): a stage layout field labeled * the external reader (POST /ltc — tools/ltc-reader.js decoding the LTC
* "LTC" streams its text to us. Source precedence: the HTTP route if it ever * audio feed itself, ground truth while its reports are fresh) and the
* answers (authoritative), else the stage field, else "not available". * 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 * 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
@@ -51,6 +56,9 @@ const SLOW_EVERY = 4;
const REQUEST_TIMEOUT_MS = 4000; 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;
/** 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'); const { createStageLtcClient } = require('./stage-ws');
@@ -64,9 +72,11 @@ function clearedState(enabled) {
lastError: '', lastError: '',
timers: [], timers: [],
// supported: null = no verdict yet (probing, or ProPresenter // supported: null = no verdict yet (probing, or ProPresenter
// unreachable); true once a timecode source is live; false when both // unreachable); true once a timecode source is live; false when every
// sources came up empty (note says why, when we know). // source came up empty (note says why, when we know). status is the
ltc: { supported: null, time: '', receiving: false, source: '', note: '' }, // 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 }; let ltcLast = { time: '', receiving: false };
/** Live view of the stage-display websocket's LTC field (stage-ws.js). */ /** Live view of the stage-display websocket's LTC field (stage-ws.js). */
let stageLtc = { bound: false, time: '', receiving: false, note: '' }; 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 /** One LTC verdict from all three sources. The external reader decodes
answers (authoritative), else the stage-display field. */ 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() { 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) { 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) { 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) { if (ingest) {
return { supported: null, source: '', time: '', receiving: false, note: '' }; // 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) { function url(target) {
@@ -331,12 +393,13 @@ module.exports = {
}, HEARTBEAT_MS); }, HEARTBEAT_MS);
heartbeat.unref?.(); heartbeat.unref?.();
current = { streams, getLatest: () => latest }; current = { streams, getLatest: () => latest, ingest: applyIngest };
return { return {
stop() { stop() {
stopped = true; stopped = true;
clearTimeout(pollTimer); clearTimeout(pollTimer);
clearTimeout(ingestExpiry);
clearInterval(heartbeat); clearInterval(heartbeat);
stageClient?.stop(); stageClient?.stop();
for (const res of streams) { for (const res of streams) {
@@ -346,11 +409,17 @@ 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' }; 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) { if (latest.reachable) {
const ltc = latest.ltc; const ltc = latest.ltc;
const ltcNote = ltc.supported const ltcNote = ltc.supported
? (ltc.source === 'stage' ? ', LTC via stage display' : ', LTC via API') ? (ltcSource[ltc.source] || '')
: (ltc.note ? ` (${ltc.note})` : ''); : (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}` };
} }
@@ -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 { 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 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) => { 'GET /stream': (req, res) => {
+11 -3
View File
@@ -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 {
@@ -198,6 +202,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 {
+566
View File
@@ -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="<device name>" -ac 1 \
* -ar 48000 -f s16le - | node tools/ltc-reader.js
*
* Flags:
* --url <base> ProdDash base URL (default http://127.0.0.1:24500)
* --module <id> target module id (default propresenter-timers)
* --token <secret> matches the module's "LTC reader token" admin setting
* --sr <hz> stdin sample rate (default 48000)
* --source <label> reader label in reports (default this machine's hostname)
* --demo no audio needed: synthesize running 30 fps LTC (with a
* stop every ~30 s) through the real decoder, and POST it
* --selftest run the encoder→decoder test suite and exit
*
* Decoding: LTC is an 80-bit SMPTE frame, biphase-mark coded — a level
* transition at every bit boundary plus one mid-bit for a 1. The decoder
* tracks transitions with an envelope-scaled hysteresis comparator (so
* input level doesn't matter), classifies the transition gaps as full/half
* bit periods against an adaptive period estimate, shifts bits until the
* sync word appears, then reads the BCD fields. Frame rate is snapped from
* the measured bit clock and the highest frame number seen; the drop-frame
* bit distinguishes 29.97 DF. Reverse play is not decoded (the sync word
* never matches backwards) — for booth status that reads as "stopped",
* which is the honest summary anyway.
*/
const os = require('os');
/* ══ 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.maxFrame = 0; // highest frame number seen → nominal fps
}
/** @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).
this.halfAt = 0;
this.sync = 0;
this.fresh = 0;
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;
};
const f = bcd(0, 4) + bcd(8, 2) * 10;
const s = bcd(16, 4) + bcd(24, 3) * 10;
const m = bcd(32, 4) + bcd(40, 3) * 10;
const h = bcd(48, 4) + bcd(56, 2) * 10;
if (f > 59 || s > 59 || m > 59 || h > 39) return; // corrupt frame
if (f > this.maxFrame) this.maxFrame = f;
const df = bits[10] === 1;
this.onFrame({ h, m, s, f, df, fps: this.fps(df) });
}
/**
* Nominal rate: the highest frame number is definitive once we've seen
* enough of a second (24/25/30); until then snap the measured bit clock.
* 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;
const fromCount = this.maxFrame >= 20 ? this.maxFrame + 1 : 0;
if (fromCount) return fromCount;
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 ═══════════════════════════════════════════════════════ */
const pad2 = (v) => String(v).padStart(2, '0');
const fmtTc = (tc) => `${pad2(tc.h)}:${pad2(tc.m)}:${pad2(tc.s)}:${pad2(tc.f)}`;
function log(msg) {
console.log(`[${new Date().toLocaleTimeString()}] ${msg}`);
}
function parseArgs(argv) {
const args = {
url: 'http://127.0.0.1:24500',
module: 'propresenter-timers',
token: '',
sr: 48000,
source: os.hostname(),
demo: false,
selftest: false,
};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a === '--demo') args.demo = true;
else if (a === '--selftest') args.selftest = true;
else if (a === '--url') args.url = String(argv[++i] || args.url).replace(/\/+$/, '');
else if (a === '--module') args.module = String(argv[++i] || args.module);
else if (a === '--token') args.token = String(argv[++i] || '');
else if (a === '--sr') args.sr = Number(argv[++i]) || args.sr;
else if (a === '--source') args.source = String(argv[++i] || args.source);
else {
console.error(`Unknown flag ${a} — see the header of tools/ltc-reader.js`);
process.exit(2);
}
}
return args;
}
/* ══ 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`);
check('df flag set', out.length > 0 && out.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(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed');
process.exit(failures ? 1 : 0);
}
/* ══ live modes: stdin decode / demo, plus the POST loop ═══════════════ */
function main(args) {
const endpoint = `${args.url}/api/modules/${args.module}/ltc`;
/* — decoded-state tracking — */
let lastTc = '';
let fps = 0;
let df = false;
let lastFrameWall = 0;
let lastChangeWall = 0;
const decoder = new LtcDecoder(args.sr, (f) => {
const tc = fmtTc(f);
const now = Date.now();
if (tc !== lastTc) lastChangeWall = now;
lastTc = tc;
fps = f.fps;
df = f.df;
lastFrameWall = now;
});
const snapshot = () => {
const now = Date.now();
// Running = frames decoding AND the address advancing. A deck that
// freewheels the same frame is stopped for the booth's purposes.
if (now - lastFrameWall < 400 && now - lastChangeWall < 400) return 'running';
if (lastTc) return 'stopped';
return 'nosignal';
};
/* — POST loop — */
let postedState = '';
let lastPostAt = 0;
let lastErrorAt = 0;
let unauthorizedAt = 0;
async function post(state) {
const body = {
state,
time: lastTc,
fps,
df,
source: args.source,
};
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(args.token ? { Authorization: `Bearer ${args.token}` } : {}),
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(2500),
});
if (res.status === 401) {
// A wrong token never fixes itself fast — complain once a minute.
if (Date.now() - unauthorizedAt > 60000) {
unauthorizedAt = Date.now();
log(`ProdDash rejected the token (401) — set --token to match the module's "LTC reader token"`);
}
return;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (state !== postedState) {
log(`→ ${state}${lastTc ? ` at ${lastTc}` : ''}${state === 'running' && fps ? ` (${df ? '29.97 DF' : `${fps} fps`})` : ''}`);
postedState = state;
}
} catch (err) {
if (Date.now() - lastErrorAt > 10000) {
lastErrorAt = Date.now();
log(`cannot reach ProdDash at ${endpoint}: ${err.message || err}`);
}
postedState = ''; // re-announce the state once we get through again
}
}
setInterval(() => {
const state = snapshot();
const due = state !== postedState ? 0 : state === 'running' ? 240 : 990;
if (Date.now() - lastPostAt >= due) {
lastPostAt = Date.now();
post(state);
}
}, 250).unref?.();
log(`posting LTC state to ${endpoint} as "${args.source}"`);
if (args.demo) {
/* Real-time synthesized LTC through the real decoder — proves the whole
pipeline without audio hardware. ~25 s running, ~6 s stopped, repeat. */
const now = new Date();
const enc = new LtcEncoder(args.sr, 30, false, {
h: now.getHours(), m: now.getMinutes(), s: now.getSeconds(), f: 0,
});
const silence = new Int16Array(args.sr / 10);
let phase = 'running';
let phaseUntil = Date.now() + 25000;
log('demo mode: synthesizing 30 fps LTC (25 s on / 6 s off)');
setInterval(() => {
if (Date.now() > phaseUntil) {
phase = phase === 'running' ? 'silent' : 'running';
phaseUntil = Date.now() + (phase === 'running' ? 25000 : 6000);
}
decoder.push(phase === 'running' ? enc.render(args.sr / 10) : silence);
}, 100);
return;
}
/* — stdin: s16le mono PCM from ffmpeg/sox/arecord — */
if (process.stdin.isTTY) {
console.error('stdin is a terminal — pipe s16le mono PCM in (see the header for ffmpeg/sox lines), or use --demo / --selftest');
process.exit(2);
}
log(`reading s16le mono PCM at ${args.sr} Hz from stdin`);
let carry = null;
process.stdin.on('data', (buf) => {
if (carry) buf = Buffer.concat([carry, buf]);
const usable = buf.length & ~1;
carry = buf.length > usable ? buf.subarray(usable) : null;
const samples = new Int16Array(usable / 2);
for (let i = 0; i < samples.length; i += 1) samples[i] = buf.readInt16LE(i * 2);
decoder.push(samples);
});
process.stdin.on('end', () => {
// The capture front-end died. Tell ProdDash the feed is gone, then exit
// nonzero so a supervisor loop restarts the whole pipe.
log('stdin ended — audio capture stopped');
lastTc = '';
post('nosignal').finally(() => process.exit(1));
});
}
const args = parseArgs(process.argv.slice(2));
if (args.selftest) selftest();
else main(args);