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:
co-authored by
Claude Fable 5
parent
c4db412014
commit
b95aa7dc34
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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: ", "))
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user