Timers: built-in LTC listener, configured in the admin page

The LTC listener now lives inside the module: it spawns the native
ltc-capture helper (moved into the module folder, compiled on first use,
exact-name device matching) for the admin-selected input device, extracts
the configured channel, and decodes SMPTE LTC in-process (ltc.js — the
engine, shared with tools/ltc-reader.js, which remains the remote-machine
option posting to /ltc).

Admin config: an "LTC listener" switch, an audio-device select populated
live from the new GET /ltc/devices route, and a 1-based channel number.
The switch off means LTC is gone entirely: no LTC entry in the Add-tile
picker, no LTC row in the Timers menu or All-timers grid, no stage/API
probes, and POST /ltc answers 409. Source precedence with it on:
listener-with-data, fresh remote reader, listener's own no-signal verdict,
then the legacy API/stage chain.

Shell (admin page): `select` fields learn `optionsRoute` — options fetched
from a module route at render, saved value kept selectable when the route
or device is gone. Documented in MODULE-GUIDE; backward compatible.
Coordinate with the shell lane before its next admin.js change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-08 17:21:48 -04:00
co-authored by Claude Fable 5
parent c4db412014
commit b95aa7dc34
11 changed files with 770 additions and 404 deletions
+3
View File
@@ -7,3 +7,6 @@ dist/
# runtime state written by the admin page / layout saves # runtime state written by the admin page / layout saves
config/modules.json config/modules.json
config/layouts/ config/layouts/
# compiled per-machine, source sits next to it
modules/propresenter-timers/ltc-capture
+22 -13
View File
@@ -92,20 +92,29 @@ are written by the app itself.
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. Read-only — no start/stop controls. Admin config: unmistakably red. Read-only — no start/stop controls. Admin config:
ProPresenter endpoint (host : port), API password, stage display ProPresenter endpoint (host : port), API password, the LTC listener
password, LTC reader token. switch + audio device + channel, LTC reader token, stage display
password.
LTC sources, best first (ProPresenter's HTTP API has no timecode route): LTC lives behind the admin **LTC listener** switch — off means no LTC
1. **The LTC audio feed itself** — run `tools/ltc-reader.js` on any tile in the picker at all. On, the sources, best first (ProPresenter's
machine that can hear the LTC signal; it decodes the SMPTE frames from HTTP API has no timecode route):
a PCM pipe (ffmpeg/sox one-liners in its header) and POSTs 1. **Built-in listener** — the module captures the admin-selected audio
running/stopped/no-signal, the timecode, and the frame rate (incl. input device/channel on the ProdDash machine (native `ltc-capture`
29.97 drop-frame) to the module's `/ltc` ingest route. LTC on one helper, compiled on first use) and decodes the SMPTE frames
input of a multichannel interface: capture all channels and pick one in-process: running/stopped/no-signal, timecode, frame rate (incl.
with `--channels 18 --ch 5` — never downmix LTC into program audio. 29.97 drop-frame). macOS note: the ProdDash server needs microphone
`--selftest` checks the decoder; `--demo` feeds synthetic LTC permission — launch it from a logged-in session (Start
end-to-end with no audio hardware. ProdDash.command) and approve the one-time prompt; a server started
2. A stage-display layout field labeled "LTC", read over ProPresenter's over SSH or by bare launchd silently captures zeros.
2. **Remote reader** — when a *different* machine hears the LTC, run
`tools/ltc-reader.js` there; it decodes the same way from a PCM pipe
(`ltc-capture`, ffmpeg, or sox; `--channels 18 --ch 5` picks one
input of a multichannel interface — never downmix LTC into program
audio) and POSTs to the module's `/ltc` route. `--selftest` checks
the decoder; `--demo` feeds synthetic LTC end-to-end with no audio
hardware.
3. A stage-display layout field labeled "LTC", read over ProPresenter's
stage websocket — value and freshness only, no frame rate. 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.
+10 -1
View File
@@ -67,10 +67,19 @@ Both schemas map field names to specs:
{ "type": "string" | "number" | "boolean" | "select" | "password" | "endpoint", { "type": "string" | "number" | "boolean" | "select" | "password" | "endpoint",
"label": "Shown next to the field", "label": "Shown next to the field",
"default": <value>, "default": <value>,
"options": [ { "value": "a", "label": "A" }, … ] // "select" only "options": [ { "value": "a", "label": "A" }, … ], // "select" only
"optionsRoute": "/devices" // "select" only, admin config only
} }
``` ```
A `select` whose choices are only knowable at runtime (audio devices, serial
ports, discovered sources) sets `optionsRoute` instead of — or as a fallback
refresh of — static `options`: the admin page GETs
`/api/modules/<id><optionsRoute>` and expects `{ "options": [ { "value",
"label" }, … ] }` from one of the module's own server routes. The saved value
stays selectable even when the route is down or the option has vanished. (See
`propresenter-timers`' "LTC audio device" for a worked example.)
`password` fields are admin-config only in practice: their values are stored `password` fields are admin-config only in practice: their values are stored
in `config/modules.json`, handed to your *server* entry, and **never sent to in `config/modules.json`, handed to your *server* entry, and **never sent to
browsers** — not in `/api/modules`, and never echoed back into the admin browsers** — not in `/api/modules`, and never echoed back into the admin
+11 -8
View File
@@ -61,7 +61,9 @@ export default function create({ root, moduleApi }) {
function menuRows() { function menuRows() {
const rows = (state?.timers || []).map((t) => ({ key: t.uuid, name: t.name })); const rows = (state?.timers || []).map((t) => ({ key: t.uuid, name: t.name }));
rows.push({ key: LTC_KEY, name: 'LTC timecode' }); // No LTC row while the listener is off in Admin — the item shouldn't
// even be offerable.
if (state?.ltcEnabled) rows.push({ key: LTC_KEY, name: 'LTC timecode' });
return rows; return rows;
} }
@@ -159,7 +161,7 @@ export default function create({ root, moduleApi }) {
for (const t of state?.timers || []) { for (const t of state?.timers || []) {
if (!hiddenItems.has(t.uuid)) items.push({ key: t.uuid, timer: t }); if (!hiddenItems.has(t.uuid)) items.push({ key: t.uuid, timer: t });
} }
if (!hiddenItems.has(LTC_KEY)) items.push({ key: LTC_KEY, ltc: ltcState }); if (state?.ltcEnabled && !hiddenItems.has(LTC_KEY)) items.push({ key: LTC_KEY, ltc: ltcState });
return items; return items;
} }
@@ -235,9 +237,10 @@ export default function create({ root, moduleApi }) {
card.timeEl.textContent = '—'; card.timeEl.textContent = '—';
card.stateEl.textContent = 'No signal'; card.stateEl.textContent = 'No signal';
} }
// The reader posts independently of ProPresenter, so its card must not // The built-in listener and the remote reader are independent of
// gray out with the rest when only ProPresenter is unreachable. // ProPresenter, so their card must not gray out with the rest when
const live = ltc.source === 'reader' ? ' src-reader' : ''; // only ProPresenter is unreachable.
const live = ltc.source === 'reader' || ltc.source === 'listener' ? ' src-reader' : '';
card.el.className = `tm-card tm-card-ltc is-ltc-${mode}${live}`; card.el.className = `tm-card tm-card-ltc is-ltc-${mode}${live}`;
} }
@@ -261,9 +264,9 @@ export default function create({ root, moduleApi }) {
} }
if (!state.enabled) { if (!state.enabled) {
bannerEl.hidden = true; bannerEl.hidden = true;
// The LTC reader feeds us with or without ProPresenter — an LTC-only // The LTC listener/reader feed us with or without ProPresenter — an
// setup is working, not broken. // LTC-only setup is working, not broken.
if (state.ltc?.supported) moduleApi.setStatus('ok', 'LTC via reader — no ProPresenter host configured'); if (state.ltc?.supported) moduleApi.setStatus('ok', 'LTC only — no ProPresenter host configured');
else moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin'); else moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
return; return;
} }
@@ -1,19 +1,23 @@
// ltc-capture — minimal CoreAudio input capture for the LTC reader. // ltc-capture — minimal CoreAudio input capture for the Timers module's
// LTC listener.
// //
// ProdDash's LTC reader (tools/ltc-reader.js) eats raw PCM on stdin and is // The module decodes LTC in-process (ltc.js) and spawns this to move
// normally fed by ffmpeg or sox — but production machines rarely have // samples from an input device to a pipe. Production machines rarely have
// either, and shipping a third-party binary just to move samples from an // ffmpeg/sox, and shipping a third-party binary for that one job is
// input device to a pipe is overkill. This is the whole job, natively: // overkill — this is the whole job, natively. The module compiles it on
// first use when swiftc is available; otherwise build it once on any
// same-architecture Mac and copy the binary next to this source:
// //
// swiftc -O -o tools/ltc-capture tools/ltc-capture.swift (once, ~5 s) // swiftc -O -o modules/propresenter-timers/ltc-capture \
// modules/propresenter-timers/ltc-capture.swift (once, ~5 s)
// //
// tools/ltc-capture --list // ltc-capture --list
// every input-capable device: name, input channels, sample rate // every input-capable device: name, input channels, sample rate
// tools/ltc-capture --device "Dante Via 16" \ // ltc-capture --device "Dante Via 16 Channel"
// | node tools/ltc-reader.js --sr 48000 --channels 16 --ch 5 ...
// streams the device's inputs as interleaved s16le to stdout, at the // streams the device's inputs as interleaved s16le to stdout, at the
// device's own sample rate and channel count (both printed to stderr // device's own sample rate and channel count (both printed to stderr
// at start — pass them to the reader as --sr / --channels). // at start). The name matches exactly first, then as a substring.
// The remote reader (tools/ltc-reader.js) pipes from it the same way.
// //
// --device matches case-insensitively on a substring of the device name or // --device matches case-insensitively on a substring of the device name or
// UID; ambiguity is an error listing the candidates. Capture uses an // UID; ambiguity is an error listing the candidates. Capture uses an
@@ -144,7 +148,12 @@ if wantList || wantDevice == nil {
} }
let query = wantDevice!.lowercased() let query = wantDevice!.lowercased()
let matches = devices.filter { $0.name.lowercased().contains(query) || $0.uid.lowercased().contains(query) } // An exact name/uid match wins outright — device lists like "Media",
// "Media 2", "Media Key" make substring-only matching ambiguous for the
// very names a picker hands us verbatim.
let exact = devices.filter { $0.name.lowercased() == query || $0.uid.lowercased() == query }
let matches = exact.count == 1 ? exact
: devices.filter { $0.name.lowercased().contains(query) || $0.uid.lowercased().contains(query) }
guard matches.count == 1 else { guard matches.count == 1 else {
if matches.isEmpty { fail("no input device matches \"\(wantDevice!)\" — see --list") } if matches.isEmpty { fail("no input device matches \"\(wantDevice!)\" — see --list") }
fail("\"\(wantDevice!)\" is ambiguous: " + matches.map { $0.name }.joined(separator: ", ")) fail("\"\(wantDevice!)\" is ambiguous: " + matches.map { $0.name }.joined(separator: ", "))
+183
View File
@@ -0,0 +1,183 @@
'use strict';
/**
* Built-in LTC listener — capture child + in-process decode.
*
* Spawns the module's native capture tool (ltc-capture, compiled from
* ltc-capture.swift beside it) for the admin-configured audio device, reads
* the interleaved s16le stream, extracts the configured channel, and decodes
* SMPTE LTC with ltc.js — all inside the module server. Reports upward via
* onUpdate roughly 4×/s:
*
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
* { error: '<what is wrong and how to fix it>' } not capturing
*
* The capture binary is per-machine (gitignored): if it is missing this
* tries one swiftc compile of the source next to it, and otherwise says to
* build it on any same-architecture Mac and copy it over. A dead child is
* restarted with backoff — fast for a crash, slow for config-shaped
* failures (unknown device, ambiguous name) that never fix themselves.
*
* macOS gotcha this file can't fix: microphone permission. A ProdDash
* server launched over SSH or from a bare launchd job gets SILENT ZEROS
* from every input (no error!), which shows up here as endless 'nosignal'.
* Launch ProdDash from a logged-in GUI context (Start ProdDash.command)
* and approve the mic prompt once.
*/
const path = require('path');
const fs = require('fs');
const { spawn, spawnSync } = require('child_process');
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
const RESTART_MS = 5000;
/** An immediate exit is config-shaped (bad device name, ambiguity) — a fast
retry loop would just spam the log. */
const BAD_CONFIG_RETRY_MS = 30000;
const EVAL_MS = 250;
/** Frames must decode AND advance this recently to count as running. */
const RUNNING_WINDOW_MS = 400;
/** stderr line the capture prints once the device is open. */
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
/**
* @param {{device:string, channel:number, moduleDir:string, log:Function,
* onUpdate:(u:object)=>void}} opts
* @returns {{stop:()=>void}}
*/
function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
const bin = path.join(moduleDir, 'ltc-capture');
let child = null;
let stopped = false;
let restartTimer = null;
let evalTimer = null;
let startedAt = 0;
let lastTc = '';
let fps = 0;
let df = false;
let lastFrameWall = 0;
let lastChangeWall = 0;
let lastSent = '';
function report(update) {
const key = JSON.stringify(update);
if (key === lastSent) return;
lastSent = key;
onUpdate(update);
}
function snapshotState() {
const now = Date.now();
if (now - lastFrameWall < RUNNING_WINDOW_MS && now - lastChangeWall < RUNNING_WINDOW_MS) return 'running';
if (lastTc) return 'stopped';
return 'nosignal';
}
function ensureBinary() {
if (fs.existsSync(bin)) return '';
const src = bin + '.swift';
log('ltc-capture binary missing — trying a one-time swiftc build');
const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 120000 });
if (built.status === 0 && fs.existsSync(bin)) {
log('ltc-capture compiled');
return '';
}
return 'LTC capture tool missing — build it on any Apple Silicon Mac with '
+ '`swiftc -O -o modules/propresenter-timers/ltc-capture modules/propresenter-timers/ltc-capture.swift` '
+ 'and copy it here';
}
function start() {
if (stopped) return;
const missing = ensureBinary();
if (missing) {
report({ error: missing });
scheduleRestart(BAD_CONFIG_RETRY_MS);
return;
}
startedAt = Date.now();
let extractor = null;
let decoder = null;
let stderrTail = '';
child = spawn(bin, ['--device', device], { stdio: ['ignore', 'pipe', 'pipe'] });
child.stderr.setEncoding('utf8');
child.stderr.on('data', (text) => {
stderrTail = (stderrTail + text).slice(-500);
const m = text.match(CAPTURING_RE);
if (!m) return;
const channels = Number(m[2]);
const rate = Number(m[3]);
if (channel > channels) {
report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` });
try { child.kill(); } catch { /* exiting anyway */ }
return;
}
log(`LTC listener: capturing "${m[1]}" (${channels} ch @ ${rate} Hz), decoding channel ${channel}`);
extractor = new ChannelExtractor(channels, channel);
decoder = new LtcDecoder(rate, (frame) => {
const tc = fmtTc(frame);
const now = Date.now();
if (tc !== lastTc) lastChangeWall = now;
lastTc = tc;
fps = frame.fps;
df = frame.df;
lastFrameWall = now;
});
});
child.stdout.on('data', (buf) => {
if (extractor && decoder) decoder.push(extractor.push(buf));
});
child.on('error', (err) => {
report({ error: `LTC capture failed to start: ${err.message}` });
});
child.on('exit', (code) => {
child = null;
if (stopped) return;
// The capture's own failure text (unknown device, ambiguous name,
// permission hint) is the most useful thing we can show.
const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
const quickDeath = Date.now() - startedAt < 3000;
report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') });
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
});
}
function scheduleRestart(ms) {
if (stopped || restartTimer) return;
restartTimer = setTimeout(() => {
restartTimer = null;
start();
}, ms);
restartTimer.unref?.();
}
evalTimer = setInterval(() => {
if (stopped || !child) return;
report({ state: snapshotState(), time: lastTc, fps, df });
}, EVAL_MS);
evalTimer.unref?.();
start();
return {
stop() {
stopped = true;
clearTimeout(restartTimer);
clearInterval(evalTimer);
if (child) {
try { child.kill(); } catch { /* already gone */ }
child = null;
}
},
};
}
module.exports = { createLtcListener };
+314
View File
@@ -0,0 +1,314 @@
'use strict';
/**
* LTC engine for the propresenter-timers module — SMPTE LTC decode from PCM,
* interleaved-channel extraction, and an encoder for tests and demos.
*
* Used in-process by the module's built-in LTC listener (ltc-listener.js),
* and by the standalone remote reader (tools/ltc-reader.js) for machines
* other than the ProdDash server. All design notes and the full protocol
* commentary live with the classes below; the encoder exists so the decode
* path can be proven end-to-end without timecode hardware (see the reader's
* --selftest).
*/
/* ══ 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.prev = null; // previous valid frame, for rollover observation
this.nominal = 0; // nominal fps locked by two agreeing rollovers
this.rollCandidate = 0;
this.df = false; // drop-frame with two-frame hysteresis
this.dfPending = null;
this.dfRun = 0;
}
/** @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). Frame
// continuity broke, so rollover observation restarts too; the locked
// nominal survives — two agreeing rollovers re-lock a changed rate.
this.halfAt = 0;
this.sync = 0;
this.fresh = 0;
this.prev = null;
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;
};
// Strict validation: LTC has no parity worth the name, so a bit error
// can produce a frame that "parses" — every field must be a legal BCD
// digit AND inside its hard ceiling, or the frame is dropped whole.
// (A lax check here once let a corrupt frames field of 35 through, and
// downstream fps logic reported 36 fps to the dashboard.)
const fu = bcd(0, 4);
const su = bcd(16, 4);
const mu = bcd(32, 4);
const hu = bcd(48, 4);
if (fu > 9 || su > 9 || mu > 9 || hu > 9) return;
const f = fu + bcd(8, 2) * 10;
const s = su + bcd(24, 3) * 10;
const m = mu + bcd(40, 3) * 10;
const h = hu + bcd(56, 2) * 10;
if (f > 29 || s > 59 || m > 59 || h > 23) return;
// Drop-frame flag with two-frame hysteresis — one corrupt-but-legal
// frame must not flash 29.97 DF across the dashboard.
const dfBit = bits[10] === 1;
this.dfRun = dfBit === this.dfPending ? this.dfRun + 1 : 1;
this.dfPending = dfBit;
if (this.dfRun >= 2) this.df = dfBit;
// Nominal rate from observed rollovers: the frame number that precedes
// an f=0-with-seconds-advance IS the rate minus one. Two consecutive
// agreeing rollovers lock it — a single corrupt frame can't fake two
// valid, correctly-successive frames twice, and a genuine rate change
// re-locks itself within two seconds.
if (this.prev && f === 0 && s === (this.prev.s + 1) % 60) {
const cand = this.prev.f + 1;
if (cand === 24 || cand === 25 || cand === 30) {
if (cand === this.rollCandidate) this.nominal = cand;
this.rollCandidate = cand;
}
}
this.prev = { h, m, s, f };
this.onFrame({ h, m, s, f, df: this.df, fps: this.fps(this.df) });
}
/**
* Nominal rate: locked by rollover consensus once two second-boundaries
* agree; until then snap the measured bit clock to a standard rate.
* 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;
if (this.nominal) return this.nominal;
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 ═══════════════════════════════════════════════════════ */
/**
* Turns a raw s16le byte stream of interleaved channels into one channel's
* samples. Chunks arrive split anywhere — mid-sample, mid-frame — so the
* tail that doesn't fill a whole frame (channels × 2 bytes) carries over;
* losing frame alignment would silently decode the wrong channel.
*/
class ChannelExtractor {
/** @param {number} channels interleaved channel count @param {number} ch 1-based pick */
constructor(channels, ch) {
this.frameBytes = channels * 2;
this.offset = (ch - 1) * 2;
this.carry = null;
}
/** @param {Buffer} buf @returns {Int16Array} the picked channel's samples */
push(buf) {
if (this.carry) buf = Buffer.concat([this.carry, buf]);
const usable = buf.length - (buf.length % this.frameBytes);
this.carry = buf.length > usable ? buf.subarray(usable) : null;
const out = new Int16Array(usable / this.frameBytes);
for (let i = 0, off = this.offset; i < out.length; i += 1, off += this.frameBytes) {
out[i] = buf.readInt16LE(off);
}
return out;
}
}
const pad2 = (v) => String(v).padStart(2, '0');
const fmtTc = (tc) => `${pad2(tc.h)}:${pad2(tc.m)}:${pad2(tc.s)}:${pad2(tc.f)}`;
module.exports = { LtcDecoder, LtcEncoder, ChannelExtractor, frameBits, nextTc, fmtTc, SYNC_WORD };
+22 -6
View File
@@ -1,7 +1,7 @@
{ {
"id": "propresenter-timers", "id": "propresenter-timers",
"name": "Timers", "name": "Timers",
"version": "1.1.0", "version": "1.2.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",
@@ -19,14 +19,30 @@
"label": "API password (only if set in ProPresenter)", "label": "API password (only if set in ProPresenter)",
"default": "" "default": ""
}, },
"stagePassword": { "ltcEnabled": {
"type": "password", "type": "boolean",
"label": "Stage display password (for LTC — ProPresenter Settings → Network)", "label": "LTC listener — decode timecode from an audio input on this machine",
"default": "" "default": false
},
"ltcDevice": {
"type": "select",
"label": "LTC audio device",
"default": "",
"optionsRoute": "/ltc/devices"
},
"ltcChannel": {
"type": "number",
"label": "LTC channel on that device (1-based)",
"default": 1
}, },
"ingestToken": { "ingestToken": {
"type": "password", "type": "password",
"label": "LTC reader token (optional — must match tools/ltc-reader.js --token)", "label": "LTC reader token (optional — for tools/ltc-reader.js on another machine)",
"default": ""
},
"stagePassword": {
"type": "password",
"label": "Stage display password (LTC fallback via a stage field labeled \"LTC\")",
"default": "" "default": ""
} }
} }
+131 -39
View File
@@ -10,8 +10,10 @@
* *
* 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 * POST /ltc → remote LTC ingest: tools/ltc-reader.js on another
* audio signal and reports { state, time, fps, df } here. * machine reports { state, time, fps, df } here.
* GET /ltc/devices → this machine's audio input devices, for the admin
* page's device picker (select + optionsRoute).
* *
* 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,
@@ -30,13 +32,19 @@
* 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 comes from two real sources: * Because the HTTP API has no timecode, LTC comes from the audio signal
* the external reader (POST /ltc — tools/ltc-reader.js decoding the LTC * itself. The whole LTC feature sits behind the admin "LTC listener"
* audio feed itself, ground truth while its reports are fresh) and the * switch — off means no LTC tile in the picker, no probes, and the ingest
* classic stage-display websocket (see stage-ws.js): a stage layout field * route answers 409. When on, the sources in precedence order:
* labeled "LTC" streams its text to us. Source precedence: fresh reader * 1. the built-in listener (ltc-listener.js — this machine captures the
* ingest, else the HTTP route if it ever answers, else the stage field, * admin-selected device/channel and decodes in-process) while it has
* else "not available". * actual data;
* 2. a fresh remote reader report (POST /ltc, tools/ltc-reader.js on the
* machine that can hear the LTC when this one can't);
* 3. the listener's own "no signal" verdict (healthy capture, silent
* feed — believed over the indirect sources below);
* 4. the HTTP timecode route if some future ProPresenter answers it;
* 5. a stage-display field labeled "LTC" (see stage-ws.js).
* *
* 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
@@ -60,14 +68,17 @@ const HEARTBEAT_MS = 15000;
the reader posts at least 1×/s, so 6s of silence means it died. */ the reader posts at least 1×/s, so 6s of silence means it died. */
const INGEST_STALE_MS = 6000; const INGEST_STALE_MS = 6000;
const { spawn } = require('child_process');
const { createStageLtcClient } = require('./stage-ws'); const { createStageLtcClient } = require('./stage-ws');
const { createLtcListener } = require('./ltc-listener');
/** Set by init(), read by the routes — both are rebuilt together on remount. */ /** Set by init(), read by the routes — both are rebuilt together on remount. */
let current = null; let current = null;
function clearedState(enabled) { function clearedState(enabled, ltcEnabled = false) {
return { return {
enabled, enabled,
ltcEnabled,
reachable: false, reachable: false,
lastError: '', lastError: '',
timers: [], timers: [],
@@ -91,8 +102,11 @@ module.exports = {
const port = Number(config.port ?? ep.port) || 1025; const port = Number(config.port ?? ep.port) || 1025;
const password = String(config.password || ''); const password = String(config.password || '');
const enabled = Boolean(host && port); const enabled = Boolean(host && port);
const ltcEnabled = Boolean(config.ltcEnabled);
const ltcDevice = String(config.ltcDevice || '');
const ltcChannel = Math.max(1, Math.trunc(Number(config.ltcChannel)) || 1);
let latest = clearedState(enabled); let latest = clearedState(enabled, ltcEnabled);
let lastFrame = ''; let lastFrame = '';
let stopped = false; let stopped = false;
let pollTimer = null; let pollTimer = null;
@@ -105,27 +119,42 @@ 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. */ /** Latest POST /ltc report from a remote reader, null until one lands. */
let ingest = null; // { state, time, fps, df, source, at } let ingest = null; // { state, time, fps, df, source, at }
let ingestExpiry = null; let ingestExpiry = null;
/** Built-in listener's latest word: { state, time, fps, df } | { error }. */
let listenerLtc = null;
/** One LTC verdict from all three sources. The external reader decodes /** One LTC verdict from every source, in the precedence the header
the LTC audio itself, so a fresh report from it is ground truth; comment explains. Text sources don't know fps — receiving/frozen/
then the HTTP route if it ever answers; then the stage-display empty maps onto the same status vocabulary the audio decode uses. */
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) { if (!ltcEnabled) {
return { return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' };
}
const audioVerdict = (src, tag) => ({
supported: true, supported: true,
source: 'reader', source: tag,
time: ingest.time, time: src.time,
receiving: ingest.state === 'running', receiving: src.state === 'running',
status: ingest.state, status: src.state,
fps: ingest.fps, fps: src.fps,
df: ingest.df, df: src.df,
note: '', note: '',
}; });
// The built-in listener while it has actual data …
if (listenerLtc && !listenerLtc.error && listenerLtc.state !== 'nosignal') {
return audioVerdict(listenerLtc, 'listener');
}
// … else a fresh remote reader (during migrations both may run —
// real data beats no data) …
if (ingest && Date.now() - ingest.at < INGEST_STALE_MS) {
return audioVerdict(ingest, 'reader');
}
// … else a healthy-but-silent listener is believed over the indirect
// sources: no audio means the LTC really stopped.
if (listenerLtc && !listenerLtc.error) {
return audioVerdict(listenerLtc, 'listener');
} }
const textStatus = (src) => (src.receiving ? 'running' : src.time ? 'stopped' : 'nosignal'); const textStatus = (src) => (src.receiving ? 'running' : src.time ? 'stopped' : 'nosignal');
if (ltcSupported === true) { if (ltcSupported === true) {
@@ -139,10 +168,10 @@ module.exports = {
// story than anything the stage feed has to say. // 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, status: '', fps: 0, df: false, note: 'LTC reader offline' };
} }
if (ltcSupported === null && !stageLtc.note && enabled) { if (!listenerLtc?.error && ltcSupported === null && !stageLtc.note && enabled) {
return { supported: null, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: '' }; 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 || '' }; return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: listenerLtc?.error || stageLtc.note || '' };
} }
/** Timecode-shaped text — same shape rule as the other sources. */ /** Timecode-shaped text — same shape rule as the other sources. */
@@ -284,6 +313,7 @@ module.exports = {
} }
async function pollLtc() { async function pollLtc() {
if (!ltcEnabled) return;
// A 404 verdict is retried once a minute — an endpoint appearing // A 404 verdict is retried once a minute — an endpoint appearing
// without an unreachable spell (ProPresenter hot-swapped in testing, // without an unreachable spell (ProPresenter hot-swapped in testing,
// or some future in-place upgrade) must not stay invisible forever. // or some future in-place upgrade) must not stay invisible forever.
@@ -308,6 +338,7 @@ module.exports = {
function broadcast() { function broadcast() {
latest = { latest = {
enabled, enabled,
ltcEnabled,
reachable: latest.reachable, reachable: latest.reachable,
lastError: latest.lastError, lastError: latest.lastError,
timers: latest.timers, timers: latest.timers,
@@ -365,6 +396,7 @@ module.exports = {
if (enabled) { if (enabled) {
pollTick(); pollTick();
log(`polling timers on ${host}:${port}`); log(`polling timers on ${host}:${port}`);
if (ltcEnabled) {
stageClient = createStageLtcClient({ stageClient = createStageLtcClient({
host, host,
port, port,
@@ -378,10 +410,32 @@ module.exports = {
broadcast(); broadcast();
}, },
}); });
}
} else { } else {
log('no ProPresenter host configured — set one in /admin'); log('no ProPresenter host configured — set one in /admin');
} }
// The built-in listener is independent of ProPresenter — LTC-only
// setups (no host configured) still get the LTC card.
let ltcListener = null;
if (ltcEnabled) {
if (ltcDevice) {
ltcListener = createLtcListener({
device: ltcDevice,
channel: ltcChannel,
moduleDir: __dirname,
log,
onUpdate(update) {
listenerLtc = update;
broadcast();
},
});
} else {
listenerLtc = { error: 'LTC listener is on but no audio device is selected — pick one in Admin' };
broadcast();
}
}
const heartbeat = setInterval(() => { const heartbeat = setInterval(() => {
for (const res of streams) { for (const res of streams) {
try { try {
@@ -402,6 +456,7 @@ module.exports = {
clearTimeout(ingestExpiry); clearTimeout(ingestExpiry);
clearInterval(heartbeat); clearInterval(heartbeat);
stageClient?.stop(); stageClient?.stop();
ltcListener?.stop();
for (const res of streams) { for (const res of streams) {
try { res.end(); } catch { /* already gone */ } try { res.end(); } catch { /* already gone */ }
} }
@@ -409,18 +464,20 @@ module.exports = {
if (current && current.streams === streams) current = null; if (current && current.streams === streams) current = null;
}, },
health() { health() {
const ltcSource = { reader: ', LTC via reader', api: ', LTC via API', stage: ', LTC via stage display' }; const ltcSource = { listener: ', LTC via listener', reader: ', LTC via remote reader', api: ', LTC via API', stage: ', LTC via stage display' };
if (!enabled) { if (!enabled) {
// The LTC reader posts to us regardless of ProPresenter — an // The LTC listener/reader work regardless of ProPresenter — an
// LTC-only setup is half-configured but working. // 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' }; if (latest.ltc.supported) return { status: 'ok', message: `No ProPresenter host configured — LTC only${ltcSource[latest.ltc.source] || ''}` };
return { status: 'error', message: 'No ProPresenter host configured' }; 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 = !ltcEnabled
? ''
: ltc.supported
? (ltcSource[ltc.source] || '') ? (ltcSource[ltc.source] || '')
: (ltc.note ? ` (${ltc.note})` : ''); : (ltc.note ? ` (LTC: ${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}` };
} }
return { return {
@@ -442,7 +499,7 @@ module.exports = {
*/ */
tiles() { tiles() {
const state = current ? current.getLatest() : null; const state = current ? current.getLatest() : null;
if (!state || !state.enabled) return []; // unconfigured → classic single entry if (!state || (!state.enabled && !state.ltcEnabled)) return []; // unconfigured → classic single entry
const TYPE_LABEL = { const TYPE_LABEL = {
countdown: 'Countdown timer', countdown: 'Countdown timer',
countdown_to_time: 'Countdown to a time of day', countdown_to_time: 'Countdown to a time of day',
@@ -451,14 +508,23 @@ module.exports = {
}; };
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } }; const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
return [ return [
{ id: 'all', name: 'All timers', description: 'Every timer and the LTC timecode, selectable per tile' }, {
id: 'all',
name: 'All timers',
description: state.ltcEnabled
? 'Every timer and the LTC timecode, selectable per tile'
: 'Every timer, selectable per tile',
},
...state.timers.map((t) => ({ ...state.timers.map((t) => ({
id: `timer:${t.uuid}`, id: `timer:${t.uuid}`,
name: t.name, name: t.name,
description: TYPE_LABEL[t.type] || 'Timer', description: TYPE_LABEL[t.type] || 'Timer',
...oneCard, ...oneCard,
})), })),
{ id: 'ltc', name: 'LTC timecode', description: 'Incoming timecode (HH:MM:SS:FF)', ...oneCard }, // The LTC entry exists only while the admin switch is on.
...(state.ltcEnabled
? [{ id: 'ltc', name: 'LTC timecode', description: 'Incoming timecode (HH:MM:SS:FF)', ...oneCard }]
: []),
]; ];
}, },
@@ -472,10 +538,36 @@ module.exports = {
sendJson(res, 200, { state: current ? current.getLatest() : clearedState(false) }); sendJson(res, 200, { state: current ? current.getLatest() : clearedState(false) });
}, },
// The external LTC reader (tools/ltc-reader.js) reports here — the // The admin device picker asks this machine what it can hear. The
// one route on this module that accepts writes, so it optionally // options shape matches the admin select's optionsRoute contract.
// carries a shared token (Admin → "LTC reader token"). 'GET /ltc/devices': (req, res) => {
const bin = require('path').join(__dirname, 'ltc-capture');
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
let err = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (t) => { err += t; });
child.on('error', () => {
sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' });
});
child.on('exit', () => {
if (res.writableEnded) return;
const options = [];
for (const line of err.split('\n')) {
const m = line.match(/^ltc-capture:\s+(.*?)\s+(\d+) ch\s+(\d+) Hz\s+uid=/);
if (m) options.push({ value: m[1], label: `${m[1]} — ${m[2]} ch @ ${(Number(m[3]) / 1000)} kHz` });
}
sendJson(res, 200, { options });
});
},
// A remote LTC reader (tools/ltc-reader.js, on the machine that can
// hear the LTC when this one can't) 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) => { 'POST /ltc': (req, res) => {
if (!config.ltcEnabled) {
return void sendJson(res, 409, { error: 'LTC listener is disabled in Admin — enable it to accept reader reports' });
}
const token = String(config.ingestToken || ''); const token = String(config.ingestToken || '');
if (token) { if (token) {
const given = String(req.headers.authorization || '').replace(/^Bearer\s+/i, ''); const given = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
+26 -5
View File
@@ -83,7 +83,7 @@ function healthText(mod) {
return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).'; return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).';
} }
function fieldFor(key, spec, value, passwordSet) { function fieldFor(key, spec, value, passwordSet, moduleId) {
const type = spec?.type || 'string'; const type = spec?.type || 'string';
const label = spec?.label || key; const label = spec?.label || key;
const field = document.createElement('label'); const field = document.createElement('label');
@@ -122,17 +122,38 @@ function fieldFor(key, spec, value, passwordSet) {
row.append(host, sep, port); row.append(host, sep, port);
field.append(span, row); field.append(span, row);
read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 }); read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 });
} else if (type === 'select' && Array.isArray(spec.options)) { } else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) {
const span = document.createElement('span'); const span = document.createElement('span');
span.textContent = label; span.textContent = label;
const select = document.createElement('select'); const select = document.createElement('select');
for (const opt of spec.options) { const fill = (options) => {
select.innerHTML = '';
// The saved value must stay selectable even when it isn't among the
// live options (device unplugged, module route down).
const saved = String(value ?? '');
if (saved && !options.some((opt) => String(typeof opt === 'object' ? opt.value : opt) === saved)) {
options = [{ value: saved, label: `${saved} (saved)` }, ...options];
}
if (!saved) options = [{ value: '', label: '— choose —' }, ...options];
for (const opt of options) {
const o = document.createElement('option'); const o = document.createElement('option');
o.value = String(typeof opt === 'object' ? opt.value : opt); o.value = String(typeof opt === 'object' ? opt.value : opt);
o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt);
select.appendChild(o); select.appendChild(o);
} }
select.value = String(value ?? ''); select.value = saved;
};
fill(Array.isArray(spec.options) ? spec.options : []);
if (spec.optionsRoute && moduleId) {
// Options discovered at runtime (audio devices, ports, sources…):
// the module serves them from one of its own routes.
fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}`)
.then((res) => (res.ok ? res.json() : null))
.then((body) => {
if (Array.isArray(body?.options)) fill(body.options);
})
.catch(() => { /* keep the static/saved options */ });
}
field.append(span, select); field.append(span, select);
read = () => select.value; read = () => select.value;
} else { } else {
@@ -227,7 +248,7 @@ function buildModuleCard(mod) {
form.className = 'module-form'; form.className = 'module-form';
const readers = new Map(); const readers = new Map();
for (const [key, spec] of schemaKeys) { for (const [key, spec] of schemaKeys) {
const { field, read } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key]); const { field, read } = fieldFor(key, spec, mod.config[key], mod.passwordSet[key], mod.id);
readers.set(key, read); readers.set(key, read);
form.appendChild(field); form.appendChild(field);
} }
+7 -300
View File
@@ -48,8 +48,8 @@
* tracks transitions with an envelope-scaled hysteresis comparator (so * tracks transitions with an envelope-scaled hysteresis comparator (so
* input level doesn't matter), classifies the transition gaps as full/half * input level doesn't matter), classifies the transition gaps as full/half
* bit periods against an adaptive period estimate, shifts bits until the * bit periods against an adaptive period estimate, shifts bits until the
* sync word appears, then reads the BCD fields. Frame rate is snapped from * sync word appears, then reads the BCD fields. Frame rate locks from
* the measured bit clock and the highest frame number seen; the drop-frame * second-rollover consensus (bit clock until then); the drop-frame
* bit distinguishes 29.97 DF. Reverse play is not decoded (the sync word * bit distinguishes 29.97 DF. Reverse play is not decoded (the sync word
* never matches backwards) — for booth status that reads as "stopped", * never matches backwards) — for booth status that reads as "stopped",
* which is the honest summary anyway. * which is the honest summary anyway.
@@ -57,306 +57,13 @@
const os = require('os'); const os = require('os');
/* ══ decoder ═══════════════════════════════════════════════════════════ */
/** Sync word as bits 64–79 (0011111111111101) shift into a 16-bit register. */ /* The LTC engine (decoder, encoder, channel extractor) lives with the
const SYNC_WORD = 0x3ffd; Timers module and is shared from there — one implementation, used both
by the module's built-in listener and by this standalone reader. */
const { LtcDecoder, LtcEncoder, ChannelExtractor, frameBits, fmtTc } =
require('../modules/propresenter-timers/ltc.js');
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.prev = null; // previous valid frame, for rollover observation
this.nominal = 0; // nominal fps locked by two agreeing rollovers
this.rollCandidate = 0;
this.df = false; // drop-frame with two-frame hysteresis
this.dfPending = null;
this.dfRun = 0;
}
/** @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). Frame
// continuity broke, so rollover observation restarts too; the locked
// nominal survives — two agreeing rollovers re-lock a changed rate.
this.halfAt = 0;
this.sync = 0;
this.fresh = 0;
this.prev = null;
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;
};
// Strict validation: LTC has no parity worth the name, so a bit error
// can produce a frame that "parses" — every field must be a legal BCD
// digit AND inside its hard ceiling, or the frame is dropped whole.
// (A lax check here once let a corrupt frames field of 35 through, and
// downstream fps logic reported 36 fps to the dashboard.)
const fu = bcd(0, 4);
const su = bcd(16, 4);
const mu = bcd(32, 4);
const hu = bcd(48, 4);
if (fu > 9 || su > 9 || mu > 9 || hu > 9) return;
const f = fu + bcd(8, 2) * 10;
const s = su + bcd(24, 3) * 10;
const m = mu + bcd(40, 3) * 10;
const h = hu + bcd(56, 2) * 10;
if (f > 29 || s > 59 || m > 59 || h > 23) return;
// Drop-frame flag with two-frame hysteresis — one corrupt-but-legal
// frame must not flash 29.97 DF across the dashboard.
const dfBit = bits[10] === 1;
this.dfRun = dfBit === this.dfPending ? this.dfRun + 1 : 1;
this.dfPending = dfBit;
if (this.dfRun >= 2) this.df = dfBit;
// Nominal rate from observed rollovers: the frame number that precedes
// an f=0-with-seconds-advance IS the rate minus one. Two consecutive
// agreeing rollovers lock it — a single corrupt frame can't fake two
// valid, correctly-successive frames twice, and a genuine rate change
// re-locks itself within two seconds.
if (this.prev && f === 0 && s === (this.prev.s + 1) % 60) {
const cand = this.prev.f + 1;
if (cand === 24 || cand === 25 || cand === 30) {
if (cand === this.rollCandidate) this.nominal = cand;
this.rollCandidate = cand;
}
}
this.prev = { h, m, s, f };
this.onFrame({ h, m, s, f, df: this.df, fps: this.fps(this.df) });
}
/**
* Nominal rate: locked by rollover consensus once two second-boundaries
* agree; until then snap the measured bit clock to a standard rate.
* 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;
if (this.nominal) return this.nominal;
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 ═══════════════════════════════════════════════════════ */
/**
* Turns a raw s16le byte stream of interleaved channels into one channel's
* samples. Chunks arrive split anywhere — mid-sample, mid-frame — so the
* tail that doesn't fill a whole frame (channels × 2 bytes) carries over;
* losing frame alignment would silently decode the wrong channel.
*/
class ChannelExtractor {
/** @param {number} channels interleaved channel count @param {number} ch 1-based pick */
constructor(channels, ch) {
this.frameBytes = channels * 2;
this.offset = (ch - 1) * 2;
this.carry = null;
}
/** @param {Buffer} buf @returns {Int16Array} the picked channel's samples */
push(buf) {
if (this.carry) buf = Buffer.concat([this.carry, buf]);
const usable = buf.length - (buf.length % this.frameBytes);
this.carry = buf.length > usable ? buf.subarray(usable) : null;
const out = new Int16Array(usable / this.frameBytes);
for (let i = 0, off = this.offset; i < out.length; i += 1, off += this.frameBytes) {
out[i] = buf.readInt16LE(off);
}
return out;
}
}
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) { function log(msg) {
console.log(`[${new Date().toLocaleTimeString()}] ${msg}`); console.log(`[${new Date().toLocaleTimeString()}] ${msg}`);