diff --git a/CLAUDE.md b/CLAUDE.md
index 6d45a83..10308d3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -45,6 +45,7 @@ title-bar controls). The rules that matter most:
`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/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)
diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md
index e0d9200..9227194 100644
--- a/docs/MODULE-GUIDE.md
+++ b/docs/MODULE-GUIDE.md
@@ -811,6 +811,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
"Sunday Service" from the Service type list. Its item titles match
`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:
diff --git a/modules/ableset/client.js b/modules/ableset/client.js
new file mode 100644
index 0000000..62ffd9f
--- /dev/null
+++ b/modules/ableset/client.js
@@ -0,0 +1,708 @@
+/* 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 = '';
+const ICON_WHEEL = '';
+
+function esc(s) {
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = `
`;
+ const shell = root.firstElementChild;
+ const body = shell.querySelector('.as-body');
+ const offlineEl = shell.querySelector('.as-offline');
+ const $ = (sel) => body.querySelector(sel);
+
+ const BAR = ``;
+
+ const SKELETONS = {
+ now: `
+
+
+
+
+ ${BAR}
+ `,
+ sections: `
+
+ `,
+ setlist: `
+
+
+ `,
+ transport: `
+
+
`,
+ tempo: `
+
+
+ `,
+ 'song-time': `
+
+
+
+ ${BAR}`,
+ 'set-time': `
+ Set
+
+
`,
+ section: `
+
+
+ `,
+ 'next-song': `
+ Next
+
+
+ `,
+ status: ``,
+ };
+ 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) => `${esc(x.name)}
`).join('')
+ || 'No sections
';
+ 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 `
+
+ ${c ? '' : ''}
+ ${esc(x.name)}
+ ${on('showDurations') ? `${x.duration != null ? fmt(x.duration) : ''}` : ''}
+
`;
+ }).join('') || 'No setlist
';
+ }
+ 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) => ``).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]) => `${esc(k)}${esc(v)}
`).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 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() {},
+ error() {
+ moduleApi.setStatus('error', 'Lost the ProdDash stream — reconnecting');
+ },
+ events: {
+ state(e) {
+ try {
+ const next = JSON.parse(e.data);
+ takePos(next.pos);
+ state = next;
+ } catch { return; }
+ reportStatus();
+ render();
+ },
+ pos(e) {
+ try { takePos(JSON.parse(e.data)); } catch { return; }
+ if (state && !moving()) impl.frame();
+ loop();
+ },
+ },
+ });
+ 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() {
+ if (stream) stream.close();
+ stream = null;
+ cancelAnimationFrame(raf);
+ raf = 0;
+ clearInterval(tickTimer);
+ if (resizeObserver) resizeObserver.disconnect();
+ root.innerHTML = '';
+ },
+ };
+}
diff --git a/modules/ableset/module.json b/modules/ableset/module.json
new file mode 100644
index 0000000..0ed3391
--- /dev/null
+++ b/modules/ableset/module.json
@@ -0,0 +1,36 @@
+{
+ "id": "ableset",
+ "name": "AbleSet",
+ "version": "1.0.0",
+ "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."
+ }
+ }
+}
diff --git a/modules/ableset/osc.js b/modules/ableset/osc.js
new file mode 100644
index 0000000..3a37d06
--- /dev/null
+++ b/modules/ableset/osc.js
@@ -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 };
diff --git a/modules/ableset/server.js b/modules/ableset/server.js
new file mode 100644
index 0000000..ce6262b
--- /dev/null
+++ b/modules/ableset/server.js
@@ -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 ` 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`);
+ },
+ };
+ },
+};
diff --git a/modules/ableset/style.css b/modules/ableset/style.css
new file mode 100644
index 0000000..d93e7f0
--- /dev/null
+++ b/modules/ableset/style.css
@@ -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); }
diff --git a/tools/ableset-mock.js b/tools/ableset-mock.js
new file mode 100644
index 0000000..ed08c64
--- /dev/null
+++ b/tools/ableset-mock.js
@@ -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 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}×` : ''}`));