720 lines
26 KiB
JavaScript
720 lines
26 KiB
JavaScript
'use strict';
|
||||
|
|
|
|||
|
|
/**
|
|||
|
|
* AbleSet — server part.
|
|||
|
|
*
|
|||
|
|
* Owns the one connection to AbleSet: an OSC subscription over UDP (AbleSet
|
|||
|
|
* listens on 39051). `/subscribe auto <port>` makes AbleSet push every value
|
|||
|
|
* that changes to our socket, `/getValues` fills in everything that is
|
|||
|
|
* already true, and AbleSet's `/heartbeat` every 2 s is how we know it is
|
|||
|
|
* still there — when heartbeats stop (AbleSet quit, the Mac slept, the
|
|||
|
|
* network dropped) we keep asking again every few seconds until it answers,
|
|||
|
|
* because a restarted AbleSet has forgotten its subscribers.
|
|||
|
|
*
|
|||
|
|
* The raw values become one view-model, streamed to every tile:
|
|||
|
|
*
|
|||
|
|
* GET /state → { state } (instant paint)
|
|||
|
|
* GET /stream → SSE: `state` on any change, `pos` while playing
|
|||
|
|
* GET /timers(/stream) → song and set countdowns for the Timers module
|
|||
|
|
*
|
|||
|
|
* `pos` carries only what moves every frame (playhead, time left); the
|
|||
|
|
* tiles extrapolate between packets from the tempo, so 5 packets a second
|
|||
|
|
* draws a smooth playhead.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const dgram = require('dgram');
|
|||
|
|
const dns = require('dns');
|
|||
|
|
const { encodeMessage, decodePacket } = require('./osc');
|
|||
|
|
|
|||
|
|
const HEARTBEAT_MS = 15000; // SSE keepalive comment
|
|||
|
|
const SILENT_MS = 5000; // no heartbeat for this long → AbleSet is gone
|
|||
|
|
const RESUBSCRIBE_MS = 3000; // how often to ask again while it is gone
|
|||
|
|
const POS_MS = 200; // playhead packets to tiles while playing
|
|||
|
|
const COALESCE_MS = 40; // AbleSet sends a section change as a burst of addresses
|
|||
|
|
const FINE_MS = 100; // fine-position throttle we ask AbleSet for
|
|||
|
|
|
|||
|
|
/** Things AbleSet can send that no tile draws — kept off the wire. */
|
|||
|
|
const EXCLUDES = ['!/mixer/**', '!/lyrics/**', '!/cues/**', '!/devices/**', '!/settings/**', '!/midi/**', '!/shared/**'];
|
|||
|
|
|
|||
|
|
let current = null;
|
|||
|
|
|
|||
|
|
function emptyState() {
|
|||
|
|
return {
|
|||
|
|
reachable: false,
|
|||
|
|
configured: false,
|
|||
|
|
lastError: '',
|
|||
|
|
live: false,
|
|||
|
|
loading: false,
|
|||
|
|
playing: false,
|
|||
|
|
paused: false,
|
|||
|
|
recording: false,
|
|||
|
|
countingIn: false,
|
|||
|
|
tempo: null,
|
|||
|
|
timeSig: null,
|
|||
|
|
setlist: { name: '', songs: [] },
|
|||
|
|
song: null,
|
|||
|
|
section: null,
|
|||
|
|
nextSection: null,
|
|||
|
|
sections: [],
|
|||
|
|
nextSong: null,
|
|||
|
|
queued: null,
|
|||
|
|
loop: { enabled: false, start: null, end: null, inSection: false },
|
|||
|
|
audio: [],
|
|||
|
|
version: '',
|
|||
|
|
pos: emptyPos(),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function emptyPos() {
|
|||
|
|
return { beats: null, remainingSong: null, remainingSet: null };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
init({ config, log }) {
|
|||
|
|
const ep = config.ableset && typeof config.ableset === 'object' ? config.ableset : {};
|
|||
|
|
const host = String(ep.host || '').trim();
|
|||
|
|
const port = Number(ep.port) || 39051;
|
|||
|
|
const configured = Boolean(host);
|
|||
|
|
|
|||
|
|
const values = new Map(); // OSC address → args, as AbleSet last said
|
|||
|
|
const streams = new Set(); // tile SSE clients
|
|||
|
|
const timerStreams = new Set(); // Timers-module SSE clients
|
|||
|
|
let lastHeard = 0;
|
|||
|
|
let lastAsked = 0;
|
|||
|
|
let resolved = '';
|
|||
|
|
let lastError = '';
|
|||
|
|
let sock = null;
|
|||
|
|
let stopped = false;
|
|||
|
|
let coalesce = null;
|
|||
|
|
let lastFrame = '';
|
|||
|
|
let lastTimersFrame = '';
|
|||
|
|
|
|||
|
|
// Playhead and countdowns, each as "value at a moment" so they can be
|
|||
|
|
// extrapolated to any other moment. AbleSet's time-left values are whole
|
|||
|
|
// seconds; restamping on every one of them makes a countdown stutter, so
|
|||
|
|
// a model is only moved when AbleSet disagrees with it by over a second.
|
|||
|
|
const beatsModel = { value: null, at: 0 };
|
|||
|
|
const songModel = { value: null, at: 0 };
|
|||
|
|
const setModel = { value: null, at: 0 };
|
|||
|
|
|
|||
|
|
const num = (a, i = 0) => {
|
|||
|
|
const v = values.get(a);
|
|||
|
|
const n = v ? Number(v[i]) : NaN;
|
|||
|
|
return v && v[i] !== null && Number.isFinite(n) ? n : null;
|
|||
|
|
};
|
|||
|
|
const str = (a, i = 0) => {
|
|||
|
|
const v = values.get(a);
|
|||
|
|
return v && v[i] != null ? String(v[i]) : '';
|
|||
|
|
};
|
|||
|
|
const arr = (a) => values.get(a) || [];
|
|||
|
|
const flag = (a) => num(a) === 1 || values.get(a)?.[0] === true;
|
|||
|
|
|
|||
|
|
const isPlaying = () => flag('/global/isPlaying');
|
|||
|
|
const tempo = () => num('/global/tempo');
|
|||
|
|
|
|||
|
|
function extrapolate(model, sign) {
|
|||
|
|
if (model.value == null) return null;
|
|||
|
|
if (!isPlaying()) return model.value;
|
|||
|
|
return model.value + sign * (Date.now() - model.at) / 1000;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function beatsNow() {
|
|||
|
|
if (beatsModel.value == null) return null;
|
|||
|
|
const t = tempo();
|
|||
|
|
if (!isPlaying() || !t) return beatsModel.value;
|
|||
|
|
return beatsModel.value + ((Date.now() - beatsModel.at) / 1000) * (t / 60);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function nudge(model, reported) {
|
|||
|
|
if (reported == null) { model.value = null; return; }
|
|||
|
|
const predicted = extrapolate(model, -1);
|
|||
|
|
if (predicted == null || Math.abs(predicted - reported) > 1.05 || !isPlaying()) {
|
|||
|
|
model.value = reported;
|
|||
|
|
model.at = Date.now();
|
|||
|
|
} else {
|
|||
|
|
// Keep the running model; re-base it so the stamp stays recent.
|
|||
|
|
model.value = predicted;
|
|||
|
|
model.at = Date.now();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function colorOf(a) {
|
|||
|
|
const v = values.get(a);
|
|||
|
|
if (!v) return null;
|
|||
|
|
const name = v[0] == null ? '' : String(v[0]);
|
|||
|
|
const hex = v[1] == null ? '' : String(v[1]);
|
|||
|
|
return name && name !== 'default' && /^#[0-9a-f]{6}$/i.test(hex) ? hex : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function part(prefix) {
|
|||
|
|
const name = str(`${prefix}Name`);
|
|||
|
|
if (!name && num(`${prefix}Index`) == null) return null;
|
|||
|
|
const index = num(`${prefix}Index`);
|
|||
|
|
if (index != null && index < 0) return null;
|
|||
|
|
return {
|
|||
|
|
name,
|
|||
|
|
index,
|
|||
|
|
start: num(`${prefix}Start`),
|
|||
|
|
end: num(`${prefix}End`),
|
|||
|
|
duration: num(`${prefix}Duration`),
|
|||
|
|
color: colorOf(`${prefix}Color`),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function audioInterfaces() {
|
|||
|
|
const out = [];
|
|||
|
|
const names = arr('/audioInterfaces/names');
|
|||
|
|
const statuses = arr('/audioInterfaces/status');
|
|||
|
|
names.forEach((n, i) => {
|
|||
|
|
out.push({ name: String(n || `Interface ${i + 1}`), status: String(statuses[i] || '') });
|
|||
|
|
});
|
|||
|
|
// AbleSet 2 reports its PlayAUDIO12 on its own addresses.
|
|||
|
|
if (!names.length && flag('/playaudio12/isConnected')) {
|
|||
|
|
const scene = num('/playaudio12/scene');
|
|||
|
|
out.push({ name: 'PlayAUDIO12', status: scene === 2 ? 'sceneB' : scene === 1 ? 'sceneA' : '' });
|
|||
|
|
}
|
|||
|
|
return out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildState() {
|
|||
|
|
const songs = arr('/setlist/songs');
|
|||
|
|
const durations = arr('/setlist/songDurations');
|
|||
|
|
const colors = arr('/setlist/songColors');
|
|||
|
|
const sectionNames = arr('/setlist/sections');
|
|||
|
|
const sectionColors = arr('/setlist/sectionColors');
|
|||
|
|
const song = part('/setlist/activeSong');
|
|||
|
|
const qIdx = values.get('/setlist/queuedIndex') || [];
|
|||
|
|
const qName = values.get('/setlist/queuedName') || [];
|
|||
|
|
const queuedType = str('/setlist/queuedType');
|
|||
|
|
const ts = values.get('/global/timeSignature');
|
|||
|
|
return {
|
|||
|
|
reachable: isReachable(),
|
|||
|
|
configured,
|
|||
|
|
lastError,
|
|||
|
|
live: flag('/global/isConnected'),
|
|||
|
|
loading: flag('/global/isLoadingFile'),
|
|||
|
|
playing: isPlaying(),
|
|||
|
|
paused: flag('/global/isPaused'),
|
|||
|
|
recording: flag('/global/isRecording'),
|
|||
|
|
countingIn: flag('/setlist/isCountingIn'),
|
|||
|
|
tempo: tempo(),
|
|||
|
|
timeSig: ts && ts.length >= 2 && ts[0] != null ? [Number(ts[0]), Number(ts[1])] : null,
|
|||
|
|
setlist: {
|
|||
|
|
name: str('/setlist/name'),
|
|||
|
|
songs: songs.map((name, i) => ({
|
|||
|
|
name: String(name ?? ''),
|
|||
|
|
duration: durations[i] == null ? null : Number(durations[i]),
|
|||
|
|
color: typeof colors[i] === 'string' && colors[i] !== 'default' ? colors[i] : null,
|
|||
|
|
})),
|
|||
|
|
},
|
|||
|
|
song,
|
|||
|
|
section: part('/setlist/activeSection'),
|
|||
|
|
nextSection: part('/setlist/nextSection'),
|
|||
|
|
sections: sectionNames.map((name, i) => ({
|
|||
|
|
name: String(name ?? ''),
|
|||
|
|
color: typeof sectionColors[i] === 'string' && sectionColors[i] !== 'default' ? sectionColors[i] : null,
|
|||
|
|
})),
|
|||
|
|
sectionLoop: {
|
|||
|
|
mode: str('/setlist/activeSectionLoopMode'),
|
|||
|
|
count: num('/setlist/activeSectionLoopCount'),
|
|||
|
|
},
|
|||
|
|
nextSong: part('/setlist/nextSong'),
|
|||
|
|
queued: queuedType && qIdx[0] != null && Number(qIdx[0]) >= 0
|
|||
|
|
? { type: queuedType, song: String(qName[0] ?? ''), section: String(qName[1] ?? ''), songIndex: Number(qIdx[0]), sectionIndex: qIdx[1] == null ? -1 : Number(qIdx[1]) }
|
|||
|
|
: null,
|
|||
|
|
loop: {
|
|||
|
|
enabled: flag('/setlist/loopEnabled'),
|
|||
|
|
start: num('/setlist/loopStart'),
|
|||
|
|
end: num('/setlist/loopEnd'),
|
|||
|
|
inSection: flag('/setlist/isInActiveSectionLoop'),
|
|||
|
|
},
|
|||
|
|
audio: audioInterfaces(),
|
|||
|
|
version: str('/version/app'),
|
|||
|
|
pos: buildPos(),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Time left, as AbleSet counts it. AbleSet only sends these while Live is
|
|||
|
|
* connected; without them the song's length and the playhead still say
|
|||
|
|
* how far there is to go, and the set is the rest of the setlist on top.
|
|||
|
|
*/
|
|||
|
|
function songLeftNow() {
|
|||
|
|
const reported = extrapolate(songModel, -1);
|
|||
|
|
if (reported != null) return reported;
|
|||
|
|
const dur = num('/setlist/activeSongDuration');
|
|||
|
|
const start = num('/setlist/activeSongStart');
|
|||
|
|
const end = num('/setlist/activeSongEnd');
|
|||
|
|
const b = beatsNow();
|
|||
|
|
if (dur == null || start == null || end == null || end <= start || b == null) return null;
|
|||
|
|
return Math.max(0, dur * (1 - Math.min(1, Math.max(0, (b - start) / (end - start)))));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setLeftNow() {
|
|||
|
|
const reported = extrapolate(setModel, -1);
|
|||
|
|
if (reported != null) return reported;
|
|||
|
|
const song = songLeftNow();
|
|||
|
|
const idx = num('/setlist/activeSongIndex');
|
|||
|
|
if (song == null || idx == null) return null;
|
|||
|
|
return song + arr('/setlist/songDurations').slice(idx + 1).reduce((a, d) => a + (Number(d) || 0), 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildPos() {
|
|||
|
|
const round = (v, d) => (v == null ? null : Math.round(v * d) / d);
|
|||
|
|
return {
|
|||
|
|
beats: round(beatsNow(), 1000),
|
|||
|
|
remainingSong: round(songLeftNow(), 100),
|
|||
|
|
remainingSet: round(setLeftNow(), 100),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isReachable() {
|
|||
|
|
return lastHeard > 0 && Date.now() - lastHeard < SILENT_MS;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── fan-out ─────────────────────────────────────────────────── */
|
|||
|
|
|
|||
|
|
function send(set, payload) {
|
|||
|
|
for (const res of set) {
|
|||
|
|
try { res.write(payload); } catch { set.delete(res); }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function broadcastState() {
|
|||
|
|
coalesce = null;
|
|||
|
|
const state = buildState();
|
|||
|
|
// Change detection ignores the moving parts, which `pos` carries.
|
|||
|
|
const { pos, ...still } = state;
|
|||
|
|
const frame = JSON.stringify(still);
|
|||
|
|
if (frame !== lastFrame) {
|
|||
|
|
lastFrame = frame;
|
|||
|
|
if (streams.size) send(streams, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
|
|||
|
|
}
|
|||
|
|
broadcastTimers();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function scheduleState() {
|
|||
|
|
if (!coalesce) coalesce = setTimeout(broadcastState, COALESCE_MS);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function broadcastTimers() {
|
|||
|
|
if (!timerStreams.size) return;
|
|||
|
|
const snap = timersSnapshot();
|
|||
|
|
const { updatedAt, ...rest } = snap;
|
|||
|
|
const key = JSON.stringify(rest.timers.map((t) => [t.state, t.label, t.status, t.targetMs]));
|
|||
|
|
if (key === lastTimersFrame) return;
|
|||
|
|
lastTimersFrame = key;
|
|||
|
|
send(timerStreams, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function timersSnapshot() {
|
|||
|
|
const now = Date.now();
|
|||
|
|
const playing = isPlaying();
|
|||
|
|
const reachable = isReachable();
|
|||
|
|
const song = part('/setlist/activeSong');
|
|||
|
|
const remSong = songLeftNow();
|
|||
|
|
const remSet = setLeftNow();
|
|||
|
|
const state = !reachable ? 'idle' : playing ? 'running' : remSong != null ? 'paused' : 'idle';
|
|||
|
|
const ms = (s) => (s == null ? null : Math.max(0, Math.round(s * 1000)));
|
|||
|
|
const songElapsed = song?.duration != null && remSong != null ? ms(song.duration - remSong) : null;
|
|||
|
|
const songs = arr('/setlist/songs');
|
|||
|
|
const idx = num('/setlist/activeSongIndex');
|
|||
|
|
const left = idx == null ? null : Math.max(0, songs.length - idx - 1);
|
|||
|
|
return {
|
|||
|
|
source: 'ableset',
|
|||
|
|
label: 'AbleSet',
|
|||
|
|
updatedAt: now,
|
|||
|
|
timers: [
|
|||
|
|
{
|
|||
|
|
id: 'song',
|
|||
|
|
label: 'Song',
|
|||
|
|
kind: 'countdown',
|
|||
|
|
state,
|
|||
|
|
startedAt: playing && songElapsed != null ? now - songElapsed : null,
|
|||
|
|
elapsedMs: songElapsed,
|
|||
|
|
remainingMs: ms(remSong),
|
|||
|
|
targetMs: ms(song?.duration),
|
|||
|
|
status: song ? { text: song.name, tone: reachable ? 'ok' : 'muted' } : null,
|
|||
|
|
detail: str('/setlist/activeSectionName') || null,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'set',
|
|||
|
|
label: 'Set',
|
|||
|
|
kind: 'countdown',
|
|||
|
|
state,
|
|||
|
|
startedAt: null,
|
|||
|
|
elapsedMs: null,
|
|||
|
|
remainingMs: ms(remSet),
|
|||
|
|
targetMs: null,
|
|||
|
|
status: str('/setlist/name') ? { text: str('/setlist/name'), tone: 'muted' } : null,
|
|||
|
|
detail: left == null ? null : `${left} song${left === 1 ? '' : 's'} left`,
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── OSC in ──────────────────────────────────────────────────── */
|
|||
|
|
|
|||
|
|
const FAST = new Set([
|
|||
|
|
'/global/beatsPosition', '/global/finePosition', '/setlist/activeSongProgress',
|
|||
|
|
'/setlist/activeSectionProgress', '/setlist/remainingTimeInSong', '/setlist/remainingTimeInSet',
|
|||
|
|
'/global/humanPosition', '/global/currentMeasure', '/global/measureOrPosition',
|
|||
|
|
'/global/cpuAverage', '/global/cpuPeak', '/heartbeat', '/subscribed',
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
function onMessage(m) {
|
|||
|
|
const { address, args } = m;
|
|||
|
|
if (address === '/global/finePosition' || address === '/global/beatsPosition') {
|
|||
|
|
const v = Number(args[0]);
|
|||
|
|
if (Number.isFinite(v)) {
|
|||
|
|
// A whole-beat report while fine ones flow would drag the playhead back.
|
|||
|
|
if (address === '/global/beatsPosition' && values.has('/global/finePosition')
|
|||
|
|
&& Math.abs((beatsNow() ?? v) - v) < 1) return void values.set(address, args);
|
|||
|
|
beatsModel.value = v;
|
|||
|
|
beatsModel.at = Date.now();
|
|||
|
|
}
|
|||
|
|
} else if (address === '/setlist/remainingTimeInSong') {
|
|||
|
|
nudge(songModel, args[0] == null ? null : Number(args[0]));
|
|||
|
|
} else if (address === '/setlist/remainingTimeInSet') {
|
|||
|
|
nudge(setModel, args[0] == null ? null : Number(args[0]));
|
|||
|
|
} else if (address === '/global/isPlaying') {
|
|||
|
|
// Freeze the models at the moment the transport changed.
|
|||
|
|
const b = beatsNow();
|
|||
|
|
if (b != null) { beatsModel.value = b; beatsModel.at = Date.now(); }
|
|||
|
|
for (const mdl of [songModel, setModel]) {
|
|||
|
|
const v = extrapolate(mdl, -1);
|
|||
|
|
if (v != null) { mdl.value = v; mdl.at = Date.now(); }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
values.set(address, args);
|
|||
|
|
if (!FAST.has(address)) scheduleState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── connection ──────────────────────────────────────────────── */
|
|||
|
|
|
|||
|
|
function ask() {
|
|||
|
|
if (!sock || stopped) return;
|
|||
|
|
lastAsked = Date.now();
|
|||
|
|
dns.lookup(host, { family: 4 }, (err, addr) => {
|
|||
|
|
if (stopped || !sock) return;
|
|||
|
|
if (err) {
|
|||
|
|
lastError = `can't find ${host} (${err.code || err.message})`;
|
|||
|
|
scheduleState();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
resolved = addr;
|
|||
|
|
const myPort = sock.address().port;
|
|||
|
|
const out = (address, args) => {
|
|||
|
|
try {
|
|||
|
|
sock.send(encodeMessage(address, args), port, addr, (e) => {
|
|||
|
|
if (e) lastError = e.message;
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
lastError = e.message;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
out('/subscribe', ['auto', myPort, 'ProdDash', FINE_MS, ...EXCLUDES]);
|
|||
|
|
out('/getValues', ['auto', myPort]);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const watchdog = setInterval(() => {
|
|||
|
|
if (!configured || stopped) return;
|
|||
|
|
const wasReachable = lastHeard > 0 && Date.now() - lastHeard < SILENT_MS + 1000;
|
|||
|
|
if (!isReachable()) {
|
|||
|
|
if (wasReachable) {
|
|||
|
|
log(`no heartbeat from AbleSet at ${host} — asking again`);
|
|||
|
|
lastError = 'AbleSet stopped answering';
|
|||
|
|
scheduleState();
|
|||
|
|
}
|
|||
|
|
if (Date.now() - lastAsked >= RESUBSCRIBE_MS) ask();
|
|||
|
|
}
|
|||
|
|
}, 1000);
|
|||
|
|
watchdog.unref?.();
|
|||
|
|
|
|||
|
|
// While playing, the playhead goes out five times a second. While
|
|||
|
|
// stopped it only goes out when it moved — a jump, a scrub.
|
|||
|
|
let lastPos = '';
|
|||
|
|
const posTimer = setInterval(() => {
|
|||
|
|
if (!streams.size || !isReachable()) return;
|
|||
|
|
const frame = JSON.stringify(buildPos());
|
|||
|
|
if (!isPlaying() && frame === lastPos) return;
|
|||
|
|
lastPos = frame;
|
|||
|
|
send(streams, `event: pos\ndata: ${frame}\n\n`);
|
|||
|
|
}, POS_MS);
|
|||
|
|
posTimer.unref?.();
|
|||
|
|
|
|||
|
|
const keepalive = setInterval(() => {
|
|||
|
|
send(streams, ': ping\n\n');
|
|||
|
|
send(timerStreams, ': ping\n\n');
|
|||
|
|
// The Timers module derives running values itself; a fresh anchor now
|
|||
|
|
// and then keeps a long song honest.
|
|||
|
|
if (timerStreams.size) {
|
|||
|
|
lastTimersFrame = '';
|
|||
|
|
broadcastTimers();
|
|||
|
|
}
|
|||
|
|
}, HEARTBEAT_MS);
|
|||
|
|
keepalive.unref?.();
|
|||
|
|
|
|||
|
|
if (configured) {
|
|||
|
|
sock = dgram.createSocket('udp4');
|
|||
|
|
sock.on('message', (buf, rinfo) => {
|
|||
|
|
if (resolved && rinfo.address !== resolved) return; // only AbleSet talks to us
|
|||
|
|
let msgs;
|
|||
|
|
try {
|
|||
|
|
msgs = decodePacket(buf);
|
|||
|
|
} catch (e) {
|
|||
|
|
log(`unreadable OSC packet from ${rinfo.address}: ${e.message}`);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const wasReachable = isReachable();
|
|||
|
|
lastHeard = Date.now();
|
|||
|
|
if (!wasReachable) {
|
|||
|
|
lastError = '';
|
|||
|
|
log(`AbleSet at ${host} (${rinfo.address}) is answering`);
|
|||
|
|
scheduleState();
|
|||
|
|
}
|
|||
|
|
for (const m of msgs) onMessage(m);
|
|||
|
|
});
|
|||
|
|
sock.on('error', (e) => {
|
|||
|
|
lastError = e.message;
|
|||
|
|
log(`OSC socket error: ${e.message}`);
|
|||
|
|
});
|
|||
|
|
sock.bind(0, () => {
|
|||
|
|
log(`following AbleSet at ${host}:${port}`);
|
|||
|
|
ask();
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
log('no AbleSet host configured — set one in /admin');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
current = {
|
|||
|
|
streams,
|
|||
|
|
timerStreams,
|
|||
|
|
getState: buildState,
|
|||
|
|
getTimers: timersSnapshot,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
stop() {
|
|||
|
|
stopped = true;
|
|||
|
|
clearInterval(watchdog);
|
|||
|
|
clearInterval(posTimer);
|
|||
|
|
clearInterval(keepalive);
|
|||
|
|
clearTimeout(coalesce);
|
|||
|
|
if (sock) {
|
|||
|
|
// Be polite: tell AbleSet to stop sending to a port nobody reads.
|
|||
|
|
const s = sock;
|
|||
|
|
sock = null;
|
|||
|
|
try {
|
|||
|
|
if (resolved) s.send(encodeMessage('/unsubscribe', ['auto', s.address().port]), port, resolved, () => { try { s.close(); } catch { /* closed */ } });
|
|||
|
|
else s.close();
|
|||
|
|
} catch {
|
|||
|
|
try { s.close(); } catch { /* closed */ }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
for (const set of [streams, timerStreams]) {
|
|||
|
|
for (const res of set) { try { res.end(); } catch { /* gone */ } }
|
|||
|
|
set.clear();
|
|||
|
|
}
|
|||
|
|
if (current && current.streams === streams) current = null;
|
|||
|
|
},
|
|||
|
|
health() {
|
|||
|
|
if (!configured) return { status: 'error', message: 'No AbleSet host configured' };
|
|||
|
|
if (isReachable()) {
|
|||
|
|
const live = flag('/global/isConnected') ? '' : ' — Ableton Live is not connected';
|
|||
|
|
const set = str('/setlist/name');
|
|||
|
|
return { status: live ? 'connecting' : 'ok', message: `Following ${host}${set ? ` · ${set}` : ''}${live}` };
|
|||
|
|
}
|
|||
|
|
if (lastError) return { status: 'error', message: `AbleSet at ${host}: ${lastError}` };
|
|||
|
|
return { status: 'connecting', message: `Waiting for AbleSet at ${host}:${port} to answer…` };
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* One picker entry per tile kind. Each carries only the gear settings its
|
|||
|
|
* tile can act on (guide rules 5–8): four or fewer switches sit loose, the
|
|||
|
|
* Now Playing card's longer list folds into a Show group.
|
|||
|
|
*/
|
|||
|
|
tiles() {
|
|||
|
|
const sw = (label, extra = {}) => ({ type: 'switch', label, default: true, ...extra });
|
|||
|
|
const clock = {
|
|||
|
|
type: 'select',
|
|||
|
|
label: 'Song time',
|
|||
|
|
default: 'remaining',
|
|||
|
|
options: [
|
|||
|
|
{ value: 'remaining', label: 'Time left' },
|
|||
|
|
{ value: 'elapsed', label: 'Elapsed' },
|
|||
|
|
{ value: 'both', label: 'Both' },
|
|||
|
|
],
|
|||
|
|
};
|
|||
|
|
const warnAt = { type: 'number', label: 'Warn at (s)', default: 30, group: 'Colours' };
|
|||
|
|
const alertAt = { type: 'number', label: 'Alert at (s)', default: 10, group: 'Colours' };
|
|||
|
|
return [
|
|||
|
|
{
|
|||
|
|
id: 'now',
|
|||
|
|
name: 'Now Playing',
|
|||
|
|
description: 'The song, its tempo, time left, section, progress and what comes next',
|
|||
|
|
defaultSize: { w: 8, h: 4 },
|
|||
|
|
minSize: { w: 3, h: 2 },
|
|||
|
|
instanceSchema: {
|
|||
|
|
clock,
|
|||
|
|
songColors: sw('Song colours', { default: false }),
|
|||
|
|
showTempo: sw('Tempo', { group: 'Show' }),
|
|||
|
|
showMeter: sw('Time signature', { group: 'Show' }),
|
|||
|
|
showDuration: sw('Duration', { group: 'Show' }),
|
|||
|
|
showClock: sw('Song time', { group: 'Show' }),
|
|||
|
|
showSection: sw('Section', { group: 'Show' }),
|
|||
|
|
showProgress: sw('Progress bar', { group: 'Show' }),
|
|||
|
|
showNext: sw('Next song', { group: 'Show' }),
|
|||
|
|
showLeft: sw('Songs left', { group: 'Show' }),
|
|||
|
|
warnAt,
|
|||
|
|
alertAt,
|
|||
|
|
},
|
|||
|
|
instanceGroups: { Show: { collapsed: true, columns: 2 }, Colours: { collapsed: true, columns: 2 } },
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'sections',
|
|||
|
|
name: 'Song Sections',
|
|||
|
|
description: 'Where the song is — its sections sliding past, as a strip or a wheel',
|
|||
|
|
defaultSize: { w: 8, h: 2 },
|
|||
|
|
minSize: { w: 2, h: 1 },
|
|||
|
|
instanceSchema: {
|
|||
|
|
showProgress: sw('Section progress'),
|
|||
|
|
showSong: sw('Song name', { default: false }),
|
|||
|
|
sectionColors: sw('Section colours', { default: false }),
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'setlist',
|
|||
|
|
name: 'Setlist',
|
|||
|
|
description: 'Every song in the set, played, playing and to come',
|
|||
|
|
defaultSize: { w: 3, h: 6 },
|
|||
|
|
minSize: { w: 2, h: 2 },
|
|||
|
|
instanceSchema: {
|
|||
|
|
showDurations: sw('Durations'),
|
|||
|
|
showPlayed: sw('Played songs'),
|
|||
|
|
showTotal: sw('Time left'),
|
|||
|
|
songColors: sw('Song colours', { default: false }),
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'transport',
|
|||
|
|
name: 'Transport',
|
|||
|
|
description: 'Playing, stopped, counting in or looping, in colour',
|
|||
|
|
defaultSize: { w: 3, h: 2 },
|
|||
|
|
minSize: { w: 1, h: 1 },
|
|||
|
|
instanceSchema: {
|
|||
|
|
showPosition: sw('Bar and beat'),
|
|||
|
|
showLoop: sw('Loop'),
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'tempo',
|
|||
|
|
name: 'Tempo',
|
|||
|
|
description: 'BPM and time signature, with a light on every beat',
|
|||
|
|
defaultSize: { w: 3, h: 2 },
|
|||
|
|
minSize: { w: 1, h: 1 },
|
|||
|
|
instanceSchema: {
|
|||
|
|
showMeter: sw('Time signature'),
|
|||
|
|
showBeats: sw('Beat lights'),
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'song-time',
|
|||
|
|
name: 'Song Time',
|
|||
|
|
description: 'Time left in the song, turning amber then red near the end',
|
|||
|
|
defaultSize: { w: 3, h: 2 },
|
|||
|
|
minSize: { w: 1, h: 1 },
|
|||
|
|
instanceSchema: {
|
|||
|
|
clock,
|
|||
|
|
showSong: sw('Song name'),
|
|||
|
|
showProgress: sw('Progress bar'),
|
|||
|
|
warnAt,
|
|||
|
|
alertAt,
|
|||
|
|
},
|
|||
|
|
instanceGroups: { Colours: { collapsed: true, columns: 2 } },
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'set-time',
|
|||
|
|
name: 'Set Time',
|
|||
|
|
description: 'Time left in the whole set and how many songs remain',
|
|||
|
|
defaultSize: { w: 3, h: 2 },
|
|||
|
|
minSize: { w: 1, h: 1 },
|
|||
|
|
instanceSchema: {
|
|||
|
|
showLeft: sw('Songs left'),
|
|||
|
|
showEnd: sw('End time'),
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'section',
|
|||
|
|
name: 'Section',
|
|||
|
|
description: 'The section playing now, what is next, and the count to the change',
|
|||
|
|
defaultSize: { w: 3, h: 2 },
|
|||
|
|
minSize: { w: 1, h: 1 },
|
|||
|
|
instanceSchema: {
|
|||
|
|
showNext: sw('Next section'),
|
|||
|
|
showCount: sw('Countdown'),
|
|||
|
|
sectionColors: sw('Section colours', { default: false }),
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'next-song',
|
|||
|
|
name: 'Next Song',
|
|||
|
|
description: 'The song after this one, and anything queued',
|
|||
|
|
defaultSize: { w: 3, h: 2 },
|
|||
|
|
minSize: { w: 1, h: 1 },
|
|||
|
|
instanceSchema: {
|
|||
|
|
showDuration: sw('Duration'),
|
|||
|
|
showQueued: sw('Queued'),
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 'status',
|
|||
|
|
name: 'AbleSet Status',
|
|||
|
|
description: 'AbleSet, Ableton Live and audio interface health at a glance',
|
|||
|
|
defaultSize: { w: 3, h: 3 },
|
|||
|
|
minSize: { w: 2, h: 1 },
|
|||
|
|
instanceSchema: {
|
|||
|
|
showAudio: sw('Audio interfaces'),
|
|||
|
|
showSetlist: sw('Setlist name'),
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
routes() {
|
|||
|
|
const sendJson = (res, code, body) => {
|
|||
|
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|||
|
|
res.end(JSON.stringify(body));
|
|||
|
|
};
|
|||
|
|
const openSse = (req, res, set, first) => {
|
|||
|
|
res.writeHead(200, {
|
|||
|
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|||
|
|
'Cache-Control': 'no-cache, no-transform',
|
|||
|
|
Connection: 'keep-alive',
|
|||
|
|
'X-Accel-Buffering': 'no',
|
|||
|
|
});
|
|||
|
|
res.write('retry: 3000\n\n');
|
|||
|
|
res.write(first);
|
|||
|
|
if (!set) return void res.end();
|
|||
|
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
|||
|
|
set.add(res);
|
|||
|
|
req.on('close', () => set.delete(res));
|
|||
|
|
};
|
|||
|
|
return {
|
|||
|
|
'GET /state': (req, res) => sendJson(res, 200, { state: current ? current.getState() : emptyState() }),
|
|||
|
|
'GET /stream': (req, res) => {
|
|||
|
|
const state = current ? current.getState() : emptyState();
|
|||
|
|
openSse(req, res, current ? current.streams : null, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
|
|||
|
|
},
|
|||
|
|
'GET /timers': (req, res) => sendJson(res, 200, current ? current.getTimers() : { source: 'ableset', label: 'AbleSet', updatedAt: Date.now(), timers: [] }),
|
|||
|
|
'GET /timers/stream': (req, res) => {
|
|||
|
|
const snap = current ? current.getTimers() : { source: 'ableset', label: 'AbleSet', updatedAt: Date.now(), timers: [] };
|
|||
|
|
openSse(req, res, current ? current.timerStreams : null, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
},
|
|||
|
|
};
|