Vista Cues 1.0.0: which Vista 3 cue lists are live, from the network MIDI that fires them

Vista 3 reports nothing about what it is playing — its OSC only takes
commands — so a booth with no lighting operator can't see the lighting
computer. ProPresenter, the tracks rig and Companion fire Vista's cue lists
with notes over macOS network MIDI sessions, and a session sends every note
to every participant, so the module joins those sessions as one more
listener (RTP-MIDI in plain Node, no helper) and hears what Vista hears.

The admin's cue sheet turns notes into Go / release / pause / back / flash
on named lists. Sheets with no release notes use [groups]: a Go replaces its
group's live list, and the first group is the headline — the main scene —
which takes over the look and clears the FX below it. The tile shows the
headline list large with how long ago and who fired it, the other live
lists as tags, recent notes, and which senders are connected.

The module invites each configured sender and keeps re-inviting, accepts
invitations from anyone, and advertises itself over Bonjour on macOS. A
busy port (a config save re-initing before the old sockets close) is
retried rather than failing. Default session port is 5040/5041, clear of
the 5009 session on NA Ingest.

tools/vista-midi-mock.js is a stand-in sender for development;
modules/vista-cues/vista.test.js covers the parser, packet decoding and
real loopback sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 11:15:45 -04:00
co-authored by Claude Opus 5.5
parent 1410cbc169
commit 2893316a1a
10 changed files with 2329 additions and 1 deletions
+262
View File
@@ -0,0 +1,262 @@
/* Vista Cues — client part.
One tile: the cue list most recently fired, big, with how long ago and
who fired it; under it the other lists still live, the recent notes, and
which senders we are in session with. All of it comes from the server's
one `state` event; between events only the "how long ago" clocks move. */
const MINUS_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M3.5 8h9"/></svg>';
const PLUS_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M3.5 8h9M8 3.5v9"/></svg>';
const CLEAR_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6.2 13.5h7.3"/><path d="M2.8 9.6 8.9 3.5a1.4 1.4 0 0 1 2 0l2.1 2.1a1.4 1.4 0 0 1 0 2l-5.9 5.9H5.6L2.8 10.7a.8.8 0 0 1 0-1.1Z"/><path d="m6 6.4 4.1 4.1"/></svg>';
const ACTION_LABEL = {
go: 'Go',
release: 'Release',
releaseAll: 'Release all',
pause: 'Pause',
back: 'Back',
flash: 'Flash',
cleared: 'Cleared',
'': '—',
};
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
/** 75 000 ms → "1:15", 3 725 000 → "1:02:05". */
function since(ms) {
const s = Math.max(0, Math.floor(ms / 1000));
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}` : `${m}:${ss}`;
}
function clockTime(ms) {
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit' });
}
export default function create({ root, moduleApi }) {
const prefs = () => moduleApi.instanceSettings || {};
const on = (k, dflt = true) => (prefs()[k] === undefined ? dflt : prefs()[k] !== false);
const scale = () => Math.min(2.5, Math.max(0.5, Number(prefs().textScale) || 1));
let state = null;
let skew = 0; // server clock − our clock
let stream = null;
let tick = null;
let el = {};
let resizeObserver = null;
const serverNow = () => Date.now() + skew;
/* ── fitting the big name to its box ─────────────────────────────── */
function fit(node) {
if (!node || !node.isConnected) return;
node.style.fontSize = '';
const avail = node.parentElement ? node.parentElement.clientWidth : node.clientWidth;
let size = parseFloat(getComputedStyle(node).fontSize) || 16;
for (let i = 0; i < 3 && node.scrollWidth > avail + 0.5 && size > 10; i += 1) {
size = Math.max(10, size * (avail / node.scrollWidth) * 0.98);
node.style.fontSize = `${size}px`;
}
}
/* ── drawing ─────────────────────────────────────────────────────── */
function build() {
root.innerHTML = `
<div class="vc-root">
<div class="vc-offline" hidden></div>
<div class="vc-body">
<div class="vc-now">
<div class="vc-kicker"><span class="vc-pill"></span><span class="vc-ago"></span></div>
<div class="vc-name-wrap"><div class="vc-name"></div></div>
<div class="vc-meta"></div>
</div>
<div class="vc-active"></div>
<div class="vc-recent"></div>
<div class="vc-sources"></div>
</div>
</div>`;
const q = (s) => root.querySelector(s);
el = {
root: q('.vc-root'),
offline: q('.vc-offline'),
pill: q('.vc-pill'),
ago: q('.vc-ago'),
name: q('.vc-name'),
meta: q('.vc-meta'),
active: q('.vc-active'),
recent: q('.vc-recent'),
sources: q('.vc-sources'),
};
}
function render() {
if (!el.root) return;
el.root.style.setProperty('--vc-scale', String(scale()));
const s = state;
if (!s) {
el.name.textContent = 'Connecting…';
el.root.dataset.tone = 'muted';
return;
}
const connected = s.sources.filter((x) => x.connected);
const offline = !s.listening || !connected.length;
el.root.classList.toggle('is-offline', offline);
el.offline.hidden = !offline;
el.offline.textContent = !s.listening ? 'Not listening' : 'No MIDI session';
// Now
const cur = s.current;
if (cur) {
el.root.dataset.tone = cur.state === 'paused' ? 'warn' : 'ok';
el.pill.textContent = cur.state === 'paused' ? 'Paused' : 'Live';
el.pill.hidden = false;
if (el.name.textContent !== cur.name) { el.name.textContent = cur.name; fit(el.name); }
const bits = [];
if (cur.goes > 1) bits.push(`Go ×${cur.goes}`);
if (cur.source) bits.push(cur.source);
if (cur.lastAction === 'back') bits.push('Back');
el.meta.textContent = bits.join(' · ');
} else {
el.root.dataset.tone = 'muted';
el.pill.hidden = true;
const msg = s.rules ? 'No cue list live' : 'No cue lists mapped';
if (el.name.textContent !== msg) { el.name.textContent = msg; fit(el.name); }
el.meta.textContent = s.rules ? (s.log.length ? '' : 'Waiting for the first cue') : 'Add the cue sheet in /admin → Vista Cues';
}
// Other live lists
el.active.hidden = !on('showActive') || !s.active.length;
if (!el.active.hidden) {
el.active.innerHTML = s.active.map((l) => `
<div class="vc-chip${l.state === 'paused' ? ' is-paused' : ''}" title="${esc(l.source || '')}">
${l.group ? `<span class="vc-chip-group">${esc(l.group)}</span>` : ''}
<span class="vc-chip-name">${esc(l.name)}</span>
<span class="vc-chip-ago" data-since="${l.lastAt}"></span>
</div>`).join('');
}
// Recent
const showUnmapped = on('showUnmapped', false);
const rows = s.log.filter((e) => e.mapped || showUnmapped).slice(0, 12);
el.recent.hidden = !on('showRecent') || !rows.length;
if (!el.recent.hidden) {
el.recent.innerHTML = rows.map((e) => `
<div class="vc-row${e.mapped ? '' : ' is-unmapped'}" data-action="${esc(e.action)}">
<span class="vc-row-time">${esc(clockTime(e.at))}</span>
<span class="vc-row-action">${esc(e.mapped ? (ACTION_LABEL[e.action] ?? e.action) : 'Unmapped')}</span>
<span class="vc-row-list">${esc(e.mapped ? e.list : e.trigger)}</span>
<span class="vc-row-source">${esc(e.source)}</span>
</div>`).join('');
}
// Senders
el.sources.hidden = !on('showSources') || !s.sources.length;
if (!el.sources.hidden) {
el.sources.innerHTML = s.sources.map((x) => `
<span class="vc-src${x.connected ? ' is-on' : ''}" title="${esc(x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…'))}">
<span class="vc-dot"></span>${esc(x.label)}
</span>`).join('');
}
updateClocks();
reportStatus();
}
function updateClocks() {
if (!state || !el.root) return;
const now = serverNow();
const cur = state.current;
el.ago.textContent = cur ? since(now - cur.lastAt) : '';
el.ago.title = cur ? `Fired ${clockTime(cur.firedAt)}` : '';
root.querySelectorAll('[data-since]').forEach((n) => {
n.textContent = since(now - Number(n.dataset.since));
});
}
function reportStatus() {
const s = state;
if (!s) return;
if (!s.listening) return moduleApi.setStatus('error', s.error || 'Not listening for MIDI');
const connected = s.sources.filter((x) => x.connected);
const waiting = s.sources.filter((x) => !x.connected);
if (!connected.length) {
const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected';
return moduleApi.setStatus('error', why);
}
const msg = `Hearing ${connected.map((x) => x.label).join(', ')}${waiting.length ? ` · not ${waiting.map((x) => x.label).join(', ')}` : ''}`;
return moduleApi.setStatus(waiting.length ? 'connecting' : 'ok', msg);
}
function take(next) {
if (!next) return;
skew = Number(next.now) ? Number(next.now) - Date.now() : 0;
state = next;
render();
}
async function refresh() {
try {
const r = await moduleApi.fetch('/state');
if (r.ok) take((await r.json()).state);
} catch { /* the stream will say so */ }
}
/* ── title bar ───────────────────────────────────────────────────── */
function bump(delta) {
const next = Math.round(Math.min(2.5, Math.max(0.5, scale() + delta)) * 10) / 10;
moduleApi.saveInstanceSettings({ textScale: next });
render();
fit(el.name);
}
return {
start() {
build();
render();
moduleApi.header.addButton({ icon: MINUS_SVG, title: 'Smaller text', onClick: () => bump(-0.1) });
moduleApi.header.addButton({ icon: PLUS_SVG, title: 'Larger text', onClick: () => bump(0.1) });
moduleApi.header.addButton({
icon: CLEAR_SVG,
title: 'Clear live lists',
async onClick() {
try { await moduleApi.fetch('/clear', { method: 'POST' }); } catch { /* status says why */ }
},
});
moduleApi.setStatus('connecting', 'Connecting…');
stream = moduleApi.sse('/stream', {
open() { refresh(); },
error() { moduleApi.setStatus('error', 'Lost connection to ProdDash'); },
events: {
state(e) {
try { take(JSON.parse(e.data)); } catch { /* malformed frame */ }
},
},
});
refresh();
tick = setInterval(updateClocks, 1000);
if (typeof ResizeObserver === 'function') {
resizeObserver = new ResizeObserver(() => fit(el.name));
resizeObserver.observe(root);
}
},
stop() {
clearInterval(tick);
tick = null;
if (stream) { stream.close(); stream = null; }
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; }
root.innerHTML = '';
el = {};
},
onResize() {
fit(el.name);
},
};
}
+148
View File
@@ -0,0 +1,148 @@
'use strict';
/**
* The admin page's "Cue lists" text → rules, and MIDI events → what Vista
* was told to do.
*
* One rule per line, the way it reads on a cue sheet:
*
* 60 = Walk-in note 60 fires Go on "Walk-in"
* C3 = Worship 1 note names work too (see "Middle C" in admin)
* 61 release Walk-in release a list
* 62 pause Walk-in also: back, pause, go (the default)
* 127 release all every list off
* ch2:64 = Message only on MIDI channel 2 (otherwise any channel)
* pc5 = Baptism a program change instead of a note
* 65 = "Back Lights" quote a name that starts with an action word
* # anything after a # is a comment
*
* Groups: a line "[Colors]" starts a group, and every rule below it belongs
* to it until the next "[…]" ("[]" ends grouping). A Go in a group replaces
* whichever list of that group was live — firing "Blue" ends "Red" — which is
* how a sheet without release notes still says what is on. The first group
* in the sheet is the headline the tile shows largest.
*
* 30 flash Blinder Flash 1 a momentary hit: logged, never left live
*
* The separator between the trigger and the rest may be "=", "-", ":", "→",
* a tab or just a space, so a column pasted from a spreadsheet works.
*/
const NOTE_NAMES = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
const NAMES_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const ACTIONS = [
// Longest first, so "release all" wins over "release".
[/^(release[\s_-]*all|all[\s_-]*off|releaseall|clear)\b/i, 'releaseAll'],
[/^(release|off|stop|kill)\b/i, 'release'],
[/^(pause|hold)\b/i, 'pause'],
[/^(back|previous|prev)\b/i, 'back'],
[/^(flash|hit|momentary)\b/i, 'flash'],
[/^(go|fire|play|start)\b/i, 'go'],
];
/** Octave offset: with middle C (note 60) called "C3", C-2 is note 0; with "C4", C-1 is. */
function octaveBase(middleC) {
return middleC === 'C4' ? 1 : 2;
}
/** "F#2" → note number, or null. */
function noteFromName(token, middleC) {
const m = /^([a-g])(#|♯|b|♭)?(-?\d+)$/i.exec(token);
if (!m) return null;
let pc = NOTE_NAMES[m[1].toLowerCase()];
if (m[2] === '#' || m[2] === '♯') pc += 1;
if (m[2] === 'b' || m[2] === '♭') pc -= 1;
const n = (Number(m[3]) + octaveBase(middleC)) * 12 + pc;
return n >= 0 && n <= 127 ? n : null;
}
function noteName(n, middleC) {
return `${NAMES_SHARP[n % 12]}${Math.floor(n / 12) - octaveBase(middleC)}`;
}
/**
* Parse the whole text. Returns `{ rules, errors, groups }` (groups in sheet order); a line that can't be read
* is reported (line number + why) and skipped, never fatal.
*/
function parseMapping(text, { middleC = 'C3' } = {}) {
const rules = [];
const errors = [];
const groups = [];
let group = '';
String(text || '').split(/\r?\n/).forEach((raw, i) => {
// A comment is a # that starts the line or follows a space ("C#3" and "Song #2" are not).
const line = raw.replace(/(^|\s)#(\s.*|$)/, '').trim();
if (!line) return;
const header = /^\[(.*)\]$/.exec(line);
if (header) {
group = header[1].trim();
if (group && !groups.includes(group)) groups.push(group);
return;
}
const m = /^(?:ch(?:annel)?\s*(\d{1,2})\s*[:/]\s*)?(pc\s*\d{1,3}|note\s*\d{1,3}|\d{1,3}|[a-g](?:#|♯|b|♭)?-?\d)\s*(?:[=:\t→>|,-]+\s*|\s+|$)(.*)$/i.exec(line);
if (!m) {
errors.push({ line: i + 1, text: raw.trim(), why: 'starts with no note' });
return;
}
const channel = m[1] ? Number(m[1]) : null;
if (channel != null && (channel < 1 || channel > 16)) {
errors.push({ line: i + 1, text: raw.trim(), why: 'channel must be 1–16' });
return;
}
const trig = m[2].replace(/\s+/g, '').toLowerCase();
let kind = 'note';
let number;
if (trig.startsWith('pc')) {
kind = 'program';
number = Number(trig.slice(2));
} else if (trig.startsWith('note')) {
number = Number(trig.slice(4));
} else if (/^\d+$/.test(trig)) {
number = Number(trig);
} else {
number = noteFromName(m[2].replace(/\s+/g, ''), middleC);
}
if (number == null || !Number.isInteger(number) || number < 0 || number > 127) {
errors.push({ line: i + 1, text: raw.trim(), why: 'note must be 0–127' });
return;
}
let rest = m[3].trim();
let action = 'go';
const quoted = (t) => /^"(.*)"$/.exec(t);
for (const [re, a] of quoted(rest) ? [] : ACTIONS) {
const am = re.exec(rest);
if (am) {
action = a;
rest = rest.slice(am[0].length).replace(/^[\s=:→>|,-]+/, '').trim();
break;
}
}
const q = quoted(rest);
if (q) rest = q[1].trim();
if (action !== 'releaseAll' && !rest) {
errors.push({ line: i + 1, text: raw.trim(), why: 'no cue list name' });
return;
}
rules.push({ line: i + 1, channel, kind, number, action, list: action === 'releaseAll' ? '' : rest, group });
});
return { rules, errors, groups };
}
/** The first rule a MIDI event matches, or null. Only note-ons and program changes fire anything. */
function match(rules, event) {
let kind;
let number;
if (event.type === 'noteon') { kind = 'note'; number = event.note; }
else if (event.type === 'program') { kind = 'program'; number = event.program; }
else return null;
return rules.find((r) => r.kind === kind && r.number === number && (r.channel == null || r.channel === event.channel)) || null;
}
/** "Note 60 (C3) · ch 1" — how an event is named on the tile. */
function triggerLabel(event, middleC) {
if (event.type === 'program') return `PC ${event.program} · ch ${event.channel}`;
return `Note ${event.note} (${noteName(event.note, middleC)}) · ch ${event.channel}`;
}
module.exports = { parseMapping, match, noteName, noteFromName, triggerLabel };
+166
View File
@@ -0,0 +1,166 @@
{
"id": "vista-cues",
"name": "Vista Cues",
"version": "1.0.0",
"proddash": ">=1.5.0",
"description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista",
"permissions": [
{
"kind": "local-network",
"reason": "Joining the network MIDI sessions that fire Vista's cue lists"
}
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 2,
"h": 1
},
"defaultSize": {
"w": 5,
"h": 4
},
"configSchema": {
"source1Name": {
"type": "string",
"label": "Name",
"default": "ProPresenter",
"group": "First sender",
"help": "What the tile calls notes from this machine."
},
"source1": {
"type": "endpoint",
"label": "Session",
"default": {
"host": "",
"port": 5004
},
"group": "First sender",
"help": "The Mac whose network MIDI session sends Vista its notes, and that session's port (Audio MIDI Setup → Window → Show MIDI Studio → Network; usually 5004)."
},
"source2Name": {
"type": "string",
"label": "Name",
"default": "Tracks",
"group": "Second sender"
},
"source2": {
"type": "endpoint",
"label": "Session",
"default": {
"host": "",
"port": 5004
},
"group": "Second sender",
"help": "Leave the host empty if this machine joins us from its own side instead."
},
"source3Name": {
"type": "string",
"label": "Name",
"default": "Companion",
"group": "Third sender"
},
"source3": {
"type": "endpoint",
"label": "Session",
"default": {
"host": "",
"port": 5004
},
"group": "Third sender",
"help": "Companion on this machine counts too: its network session's host and port (this Mac's own name works)."
},
"cueLists": {
"type": "text",
"label": "Cue lists",
"default": "",
"group": "Cue lists",
"help": "One per line from the cue sheet: 60 = Walk-in · 61 release Walk-in · 127 release all · 30 flash Blinder 1 (momentary). A line like [Colors] starts a group: a Go in a group replaces that group's live list; the first group is the tile's headline. Also pause, back, note names, ch2:64, pc5, and \"quotes\" around a name starting with one of those words."
},
"middleC": {
"type": "select",
"label": "Middle C",
"default": "C3",
"options": [
{
"value": "C3",
"label": "C3 (Ableton, Yamaha)"
},
{
"value": "C4",
"label": "C4 (Logic, scientific)"
}
],
"group": "Cue lists",
"help": "Only matters for note names in the sheet. Note 60 is middle C either way."
},
"headTakesOver": {
"type": "switch",
"label": "Main group takes over",
"default": true,
"group": "Cue lists",
"help": "On: a Go in the sheet's first [group] (the main scene) clears every other live list, as a new scene takes over the look. FX fired after it layer on top again."
},
"exclusive": {
"type": "switch",
"label": "Go releases others",
"default": false,
"group": "Cue lists",
"help": "On: a Go replaces every other live list, groups or not. Leave off when lists stack, and use groups instead."
},
"sessionName": {
"type": "string",
"label": "Session name",
"default": "ProdDash Lighting",
"group": "This machine",
"help": "How ProdDash appears in other Macs' Audio MIDI Setup Directory."
},
"sessionPort": {
"type": "number",
"label": "Session port",
"default": 5040,
"min": 1024,
"max": 65534,
"group": "This machine",
"help": "UDP; this one and the next are used. Anything not already taken by a MIDI session on this machine."
},
"advertise": {
"type": "switch",
"label": "Bonjour",
"default": true,
"group": "This machine",
"help": "List this machine in Audio MIDI Setup's Directory on the network (macOS)."
}
},
"configGroups": {
"First sender": {
"help": "ProdDash joins the senders' network MIDI sessions, like Vista does, so it hears every note Vista hears. On each sender, in Audio MIDI Setup → MIDI Network Setup, the session must be enabled and 'Who may connect to me' set to Anyone. Or skip the address and connect to \"ProdDash Lighting\" from that Mac's Directory instead."
},
"This machine": {
"collapsed": true
}
},
"instanceSchema": {
"showActive": {
"type": "switch",
"label": "Other lists",
"default": true
},
"showRecent": {
"type": "switch",
"label": "Recent",
"default": true
},
"showSources": {
"type": "switch",
"label": "Senders",
"default": true
},
"showUnmapped": {
"type": "switch",
"label": "Unmapped notes",
"default": false
}
}
}
+630
View File
@@ -0,0 +1,630 @@
'use strict';
/**
* A listen-only RTP-MIDI (AppleMIDI) session participant, in plain Node.
*
* This is the protocol behind a macOS "Network" MIDI session (Audio MIDI
* Setup → MIDI Network Setup) and rtpMIDI on Windows: two UDP ports, a
* control port P and a data port P+1. A session sends every MIDI message to
* every participant it is connected to, so joining the same session Vista is
* in hears exactly the notes Vista hears.
*
* Both ways of joining work:
* - someone invites us (we show up in Audio MIDI Setup's Directory, or they
* add our address by hand) and we accept anyone;
* - we invite them (`invite(host, port)`), and keep re-inviting until they
* accept, then keep the connection alive with clock syncs.
*
* We never send MIDI. Packet layouts follow Apple's AppleMIDI session
* protocol and RFC 6295 (RTP payload for MIDI).
*/
const dgram = require('dgram');
const dns = require('dns');
const crypto = require('crypto');
const { EventEmitter } = require('events');
const SIG = 0xffff;
const CMD = { IN: 0x494e, OK: 0x4f4b, NO: 0x4e4f, BY: 0x4259, CK: 0x434b, RS: 0x5253 };
const PROTOCOL_VERSION = 2;
const INVITE_RETRY_MS = 1500; // between invitation attempts while setting up
const INVITE_ATTEMPTS = 12; // then give up for a while…
const REINVITE_MS = 5000; // …and start over after this long
const SYNC_FAST_MS = 1500; // first few clock syncs, close together
const SYNC_MS = 10000; // then this often, as macOS does
const SYNC_TIMEOUT_MS = 25000; // no sync answer for this long → connection is gone
const IDLE_MS = 90000; // an inbound participant we have not heard from at all
const FEEDBACK_MS = 1000; // receiver feedback (lets the sender trim its journal)
/** The session clock: 100 µs ticks since this process started the session. */
function makeClock() {
const t0 = process.hrtime.bigint();
return () => (process.hrtime.bigint() - t0) / 100000n;
}
function randomU32() {
return crypto.randomBytes(4).readUInt32BE(0);
}
/* ── packets ─────────────────────────────────────────────────────────── */
function sessionPacket(cmd, token, ssrc, name) {
const nameBuf = name == null ? Buffer.alloc(0) : Buffer.from(`${name}\0`, 'utf8');
const b = Buffer.alloc(16 + nameBuf.length);
b.writeUInt16BE(SIG, 0);
b.writeUInt16BE(cmd, 2);
b.writeUInt32BE(PROTOCOL_VERSION, 4);
b.writeUInt32BE(token >>> 0, 8);
b.writeUInt32BE(ssrc >>> 0, 12);
nameBuf.copy(b, 16);
return b;
}
function syncPacket(ssrc, count, ts) {
const b = Buffer.alloc(36);
b.writeUInt16BE(SIG, 0);
b.writeUInt16BE(CMD.CK, 2);
b.writeUInt32BE(ssrc >>> 0, 4);
b.writeUInt8(count, 8);
for (let i = 0; i < 3; i++) b.writeBigUInt64BE(BigInt.asUintN(64, ts[i] || 0n), 12 + i * 8);
return b;
}
function feedbackPacket(ssrc, seq) {
const b = Buffer.alloc(12);
b.writeUInt16BE(SIG, 0);
b.writeUInt16BE(CMD.RS, 2);
b.writeUInt32BE(ssrc >>> 0, 4);
b.writeUInt16BE(seq & 0xffff, 8);
return b;
}
/** A session command (IN/OK/NO/BY/CK/RS), or null when it isn't one. */
function parseSession(buf) {
if (buf.length < 4 || buf.readUInt16BE(0) !== SIG) return null;
const cmd = buf.readUInt16BE(2);
if (cmd === CMD.CK) {
if (buf.length < 36) return null;
return {
cmd,
ssrc: buf.readUInt32BE(4),
count: buf.readUInt8(8),
ts: [buf.readBigUInt64BE(12), buf.readBigUInt64BE(20), buf.readBigUInt64BE(28)],
};
}
if (cmd === CMD.RS) {
if (buf.length < 12) return null;
return { cmd, ssrc: buf.readUInt32BE(4), seq: buf.readUInt16BE(8) };
}
if (cmd === CMD.IN || cmd === CMD.OK || cmd === CMD.NO || cmd === CMD.BY) {
if (buf.length < 16) return null;
let name = '';
if (buf.length > 16) {
const end = buf.indexOf(0, 16);
name = buf.toString('utf8', 16, end < 0 ? buf.length : end);
}
return { cmd, version: buf.readUInt32BE(4), token: buf.readUInt32BE(8), ssrc: buf.readUInt32BE(12), name };
}
return null;
}
/** Data bytes that follow a status byte (sysex is handled separately). */
function dataLength(status) {
const hi = status & 0xf0;
if (hi === 0xc0 || hi === 0xd0) return 1;
if (hi < 0xf0) return 2;
if (status === 0xf1 || status === 0xf3) return 1;
if (status === 0xf2) return 2;
return 0;
}
/**
* The MIDI commands in one RTP-MIDI packet (RFC 6295 §3), as
* `{ status, data: [..] }` in order. Journal data is ignored: we only show
* what is happening, and a note lost on the wire is one Vista also missed.
*/
function parseRtpMidi(buf) {
if (buf.length < 13 || (buf[0] >> 6) !== 2) return null;
const cc = buf[0] & 0x0f;
const ext = Boolean(buf[0] & 0x10);
const seq = buf.readUInt16BE(2);
const ssrc = buf.readUInt32BE(8);
let off = 12 + cc * 4;
if (ext) {
if (buf.length < off + 4) return null;
off += 4 + buf.readUInt16BE(off + 2) * 4;
}
if (off >= buf.length) return null;
const header = buf[off];
const big = Boolean(header & 0x80);
const zero = Boolean(header & 0x20);
let len = header & 0x0f;
if (big) {
if (off + 1 >= buf.length) return null;
len = (len << 8) | buf[off + 1];
off += 2;
} else {
off += 1;
}
const end = Math.min(buf.length, off + len);
const messages = [];
let running = 0;
let first = true;
while (off < end) {
if (!first || zero) {
// Delta time: 1–4 octets, high bit means "more follow".
let n = 0;
while (off < end && (buf[off] & 0x80) && n < 3) { off++; n++; }
off++;
if (off >= end) break;
}
first = false;
let status = buf[off];
if (status & 0x80) {
off++;
} else if (running) {
status = running;
} else {
break; // data byte with nothing to run on: give up on this packet
}
if (status === 0xf0 || status === 0xf7) {
// A sysex, or a segment of one: runs to F7 (end), F0 (more follows) or F4 (cancelled).
const start = off;
while (off < end && buf[off] !== 0xf7 && buf[off] !== 0xf0 && buf[off] !== 0xf4) off++;
const data = Array.from(buf.subarray(start, off));
off++;
messages.push({ status, data });
running = 0;
continue;
}
if (status < 0xf0) running = status;
else if (status < 0xf8) running = 0; // system common cancels running status; realtime doesn't
const n = dataLength(status);
if (off + n > end) break;
messages.push({ status, data: Array.from(buf.subarray(off, off + n)) });
off += n;
}
return { seq, ssrc, messages };
}
/** `{ status, data }` → a plain event, or null for what nobody here cares about. */
function describe(msg) {
const hi = msg.status & 0xf0;
const channel = (msg.status & 0x0f) + 1;
if (hi === 0x90 && msg.data[1] > 0) return { type: 'noteon', channel, note: msg.data[0], velocity: msg.data[1] };
if (hi === 0x80 || (hi === 0x90 && msg.data[1] === 0)) return { type: 'noteoff', channel, note: msg.data[0] };
if (hi === 0xc0) return { type: 'program', channel, program: msg.data[0] };
if (hi === 0xb0) return { type: 'cc', channel, controller: msg.data[0], value: msg.data[1] };
return null;
}
/* ── the session ─────────────────────────────────────────────────────── */
class Session extends EventEmitter {
/**
* @param {{ name: string, port: number, log?: Function }} opts
* Events: 'midi' ({ participant, event }), 'change' (participants or
* listening changed), 'listening' (both ports bound).
*/
constructor({ name, port, log }) {
super();
this.name = name || 'ProdDash';
this.port = port;
this.log = log || (() => {});
this.ssrc = randomU32();
this.now = makeClock();
this.control = null;
this.data = null;
this.participants = new Map(); // key → participant
this.invites = new Map(); // key "host:port" → outbound invitation
this.strangers = new Map(); // address → when we last told it we're not in its session
this.timers = new Set();
this.stopped = false;
this.error = '';
}
/**
* Open both ports. Resolves once both are bound — or once the first try
* failed, with `error` set. A port that is busy (our own previous session
* still closing after a config save, or another program) is retried every
* few seconds for as long as the session runs; 'listening' fires when it
* finally binds.
*/
start() {
this.every(FEEDBACK_MS, () => this.sendFeedback());
this.every(5000, () => this.reap());
return new Promise((resolve) => {
let first = true;
const attempt = (delay) => this.later(delay, async () => {
const ok = await this.bind();
if (ok) {
this.error = '';
this.emit('listening');
this.emit('change');
} else {
this.emit('change');
attempt(first ? 250 : 3000);
}
if (first) { first = false; resolve(ok); }
});
attempt(0);
});
}
/** Bind control and data sockets together; on failure close both. */
bind() {
const open = (port, which) => new Promise((resolve) => {
const sock = dgram.createSocket('udp4');
const fail = (e) => {
try { sock.close(); } catch { /* not open */ }
resolve({ sock: null, port, e });
};
sock.once('error', fail);
sock.bind(port, () => {
sock.removeListener('error', fail);
sock.on('error', (e) => this.log(`socket error on ${port}: ${e.message}`));
sock.on('message', (buf, rinfo) => {
try { this.onPacket(buf, rinfo, which); } catch (e) { this.log(`bad packet from ${rinfo.address}: ${e.message}`); }
});
resolve({ sock, port });
});
});
return Promise.all([open(this.port, 'control'), open(this.port + 1, 'data')]).then(([c, d]) => {
if (this.stopped || !c.sock || !d.sock) {
for (const x of [c, d]) if (x.sock) { try { x.sock.close(); } catch { /* closed */ } }
const bad = !c.sock ? c : d;
if (bad.e) {
const msg = bad.e.code === 'EADDRINUSE'
? `UDP port ${bad.port} is in use on this machine — pick another session port in /admin`
: `UDP port ${bad.port}: ${bad.e.message}`;
if (msg !== this.error) this.log(msg);
this.error = msg;
}
return false;
}
this.control = c.sock;
this.data = d.sock;
return true;
});
}
stop() {
this.stopped = true;
for (const t of this.timers) { clearTimeout(t); clearInterval(t); }
this.timers.clear();
const control = this.control;
const data = this.data;
const close = () => {
for (const s of [control, data]) { try { s && s.close(); } catch { /* closed */ } }
};
// Say goodbye, then close once the goodbyes have left (a config save
// re-inits straight away and wants these ports back).
const byes = [...this.participants.values()].filter((p) => p.connected);
let left = byes.length;
if (!control || !left) close();
else {
for (const p of byes) {
try {
control.send(sessionPacket(CMD.BY, p.token, this.ssrc), p.controlPort, p.address, () => { if (--left === 0) close(); });
} catch { if (--left === 0) close(); }
}
}
this.control = null;
this.data = null;
this.participants.clear();
this.invites.clear();
}
every(ms, fn) {
const t = setInterval(fn, ms);
t.unref?.();
this.timers.add(t);
return t;
}
later(ms, fn) {
const t = setTimeout(() => { this.timers.delete(t); if (!this.stopped) fn(); }, ms);
t.unref?.();
this.timers.add(t);
return t;
}
send(sock, buf, port, address) {
if (!sock || this.stopped) return;
try { sock.send(buf, port, address); } catch { /* socket closed */ }
}
/** Every participant, for display. */
list() {
return [...this.participants.values()].map((p) => ({
name: p.name,
address: p.address,
port: p.controlPort,
connected: p.connected,
outbound: p.outbound,
lastHeard: p.lastHeard || null,
since: p.since || null,
}));
}
/* — outbound invitations — */
/**
* Keep a connection to the session at host:port. Re-invites on its own
* whenever the connection drops. Returns the invitation's state object.
*/
invite(host, port, label) {
const key = `${host}:${port}`;
if (this.invites.has(key)) return this.invites.get(key);
const inv = { key, host, port, label: label || host, address: '', token: 0, attempts: 0, stage: 'idle', error: '', timer: null };
this.invites.set(key, inv);
this.beginInvite(inv);
return inv;
}
beginInvite(inv) {
if (this.stopped || !this.invites.has(inv.key)) return;
dns.lookup(inv.host, { family: 4 }, (err, address) => {
if (this.stopped) return;
if (err) {
inv.error = `can't find ${inv.host}`;
inv.stage = 'idle';
this.emit('change');
this.later(REINVITE_MS, () => this.beginInvite(inv));
return;
}
inv.address = address;
// Already in a session with that machine (it invited us)? Nothing to do.
const existing = this.findByAddress(address, inv.port);
if (existing && existing.connected) {
existing.label = inv.label;
inv.stage = 'connected';
inv.error = '';
this.emit('change');
return;
}
inv.token = randomU32();
inv.attempts = 0;
inv.stage = 'control';
this.sendInvite(inv);
});
}
sendInvite(inv) {
if (this.stopped || !this.invites.has(inv.key)) return;
if (inv.stage !== 'control' && inv.stage !== 'data') return;
if (inv.attempts >= INVITE_ATTEMPTS) {
inv.error = inv.error || 'no answer';
inv.stage = 'idle';
this.emit('change');
this.later(REINVITE_MS, () => this.beginInvite(inv));
return;
}
inv.attempts++;
const sock = inv.stage === 'control' ? this.control : this.data;
const port = inv.stage === 'control' ? inv.port : inv.port + 1;
this.send(sock, sessionPacket(CMD.IN, inv.token, this.ssrc, this.name), port, inv.address);
clearTimeout(inv.timer);
inv.timer = this.later(INVITE_RETRY_MS, () => this.sendInvite(inv));
}
inviteFor(rinfo, token) {
for (const inv of this.invites.values()) {
if (inv.token === token && inv.address === rinfo.address) return inv;
}
return null;
}
findByAddress(address, controlPort) {
for (const p of this.participants.values()) {
if (p.address === address && (controlPort == null || p.controlPort === controlPort)) return p;
}
return null;
}
/* — packets in — */
onPacket(buf, rinfo, which) {
const s = parseSession(buf);
if (!s) {
if (which === 'data') this.onRtp(buf, rinfo);
return;
}
const sock = which === 'control' ? this.control : this.data;
switch (s.cmd) {
case CMD.IN: return this.onInvitation(s, rinfo, which, sock);
case CMD.OK: return this.onAccepted(s, rinfo, which);
case CMD.NO: return this.onRejected(s, rinfo);
case CMD.BY: return this.onBye(s, rinfo);
case CMD.CK: return this.onSync(s, rinfo);
default: return undefined; // RS from a peer: nothing for a listener to do
}
}
onInvitation(s, rinfo, which, sock) {
// We accept anyone: being in more sessions only means hearing more cues.
this.send(sock, sessionPacket(CMD.OK, s.token, this.ssrc, this.name), rinfo.port, rinfo.address);
const key = `in:${s.ssrc}`;
let p = this.participants.get(key);
if (which === 'control') {
if (!p) {
p = {
key, ssrc: s.ssrc, token: s.token, name: s.name || rinfo.address, label: '',
address: rinfo.address, controlPort: rinfo.port, dataPort: rinfo.port + 1,
outbound: false, connected: false, lastSeq: -1, fedBack: -1, lastHeard: Date.now(), since: 0,
};
this.participants.set(key, p);
this.log(`invited by "${p.name}" at ${rinfo.address}:${rinfo.port}`);
} else {
p.token = s.token;
p.controlPort = rinfo.port;
p.name = s.name || p.name;
}
} else if (p) {
p.dataPort = rinfo.port;
p.connected = true;
p.since = Date.now();
p.lastHeard = Date.now();
this.log(`in session with "${p.name}" (${p.address})`);
for (const inv of this.invites.values()) {
if (inv.address === p.address && inv.port === p.controlPort) {
p.label = inv.label;
inv.stage = 'connected';
inv.error = '';
clearTimeout(inv.timer);
}
}
this.emit('change');
}
}
onAccepted(s, rinfo, which) {
const inv = this.inviteFor(rinfo, s.token);
if (!inv) return;
clearTimeout(inv.timer);
if (which === 'control' && inv.stage === 'control') {
inv.stage = 'data';
inv.attempts = 0;
inv.remoteName = s.name;
this.sendInvite(inv);
return;
}
if (which === 'data' && inv.stage === 'data') {
const key = `out:${inv.key}`;
// Drop any stale record of the same remote before taking the new one.
for (const [k, q] of this.participants) if (q.ssrc === s.ssrc && k !== key) this.participants.delete(k);
const p = {
key, ssrc: s.ssrc, token: inv.token, name: inv.remoteName || s.name || inv.host, label: inv.label,
address: rinfo.address, controlPort: inv.port, dataPort: rinfo.port,
outbound: true, connected: true, lastSeq: -1, fedBack: -1,
lastHeard: Date.now(), lastSyncAnswer: Date.now(), since: Date.now(), syncs: 0,
};
this.participants.set(key, p);
inv.stage = 'connected';
inv.error = '';
this.log(`joined "${p.name}" at ${inv.host} (${rinfo.address}:${inv.port})`);
this.emit('change');
this.startSync(p, inv);
}
}
onRejected(s, rinfo) {
const inv = this.inviteFor(rinfo, s.token);
if (!inv) return;
clearTimeout(inv.timer);
inv.stage = 'idle';
inv.error = 'the session refused us — set "Who may connect" to Anyone in Audio MIDI Setup';
this.log(`${inv.host}: invitation refused`);
this.emit('change');
this.later(REINVITE_MS * 2, () => this.beginInvite(inv));
}
onBye(s, rinfo) {
for (const [k, p] of this.participants) {
if (p.ssrc === s.ssrc && p.address === rinfo.address) this.drop(k, 'left the session');
}
}
drop(key, why) {
const p = this.participants.get(key);
if (!p) return;
this.participants.delete(key);
clearInterval(p.syncTimer);
this.timers.delete(p.syncTimer);
this.log(`"${p.name}" ${why}`);
// If we were asked to keep this one, go and get it back.
for (const inv of this.invites.values()) {
if (inv.address === p.address && inv.port === p.controlPort && inv.stage === 'connected') {
inv.stage = 'idle';
inv.error = why;
this.later(p.outbound ? 1000 : REINVITE_MS, () => this.beginInvite(inv));
}
}
this.emit('change');
}
/* — clock sync — */
startSync(p) {
const tick = () => {
if (!this.participants.has(p.key)) return;
if (Date.now() - p.lastSyncAnswer > SYNC_TIMEOUT_MS) {
this.send(this.control, sessionPacket(CMD.BY, p.token, this.ssrc), p.controlPort, p.address);
this.drop(p.key, 'stopped answering');
return;
}
this.send(this.data, syncPacket(this.ssrc, 0, [this.now(), 0n, 0n]), p.dataPort, p.address);
p.syncs++;
if (p.syncs === 6) {
clearInterval(p.syncTimer);
this.timers.delete(p.syncTimer);
p.syncTimer = this.every(SYNC_MS, tick);
}
};
p.syncTimer = this.every(SYNC_FAST_MS, tick);
tick();
}
onSync(s, rinfo) {
const p = [...this.participants.values()].find((q) => q.ssrc === s.ssrc);
if (p) p.lastHeard = Date.now();
if (s.count === 0) {
this.send(this.data, syncPacket(this.ssrc, 1, [s.ts[0], this.now(), 0n]), rinfo.port, rinfo.address);
} else if (s.count === 1) {
if (p) p.lastSyncAnswer = Date.now();
this.send(this.data, syncPacket(this.ssrc, 2, [s.ts[0], s.ts[1], this.now()]), rinfo.port, rinfo.address);
}
// count 2 completes a sync someone else started; nothing to answer.
}
/** Drop inbound participants that have gone quiet (their machine slept or left without a BY). */
reap() {
const now = Date.now();
for (const [k, p] of this.participants) {
if (!p.outbound && now - (p.lastHeard || 0) > IDLE_MS) this.drop(k, 'went quiet');
}
}
sendFeedback() {
for (const p of this.participants.values()) {
if (!p.connected || p.lastSeq < 0 || p.lastSeq === p.fedBack) continue;
p.fedBack = p.lastSeq;
this.send(this.control, feedbackPacket(this.ssrc, p.lastSeq), p.controlPort, p.address);
}
}
/* — MIDI — */
onRtp(buf, rinfo) {
const pkt = parseRtpMidi(buf);
if (!pkt) return;
let p = [...this.participants.values()].find((q) => q.ssrc === pkt.ssrc && q.connected);
if (!p) {
// MIDI from someone not in a session with us (a stale session after our restart).
// Ask them to start over so the session comes back properly — once in a while, not per note.
const last = this.strangers.get(rinfo.address) || 0;
if (Date.now() - last > 5000) {
this.strangers.set(rinfo.address, Date.now());
this.send(this.control, sessionPacket(CMD.BY, 0, this.ssrc), rinfo.port - 1, rinfo.address);
}
return;
}
p.lastHeard = Date.now();
p.lastSeq = pkt.seq;
for (const m of pkt.messages) {
const event = describe(m);
if (event) this.emit('midi', { participant: p, event });
}
}
}
module.exports = {
Session,
parseRtpMidi,
parseSession,
describe,
sessionPacket,
syncPacket,
CMD,
};
+355
View File
@@ -0,0 +1,355 @@
'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 });
},
};
},
};
+185
View File
@@ -0,0 +1,185 @@
/* Vista Cues. Sizes hang off the tile (container units) times the tile's
own text scale (--vc-scale, the title bar's − / +), so one tile reads the
same at 3×2 on a laptop as at 8×5 on a wall. Colours are the shell's. */
[data-module="vista-cues"] .vc-root {
--tone: var(--text);
--vc-scale: 1;
position: absolute;
inset: 0;
container-type: size;
overflow: hidden;
color: var(--text);
font-variant-numeric: tabular-nums;
user-select: none;
-webkit-user-select: none;
}
[data-module="vista-cues"] .vc-root[data-tone="ok"] { --tone: var(--accent); }
[data-module="vista-cues"] .vc-root[data-tone="warn"] { --tone: var(--warn); }
[data-module="vista-cues"] .vc-root[data-tone="muted"] { --tone: var(--muted); }
[data-module="vista-cues"] [hidden] { display: none !important; }
[data-module="vista-cues"] .vc-body {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: calc(clamp(3px, 2.5cqh, 14px) * var(--vc-scale));
padding: clamp(6px, 3.5cqmin, 22px);
min-width: 0;
}
[data-module="vista-cues"] .vc-offline {
position: absolute;
top: clamp(4px, 3cqmin, 14px);
right: clamp(6px, 3.5cqmin, 22px);
z-index: 2;
font-size: calc(clamp(9px, 4.5cqmin, 14px) * var(--vc-scale));
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--danger);
}
[data-module="vista-cues"] .vc-root.is-offline .vc-now { opacity: 0.45; filter: grayscale(0.6); }
/* ── now ── */
[data-module="vista-cues"] .vc-now {
flex: 0 0 auto;
min-width: 0;
padding-left: clamp(6px, 2cqmin, 14px);
border-left: clamp(3px, 1.1cqmin, 7px) solid var(--tone);
}
[data-module="vista-cues"] .vc-kicker {
display: flex;
align-items: center;
gap: 0.6em;
font-size: calc(clamp(10px, 5cqmin, 18px) * var(--vc-scale));
color: var(--muted);
}
[data-module="vista-cues"] .vc-pill {
padding: 0.05em 0.55em;
border-radius: 999px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
background: var(--tone);
color: var(--accent-text);
}
[data-module="vista-cues"] .vc-root[data-tone="warn"] .vc-pill { color: var(--bg); }
[data-module="vista-cues"] .vc-ago { font-weight: 600; }
[data-module="vista-cues"] .vc-name-wrap { min-width: 0; }
[data-module="vista-cues"] .vc-name {
font-size: calc(clamp(16px, 14cqmin, 96px) * var(--vc-scale));
line-height: 1.12;
font-weight: 800;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
[data-module="vista-cues"] .vc-root[data-tone="muted"] .vc-name { color: var(--muted); font-weight: 600; }
[data-module="vista-cues"] .vc-meta {
font-size: calc(clamp(10px, 4.6cqmin, 18px) * var(--vc-scale));
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="vista-cues"] .vc-meta:empty { display: none; }
/* ── other live lists ── */
[data-module="vista-cues"] .vc-active {
display: flex;
flex-wrap: wrap;
gap: 0.4em;
flex: 0 0 auto;
font-size: calc(clamp(10px, 4.4cqmin, 17px) * var(--vc-scale));
}
[data-module="vista-cues"] .vc-chip {
display: inline-flex;
align-items: baseline;
gap: 0.5em;
max-width: 100%;
padding: 0.2em 0.65em;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
[data-module="vista-cues"] .vc-chip.is-paused {
border-color: color-mix(in srgb, var(--warn) 55%, transparent);
background: color-mix(in srgb, var(--warn) 14%, transparent);
}
[data-module="vista-cues"] .vc-chip-name {
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="vista-cues"] .vc-chip-ago,
[data-module="vista-cues"] .vc-chip-group { color: var(--muted); }
[data-module="vista-cues"] .vc-chip-group { font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.04em; }
/* ── recent ── */
[data-module="vista-cues"] .vc-recent {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
font-size: calc(clamp(10px, 3.8cqmin, 15px) * var(--vc-scale));
border-top: 1px solid var(--border);
padding-top: 0.35em;
}
[data-module="vista-cues"] .vc-row {
display: grid;
grid-template-columns: auto 6.2em minmax(0, 1fr) auto;
gap: 0.8em;
align-items: baseline;
padding: 0.12em 0;
white-space: nowrap;
}
[data-module="vista-cues"] .vc-row > span { overflow: hidden; text-overflow: ellipsis; }
[data-module="vista-cues"] .vc-row-time,
[data-module="vista-cues"] .vc-row-source { color: var(--muted); }
[data-module="vista-cues"] .vc-row-action { font-weight: 700; color: var(--accent); }
[data-module="vista-cues"] .vc-row[data-action="release"] .vc-row-action,
[data-module="vista-cues"] .vc-row[data-action="releaseAll"] .vc-row-action,
[data-module="vista-cues"] .vc-row[data-action="cleared"] .vc-row-action { color: var(--muted); }
[data-module="vista-cues"] .vc-row[data-action="pause"] .vc-row-action { color: var(--warn); }
[data-module="vista-cues"] .vc-row[data-action="flash"] .vc-row-action { color: var(--text); }
[data-module="vista-cues"] .vc-row.is-unmapped { opacity: 0.6; }
[data-module="vista-cues"] .vc-row.is-unmapped .vc-row-action { color: var(--muted); font-weight: 500; }
@container (max-width: 300px) {
[data-module="vista-cues"] .vc-row { grid-template-columns: 5.2em minmax(0, 1fr); }
[data-module="vista-cues"] .vc-row-time,
[data-module="vista-cues"] .vc-row-source { display: none; }
}
@container (max-height: 140px) {
[data-module="vista-cues"] .vc-recent,
[data-module="vista-cues"] .vc-active { display: none; }
}
/* ── senders ── */
[data-module="vista-cues"] .vc-sources {
display: flex;
flex-wrap: wrap;
gap: 0.9em;
margin-top: auto;
font-size: calc(clamp(9px, 3.4cqmin, 13px) * var(--vc-scale));
color: var(--muted);
}
[data-module="vista-cues"] .vc-src { display: inline-flex; align-items: center; gap: 0.4em; }
[data-module="vista-cues"] .vc-dot {
width: 0.65em;
height: 0.65em;
border-radius: 50%;
background: var(--danger);
}
[data-module="vista-cues"] .vc-src.is-on { color: var(--text); }
[data-module="vista-cues"] .vc-src.is-on .vc-dot { background: var(--accent); }
@container (max-height: 140px) {
[data-module="vista-cues"] .vc-sources { display: none; }
}
+371
View File
@@ -0,0 +1,371 @@
'use strict';
/**
* Tests for Vista Cues.
*
* node modules/vista-cues/vista.test.js
*
* Plain Node, no dependencies; exits non-zero on the first failure. Covers
* the cue-sheet parser, RTP-MIDI packet decoding, and a real session over
* loopback UDP: a fake sender invites the module's session, plays notes, and
* the server entry's state follows.
*/
const assert = require('assert');
const dgram = require('dgram');
const http = require('http');
const { parseMapping, match, noteFromName } = require('./mapping');
const { parseRtpMidi, describe, Session } = require('./rtpmidi');
let passed = 0;
async function test(name, fn) {
await fn();
passed++;
console.log(` ok ${name}`);
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
async function until(fn, ms = 3000) {
const end = Date.now() + ms;
while (Date.now() < end) {
if (fn()) return;
await wait(20);
}
throw new Error('timed out waiting');
}
/* ── a minimal independent sender ─────────────────────────────────────── */
function sessionPkt(cmd, token, ssrc, name) {
const n = name == null ? Buffer.alloc(0) : Buffer.from(`${name}\0`);
const b = Buffer.alloc(16 + n.length);
b.writeUInt16BE(0xffff, 0);
b.write(cmd, 2, 'ascii');
b.writeUInt32BE(2, 4);
b.writeUInt32BE(token, 8);
b.writeUInt32BE(ssrc, 12);
n.copy(b, 16);
return b;
}
function rtp(ssrc, seq, midiBytes, flags = 0) {
const midi = Buffer.from(midiBytes);
const b = Buffer.alloc(13 + midi.length);
b[0] = 0x80;
b[1] = 0x61;
b.writeUInt16BE(seq, 2);
b.writeUInt32BE(1234, 4);
b.writeUInt32BE(ssrc, 8);
b[12] = flags | midi.length;
midi.copy(b, 13);
return b;
}
/** A sender that accepts invitations (like a Mac session) and can play notes. */
async function fakeSender(port, name) {
const ssrc = 0x0badf00d;
const control = dgram.createSocket('udp4');
const data = dgram.createSocket('udp4');
const peers = [];
const syncs = [];
let seq = 100;
const handle = (sock, which) => (buf, r) => {
const cmd = buf.toString('ascii', 2, 4);
if (cmd === 'IN') {
sock.send(sessionPkt('OK', buf.readUInt32BE(8), ssrc, name), r.port, r.address);
if (which === 'data') peers.push({ address: r.address, port: r.port });
} else if (cmd === 'CK') {
syncs.push(buf[8]);
if (buf[8] === 0) {
const out = Buffer.from(buf);
out.writeUInt32BE(ssrc, 4);
out[8] = 1;
data.send(out, r.port, r.address);
}
}
};
control.on('message', handle(control, 'control'));
data.on('message', handle(data, 'data'));
await new Promise((r) => control.bind(port, '127.0.0.1', r));
await new Promise((r) => data.bind(port + 1, '127.0.0.1', r));
return {
peers,
syncs,
note(n, ch = 1) {
for (const p of peers) data.send(rtp(ssrc, seq++, [0x90 | (ch - 1), n, 100, 0x00, n, 0]), p.port, p.address);
},
close() { control.close(); data.close(); },
};
}
function freePort() {
return new Promise((resolve) => {
const s = dgram.createSocket('udp4');
s.bind(0, () => { const p = s.address().port; s.close(() => resolve(p)); });
});
}
/** Two adjacent free UDP ports, as a session needs. */
async function freePair() {
for (let i = 0; i < 20; i++) {
const p = await freePort();
if (p > 65000) continue;
const ok = await new Promise((resolve) => {
const s = dgram.createSocket('udp4');
s.on('error', () => resolve(false));
s.bind(p + 1, () => s.close(() => resolve(true)));
});
if (ok) return p;
}
throw new Error('no free port pair');
}
(async () => {
console.log('mapping');
await test('reads the cue sheet forms', () => {
const { rules, errors } = parseMapping([
'# comment',
'60 = Walk-in',
'C3 - Worship 1',
'61 release Walk-in',
'62\tPause\tWorship 1',
'127 release all',
'ch2:64 = Message # only on channel 2',
'pc5 = Baptism',
'note 66: Go: Song #2',
'67 = "Back Lights"',
'F#2 = Hazer',
].join('\n'));
assert.deepStrictEqual(errors, []);
const brief = rules.map((r) => [r.channel, r.kind, r.number, r.action, r.list]);
assert.deepStrictEqual(brief, [
[null, 'note', 60, 'go', 'Walk-in'],
[null, 'note', 60, 'go', 'Worship 1'],
[null, 'note', 61, 'release', 'Walk-in'],
[null, 'note', 62, 'pause', 'Worship 1'],
[null, 'note', 127, 'releaseAll', ''],
[2, 'note', 64, 'go', 'Message'],
[null, 'program', 5, 'go', 'Baptism'],
[null, 'note', 66, 'go', 'Song #2'],
[null, 'note', 67, 'go', 'Back Lights'],
[null, 'note', 54, 'go', 'Hazer'],
]);
});
await test('middle C naming', () => {
assert.strictEqual(noteFromName('C3', 'C3'), 60);
assert.strictEqual(noteFromName('C4', 'C4'), 60);
assert.strictEqual(noteFromName('C-2', 'C3'), 0);
assert.strictEqual(noteFromName('Bb3', 'C3'), 70);
});
await test('bad lines are reported, not fatal', () => {
const { rules, errors } = parseMapping('hello\n200 = Big\n61 release\nch17:3 = X\n60 = OK');
assert.strictEqual(rules.length, 1);
assert.deepStrictEqual(errors.map((e) => e.line), [1, 2, 3, 4]);
});
await test('groups and flashes', () => {
const { rules, groups, errors } = parseMapping('5 = Loose\n[Scene]\n0 = Pre Service\n8 = Message\n[Color]\n23 = Blue\n30 flash Blinder Flash 1\n[]\n9 = After');
assert.deepStrictEqual(errors, []);
assert.deepStrictEqual(groups, ['Scene', 'Color']);
assert.deepStrictEqual(rules.map((r) => [r.number, r.group, r.action]), [
[5, '', 'go'], [0, 'Scene', 'go'], [8, 'Scene', 'go'], [23, 'Color', 'go'], [30, 'Color', 'flash'], [9, '', 'go'],
]);
});
await test('the NA cue sheet parses cleanly', () => {
const fs = require('fs');
const path = require('path');
const file = path.join(__dirname, '..', '..', '..', 'NA cue sheet.txt');
if (!fs.existsSync(file)) return;
const { rules, errors, groups } = parseMapping(fs.readFileSync(file, 'utf8'));
assert.deepStrictEqual(errors, []);
assert.strictEqual(rules.length, 53); // every note marked TRUE for NA in the sheet
assert.deepStrictEqual(groups, ['Main', 'Color', 'Blinders', 'Build', 'House', 'Lekos']);
assert.strictEqual(new Set(rules.map((r) => r.number)).size, rules.length);
});
await test('matching respects channel and kind', () => {
const { rules } = parseMapping('ch2:64 = Two\n64 = Any\npc64 = Program');
assert.strictEqual(match(rules, { type: 'noteon', channel: 2, note: 64 }).list, 'Two');
assert.strictEqual(match(rules, { type: 'noteon', channel: 1, note: 64 }).list, 'Any');
assert.strictEqual(match(rules, { type: 'program', channel: 1, program: 64 }).list, 'Program');
assert.strictEqual(match(rules, { type: 'noteoff', channel: 1, note: 64 }), null);
});
console.log('rtp-midi');
await test('note on + running-status note off', () => {
const p = parseRtpMidi(rtp(7, 9, [0x90, 60, 100, 0x00, 60, 0]));
assert.strictEqual(p.ssrc, 7);
assert.strictEqual(p.seq, 9);
assert.deepStrictEqual(p.messages.map(describe), [
{ type: 'noteon', channel: 1, note: 60, velocity: 100 },
{ type: 'noteoff', channel: 1, note: 60 },
]);
});
await test('Z flag, multi-byte deltas, program change, sysex in between', () => {
const p = parseRtpMidi(rtp(7, 1, [0x05, 0xc2, 4, 0x81, 0x00, 0xf0, 0x7f, 0x01, 0xf7, 0x00, 0x93, 61, 9], 0x20));
assert.deepStrictEqual(p.messages.map(describe).filter(Boolean), [
{ type: 'program', channel: 3, program: 4 },
{ type: 'noteon', channel: 4, note: 61, velocity: 9 },
]);
});
await test('journal bytes after the command section are ignored', () => {
const b = Buffer.concat([rtp(7, 1, [0x90, 62, 1], 0x40), Buffer.from([0x80, 0x00, 0x05, 0x01, 0x02])]);
assert.deepStrictEqual(parseRtpMidi(b).messages.map(describe), [{ type: 'noteon', channel: 1, note: 62, velocity: 1 }]);
});
console.log('session');
await test('invites a sender, keeps sync, and hears its notes', async () => {
const senderPort = await freePair();
const sender = await fakeSender(senderPort, 'Fake PP');
const port = await freePair();
const s = new Session({ name: 'Test', port });
assert.ok(await s.start());
const heard = [];
s.on('midi', ({ participant, event }) => heard.push([participant.label, event.type, event.note]));
s.invite('127.0.0.1', senderPort, 'ProPresenter');
await until(() => s.list().some((p) => p.connected));
await until(() => sender.syncs.includes(2)); // we answered its count-1 with a count-2
sender.note(60);
await until(() => heard.length >= 2);
assert.deepStrictEqual(heard, [['ProPresenter', 'noteon', 60], ['ProPresenter', 'noteoff', 60]]);
s.stop();
sender.close();
await wait(80);
});
await test('a config save re-inits on the same port straight away', async () => {
const senderPort = await freePair();
const sender = await fakeSender(senderPort, 'Fake PP');
const port = await freePair();
const mod = require('./server');
const config = { sessionPort: port, advertise: false, source1: { host: '127.0.0.1', port: senderPort } };
let handle = mod.init({ config, log: () => {} });
await until(() => handle.health().status === 'ok');
handle.stop();
handle = mod.init({ config, log: () => {} }); // what the shell does on save: stop(), then init()
await until(() => handle.health().status === 'ok');
handle.stop();
sender.close();
await wait(80);
});
await test('a port taken by someone else is reported, then taken once free', async () => {
const port = await freePair();
const squatter = dgram.createSocket('udp4');
await new Promise((r) => squatter.bind(port + 1, r));
const s = new Session({ name: 'Test', port });
let listening = false;
s.on('listening', () => { listening = true; });
assert.strictEqual(await s.start(), false);
assert.match(s.error, /in use/);
squatter.close();
await until(() => listening, 5000);
assert.strictEqual(s.error, '');
s.stop();
});
await test('server entry: notes become live cue lists, over HTTP', async () => {
const senderPort = await freePair();
const sender = await fakeSender(senderPort, 'Fake PP');
const port = await freePair();
const mod = require('./server');
const handle = mod.init({
config: {
sessionPort: port,
advertise: false,
source1Name: 'ProPresenter',
source1: { host: '127.0.0.1', port: senderPort },
cueLists: '60 = Walk-in\n61 = Worship 1\n70 release Walk-in\n127 release all',
},
log: () => {},
});
const routes = mod.routes({});
const server = http.createServer((req, res) => {
const r = routes[`${req.method} ${req.url}`];
if (r) r(req, res); else { res.writeHead(404); res.end(); }
});
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const get = async (path, method = 'GET') => {
const res = await fetch(`http://127.0.0.1:${server.address().port}${path}`, { method });
return res.json();
};
const state = async () => (await get('/state')).state;
await until(() => sender.peers.length > 0);
await until(() => handle.health().status === 'ok');
sender.note(60);
await wait(100);
sender.note(61);
await wait(100);
let st = await state();
assert.strictEqual(st.current.name, 'Worship 1');
assert.deepStrictEqual(st.active.map((l) => l.name), ['Walk-in']);
assert.strictEqual(st.current.source, 'ProPresenter');
assert.strictEqual(st.sources[0].connected, true);
sender.note(70);
sender.note(99); // unmapped
await wait(150);
st = await state();
assert.deepStrictEqual(st.active, []);
assert.strictEqual(st.log[0].mapped, false);
assert.match(st.log[0].trigger, /Note 99/);
sender.note(127);
await wait(100);
st = await state();
assert.strictEqual(st.current, null);
sender.note(60);
await wait(100);
await get('/clear', 'POST');
st = await state();
assert.strictEqual(st.current, null);
assert.strictEqual(st.log[0].action, 'cleared');
handle.stop();
// Groups: a Go replaces its group's live list; the first group is the headline; flashes stay out.
const h2 = mod.init({
config: {
sessionPort: port, advertise: false, source1: { host: '127.0.0.1', port: senderPort },
cueLists: '[Scene]\n0 = Pre Service\n8 = Message\n[Color]\n22 = Red\n23 = Blue\n30 flash Blinder Flash 1',
},
log: () => {},
});
await until(() => h2.health().status === 'ok');
for (const n of [0, 22, 23, 30]) { sender.note(n); await wait(60); }
await wait(100);
st = await state();
assert.strictEqual(st.current.name, 'Pre Service');
assert.deepStrictEqual(st.active.map((l) => [l.group, l.name]), [['Color', 'Blue']]); // Blue replaced Red
assert.strictEqual(st.log[0].action, 'flash');
sender.note(8); // a new main scene takes over the look…
await wait(100);
st = await state();
assert.strictEqual(st.current.name, 'Message');
assert.deepStrictEqual(st.active, []);
sender.note(22); // …and FX fired after it layer on top again
await wait(100);
st = await state();
assert.deepStrictEqual(st.active.map((l) => l.name), ['Red']);
h2.stop();
server.close();
sender.close();
await wait(80);
});
console.log(`\n${passed} passed`);
process.exit(0);
})().catch((e) => {
console.error(e);
process.exit(1);
});