356 lines
14 KiB
JavaScript
356 lines
14 KiB
JavaScript
'use strict';
|
|||
|
|
|
||
|
|
/**
|
||
|
|
* Vista Cues — server part.
|
||
|
|
*
|
||
|
|
* Vista 3 cannot say what it is playing: its OSC is input-only and it sends
|
||
|
|
* no feedback. What it *does* have is a network MIDI session that
|
||
|
|
* ProPresenter and the tracks rig fire cue lists through. A session sends
|
||
|
|
* every message to every participant, so this module joins those sessions
|
||
|
|
* as one more participant (rtpmidi.js) and hears exactly what Vista hears.
|
||
|
|
* The admin's "Cue lists" sheet (mapping.js) turns each note into
|
||
|
|
* "Go on Walk-in", "Release Worship 1"…, and from those the module keeps
|
||
|
|
* which lists are live.
|
||
|
|
*
|
||
|
|
* GET /state → { state } (instant paint)
|
||
|
|
* GET /stream → SSE: `state` on every change
|
||
|
|
* POST /clear → forget which lists are live (when Vista was reset by hand)
|
||
|
|
*
|
||
|
|
* What the tile shows is what Vista was told, not what Vista confirmed: if
|
||
|
|
* Vista is closed or someone works the desk by hand, the two can differ.
|
||
|
|
*/
|
||
|
|
|
||
|
|
const fs = require('fs');
|
||
|
|
const { spawn } = require('child_process');
|
||
|
|
const { Session } = require('./rtpmidi');
|
||
|
|
const { parseMapping, match, triggerLabel } = require('./mapping');
|
||
|
|
|
||
|
|
const HEARTBEAT_MS = 15000;
|
||
|
|
const LOG_MAX = 40;
|
||
|
|
const DNS_SD = '/usr/bin/dns-sd';
|
||
|
|
|
||
|
|
let current = null;
|
||
|
|
|
||
|
|
function emptyState(extra = {}) {
|
||
|
|
return {
|
||
|
|
listening: false,
|
||
|
|
error: '',
|
||
|
|
sessionName: '',
|
||
|
|
port: null,
|
||
|
|
advertised: false,
|
||
|
|
sources: [],
|
||
|
|
current: null,
|
||
|
|
active: [],
|
||
|
|
log: [],
|
||
|
|
mappingErrors: [],
|
||
|
|
rules: 0,
|
||
|
|
now: Date.now(),
|
||
|
|
...extra,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function endpointOf(v) {
|
||
|
|
const ep = v && typeof v === 'object' ? v : {};
|
||
|
|
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 };
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
init({ config, log }) {
|
||
|
|
const sessionName = String(config.sessionName || '').trim() || 'ProdDash Lighting';
|
||
|
|
const port = Math.round(Number(config.sessionPort)) || 5040;
|
||
|
|
const middleC = config.middleC === 'C4' ? 'C4' : 'C3';
|
||
|
|
const exclusive = config.exclusive === true;
|
||
|
|
const headTakesOver = config.headTakesOver !== false;
|
||
|
|
const { rules, errors: mappingErrors, groups } = parseMapping(config.cueLists, { middleC });
|
||
|
|
if (mappingErrors.length) {
|
||
|
|
for (const e of mappingErrors) log(`cue lists line ${e.line} skipped (${e.why}): ${e.text}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const senders = [
|
||
|
|
{ label: String(config.source1Name || '').trim() || 'ProPresenter', ...endpointOf(config.source1) },
|
||
|
|
{ label: String(config.source2Name || '').trim() || 'Tracks', ...endpointOf(config.source2) },
|
||
|
|
{ label: String(config.source3Name || '').trim() || 'Companion', ...endpointOf(config.source3) },
|
||
|
|
].filter((s) => s.host);
|
||
|
|
|
||
|
|
const streams = new Set();
|
||
|
|
const lists = new Map(); // lower-cased name → { name, group, state, firedAt, lastAt, lastAction, source, trigger, goes }
|
||
|
|
const history = []; // newest first
|
||
|
|
let currentKey = '';
|
||
|
|
let lastFrame = '';
|
||
|
|
let advertiser = null;
|
||
|
|
let advertised = false;
|
||
|
|
let stopped = false;
|
||
|
|
|
||
|
|
if (port < 1024 || port > 65534) throw new Error('Session port must be between 1024 and 65534');
|
||
|
|
|
||
|
|
const session = new Session({ name: sessionName, port, log });
|
||
|
|
const invites = senders.map((s) => ({ ...s, inv: null }));
|
||
|
|
|
||
|
|
/* ── what Vista was told ─────────────────────────────────────── */
|
||
|
|
|
||
|
|
function sourceLabel(p) {
|
||
|
|
return p.label || p.name || p.address;
|
||
|
|
}
|
||
|
|
|
||
|
|
function record(entry) {
|
||
|
|
history.unshift({ at: Date.now(), ...entry });
|
||
|
|
if (history.length > LOG_MAX) history.length = LOG_MAX;
|
||
|
|
}
|
||
|
|
|
||
|
|
function apply(rule, source, trigger) {
|
||
|
|
const now = Date.now();
|
||
|
|
const key = rule.list.toLowerCase();
|
||
|
|
record({ action: rule.action, list: rule.list, group: rule.group, source, trigger, mapped: true });
|
||
|
|
if (rule.action === 'flash') return; // a momentary hit: in the log, never live
|
||
|
|
if (rule.action === 'releaseAll') {
|
||
|
|
lists.clear();
|
||
|
|
currentKey = '';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (rule.action === 'release') {
|
||
|
|
lists.delete(key);
|
||
|
|
if (currentKey === key) currentKey = newestKey();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
let l = lists.get(key);
|
||
|
|
if (rule.action === 'go') {
|
||
|
|
// A Go replaces the other lists of its group. A Go in the first group (the
|
||
|
|
// main scene) takes over everything, as Vista's last-takes-precedence does;
|
||
|
|
// and with "Go releases others" on, every Go does.
|
||
|
|
const takeover = exclusive || (headTakesOver && rule.group && rule.group === groups[0]);
|
||
|
|
for (const [k, other] of [...lists]) {
|
||
|
|
if (k !== key && (takeover || (rule.group && other.group === rule.group))) lists.delete(k);
|
||
|
|
}
|
||
|
|
if (!l) {
|
||
|
|
l = { name: rule.list, group: rule.group, state: 'active', firedAt: now, goes: 0 };
|
||
|
|
lists.set(key, l);
|
||
|
|
}
|
||
|
|
l.state = 'active';
|
||
|
|
l.goes++;
|
||
|
|
l.lastAt = now;
|
||
|
|
l.lastAction = 'go';
|
||
|
|
l.source = source;
|
||
|
|
l.trigger = trigger;
|
||
|
|
currentKey = key;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
// pause / back act on a list that is live; on one that isn't, they only go in the log.
|
||
|
|
if (!l) return;
|
||
|
|
l.lastAt = now;
|
||
|
|
l.lastAction = rule.action;
|
||
|
|
l.source = source;
|
||
|
|
l.trigger = trigger;
|
||
|
|
if (rule.action === 'pause') l.state = 'paused'; // the next Go resumes it
|
||
|
|
if (rule.action === 'back') { l.state = 'active'; l.goes = Math.max(1, l.goes - 1); }
|
||
|
|
}
|
||
|
|
|
||
|
|
function newestKey() {
|
||
|
|
let best = '';
|
||
|
|
let at = -1;
|
||
|
|
for (const [k, l] of lists) if (l.lastAt > at) { best = k; at = l.lastAt; }
|
||
|
|
return best;
|
||
|
|
}
|
||
|
|
|
||
|
|
session.on('midi', ({ participant, event }) => {
|
||
|
|
if (event.type !== 'noteon' && event.type !== 'program') return;
|
||
|
|
const source = sourceLabel(participant);
|
||
|
|
const trigger = triggerLabel(event, middleC);
|
||
|
|
const rule = match(rules, event);
|
||
|
|
if (rule) {
|
||
|
|
apply(rule, source, trigger);
|
||
|
|
log(`${source}: ${trigger} → ${rule.action}${rule.list ? ` ${rule.list}` : ''}`);
|
||
|
|
} else {
|
||
|
|
record({ action: '', list: '', source, trigger, mapped: false });
|
||
|
|
}
|
||
|
|
broadcast();
|
||
|
|
});
|
||
|
|
session.on('change', () => broadcast());
|
||
|
|
// Senders are joined once our ports are ours — straight away normally,
|
||
|
|
// or a moment later when a busy port frees up.
|
||
|
|
session.on('listening', () => {
|
||
|
|
if (stopped) return;
|
||
|
|
log(`session "${sessionName}" listening on UDP ${port}/${port + 1}`);
|
||
|
|
for (const s of invites) s.inv = session.invite(s.host, s.port, s.label);
|
||
|
|
if (!advertiser) advertise();
|
||
|
|
});
|
||
|
|
|
||
|
|
/* ── the view-model ──────────────────────────────────────────── */
|
||
|
|
|
||
|
|
function sources() {
|
||
|
|
const out = [];
|
||
|
|
const seen = new Set();
|
||
|
|
for (const s of invites) {
|
||
|
|
const inv = s.inv;
|
||
|
|
const p = inv && [...session.participants.values()].find((q) => q.connected && q.address === inv.address && q.controlPort === inv.port);
|
||
|
|
if (p) seen.add(p.key);
|
||
|
|
out.push({
|
||
|
|
label: s.label,
|
||
|
|
name: p ? p.name : '',
|
||
|
|
address: inv?.address || s.host,
|
||
|
|
configured: true,
|
||
|
|
connected: Boolean(p),
|
||
|
|
error: p ? '' : (inv?.error || ''),
|
||
|
|
lastHeard: p ? p.lastHeard : null,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
for (const p of session.participants.values()) {
|
||
|
|
if (seen.has(p.key) || !p.connected) continue;
|
||
|
|
out.push({ label: p.label || p.name, name: p.name, address: p.address, configured: false, connected: true, error: '', lastHeard: p.lastHeard });
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
function groupRank(l) {
|
||
|
|
const i = groups.indexOf(l.group);
|
||
|
|
return i < 0 ? groups.length : i;
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildState() {
|
||
|
|
const live = [...lists.values()].sort((a, b) => b.lastAt - a.lastAt);
|
||
|
|
// The headline: the live list of the sheet's first group (the scene, say,
|
||
|
|
// rather than whichever colour changed last); without groups, the last Go.
|
||
|
|
const head = groups[0];
|
||
|
|
const cur = (head && live.find((l) => l.group === head))
|
||
|
|
|| (currentKey ? lists.get(currentKey) : null)
|
||
|
|
|| null;
|
||
|
|
return {
|
||
|
|
listening: Boolean(session.control),
|
||
|
|
error: session.error,
|
||
|
|
sessionName,
|
||
|
|
port,
|
||
|
|
advertised,
|
||
|
|
sources: sources(),
|
||
|
|
current: cur || null,
|
||
|
|
// The rest in sheet order of their group, so a colour chip stays put as it changes.
|
||
|
|
active: live.filter((l) => l !== cur).sort((a, b) => groupRank(a) - groupRank(b) || b.lastAt - a.lastAt),
|
||
|
|
headline: head || '',
|
||
|
|
log: history,
|
||
|
|
mappingErrors,
|
||
|
|
rules: rules.length,
|
||
|
|
now: Date.now(),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function send(payload) {
|
||
|
|
for (const res of streams) {
|
||
|
|
try { res.write(payload); } catch { streams.delete(res); }
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function broadcast() {
|
||
|
|
if (stopped) return;
|
||
|
|
const state = buildState();
|
||
|
|
const { now, sources: src, ...rest } = state;
|
||
|
|
// lastHeard moves with every packet; leave it out of change detection.
|
||
|
|
const frame = JSON.stringify({ ...rest, src: src.map(({ lastHeard, ...s }) => s) });
|
||
|
|
if (frame === lastFrame) return;
|
||
|
|
lastFrame = frame;
|
||
|
|
send(`event: state\ndata: ${JSON.stringify(state)}\n\n`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const keepalive = setInterval(() => send(': ping\n\n'), HEARTBEAT_MS);
|
||
|
|
keepalive.unref?.();
|
||
|
|
|
||
|
|
/* ── being found: Bonjour on macOS ───────────────────────────── */
|
||
|
|
|
||
|
|
// Advertising _apple-midi._udp puts us in Audio MIDI Setup's Directory on
|
||
|
|
// every Mac here, so a sender can add us with a click. macOS ships the
|
||
|
|
// dns-sd tool; we run it as a child and stop it with the module. Elsewhere
|
||
|
|
// nothing is advertised and senders are joined by address alone.
|
||
|
|
function advertise() {
|
||
|
|
if (stopped || config.advertise === false || process.platform !== 'darwin' || !fs.existsSync(DNS_SD)) return;
|
||
|
|
advertiser = spawn(DNS_SD, ['-R', sessionName, '_apple-midi._udp', 'local', String(port)], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||
|
|
advertiser.stdout.on('data', (d) => {
|
||
|
|
if (/Name now registered and active/i.test(String(d)) && !advertised) {
|
||
|
|
advertised = true;
|
||
|
|
log(`advertised as "${sessionName}" on port ${port}`);
|
||
|
|
broadcast();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
advertiser.stderr.on('data', () => {});
|
||
|
|
advertiser.on('error', (e) => { log(`dns-sd: ${e.message}`); advertiser = null; });
|
||
|
|
advertiser.on('exit', () => {
|
||
|
|
advertiser = null;
|
||
|
|
advertised = false;
|
||
|
|
if (!stopped) setTimeout(advertise, 5000).unref?.();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/* ── start ───────────────────────────────────────────────────── */
|
||
|
|
|
||
|
|
session.start().then(() => broadcast());
|
||
|
|
|
||
|
|
current = {
|
||
|
|
streams,
|
||
|
|
getState: buildState,
|
||
|
|
clear() {
|
||
|
|
lists.clear();
|
||
|
|
currentKey = '';
|
||
|
|
record({ action: 'cleared', list: '', source: 'Dashboard', trigger: '', mapped: true });
|
||
|
|
broadcast();
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
return {
|
||
|
|
stop() {
|
||
|
|
stopped = true;
|
||
|
|
clearInterval(keepalive);
|
||
|
|
session.stop();
|
||
|
|
if (advertiser) { try { advertiser.kill(); } catch { /* gone */ } advertiser = null; }
|
||
|
|
for (const res of streams) { try { res.end(); } catch { /* gone */ } }
|
||
|
|
streams.clear();
|
||
|
|
if (current && current.streams === streams) current = null;
|
||
|
|
},
|
||
|
|
health() {
|
||
|
|
if (session.error) return { status: 'error', message: session.error };
|
||
|
|
const src = sources();
|
||
|
|
const on = src.filter((s) => s.connected);
|
||
|
|
const lines = mappingErrors.length ? ` · ${mappingErrors.length} cue-list line${mappingErrors.length === 1 ? '' : 's'} not understood (first: line ${mappingErrors[0].line})` : '';
|
||
|
|
const map = rules.length ? '' : ' · no cue lists mapped yet';
|
||
|
|
if (on.length) {
|
||
|
|
const off = src.filter((s) => !s.connected).map((s) => `${s.label} (${s.error || 'joining…'})`);
|
||
|
|
return {
|
||
|
|
status: off.length ? 'connecting' : 'ok',
|
||
|
|
message: `In session with ${on.map((s) => s.label).join(', ')}${off.length ? ` · waiting for ${off.join(', ')}` : ''}${map}${lines}`,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
const where = advertised ? `advertised as "${sessionName}"` : `port ${port}`;
|
||
|
|
if (src.length) {
|
||
|
|
return { status: 'connecting', message: `Joining ${src.map((s) => `${s.label}${s.error ? ` (${s.error})` : ''}`).join(', ')} · ${where}${map}${lines}` };
|
||
|
|
}
|
||
|
|
return { status: 'connecting', message: `Waiting for a MIDI session to connect · ${where}${map}${lines}` };
|
||
|
|
},
|
||
|
|
};
|
||
|
|
},
|
||
|
|
|
||
|
|
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));
|
||
|
|
};
|
||
|
|
return {
|
||
|
|
'GET /state': (req, res) => sendJson(res, 200, { state: current ? current.getState() : emptyState() }),
|
||
|
|
'GET /stream': (req, res) => {
|
||
|
|
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(`event: state\ndata: ${JSON.stringify(current ? current.getState() : emptyState())}\n\n`);
|
||
|
|
if (!current) return void res.end();
|
||
|
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||
|
|
const set = current.streams;
|
||
|
|
set.add(res);
|
||
|
|
req.on('close', () => set.delete(res));
|
||
|
|
},
|
||
|
|
'POST /clear': (req, res) => {
|
||
|
|
if (!current) return sendJson(res, 503, { error: 'Vista Cues is not running' });
|
||
|
|
current.clear();
|
||
|
|
return sendJson(res, 200, { ok: true });
|
||
|
|
},
|
||
|
|
};
|
||
|
|
},
|
||
|
|
};
|