Compare commits

...
Author SHA1 Message Date
Waters ChurchandClaude Fable 5 8843339f11 Timers 1.5.0: the LTC capture tool installs itself; tiles warn in one line
A catalog install ships source only, and a catalog update deletes the
module folder — a previously built ltc-capture binary with it — so every
install or update left LTC dead until someone compiled the tool by hand.
The build also still used the blocking spawnSync that once froze the whole
server (the bug spl-meter fixed in 0.4.0), and only ran when a listener
started: no tool meant no device list, and no device meant no listener.

ensureCaptureBinary() (module scope in ltc-listener.js, the spl-meter
0.4.2 pattern) now builds the tool in the background as part of coming
up: kicked when the LTC source starts and by the admin device picker,
single-flight, built to a temp name and renamed so a killed build can't
leave a truncated binary, and rebuilt behind a still-serving binary when
a module update leaves the source newer. The waiting listener starts
itself the moment the build lands.

Tile-side warnings are now one line — 'Not configured — see Admin',
'Unreachable', 'Capture tool building…' — with the client's source-label
prefix no longer repeated. The full diagnostics (host:port, errno, the
capture tool's own words, build instructions) move to the admin health
line and the log, where the person who can act on them is looking.

Verified: build from the exact catalog-install state (source only) lands
and lists 12 real devices; touching the source triggers an atomic rebuild
while the old binary keeps serving; /ltc/devices kicks the build and
answers with the device list; live server shows terse source messages on
the tile and the detailed story in /api/admin/state health. ltc and phase
selftests still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 09:47:05 -04:00
michael 5f60cd563c Merge pull request 'Timers 1.4.2: ProPresenter digits no longer flick back a second' (#26) from claude/timers-second-phase into main 2026-09-27 09:05:46 -04:00
michael de32046553 Merge pull request 'AbleSet 1.0.0: follow AbleSet's setlist over OSC' (#17) from claude/proddash-ableset-module-6c2556 into main 2026-09-27 09:02:23 -04:00
Waters ChurchandClaude Opus 5.5 c1d9c59325 Merge origin/main into claude/proddash-ableset-module-6c2556
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 09:01:35 -04:00
Waters ChurchandClaude Fable 5 2f4c357bf5 Timers 1.4.2: ProPresenter digits no longer flick back a second
ProPresenter reports running timers as truncated whole seconds, and the
poller re-anchored the tiles' local count at the bottom of that second on
every 500 ms poll — so the displayed digit ran up to a second ahead and
got yanked back a step whenever a fresh poll sampled the next flip later
than the last one did. Tiles flickered ...12:33 → 12:34 → 12:33... about
once a second.

phase.js turns the quantized reports into one stable anchor per timer:
the only information in a poll is a FLIP (the report stepping one second
in the direction of travel), which dates the true boundary to the
midpoint of the two polls that straddle it. While reports stay consistent
with the anchor (±450 ms) the anchor holds, so tiles count along one
steady clock; a report the anchor can't explain (reset, seek, resume,
accumulated drift) re-anchors. Timers carry the anchor as a per-timer
asOf the client extrapolates from (optional in the provider contract,
documented in the guide); the client additionally refuses to tick a
running card backwards over sub-1.5 s jitter.

phase.selftest.js (66 checks) drives a simulated ProPresenter — truncated
reports, jittered polls — through the tracker and the client's render
math: digits never step backwards, dwells hold 1 s (the ~2 s zero-crossing
dwell is trunc's honest behavior), anchors stay within tolerance of the
true value, resets snap, pause/resume re-locks, 0.2% clock drift is
absorbed. Verified live against tools/pp-timers-mock.js: 78 s of 100 ms
DOM sampling across four running cards — every step ±1 in the card's own
direction, dwells 998–1002 ms, zero regressions; upstream kill/restart
still recovers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 08:46:50 -04:00
Waters ChurchandClaude Opus 5.5 9192eed318 AbleSet 1.0.1: a remounted tile asks for the state instead of waiting
Every gear change remounts the tile, and the remounted tile joins the
page's shared stream mid-flow, so it heard nothing until AbleSet next
changed — with the transport stopped, never. Tiles went blank until a
reload. The tile now fetches /state when it starts and whenever the
stream (re)opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:07:13 -04:00
Waters ChurchandClaude Opus 5.5 41fd76817c AbleSet 1.0.0: follow AbleSet's setlist over OSC
A new module that subscribes to AbleSet's OSC API (UDP 39051) and streams
what it is playing to ten tiles: Now Playing (song, tempo, meter, duration,
time left, section, progress bar, next song, songs left), Song Sections
(a sliding strip, or a lottery wheel from the title bar), Setlist,
Transport, Tempo, Song Time, Set Time, Section, Next Song and Status, each
coloured by its state. The song and set countdowns are offered to the
Timers module.

A heartbeat watchdog resubscribes when AbleSet goes quiet (a restarted
AbleSet forgets its subscribers). Time left falls back to the song length
and playhead when AbleSet stops sending it (Live disconnected).

tools/ableset-mock.js plays a setlist over the same API for testing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:24:19 -04:00
14 changed files with 2755 additions and 49 deletions
+1
View File
@@ -45,6 +45,7 @@ title-bar controls). The rules that matter most:
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter). `node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter).
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`). `node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). `node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
`node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`).
## Reference code (read-only forks — don't develop these here) ## Reference code (read-only forks — don't develop these here)
+6
View File
@@ -684,6 +684,7 @@ clock, and any module that offers some. A module offers timers by:
| `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). | | `state` | `running`, `paused`, `stopped` (finished; value frozen), `idle` (exists, hasn't started — a timer that will start at 11:00). |
| `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. | | `startedAt` | ms epoch when it last started running; the consumer extrapolates from here, so a running timer never needs a fast poll. |
| `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. | | `elapsedMs`, `remainingMs` | The values at `updatedAt`; `null` when not meaningful for the kind. |
| `asOf` | Optional, per timer: ms epoch this timer's durations were measured, when that differs from the snapshot's `updatedAt` (e.g. a value phase-locked to an upstream's second flips). Consumers extrapolate from it instead. |
| `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. | | `targetMs` | Planned length (elapsed) or full length (countdown), or `null`. Lets the consumer colour overruns. |
| `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. | | `status` | Optional one-liner the consumer shows beside the time — the current item, "ahead 1:20". `tone`: `ok`, `warn`, `danger`, `muted`. |
| `detail` | Optional second line. | | `detail` | Optional second line. |
@@ -811,6 +812,11 @@ client-side failures (a tile that fails to start says so in the tile).
Application ID + Secret under PCO Plan in `/admin`, save, then pick Application ID + Secret under PCO Plan in `/admin`, save, then pick
"Sunday Service" from the Service type list. Its item titles match "Sunday Service" from the Service type list. Its item titles match
`pp-mock.js`, so running both mocks exercises the ProPresenter matching. `pp-mock.js`, so running both mocks exercises the ProPresenter matching.
- AbleSet: `node tools/ableset-mock.js 39071` — a setlist playing through
over AbleSet's OSC API (UDP): sections, a tempo and meter per song, a
section that loops twice, a song queued out of order, heartbeats.
`--stopped`, `--no-live` and `--speed 4` exercise the other states. Point
the AbleSet module at host `127.0.0.1`, port `39071`.
**The resilience checklist** — every module must pass this before it's done: **The resilience checklist** — every module must pass this before it's done:
+727
View File
@@ -0,0 +1,727 @@
/* AbleSet — client part.
One factory draws every AbleSet tile; `moduleApi.variant` says which one
(the server's tiles() list). All of them read the same stream: `state`
whenever something changes, `pos` five times a second while playing.
Between packets the playhead and the countdowns are extrapolated from the
tempo and the clock, so bars glide instead of stepping. */
const VIEWS = ['now', 'sections', 'setlist', 'transport', 'tempo', 'song-time', 'set-time', 'section', 'next-song', 'status'];
/* AbleSet names its colours after Live's palette; these are close enough to
read as the same colour on a dark tile. Unknown names draw no colour. */
const NAMED_COLORS = {
red: '#ff4d4f', orange: '#ff8f3a', yellow: '#f5d442', lime: '#a3e635', green: '#34d399',
mint: '#5eead4', teal: '#2dd4bf', cyan: '#22d3ee', sky: '#38bdf8', blue: '#3b82f6',
indigo: '#6366f1', purple: '#a855f7', violet: '#8b5cf6', magenta: '#e879f9', pink: '#f472b6',
rose: '#fb7185', brown: '#b0794a', gray: '#9ca3af', grey: '#9ca3af', white: '#f5f5f5',
};
const ICON_STRIP = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="5" width="4" height="6" rx="1"/><rect x="6.5" y="4" width="4" height="8" rx="1"/><rect x="11.5" y="5" width="3" height="6" rx="1"/></svg>';
const ICON_WHEEL = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2.5h8M3 6h10M3 10h10M4 13.5h8"/><rect x="2" y="5" width="12" height="6" rx="1.2"/></svg>';
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
/** 83 → "01:23", 3725 → "1:02:05". Whole seconds round down, as AbleSet's own display does. */
function fmt(sec) {
if (sec == null || !Number.isFinite(sec)) return '--:--';
const s = Math.max(0, Math.floor(sec + 1e-6));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const ss = String(s % 60).padStart(2, '0');
return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${String(m).padStart(2, '0')}:${ss}`;
}
function fmtBpm(t) {
if (t == null) return '--';
return (Math.round(t * 100) / 100).toString();
}
function colorFor(value) {
if (!value) return null;
if (/^#[0-9a-f]{6}$/i.test(value)) return value;
return NAMED_COLORS[String(value).toLowerCase()] || null;
}
export default function create({ root, moduleApi }) {
const view = VIEWS.includes(moduleApi.variant) ? moduleApi.variant
: moduleApi.variant ? '' : 'now'; // an entry that vanished draws nothing
const prefs = () => moduleApi.instanceSettings || {};
const on = (k) => prefs()[k] !== false;
let state = null;
let pos = { beats: null, remainingSong: null, remainingSet: null };
let posAt = 0;
let stream = null;
let raf = 0;
let resizeObserver = null;
let tickTimer = null;
/* ── moving values, extrapolated to this frame ─────────────────── */
const moving = () => Boolean(state && state.reachable && state.playing);
const dt = () => (moving() ? (performance.now() - posAt) / 1000 : 0);
function beatsNow() {
if (pos.beats == null) return null;
const t = state?.tempo;
return t ? pos.beats + dt() * (t / 60) : pos.beats;
}
const songLeft = () => (pos.remainingSong == null ? null : Math.max(0, pos.remainingSong - dt()));
const setLeft = () => (pos.remainingSet == null ? null : Math.max(0, pos.remainingSet - dt()));
function fraction(start, end, b) {
if (start == null || end == null || b == null || end <= start) return null;
return Math.min(1, Math.max(0, (b - start) / (end - start)));
}
function songsLeft() {
if (!state?.song || state.song.index == null) return null;
return Math.max(0, state.setlist.songs.length - state.song.index - 1);
}
/** Beats per bar from the time signature (Live counts quarter notes). */
function beatsPerBar() {
const ts = state?.timeSig;
return ts && ts[0] > 0 && ts[1] > 0 ? (ts[0] * 4) / ts[1] : 4;
}
/** Bar and beat within the song, 1-based. */
function barBeat() {
const b = beatsNow();
const start = state?.song?.start ?? 0;
if (b == null) return null;
const rel = Math.max(0, b - start);
const bpb = beatsPerBar();
const ts = state?.timeSig || [4, 4];
const beatLen = 4 / (ts[1] || 4);
const bar = Math.floor(rel / bpb) + 1;
const beat = Math.floor((rel % bpb) / beatLen + 1e-6) + 1;
return { bar, beat, of: ts[0] || 4 };
}
function tone(sec) {
if (sec == null || !moving()) return '';
const warn = Number(prefs().warnAt ?? 30);
const alert = Number(prefs().alertAt ?? 10);
if (sec <= alert) return 'alert';
if (sec <= warn) return 'warn';
return '';
}
/* ── fitting one-line text to its box ──────────────────────────── */
/** Shrink a nowrap element until it fits the width its parent gives it. */
function fit(el) {
if (!el || !el.isConnected) return;
el.style.fontSize = '';
const max = parseFloat(getComputedStyle(el).fontSize) || 16;
const avail = el.parentElement ? el.parentElement.clientWidth : el.clientWidth;
let size = max;
for (let i = 0; i < 3 && el.scrollWidth > avail + 0.5 && size > 9; i += 1) {
size = Math.max(9, size * (avail / el.scrollWidth) * 0.98);
el.style.fontSize = `${size}px`;
}
}
const fitAll = () => root.querySelectorAll('[data-fit]').forEach(fit);
/** Set text, refitting only when it changed. */
function text(el, value) {
if (!el) return;
const v = String(value ?? '');
if (el.textContent === v) return;
el.textContent = v;
if (el.hasAttribute('data-fit')) fit(el);
}
function show(el, visible) {
if (el) el.hidden = !visible;
}
/* ── skeleton per view ─────────────────────────────────────────── */
root.innerHTML = `<div class="as-root as-${view || 'none'}"><div class="as-offline" hidden></div><div class="as-body"></div></div>`;
const shell = root.firstElementChild;
const body = shell.querySelector('.as-body');
const offlineEl = shell.querySelector('.as-offline');
const $ = (sel) => body.querySelector(sel);
const BAR = `<div class="as-bar"><div class="as-bar-done"></div><div class="as-bar-sect"><div class="as-bar-played"></div></div></div>`;
const SKELETONS = {
now: `
<div class="as-title-box"><div class="as-title" data-fit></div></div>
<div class="as-badges">
<span class="as-badge b-tempo"></span><span class="as-badge b-meter"></span><span class="as-badge b-dur"></span><span class="as-badge b-clock"></span><span class="as-badge b-section"></span>
</div>
${BAR}
<div class="as-next">
<div class="as-next-label">Next:</div>
<div class="as-next-box"><div class="as-next-title" data-fit></div></div>
<div class="as-left"></div>
</div>`,
sections: `
<div class="as-song-name" data-fit></div>
<div class="as-viewport"><div class="as-track"></div></div>`,
setlist: `
<div class="as-set-head"><span class="as-set-name"></span></div>
<div class="as-list"></div>
<div class="as-set-foot"></div>`,
transport: `
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-pos"></span><span class="as-loop"></span></div>`,
tempo: `
<div class="as-big-box"><div class="as-big" data-fit><span class="as-bpm"></span><small> BPM</small></div></div>
<div class="as-sub"><span class="as-meter"></span></div>
<div class="as-beats"></div>`,
'song-time': `
<div class="as-caption" data-fit></div>
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub as-sub-time"></div>
${BAR}`,
'set-time': `
<div class="as-caption" data-fit>Set</div>
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-left-count"></span><span class="as-end"></span></div>`,
section: `
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-next-sect"></span><span class="as-count"></span></div>
<div class="as-sect-bar"><div></div></div>`,
'next-song': `
<div class="as-caption">Next</div>
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-next-dur"></span></div>
<div class="as-queued" hidden></div>`,
status: `<div class="as-rows"></div>`,
};
body.innerHTML = SKELETONS[view] || '';
/* ── shared pieces ─────────────────────────────────────────────── */
/** The three-part song bar: played sections, this section, the playhead. */
function paintBar(bar) {
if (!bar) return;
const s = state?.song;
const sect = state?.section;
const b = beatsNow();
const len = s && s.end > s.start ? s.end - s.start : 0;
const at = (v) => (len ? Math.min(100, Math.max(0, ((v - s.start) / len) * 100)) : 0);
const sStart = sect && sect.start != null ? at(sect.start) : 0;
const sEnd = sect && sect.end != null ? at(sect.end) : 0;
const head = b != null ? at(b) : 0;
bar.style.setProperty('--done', `${sStart}%`);
bar.style.setProperty('--sect-l', `${sStart}%`);
bar.style.setProperty('--sect-w', `${Math.max(0, sEnd - sStart)}%`);
const inSect = sEnd > sStart ? Math.min(100, Math.max(0, ((head - sStart) / (sEnd - sStart)) * 100)) : 0;
bar.style.setProperty('--played', `${inSect}%`);
}
function songTint(el, hex) {
if (hex) el.style.setProperty('--song', hex);
else el.style.removeProperty('--song');
el.classList.toggle('has-song-color', Boolean(hex));
}
/* ── NOW PLAYING ───────────────────────────────────────────────── */
const nowView = {
render() {
const s = state;
const song = s.song;
text($('.as-title'), song ? song.name : s.setlist.name || 'No song');
shell.classList.toggle('is-playing', moving());
songTint(shell, on('songColors') && song ? colorFor(song.color) : null);
text($('.b-tempo'), s.tempo != null ? `${fmtBpm(s.tempo)} BPM` : '');
text($('.b-meter'), s.timeSig ? `${s.timeSig[0]}/${s.timeSig[1]}` : '');
text($('.b-dur'), song && song.duration != null ? fmt(song.duration) : '');
const sect = s.section;
text($('.b-section'), sect ? sect.name : '');
const sc = on('songColors') && sect ? colorFor(sect.color) : null;
$('.b-section').style.color = sc || '';
show($('.b-tempo'), on('showTempo') && s.tempo != null);
show($('.b-meter'), on('showMeter') && Boolean(s.timeSig));
show($('.b-dur'), on('showDuration') && song?.duration != null);
show($('.b-clock'), on('showClock') && pos.remainingSong != null);
show($('.b-section'), on('showSection') && Boolean(sect?.name));
show($('.as-bar'), on('showProgress') && Boolean(song));
const next = s.queued?.type === 'song' ? { name: s.queued.song } : s.nextSong;
text($('.as-next-label'), s.queued?.type === 'song' ? 'Queued:' : 'Next:');
$('.as-next').classList.toggle('is-queued', s.queued?.type === 'song');
text($('.as-next-title'), next ? next.name : 'End of set');
show($('.as-next-label'), on('showNext'));
show($('.as-next-box'), on('showNext'));
show($('.as-left'), on('showLeft') && songsLeft() != null);
this.frame();
},
frame() {
const left = songLeft();
const clockMode = prefs().clock || 'remaining';
const dur = state.song?.duration;
const elapsed = dur != null && left != null ? Math.max(0, dur - left) : null;
const clock = clockMode === 'elapsed' ? fmt(elapsed)
: clockMode === 'both' ? `${fmt(elapsed)} / -${fmt(left)}` : `-${fmt(left)}`;
const c = $('.b-clock');
text(c, clock);
c.dataset.tone = tone(left);
paintBar($('.as-bar'));
const n = songsLeft();
if (n != null) text($('.as-left'), `${n} song${n === 1 ? '' : 's'} (${fmt(setLeft())}) left`);
},
};
/* ── SONG SECTIONS: a strip, or a lottery wheel ────────────────── */
const WHEEL_STEP = 30; // degrees between wheel faces
let sectionKey = '';
let wheelButton = null;
const sectionsView = {
style() { return prefs().style === 'wheel' ? 'wheel' : 'strip'; },
build() {
const track = $('.as-track');
const list = state.sections || [];
const key = `${state.song?.name}|${list.map((x) => x.name).join('|')}`;
if (key === sectionKey && track.children.length === list.length) return;
sectionKey = key;
track.innerHTML = list.map((x, i) => `<div class="as-pill" data-i="${i}"><span class="as-pill-fill"></span><span class="as-pill-text">${esc(x.name)}</span></div>`).join('')
|| '<div class="as-pill is-empty"><span class="as-pill-text">No sections</span></div>';
this.jump();
},
/** Place without gliding — a new song, or strip ↔ wheel, lands in one step. */
jump() {
const track = $('.as-track');
track.classList.add('no-anim');
requestAnimationFrame(() => requestAnimationFrame(() => track.classList.remove('no-anim')));
},
render() {
const wheel = this.style() === 'wheel';
shell.classList.toggle('is-wheel', wheel);
shell.classList.toggle('is-playing', moving());
text($('.as-song-name'), state.song ? state.song.name : '');
show($('.as-song-name'), on('showSong') && Boolean(state.song));
this.build();
const cur = state.section?.index ?? -1;
const nxt = state.nextSection?.index ?? -1;
const pills = $('.as-track').children;
for (const el of pills) {
const i = Number(el.dataset.i);
if (Number.isNaN(i)) continue;
el.classList.toggle('is-past', i < cur);
el.classList.toggle('is-current', i === cur);
el.classList.toggle('is-next', i === nxt);
const c = on('sectionColors') ? colorFor(state.sections[i]?.color) || (i === cur ? colorFor(state.section?.color) : null) : null;
if (c) el.style.setProperty('--sect', c);
else el.style.removeProperty('--sect');
el.classList.toggle('has-color', Boolean(c));
}
this.place();
this.frame();
},
place() {
const track = $('.as-track');
const vp = $('.as-viewport');
const cur = Math.max(0, state.section?.index ?? 0);
const pills = [...track.children];
if (this.style() === 'wheel') {
const h = vp.clientHeight / 3.4;
const r = (h / 2) / Math.tan((WHEEL_STEP / 2) * (Math.PI / 180));
track.style.setProperty('--face-h', `${h}px`);
pills.forEach((el, i) => {
const d = i - cur;
el.style.transform = `translateY(-50%) rotateX(${-d * WHEEL_STEP}deg) translateZ(${r}px)`;
el.style.opacity = Math.abs(d) * WHEEL_STEP > 75 ? "0" : String(1 - Math.abs(d) * 0.3);
});
track.style.transform = `translateZ(${-r}px)`;
} else {
pills.forEach((el) => { el.style.transform = ''; el.style.opacity = ''; });
const el = pills[cur] || pills[0];
if (!el) return;
const center = el.offsetLeft + el.offsetWidth / 2;
track.style.transform = `translateX(${vp.clientWidth / 2 - center}px)`;
}
},
frame() {
const cur = state.section?.index ?? -1;
const el = $('.as-track').children[cur];
if (!el) return;
const f = fraction(state.section.start, state.section.end, beatsNow());
el.style.setProperty('--p', on('showProgress') && f != null ? `${f * 100}%` : '0%');
},
toggleStyle() {
const next = this.style() === 'wheel' ? 'strip' : 'wheel';
moduleApi.saveInstanceSettings({ style: next });
this.syncButton();
this.jump();
if (state) this.render();
},
syncButton() {
if (!wheelButton) return;
const wheel = this.style() === 'wheel';
wheelButton.innerHTML = wheel ? ICON_STRIP : ICON_WHEEL;
wheelButton.title = wheel ? 'Strip view' : 'Wheel view';
},
};
/* ── SETLIST ───────────────────────────────────────────────────── */
let listKey = '';
let lastScrolled = -2;
const setlistView = {
render() {
const s = state;
text($('.as-set-name'), s.setlist.name || 'Setlist');
const list = $('.as-list');
const songs = s.setlist.songs;
const key = JSON.stringify([songs, on('showDurations'), on('songColors')]);
if (key !== listKey) {
listKey = key;
lastScrolled = -2;
list.innerHTML = songs.map((x, i) => {
const c = on('songColors') ? colorFor(x.color) : null;
return `<div class="as-row" data-i="${i}"${c ? ` style="--song:${c}"` : ''}>
<span class="as-row-fill"></span>
${c ? '<span class="as-swatch"></span>' : ''}
<span class="as-row-name">${esc(x.name)}</span>
${on('showDurations') ? `<span class="as-row-dur">${x.duration != null ? fmt(x.duration) : ''}</span>` : ''}
</div>`;
}).join('') || '<div class="as-empty">No setlist</div>';
}
const cur = s.song?.index ?? -1;
const nxt = s.nextSong?.index ?? -1;
const q = s.queued?.type === 'song' ? s.queued.songIndex : -1;
for (const row of list.children) {
const i = Number(row.dataset.i);
if (Number.isNaN(i)) continue;
row.classList.toggle('is-past', i < cur);
row.classList.toggle('is-current', i === cur);
row.classList.toggle('is-next', i === nxt && q < 0);
row.classList.toggle('is-queued', i === q);
row.hidden = !on('showPlayed') && i < cur;
}
shell.classList.toggle('is-playing', moving());
if (cur !== lastScrolled && list.children[cur]) {
lastScrolled = cur;
const row = list.children[cur];
list.scrollTo({ top: Math.max(0, row.offsetTop - list.clientHeight * 0.3), behavior: 'smooth' });
}
show($('.as-set-foot'), on('showTotal'));
this.frame();
},
frame() {
const row = $('.as-list').children[state.song?.index ?? -1];
if (row && state.song) {
const f = fraction(state.song.start, state.song.end, beatsNow());
row.style.setProperty('--p', `${(f ?? 0) * 100}%`);
}
const n = songsLeft();
text($('.as-set-foot'), n == null ? '' : `${n} song${n === 1 ? '' : 's'} left · ${fmt(setLeft())}`);
},
};
/* ── TRANSPORT ─────────────────────────────────────────────────── */
const transportView = {
render() {
const s = state;
let word = 'Stopped';
let t = 'muted';
if (s.loading) { word = 'Loading'; t = 'warn'; }
else if (!s.live) { word = 'No Live'; t = 'alert'; }
else if (s.recording && s.playing) { word = 'Recording'; t = 'alert'; }
else if (s.countingIn) { word = 'Count-in'; t = 'warn'; }
else if (s.playing && (s.loop.inSection || s.sectionLoop?.mode)) { word = 'Looping'; t = 'warn'; }
else if (s.playing) { word = 'Playing'; t = 'ok'; }
else if (s.paused) { word = 'Paused'; t = 'warn'; }
text($('.as-big'), word);
shell.dataset.tone = t;
const loop = s.sectionLoop?.mode
? `Section loop${s.sectionLoop.count ? ` ×${s.sectionLoop.count}` : ''}`
: s.loop.inSection ? 'Section loop' : '';
text($('.as-loop'), loop);
show($('.as-loop'), on('showLoop') && Boolean(loop));
show($('.as-pos'), on('showPosition'));
this.frame();
},
frame() {
const bb = barBeat();
text($('.as-pos'), bb ? `Bar ${bb.bar} · ${bb.beat}` : '');
},
};
/* ── TEMPO ─────────────────────────────────────────────────────── */
let beatCount = 0;
const tempoView = {
render() {
const s = state;
text($('.as-bpm'), fmtBpm(s.tempo));
fit($('.as-big'));
text($('.as-meter'), s.timeSig ? `${s.timeSig[0]}/${s.timeSig[1]}` : '');
show($('.as-meter'), on('showMeter'));
const n = Math.min(16, Math.max(1, s.timeSig ? s.timeSig[0] : 4));
const beats = $('.as-beats');
if (beatCount !== n) {
beatCount = n;
beats.innerHTML = Array.from({ length: n }, (_, i) => `<i class="${i === 0 ? 'is-down' : ''}"></i>`).join('');
}
show(beats, on('showBeats'));
shell.classList.toggle('is-playing', moving());
this.frame();
},
frame() {
const bb = moving() ? barBeat() : null;
const lights = $('.as-beats').children;
for (let i = 0; i < lights.length; i += 1) lights[i].classList.toggle('is-lit', Boolean(bb) && bb.beat === i + 1);
},
};
/* ── SONG TIME ─────────────────────────────────────────────────── */
const songTimeView = {
render() {
const song = state.song;
text($('.as-caption'), song ? song.name : 'Song');
show($('.as-caption'), on('showSong'));
show($('.as-bar'), on('showProgress') && Boolean(song));
this.frame();
},
frame() {
const left = songLeft();
const dur = state.song?.duration;
const elapsed = dur != null && left != null ? Math.max(0, dur - left) : null;
const mode = prefs().clock || 'remaining';
text($('.as-big'), mode === 'elapsed' ? fmt(elapsed) : `-${fmt(left)}`);
text($('.as-sub-time'), mode === 'both' ? `${fmt(elapsed)} of ${fmt(dur)}` : dur != null ? `of ${fmt(dur)}` : '');
shell.dataset.tone = tone(left) || (moving() ? 'ok' : 'muted');
paintBar($('.as-bar'));
},
};
/* ── SET TIME ──────────────────────────────────────────────────── */
const setTimeView = {
render() {
text($('.as-caption'), state.setlist.name || 'Set');
show($('.as-left-count'), on('showLeft'));
show($('.as-end'), on('showEnd'));
shell.dataset.tone = moving() ? 'ok' : 'muted';
this.frame();
},
frame() {
const left = setLeft();
text($('.as-big'), fmt(left));
const n = songsLeft();
text($('.as-left-count'), n == null ? '' : `${n} song${n === 1 ? '' : 's'} left`);
const end = left != null ? new Date(Date.now() + left * 1000) : null;
text($('.as-end'), end ? `Ends ${end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}` : '');
},
};
/* ── SECTION ───────────────────────────────────────────────────── */
const sectionView = {
render() {
const sect = state.section;
text($('.as-big'), sect ? sect.name : '—');
const c = on('sectionColors') && sect ? colorFor(sect.color) : null;
songTint(shell, c);
const nx = state.queued?.type === 'section' ? state.queued.section : state.nextSection?.name;
text($('.as-next-sect'), nx ? `→ ${nx}` : '');
show($('.as-next-sect'), on('showNext') && Boolean(nx));
show($('.as-count'), on('showCount'));
show($('.as-sect-bar'), on('showCount'));
this.frame();
},
frame() {
const sect = state.section;
const b = beatsNow();
const looping = Boolean(state.sectionLoop?.mode || state.loop.inSection);
let label = '';
let t = moving() ? 'ok' : 'muted';
if (sect && sect.end != null && b != null) {
const leftBeats = Math.max(0, sect.end - b);
const bpb = beatsPerBar();
const beatLen = 4 / (state.timeSig?.[1] || 4);
if (looping) { label = 'Looping'; t = 'warn'; }
else if (leftBeats <= bpb + 1e-6) {
const n = Math.max(1, Math.ceil(leftBeats / beatLen - 1e-6));
label = `${n} beat${n === 1 ? '' : 's'}`;
if (moving()) t = 'warn';
} else {
const n = Math.ceil(leftBeats / bpb - 1e-6);
label = `${n} bars`;
}
const f = fraction(sect.start, sect.end, b);
$('.as-sect-bar').style.setProperty('--p', `${(f ?? 0) * 100}%`);
}
text($('.as-count'), label);
shell.dataset.tone = t;
},
};
/* ── NEXT SONG ─────────────────────────────────────────────────── */
const nextSongView = {
render() {
const n = state.nextSong;
text($('.as-big'), n ? n.name : 'End of set');
text($('.as-next-dur'), n?.duration != null ? fmt(n.duration) : '');
show($('.as-next-dur'), on('showDuration') && n?.duration != null);
const q = state.queued;
const qText = q ? (q.type === 'section' ? `Queued: ${q.section}${q.song ? ` (${q.song})` : ''}` : `Queued: ${q.song}`) : '';
text($('.as-queued'), qText);
show($('.as-queued'), on('showQueued') && Boolean(q));
shell.dataset.tone = q ? 'warn' : n ? '' : 'muted';
},
frame() {},
};
/* ── STATUS ────────────────────────────────────────────────────── */
const statusView = {
render() {
const s = state;
const rows = [
['AbleSet', s.reachable ? 'Connected' : 'No answer', s.reachable ? 'ok' : 'alert'],
['Ableton Live', !s.reachable ? '—' : s.live ? 'Connected' : 'Not connected', !s.reachable ? 'muted' : s.live ? 'ok' : 'alert'],
];
if (s.loading) rows.push(['Project', 'Loading…', 'warn']);
if (s.recording) rows.push(['Recording', 'On', 'alert']);
if (on('showSetlist')) rows.push(['Setlist', s.setlist.name || '—', s.setlist.name ? '' : 'muted']);
if (on('showAudio')) {
if (!s.audio.length) rows.push(['Audio interface', 'None', 'muted']);
for (const a of s.audio) {
const map = {
armed: ['Armed', 'ok'], sceneA: ['Main (A)', 'ok'], sceneB: ['Backup (B)', 'warn'], alarm: ['Alarm', 'alert'],
};
const [label, t] = map[a.status] || [a.status || 'Connected', ''];
rows.push([a.name, s.reachable ? label : '—', s.reachable ? t : 'muted']);
}
}
$('.as-rows').innerHTML = rows.map(([k, v, t]) => `<div class="as-srow" data-tone="${t}"><i></i><span class="as-sk">${esc(k)}</span><span class="as-sv">${esc(v)}</span></div>`).join('');
const worst = rows.some((r) => r[2] === 'alert') ? 'alert' : rows.some((r) => r[2] === 'warn') ? 'warn' : 'ok';
shell.dataset.tone = worst;
},
frame() {},
};
const VIEW_IMPL = {
now: nowView, sections: sectionsView, setlist: setlistView, transport: transportView, tempo: tempoView,
'song-time': songTimeView, 'set-time': setTimeView, section: sectionView, 'next-song': nextSongView, status: statusView,
};
const impl = VIEW_IMPL[view];
/* ── drawing loop ──────────────────────────────────────────────── */
function render() {
if (!impl || !state) return;
const offline = !state.reachable && view !== 'status';
shell.classList.toggle('is-offline', offline);
show(offlineEl, offline);
if (offline) {
text(offlineEl, !state.configured ? 'Set the AbleSet host in /admin' : 'AbleSet offline');
}
impl.render();
loop();
}
function loop() {
if (raf || !moving()) return;
const step = () => {
raf = 0;
if (!state || !moving()) return;
impl.frame();
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
function reportStatus() {
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
if (!state.configured) return moduleApi.setStatus('error', 'No AbleSet host set in /admin');
if (!state.reachable) return moduleApi.setStatus('error', state.lastError ? `AbleSet: ${state.lastError}` : 'Waiting for AbleSet to answer');
if (!state.live) return moduleApi.setStatus('connecting', 'AbleSet is up, but Ableton Live is not connected');
return moduleApi.setStatus('ok', state.setlist.name ? `AbleSet · ${state.setlist.name}` : 'AbleSet');
}
function applyState(next) {
if (!next || typeof next !== 'object') return;
takePos(next.pos);
state = next;
reportStatus();
render();
}
/* The page's one stream is shared, so a tile mounted onto it (a gear
change remounts the tile) joins mid-flow and hears nothing until
AbleSet next changes — which, stopped, may be never. Ask for the state. */
let stopped = false;
async function backfill() {
try {
const res = await moduleApi.fetch('/state');
if (!res.ok) return;
const body = await res.json();
if (!stopped) applyState(body.state);
} catch { /* the stream's error handler reports the outage */ }
}
function takePos(p) {
if (!p) return;
pos = p;
posAt = performance.now();
}
return {
start() {
if (!impl) return;
moduleApi.setStatus('connecting', 'Connecting…');
if (view === 'sections') {
wheelButton = moduleApi.header.addButton({
icon: ICON_WHEEL,
title: 'Wheel view',
onClick: () => sectionsView.toggleStyle(),
});
sectionsView.syncButton();
}
stream = moduleApi.sse('/stream', {
open() { backfill(); },
error() {
moduleApi.setStatus('error', 'Lost the ProdDash stream — reconnecting');
},
events: {
state(e) {
let next;
try { next = JSON.parse(e.data); } catch { return; }
applyState(next);
},
pos(e) {
try { takePos(JSON.parse(e.data)); } catch { return; }
if (state && !moving()) impl.frame();
loop();
},
},
});
backfill();
resizeObserver = new ResizeObserver(() => {
fitAll();
if (view === 'sections' && state) sectionsView.place();
});
resizeObserver.observe(shell);
// Stopped tiles still have a clock to keep honest (Set Time's end time).
tickTimer = setInterval(() => { if (state && !moving()) impl.frame(); }, 1000);
},
stop() {
stopped = true;
if (stream) stream.close();
stream = null;
cancelAnimationFrame(raf);
raf = 0;
clearInterval(tickTimer);
if (resizeObserver) resizeObserver.disconnect();
root.innerHTML = '';
},
};
}
+36
View File
@@ -0,0 +1,36 @@
{
"id": "ableset",
"name": "AbleSet",
"version": "1.0.1",
"proddash": ">=1.5.0",
"description": "The song AbleSet is playing — tempo, time left, sections, setlist and status",
"permissions": [
{
"kind": "local-network",
"reason": "Following AbleSet on this network"
}
],
"provides": ["timers"],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 1,
"h": 1
},
"defaultSize": {
"w": 8,
"h": 4
},
"configSchema": {
"ableset": {
"type": "endpoint",
"label": "AbleSet",
"default": {
"host": "",
"port": 39051
},
"help": "The Mac running AbleSet (e.g. AB-Tracks.local). 39051 is AbleSet's OSC port; it only changes if someone changed it in AbleSet's settings."
}
}
}
+101
View File
@@ -0,0 +1,101 @@
'use strict';
/**
* The slice of OSC 1.0 AbleSet speaks — messages and bundles with int32,
* float32, string, blob, true/false/nil — encoded and decoded with Buffer
* alone, so the module needs nothing outside Node.
*/
function pad4(n) {
return (n + 3) & ~3;
}
function encodeString(s) {
const raw = Buffer.from(String(s), 'utf8');
const out = Buffer.alloc(pad4(raw.length + 1));
raw.copy(out);
return out;
}
/**
* One message. Arguments are typed by their JS value: integers are int32,
* other numbers float32, booleans T/F, null nil, everything else a string.
*/
function encodeMessage(address, args = []) {
let tags = ',';
const parts = [];
for (const a of args) {
if (a === null || a === undefined) {
tags += 'N';
} else if (typeof a === 'boolean') {
tags += a ? 'T' : 'F';
} else if (typeof a === 'number' && Number.isInteger(a)) {
tags += 'i';
const b = Buffer.alloc(4);
b.writeInt32BE(a);
parts.push(b);
} else if (typeof a === 'number') {
tags += 'f';
const b = Buffer.alloc(4);
b.writeFloatBE(a);
parts.push(b);
} else {
tags += 's';
parts.push(encodeString(a));
}
}
return Buffer.concat([encodeString(address), encodeString(tags), ...parts]);
}
function readString(buf, at) {
let end = at;
while (end < buf.length && buf[end] !== 0) end += 1;
if (end >= buf.length) throw new Error('unterminated OSC string');
return { value: buf.toString('utf8', at, end), next: pad4(end + 1) };
}
function decodeMessage(buf, out) {
const addr = readString(buf, 0);
let at = addr.next;
const args = [];
if (at < buf.length && buf[at] === 0x2c) {
const tagStr = readString(buf, at);
at = tagStr.next;
for (const t of tagStr.value.slice(1)) {
switch (t) {
case 'i': args.push(buf.readInt32BE(at)); at += 4; break;
case 'f': args.push(buf.readFloatBE(at)); at += 4; break;
case 'd': args.push(buf.readDoubleBE(at)); at += 8; break;
case 'h': args.push(Number(buf.readBigInt64BE(at))); at += 8; break;
case 's': case 'S': { const s = readString(buf, at); args.push(s.value); at = s.next; break; }
case 'b': { const n = buf.readInt32BE(at); args.push(buf.subarray(at + 4, at + 4 + n)); at = pad4(at + 4 + n); break; }
case 'T': args.push(true); break;
case 'F': args.push(false); break;
case 'N': args.push(null); break;
case 'I': args.push(Infinity); break;
case 't': at += 8; break;
default: throw new Error(`unsupported OSC type tag "${t}"`);
}
}
}
out.push({ address: addr.value, args });
}
/** Every message in a packet, bundles flattened, in order. */
function decodePacket(buf, out = []) {
if (buf.length >= 16 && buf.toString('ascii', 0, 8) === '#bundle\0') {
let at = 16; // "#bundle\0" + 8-byte timetag
while (at + 4 <= buf.length) {
const n = buf.readInt32BE(at);
at += 4;
if (n <= 0 || at + n > buf.length) break;
decodePacket(buf.subarray(at, at + n), out);
at += n;
}
return out;
}
decodeMessage(buf, out);
return out;
}
module.exports = { encodeMessage, decodePacket };
+719
View File
@@ -0,0 +1,719 @@
'use strict';
/**
* AbleSet — server part.
*
* Owns the one connection to AbleSet: an OSC subscription over UDP (AbleSet
* listens on 39051). `/subscribe auto <port>` makes AbleSet push every value
* that changes to our socket, `/getValues` fills in everything that is
* already true, and AbleSet's `/heartbeat` every 2 s is how we know it is
* still there — when heartbeats stop (AbleSet quit, the Mac slept, the
* network dropped) we keep asking again every few seconds until it answers,
* because a restarted AbleSet has forgotten its subscribers.
*
* The raw values become one view-model, streamed to every tile:
*
* GET /state → { state } (instant paint)
* GET /stream → SSE: `state` on any change, `pos` while playing
* GET /timers(/stream) → song and set countdowns for the Timers module
*
* `pos` carries only what moves every frame (playhead, time left); the
* tiles extrapolate between packets from the tempo, so 5 packets a second
* draws a smooth playhead.
*/
const dgram = require('dgram');
const dns = require('dns');
const { encodeMessage, decodePacket } = require('./osc');
const HEARTBEAT_MS = 15000; // SSE keepalive comment
const SILENT_MS = 5000; // no heartbeat for this long → AbleSet is gone
const RESUBSCRIBE_MS = 3000; // how often to ask again while it is gone
const POS_MS = 200; // playhead packets to tiles while playing
const COALESCE_MS = 40; // AbleSet sends a section change as a burst of addresses
const FINE_MS = 100; // fine-position throttle we ask AbleSet for
/** Things AbleSet can send that no tile draws — kept off the wire. */
const EXCLUDES = ['!/mixer/**', '!/lyrics/**', '!/cues/**', '!/devices/**', '!/settings/**', '!/midi/**', '!/shared/**'];
let current = null;
function emptyState() {
return {
reachable: false,
configured: false,
lastError: '',
live: false,
loading: false,
playing: false,
paused: false,
recording: false,
countingIn: false,
tempo: null,
timeSig: null,
setlist: { name: '', songs: [] },
song: null,
section: null,
nextSection: null,
sections: [],
nextSong: null,
queued: null,
loop: { enabled: false, start: null, end: null, inSection: false },
audio: [],
version: '',
pos: emptyPos(),
};
}
function emptyPos() {
return { beats: null, remainingSong: null, remainingSet: null };
}
module.exports = {
init({ config, log }) {
const ep = config.ableset && typeof config.ableset === 'object' ? config.ableset : {};
const host = String(ep.host || '').trim();
const port = Number(ep.port) || 39051;
const configured = Boolean(host);
const values = new Map(); // OSC address → args, as AbleSet last said
const streams = new Set(); // tile SSE clients
const timerStreams = new Set(); // Timers-module SSE clients
let lastHeard = 0;
let lastAsked = 0;
let resolved = '';
let lastError = '';
let sock = null;
let stopped = false;
let coalesce = null;
let lastFrame = '';
let lastTimersFrame = '';
// Playhead and countdowns, each as "value at a moment" so they can be
// extrapolated to any other moment. AbleSet's time-left values are whole
// seconds; restamping on every one of them makes a countdown stutter, so
// a model is only moved when AbleSet disagrees with it by over a second.
const beatsModel = { value: null, at: 0 };
const songModel = { value: null, at: 0 };
const setModel = { value: null, at: 0 };
const num = (a, i = 0) => {
const v = values.get(a);
const n = v ? Number(v[i]) : NaN;
return v && v[i] !== null && Number.isFinite(n) ? n : null;
};
const str = (a, i = 0) => {
const v = values.get(a);
return v && v[i] != null ? String(v[i]) : '';
};
const arr = (a) => values.get(a) || [];
const flag = (a) => num(a) === 1 || values.get(a)?.[0] === true;
const isPlaying = () => flag('/global/isPlaying');
const tempo = () => num('/global/tempo');
function extrapolate(model, sign) {
if (model.value == null) return null;
if (!isPlaying()) return model.value;
return model.value + sign * (Date.now() - model.at) / 1000;
}
function beatsNow() {
if (beatsModel.value == null) return null;
const t = tempo();
if (!isPlaying() || !t) return beatsModel.value;
return beatsModel.value + ((Date.now() - beatsModel.at) / 1000) * (t / 60);
}
function nudge(model, reported) {
if (reported == null) { model.value = null; return; }
const predicted = extrapolate(model, -1);
if (predicted == null || Math.abs(predicted - reported) > 1.05 || !isPlaying()) {
model.value = reported;
model.at = Date.now();
} else {
// Keep the running model; re-base it so the stamp stays recent.
model.value = predicted;
model.at = Date.now();
}
}
function colorOf(a) {
const v = values.get(a);
if (!v) return null;
const name = v[0] == null ? '' : String(v[0]);
const hex = v[1] == null ? '' : String(v[1]);
return name && name !== 'default' && /^#[0-9a-f]{6}$/i.test(hex) ? hex : null;
}
function part(prefix) {
const name = str(`${prefix}Name`);
if (!name && num(`${prefix}Index`) == null) return null;
const index = num(`${prefix}Index`);
if (index != null && index < 0) return null;
return {
name,
index,
start: num(`${prefix}Start`),
end: num(`${prefix}End`),
duration: num(`${prefix}Duration`),
color: colorOf(`${prefix}Color`),
};
}
function audioInterfaces() {
const out = [];
const names = arr('/audioInterfaces/names');
const statuses = arr('/audioInterfaces/status');
names.forEach((n, i) => {
out.push({ name: String(n || `Interface ${i + 1}`), status: String(statuses[i] || '') });
});
// AbleSet 2 reports its PlayAUDIO12 on its own addresses.
if (!names.length && flag('/playaudio12/isConnected')) {
const scene = num('/playaudio12/scene');
out.push({ name: 'PlayAUDIO12', status: scene === 2 ? 'sceneB' : scene === 1 ? 'sceneA' : '' });
}
return out;
}
function buildState() {
const songs = arr('/setlist/songs');
const durations = arr('/setlist/songDurations');
const colors = arr('/setlist/songColors');
const sectionNames = arr('/setlist/sections');
const sectionColors = arr('/setlist/sectionColors');
const song = part('/setlist/activeSong');
const qIdx = values.get('/setlist/queuedIndex') || [];
const qName = values.get('/setlist/queuedName') || [];
const queuedType = str('/setlist/queuedType');
const ts = values.get('/global/timeSignature');
return {
reachable: isReachable(),
configured,
lastError,
live: flag('/global/isConnected'),
loading: flag('/global/isLoadingFile'),
playing: isPlaying(),
paused: flag('/global/isPaused'),
recording: flag('/global/isRecording'),
countingIn: flag('/setlist/isCountingIn'),
tempo: tempo(),
timeSig: ts && ts.length >= 2 && ts[0] != null ? [Number(ts[0]), Number(ts[1])] : null,
setlist: {
name: str('/setlist/name'),
songs: songs.map((name, i) => ({
name: String(name ?? ''),
duration: durations[i] == null ? null : Number(durations[i]),
color: typeof colors[i] === 'string' && colors[i] !== 'default' ? colors[i] : null,
})),
},
song,
section: part('/setlist/activeSection'),
nextSection: part('/setlist/nextSection'),
sections: sectionNames.map((name, i) => ({
name: String(name ?? ''),
color: typeof sectionColors[i] === 'string' && sectionColors[i] !== 'default' ? sectionColors[i] : null,
})),
sectionLoop: {
mode: str('/setlist/activeSectionLoopMode'),
count: num('/setlist/activeSectionLoopCount'),
},
nextSong: part('/setlist/nextSong'),
queued: queuedType && qIdx[0] != null && Number(qIdx[0]) >= 0
? { type: queuedType, song: String(qName[0] ?? ''), section: String(qName[1] ?? ''), songIndex: Number(qIdx[0]), sectionIndex: qIdx[1] == null ? -1 : Number(qIdx[1]) }
: null,
loop: {
enabled: flag('/setlist/loopEnabled'),
start: num('/setlist/loopStart'),
end: num('/setlist/loopEnd'),
inSection: flag('/setlist/isInActiveSectionLoop'),
},
audio: audioInterfaces(),
version: str('/version/app'),
pos: buildPos(),
};
}
/**
* Time left, as AbleSet counts it. AbleSet only sends these while Live is
* connected; without them the song's length and the playhead still say
* how far there is to go, and the set is the rest of the setlist on top.
*/
function songLeftNow() {
const reported = extrapolate(songModel, -1);
if (reported != null) return reported;
const dur = num('/setlist/activeSongDuration');
const start = num('/setlist/activeSongStart');
const end = num('/setlist/activeSongEnd');
const b = beatsNow();
if (dur == null || start == null || end == null || end <= start || b == null) return null;
return Math.max(0, dur * (1 - Math.min(1, Math.max(0, (b - start) / (end - start)))));
}
function setLeftNow() {
const reported = extrapolate(setModel, -1);
if (reported != null) return reported;
const song = songLeftNow();
const idx = num('/setlist/activeSongIndex');
if (song == null || idx == null) return null;
return song + arr('/setlist/songDurations').slice(idx + 1).reduce((a, d) => a + (Number(d) || 0), 0);
}
function buildPos() {
const round = (v, d) => (v == null ? null : Math.round(v * d) / d);
return {
beats: round(beatsNow(), 1000),
remainingSong: round(songLeftNow(), 100),
remainingSet: round(setLeftNow(), 100),
};
}
function isReachable() {
return lastHeard > 0 && Date.now() - lastHeard < SILENT_MS;
}
/* ── fan-out ─────────────────────────────────────────────────── */
function send(set, payload) {
for (const res of set) {
try { res.write(payload); } catch { set.delete(res); }
}
}
function broadcastState() {
coalesce = null;
const state = buildState();
// Change detection ignores the moving parts, which `pos` carries.
const { pos, ...still } = state;
const frame = JSON.stringify(still);
if (frame !== lastFrame) {
lastFrame = frame;
if (streams.size) send(streams, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
}
broadcastTimers();
}
function scheduleState() {
if (!coalesce) coalesce = setTimeout(broadcastState, COALESCE_MS);
}
function broadcastTimers() {
if (!timerStreams.size) return;
const snap = timersSnapshot();
const { updatedAt, ...rest } = snap;
const key = JSON.stringify(rest.timers.map((t) => [t.state, t.label, t.status, t.targetMs]));
if (key === lastTimersFrame) return;
lastTimersFrame = key;
send(timerStreams, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
}
function timersSnapshot() {
const now = Date.now();
const playing = isPlaying();
const reachable = isReachable();
const song = part('/setlist/activeSong');
const remSong = songLeftNow();
const remSet = setLeftNow();
const state = !reachable ? 'idle' : playing ? 'running' : remSong != null ? 'paused' : 'idle';
const ms = (s) => (s == null ? null : Math.max(0, Math.round(s * 1000)));
const songElapsed = song?.duration != null && remSong != null ? ms(song.duration - remSong) : null;
const songs = arr('/setlist/songs');
const idx = num('/setlist/activeSongIndex');
const left = idx == null ? null : Math.max(0, songs.length - idx - 1);
return {
source: 'ableset',
label: 'AbleSet',
updatedAt: now,
timers: [
{
id: 'song',
label: 'Song',
kind: 'countdown',
state,
startedAt: playing && songElapsed != null ? now - songElapsed : null,
elapsedMs: songElapsed,
remainingMs: ms(remSong),
targetMs: ms(song?.duration),
status: song ? { text: song.name, tone: reachable ? 'ok' : 'muted' } : null,
detail: str('/setlist/activeSectionName') || null,
},
{
id: 'set',
label: 'Set',
kind: 'countdown',
state,
startedAt: null,
elapsedMs: null,
remainingMs: ms(remSet),
targetMs: null,
status: str('/setlist/name') ? { text: str('/setlist/name'), tone: 'muted' } : null,
detail: left == null ? null : `${left} song${left === 1 ? '' : 's'} left`,
},
],
};
}
/* ── OSC in ──────────────────────────────────────────────────── */
const FAST = new Set([
'/global/beatsPosition', '/global/finePosition', '/setlist/activeSongProgress',
'/setlist/activeSectionProgress', '/setlist/remainingTimeInSong', '/setlist/remainingTimeInSet',
'/global/humanPosition', '/global/currentMeasure', '/global/measureOrPosition',
'/global/cpuAverage', '/global/cpuPeak', '/heartbeat', '/subscribed',
]);
function onMessage(m) {
const { address, args } = m;
if (address === '/global/finePosition' || address === '/global/beatsPosition') {
const v = Number(args[0]);
if (Number.isFinite(v)) {
// A whole-beat report while fine ones flow would drag the playhead back.
if (address === '/global/beatsPosition' && values.has('/global/finePosition')
&& Math.abs((beatsNow() ?? v) - v) < 1) return void values.set(address, args);
beatsModel.value = v;
beatsModel.at = Date.now();
}
} else if (address === '/setlist/remainingTimeInSong') {
nudge(songModel, args[0] == null ? null : Number(args[0]));
} else if (address === '/setlist/remainingTimeInSet') {
nudge(setModel, args[0] == null ? null : Number(args[0]));
} else if (address === '/global/isPlaying') {
// Freeze the models at the moment the transport changed.
const b = beatsNow();
if (b != null) { beatsModel.value = b; beatsModel.at = Date.now(); }
for (const mdl of [songModel, setModel]) {
const v = extrapolate(mdl, -1);
if (v != null) { mdl.value = v; mdl.at = Date.now(); }
}
}
values.set(address, args);
if (!FAST.has(address)) scheduleState();
}
/* ── connection ──────────────────────────────────────────────── */
function ask() {
if (!sock || stopped) return;
lastAsked = Date.now();
dns.lookup(host, { family: 4 }, (err, addr) => {
if (stopped || !sock) return;
if (err) {
lastError = `can't find ${host} (${err.code || err.message})`;
scheduleState();
return;
}
resolved = addr;
const myPort = sock.address().port;
const out = (address, args) => {
try {
sock.send(encodeMessage(address, args), port, addr, (e) => {
if (e) lastError = e.message;
});
} catch (e) {
lastError = e.message;
}
};
out('/subscribe', ['auto', myPort, 'ProdDash', FINE_MS, ...EXCLUDES]);
out('/getValues', ['auto', myPort]);
});
}
const watchdog = setInterval(() => {
if (!configured || stopped) return;
const wasReachable = lastHeard > 0 && Date.now() - lastHeard < SILENT_MS + 1000;
if (!isReachable()) {
if (wasReachable) {
log(`no heartbeat from AbleSet at ${host} — asking again`);
lastError = 'AbleSet stopped answering';
scheduleState();
}
if (Date.now() - lastAsked >= RESUBSCRIBE_MS) ask();
}
}, 1000);
watchdog.unref?.();
// While playing, the playhead goes out five times a second. While
// stopped it only goes out when it moved — a jump, a scrub.
let lastPos = '';
const posTimer = setInterval(() => {
if (!streams.size || !isReachable()) return;
const frame = JSON.stringify(buildPos());
if (!isPlaying() && frame === lastPos) return;
lastPos = frame;
send(streams, `event: pos\ndata: ${frame}\n\n`);
}, POS_MS);
posTimer.unref?.();
const keepalive = setInterval(() => {
send(streams, ': ping\n\n');
send(timerStreams, ': ping\n\n');
// The Timers module derives running values itself; a fresh anchor now
// and then keeps a long song honest.
if (timerStreams.size) {
lastTimersFrame = '';
broadcastTimers();
}
}, HEARTBEAT_MS);
keepalive.unref?.();
if (configured) {
sock = dgram.createSocket('udp4');
sock.on('message', (buf, rinfo) => {
if (resolved && rinfo.address !== resolved) return; // only AbleSet talks to us
let msgs;
try {
msgs = decodePacket(buf);
} catch (e) {
log(`unreadable OSC packet from ${rinfo.address}: ${e.message}`);
return;
}
const wasReachable = isReachable();
lastHeard = Date.now();
if (!wasReachable) {
lastError = '';
log(`AbleSet at ${host} (${rinfo.address}) is answering`);
scheduleState();
}
for (const m of msgs) onMessage(m);
});
sock.on('error', (e) => {
lastError = e.message;
log(`OSC socket error: ${e.message}`);
});
sock.bind(0, () => {
log(`following AbleSet at ${host}:${port}`);
ask();
});
} else {
log('no AbleSet host configured — set one in /admin');
}
current = {
streams,
timerStreams,
getState: buildState,
getTimers: timersSnapshot,
};
return {
stop() {
stopped = true;
clearInterval(watchdog);
clearInterval(posTimer);
clearInterval(keepalive);
clearTimeout(coalesce);
if (sock) {
// Be polite: tell AbleSet to stop sending to a port nobody reads.
const s = sock;
sock = null;
try {
if (resolved) s.send(encodeMessage('/unsubscribe', ['auto', s.address().port]), port, resolved, () => { try { s.close(); } catch { /* closed */ } });
else s.close();
} catch {
try { s.close(); } catch { /* closed */ }
}
}
for (const set of [streams, timerStreams]) {
for (const res of set) { try { res.end(); } catch { /* gone */ } }
set.clear();
}
if (current && current.streams === streams) current = null;
},
health() {
if (!configured) return { status: 'error', message: 'No AbleSet host configured' };
if (isReachable()) {
const live = flag('/global/isConnected') ? '' : ' — Ableton Live is not connected';
const set = str('/setlist/name');
return { status: live ? 'connecting' : 'ok', message: `Following ${host}${set ? ` · ${set}` : ''}${live}` };
}
if (lastError) return { status: 'error', message: `AbleSet at ${host}: ${lastError}` };
return { status: 'connecting', message: `Waiting for AbleSet at ${host}:${port} to answer…` };
},
};
},
/**
* One picker entry per tile kind. Each carries only the gear settings its
* tile can act on (guide rules 5–8): four or fewer switches sit loose, the
* Now Playing card's longer list folds into a Show group.
*/
tiles() {
const sw = (label, extra = {}) => ({ type: 'switch', label, default: true, ...extra });
const clock = {
type: 'select',
label: 'Song time',
default: 'remaining',
options: [
{ value: 'remaining', label: 'Time left' },
{ value: 'elapsed', label: 'Elapsed' },
{ value: 'both', label: 'Both' },
],
};
const warnAt = { type: 'number', label: 'Warn at (s)', default: 30, group: 'Colours' };
const alertAt = { type: 'number', label: 'Alert at (s)', default: 10, group: 'Colours' };
return [
{
id: 'now',
name: 'Now Playing',
description: 'The song, its tempo, time left, section, progress and what comes next',
defaultSize: { w: 8, h: 4 },
minSize: { w: 3, h: 2 },
instanceSchema: {
clock,
songColors: sw('Song colours', { default: false }),
showTempo: sw('Tempo', { group: 'Show' }),
showMeter: sw('Time signature', { group: 'Show' }),
showDuration: sw('Duration', { group: 'Show' }),
showClock: sw('Song time', { group: 'Show' }),
showSection: sw('Section', { group: 'Show' }),
showProgress: sw('Progress bar', { group: 'Show' }),
showNext: sw('Next song', { group: 'Show' }),
showLeft: sw('Songs left', { group: 'Show' }),
warnAt,
alertAt,
},
instanceGroups: { Show: { collapsed: true, columns: 2 }, Colours: { collapsed: true, columns: 2 } },
},
{
id: 'sections',
name: 'Song Sections',
description: 'Where the song is — its sections sliding past, as a strip or a wheel',
defaultSize: { w: 8, h: 2 },
minSize: { w: 2, h: 1 },
instanceSchema: {
showProgress: sw('Section progress'),
showSong: sw('Song name', { default: false }),
sectionColors: sw('Section colours', { default: false }),
},
},
{
id: 'setlist',
name: 'Setlist',
description: 'Every song in the set, played, playing and to come',
defaultSize: { w: 3, h: 6 },
minSize: { w: 2, h: 2 },
instanceSchema: {
showDurations: sw('Durations'),
showPlayed: sw('Played songs'),
showTotal: sw('Time left'),
songColors: sw('Song colours', { default: false }),
},
},
{
id: 'transport',
name: 'Transport',
description: 'Playing, stopped, counting in or looping, in colour',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showPosition: sw('Bar and beat'),
showLoop: sw('Loop'),
},
},
{
id: 'tempo',
name: 'Tempo',
description: 'BPM and time signature, with a light on every beat',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showMeter: sw('Time signature'),
showBeats: sw('Beat lights'),
},
},
{
id: 'song-time',
name: 'Song Time',
description: 'Time left in the song, turning amber then red near the end',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
clock,
showSong: sw('Song name'),
showProgress: sw('Progress bar'),
warnAt,
alertAt,
},
instanceGroups: { Colours: { collapsed: true, columns: 2 } },
},
{
id: 'set-time',
name: 'Set Time',
description: 'Time left in the whole set and how many songs remain',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showLeft: sw('Songs left'),
showEnd: sw('End time'),
},
},
{
id: 'section',
name: 'Section',
description: 'The section playing now, what is next, and the count to the change',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showNext: sw('Next section'),
showCount: sw('Countdown'),
sectionColors: sw('Section colours', { default: false }),
},
},
{
id: 'next-song',
name: 'Next Song',
description: 'The song after this one, and anything queued',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showDuration: sw('Duration'),
showQueued: sw('Queued'),
},
},
{
id: 'status',
name: 'AbleSet Status',
description: 'AbleSet, Ableton Live and audio interface health at a glance',
defaultSize: { w: 3, h: 3 },
minSize: { w: 2, h: 1 },
instanceSchema: {
showAudio: sw('Audio interfaces'),
showSetlist: sw('Setlist name'),
},
},
];
},
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));
};
const openSse = (req, res, set, first) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
res.write(first);
if (!set) return void res.end();
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
set.add(res);
req.on('close', () => set.delete(res));
};
return {
'GET /state': (req, res) => sendJson(res, 200, { state: current ? current.getState() : emptyState() }),
'GET /stream': (req, res) => {
const state = current ? current.getState() : emptyState();
openSse(req, res, current ? current.streams : null, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
},
'GET /timers': (req, res) => sendJson(res, 200, current ? current.getTimers() : { source: 'ableset', label: 'AbleSet', updatedAt: Date.now(), timers: [] }),
'GET /timers/stream': (req, res) => {
const snap = current ? current.getTimers() : { source: 'ableset', label: 'AbleSet', updatedAt: Date.now(), timers: [] };
openSse(req, res, current ? current.timerStreams : null, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
},
};
},
};
+416
View File
@@ -0,0 +1,416 @@
/* AbleSet tiles. Every size hangs off the tile itself (container units), so
a tile reads the same at 2×1 on a laptop as at 12×6 on a wall. Colours are
the shell's variables; a tone (ok / warn / alert / muted) picks among them,
and AbleSet's own song or section colour arrives as --song when a tile is
set to use it. */
[data-module="ableset"] .as-root {
--tone: var(--text);
--live: var(--accent);
position: absolute;
inset: 0;
container-type: size;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
color: var(--text);
font-variant-numeric: tabular-nums;
}
[data-module="ableset"] .as-root.has-song-color { --live: var(--song); }
[data-module="ableset"] .as-root[data-tone="ok"] { --tone: var(--accent); }
[data-module="ableset"] .as-root[data-tone="warn"] { --tone: var(--warn); }
[data-module="ableset"] .as-root[data-tone="alert"] { --tone: var(--danger); }
[data-module="ableset"] .as-root[data-tone="muted"] { --tone: var(--muted); }
[data-module="ableset"] .as-body {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
padding: clamp(4px, 4cqmin, 28px);
gap: clamp(2px, 2.5cqh, 18px);
min-width: 0;
}
[data-module="ableset"] [hidden] { display: none !important; }
[data-module="ableset"] .as-offline {
position: absolute;
top: clamp(3px, 3cqmin, 14px);
right: clamp(4px, 4cqmin, 18px);
z-index: 2;
font-size: clamp(9px, 6cqmin, 15px);
font-weight: 700;
color: var(--danger);
text-transform: uppercase;
letter-spacing: 0.04em;
}
[data-module="ableset"] .as-root.is-offline .as-body { opacity: 0.35; filter: grayscale(0.7); }
[data-module="ableset"] [data-fit] {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* ── the song bar: played sections · this section · the playhead ── */
[data-module="ableset"] .as-bar {
--done: 0%;
--sect-l: 0%;
--sect-w: 0%;
--played: 0%;
position: relative;
flex-shrink: 0;
height: clamp(4px, 5.5cqh, 26px);
border-radius: 999px;
overflow: hidden;
background: color-mix(in srgb, var(--live) 14%, var(--panel));
}
[data-module="ableset"] .as-bar-done {
position: absolute;
inset: 0 auto 0 0;
width: var(--done);
background: color-mix(in srgb, var(--live) 32%, var(--panel));
}
[data-module="ableset"] .as-bar-sect {
position: absolute;
top: 0;
bottom: 0;
left: var(--sect-l);
width: var(--sect-w);
background: color-mix(in srgb, var(--live) 55%, var(--panel));
overflow: hidden;
}
[data-module="ableset"] .as-bar-played {
position: absolute;
inset: 0 auto 0 0;
width: var(--played);
background: linear-gradient(90deg,
color-mix(in srgb, var(--live) 70%, var(--panel)) 0%,
color-mix(in srgb, var(--live) 70%, var(--panel)) calc(100% - 3.5cqw),
color-mix(in srgb, var(--live) 70%, var(--text)) 100%);
}
/* ── NOW PLAYING ─────────────────────────────────────────────────── */
[data-module="ableset"] .as-now .as-body {
align-items: center;
padding-inline: clamp(6px, 7cqw, 90px);
gap: clamp(3px, 4cqh, 30px);
}
[data-module="ableset"] .as-now .as-title-box,
[data-module="ableset"] .as-now .as-next-box { width: 100%; display: flex; justify-content: center; min-width: 0; }
[data-module="ableset"] .as-now .as-title {
font-size: clamp(14px, 20cqh, 170px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.01em;
color: var(--text);
transition: color 0.3s;
}
[data-module="ableset"] .as-now.is-playing .as-title { color: var(--live); }
[data-module="ableset"] .as-now .as-bar { width: 100%; }
[data-module="ableset"] .as-badges {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: clamp(3px, 1.8cqh, 16px);
max-width: 100%;
}
[data-module="ableset"] .as-badge {
font-size: clamp(9px, 7cqh, 44px);
font-weight: 700;
line-height: 1;
padding: 0.28em 0.5em;
border-radius: 0.22em;
background: color-mix(in srgb, var(--text) 12%, var(--panel));
color: color-mix(in srgb, var(--text) 80%, var(--accent));
white-space: nowrap;
}
[data-module="ableset"] .as-badge.b-section {
color: var(--text);
text-transform: uppercase;
background: color-mix(in srgb, var(--text) 7%, var(--panel));
}
[data-module="ableset"] .as-badge.b-clock[data-tone="warn"] { color: var(--accent-text); background: var(--warn); }
[data-module="ableset"] .as-badge.b-clock[data-tone="alert"] { color: var(--accent-text); background: var(--danger); }
[data-module="ableset"] .as-next {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-width: 0;
gap: clamp(1px, 0.6cqh, 6px);
}
[data-module="ableset"] .as-next-label {
font-size: clamp(8px, 5cqh, 30px);
font-weight: 700;
color: color-mix(in srgb, var(--live) 55%, var(--muted));
}
[data-module="ableset"] .as-next.is-queued .as-next-label { color: var(--warn); }
[data-module="ableset"] .as-next-title {
font-size: clamp(11px, 12cqh, 90px);
font-weight: 500;
line-height: 1.1;
color: color-mix(in srgb, var(--live) 45%, var(--muted));
}
[data-module="ableset"] .as-left {
font-size: clamp(8px, 6cqh, 36px);
color: color-mix(in srgb, var(--live) 40%, var(--muted));
}
/* ── SONG SECTIONS ──────────────────────────────────────────────── */
[data-module="ableset"] .as-sections .as-body { padding: clamp(3px, 3cqmin, 16px) 0; gap: clamp(2px, 2cqh, 10px); }
[data-module="ableset"] .as-sections .as-song-name {
flex-shrink: 0;
text-align: center;
padding: 0 clamp(4px, 3cqw, 20px);
font-size: clamp(9px, 13cqh, 28px);
font-weight: 700;
color: var(--muted);
}
[data-module="ableset"] .as-viewport {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
}
[data-module="ableset"] .as-track {
position: absolute;
top: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
gap: clamp(4px, 2cqw, 18px);
transition: transform 0.65s cubic-bezier(0.22, 0.9, 0.3, 1);
will-change: transform;
}
[data-module="ableset"] .as-track.no-anim,
[data-module="ableset"] .as-track.no-anim .as-pill { transition: none !important; }
[data-module="ableset"] .as-pill {
--p: 0%;
--sect: var(--accent);
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
height: min(78%, 22cqw);
padding: 0 0.8em;
border-radius: 0.35em;
font-size: clamp(9px, 26cqh, 64px);
font-weight: 700;
line-height: 1;
white-space: nowrap;
overflow: hidden;
color: var(--muted);
background: color-mix(in srgb, var(--text) 8%, var(--panel));
border: 2px solid transparent;
transition: background 0.4s, color 0.4s, border-color 0.4s, opacity 0.5s, transform 0.65s cubic-bezier(0.22, 0.9, 0.3, 1);
}
[data-module="ableset"] .as-pill.has-color { background: color-mix(in srgb, var(--sect) 22%, var(--panel)); }
[data-module="ableset"] .as-pill.is-past { opacity: 0.45; }
[data-module="ableset"] .as-pill.is-next { border-color: color-mix(in srgb, var(--sect) 60%, transparent); color: var(--text); }
[data-module="ableset"] .as-pill.is-current {
color: var(--accent-text);
background: color-mix(in srgb, var(--sect) 55%, var(--panel));
}
[data-module="ableset"] .as-pill-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: var(--sect);
}
[data-module="ableset"] .as-pill.is-current .as-pill-fill { width: var(--p); }
[data-module="ableset"] .as-pill-text { position: relative; }
[data-module="ableset"] .as-pill.is-empty { color: var(--muted); }
/* the lottery wheel: faces on a drum turning towards the viewer */
[data-module="ableset"] .is-wheel .as-viewport {
perspective: 900px;
-webkit-mask-image: linear-gradient(180deg, transparent, #000 22%, #000 78%, transparent);
mask-image: linear-gradient(180deg, transparent, #000 22%, #000 78%, transparent);
}
[data-module="ableset"] .is-wheel .as-track {
--face-h: 30px;
inset: 50% 0 auto 0;
display: block;
height: 0;
transform-style: preserve-3d;
transition: none;
}
[data-module="ableset"] .is-wheel .as-pill {
position: absolute;
left: 50%;
top: 0;
translate: -50% 0;
height: var(--face-h);
justify-content: center;
min-width: 55%;
max-width: 94%;
font-size: clamp(9px, calc(var(--face-h) * 0.55), 72px);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
/* ── SETLIST ─────────────────────────────────────────────────────── */
[data-module="ableset"] .as-setlist .as-body { justify-content: flex-start; gap: 6px; padding: 8px; }
[data-module="ableset"] .as-set-head {
flex-shrink: 0;
font-size: 0.8rem;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="ableset"] .as-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 3px; scrollbar-width: thin; }
[data-module="ableset"] .as-row {
--p: 0%;
--song: var(--accent);
position: relative;
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.35em 0.55em;
border-radius: 6px;
font-size: clamp(11px, 5.5cqw, 19px);
background: color-mix(in srgb, var(--text) 5%, transparent);
overflow: hidden;
flex-shrink: 0;
}
[data-module="ableset"] .as-row-fill { position: absolute; inset: 0 auto 0 0; width: 0; background: color-mix(in srgb, var(--accent) 22%, transparent); }
[data-module="ableset"] .as-row.is-current .as-row-fill { width: var(--p); }
[data-module="ableset"] .as-row-name { position: relative; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
[data-module="ableset"] .as-row-dur { position: relative; color: var(--muted); font-size: 0.85em; }
[data-module="ableset"] .as-swatch { position: relative; width: 0.55em; height: 0.55em; border-radius: 50%; background: var(--song); flex-shrink: 0; }
[data-module="ableset"] .as-row.is-past { opacity: 0.4; }
[data-module="ableset"] .as-row.is-current { font-weight: 700; color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); box-shadow: inset 3px 0 0 var(--accent); }
[data-module="ableset"] .as-row.is-next { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--text) 40%, transparent); }
[data-module="ableset"] .as-row.is-queued { box-shadow: inset 3px 0 0 var(--warn); color: var(--warn); }
[data-module="ableset"] .as-set-foot { flex-shrink: 0; font-size: 0.85rem; color: var(--muted); text-align: right; }
[data-module="ableset"] .as-empty { color: var(--muted); padding: 1em; text-align: center; }
/* ── single readouts: transport, tempo, times, section, next ────── */
[data-module="ableset"] .as-big-box { display: flex; justify-content: center; min-width: 0; width: 100%; }
[data-module="ableset"] .as-big {
font-size: clamp(14px, 38cqh, 220px);
font-weight: 800;
line-height: 1;
color: var(--tone);
text-align: center;
transition: color 0.3s;
}
[data-module="ableset"] .as-big small { font-size: 0.35em; font-weight: 700; color: var(--muted); }
[data-module="ableset"] .as-caption {
text-align: center;
font-size: clamp(8px, 12cqh, 30px);
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
[data-module="ableset"] .as-sub {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.25em 1em;
font-size: clamp(8px, 12cqh, 34px);
color: var(--muted);
text-align: center;
white-space: nowrap;
}
[data-module="ableset"] .as-sub:empty { display: none; }
/* A tone washes the tile's background, so a wall of them reads at a glance. */
[data-module="ableset"] .as-transport,
[data-module="ableset"] .as-song-time,
[data-module="ableset"] .as-section,
[data-module="ableset"] .as-status,
[data-module="ableset"] .as-next-song {
background: color-mix(in srgb, var(--tone) 9%, transparent);
transition: background 0.3s;
}
[data-module="ableset"] .as-root[data-tone="muted"],
[data-module="ableset"] .as-root[data-tone=""] { background: none; }
[data-module="ableset"] .as-transport .as-big { text-transform: uppercase; font-size: clamp(12px, 34cqh, 180px); }
[data-module="ableset"] .as-transport .as-loop { color: var(--warn); font-weight: 700; }
[data-module="ableset"] .as-tempo .as-big { color: var(--text); }
[data-module="ableset"] .as-beats { display: flex; justify-content: center; gap: clamp(3px, 3cqw, 14px); flex-shrink: 0; }
[data-module="ableset"] .as-beats i {
width: clamp(6px, 11cqh, 30px);
aspect-ratio: 1;
border-radius: 50%;
background: color-mix(in srgb, var(--text) 14%, var(--panel));
transition: background 0.08s;
}
[data-module="ableset"] .as-beats i.is-lit { background: var(--text); }
[data-module="ableset"] .as-beats i.is-down.is-lit { background: var(--accent); }
[data-module="ableset"] .as-song-time .as-bar { height: clamp(3px, 5cqh, 14px); }
[data-module="ableset"] .as-section .as-big { text-transform: uppercase; }
[data-module="ableset"] .as-section.has-song-color .as-big { color: var(--song); }
[data-module="ableset"] .as-section .as-count { color: var(--tone); font-weight: 700; }
[data-module="ableset"] .as-sect-bar {
--p: 0%;
height: clamp(3px, 4cqh, 12px);
border-radius: 999px;
background: color-mix(in srgb, var(--text) 12%, var(--panel));
overflow: hidden;
flex-shrink: 0;
}
[data-module="ableset"] .as-sect-bar > div { height: 100%; width: var(--p); background: var(--tone); }
[data-module="ableset"] .as-next-song .as-big { color: var(--text); font-size: clamp(12px, 30cqh, 150px); }
[data-module="ableset"] .as-queued {
align-self: center;
font-size: clamp(8px, 11cqh, 28px);
font-weight: 700;
padding: 0.2em 0.6em;
border-radius: 0.3em;
color: var(--accent-text);
background: var(--warn);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* ── STATUS ──────────────────────────────────────────────────────── */
[data-module="ableset"] .as-status .as-body { justify-content: flex-start; padding: 8px 10px; }
[data-module="ableset"] .as-rows { display: flex; flex-direction: column; gap: 4px; }
[data-module="ableset"] .as-srow {
--dot: var(--muted);
display: flex;
align-items: center;
gap: 0.55em;
font-size: clamp(11px, 4.5cqw, 18px);
padding: 0.3em 0.5em;
border-radius: 6px;
background: color-mix(in srgb, var(--dot) 10%, transparent);
}
[data-module="ableset"] .as-srow[data-tone="ok"] { --dot: var(--accent); }
[data-module="ableset"] .as-srow[data-tone="warn"] { --dot: var(--warn); }
[data-module="ableset"] .as-srow[data-tone="alert"] { --dot: var(--danger); }
[data-module="ableset"] .as-srow[data-tone=""] { --dot: var(--text); background: color-mix(in srgb, var(--text) 5%, transparent); }
[data-module="ableset"] .as-srow i { width: 0.6em; height: 0.6em; border-radius: 50%; background: var(--dot); flex-shrink: 0; }
[data-module="ableset"] .as-sk { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
[data-module="ableset"] .as-sv { font-weight: 700; color: var(--dot); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60%; }
[data-module="ableset"] .as-srow[data-tone=""] .as-sv { color: var(--text); }
+19 -2
View File
@@ -123,10 +123,13 @@ export function framesToTc(n, nominal, df) {
* elapsed ms otherwise, or null when the snapshot can't say. The values as * elapsed ms otherwise, or null when the snapshot can't say. The values as
* of the snapshot (`elapsedMs` / `remainingMs`) are preferred and carried * of the snapshot (`elapsedMs` / `remainingMs`) are preferred and carried
* forward while running; `startedAt` is the fallback when they are absent. * forward while running; `startedAt` is the fallback when they are absent.
* A timer measured at its own instant carries `asOf` (a phase-locked
* ProPresenter value — see the server's phase.js) which beats the source's
* snapshot time.
*/ */
export function valueAt(timer, sourceUpdatedAt, now) { export function valueAt(timer, sourceUpdatedAt, now) {
const running = timer.state === 'running'; const running = timer.state === 'running';
const at = Number(sourceUpdatedAt) || now; const at = Number(timer.asOf) || Number(sourceUpdatedAt) || now;
const dt = running ? Math.max(0, now - at) : 0; const dt = running ? Math.max(0, now - at) : 0;
const has = (v) => v !== null && v !== undefined && Number.isFinite(v); const has = (v) => v !== null && v !== undefined && Number.isFinite(v);
if (timer.kind === 'countdown') { if (timer.kind === 'countdown') {
@@ -647,7 +650,21 @@ export default function create({ root, moduleApi }) {
statusTone = timer.status?.tone || 'muted'; statusTone = timer.status?.tone || 'muted';
} }
} else { } else {
const value = valueAt(timer, source.updatedAt, now); let value = valueAt(timer, source.updatedAt, now);
// A running card must never tick backwards: an anchor correction or
// clock-offset wobble can re-place a second boundary by a few
// hundred ms, and a digit that regresses reads as broken. A small
// step against the direction of travel holds the shown value (time
// re-passes it within the jitter bound); a big one is a real
// reset/seek and snaps.
if (timer.state === 'running' && value !== null) {
const dir = timer.kind === 'countdown' ? -1 : 1;
const prev = card.mono;
if (prev && (value - prev) * dir < 0 && Math.abs(value - prev) < 1500) value = prev;
card.mono = value;
} else {
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); tone = toneFor(timer, value);
statusText = timer.status?.text || defaultStatus(timer, value, tone); statusText = timer.status?.text || defaultStatus(timer, value, tone);
+136 -30
View File
@@ -10,13 +10,23 @@
* onUpdate roughly 4×/s: * onUpdate roughly 4×/s:
* *
* { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy * { state: 'running'|'stopped'|'nosignal', time, fps, df } healthy
* { error: '<what is wrong and how to fix it>' } not capturing * { error: '<short, for the tiles>', detail: '<for admin/log>' } not capturing
* *
* The capture binary is per-machine (gitignored): if it is missing this * Error strings are two-tier on purpose: `error` is what a dashboard tile
* tries one swiftc compile of the source next to it, and otherwise says to * shows (short — "Not configured — see Admin"), `detail` is the full story
* build it on any same-architecture Mac and copy it over. A dead child is * for the admin health line and the log.
* restarted with backoff — fast for a crash, slow for config-shaped *
* failures (unknown device, ambiguous name) that never fix themselves. * The capture binary is per-machine (gitignored), and a catalog install or
* update ships source only — the update even wipes a previously built
* binary. ensureCaptureBinary() therefore rebuilds it with swiftc in the
* background as part of coming up: kicked when the LTC source starts and by
* the admin device picker, never on the event loop (a blocking build once
* froze the whole server — see spl-meter's history), built to a temp name
* and renamed so a killed build can't leave a half-written "binary", and
* rebuilt when a module update leaves the source newer than the binary.
* A dead capture child is restarted with backoff — fast for a crash, slow
* for config-shaped failures (unknown device, ambiguity) that never fix
* themselves.
* *
* macOS gotcha this file can't fix: microphone permission. A ProdDash * macOS gotcha this file can't fix: microphone permission. A ProdDash
* server launched over SSH or from a bare launchd job gets SILENT ZEROS * server launched over SSH or from a bare launchd job gets SILENT ZEROS
@@ -27,7 +37,7 @@
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { spawn, spawnSync } = require('child_process'); const { spawn } = require('child_process');
const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc'); const { LtcDecoder, ChannelExtractor, fmtTc } = require('./ltc');
const RESTART_MS = 5000; const RESTART_MS = 5000;
@@ -41,6 +51,100 @@ const RUNNING_WINDOW_MS = 400;
/** stderr line the capture prints once the device is open. */ /** stderr line the capture prints once the device is open. */
const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/; const CAPTURING_RE = /capturing "(.+)": (\d+) channels @ (\d+) Hz/;
/* ── the capture tool: build it when the install didn't bring one ──────── */
/** Tile-facing words (short); the detail goes to admin health and the log. */
const TOOL_BUILDING = 'Capture tool building…';
const TOOL_MISSING = 'Capture tool unavailable — see Admin';
const BUILD_HELP = 'LTC capture tool could not be built here — build it on any Mac of the '
+ 'same architecture with `swiftc -O -o ltc-capture ltc-capture.swift` (both files are in '
+ 'the module folder) and copy the binary next to ltc-capture.swift';
let building = false;
let buildFailed = false;
/**
* Make sure the capture tool exists, building it in the background when it
* does not — module scope, so the LTC source can kick it the moment it
* starts and the admin device picker can kick it before any device is
* configured (a listener-only build would deadlock: no tool → no device
* list → no device → no listener). A catalog install ships source only and
* a catalog update deletes the module folder (old binary included), so this
* IS the install step for the tool.
*
* Never blocks (a synchronous build once held the whole server's event loop
* — every module — for the duration). Builds to a temp name and renames, so
* a killed or crashed build can't leave a truncated file that looks like a
* ready binary. A binary older than the source (a module update over an
* existing folder) keeps serving while a replacement builds behind it.
*
* @returns {{ready:boolean, error:string, detail:string}} `ready` = a binary
* is usable right now; otherwise `error` is the short tile text and
* `detail` the full one.
*/
function ensureCaptureBinary({ moduleDir, log, onReady }) {
const bin = path.join(moduleDir, 'ltc-capture');
const src = bin + '.swift';
const ok = { ready: true, error: '', detail: '' };
let haveBin = false;
let stale = false;
try {
haveBin = fs.existsSync(bin);
if (haveBin && fs.existsSync(src)) stale = fs.statSync(src).mtimeMs > fs.statSync(bin).mtimeMs;
} catch { /* treat as missing */ }
if (haveBin && !stale) return ok;
if (!fs.existsSync(src)) {
return haveBin ? ok : { ready: false, error: TOOL_MISSING, detail: 'ltc-capture.swift is missing from the module folder' };
}
const notYet = haveBin ? ok : {
ready: false,
error: building || !buildFailed ? TOOL_BUILDING : TOOL_MISSING,
detail: building || !buildFailed ? 'Building the LTC capture tool — ready in a moment' : BUILD_HELP,
};
if (building || buildFailed) return notYet;
building = true;
const tmp = bin + '.building';
log(stale ? 'ltc-capture is older than its source — rebuilding it in the background'
: 'ltc-capture binary missing — building it in the background');
const build = spawn('swiftc', ['-O', '-o', tmp, 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);
giveUp.unref?.();
build.on('error', (err) => {
clearTimeout(giveUp);
building = false;
buildFailed = true;
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
log(`swiftc could not be run: ${err.message}`);
});
build.on('exit', (code) => {
clearTimeout(giveUp);
building = false;
if (code === 0 && fs.existsSync(tmp)) {
try {
fs.renameSync(tmp, bin);
} catch (err) {
buildFailed = true;
log(`ltc-capture built but could not be moved into place: ${err.message}`);
return;
}
buildFailed = false;
log('ltc-capture built');
if (typeof onReady === 'function') onReady();
return;
}
buildFailed = true;
try { fs.rmSync(tmp, { force: true }); } catch { /* nothing to clean */ }
log(`ltc-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
});
return notYet;
}
/** /**
* @param {{device:string, channel:number, moduleDir:string, log:Function, * @param {{device:string, channel:number, moduleDir:string, log:Function,
* onUpdate:(u:object)=>void}} opts * onUpdate:(u:object)=>void}} opts
@@ -75,26 +179,21 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
return 'nosignal'; 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() { function start() {
if (stopped) return; if (stopped) return;
const missing = ensureBinary(); const tool = ensureCaptureBinary({
if (missing) { moduleDir,
report({ error: missing }); log,
scheduleRestart(BAD_CONFIG_RETRY_MS); onReady() {
// The build this start() was waiting on landed — go now.
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
},
});
if (!tool.ready) {
report({ error: tool.error, detail: tool.detail });
// A build in flight restarts us itself when it lands; anything else is
// config-shaped and should back off hard rather than spin.
if (tool.error !== TOOL_BUILDING) scheduleRestart(BAD_CONFIG_RETRY_MS);
return; return;
} }
@@ -113,7 +212,10 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
const channels = Number(m[2]); const channels = Number(m[2]);
const rate = Number(m[3]); const rate = Number(m[3]);
if (channel > channels) { if (channel > channels) {
report({ error: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.` }); report({
error: 'Channel not on this device — see Admin',
detail: `"${m[1]}" has ${channels} input channels — channel ${channel} is configured. Fix it in Admin.`,
});
try { child.kill(); } catch { /* exiting anyway */ } try { child.kill(); } catch { /* exiting anyway */ }
return; return;
} }
@@ -135,17 +237,21 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
}); });
child.on('error', (err) => { child.on('error', (err) => {
report({ error: `LTC capture failed to start: ${err.message}` }); report({ error: TOOL_MISSING, detail: `LTC capture failed to start: ${err.message}` });
}); });
child.on('exit', (code) => { child.on('exit', (code) => {
child = null; child = null;
if (stopped) return; if (stopped) return;
// The capture's own failure text (unknown device, ambiguous name, // The capture's own failure text (unknown device, ambiguous name,
// permission hint) is the most useful thing we can show. // permission hint) is the most useful DETAIL we have; the tile just
// needs to know the input is down and where to fix it.
const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l)); const said = stderrTail.split('\n').reverse().find((l) => l.includes('ltc-capture:') && !CAPTURING_RE.test(l));
const quickDeath = Date.now() - startedAt < 3000; const quickDeath = Date.now() - startedAt < 3000;
report({ error: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting') }); report({
error: quickDeath ? 'Audio device unavailable — see Admin' : 'Audio capture restarting…',
detail: (said ? said.replace(/^.*ltc-capture:\s*/, '') : `LTC capture exited (code ${code})`) + (quickDeath ? '' : ' — restarting'),
});
scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS); scheduleRestart(quickDeath ? BAD_CONFIG_RETRY_MS : RESTART_MS);
}); });
} }
@@ -188,4 +294,4 @@ function createLtcListener({ device, channel, moduleDir, log, onUpdate }) {
}; };
} }
module.exports = { createLtcListener }; module.exports = { createLtcListener, ensureCaptureBinary };
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "propresenter-timers", "id": "propresenter-timers",
"name": "Timers", "name": "Timers",
"version": "1.4.1", "version": "1.5.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": [
+113
View File
@@ -0,0 +1,113 @@
'use strict';
/**
* Phase lock for whole-second timer reports (the ProPresenter poller).
*
* ProPresenter reports a running timer as whole seconds — trunc(value):
* "12:34" means the true remaining time is anywhere in [754 s, 755 s). The
* old poller re-anchored a tile's local count at the bottom of that interval
* on every poll, so the displayed digit ran up to a second ahead and then
* got yanked back a step whenever a fresh poll happened to sample the next
* flip later than the last one did — the poll clock and ProPresenter's
* second boundaries drift through each other, so tiles flickered
* "…12:33 → 12:34 → 12:33…" about once a second.
*
* This tracker turns the quantized reports into ONE stable anchor
* { valueMs, at } — the continuous value at a fixed instant — which the
* client extrapolates from. Rules:
*
* - The only real information in a poll is a FLIP: the report stepping one
* second in the direction of travel. At the flip instant the true value
* equals the interval boundary just crossed, and the flip happened between
* the last two polls, so their midpoint dates it to within half a poll.
* - While a poll's report matches what the anchor predicts (within TOL_MS
* around the reported second's interval), the anchor HOLDS — the same
* numbers are republished, so consumers count along one steady clock
* instead of a freshly re-phased one per sample.
* - A report the anchor can't explain re-anchors: at the flip boundary when
* this poll saw a clean one-second flip, else mid-interval (first sight,
* an operator reset/seek, a resume, or clock drift past TOL_MS).
* - Before the first flip the anchor sits mid-interval: at most ±½ s off,
* and the first flip (≤1 s away) locks it — instead of the old guaranteed
* whole-second bias one way.
*
* trunc() semantics also cover overrun: a countdown at −12.4 s reports
* "-12", so a negative countdown's interval is (W−1 s, W] and its flip
* boundary is W itself, where a positive countdown's are [W, W+1 s) and
* W+1 s. Around zero ("00:00:00" covers (−1 s, 1 s)) the flip in from above
* crosses +1 s.
*/
/** How far a report may sit outside the anchor's prediction before it means
something real. A flip is dated to the midpoint of two polls, so its own
error bound is ±½ poll (~±300 ms at the 500 ms cadence, more when a poll
runs long); the tolerance must sit ABOVE that or consecutive flips
re-anchor each other forever, and safely below a full second so a real
one-second seek still registers. */
const TOL_MS = 450;
/** The continuous-value interval implied by a whole-second report. */
function intervalFor(kind, wholeMs) {
if (kind === 'countdown') {
if (wholeMs === 0) return { lo: -1000, hi: 1000 };
if (wholeMs < 0) return { lo: wholeMs - 1000, hi: wholeMs };
}
return { lo: wholeMs, hi: wholeMs + 1000 };
}
/** The continuous value at the instant a running timer's report flips TO wholeMs. */
function boundaryFor(kind, wholeMs) {
if (kind === 'countdown') return wholeMs <= 0 ? intervalFor(kind, wholeMs).hi : wholeMs + 1000;
return wholeMs;
}
/**
* One tracker per timer. Feed one observation per poll; get back what to
* publish: `{ valueMs, asOf }` — a continuous value at a fixed instant,
* stable across polls while the reports stay consistent with it. Not
* running (or no value): the report is returned verbatim, dated at the poll.
*/
function createTimerPhase() {
let anchor = null; // { valueMs, at }
let kind = '';
let lastWhole = null;
let lastAt = 0;
let wasRunning = false;
function observe({ kind: rawKind, running, wholeMs, pollAt }) {
const k = rawKind === 'countdown' ? 'countdown' : 'elapsed';
const dir = k === 'countdown' ? -1 : 1;
const flip = Boolean(running) && wasRunning && k === kind && lastAt > 0
&& lastWhole !== null && wholeMs !== null
&& wholeMs - lastWhole === dir * 1000;
const prevAt = lastAt;
lastWhole = wholeMs;
lastAt = pollAt;
wasRunning = Boolean(running);
if (k !== kind) anchor = null;
kind = k;
if (!running || wholeMs === null) {
anchor = null;
return { valueMs: wholeMs, asOf: pollAt };
}
const { lo, hi } = intervalFor(k, wholeMs);
// A mid-interval anchor is provisional — a guess up to ±½ s off that the
// tolerance check may fail to catch for a long time (its error only
// shows at a poll landing more than TOL_MS past a flip). The first
// clean flip is strictly better evidence (±½ poll), so it always wins.
if (anchor && !(anchor.provisional && flip)) {
const predicted = anchor.valueMs + dir * (pollAt - anchor.at);
if (predicted >= lo - TOL_MS && predicted <= hi + TOL_MS) {
return { valueMs: anchor.valueMs, asOf: anchor.at };
}
}
anchor = flip
? { valueMs: boundaryFor(k, wholeMs), at: (prevAt + pollAt) / 2, provisional: false }
: { valueMs: (lo + hi) / 2, at: pollAt, provisional: true };
return { valueMs: anchor.valueMs, asOf: anchor.at };
}
return { observe };
}
module.exports = { createTimerPhase, TOL_MS };
@@ -0,0 +1,205 @@
'use strict';
/**
* Self-test for phase.js — the whole-second phase lock — plus the client's
* never-tick-backwards guard, driven by a simulated ProPresenter:
*
* node modules/propresenter-timers/phase.selftest.js
*
* The simulation runs a true continuous timer, reports it the way
* ProPresenter does (truncated whole seconds, sampled every ~500 ms with
* jitter), feeds the reports through createTimerPhase(), then renders the
* displayed value at 25 ms steps exactly the way client.js does (extrapolate
* from { valueMs, asOf }, clamp regressions under 1.5 s). Asserts:
*
* - the displayed digits NEVER step against the direction of travel
* (the reported bug: countdown tiles flicking back to the previous
* second about once a second),
* - each displayed second dwells ~1 s,
* - the published anchor stays within tolerance of the true value,
* - operator resets/seeks snap within a poll or two,
* - pause → resume re-locks,
* - a ProPresenter whose clock drifts against the server's stays locked.
*/
const { createTimerPhase, TOL_MS } = require('./phase');
let checks = 0;
let failures = 0;
function ok(name, cond, detail) {
checks += 1;
if (!cond) {
failures += 1;
console.error(`FAIL ${name}${detail ? ` — ${detail}` : ''}`);
}
}
/** Deterministic pseudo-random (so a failure reproduces). */
function makeRand(seed) {
let s = seed >>> 0;
return () => {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 2 ** 32;
};
}
/** ProPresenter's report: whole seconds, truncated toward zero. */
const trunc = (ms) => Math.trunc(ms / 1000) * 1000;
/**
* Run one scenario. `trueValueAt(t)` is the continuous value (countdown
* remaining or elapsed) on the SERVER's clock; null = not reported (paused
* timers report a frozen value via `frozen`).
*/
function simulate({ name, kind, seed, durationMs, trueValueAt, runningAt = () => true, pollMs = 500, jitterMs = 120 }) {
const rand = makeRand(seed);
const phase = createTimerPhase();
const dir = kind === 'countdown' ? -1 : 1;
const polls = [];
for (let t = 0; t <= durationMs;) {
const running = runningAt(t);
const value = trueValueAt(t);
polls.push({ t, out: phase.observe({ kind, running, wholeMs: value === null ? null : trunc(value), pollAt: t }), running });
t += pollMs + Math.floor(rand() * jitterMs);
}
// Render like client.js: between polls, extrapolate from the latest
// anchor; clamp sub-1.5 s regressions while running.
const shown = []; // { t, sec, err }
let mono = null;
let p = 0;
for (let t = polls[0].t; t <= durationMs; t += 25) {
while (p + 1 < polls.length && polls[p + 1].t <= t) p += 1;
const { out, running } = polls[p];
if (!running || out.valueMs === null) {
mono = null;
continue;
}
let value = out.valueMs + dir * (t - out.asOf);
if (mono !== null && (value - mono) * dir < 0 && Math.abs(value - mono) < 1500) value = mono;
mono = value;
const truth = trueValueAt(t);
shown.push({ t, sec: Math.trunc(value / 1000), value, err: truth === null ? 0 : value - truth });
}
return { name, dir, shown };
}
/** Assertions shared by the steady scenarios. `allowOdd` admits dwell pairs
from legitimate corrections (absorbing clock drift re-places a boundary
by up to TOL_MS once in a while). */
function assertSteady({ name, dir, shown }, { settleMs = 1600, maxErr = 700, allowOdd = 0 } = {}) {
let regressions = 0;
let maxAbsErr = 0;
const changes = [];
for (let i = 1; i < shown.length; i += 1) {
const step = shown[i].sec - shown[i - 1].sec;
if (step !== 0 && Math.sign(step) !== dir) regressions += 1;
if (step !== 0) changes.push(shown[i].t);
if (shown[i].t - shown[0].t > settleMs) maxAbsErr = Math.max(maxAbsErr, Math.abs(shown[i].err));
}
// Whole dwells only (drop the partial first and trailing segments), after
// the lock settles — the first observed flip corrects the provisional
// mid-interval anchor once, stretching or squeezing a boundary in the
// timer's first moments, which no one can see on a wall. A countdown
// crossing zero legitimately shows "0" for ~2 s — trunc keeps the digit
// for the whole (−1 s, +1 s) span, like ProPresenter's own display — so
// allow one ~2 s dwell; everything else must be ~1 s.
const settled = shown.length ? shown[0].t + 2500 : 0;
const dwells = changes.slice(1).map((t, i) => t - changes[i]).filter((d, i) => changes[i + 1] > settled + d);
const near = (d, target) => Math.abs(d - target) <= 200;
const long = dwells.filter((d) => near(d, 2000)).length;
const bad = dwells.filter((d) => !near(d, 1000) && !near(d, 2000)).length;
ok(`${name}: no digit ever steps backwards`, regressions === 0, `${regressions} regressions`);
ok(`${name}: seconds dwell ~1 s (${dwells.length} sampled)`, dwells.length > 5 && bad <= allowOdd && long <= 1,
`${bad} odd dwells, ${long} ~2 s dwells [${dwells.join(',')}]`);
ok(`${name}: locked within ${maxErr} ms of the true value`, maxAbsErr <= maxErr, `max ${Math.round(maxAbsErr)} ms`);
}
/* 1. Plain countdown, many sub-second phases and poll seeds. */
for (let i = 0; i < 12; i += 1) {
const phase0 = (i * 83) % 1000;
const run = simulate({
name: `countdown q=${phase0}ms seed=${i}`,
kind: 'countdown',
seed: 1000 + i,
durationMs: 30000,
trueValueAt: (t) => 20000 + phase0 - t, // runs through zero into overrun
});
assertSteady(run);
}
/* 2. Elapsed timer (counts up). */
for (let i = 0; i < 6; i += 1) {
const run = simulate({
name: `elapsed seed=${i}`,
kind: 'elapsed',
seed: 2000 + i,
durationMs: 25000,
trueValueAt: (t) => 400 + i * 137 + t,
});
assertSteady(run);
}
/* 3. Operator reset mid-run: value jumps back up to full length. */
{
const run = simulate({
name: 'reset',
kind: 'countdown',
seed: 7,
durationMs: 24000,
trueValueAt: (t) => (t < 12000 ? 15000 - t : 60000 - (t - 12000)),
});
// After the reset the shown value must reach the new region within ~2 polls.
const after = run.shown.filter((s) => s.t >= 13400);
ok('reset: snaps to the new value', after.length && after.every((s) => Math.abs(s.err) <= 1300), after.length ? `err ${Math.round(Math.max(...after.map((s) => Math.abs(s.err))))} ms` : 'no samples');
// Before and after the jump, digits stay monotonic within each phase.
const pre = { ...run, shown: run.shown.filter((s) => s.t < 12000) };
assertSteady(pre, { maxErr: 700 });
}
/* 4. Pause, then resume: no stale-anchor explosion. */
{
const frozen = 8000;
const run = simulate({
name: 'pause/resume',
kind: 'countdown',
seed: 11,
durationMs: 26000,
trueValueAt: (t) => (t < 6000 ? 14000 - t : t < 14000 ? frozen : frozen - (t - 14000)),
runningAt: (t) => t < 6000 || t >= 14000,
});
const resumed = { ...run, shown: run.shown.filter((s) => s.t >= 14000) };
assertSteady(resumed);
}
/* 5. ProPresenter's clock drifts 0.2% against the server's: the anchor
re-locks over time instead of accumulating a visible error. */
{
const run = simulate({
name: 'clock drift',
kind: 'countdown',
seed: 13,
durationMs: 120000,
trueValueAt: (t) => 130000 - t * 1.002,
});
// Drift accumulates ~2 ms/s; every ~4 min the anchor re-places a boundary
// by up to TOL_MS to absorb it — a slightly long/short dwell pair, never
// a backwards step (the separate regression check still demands zero).
assertSteady(run, { maxErr: 700 + TOL_MS, allowOdd: 2 });
}
/* 6. Idle timers pass through untouched. */
{
const phase = createTimerPhase();
const a = phase.observe({ kind: 'countdown', running: false, wholeMs: 300000, pollAt: 1000 });
ok('idle passes through', a.valueMs === 300000 && a.asOf === 1000);
const b = phase.observe({ kind: 'countdown', running: false, wholeMs: null, pollAt: 1500 });
ok('missing value passes through', b.valueMs === null && b.asOf === 1500);
}
if (failures) {
console.error(`\n${failures} of ${checks} checks FAILED`);
process.exit(1);
}
console.log(`phase.selftest: all ${checks} checks passed`);
+77 -16
View File
@@ -81,7 +81,8 @@
const http = require('http'); const http = require('http');
const path = require('path'); const path = require('path');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const { createLtcListener } = require('./ltc-listener'); const { createLtcListener, ensureCaptureBinary } = require('./ltc-listener');
const { createTimerPhase } = require('./phase');
const manifest = require('./module.json'); const manifest = require('./module.json');
/** This module's id — left out of provider discovery (we don't consume ourselves). */ /** This module's id — left out of provider discovery (we don't consume ourselves). */
@@ -209,6 +210,7 @@ function sanitizeTimer(t) {
label: String(t.label || id).slice(0, 120), label: String(t.label || id).slice(0, 120),
kind: KINDS.has(t.kind) ? t.kind : 'elapsed', kind: KINDS.has(t.kind) ? t.kind : 'elapsed',
state: STATES.has(t.state) ? t.state : 'stopped', state: STATES.has(t.state) ? t.state : 'stopped',
asOf: numOrNull(t.asOf),
startedAt: numOrNull(t.startedAt), startedAt: numOrNull(t.startedAt),
elapsedMs: numOrNull(t.elapsedMs), elapsedMs: numOrNull(t.elapsedMs),
remainingMs: numOrNull(t.remainingMs), remainingMs: numOrNull(t.remainingMs),
@@ -250,6 +252,8 @@ function createProPresenterSource({ config, log }) {
let updatedAt = 0; let updatedAt = 0;
/** uuid → { name, index, type, allowsOverrun, targetMs, detail } from /v1/timers, in list order. */ /** uuid → { name, index, type, allowsOverrun, targetMs, detail } from /v1/timers, in list order. */
let timerConfigs = new Map(); let timerConfigs = new Map();
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
const phases = new Map();
function url(target) { function url(target) {
const built = new URL(`http://${host}:${port}${target}`); const built = new URL(`http://${host}:${port}${target}`);
@@ -325,9 +329,21 @@ function createProPresenterSource({ config, log }) {
const atStart = value === null || (kind === 'countdown' ? targetMs !== null && value === targetMs : value === 0); const atStart = value === null || (kind === 'countdown' ? targetMs !== null && value === targetMs : value === 0);
state = atStart ? 'idle' : 'paused'; state = atStart ? 'idle' : 'paused';
} }
const elapsedMs = kind === 'elapsed' ? value : targetMs !== null && value !== null ? targetMs - value : null; // ProPresenter reports whole (truncated) seconds. Re-anchoring the tiles'
const remainingMs = kind === 'countdown' ? value : targetMs !== null && value !== null ? targetMs - value : null; // local count at the bottom of that second every poll made the digits run
const startedAt = state === 'running' && elapsedMs !== null ? now - elapsedMs : null; // ahead and flick back a step about once a second — phase.js turns the
// reports into one stable anchor { valueMs, asOf } the tiles count from.
let phase = phases.get(uuid);
if (!phase) {
phase = createTimerPhase();
phases.set(uuid, phase);
}
const anchored = phase.observe({ kind, running: state === 'running', wholeMs: value, pollAt: now });
const v = anchored.valueMs;
const asOf = anchored.asOf;
const elapsedMs = kind === 'elapsed' ? v : targetMs !== null && v !== null ? targetMs - v : null;
const remainingMs = kind === 'countdown' ? v : targetMs !== null && v !== null ? targetMs - v : null;
const startedAt = state === 'running' && elapsedMs !== null ? asOf - elapsedMs : null;
const text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped'; const text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped';
const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted'; const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted';
return { return {
@@ -335,6 +351,7 @@ function createProPresenterSource({ config, log }) {
label: (live && live.name) || (cfg && cfg.name) || 'Timer', label: (live && live.name) || (cfg && cfg.name) || 'Timer',
kind, kind,
state, state,
asOf,
startedAt, startedAt,
elapsedMs, elapsedMs,
remainingMs, remainingMs,
@@ -367,6 +384,9 @@ function createProPresenterSource({ config, log }) {
// Timers reported live but missing from the (possibly stale) config // Timers reported live but missing from the (possibly stale) config
// list still deserve a card — type unknown until the slow lane catches up. // list still deserve a card — type unknown until the slow lane catches up.
for (const [uuid, now_] of live) out.push(toTimer(uuid, null, now_, now)); for (const [uuid, now_] of live) out.push(toTimer(uuid, null, now_, now));
for (const uuid of phases.keys()) {
if (!out.some((t) => t.id === uuid)) phases.delete(uuid); // timer deleted in ProPresenter
}
return out; return out;
} }
@@ -376,7 +396,16 @@ function createProPresenterSource({ config, log }) {
return lastError ? 'error' : 'connecting'; return lastError ? 'error' : 'connecting';
} }
function message() { /** Short, for the tiles — a dashboard warns, the admin page explains.
The client prefixes the source label, so no "ProPresenter" here. */
function tileMessage() {
if (!configured) return 'Not configured — see Admin';
if (reachable) return `${timers.length} timer${timers.length === 1 ? '' : 's'}`;
return 'Unreachable';
}
/** The full story, for the admin health line. */
function adminMessage() {
if (!configured) return 'No ProPresenter host configured — set one in Admin, or switch ProPresenter timers off'; if (!configured) return 'No ProPresenter host configured — set one in Admin, or switch ProPresenter timers off';
if (reachable) return `Polling ${host}:${port}, ${timers.length} timer${timers.length === 1 ? '' : 's'}`; if (reachable) return `Polling ${host}:${port}, ${timers.length} timer${timers.length === 1 ? '' : 's'}`;
return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`; return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`;
@@ -384,7 +413,7 @@ function createProPresenterSource({ config, log }) {
function publish() { function publish() {
if (stopped) return; if (stopped) return;
emit({ status: status(), message: message(), timers, updatedAt }); emit({ status: status(), message: tileMessage(), timers, updatedAt });
} }
async function pollTick() { async function pollTick() {
@@ -430,7 +459,7 @@ function createProPresenterSource({ config, log }) {
clearTimeout(pollTimer); clearTimeout(pollTimer);
}, },
health() { health() {
return { status: status(), message: message() }; return { status: status(), message: adminMessage() };
}, },
}; };
} }
@@ -444,7 +473,9 @@ function createLtcSource({ config, log, moduleDir }) {
let emit = () => {}; let emit = () => {};
let listener = null; let listener = null;
let stopped = false; let stopped = false;
/** The listener's latest word: { state, time, fps, df, ageMs } | { error } | null while starting. */ /** The listener's latest word: { state, time, fps, df, ageMs } |
{ error, detail } (error = short tile text, detail = the admin/log
story) | null while starting. */
let word = null; let word = null;
function rateText() { function rateText() {
@@ -496,8 +527,16 @@ function createLtcSource({ config, log, moduleDir }) {
kind: 'ltc', kind: 'ltc',
start(e) { start(e) {
emit = e; emit = e;
// Kick the capture-tool build right away: a catalog install ships
// source only and a catalog update wipes any previously built binary,
// so coming up after an install/update must rebuild it without
// waiting for a device to be picked or a listener to need it.
ensureCaptureBinary({ moduleDir, log });
if (!device) { if (!device) {
word = { error: 'LTC timecode is on but no audio device is selected — pick one in Admin' }; word = {
error: 'Not configured — see Admin',
detail: 'LTC timecode is on but no audio device is selected — pick one in Admin',
};
publish(); publish();
return; return;
} }
@@ -519,7 +558,8 @@ function createLtcSource({ config, log, moduleDir }) {
listener = null; listener = null;
}, },
health() { health() {
return { status: status(), message: message() }; // The admin page gets the full story; the tiles got the short one.
return { status: status(), message: (word && word.error && word.detail) || message() };
}, },
}; };
} }
@@ -568,7 +608,15 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
let silenceTimer = null; let silenceTimer = null;
let backoff = BACKOFF_MIN_MS; let backoff = BACKOFF_MIN_MS;
function message() { /** Short, for the tiles; the lastError detail belongs to the admin line.
The client prefixes the source label, so it isn't repeated here. */
function tileMessage() {
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}`;
if (state === 'connecting') return 'Connecting…';
return 'Unavailable';
}
function adminMessage() {
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`; if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`;
if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…'; if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…';
return `unreachable — ${lastError}`; return `unreachable — ${lastError}`;
@@ -576,7 +624,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
function publish() { function publish() {
if (stopped) return; if (stopped) return;
emit({ label, status: state, message: message(), timers, updatedAt }); emit({ label, status: state, message: tileMessage(), timers, updatedAt });
} }
function accept(raw) { function accept(raw) {
@@ -754,7 +802,7 @@ function createModuleSource({ moduleId, name, shellPort, log }) {
if (!stopped && !stream && via === 'poll') connectStream(); if (!stopped && !stream && via === 'poll') connectStream();
}, },
health() { health() {
return { status: state, message: message() }; return { status: state, message: adminMessage() };
}, },
}; };
} }
@@ -992,12 +1040,17 @@ module.exports = {
for (const t of v.timers) { for (const t of v.timers) {
const out = { ...t, id: `${v.id}:${t.id}`, group: v.label }; const out = { ...t, id: `${v.id}:${t.id}`, group: v.label };
if (v.id === 'clock') out.status = { text: new Date(now).toISOString(), tone: 'ok' }; if (v.id === 'clock') out.status = { text: new Date(now).toISOString(), tone: 'ok' };
if (t.state === 'running' && v.updatedAt > 0) { // A timer measured at its own instant (asOf — a phase-locked
const dt = Math.max(0, now - v.updatedAt); // ProPresenter value) re-anchors from there; the rest from the
// source snapshot. Consumers see plain contract values as of `now`.
const at = Number(out.asOf) || v.updatedAt;
if (t.state === 'running' && at > 0) {
const dt = Math.max(0, now - at);
if (out.elapsedMs !== null && out.elapsedMs !== undefined) out.elapsedMs += dt; if (out.elapsedMs !== null && out.elapsedMs !== undefined) out.elapsedMs += dt;
if (out.remainingMs !== null && out.remainingMs !== undefined) out.remainingMs -= dt; if (out.remainingMs !== null && out.remainingMs !== undefined) out.remainingMs -= dt;
if (out.ltc && Number.isFinite(out.ltc.ageMs)) out.ltc = { ...out.ltc, ageMs: out.ltc.ageMs + dt }; if (out.ltc && Number.isFinite(out.ltc.ageMs)) out.ltc = { ...out.ltc, ageMs: out.ltc.ageMs + dt };
} }
delete out.asOf;
timers.push(out); timers.push(out);
} }
} }
@@ -1147,7 +1200,7 @@ module.exports = {
return out; return out;
}, },
routes() { routes({ log }) {
return { return {
'GET /state': (req, res) => { 'GET /state': (req, res) => {
sendJson(res, 200, { state: current ? current.getState() : emptyState() }); sendJson(res, 200, { state: current ? current.getState() : emptyState() });
@@ -1170,7 +1223,15 @@ module.exports = {
// The admin device picker asks this machine what it can hear. The // The admin device picker asks this machine what it can hear. The
// options shape matches the admin select's optionsRoute contract. // options shape matches the admin select's optionsRoute contract.
// Opening the picker also kicks the capture-tool build when a catalog
// install/update left only the source — without this, no tool meant
// no device list, and no device meant nothing would ever build it.
'GET /ltc/devices': (req, res) => { 'GET /ltc/devices': (req, res) => {
const tool = ensureCaptureBinary({ moduleDir: __dirname, log });
if (!tool.ready) {
sendJson(res, 200, { options: [], error: tool.detail || tool.error });
return;
}
const bin = path.join(__dirname, 'ltc-capture'); const bin = path.join(__dirname, 'ltc-capture');
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 }); const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
let err = ''; let err = '';
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env node
'use strict';
/**
* A pretend AbleSet for testing the ableset module without the booth rig.
* Speaks AbleSet's OSC API over UDP: answers /subscribe, /getValues and
* /unsubscribe, sends /heartbeat every 2 s and pushes value changes while a
* setlist plays through — sections, a tempo per song, a section that loops
* twice, a song queued out of order, and the time left in song and set.
*
* node tools/ableset-mock.js [port] (default 39051)
* --stopped start with the transport stopped (press nothing; it stays)
* --no-live report Ableton Live as not connected
* --speed <x> play x times faster than real time (default 1)
*
* Point the AbleSet module at host 127.0.0.1 and this port.
*/
const dgram = require('dgram');
const { encodeMessage, decodePacket } = require('../modules/ableset/osc');
const argv = process.argv.slice(2);
const PORT = Number(argv.find((a) => /^\d+$/.test(a))) || 39051;
const SPEED = Number(argv[argv.indexOf('--speed') + 1]) > 0 && argv.includes('--speed') ? Number(argv[argv.indexOf('--speed') + 1]) : 1;
let playing = !argv.includes('--stopped');
const live = !argv.includes('--no-live');
// name, tempo, [section name, bars] …
const SONGS = [
['1- CALL TO WORSHIP', 72, [['INTRO', 4], ['READING', 12], ['OUTRO', 4]]],
['2 -ALLELUIA - A', 125, [['INTRO', 8], ['CHORUS', 8], ['VERSE 1', 16], ['PRE-CHORUS', 8], ['CHORUS', 16], ['BRIDGE', 16], ['CHORUS', 16], ['ENDING', 8]]],
['3- WHAT A GOD - A', 76.81, [['INTRO', 4], ['VERSE 1', 16], ['CHORUS', 8], ['VERSE 2', 16], ['CHORUS', 8], ['BRIDGE', 8], ['BRIDGE', 8], ['CHORUS', 16], ['TAG', 4], ['ENDING', 8]]],
['4- The Blood - Gb', 68, [['INTRO', 4], ['VERSE 1', 8], ['CHORUS', 8], ['VERSE 2', 8], ['CHORUS', 8], ['OUTRO', 4]]],
['LOOP', 100, [['LOOP', 16]]],
['5- BE PRAISED- Db', 140, [['INTRO', 8], ['VERSE', 16], ['CHORUS', 16], ['INSTRUMENTAL', 8], ['CHORUS', 16], ['ENDING', 4]], '6/8'],
];
const COLORS = ['default', 'blue', 'default', 'red', 'default', 'purple'];
// Lay the songs end to end on one arrangement, as AbleSet does.
let cursor = 8;
const songs = SONGS.map(([name, tempo, parts, sig], i) => {
const [n, d] = (sig || '4/4').split('/').map(Number);
const bpb = (n * 4) / d;
const start = cursor;
const sections = parts.map(([sname, bars]) => {
const s = { name: sname, start: cursor, end: cursor + bars * bpb };
cursor = s.end;
return s;
});
return { name, tempo, sig: [n, d], start, end: cursor, sections, color: COLORS[i] || 'default' };
});
const secs = (song, beats) => (beats * 60) / song.tempo;
const songDuration = (song) => secs(song, song.end - song.start);
let songIdx = 1;
let beats = songs[songIdx].start + 40; // a little way into the second song
let loopsLeft = 0; // section-loop repeats still to play
let queued = null; // index of a song queued out of order
const subscribers = new Map(); // "ip:port" → { ip, port }
const sock = dgram.createSocket('udp4');
const sent = new Map();
function values() {
const song = songs[songIdx];
const sIdx = Math.max(0, song.sections.findIndex((s) => beats >= s.start && beats < s.end));
const sect = song.sections[sIdx];
const nextSect = song.sections[sIdx + 1] || null;
const nextSongIdx = queued != null ? queued : songIdx + 1 < songs.length ? songIdx + 1 : -1;
const nextSong = songs[nextSongIdx] || null;
const leftInSong = secs(song, song.end - beats);
const leftInSet = leftInSong + songs.slice(songIdx + 1).reduce((a, s) => a + songDuration(s), 0);
const col = (name) => [name, name === 'default' ? '#FFFFFF' : { blue: '#3B82F6', red: '#EF4444', purple: '#A855F7' }[name] || '#FFFFFF'];
return {
'/global/isConnected': [live ? 1 : 0],
'/global/isLoadingFile': [0],
'/global/isPlaying': [playing ? 1 : 0],
'/global/isRecording': [0],
'/global/tempo': [song.tempo],
'/global/timeSignature': song.sig,
'/global/beatsPosition': [Math.floor(beats)],
'/global/finePosition': [beats],
'/setlist/name': ['260927 AB (mock)'],
'/setlist/songs': songs.map((s) => s.name),
'/setlist/songDurations': songs.map(songDuration),
'/setlist/songColors': songs.map((s) => s.color),
'/setlist/sections': song.sections.map((s) => s.name),
'/setlist/sectionColors': song.sections.map((s) => (s.name === 'BRIDGE' ? 'purple' : 'default')),
'/setlist/activeSongName': [song.name],
'/setlist/activeSongIndex': [songIdx],
'/setlist/activeSongStart': [song.start],
'/setlist/activeSongEnd': [song.end],
'/setlist/activeSongDuration': [songDuration(song)],
'/setlist/activeSongColor': col(song.color),
'/setlist/activeSectionName': [sect.name],
'/setlist/activeSectionIndex': [sIdx],
'/setlist/activeSectionStart': [sect.start],
'/setlist/activeSectionEnd': [sect.end],
'/setlist/activeSectionDuration': [secs(song, sect.end - sect.start)],
'/setlist/activeSectionColor': col(sect.name === 'BRIDGE' ? 'purple' : 'default'),
'/setlist/activeSectionLoopMode': [loopsLeft > 0 ? 'loop' : null],
'/setlist/activeSectionLoopCount': [loopsLeft],
'/setlist/isInActiveSectionLoop': [loopsLeft > 0 ? 1 : 0],
'/setlist/nextSectionName': [nextSect ? nextSect.name : null],
'/setlist/nextSectionIndex': [nextSect ? sIdx + 1 : -1],
'/setlist/nextSectionStart': [nextSect ? nextSect.start : null],
'/setlist/nextSectionEnd': [nextSect ? nextSect.end : null],
'/setlist/nextSongName': [nextSong ? nextSong.name : null],
'/setlist/nextSongIndex': [nextSongIdx],
'/setlist/nextSongDuration': [nextSong ? songDuration(nextSong) : null],
'/setlist/nextSongColor': col(nextSong ? nextSong.color : 'default'),
'/setlist/queuedName': [queued != null ? songs[queued].name : null, null],
'/setlist/queuedIndex': [queued != null ? queued : -1, -1],
'/setlist/queuedType': [queued != null ? 'song' : null],
'/setlist/loopEnabled': [0],
'/setlist/isCountingIn': [0],
'/setlist/remainingTimeInSong': [Math.round(leftInSong)],
'/setlist/remainingTimeInSet': [Math.round(leftInSet)],
'/audioInterfaces/connected': [1],
'/audioInterfaces/count': [1],
'/audioInterfaces/names': ['PlayAUDIO12'],
'/audioInterfaces/status': ['sceneA'],
'/version/app': ['mock'],
};
}
function sendTo(sub, address, args) {
sock.send(encodeMessage(address, args), sub.port, sub.ip); // null goes out as nil, as AbleSet sends it
}
function push(force = false) {
const now = values();
for (const [address, args] of Object.entries(now)) {
const key = JSON.stringify(args);
if (!force && sent.get(address) === key && address !== '/global/finePosition') continue;
if (address === '/global/finePosition' && !playing && !force) continue;
sent.set(address, key);
for (const sub of subscribers.values()) sendTo(sub, address, args);
}
}
sock.on('message', (buf, rinfo) => {
let msgs;
try { msgs = decodePacket(buf); } catch { return; }
for (const { address, args } of msgs) {
const ip = args[0] === 'auto' || !args[0] ? rinfo.address : String(args[0]);
const port = Number(args[1]) || rinfo.port;
if (address === '/subscribe') {
subscribers.set(`${ip}:${port}`, { ip, port });
sendTo({ ip, port }, '/subscribed', []);
console.log(`subscribed ${ip}:${port} (${args[2] || 'no name'})`);
} else if (address === '/unsubscribe') {
subscribers.delete(`${ip}:${port}`);
console.log(`unsubscribed ${ip}:${port}`);
} else if (address === '/getValues') {
const sub = { ip, port };
for (const [a, v] of Object.entries(values())) sendTo(sub, a, v);
}
}
});
setInterval(() => {
for (const sub of subscribers.values()) sendTo(sub, '/heartbeat', []);
}, 2000);
let last = Date.now();
setInterval(() => {
const now = Date.now();
const dtSec = ((now - last) / 1000) * SPEED;
last = now;
if (playing) {
const song = songs[songIdx];
const before = song.sections.find((s) => beats >= s.start && beats < s.end);
beats += dtSec * (song.tempo / 60);
// The second BRIDGE of song 3 loops twice before it lets go.
if (before && beats >= before.end && loopsLeft > 0) {
loopsLeft -= 1;
beats = before.start + (beats - before.end);
}
const after = song.sections.find((s) => beats >= s.start && beats < s.end);
if (after && after !== before && after.name === 'BRIDGE' && song.sections.indexOf(after) === 6) loopsLeft = 2;
if (beats >= song.end) {
// Stop at the end of each song, as a service does, then carry on.
const nextIdx = queued != null ? queued : songIdx + 1 < songs.length ? songIdx + 1 : 0;
queued = null;
songIdx = nextIdx;
beats = songs[songIdx].start;
playing = false;
push();
setTimeout(() => { playing = true; last = Date.now(); push(); }, 4000 / SPEED);
// Halfway through the set someone queues the last song early.
if (songIdx === 3) setTimeout(() => { queued = 5; push(); }, 8000 / SPEED);
}
}
push();
}, 100);
sock.bind(PORT, () => console.log(`AbleSet mock on udp ${PORT}${playing ? '' : ' (stopped)'}${live ? '' : ' (Live not connected)'}${SPEED !== 1 ? ` at ${SPEED}×` : ''}`));