#!/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); });