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