Compare commits

..
Author SHA1 Message Date
michael 58f3e5010a Merge pull request 'SPL Meter 0.4.2: the device picker can build the tool it needs' (#18) from claude/spl-devices-without-binary into main 2026-09-28 11:30:36 -04:00
michael 7cc19ef696 Merge pull request 'Timers 1.6.0: the ProPresenter video countdown, with looping videos hidden' (#33) from claude/timers-video-countdown into main 2026-09-27 11:35:52 -04:00
Waters ChurchandClaude Fable 5 86a3270f1c Timers 1.6.0: the ProPresenter video countdown, with looping videos hidden
Time left on the video ProPresenter is playing — the one number a booth
needs during a bumper or sermon video — was missing: the module only read
the configured timers, and the video countdown lives in the transport
routes. Verified live on ProPresenter 21.4:
  GET /v1/transport/presentation/current → { is_playing, uuid, name,
    artist, audio_only, duration }
  GET /v1/transport/presentation/time    → bare seconds as a sub-second
    float — better than /v1/timer/video_countdown's whole-second string,
    so no quantization phase lock is needed, only an anchor hold (a frame
    is broadcast only when the anchor drifts, not per poll).

The Video card rides the ProPresenter source: transport current on the
slow lane, position on the hot path (skipped while no media is loaded),
running/paused/idle from is_playing and position, the media name (minus
its file extension) as the detail line. It persists as an idle '—'
between videos — a wall tile must not appear and vanish with the media —
and is absent only on a ProPresenter whose transport routes 404.

No looping flag exists anywhere in the API, so looping is inferred: the
same media's position jumping backwards is a wrap, and the flag sticks
until the media changes. Each tile gets a 'Looping videos' switch
(default off): off, a looping background's meaningless time is hidden —
the card sits out of a multi-card tile, and a solo Video tile shows a
quiet 'Looping' card instead of sitting empty. Shown loops never take
the warn/overrun tones: a 15 s background flashing red every wrap would
cry wolf all service.

The picker offers 'Video' as its own tile (solo gear carries the loop
switch); pp-timers-mock gains the transport routes with a 4-minute cycle
(bumper playing → paused → resumed → nothing → looping background).
Verified in the browser across the whole cycle, both tile kinds, and the
per-tile toggle. Selftests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 11:32:36 -04:00
michael b3b7b29007 Merge pull request 'Timers 1.5.1: a failed capture-tool build must not park the listener forever' (#30) from claude/timers-build-retry into main 2026-09-27 10:59:17 -04:00
Waters ChurchandClaude Fable 5 62c34fe80c Timers 1.5.1: a failed capture-tool build must not park the listener forever
Found live on AB: the build failed (its CLT is broken), the compiled
binary was then copied in by hand — and the listener never noticed. It
had seen 'build in flight' and stopped scheduling retries, waiting for
ensureCaptureBinary's onReady, which only fires on SUCCESS. The tile said
'Capture tool building…' indefinitely with a working binary on disk; only
a module remount recovered it.

onReady stays as the fast path; the 30 s retry now stays armed in every
not-ready state, so a failed build keeps re-checking and a binary that
arrives from outside (scp'd to a machine that can't compile) is picked up
within half a minute.

Reproduced the exact shape in a swiftc-less environment: build fails,
binary copied in at 3 s, retry at 30 s spawns it (the capture's own
unknown-device answer proves the real binary ran) — previously a
permanent deadlock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 10:55:44 -04:00
michael 4c6a2b8f76 Merge pull request 'Shell 1.9.2: a module update keeps per-machine executables' (#29) from claude/shell-keep-executables into main 2026-09-27 10:49:07 -04:00
Waters ChurchandClaude Opus 5 9313c9e42a SPL Meter 0.4.2: the device picker can build the tool it needs
NA could not list a single audio interface. The module was installed from
the catalog, which ships source only because the capture binary is
gitignored, and the picker shells out to that binary with --list. So it
returned nothing, forever:

  [spl-meter] device list failed: spawn .../spl-capture ENOENT

The missing-binary build existed, but only inside the listener, and the
listener only starts once a device has been picked. No tool meant no device
list, and no device meant no listener to build the tool. Nothing was going
to break that on its own.

The build now lives at module scope and both callers use it: init() asks for
it on mount whether or not a device is configured, and /devices asks again
each time it is called, so opening the admin page is enough to start it. It
is still a plain spawn that never touches the event loop, and health() now
names the situation instead of leaving an empty dropdown unexplained.

Also removed two per-listener build flags left shadowing the module-scope
ones, which would have made the "is a build already running" test always
false and let a second build start under the first.

Tested the way it actually failed: a data directory holding only the six
source files, no binary. The build starts on mount and /devices answers with
the real device list moments later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:25:43 -04:00
9 changed files with 301 additions and 71 deletions
+5 -3
View File
@@ -303,9 +303,11 @@ below. Nothing is installed without someone clicking.
status, NOW and NEXT labels, slide count, progress bar, the next list and status, NOW and NEXT labels, slide count, progress bar, the next list and
its item tags, plus tidy titles. its item tags, plus tidy titles.
- **Timers** — live ProPresenter timers (countdown, countdown-to-time, - **Timers** — live ProPresenter timers (countdown, countdown-to-time,
elapsed) and LTC timecode as auto-sized cards. The + picker lists every elapsed), the **video countdown** (time left on what ProPresenter is
discovered timer (and the LTC card) as its own tile, or add "All timers" playing, with a per-tile "Looping videos" switch so looping backgrounds
and pick a selection via the tile's "Timers ▾" menu; overruns are stay hidden) 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 is in unmistakably red. Read-only — no start/stop controls. Admin config is in
three groups: **ProPresenter** (endpoint host : port, API password), three groups: **ProPresenter** (endpoint host : port, API password),
**LTC Timecode** (the listener switch, and — when it's on — the audio **LTC Timecode** (the listener switch, and — when it's on — the audio
+15 -3
View File
@@ -70,6 +70,7 @@ const DEFAULTS = {
showDetail: true, showDetail: true,
showSeconds: true, showSeconds: true,
leadingZeros: false, leadingZeros: false,
showLoops: false,
clock24h: true, clock24h: true,
clockSeconds: true, clockSeconds: true,
clockDate: false, clockDate: false,
@@ -384,7 +385,12 @@ export default function create({ root, moduleApi }) {
for (const source of state.sources) { for (const source of state.sources) {
for (const timer of source.timers) { for (const timer of source.timers) {
if (keyOf(source, timer) !== solo) continue; if (keyOf(source, timer) !== solo) continue;
return [{ id: source.id, source, items: [{ key: solo, source, timer }] }]; // A solo video tile with looping media hidden must not sit empty:
// it shows a quiet card instead of a countdown nobody should read.
const shown = timer.looping && pref('showLoops') !== true
? { ...timer, state: 'idle', remainingMs: null, elapsedMs: null, startedAt: null, asOf: null, status: { text: 'Looping', tone: 'muted' } }
: timer;
return [{ id: source.id, source, items: [{ key: solo, source, timer: shown }] }];
} }
} }
// The timer this tile was added for is not offered right now. Nothing is // The timer this tile was added for is not offered right now. Nothing is
@@ -398,7 +404,11 @@ export default function create({ root, moduleApi }) {
const items = []; const items = [];
for (const timer of source.timers) { for (const timer of source.timers) {
const key = keyOf(source, timer); const key = keyOf(source, timer);
if (!hidden.has(key)) items.push({ key, source, timer }); if (hidden.has(key)) continue;
// A looping background's time means nothing — the card sits out
// while a loop plays (per tile: the "Looping videos" switch).
if (timer.looping && pref('showLoops') !== true) continue;
items.push({ key, source, timer });
} }
// Every timer of a live source hidden on purpose: nothing to say. // Every timer of a live source hidden on purpose: nothing to say.
if (!items.length && source.timers.length) continue; if (!items.length && source.timers.length) continue;
@@ -666,7 +676,9 @@ export default function create({ root, moduleApi }) {
card.mono = null; card.mono = null;
} }
text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' }); text = value === null ? '—' : fmtDuration(value, { ...digits, up: timer.kind === 'countdown' });
tone = toneFor(timer, value); // A shown loop never warns or overruns — it wraps at zero by design,
// and a background flashing red every pass would cry wolf all service.
tone = timer.looping ? (timer.state === 'running' ? 'ok' : 'muted') : toneFor(timer, value);
statusText = timer.status?.text || defaultStatus(timer, value, tone); statusText = timer.status?.text || defaultStatus(timer, value, tone);
statusTone = timer.status?.tone || tone; statusTone = timer.status?.tone || tone;
detailText = timer.detail || ''; detailText = timer.detail || '';
+7 -3
View File
@@ -191,9 +191,13 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
}); });
if (!tool.ready) { if (!tool.ready) {
report({ error: tool.error, detail: tool.detail }); report({ error: tool.error, detail: tool.detail });
// A build in flight restarts us itself when it lands; anything else is // onReady is only the FAST path (a successful build restarts us at
// config-shaped and should back off hard rather than spin. // once). The slow retry must stay armed regardless: a failed build
if (tool.error !== TOOL_BUILDING) scheduleRestart(BAD_CONFIG_RETRY_MS); // never calls back, and a binary can also arrive from outside — scp'd
// in on a machine whose toolchain can't build (AB's broken CLT).
// Waiting on onReady alone deadlocked exactly that way in production:
// "Capture tool building…" forever, with a working binary on disk.
scheduleRestart(BAD_CONFIG_RETRY_MS);
return; return;
} }
+7 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "propresenter-timers", "id": "propresenter-timers",
"name": "Timers", "name": "Timers",
"version": "1.5.0", "version": "1.6.0",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Timers, Clocks, and LTC From configured Modules", "description": "Timers, Clocks, and LTC From configured Modules",
"provides": [ "provides": [
@@ -161,6 +161,12 @@
"default": false, "default": false,
"group": "Show" "group": "Show"
}, },
"showLoops": {
"type": "switch",
"label": "Looping videos",
"default": false,
"group": "Show"
},
"showHeadings": { "showHeadings": {
"type": "switch", "type": "switch",
"label": "Source headings", "label": "Source headings",
+137
View File
@@ -255,6 +255,21 @@ function createProPresenterSource({ config, log }) {
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */ /** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
const phases = new Map(); const phases = new Map();
/* The video countdown — time left on what the presentation layer is
playing, from the transport routes (verified live on ProPresenter 21.4):
GET /v1/transport/presentation/current → { is_playing, uuid, name,
artist, audio_only, duration } (no looping flag exists — inferred)
GET /v1/transport/presentation/time → bare seconds, sub-second
float — better than /v1/timer/video_countdown's whole-second string,
so no quantization phase lock is needed, only an anchor hold. */
let media = null; // { uuid, name, duration, playing } from the slow lane
let mediaAt = 0;
let transportOk = null; // null until the first answer; false = no transport route (older ProPresenter)
let videoPos = null; // playback position in seconds (float)
let videoAsOf = 0; // server ms when videoPos was measured
let videoAnchor = null; // published { remainingMs, asOf }, held while it predicts the measurements
let loopingUuid = ''; // media seen wrapping — a looping background
function url(target) { function url(target) {
const built = new URL(`http://${host}:${port}${target}`); const built = new URL(`http://${host}:${port}${target}`);
if (password) built.searchParams.set('password', password); if (password) built.searchParams.set('password', password);
@@ -390,6 +405,121 @@ function createProPresenterSource({ config, log }) {
return out; return out;
} }
/**
* Follow the presentation transport layer. Failures here only drop the
* video card, never the whole source — the timers must survive a
* ProPresenter build with transport quirks.
*/
async function pollVideo() {
if (tick % SLOW_EVERY === 1) {
try {
const raw = await requestJson('/v1/transport/presentation/current');
const uuid = String(raw?.uuid || '');
if (!media || media.uuid !== uuid) {
if (media && media.uuid !== uuid) loopingUuid = '';
videoPos = null;
videoAnchor = null;
}
media = uuid ? {
uuid,
name: String(raw.name || ''),
duration: Number(raw.duration) > 0 ? Number(raw.duration) : 0,
playing: Boolean(raw.is_playing),
} : null;
mediaAt = Date.now();
transportOk = true;
} catch (err) {
media = null; // the slow lane retries in ~2 s
if (err && err.status === 404) transportOk = false; // no transport route on this build
}
}
if (!media) {
videoPos = null;
videoAnchor = null;
return;
}
try {
const pos = Number(await requestJson('/v1/transport/presentation/time'));
if (Number.isFinite(pos) && pos >= 0) {
// Same media, position jumped back by more than a stutter: it
// wrapped — a looping background. Sticks until the media changes.
// (A video manually re-triggered from the start looks the same and
// is mislabeled for that pass; loops re-wrap, replays don't recur.)
if (videoPos !== null && media.playing && videoPos - pos > 1.5) loopingUuid = media.uuid;
videoPos = pos;
videoAsOf = Date.now();
} else {
videoPos = null;
}
} catch {
videoPos = null;
}
}
/** The video countdown as one synthetic timer card (id 'video'). The card
persists as an idle "—" between videos — a wall tile must not appear
and vanish with the media — and is only absent entirely on a
ProPresenter without transport routes. */
function videoTimer() {
if (transportOk !== true) return null;
if (!media) {
return {
id: 'video',
label: 'Video',
kind: 'countdown',
state: 'idle',
asOf: mediaAt || updatedAt,
startedAt: null,
elapsedMs: null,
remainingMs: null,
targetMs: null,
looping: false,
status: null,
detail: '',
};
}
const playing = media.playing;
const dur = media.duration > 0 ? media.duration * 1000 : null;
let remainingMs = null;
let asOf = videoAsOf || mediaAt;
if (videoPos !== null && dur !== null) {
remainingMs = Math.max(0, dur - videoPos * 1000);
if (playing) {
// Hold the published anchor while it still predicts the measured
// position: the tiles extrapolate locally, and a fresh anchor per
// poll would broadcast a frame every 500 ms for nothing.
const p = videoAnchor;
const predicted = p ? p.remainingMs - (videoAsOf - p.asOf) : NaN;
if (p && Math.abs(predicted - remainingMs) < 120) {
remainingMs = p.remainingMs;
asOf = p.asOf;
} else {
videoAnchor = { remainingMs, asOf };
}
} else {
videoAnchor = null;
}
} else {
videoAnchor = null;
}
const looping = media.uuid === loopingUuid;
return {
id: 'video',
label: 'Video',
kind: 'countdown',
state: playing ? 'running' : videoPos > 0 ? 'paused' : 'idle',
asOf,
startedAt: null,
elapsedMs: remainingMs !== null && dur !== null ? dur - remainingMs : null,
remainingMs,
targetMs: dur,
looping,
status: looping ? { text: playing ? 'Looping' : 'Paused', tone: 'muted' } : null,
// The media name, without the file extension nobody reads on a wall.
detail: media.name.replace(/\.(mp4|mov|m4v|mxf|avi|mkv|mp3|wav|aiff?)$/i, ''),
};
}
function status() { function status() {
if (!configured) return 'error'; if (!configured) return 'error';
if (reachable) return 'ok'; if (reachable) return 'ok';
@@ -429,6 +559,9 @@ function createProPresenterSource({ config, log }) {
const currentTimes = await requestJson('/v1/timers/current'); const currentTimes = await requestJson('/v1/timers/current');
updatedAt = Date.now(); updatedAt = Date.now();
timers = mergeTimers(currentTimes, updatedAt); timers = mergeTimers(currentTimes, updatedAt);
await pollVideo();
const video = videoTimer();
if (video) timers.push(video);
reachable = true; reachable = true;
lastError = ''; lastError = '';
} catch (err) { } catch (err) {
@@ -970,6 +1103,8 @@ const GEAR = {
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS }, all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */ /** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS), timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
/** The video countdown: a timer card, plus whether looping media counts. */
video: gearOf([...TIMER_ELEMENTS, 'showLoops', ...COLOURS], OPEN_COLOURS),
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */ /** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS), ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
/** The time of day: plain digits, so only how they read. */ /** The time of day: plain digits, so only how they read. */
@@ -983,6 +1118,7 @@ function gearFor(source, timer) {
if (source.kind === 'ltc') return GEAR.ltc; if (source.kind === 'ltc') return GEAR.ltc;
if (source.kind === 'clock') return GEAR.clock; if (source.kind === 'clock') return GEAR.clock;
if (timer.kind === 'clock') return GEAR.providerClock; if (timer.kind === 'clock') return GEAR.providerClock;
if (source.id === 'propresenter' && timer.id === 'video') return GEAR.video;
return GEAR.timer; return GEAR.timer;
} }
@@ -1191,6 +1327,7 @@ module.exports = {
for (const t of src.timers) { for (const t of src.timers) {
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own' const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
: src.kind === 'clock' ? 'The time of day on its own' : src.kind === 'clock' ? 'The time of day on its own'
: src.id === 'propresenter' && t.id === 'video' ? 'Time left on the video ProPresenter is playing'
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`; : `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
// Layouts from before 1.3.0 hold 'timer:<uuid>' / 'ltc' — the same tiles. // Layouts from before 1.3.0 hold 'timer:<uuid>' / 'ltc' — the same tiles.
const aliases = src.id === 'propresenter' ? [`timer:${t.id}`] : src.kind === 'ltc' ? ['ltc'] : []; const aliases = src.id === 'propresenter' ? [`timer:${t.id}`] : src.kind === 'ltc' ? ['ltc'] : [];
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "spl-meter", "id": "spl-meter",
"name": "SPL Meter", "name": "SPL Meter",
"version": "0.4.1", "version": "0.4.2",
"proddash": ">=1.5.0", "proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [ "permissions": [
+23 -1
View File
@@ -20,7 +20,7 @@
*/ */
const path = require('path'); const path = require('path');
const { createSplListener } = require('./spl-listener'); const { createSplListener, ensureCaptureBinary } = require('./spl-listener');
/** Reference level of a sound level calibrator. Effectively always 94 dB. */ /** Reference level of a sound level calibrator. Effectively always 94 dB. */
const CALIBRATOR_DB = 94; const CALIBRATOR_DB = 94;
@@ -114,11 +114,19 @@ module.exports = {
error: '', error: '',
streams: new Set(), streams: new Set(),
silentSince: null, silentSince: null,
toolMissing: '',
listener: null, listener: null,
lastSent: '', lastSent: '',
pinger: null, pinger: null,
}; };
// Before anything else: the device picker shells out to this tool, so a
// machine that installed the module from the catalog (source only — the
// binary is gitignored) needs the build kicked off here. Waiting for a
// listener would never happen: no tool means no device list, and no
// device means no listener.
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
if (!config.enabled) { if (!config.enabled) {
state.error = 'Sound level measurement is off — turn it on in ProdDash settings'; state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) }; return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
@@ -177,6 +185,9 @@ module.exports = {
return { return {
stop() { teardown(); }, stop() { teardown(); },
health() { health() {
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
return { status: 'error', message: state.toolMissing };
}
if (state.error) return { status: 'error', message: state.error }; if (state.error) return { status: 'error', message: state.error };
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' }; if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
const quiet = silentMs(); const quiet = silentMs();
@@ -300,6 +311,13 @@ module.exports = {
/** Device list for the admin page. Runs the capture tool's --list, /** Device list for the admin page. Runs the capture tool's --list,
which is the only thing that knows what CoreAudio can see. */ which is the only thing that knows what CoreAudio can see. */
'GET /devices': async (req, res) => { 'GET /devices': async (req, res) => {
const missing = ensureCaptureBinary({ moduleDir: __dirname, log });
if (missing) {
if (state) state.toolMissing = missing;
log(`device list unavailable — ${missing}`);
json(res, 200, { options: [] });
return;
}
try { try {
const devices = await listDevices(); const devices = await listDevices();
json(res, 200, { json(res, 200, {
@@ -438,6 +456,10 @@ function listDevices() {
}); });
} }
function fsExists(p) {
try { return require('fs').existsSync(p); } catch { return false; }
}
function json(res, code, body) { function json(res, code, body) {
const text = JSON.stringify(body); const text = JSON.stringify(body);
res.writeHead(code, { res.writeHead(code, {
+75 -57
View File
@@ -17,7 +17,8 @@
* module's ltc-capture. It normally arrives already built — compiled beside * module's ltc-capture. It normally arrives already built — compiled beside
* the app, or copied from any Mac of the same architecture. When it is * the app, or copied from any Mac of the same architecture. When it is
* missing this will build it, but in the background and never on the event * missing this will build it, but in the background and never on the event
* loop: see ensureBinary for what that cost the first time it was wrong. * loop, and from module scope so the device picker can trigger it too — see
* ensureCaptureBinary for what both of those cost the first time round.
* *
* The same macOS gotcha applies as for LTC, and is worth repeating because * The same macOS gotcha applies as for LTC, and is worth repeating because
* it costs an afternoon every time: a ProdDash launched over SSH or from a * it costs an afternoon every time: a ProdDash launched over SSH or from a
@@ -30,6 +31,74 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
+ 'and copy it next to spl-capture.swift';
const BUILDING = 'Building the capture tool — this takes a minute or two';
/**
* Build the capture tool if it is missing, WITHOUT blocking, and shared by
* everything that needs it.
*
* This lives at module scope rather than inside a listener because of a trap
* the first version walked into: the admin page's device picker runs the tool
* with --list, but the build only ran when a listener started, and a listener
* only starts once a device has been picked. On a machine that got the module
* from the catalog — source only, the binary being gitignored — the picker
* was empty, and nothing would ever fill it.
*
* It also must never block. It was a spawnSync once, copied from the LTC
* listener where the source is a third the size and builds in seconds. Here
* it took nearly two minutes on the production Mac and held the Node event
* loop for all of it, freezing every other module, then did it again on the
* next retry.
*
* The binary should normally already be there: built beside the app, or
* copied from any Mac of the same architecture. This is the fallback.
*
* @returns {string} '' when the tool is ready, otherwise what to say.
*/
let building = false;
let buildFailed = false;
function ensureCaptureBinary({ moduleDir, log, onReady }) {
const bin = path.join(moduleDir, 'spl-capture');
if (fs.existsSync(bin)) return '';
const src = bin + '.swift';
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
if (building) return BUILDING;
if (buildFailed) return BUILD_HELP;
building = true;
log('spl-capture binary missing — building it in the background');
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
let buildErr = '';
build.stderr.setEncoding('utf8');
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
const giveUp = setTimeout(() => {
try { build.kill('SIGKILL'); } catch { /* already gone */ }
}, 300000);
build.on('error', (err) => {
clearTimeout(giveUp);
building = false; buildFailed = true;
log(`swiftc could not be run: ${err.message}`);
});
build.on('exit', (code) => {
clearTimeout(giveUp);
building = false;
if (code === 0 && fs.existsSync(bin)) {
log('spl-capture built');
buildFailed = false;
if (typeof onReady === 'function') onReady();
return;
}
buildFailed = true;
log(`spl-capture build failed (exit ${code})`
+ (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
});
return BUILDING;
}
const RESTART_MS = 5000; const RESTART_MS = 5000;
/** An immediate exit is config-shaped (bad device, channel out of range) — /** An immediate exit is config-shaped (bad device, channel out of range) —
retrying every 5 s would only fill the log with the same sentence. */ retrying every 5 s would only fill the log with the same sentence. */
@@ -53,64 +122,13 @@ function createSplListener(opts) {
let stallTimer = null; let stallTimer = null;
let startedAt = 0; let startedAt = 0;
let meta = null; let meta = null;
let building = false;
let buildFailed = false;
let lastFrameWall = 0; let lastFrameWall = 0;
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with ' /** '' when the tool is ready, otherwise what to say about it. */
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
+ 'and copy it next to spl-capture.swift';
/**
* Build the capture tool if it is missing, WITHOUT blocking.
*
* This used to be a spawnSync, copied from the LTC listener where the
* source is a third the size and builds in a few seconds. Here it took
* nearly two minutes on the production Mac and froze the entire ProdDash
* server for every second of it — one module's optional helper took the
* whole dashboard down, then did it again on every retry. Nothing that
* runs while a service might be on is allowed to hold the event loop.
*
* The build is a fallback for a module installed from the catalog on a
* machine with a Swift toolchain. The normal path is a binary built
* alongside the app, or copied from any same-architecture Mac.
*
* @returns {string} '' when the binary is ready, otherwise what to say.
*/
function ensureBinary() { function ensureBinary() {
if (fs.existsSync(bin)) return ''; return ensureCaptureBinary({ moduleDir, log, onReady() {
const src = bin + '.swift';
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
if (building) return 'Building the capture tool — this takes a minute or two';
if (buildFailed) return BUILD_HELP;
building = true;
log('spl-capture binary missing — building it in the background');
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
let buildErr = '';
build.stderr.setEncoding('utf8');
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
const giveUp = setTimeout(() => {
try { build.kill('SIGKILL'); } catch { /* already gone */ }
}, 300000);
build.on('error', (err) => {
clearTimeout(giveUp);
building = false; buildFailed = true;
log(`swiftc could not be run: ${err.message}`);
});
build.on('exit', (code) => {
clearTimeout(giveUp);
building = false;
if (code === 0 && fs.existsSync(bin)) {
log('spl-capture built');
buildFailed = false;
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); } if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
return; } });
}
buildFailed = true;
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
});
return 'Building the capture tool — this takes a minute or two';
} }
function scheduleRestart(ms) { function scheduleRestart(ms) {
@@ -137,7 +155,7 @@ function createSplListener(opts) {
onUpdate({ error: missing }); onUpdate({ error: missing });
// A build in flight calls start() itself when it lands; anything else // A build in flight calls start() itself when it lands; anything else
// is config-shaped and should back off hard rather than spin. // is config-shaped and should back off hard rather than spin.
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS); if (missing !== BUILDING) scheduleRestart(BAD_CONFIG_RETRY_MS);
return; return;
} }
@@ -236,4 +254,4 @@ function createSplListener(opts) {
}; };
} }
module.exports = { createSplListener }; module.exports = { createSplListener, ensureCaptureBinary };
+29
View File
@@ -152,10 +152,39 @@ const server = http.createServer((req, res) => {
if (p === '/v1/timers') return sendJson(res, TIMERS.filter((t) => !DROP.has(t.id.uuid))); if (p === '/v1/timers') return sendJson(res, TIMERS.filter((t) => !DROP.has(t.id.uuid)));
if (p === '/v1/timers/current') return sendJson(res, currentTimes().filter((t) => !DROP.has(t.id.uuid))); if (p === '/v1/timers/current') return sendJson(res, currentTimes().filter((t) => !DROP.has(t.id.uuid)));
if (p === '/v1/timecode/status' && !NO_LTC) return sendJson(res, timecodeStatus()); if (p === '/v1/timecode/status' && !NO_LTC) return sendJson(res, timecodeStatus());
if (p === '/v1/transport/presentation/current') return sendJson(res, transport().current);
if (p === '/v1/transport/presentation/time') return sendJson(res, transport().time);
sendJson(res, { error: 'not found' }, 404); sendJson(res, { error: 'not found' }, 404);
}); });
/* ── transport (video countdown): a 4-minute cycle ─────────────────
0–60 s "Sermon Bumper.mp4" (3:00) playing
60–75 s the same bumper paused at 1:00
75–90 s playing again
90–120 s nothing loaded
120–240 s "Autumn Loop.mp4" (0:15) playing and wrapping — the module
must infer looping from the position jumping back.
Shapes match ProPresenter 21.4 live: `current` = { is_playing, uuid,
name, artist, audio_only, duration }, `time` = bare seconds (float). */
function transport() {
const phase = ((Date.now() - startedAt) / 1000) % 240;
const none = { current: { is_playing: false, uuid: '', name: '', artist: '', audio_only: false, duration: 0 }, time: -1 };
if (phase < 90) {
const playing = phase < 60 || phase >= 75;
const pos = Math.min(phase, 60) + (phase >= 75 ? phase - 75 : 0);
return {
current: { is_playing: playing, uuid: 'MEDIA-BUMPER', name: 'Sermon Bumper.mp4', artist: '', audio_only: false, duration: 180 },
time: Math.round(pos * 10) / 10,
};
}
if (phase < 120) return none;
return {
current: { is_playing: true, uuid: 'MEDIA-LOOP', name: 'Autumn Loop.mp4', artist: '', audio_only: false, duration: 15 },
time: Math.round(((phase - 120) % 15) * 10) / 10,
};
}
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */ /* ── stage-display websocket (/stagedisplay) ─────────────────────── */
const cryptoMod = require('crypto'); const cryptoMod = require('crypto');