'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`); }, }; }, };