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} +
+
Next:
+
+
+
`, + sections: ` +
+
`, + setlist: ` +
+
+
`, + transport: ` +
+
`, + tempo: ` +
BPM
+
+
`, + '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}×` : ''}`));