178 lines
6.7 KiB
JavaScript
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);
|
||
|
|
});
|