Files
ProdDash/tools/vista-midi-mock.js
T
christianandClaude Opus 5.5 2893316a1a 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>
2026-10-11 11:15:45 -04:00

178 lines
6.7 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* A stand-in for the ProPresenter Mac's network MIDI session, for the Vista
* Cues module: an RTP-MIDI (AppleMIDI) session that accepts invitations and
* plays a cue sheet's notes to everyone in it, the way ProPresenter's MIDI
* actions reach Vista.
*
* node tools/vista-midi-mock.js 5104 # wait to be invited on UDP 5104/5105
* node tools/vista-midi-mock.js 5104 --invite 127.0.0.1:5040
* # …or invite ProdDash yourself
* --name "Tracks Laptop" session name (default "ProPresenter Mock")
* --every 4 seconds between cues (default 4)
* --channel 1 MIDI channel (default 1)
* --notes 2,23,8,30 play these notes in a loop instead of the built-in sheet's
*
* Point Vista Cues' "First sender" at 127.0.0.1 port 5104 and paste the
* sheet this prints into "Cue lists". Kill it to watch the tile go red;
* start it again to watch it come back.
*
* Written separately from the module's own rtpmidi.js on purpose, so a bug
* there is not mirrored here.
*/
const dgram = require('dgram');
const argv = process.argv.slice(2);
const opt = (name, dflt) => {
const i = argv.indexOf(`--${name}`);
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
};
const PORT = Number(argv.find((a) => /^\d+$/.test(a))) || 5104;
const NAME = opt('name', 'ProPresenter Mock');
const EVERY = Number(opt('every', 4)) * 1000;
const CHANNEL = Math.min(16, Math.max(1, Number(opt('channel', 1)))) - 1;
const INVITE = opt('invite', '');
const SHEET = [
'60 = Walk-in',
'61 = Worship 1',
'62 = Worship 2',
'63 = Message',
'64 pause Worship 2',
'70 release Walk-in',
'71 release Worship 1',
'127 release all',
];
const NOTES = opt('notes', '');
const SCRIPT = NOTES ? NOTES.split(',').map(Number).filter((n) => n >= 0 && n <= 127) : [60, 61, 70, 62, 64, 64, 71, 90, 63, 127];
const ssrc = (Math.random() * 0xffffffff) >>> 0;
const t0 = process.hrtime.bigint();
const now = () => (process.hrtime.bigint() - t0) / 100000n;
let seq = Math.floor(Math.random() * 0xffff);
const peers = new Map(); // ssrc → { address, controlPort, dataPort, token, name, ready }
function session(cmd, token, 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 >>> 0, 8);
b.writeUInt32BE(ssrc, 12);
n.copy(b, 16);
return b;
}
function ck(count, ts) {
const b = Buffer.alloc(36);
b.writeUInt16BE(0xffff, 0);
b.write('CK', 2, 'ascii');
b.writeUInt32BE(ssrc, 4);
b[8] = count;
ts.forEach((t, i) => b.writeBigUInt64BE(BigInt.asUintN(64, t), 12 + i * 8));
return b;
}
/** One RTP-MIDI packet: note on, then (delta 0, running status) the note off. */
function notePacket(note) {
const midi = Buffer.from([0x90 | CHANNEL, note, 100, 0x00, note, 0x00]);
const b = Buffer.alloc(12 + 1 + midi.length);
b[0] = 0x80;
b[1] = 0x61;
seq = (seq + 1) & 0xffff;
b.writeUInt16BE(seq, 2);
b.writeUInt32BE(Number(now() & 0xffffffffn), 4);
b.writeUInt32BE(ssrc, 8);
b[12] = midi.length; // B=0 J=0 Z=0 P=0, LEN
midi.copy(b, 13);
return b;
}
const control = dgram.createSocket('udp4');
const data = dgram.createSocket('udp4');
function onPacket(sock, which) {
return (buf, r) => {
if (buf.length < 4 || buf.readUInt16BE(0) !== 0xffff) return;
const cmd = buf.toString('ascii', 2, 4);
if (cmd === 'IN') {
const token = buf.readUInt32BE(8);
const them = buf.readUInt32BE(12);
const end = buf.indexOf(0, 16);
const name = buf.toString('utf8', 16, end < 0 ? buf.length : end);
sock.send(session('OK', token, NAME), r.port, r.address);
const p = peers.get(them) || { address: r.address, token, name, ready: false };
if (which === 'control') p.controlPort = r.port;
else { p.dataPort = r.port; p.ready = true; console.log(`[mock] "${name}" joined from ${r.address}`); }
peers.set(them, p);
} else if (cmd === 'OK') {
// Our own invitation (--invite) was accepted.
const them = buf.readUInt32BE(12);
const end = buf.indexOf(0, 16);
const name = buf.toString('utf8', 16, end < 0 ? buf.length : end);
if (which === 'control') {
pending.stage = 'data';
data.send(session('IN', pending.token, NAME), pending.port + 1, pending.host);
} else {
pending.stage = 'done';
peers.set(them, { address: r.address, controlPort: pending.port, dataPort: r.port, token: pending.token, name, ready: true });
console.log(`[mock] joined "${name}" at ${r.address}:${pending.port}`);
}
} else if (cmd === 'BY') {
const them = buf.readUInt32BE(12);
if (peers.delete(them)) console.log('[mock] a participant left');
} else if (cmd === 'CK') {
const count = buf[8];
const ts = [buf.readBigUInt64BE(12), buf.readBigUInt64BE(20), buf.readBigUInt64BE(28)];
if (count === 0) data.send(ck(1, [ts[0], now(), 0n]), r.port, r.address);
else if (count === 1) data.send(ck(2, [ts[0], ts[1], now()]), r.port, r.address);
}
};
}
const pending = { stage: '', host: '', port: 0, token: 0 };
control.on('message', onPacket(control, 'control'));
data.on('message', onPacket(data, 'data'));
control.bind(PORT, () => data.bind(PORT + 1, () => {
console.log(`[mock] session "${NAME}" on UDP ${PORT}/${PORT + 1}, channel ${CHANNEL + 1}`);
console.log('[mock] cue sheet for Vista Cues → Cue lists:\n ' + SHEET.join('\n '));
if (INVITE) {
const [host, port] = INVITE.split(':');
Object.assign(pending, { stage: 'control', host, port: Number(port) || 5040, token: (Math.random() * 0xffffffff) >>> 0 });
const tryInvite = () => {
if (pending.stage === 'done') return;
pending.stage = 'control';
control.send(session('IN', pending.token, NAME), pending.port, pending.host);
setTimeout(tryInvite, 2000);
};
tryInvite();
}
}));
// Clock sync, as the session that started it would do (harmless otherwise).
setInterval(() => {
for (const p of peers.values()) if (p.ready) data.send(ck(0, [now(), 0n, 0n]), p.dataPort, p.address);
}, 10000);
let step = 0;
setInterval(() => {
const ready = [...peers.values()].filter((p) => p.ready);
if (!ready.length) return;
const note = SCRIPT[step % SCRIPT.length];
step += 1;
const pkt = notePacket(note);
for (const p of ready) data.send(pkt, p.dataPort, p.address);
console.log(`[mock] note ${note} → ${ready.length} participant${ready.length === 1 ? '' : 's'}`);
}, EVERY);
process.on('SIGINT', () => {
for (const p of peers.values()) control.send(session('BY', p.token), p.controlPort, p.address);
setTimeout(() => process.exit(0), 100);
});