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
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user