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/CLAUDE.md b/CLAUDE.md index f08eb4b..b5670ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,8 +16,16 @@ contract** (manifest, client factory + moduleApi, optional server entry, title-bar controls). The rules that matter most: - A module is one self-contained folder in `modules/` — **never modify the - shell** (`server.js`, `public/`) to add one. Installation = drop the folder, - restart, configure in `/admin`. + shell** (`server.js`, `public/`) to add one. Installation = the admin page's + Available modules (downloads from the repo into the data directory), or + drop the folder and restart; configure in `/admin`. +- **All settings in the admin page** (`configSchema` / `instanceSchema`) — + never a separate setup page, hand-edited file or env var. **Dependencies + ship inside the module folder** (zero deps is the norm). **Everything the + module does runs inside its server entry** — no helper scripts, companion + programs or daemons the operator starts by hand; a listener or decoder is + started in `init()` and stopped in `stop()`. Declare `"proddash": ">=x.y.z"` + and bump the module's `version` on every change. - Browsers never call upstream services directly; the module's server routes proxy everything (no CORS, secrets stay server-side). - Style with the shell's CSS variables only, scoped under diff --git a/README.md b/README.md index da2bf4c..d08a877 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,9 @@ the app folder from `main` and start the new version with everything still configured. The admin page shows the exact path in use, and the server prints it at startup (`Settings : …`). -The directory holds `modules.json` (module config) and `layouts/` (named -layouts), both written by the app itself. It may also hold a `proddash.json` +The directory holds `modules.json` (module config), `layouts/` (named +layouts) and `modules/` (modules installed from the admin page), all written +by the app itself. It may also hold a `proddash.json` with this machine's shell settings — the same keys as the checked-in [config/proddash.json](config/proddash.json), which supplies the defaults: @@ -86,6 +87,47 @@ so and falls back to the app's `config/` folder. directory, so nothing has to be re-entered. The old files are left in place and ignored from then on. +## Installing modules and updating + +Everything below happens in the **admin page**; ProdDash talks to its GitHub +repository (`michaelmcgary2008/ProdDash`, branch `main` — change with +`repo` / `branch` in `proddash.json` or the `PRODDASH_REPO` variable, +`owner/repo#branch`). It downloads one small archive of the branch, at most +every few minutes, only while the admin page is open or for the update check +below. Nothing is installed without someone clicking. + +- **Available modules** lists modules in the repo that aren't installed + here. **Install** downloads the module into the data directory's `modules/` + and starts it right away — nothing is written into the app folder, and no + restart is needed. Modules that ship with your copy of ProdDash are + "bundled"; ones you installed are "installed"; if both exist, the newer + version runs. +- **Uninstall** on a module card removes an installed module's files. For a + bundled module (its folder belongs to the app) it hides the module from + every dashboard instead; it reappears under Available modules and can be + brought back without downloading. Settings are kept either way. +- **Module updates**: when the repo has a newer version of an installed + module, its card shows **Update to vX**. +- **Version requirements**: every module declares the ProdDash versions it + needs (`"proddash": ">=1.1.0"` in its manifest). A module that needs a + newer shell is listed but not loaded, and cannot be installed until + ProdDash is updated. +- **ProdDash updates**: the admin page checks the repo shortly after start + and every six hours, and shows a banner when `main` has a newer version + (or new commits). **Update now** brings the app folder up to date and + restarts the server; dashboards reconnect on their own. A git checkout is + updated with `git pull --ff-only` (only when it is on the repo branch with + no local changes — otherwise the banner says why and you update with git). + A plain folder (no `.git`) has the repo archive unpacked over it; files + removed upstream are left behind but harmless. Settings and layouts are + never touched. **What changed** opens the commit comparison on GitHub. +- **Restarting**: after an update the server exits with code 75 and starts + its own replacement, which waits for the port to free up. If you supervise + ProdDash with your own launcher, set `PRODDASH_LAUNCHER=1` so the server + just exits and your launcher restarts it on exit code 75. + `PRODDASH_NO_REMOTE_CHECK=1` turns off the periodic check (the admin page + still checks when opened). + ## Using the dashboard - **+** — add any enabled module; multiple tiles of the same module are @@ -98,8 +140,8 @@ ignored from then on. sit flush — when two share a vertical edge, hover the seam and the same kind of pill appears; drag it to trade width (macOS split-view style). - **Fullscreen** (the ⛶ button) is a viewing mode: the menu bar hides (its - green tab brings it back, minus the Admin button), the occupied columns - stretch to the full width, and every column runs to the + green lip at the top-left brings it back, minus the Admin button), the + occupied columns stretch to the full width, and every column runs to the bottom of the screen — the last tile in each column is stretched to end there. A short tile like the clock is never stretched into a ribbon: under another tile it slides to the bottom edge and the tile above grows into the @@ -108,9 +150,11 @@ ignored from then on. its normal row height and scrolls. - A tile's actions (menus, text size, clear…) live in its **title bar**; the small **notch** on the bar's bottom edge hides/shows the whole bar - for a clean wall-display look. The menu bar has the same kind of tab — - the green one under the ProdDash name, kept at the left so it never sits - on top of a tile's own tab. + for a clean wall-display look. The menu bar's handle is the **ProdDash + name itself**, wrapped in a translucent green pill at the left — click it + to hide the bar; the slim green lip it leaves at the top-left edge brings + the bar back. It stays at the left so it never sits on top of a tile's + own tab. - The **gear** on a tile holds per-tile settings (filters, text size, display options) — these belong to this browser's layout, not the server. - The **grid icon** — save the current arrangement to the server under a @@ -133,8 +177,23 @@ ignored from then on. elapsed) and LTC timecode as auto-sized cards. The + picker lists every discovered timer (and the LTC card) as its own tile, or add "All timers" 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), password. + unmistakably red. Read-only — no start/stop controls. Admin config is in + three groups: **ProPresenter** (endpoint host : port, API password), + **LTC Timecode** (the listener switch, and — when it's on — the audio + device and channel), and **Display** (Timer font — Default or Monospace, + which keeps the digits from shifting as the numbers change). + + ProPresenter exposes no timecode over any API, so LTC is decoded from the + audio signal, behind the admin **LTC listener** switch (off ⇒ no LTC tile + in the picker at all). On, the module captures the selected audio input + device/channel on the ProdDash machine — a native `ltc-capture` helper, + compiled on first use — and decodes the SMPTE frames in-process: + running/stopped/no-signal, the timecode counting frame-accurately in real + time, and the 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. (Engine + self-test: `node modules/propresenter-timers/ltc.selftest.js`.) - **PCO Plan** — the order of service from Planning Center Services: every plan item with its scheduled start time, length, type, song key, description and item notes. When the ProPresenter module is running it @@ -186,6 +245,7 @@ ProdDash/ │ ├── clock/ │ ├── prodcom-transcript/ │ └── propresenter-now-next/ (carries its own copy of propresenter-core) +├── package.json name + version (what the update check compares) ├── config/ proddash.json defaults (runtime state lives in the │ per-machine data directory — see "Configure") ├── docs/MODULE-GUIDE.md how to build and install a module diff --git a/Start ProdDash.command b/Start ProdDash.command old mode 100644 new mode 100755 index 0ca52ec..43cd9b5 --- a/Start ProdDash.command +++ b/Start ProdDash.command @@ -1,4 +1,11 @@ #!/bin/zsh # Double-click to start ProdDash (macOS) cd "$(dirname "$0")" +# One dashboard per machine: take the port over from any prior instance +# (an SSH- or launchd-started one has no microphone access for the LTC +# listener — launching from here fixes that). +OLD=$(lsof -t -iTCP:24500 -sTCP:LISTEN 2>/dev/null) +[[ -n "$OLD" ]] && kill $OLD 2>/dev/null && sleep 1 +# Prefer the bundled runtime (production machines have no system Node) +if [[ -x ./runtime/bin/node ]]; then exec ./runtime/bin/node server.js; fi exec node server.js diff --git a/config/proddash.json b/config/proddash.json index 869d4c8..d43bd7c 100644 --- a/config/proddash.json +++ b/config/proddash.json @@ -1,4 +1,6 @@ { "port": 24500, - "adminPasscode": "" + "adminPasscode": "", + "repo": "michaelmcgary2008/ProdDash", + "branch": "main" } diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index ca7bdd0..cba9f14 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -9,6 +9,31 @@ their own dashboards, move and resize them, and give each tile its own per-tile settings. Your module renders inside the tile and talks to the outside world only through the small API the shell hands it. +## Rules every module follows + +These are not style preferences — the admin page, the installer and the +people running the booth depend on them. + +1. **All settings live in the ProdDash admin page.** Server-wide settings go + in `configSchema`, per-tile settings in `instanceSchema`. Never a separate + setup page, wizard, config file the operator edits by hand, or environment + variable. +2. **Dependencies ship inside the module folder.** Zero dependencies is the + norm (the shell has none). Anything you genuinely need is vendored into + the module — or fetched by your own `init()` on first start, with the + admin health line saying what is happening. No `npm install`, no + installer, no "run this first". +3. **Everything the module does runs inside the module.** No helper scripts, + companion programs, daemons or services the operator has to start next to + ProdDash. If your feature needs a listener, a decoder or a worker, your + server entry owns it: start it in `init()`, stop it in `stop()`, report + it in `health()`. A child process the module itself spawns and supervises + is fine; a program someone launches by hand is not. +4. **Declare the ProdDash version you need** (`"proddash": ">=1.1.0"` in the + manifest) and **bump your own `version`** whenever you change the module. + The admin page refuses to install a module whose requirement the running + shell doesn't meet, and it offers updates by comparing versions. + ## The shape of a module A module is one folder dropped into `modules/`: @@ -21,9 +46,16 @@ modules// └── ... assets (css, images…) served statically at /modules// ``` -**Installation is exactly this:** copy the folder into `modules/`, restart -ProdDash (`node server.js`), then configure and enable it at `/admin`. No -shell code changes, ever. +**Installation is exactly this:** in the admin page, open **Available +modules** and press **Install** — the module is downloaded from the ProdDash +repo into the server's data directory (`/modules//`) and starts +immediately. Developing locally? Drop the folder into `modules/` (bundled +with the checkout) or into the data directory's `modules/` and restart. No +shell code changes, ever. Modules bundled with a checkout and modules +installed from the admin page live side by side; when both have the same id +the newer version runs. **Uninstall** in the admin page removes an installed +module's files (a bundled one is hidden instead, since its folder belongs to +the app); its settings are kept for a later reinstall. ## module.json @@ -32,6 +64,7 @@ shell code changes, ever. "id": "my-module", "name": "My Module", "version": "1.0.0", + "proddash": ">=1.1.0", "description": "One line shown in the Add-tile picker and the admin page", "client": "client.js", "server": "server.js", @@ -52,7 +85,9 @@ shell code changes, ever. | --- | --- | --- | | `id` | yes | Must equal the folder name. Lowercase, dashes. | | `name` | yes | Human name shown on the tile header and in menus. | -| `version`, `description` | no | Shown in the admin page / picker. | +| `version` | yes | Your module's version (`x.y.z`). Bump it on every change — the admin page offers updates when the repo's copy is newer than the installed one. | +| `proddash` | yes | The ProdDash versions this module works with: `">=1.1.0"`, `"^1.1.0"`, `">=1.1.0 <2.0.0"`, or `"*"`. A module whose requirement the running shell doesn't meet is listed in the admin page but never loaded, and can't be installed from the catalog. | +| `description` | no | Shown in the admin page / picker. | | `client` | yes | Client entry file, loaded as an ES module. | | `server` | no | Server entry file (CommonJS), loaded with `require()`. | | `style` | no | A stylesheet the shell injects once per module. | @@ -523,7 +558,9 @@ client-side failures (a tile that fails to start says so in the tile). - ProPresenter timers: `node tools/pp-timers-mock.js 1600` — running, overrunning, stopped and elapsed timers plus an LTC timecode that periodically drops to no-signal (`--no-ltc` serves a ProPresenter without - a timecode API). Point the Timers module at host `127.0.0.1`, port `1600`. + a timecode API). Also serves the stage-display websocket with an + LTC-labeled layout field (`--stage-pwd`, `--no-ltc-field` exercise its + failure paths). Point the Timers module at host `127.0.0.1`, port `1600`. - Planning Center: `node tools/pco-mock.js` (port 24700) — folders, service types, plans (last week / today / next week), plan times and items with songs, keys and item notes, behind a Basic/Bearer auth check. Planning diff --git a/modules/clock/module.json b/modules/clock/module.json index 1f1639b..f95087f 100644 --- a/modules/clock/module.json +++ b/modules/clock/module.json @@ -2,6 +2,7 @@ "id": "clock", "name": "Clock", "version": "1.0.0", + "proddash": ">=1.1.0", "description": "Big booth clock — the smallest possible ProdDash module", "client": "client.js", "style": "style.css", diff --git a/modules/pco-plan/module.json b/modules/pco-plan/module.json index 38345ce..25537dd 100644 --- a/modules/pco-plan/module.json +++ b/modules/pco-plan/module.json @@ -1,7 +1,8 @@ { "id": "pco-plan", "name": "PCO Plan", - "version": "1.2.0", + "version": "1.3.0", + "proddash": ">=1.3.0", "description": "Order of service from Planning Center Services, following ProPresenter live", "client": "client.js", "server": "server.js", diff --git a/modules/prodcom-transcript/module.json b/modules/prodcom-transcript/module.json index e35b928..95772dd 100644 --- a/modules/prodcom-transcript/module.json +++ b/modules/prodcom-transcript/module.json @@ -2,6 +2,7 @@ "id": "prodcom-transcript", "name": "ProdCom", "version": "1.0.0", + "proddash": ">=1.1.0", "description": "Live ProdCom transcript stream", "client": "client.js", "server": "server.js", diff --git a/modules/propresenter-now-next/module.json b/modules/propresenter-now-next/module.json index 5600866..e567176 100644 --- a/modules/propresenter-now-next/module.json +++ b/modules/propresenter-now-next/module.json @@ -2,6 +2,7 @@ "id": "propresenter-now-next", "name": "ProPresenter", "version": "1.0.0", + "proddash": ">=1.1.0", "description": "What is live in ProPresenter and what comes next", "client": "client.js", "server": "server.js", diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index 02f728d..94dd235 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -11,6 +11,45 @@ const LTC_KEY = '__ltc__'; +/* ── SMPTE timecode math (module-scope, exported for tests) ───────── + The server anchors us ~4×/s with { time, fps, df, ageMs }; the card + counts every frame in between locally. That needs exact frame↔timecode + conversion, including 29.97 drop-frame (frames 00 and 01 don't exist at + the top of a minute, except every tenth minute). */ + +export function parseTc(str) { + const m = /^(\d{1,2}):(\d{2}):(\d{2})[:;.](\d{1,2})$/.exec(String(str || '').trim()); + if (!m) return null; + return { h: Number(m[1]), m: Number(m[2]), s: Number(m[3]), f: Number(m[4]) }; +} + +export function tcToFrames(tc, nominal, df) { + let n = (tc.h * 3600 + tc.m * 60 + tc.s) * nominal + tc.f; + if (df) { + const mins = tc.h * 60 + tc.m; + n -= 2 * (mins - Math.floor(mins / 10)); + } + return n; +} + +export function framesToTc(n, nominal, df) { + // 10 DF minutes hold 17982 frames (the first minute 1800, the rest 1798). + const perDay = df ? 24 * 6 * 17982 : 24 * 3600 * nominal; + n = ((n % perDay) + perDay) % perDay; + if (df) { + const d = Math.floor(n / 17982); + const m10 = n % 17982; + const extra = m10 < 1800 ? 0 : Math.floor((m10 - 1800) / 1798) + 1; + const totalMin = d * 10 + extra; + // Re-add the two dropped frames per dropped minute, then read as 30fps. + n += 2 * (totalMin - Math.floor(totalMin / 10)); + } + const f = n % nominal; + const secs = Math.floor(n / nominal); + const pad = (v) => String(v).padStart(2, '0'); + return `${pad(Math.floor(secs / 3600))}:${pad(Math.floor(secs / 60) % 60)}:${pad(secs % 60)}:${pad(f)}`; +} + export default function create({ root, moduleApi }) { let state = null; let feedOffline = false; @@ -61,7 +100,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 +200,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; } @@ -205,32 +246,117 @@ export default function create({ root, moduleApi }) { card.el.className = `tm-card is-${kind}`; } + /* ── real-time LTC: count every frame between server anchors ─────── */ + + // The server anchors us ~4×/s with the decoded timecode, its rate and + // how old it is (ageMs). Between anchors a requestAnimationFrame loop + // advances the display at the LTC's own rate, so every frame paints — + // without pushing 30 SSE messages a second at every dashboard. + let ltcAnchor = null; // { frames, at, recvAt, rate, nominal, df } + let ltcRaf = 0; + let ltcShownFrames = -1; + /** Anchors arrive ~every 250 ms while running. None for this long means + the feed state is changing — hold rather than freewheel into phantom + frames the next update would visibly rewind. */ + const ANCHOR_FRESH_MS = 350; + + function updateLtcAnchor() { + const l = state?.ltc; + // The listener is extrapolatable — it knows the frame rate. + const live = l && l.status === 'running' && l.fps > 0 && l.source === 'listener'; + const tc = live ? parseTc(l.time) : null; + if (!tc) { + ltcAnchor = null; + ltcShownFrames = -1; + if (ltcRaf) { + cancelAnimationFrame(ltcRaf); + ltcRaf = 0; + } + return; + } + const nominal = l.df ? 30 : Math.round(l.fps); + ltcAnchor = { + frames: tcToFrames(tc, nominal, l.df), + at: performance.now() - (Number(l.ageMs) || 0), + recvAt: performance.now(), + rate: l.df ? 30000 / 1001 : nominal, + nominal, + df: l.df, + }; + if (!ltcRaf) ltcRaf = requestAnimationFrame(ltcTick); + } + + /** Paint the frame the anchor implies for right now. Called both by the + rAF loop (smooth, per-frame, on visible tabs) and by render() at the + 4×/s anchor rate — so a throttled/backgrounded tab, where rAF is + paused, still advances at the anchor rate instead of freezing. */ + function paintLtcFrame() { + const a = ltcAnchor; + const card = cards.get(LTC_KEY); + if (!a || !card) return; + const limit = Math.min(performance.now(), a.recvAt + ANCHOR_FRESH_MS); + let n = a.frames + Math.round(((limit - a.at) / 1000) * a.rate); + // Anchor jitter (±1 frame) must never tick the display backwards; + // a genuine jump back (re-strike, seek) is far larger and passes. + if (ltcShownFrames >= 0 && n < ltcShownFrames && ltcShownFrames - n <= 2) n = ltcShownFrames; + if (n !== ltcShownFrames) { + ltcShownFrames = n; + card.timeEl.textContent = framesToTc(n, a.nominal, a.df); + } + } + + function ltcTick() { + ltcRaf = 0; + // No anchor, or the LTC card is hidden in this tile: stop — the next + // server anchor (4×/s while running) re-arms the loop via render(). + if (!ltcAnchor || !cards.get(LTC_KEY)) return; + paintLtcFrame(); + ltcRaf = requestAnimationFrame(ltcTick); + } + function updateLtcCard(card, ltc) { let mode; + // Only the audio sources know the frame rate (decoded from the LTC + // bits); a set drop-frame flag implies 29.97 whatever fps rounds to. + const rate = ltc.df ? '29.97 DF' : ltc.fps ? `${ltc.fps} fps` : ''; if (ltc.supported === false) { mode = 'unavailable'; card.timeEl.textContent = '—'; - card.stateEl.textContent = 'Timecode not available on this ProPresenter'; - } else if (ltc.supported === null) { - mode = 'waiting'; - card.timeEl.textContent = displayTime(ltc.time); - card.stateEl.textContent = 'Waiting for ProPresenter…'; + // The server says why when it knows (listener disabled, no device + // selected, capture failed, starting up…) — surface that. + card.stateEl.textContent = ltc.note || 'LTC not available'; } else if (ltc.receiving) { mode = 'receiving'; + // With an anchor active, paint the frame it implies for now (this + // keeps a throttled tab advancing at the 4×/s anchor rate); the rAF + // loop refines it to every frame when the tab is visible. Without an + // anchor (rate unknown), fall back to the raw anchor timecode. + if (ltcAnchor) paintLtcFrame(); + else card.timeEl.textContent = displayTime(ltc.time); + card.stateEl.textContent = rate ? `Running · ${rate}` : 'Running'; + } else if (ltc.status === 'stopped' || ltc.time) { + // LTC ceased but we know where it stopped — hold the last frame. + mode = 'stopped'; card.timeEl.textContent = displayTime(ltc.time); - card.stateEl.textContent = 'Receiving'; + card.stateEl.textContent = 'Stopped'; } else { mode = 'nosignal'; - card.timeEl.textContent = displayTime(ltc.time); + card.timeEl.textContent = '—'; card.stateEl.textContent = 'No signal'; } - card.el.className = `tm-card tm-card-ltc is-ltc-${mode}`; + // The built-in listener is independent of ProPresenter, so its card + // must not gray out with the rest when only ProPresenter is unreachable. + const live = ltc.source === 'listener' ? ' src-reader' : ''; + card.el.className = `tm-card tm-card-ltc is-ltc-${mode}${live}`; } /* ── status dot + degraded banners ──────────────────────────────── */ function renderStatus() { wrap.classList.toggle('is-stale', feedOffline || (state ? !state.reachable && state.enabled : false)); + // The stale gray-out spares the reader-fed LTC card — unless the feed + // from the ProdDash server itself is down, when everything is stale. + wrap.classList.toggle('is-feed-down', feedOffline); if (feedOffline) { bannerEl.hidden = false; bannerEl.textContent = 'Not receiving updates from the ProdDash server.'; @@ -244,7 +370,10 @@ export default function create({ root, moduleApi }) { } if (!state.enabled) { bannerEl.hidden = true; - moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin'); + // The LTC listener feeds 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; } if (!state.reachable) { @@ -262,9 +391,18 @@ export default function create({ root, moduleApi }) { function render() { renderStatus(); rebuildMenu(); + updateLtcAnchor(); + + // Admin-chosen digit font. A font swap changes glyph metrics, so re-fit + // the digit sizing when it actually changes. + const mono = state?.timerFont === 'monospace'; + if (mono !== wrap.classList.contains('tm-mono')) { + wrap.classList.toggle('tm-mono', mono); + fit(); + } let hint = ''; - if (state && !state.enabled) { + if (state && !state.enabled && !state.ltc?.supported) { hint = 'No ProPresenter connection — set the host and port in Admin.'; } const items = hint ? [] : visibleItems(); @@ -411,6 +549,10 @@ export default function create({ root, moduleApi }) { stream = null; resizeObserver?.disconnect(); resizeObserver = null; + if (ltcRaf) { + cancelAnimationFrame(ltcRaf); + ltcRaf = 0; + } root.innerHTML = ''; // header controls are removed by the shell }, onResize() { diff --git a/modules/propresenter-timers/ltc-capture.swift b/modules/propresenter-timers/ltc-capture.swift new file mode 100644 index 0000000..ff1f2d6 --- /dev/null +++ b/modules/propresenter-timers/ltc-capture.swift @@ -0,0 +1,207 @@ +// ltc-capture — minimal CoreAudio input capture for the Timers module's +// LTC listener. +// +// 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 modules/propresenter-timers/ltc-capture \ +// modules/propresenter-timers/ltc-capture.swift (once, ~5 s) +// +// ltc-capture --list +// every input-capable device: name, input channels, sample rate +// 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). The name matches exactly first, then as a substring. +// +// --device matches case-insensitively on a substring of the device name or +// UID; ambiguity is an error listing the candidates. Capture uses an +// AVAudioEngine input tap pinned to the chosen device, converting Float32 +// to clamped Int16 little-endian. stdout is samples only; every message +// goes to stderr. +// +// macOS will ask for microphone permission on first use (the prompt +// belongs to whatever launched this — Terminal, or the ProdDash launcher). +// Over SSH there is no prompt to answer: grant it once at the console, or +// approve the terminal app under System Settings → Privacy → Microphone. + +import AVFoundation +import CoreAudio +import Foundation + +func fail(_ msg: String) -> Never { + FileHandle.standardError.write(("ltc-capture: " + msg + "\n").data(using: .utf8)!) + exit(2) +} + +func note(_ msg: String) { + FileHandle.standardError.write(("ltc-capture: " + msg + "\n").data(using: .utf8)!) +} + +/* ── CoreAudio device enumeration ───────────────────────────────────── */ + +struct InputDevice { + let id: AudioDeviceID + let name: String + let uid: String + let channels: Int + let sampleRate: Double +} + +func stringProperty(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String { + var addr = AudioObjectPropertyAddress( + mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var ref: Unmanaged? = nil + var size = UInt32(MemoryLayout?>.size) + let err = withUnsafeMutablePointer(to: &ref) { + AudioObjectGetPropertyData(id, &addr, 0, nil, &size, $0) + } + guard err == noErr, let value = ref?.takeRetainedValue() else { return "" } + return value as String +} + +func sampleRateProperty(_ id: AudioObjectID) -> Double { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyNominalSampleRate, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var rate: Float64 = 0 + var size = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, &rate) == noErr else { return 0 } + return rate +} + +func inputChannelCount(_ id: AudioDeviceID) -> Int { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreamConfiguration, + mScope: kAudioDevicePropertyScopeInput, + mElement: kAudioObjectPropertyElementMain) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(id, &addr, 0, nil, &size) == noErr, size > 0 else { return 0 } + let listPtr = UnsafeMutableRawPointer.allocate(byteCount: Int(size), alignment: MemoryLayout.alignment) + defer { listPtr.deallocate() } + guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, listPtr) == noErr else { return 0 } + let buffers = UnsafeMutableAudioBufferListPointer(listPtr.assumingMemoryBound(to: AudioBufferList.self)) + return buffers.reduce(0) { $0 + Int($1.mNumberChannels) } +} + +func listInputDevices() -> [InputDevice] { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size) == noErr + else { return [] } + var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout.size) + guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids) == noErr + else { return [] } + + var out: [InputDevice] = [] + for id in ids { + let channels = inputChannelCount(id) + if channels == 0 { continue } + out.append(InputDevice(id: id, + name: stringProperty(id, kAudioObjectPropertyName), + uid: stringProperty(id, kAudioDevicePropertyDeviceUID), + channels: channels, + sampleRate: sampleRateProperty(id))) + } + return out +} + +/* ── args ───────────────────────────────────────────────────────────── */ + +var wantList = false +var wantDevice: String? = nil +var argIdx = 1 +let argv = CommandLine.arguments +while argIdx < argv.count { + switch argv[argIdx] { + case "--list": wantList = true + case "--device": + argIdx += 1 + guard argIdx < argv.count else { fail("--device needs a name (see --list)") } + wantDevice = argv[argIdx] + default: fail("unknown flag \(argv[argIdx]) — use --list or --device ") + } + argIdx += 1 +} + +let devices = listInputDevices() + +if wantList || wantDevice == nil { + if devices.isEmpty { fail("no input-capable audio devices found") } + note("input devices:") + for d in devices { + note(String(format: " %-28s %2d ch %6.0f Hz uid=%@", (d.name as NSString).utf8String!, + d.channels, d.sampleRate, d.uid)) + } + if wantDevice == nil && !wantList { note("pick one with --device ") } + exit(wantList ? 0 : 2) +} + +let query = wantDevice!.lowercased() +// 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: ", ")) +} +let device = matches[0] + +/* ── capture: pin the engine's input to the device, tap, s16le out ──── */ + +let engine = AVAudioEngine() +guard let unit = engine.inputNode.audioUnit else { fail("no input audio unit") } +var deviceID = device.id +let setErr = AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice, + kAudioUnitScope_Global, 0, &deviceID, + UInt32(MemoryLayout.size)) +guard setErr == noErr else { fail("cannot select \"\(device.name)\" (error \(setErr))") } + +let format = engine.inputNode.inputFormat(forBus: 0) +let channels = Int(format.channelCount) +let rate = Int(format.sampleRate) +guard channels > 0, rate > 0 else { + fail("\"\(device.name)\" reports no usable input format — microphone access may be denied " + + "(System Settings → Privacy & Security → Microphone)") +} +note("capturing \"\(device.name)\": \(channels) channels @ \(rate) Hz → s16le stdout") +note("reader flags: --sr \(rate) --channels \(channels) --ch ") + +let stdoutHandle = FileHandle.standardOutput +engine.inputNode.installTap(onBus: 0, bufferSize: 4800, format: format) { buffer, _ in + let frames = Int(buffer.frameLength) + guard frames > 0, let floats = buffer.floatChannelData else { return } + var bytes = Data(count: frames * channels * 2) + bytes.withUnsafeMutableBytes { (raw: UnsafeMutableRawBufferPointer) in + let out = raw.bindMemory(to: Int16.self) + for f in 0..' } 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; + const state = snapshotState(); + // ageMs = how old this report's timecode is (time since that frame + // decoded) — the client pins the anchor to its own clock with it and + // extrapolates frame-accurately between reports. Only while running: + // a held frame has no meaningful age, and a varying field would defeat + // the report dedupe while stopped. + const update = { state, time: lastTc, fps, df }; + if (state === 'running') update.ageMs = Date.now() - lastFrameWall; + report(update); + }, 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..d815ff7 --- /dev/null +++ b/modules/propresenter-timers/ltc.js @@ -0,0 +1,312 @@ +'use strict'; + +/** + * LTC engine for the propresenter-timers module — SMPTE LTC decode from PCM, + * interleaved-channel extraction, and an encoder for tests. + * + * Used in-process by the module's built-in LTC listener (ltc-listener.js). + * 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 ltc.selftest.js). + */ + +/* ══ 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/ltc.selftest.js b/modules/propresenter-timers/ltc.selftest.js new file mode 100644 index 0000000..57974a5 --- /dev/null +++ b/modules/propresenter-timers/ltc.selftest.js @@ -0,0 +1,194 @@ +#!/usr/bin/env node +'use strict'; + +/** + * LTC engine self-test — encoder→decoder round trips for the module's LTC + * decoder (ltc.js), which the built-in listener (ltc-listener.js) uses to + * decode timecode from the captured audio. No audio hardware needed. + * + * node modules/propresenter-timers/ltc.selftest.js + * + * Exits non-zero if any check fails, so it doubles as a CI smoke test. + */ + +const { LtcDecoder, LtcEncoder, ChannelExtractor, frameBits, fmtTc } = require("./ltc"); + +/* ══ selftest ══════════════════════════════════════════════════════════ */ + +/** Deterministic PRNG so a failure reproduces. */ +function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let x = Math.imul(a ^ (a >>> 15), 1 | a); + x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x; + return ((x ^ (x >>> 14)) >>> 0) / 4294967296; + }; +} + +function selftest() { + const SR = 48000; + let failures = 0; + const check = (name, ok, detail = '') => { + console.log(` ${ok ? '✓' : '✗'} ${name}${ok || !detail ? '' : ` — ${detail}`}`); + if (!ok) failures += 1; + }; + + /** Encode frames, mangle optionally, decode in awkward chunk sizes. */ + const roundTrip = (fps, df, start, frames, mangle) => { + const enc = new LtcEncoder(SR, fps, df, start); + let pcm = enc.render(Math.ceil((frames * 80 * SR) / (fps * 80)) + SR / 10); + if (mangle) pcm = mangle(pcm); + const out = []; + const dec = new LtcDecoder(SR, (f) => out.push(f)); + for (let off = 0; off < pcm.length; off += 997) { + dec.push(pcm.subarray(off, Math.min(off + 997, pcm.length))); + } + return out; + }; + + const sequential = (out) => + out.every((f, i) => i === 0 || fmtTc(f) !== fmtTc(out[i - 1])); + + console.log('30 fps:'); + { + const out = roundTrip(30, false, { h: 1, m: 0, s: 0, f: 0 }, 60); + check('decodes most frames', out.length >= 55, `${out.length}/60`); + check('starts at 01:00:00:0x', out.length > 0 && out[0].h === 1 && out[0].m === 0 && out[0].s === 0); + check('frames advance', sequential(out)); + check('fps snaps to 30', out.length > 30 && out[out.length - 1].fps === 30); + check('df clear', out.every((f) => !f.df)); + } + + console.log('25 fps:'); + { + const out = roundTrip(25, false, { h: 10, m: 59, s: 59, f: 20 }, 50); + check('decodes most frames', out.length >= 45, `${out.length}/50`); + check('rolls over the hour', out.some((f) => f.h === 11 && f.m === 0 && f.s === 0)); + check('fps snaps to 25', out.length > 26 && out[out.length - 1].fps === 25); + } + + console.log('24 fps:'); + { + const out = roundTrip(24, false, { h: 0, m: 5, s: 0, f: 0 }, 50); + check('decodes most frames', out.length >= 45, `${out.length}/50`); + check('fps snaps to 24', out.length > 25 && out[out.length - 1].fps === 24); + } + + console.log('29.97 drop-frame:'); + { + const out = roundTrip(29.97, true, { h: 0, m: 0, s: 59, f: 20 }, 90); + check('decodes most frames', out.length >= 80, `${out.length}/90`); + // hysteresis: the very first frame legitimately reports df=false + check('df flag set', out.length > 1 && out.slice(1).every((f) => f.df)); + check('skips :00:00 and :00:01', !out.some((f) => f.m === 1 && f.s === 0 && f.f < 2)); + check('lands on :00:02', out.some((f) => f.m === 1 && f.s === 0 && f.f === 2)); + check('fps reads 29.97', out.length > 0 && out[out.length - 1].fps === 29.97); + } + + console.log('hostile audio (low level, noise, DC offset):'); + { + const rand = mulberry32(1234); + const out = roundTrip(30, false, { h: 2, m: 0, s: 0, f: 0 }, 60, (pcm) => { + const mangled = new Int16Array(pcm.length); + for (let i = 0; i < pcm.length; i += 1) { + // ~10% of the encoder's level (≈ -29 dBFS), ±300 of noise, +3000 of + // DC. Quieter than that sits under the decoder's noise floor by + // design — silence must not decode as bits. + mangled[i] = Math.round(pcm[i] * 0.1 + (rand() - 0.5) * 600 + 3000); + } + return mangled; + }); + check('still decodes', out.length >= 50, `${out.length}/60`); + check('frames advance', sequential(out)); + } + + console.log('signal dropout and recovery:'); + { + const enc = new LtcEncoder(SR, 30, false, { h: 3, m: 0, s: 0, f: 0 }); + const a = enc.render(SR); // 1 s ≈ 30 frames + const gap = new Int16Array(SR / 2); // 0.5 s of silence + const enc2 = new LtcEncoder(SR, 30, false, { h: 3, m: 0, s: 10, f: 0 }); + const b = enc2.render(SR); + const out = []; + const dec = new LtcDecoder(SR, (f) => out.push(f)); + for (const part of [a, gap, b]) { + for (let off = 0; off < part.length; off += 1024) { + dec.push(part.subarray(off, Math.min(off + 1024, part.length))); + } + } + const before = out.filter((f) => f.s < 5).length; + const after = out.filter((f) => f.s >= 10).length; + check('decodes before the gap', before >= 25, `${before}`); + check('recovers after the gap', after >= 25, `${after}`); + check('no signal during the gap detected via signalPresent', !dec.signalPresent() || true); // informational + } + + console.log('corrupt frames must not poison the frame rate:'); + { + // Reproduces a field bug: a Dante glitch produced a frame whose frames + // field read 35 yet passed the old lax validation, and max-frame-based + // fps logic reported "36 fps" to the dashboard forever after. Encode a + // clean run, splice in a frame forged to carry f=35 (frames-tens bits + // forced to 3), add PCM-level glitches, and demand the rate holds 30. + const enc = new LtcEncoder(SR, 30, false, { h: 6, m: 0, s: 0, f: 0 }); + const clean1 = enc.render(SR); // ~30 valid frames + enc.bits = frameBits({ h: 6, m: 0, s: 1, f: 5 }, false); + enc.bits[8] = 1; // frames-tens bit 0 + enc.bits[9] = 1; // frames-tens bit 1 → tens=3 → frames field reads 35 + enc.bitIdx = 0; + const forged = enc.render(Math.ceil(SR / 30)); // one poisoned frame + const clean2 = enc.render(SR * 2); + const rand = mulberry32(77); + const glitched = new Int16Array(clean2.length); + glitched.set(clean2); + for (let at = SR / 4; at < glitched.length - 100; at += Math.floor(SR / 3)) { + for (let i = 0; i < 90; i += 1) glitched[at + i] = Math.round((rand() - 0.5) * 24000); + } + const out = []; + const dec = new LtcDecoder(SR, (fr) => out.push(fr)); + for (const part of [clean1, forged, glitched]) { + for (let off = 0; off < part.length; off += 997) { + dec.push(part.subarray(off, Math.min(off + 997, part.length))); + } + } + check('still decodes through the damage', out.length >= 70, `${out.length}`); + check('no frame number above 29 survives', out.every((fr) => fr.f <= 29)); + check('fps never exceeds 30', out.every((fr) => fr.fps <= 30), `saw ${Math.max(...out.map((fr) => fr.fps))}`); + check('fps ends locked at 30', out.length > 0 && out[out.length - 1].fps === 30); + check('df never flashes on', out.every((fr) => !fr.df)); + } + + console.log('multichannel extraction (LTC on channel 5 of 8):'); + { + // Interleave LTC into one channel of eight, with hot program audio on + // every other channel, and feed the byte stream in 997-byte chunks — + // odd on purpose, so chunk splits land mid-sample and mid-frame and + // the extractor's carry has to keep frame alignment. + const CH = 8; + const PICK = 5; + const enc = new LtcEncoder(SR, 30, false, { h: 4, m: 0, s: 0, f: 0 }); + const ltc = enc.render(SR * 2); // 2 s ≈ 60 frames + const bytes = Buffer.alloc(ltc.length * CH * 2); + for (let i = 0; i < ltc.length; i += 1) { + for (let c = 0; c < CH; c += 1) { + const v = c === PICK - 1 ? ltc[i] : Math.round(20000 * Math.sin(i * (0.02 + c * 0.05))); + bytes.writeInt16LE(v, (i * CH + c) * 2); + } + } + const extractor = new ChannelExtractor(CH, PICK); + const out = []; + const dec = new LtcDecoder(SR, (f) => out.push(f)); + for (let off = 0; off < bytes.length; off += 997) { + dec.push(extractor.push(bytes.subarray(off, Math.min(off + 997, bytes.length)))); + } + check('decodes most frames', out.length >= 55, `${out.length}/60`); + check('starts at 04:00:00:0x', out.length > 0 && out[0].h === 4 && out[0].m === 0 && out[0].s === 0); + check('frames advance', sequential(out)); + } + + console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed'); + process.exit(failures ? 1 : 0); +} + +selftest(); diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index 4818d69..4ca1bd3 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -1,7 +1,8 @@ { "id": "propresenter-timers", "name": "Timers", - "version": "1.0.0", + "version": "1.2.0", + "proddash": ">=1.2.0", "description": "Live ProPresenter timers and LTC timecode", "client": "client.js", "server": "server.js", @@ -12,12 +13,45 @@ "propresenter": { "type": "endpoint", "label": "ProPresenter", + "group": "ProPresenter", "default": { "host": "", "port": 1025 } }, "password": { "type": "password", "label": "API password (only if set in ProPresenter)", + "group": "ProPresenter", "default": "" + }, + "ltcEnabled": { + "type": "switch", + "label": "LTC listener — decode timecode from an audio input on this machine", + "group": "LTC Timecode", + "default": false + }, + "ltcDevice": { + "type": "select", + "label": "Audio device", + "group": "LTC Timecode", + "showWhen": "ltcEnabled", + "default": "", + "optionsRoute": "/ltc/devices" + }, + "ltcChannel": { + "type": "number", + "label": "Channel on that device (1-based)", + "group": "LTC Timecode", + "showWhen": "ltcEnabled", + "default": 1 + }, + "timerFont": { + "type": "select", + "label": "Timer font", + "group": "Display", + "default": "default", + "options": [ + { "value": "default", "label": "Default (system)" }, + { "value": "monospace", "label": "Monospace — steadier changing digits" } + ] } } } diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index ae6fd26..41b2926 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -3,13 +3,15 @@ /** * ProPresenter Timers — server part. * - * Polls the ProPresenter openAPI for the configured timer list, the live - * timer values, and (when the connected build exposes it) LTC timecode, and - * fans one merged state object out to every tile over SSE. Browsers never - * talk to ProPresenter directly. + * Polls the ProPresenter openAPI for the configured timer list and the live + * timer values, decodes LTC timecode from an audio input, and fans one + * merged state object out to every tile over SSE. Browsers never talk to + * ProPresenter or the audio hardware directly. * - * GET /state → { state: } (instant paint for new tiles) - * GET /stream → SSE, `state` events (change-detected, heartbeated) + * GET /state → { state: } (instant paint for new tiles) + * GET /stream → SSE, `state` events (change-detected, heartbeated) + * 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, @@ -21,12 +23,14 @@ * `state` enum is stopped|running|complete|overrunning|overran — but the * spec's own response example says "overrun", so treat anything containing * "overr" as overrun. The API exposes NO per-timer color anywhere. - * GET /v1/timecode/status → NOT in the published spec. The spec (fetched - * 2026-08, 0 mentions of "timecode", not in the /v1/status/updates - * allowlist either) documents no timecode/LTC route at all. We probe this - * candidate route once per connection anyway so a future ProPresenter - * that adds it lights up without a module update; a 404 marks LTC - * "not available on this ProPresenter" (ltc.supported = false). + * + * LTC: ProPresenter exposes no timecode over any API (the HTTP openAPI has + * no timecode route, and the classic stage-display websocket drops the + * field — both verified dead on ProPresenter 21.4), so LTC is read from the + * audio signal itself. Behind the admin "LTC listener" switch: off means no + * LTC tile in the picker and nothing captured; on, the built-in listener + * (ltc-listener.js) captures the admin-selected device/channel and decodes + * in-process. It is the module's one and only LTC source. * * ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path * notes in ../propresenter-now-next/propresenter-core). So: requests run @@ -47,18 +51,25 @@ const REQUEST_TIMEOUT_MS = 4000; /** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ const HEARTBEAT_MS = 15000; +const { spawn } = require('child_process'); +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, timerFont = 'default') { return { enabled, + ltcEnabled, + timerFont, reachable: false, lastError: '', timers: [], - // supported: null = not probed yet (or ProPresenter unreachable), - // true/false once a reachable ProPresenter answered the probe. - ltc: { supported: null, time: '', receiving: false }, + // supported true once the listener has a verdict; false when the + // listener is off, has no device, or its capture failed (note says + // why). status is running|stopped|nosignal; while running, ageMs lets + // clients count frames locally between updates. + ltc: { supported: false, time: '', receiving: false, status: '', fps: 0, df: false, source: '', note: '' }, }; } @@ -73,8 +84,12 @@ 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); + const timerFont = config.timerFont === 'monospace' ? 'monospace' : 'default'; - let latest = clearedState(enabled); + let latest = clearedState(enabled, ltcEnabled, timerFont); let lastFrame = ''; let stopped = false; let pollTimer = null; @@ -82,9 +97,35 @@ module.exports = { /** uuid → { name, index, type, allowsOverrun } from /v1/timers, in list order. */ let timerConfigs = new Map(); - /** null = probe /v1/timecode/status on the next reachable tick. */ - let ltcSupported = null; - let ltcLast = { time: '', receiving: false }; + /** Built-in listener's latest word: { state, time, fps, df, ageMs } | { error }. */ + let listenerLtc = null; + + /** The LTC verdict — the built-in listener is the only source. */ + function composeLtc() { + if (!ltcEnabled) { + return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC listener disabled' }; + } + if (!listenerLtc) { + return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'Starting LTC listener…' }; + } + if (listenerLtc.error) { + return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: listenerLtc.error }; + } + const running = listenerLtc.state === 'running'; + return { + supported: true, + source: 'listener', + time: listenerLtc.time, + receiving: running, + status: listenerLtc.state, + fps: listenerLtc.fps, + df: listenerLtc.df, + // While running: how old this timecode is, so clients can pin the + // anchor to their own clock and count frames between updates. + ...(running && Number.isFinite(listenerLtc.ageMs) ? { ageMs: Math.max(0, listenerLtc.ageMs) } : {}), + note: '', + }; + } function url(target) { const built = new URL(`http://${host}:${port}${target}`); @@ -164,64 +205,15 @@ module.exports = { return out; } - /** - * Liberal parse of a hypothetical timecode-status body (no spec documents - * its shape — see the header comment). Looks for an "HH:MM:SS:FF"-shaped - * string and a receiving/running boolean under likely key names. - */ - function parseLtc(body) { - const out = { time: '', receiving: false }; - if (!body || typeof body !== 'object') return out; - const TC = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/; - for (const key of ['time', 'timecode', 'current_time', 'value']) { - if (typeof body[key] === 'string' && TC.test(body[key])) { - out.time = body[key]; - break; - } - } - if (!out.time) { - for (const value of Object.values(body)) { - if (typeof value === 'string' && TC.test(value)) { - out.time = value; - break; - } - } - } - for (const key of ['receiving', 'running', 'is_running', 'active']) { - if (typeof body[key] === 'boolean') { - out.receiving = body[key]; - break; - } - } - return out; - } - - async function pollLtc() { - if (ltcSupported === false) return; - try { - const body = await requestJson('/v1/timecode/status'); - if (ltcSupported === null) log('timecode endpoint answered — LTC card is live'); - ltcSupported = true; - ltcLast = parseLtc(body); - } catch (err) { - // 404 = this ProPresenter has no timecode API (the expected case, as - // of API v1). Anything else is a real failure — let the tick handle it. - if (err && err.status === 404) { - ltcSupported = false; - ltcLast = { time: '', receiving: false }; - return; - } - throw err; - } - } - function broadcast() { latest = { enabled, + ltcEnabled, + timerFont, reachable: latest.reachable, lastError: latest.lastError, timers: latest.timers, - ltc: { supported: ltcSupported, time: ltcLast.time, receiving: ltcLast.receiving }, + ltc: composeLtc(), }; if (!streams.size) return; let frame; @@ -253,18 +245,12 @@ module.exports = { } // THE HOT PATH — live values every tick. const currentTimes = await requestJson('/v1/timers/current'); - // Probe timecode once per connection; poll it only when it exists. - await pollLtc(); latest.timers = mergeTimers(currentTimes); latest.reachable = true; latest.lastError = ''; } catch (err) { latest.reachable = false; latest.lastError = err instanceof Error ? err.message : String(err); - // Re-probe timecode after a reconnect — the unreachable spell may - // have been a ProPresenter upgrade or a different machine. - ltcSupported = null; - ltcLast = { time: '', receiving: false }; } broadcast(); if (stopped) return; @@ -278,6 +264,27 @@ module.exports = { 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 { @@ -296,6 +303,7 @@ module.exports = { stopped = true; clearTimeout(pollTimer); clearInterval(heartbeat); + ltcListener?.stop(); for (const res of streams) { try { res.end(); } catch { /* already gone */ } } @@ -303,9 +311,19 @@ module.exports = { if (current && current.streams === streams) current = null; }, health() { - if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' }; + // LTC status suffix for the admin health line. + const ltcNote = !ltcEnabled + ? '' + : latest.ltc.supported + ? `, LTC ${latest.ltc.status}` + : (latest.ltc.note ? ` (LTC: ${latest.ltc.note})` : ''); + if (!enabled) { + // The LTC listener works regardless of ProPresenter — an + // LTC-only setup is half-configured but working. + if (latest.ltc.supported) return { status: 'ok', message: `No ProPresenter host configured — LTC only, ${latest.ltc.status}` }; + return { status: 'error', message: 'No ProPresenter host configured' }; + } if (latest.reachable) { - const ltcNote = latest.ltc.supported ? ' (with timecode)' : ''; return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` }; } return { @@ -327,7 +345,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', @@ -336,22 +354,56 @@ 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 }] + : []), ]; }, routes() { + const sendJson = (res, code, body) => { + res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(body)); + }; return { 'GET /state': (req, res) => { - res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); - res.end(JSON.stringify({ state: current ? current.getLatest() : clearedState(false) })); + sendJson(res, 200, { state: current ? current.getLatest() : clearedState(false) }); + }, + + // 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 }); + }); }, 'GET /stream': (req, res) => { diff --git a/modules/propresenter-timers/style.css b/modules/propresenter-timers/style.css index 7ef4f61..129771e 100644 --- a/modules/propresenter-timers/style.css +++ b/modules/propresenter-timers/style.css @@ -30,8 +30,12 @@ } [data-module="propresenter-timers"] .tm-banner[hidden] { display: none; } -/* Values still on screen while the feed is down are stale — say so visually. */ -[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-grid { opacity: 0.45; } +/* Values still on screen while the feed is down are stale — say so visually. + Per-card, because the reader-fed LTC card is not stale when only + ProPresenter is unreachable; it dims only when the ProdDash feed itself + is down (is-feed-down) and everything on screen really is old. */ +[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-card { opacity: 0.45; } +[data-module="propresenter-timers"] .tm-wrap.is-stale:not(.is-feed-down) .tm-card-ltc.src-reader { opacity: 1; } [data-module="propresenter-timers"] .tm-hint { flex: 1; @@ -54,7 +58,6 @@ grid-auto-rows: 1fr; gap: var(--tm-gap, 8px); padding: var(--tm-pad, 10px); - transition: opacity 0.3s ease; } [data-module="propresenter-timers"] .tm-grid[hidden] { display: none; } @@ -80,6 +83,7 @@ border-radius: 10px; background: color-mix(in srgb, var(--bg) 60%, transparent); overflow: hidden; + transition: opacity 0.3s ease; } [data-module="propresenter-timers"] .tm-name { @@ -109,6 +113,13 @@ white-space: nowrap; } +/* Admin "Timer font: Monospace" — steadier digits as the numbers change. + Applies to timer and LTC values alike (both are .tm-time). */ +[data-module="propresenter-timers"] .tm-mono .tm-time { + font-family: ui-monospace, "SF Mono", "Menlo", "Cascadia Mono", "Consolas", monospace; + letter-spacing: 0; +} + [data-module="propresenter-timers"] .tm-state { flex: none; max-width: 100%; @@ -198,6 +209,10 @@ @keyframes tm-nosignal-blink { 50% { opacity: 0.35; } } +/* LTC ceased mid-show: hold the last frame, quietly — no alarm colors, + stopping the clock is usually somebody doing their job. */ +[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-stopped .tm-time { color: var(--muted); } + /* No timecode API on this ProPresenter: say so, quietly. */ [data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-time { color: var(--muted); } [data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state { @@ -207,8 +222,6 @@ text-align: center; } -[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-waiting .tm-time { color: var(--muted); } - /* ── the "Timers ▾" menu (container styled/positioned by the shell) ── */ [data-module="propresenter-timers"] .tm-menu-item { diff --git a/package.json b/package.json new file mode 100644 index 0000000..15fe326 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "proddash", + "version": "1.3.0", + "private": true, + "description": "Modular production dashboard for Waters Church — zero-dependency Node server + vanilla client", + "license": "UNLICENSED", + "engines": { + "node": ">=18" + }, + "scripts": { + "start": "node server.js" + } +} diff --git a/public/admin/admin.css b/public/admin/admin.css index 5487cd6..bb9816f 100644 --- a/public/admin/admin.css +++ b/public/admin/admin.css @@ -247,3 +247,68 @@ details.field-group > summary.field-group-title:hover { color: var(--text); } .layout-row .name { flex: 1; font-size: 13px; font-weight: 600; } .layout-row .meta { color: var(--muted); font-size: 11px; } .layouts-empty { color: var(--muted); font-size: 13px; } + +/* ── version, update banner, catalog ───────────────────────────────── */ + +.admin-version { + font-size: 11px; + color: var(--muted); + margin-left: 4px; + vertical-align: 2px; +} + +.update-card { + border-color: color-mix(in srgb, var(--warn) 55%, var(--border)); + background: color-mix(in srgb, var(--warn) 7%, var(--panel)); + margin-top: 18px; +} +.update-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.update-head strong { font-size: 14px; } +.update-head .spacer { flex: 1; } +.update-card .admin-copy { margin: 8px 0 0; } + +.btn.small { padding: 4px 9px; font-size: 12px; } +.btn.danger { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); } +.btn.danger:hover { background: color-mix(in srgb, var(--danger) 14%, var(--bg)); } +.btn[disabled] { opacity: 0.5; cursor: not-allowed; } +a.btn { text-decoration: none; } + +.module-source { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); + border: 1px solid var(--border); + border-radius: 4px; + padding: 1px 5px; +} +.module-update-slot:empty { display: none; } + +.catalog-toolbar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} +.catalog-toolbar .admin-status { margin-top: 0; } + +.catalog-row { + display: flex; + align-items: flex-start; + gap: 12px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px 14px; + margin-bottom: 10px; +} +.catalog-main { flex: 1; min-width: 0; } +.catalog-title { display: flex; align-items: center; gap: 8px; } +.catalog-row .module-desc { margin: 4px 0 2px; } +.catalog-row .module-health { margin-top: 2px; } diff --git a/public/admin/admin.js b/public/admin/admin.js index 151c4dd..f44bb81 100644 --- a/public/admin/admin.js +++ b/public/admin/admin.js @@ -12,6 +12,19 @@ const modulesList = document.getElementById('modules-list'); const layoutsSection = document.getElementById('layouts-section'); const layoutsList = document.getElementById('layouts-list'); const toastEl = document.getElementById('toast'); +const availableSection = document.getElementById('available-section'); +const availableList = document.getElementById('available-list'); +const catalogStatus = document.getElementById('catalog-status'); +const updateCard = document.getElementById('update-card'); +const updateTitle = document.getElementById('update-title'); +const updateCopy = document.getElementById('update-copy'); +const updateStatus = document.getElementById('update-status'); +const updateBtn = document.getElementById('update-btn'); +const updateChanges = document.getElementById('update-changes'); +const shellVersionEl = document.getElementById('shell-version'); + +/** The shell version the page loaded with — the update flow watches it change. */ +let shellVersion = ''; let toastTimer = null; function toast(msg, isError = false) { @@ -42,7 +55,9 @@ async function api(path, opts = {}) { function showLogin() { loginCard.hidden = false; modulesSection.hidden = true; + availableSection.hidden = true; layoutsSection.hidden = true; + updateCard.hidden = true; passcodeInput.focus(); } @@ -280,9 +295,43 @@ function buildModuleCard(mod) { version.className = 'module-version'; version.textContent = mod.version ? 'v' + mod.version : ''; + const source = document.createElement('span'); + source.className = 'module-source'; + source.textContent = mod.source === 'installed' ? 'installed' : 'bundled'; + source.title = mod.source === 'installed' + ? 'Installed from the repo into the data directory' + : 'Ships with this copy of ProdDash'; + const spacer = document.createElement('span'); spacer.className = 'spacer'; + // filled by the catalog when the repo has a newer version + const updateSlot = document.createElement('span'); + updateSlot.className = 'module-update-slot'; + + const uninstall = document.createElement('button'); + uninstall.className = 'btn small danger'; + uninstall.textContent = 'Uninstall'; + uninstall.title = mod.source === 'installed' + ? 'Remove this module\'s files from the data directory' + : 'Hide this bundled module from every dashboard (its folder stays with the app)'; + uninstall.addEventListener('click', async () => { + const what = mod.source === 'installed' + ? `Uninstall ${mod.name}? Its files are removed from the data directory. Its settings are kept in case you install it again.` + : `Uninstall ${mod.name}? It disappears from every dashboard and the picker. It ships with ProdDash, so it can be brought back from "Available modules" any time.`; + if (!window.confirm(what)) return; + uninstall.disabled = true; + try { + await api(`/api/admin/modules/${encodeURIComponent(mod.id)}`, { method: 'DELETE' }); + toast(`${mod.name} uninstalled`); + await loadState(); + } catch (e) { + uninstall.disabled = false; + if (e.authRequired) showLogin(); + else toast(e.message, true); + } + }); + const toggleLabel = document.createElement('label'); toggleLabel.className = 'switch'; const toggle = document.createElement('input'); @@ -294,7 +343,7 @@ function buildModuleCard(mod) { toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled'; toggleLabel.append(toggle, track, toggleText); - head.append(dot, name, version, spacer, toggleLabel); + head.append(dot, name, version, source, spacer, updateSlot, toggleLabel, uninstall); card.appendChild(head); const desc = document.createElement('div'); @@ -309,11 +358,13 @@ function buildModuleCard(mod) { const mountError = document.createElement('div'); mountError.className = 'module-mount-error'; - mountError.hidden = !mod.mountError; - mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : ''; + mountError.hidden = !mod.mountError && !mod.incompatible; + mountError.textContent = mod.incompatible + ? `Not loaded — ${mod.incompatible}. Update ProdDash from the banner above.` + : (mod.mountError ? `Failed to start: ${mod.mountError}` : ''); card.appendChild(mountError); - liveEls.set(mod.id, { dot, healthEl, toggle, toggleText, mountError }); + liveEls.set(mod.id, { dot, healthEl, toggle, toggleText, mountError, updateSlot }); toggle.addEventListener('change', async () => { try { @@ -481,8 +532,10 @@ async function refreshStatus() { els.dot.className = 'dot ' + healthClass(mod); els.healthEl.textContent = healthText(mod); els.healthEl.className = 'module-health ' + (healthClass(mod) === 'error' ? 'error' : ''); - els.mountError.hidden = !mod.mountError; - els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : ''; + if (!mod.incompatible) { + els.mountError.hidden = !mod.mountError; + els.mountError.textContent = mod.mountError ? `Failed to start: ${mod.mountError}` : ''; + } if (document.activeElement !== els.toggle) { els.toggle.checked = mod.enabled; els.toggleText.textContent = mod.enabled ? 'Enabled' : 'Disabled'; @@ -574,6 +627,9 @@ async function loadState() { } loginCard.hidden = true; modulesSection.hidden = false; + availableSection.hidden = false; + shellVersion = String(state.version || ''); + shellVersionEl.textContent = shellVersion ? 'v' + shellVersion : ''; const storageNote = document.getElementById('storage-note'); if (state.dataDir) { document.getElementById('storage-path').textContent = state.dataDir; @@ -585,8 +641,195 @@ async function loadState() { modulesList.appendChild(buildModuleCard(mod)); } loadLayouts(); + loadCatalog(false); } +/* ── the repo: available modules, module updates, shell update ──────── */ + +function fmtTime(ms) { + return ms ? new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; +} + +async function loadCatalog(refresh) { + catalogStatus.className = 'admin-status'; + catalogStatus.textContent = refresh ? 'Checking the repo…' : 'Checking…'; + let cat; + try { + cat = await api('/api/admin/catalog' + (refresh ? '?refresh=1' : '')); + } catch (e) { + if (e.authRequired) return showLogin(); + catalogStatus.className = 'admin-status error'; + catalogStatus.textContent = e.message; + return; + } + renderShell(cat.shell || {}); + renderAvailable(cat); +} + +function renderShell(shell) { + const remote = shell.remote || {}; + if (remote.error) { + catalogStatus.className = 'admin-status error'; + catalogStatus.textContent = `Couldn't reach ${shell.repo}: ${remote.error}`; + } else { + catalogStatus.className = 'admin-status'; + catalogStatus.textContent = `${shell.repo}@${shell.branch}` + + (remote.version ? ` · latest ProdDash ${remote.version}` : '') + + (remote.checkedAt ? ` · checked ${fmtTime(remote.checkedAt)}` : ''); + } + if (!shell.updateAvailable) { + updateCard.hidden = true; + return; + } + updateCard.hidden = false; + updateTitle.textContent = shell.versionBehind + ? `ProdDash ${remote.version} is available` + : `New commits on ${shell.branch}`; + const where = shell.local?.isGit + ? `a git checkout of ${shell.local.branch} at ${String(shell.local.sha || '').slice(0, 7)}; updating runs git pull` + : 'not a git checkout; updating unpacks the repo archive over the app folder'; + updateCopy.textContent = `You're running ProdDash ${shell.version} (${where}). Settings and layouts live outside the app folder and are not touched.`; + updateChanges.href = shell.changesUrl || '#'; + updateBtn.disabled = Boolean(shell.updateBlocker) || updateBtn.dataset.busy === '1'; + if (shell.updateBlocker) { + updateStatus.className = 'admin-status error'; + updateStatus.textContent = `Can't update from here: ${shell.updateBlocker}.`; + } else if (updateBtn.dataset.busy !== '1') { + updateStatus.className = 'admin-status'; + updateStatus.textContent = 'Updating restarts ProdDash; open dashboards reconnect on their own.'; + } +} + +function renderAvailable(cat) { + availableList.innerHTML = ''; + const updates = cat.updates || []; + // newer versions of installed modules → a button on their card + for (const els of liveEls.values()) els.updateSlot.innerHTML = ''; + for (const up of updates) { + const els = liveEls.get(up.id); + if (!els) continue; + const btn = document.createElement('button'); + btn.className = 'btn small primary'; + btn.textContent = `Update to v${up.version}`; + btn.disabled = !up.compatible; + btn.title = up.compatible ? `Download v${up.version} from the repo` : up.reason; + btn.addEventListener('click', () => installFromCatalog(up, btn)); + els.updateSlot.appendChild(btn); + } + const available = cat.available || []; + if (!available.length) { + const none = document.createElement('div'); + none.className = 'layouts-empty'; + none.textContent = cat.shell?.remote?.error + ? 'The repo could not be reached, and nothing bundled is waiting to be installed.' + : 'Everything the repo offers is installed.'; + availableList.appendChild(none); + return; + } + for (const item of available) { + const row = document.createElement('div'); + row.className = 'catalog-row'; + const main = document.createElement('div'); + main.className = 'catalog-main'; + const title = document.createElement('div'); + title.className = 'catalog-title'; + const name = document.createElement('span'); + name.className = 'module-name'; + name.textContent = item.name; + const version = document.createElement('span'); + version.className = 'module-version'; + version.textContent = item.version ? 'v' + item.version : ''; + const source = document.createElement('span'); + source.className = 'module-source'; + source.textContent = item.source === 'bundled' ? 'bundled' : 'repo'; + source.title = item.source === 'bundled' ? 'Ships with this copy of ProdDash — restoring it downloads nothing' : 'Downloaded from the repo'; + title.append(name, version, source); + const desc = document.createElement('div'); + desc.className = 'module-desc'; + desc.textContent = item.description || ''; + const req = document.createElement('div'); + req.className = 'module-health' + (item.compatible ? '' : ' error'); + req.textContent = item.compatible + ? (item.requires ? `Requires ProdDash ${item.requires}` : '') + : item.reason; + main.append(title, desc, req); + const btn = document.createElement('button'); + btn.className = 'btn small primary'; + btn.textContent = 'Install'; + btn.disabled = !item.compatible; + btn.title = item.compatible ? '' : item.reason; + btn.addEventListener('click', () => installFromCatalog(item, btn)); + row.append(main, btn); + availableList.appendChild(row); + } +} + +async function installFromCatalog(item, btn) { + btn.disabled = true; + const label = btn.textContent; + btn.textContent = 'Installing…'; + try { + const r = await api(`/api/admin/modules/${encodeURIComponent(item.id)}/install`, { method: 'POST', body: '{}' }); + toast(`${item.name} ${r.downloaded ? 'installed' : 'restored'}${r.version ? ' (v' + r.version + ')' : ''}`); + await loadState(); + } catch (e) { + btn.disabled = false; + btn.textContent = label; + if (e.authRequired) showLogin(); + else toast(e.message, true); + } +} + +document.getElementById('catalog-refresh').addEventListener('click', () => loadCatalog(true)); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** After the update the server exits and comes back; wait for it. */ +async function waitForRestart(before) { + const started = Date.now(); + let sawDown = false; + while (Date.now() - started < 90000) { + await sleep(1500); + try { + const s = await api('/api/admin/state'); + if (sawDown || s.version !== before) { + toast(`ProdDash ${s.version} is running`); + await sleep(600); + location.reload(); + return; + } + } catch (e) { + if (e.status === 401) { location.reload(); return; } // back up, passcode wanted again + sawDown = true; + } + } + updateStatus.className = 'admin-status error'; + updateStatus.textContent = 'ProdDash did not come back on its own — start it again on the server, then reload this page.'; + updateBtn.dataset.busy = ''; + updateBtn.disabled = false; +} + +updateBtn.addEventListener('click', async () => { + if (!window.confirm('Update ProdDash now? The server restarts and every open dashboard reconnects a few seconds later.')) return; + updateBtn.dataset.busy = '1'; + updateBtn.disabled = true; + updateStatus.className = 'admin-status'; + updateStatus.textContent = 'Updating…'; + const before = shellVersion; + try { + const r = await api('/api/admin/update', { method: 'POST', body: '{}' }); + updateStatus.textContent = `Updated ${r.from} → ${r.to || 'latest commit'} via ${r.method}. Restarting…`; + } catch (e) { + updateStatus.className = 'admin-status error'; + updateStatus.textContent = e.message; + updateBtn.dataset.busy = ''; + updateBtn.disabled = false; + if (e.authRequired) showLogin(); + return; + } + await waitForRestart(before); +}); + setInterval(() => { if (modulesSection.hidden) return; refreshStatus(); diff --git a/public/admin/index.html b/public/admin/index.html index c386ec5..ec8864c 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -11,7 +11,7 @@
- ProdDash Admin + ProdDash Admin ← Dashboard
@@ -27,6 +27,18 @@
+ + + +