diff --git a/.gitignore b/.gitignore index bf6e2da..542e8b2 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ dist/ # runtime state written by the admin page / layout saves config/modules.json config/layouts/ + +# compiled per-machine, source sits next to it +modules/propresenter-timers/ltc-capture diff --git a/README.md b/README.md index 64bd454..10334f1 100644 --- a/README.md +++ b/README.md @@ -92,20 +92,29 @@ are written by the app itself. 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 unmistakably red. Read-only — no start/stop controls. Admin config: - ProPresenter endpoint (host : port), API password, stage display - password, LTC reader token. + ProPresenter endpoint (host : port), API password, the LTC listener + switch + audio device + channel, LTC reader token, stage display + password. - LTC sources, best first (ProPresenter's HTTP API has no timecode route): - 1. **The LTC audio feed itself** — run `tools/ltc-reader.js` on any - machine that can hear the LTC signal; it decodes the SMPTE frames from - a PCM pipe (ffmpeg/sox one-liners in its header) and POSTs - running/stopped/no-signal, the timecode, and the frame rate (incl. - 29.97 drop-frame) to the module's `/ltc` ingest route. LTC on one - input of a multichannel interface: capture all channels and pick one - with `--channels 18 --ch 5` — never downmix LTC into program audio. - `--selftest` checks the decoder; `--demo` feeds synthetic LTC - end-to-end with no audio hardware. - 2. A stage-display layout field labeled "LTC", read over ProPresenter's + LTC lives behind the admin **LTC listener** switch — off means no LTC + tile in the picker at all. On, the sources, best first (ProPresenter's + HTTP API has no timecode route): + 1. **Built-in listener** — the module captures the admin-selected audio + input device/channel on the ProdDash machine (native `ltc-capture` + helper, compiled on first use) and decodes the SMPTE frames + in-process: running/stopped/no-signal, timecode, frame rate (incl. + 29.97 drop-frame). macOS note: the ProdDash server needs microphone + permission — launch it from a logged-in session (Start + ProdDash.command) and approve the one-time prompt; a server started + 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. - **Clock** — a big booth clock; also the smallest possible module and the reference for module authors. diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index b84ddec..2f2886f 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -67,10 +67,19 @@ Both schemas map field names to specs: { "type": "string" | "number" | "boolean" | "select" | "password" | "endpoint", "label": "Shown next to the field", "default": , - "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/` 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 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 diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index c239214..f397668 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -61,7 +61,9 @@ export default function create({ root, moduleApi }) { function menuRows() { 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; } @@ -159,7 +161,7 @@ export default function create({ root, moduleApi }) { for (const t of state?.timers || []) { 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; } @@ -235,9 +237,10 @@ export default function create({ root, moduleApi }) { card.timeEl.textContent = '—'; card.stateEl.textContent = 'No signal'; } - // The reader posts independently of ProPresenter, so its card must not - // gray out with the rest when only ProPresenter is unreachable. - const live = ltc.source === 'reader' ? ' src-reader' : ''; + // The built-in listener and the remote reader are independent of + // ProPresenter, so their card must not gray out with the rest when + // 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}`; } @@ -261,9 +264,9 @@ export default function create({ root, moduleApi }) { } if (!state.enabled) { bannerEl.hidden = true; - // The LTC reader feeds us with or without ProPresenter — an LTC-only - // setup is working, not broken. - if (state.ltc?.supported) moduleApi.setStatus('ok', 'LTC via reader — no ProPresenter host configured'); + // The LTC listener/reader feed us with or without ProPresenter — an + // LTC-only setup is working, not broken. + 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'); return; } diff --git a/tools/ltc-capture.swift b/modules/propresenter-timers/ltc-capture.swift similarity index 86% rename from tools/ltc-capture.swift rename to modules/propresenter-timers/ltc-capture.swift index c82c676..c5d3c86 100644 --- a/tools/ltc-capture.swift +++ b/modules/propresenter-timers/ltc-capture.swift @@ -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 -// normally fed by ffmpeg or sox — but production machines rarely have -// either, and shipping a third-party binary just to move samples from an -// input device to a pipe is overkill. This is the whole job, natively: +// The module decodes LTC in-process (ltc.js) and spawns this to move +// samples from an input device to a pipe. Production machines rarely have +// ffmpeg/sox, and shipping a third-party binary for that one job is +// 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 -// tools/ltc-capture --device "Dante Via 16" \ -// | node tools/ltc-reader.js --sr 48000 --channels 16 --ch 5 ... +// ltc-capture --device "Dante Via 16 Channel" // streams the device's inputs as interleaved s16le to stdout, at the // 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 // UID; ambiguity is an error listing the candidates. Capture uses an @@ -144,7 +148,12 @@ if wantList || wantDevice == nil { } 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 { if matches.isEmpty { fail("no input device matches \"\(wantDevice!)\" — see --list") } fail("\"\(wantDevice!)\" is ambiguous: " + matches.map { $0.name }.joined(separator: ", ")) diff --git a/modules/propresenter-timers/ltc-listener.js b/modules/propresenter-timers/ltc-listener.js new file mode 100644 index 0000000..2390b61 --- /dev/null +++ b/modules/propresenter-timers/ltc-listener.js @@ -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: '' } 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 }; diff --git a/modules/propresenter-timers/ltc.js b/modules/propresenter-timers/ltc.js new file mode 100644 index 0000000..409fe46 --- /dev/null +++ b/modules/propresenter-timers/ltc.js @@ -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 }; diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index 00153b4..9e4b7e6 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -1,7 +1,7 @@ { "id": "propresenter-timers", "name": "Timers", - "version": "1.1.0", + "version": "1.2.0", "description": "Live ProPresenter timers and LTC timecode", "client": "client.js", "server": "server.js", @@ -19,14 +19,30 @@ "label": "API password (only if set in ProPresenter)", "default": "" }, - "stagePassword": { - "type": "password", - "label": "Stage display password (for LTC — ProPresenter Settings → Network)", - "default": "" + "ltcEnabled": { + "type": "boolean", + "label": "LTC listener — decode timecode from an audio input on this machine", + "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": { "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": "" } } diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index 685b71a..1e80bad 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -8,10 +8,12 @@ * fans one merged state object out to every tile over SSE. Browsers never * talk to ProPresenter directly. * - * GET /state → { state: } (instant paint for new tiles) - * GET /stream → SSE, `state` events (change-detected, heartbeated) - * POST /ltc → LTC ingest: tools/ltc-reader.js decodes the actual LTC - * audio signal and reports { state, time, fps, df } here. + * GET /state → { state: } (instant paint for new tiles) + * GET /stream → SSE, `state` events (change-detected, heartbeated) + * POST /ltc → remote LTC ingest: tools/ltc-reader.js on another + * 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): * GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun, @@ -30,13 +32,19 @@ * once per connection anyway so a future ProPresenter that adds it * lights up without a module update. * - * Because the HTTP API has no timecode, LTC comes from two real sources: - * the external reader (POST /ltc — tools/ltc-reader.js decoding the LTC - * audio feed itself, ground truth while its reports are fresh) and the - * classic stage-display websocket (see stage-ws.js): a stage layout field - * labeled "LTC" streams its text to us. Source precedence: fresh reader - * ingest, else the HTTP route if it ever answers, else the stage field, - * else "not available". + * Because the HTTP API has no timecode, LTC comes from the audio signal + * itself. The whole LTC feature sits behind the admin "LTC listener" + * switch — off means no LTC tile in the picker, no probes, and the ingest + * route answers 409. When on, the sources in precedence order: + * 1. the built-in listener (ltc-listener.js — this machine captures the + * admin-selected device/channel and decodes in-process) while it has + * 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 * 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. */ const INGEST_STALE_MS = 6000; +const { spawn } = require('child_process'); const { createStageLtcClient } = require('./stage-ws'); +const { createLtcListener } = require('./ltc-listener'); /** Set by init(), read by the routes — both are rebuilt together on remount. */ let current = null; -function clearedState(enabled) { +function clearedState(enabled, ltcEnabled = false) { return { enabled, + ltcEnabled, reachable: false, lastError: '', timers: [], @@ -91,8 +102,11 @@ module.exports = { const port = Number(config.port ?? ep.port) || 1025; const password = String(config.password || ''); 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 stopped = false; let pollTimer = null; @@ -105,27 +119,42 @@ module.exports = { let ltcLast = { time: '', receiving: false }; /** Live view of the stage-display websocket's LTC field (stage-ws.js). */ 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 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 - the LTC audio itself, so a fresh report from it is ground truth; - then the HTTP route if it ever answers; then the stage-display - field. Text sources don't know fps — receiving/frozen/empty maps - onto the same status vocabulary the reader uses. */ + /** One LTC verdict from every source, in the precedence the header + comment explains. Text sources don't know fps — receiving/frozen/ + empty maps onto the same status vocabulary the audio decode uses. */ function composeLtc() { + if (!ltcEnabled) { + return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' }; + } + const audioVerdict = (src, tag) => ({ + supported: true, + source: tag, + time: src.time, + receiving: src.state === 'running', + status: src.state, + fps: src.fps, + df: src.df, + 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 { - supported: true, - source: 'reader', - time: ingest.time, - receiving: ingest.state === 'running', - status: ingest.state, - fps: ingest.fps, - df: ingest.df, - note: '', - }; + 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'); if (ltcSupported === true) { @@ -139,10 +168,10 @@ module.exports = { // 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' }; } - 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: 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. */ @@ -284,6 +313,7 @@ module.exports = { } async function pollLtc() { + if (!ltcEnabled) return; // A 404 verdict is retried once a minute — an endpoint appearing // without an unreachable spell (ProPresenter hot-swapped in testing, // or some future in-place upgrade) must not stay invisible forever. @@ -308,6 +338,7 @@ module.exports = { function broadcast() { latest = { enabled, + ltcEnabled, reachable: latest.reachable, lastError: latest.lastError, timers: latest.timers, @@ -365,23 +396,46 @@ module.exports = { if (enabled) { pollTick(); log(`polling timers on ${host}:${port}`); - stageClient = createStageLtcClient({ - host, - port, - password: String(config.stagePassword || ''), - log, - // The HTTP poller knows every configured timer's uuid — anything - // else streaming timecode-shaped text can be heuristically bound. - isTimerUid: (uid) => timerConfigs.has(uid), - onUpdate(update) { - stageLtc = update; - broadcast(); - }, - }); + if (ltcEnabled) { + stageClient = createStageLtcClient({ + host, + port, + password: String(config.stagePassword || ''), + log, + // The HTTP poller knows every configured timer's uuid — anything + // else streaming timecode-shaped text can be heuristically bound. + isTimerUid: (uid) => timerConfigs.has(uid), + onUpdate(update) { + stageLtc = update; + broadcast(); + }, + }); + } } else { 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(() => { for (const res of streams) { try { @@ -402,6 +456,7 @@ module.exports = { clearTimeout(ingestExpiry); clearInterval(heartbeat); stageClient?.stop(); + ltcListener?.stop(); for (const res of streams) { try { res.end(); } catch { /* already gone */ } } @@ -409,18 +464,20 @@ module.exports = { if (current && current.streams === streams) current = null; }, 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) { - // 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. - 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' }; } if (latest.reachable) { const ltc = latest.ltc; - const ltcNote = ltc.supported - ? (ltcSource[ltc.source] || '') - : (ltc.note ? ` (${ltc.note})` : ''); + const ltcNote = !ltcEnabled + ? '' + : ltc.supported + ? (ltcSource[ltc.source] || '') + : (ltc.note ? ` (LTC: ${ltc.note})` : ''); return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` }; } return { @@ -442,7 +499,7 @@ module.exports = { */ tiles() { 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 = { countdown: 'Countdown timer', 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 } }; 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) => ({ id: `timer:${t.uuid}`, name: t.name, description: TYPE_LABEL[t.type] || 'Timer', ...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) }); }, - // The external LTC reader (tools/ltc-reader.js) reports here — the - // one route on this module that accepts writes, so it optionally - // carries a shared token (Admin → "LTC reader token"). + // The admin device picker asks this machine what it can hear. The + // options shape matches the admin select's optionsRoute contract. + '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) => { + 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 || ''); if (token) { const given = String(req.headers.authorization || '').replace(/^Bearer\s+/i, ''); diff --git a/public/admin/admin.js b/public/admin/admin.js index fdcd56c..01c1914 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -83,7 +83,7 @@ function healthText(mod) { 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 label = spec?.label || key; const field = document.createElement('label'); @@ -122,17 +122,38 @@ function fieldFor(key, spec, value, passwordSet) { row.append(host, sep, port); field.append(span, row); 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'); span.textContent = label; const select = document.createElement('select'); - for (const opt of spec.options) { - const o = document.createElement('option'); - o.value = String(typeof opt === 'object' ? opt.value : opt); - o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); - select.appendChild(o); + 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'); + o.value = String(typeof opt === 'object' ? opt.value : opt); + o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); + select.appendChild(o); + } + 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 */ }); } - select.value = String(value ?? ''); field.append(span, select); read = () => select.value; } else { @@ -227,7 +248,7 @@ function buildModuleCard(mod) { form.className = 'module-form'; const readers = new Map(); 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); form.appendChild(field); } diff --git a/tools/ltc-reader.js b/tools/ltc-reader.js index f7f5d3f..9d33606 100644 --- a/tools/ltc-reader.js +++ b/tools/ltc-reader.js @@ -48,8 +48,8 @@ * tracks transitions with an envelope-scaled hysteresis comparator (so * input level doesn't matter), classifies the transition gaps as full/half * bit periods against an adaptive period estimate, shifts bits until the - * sync word appears, then reads the BCD fields. Frame rate is snapped from - * the measured bit clock and the highest frame number seen; the drop-frame + * sync word appears, then reads the BCD fields. Frame rate locks from + * second-rollover consensus (bit clock until then); the drop-frame * bit distinguishes 29.97 DF. Reverse play is not decoded (the sync word * never matches backwards) — for booth status that reads as "stopped", * which is the honest summary anyway. @@ -57,306 +57,13 @@ const os = require('os'); -/* ══ decoder ═══════════════════════════════════════════════════════════ */ -/** Sync word as bits 64–79 (0011111111111101) shift into a 16-bit register. */ -const SYNC_WORD = 0x3ffd; +/* The LTC engine (decoder, encoder, channel extractor) lives with the + 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) { console.log(`[${new Date().toLocaleTimeString()}] ${msg}`);