Compare commits

..
Author SHA1 Message Date
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
michael 1410cbc169 Merge pull request 'Shell 1.13.0: the Layouts menu lists the tiles a layout holds but the board is not showing, with a ✕ to remove each' (#46) from claude/layouts-menu-not-shown into main 2026-10-11 09:54:06 -04:00
christian 60f82690f3 Merge pull request 'Resi Cues 1.0.3: the next cue's name keeps moving, and Resi logins stop hammering the account' (#43) from claude/resi-cues-startup-pause into main 2026-10-07 17:24:56 -04:00
christianandClaude Opus 5.5 79d3237328 Resi Cues 1.0.3: a login without access to the venue stops signing in every poll
Every 401/403 was answered by signing in again on the spot. For a login that
works but may not list the account's venues (media@waters.church at North
Attleboro got 403 on /venues), that was a successful sign-in on every 1s poll
-- the pattern that had just got another Resi account locked.

- A refusal straight after signing in is a permission, not an expired
  session: it is held off like a failed login, with the message "This Resi
  login isn't allowed to see its venues (403) — ask a Resi admin for access".
  Only a session over a minute old is renewed.
- 429 on any request holds off too, not only on the login.
- The players cache-bust fallback no longer drops the parameter for good on a
  401/403/429, which are about the login rather than the parameter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:27:38 -04:00
christianandClaude Opus 5.5 67f612e371 Resi Cues 1.0.2: a failed Resi login holds off instead of retrying every second
A failed login left no session, so the next 1s poll signed in again, and the
next, for ever. Resi answers that with 429s, and since the retries never let
up the account never got out of them: at North Attleboro the decoder picker
stayed empty with "Resi login failed (429)". The picker made it worse by
building a fresh client -- and signing in -- on every admin page load.

- After a failed login, further attempts wait 15s, doubling to 5min (or as
  long as Resi's Retry-After asks); calls in between fail at once with the
  reason and how long until the next try.
- The status says what happened: rate-limited, password rejected, or Resi
  unreachable.
- The decoder picker reuses the running module's client when the login
  matches, otherwise one client per login, so it shares the hold-off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:44:14 -04:00
christianandClaude Opus 5.5 0f0adb4c60 Resi Cues 1.0.1: the next cue's name keeps moving on a 30-second Resi
On a Resi that publishes every ~30s, the board could start a few seconds
before a publish and learn a 2s cadence from that partial first gap. Every
honest 30s gap after that was thrown out as a pause, so playback read as
paused 15s into every update: the countdown froze, and the next cue -- and
the name on the Timers card -- stopped moving on.

- The partial gap right after startup is no longer learned, and one outlier
  gap can no longer lock out the real cadence.
- Until a cadence is measured, a pause is only called after 45s, not 15s.
- The Timers card is titled with the cue's name; its status line now says
  "Next cue" / "Held" instead of repeating the name.
- server.test.js awaited nothing, so an async test raced the next one and
  failed on main; tests now run one at a time.

Same fix as the Resi -> ProPresenter bridge and the Companion module 0.1.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:59:12 -04:00
15 changed files with 2563 additions and 39 deletions
+1
View File
@@ -52,6 +52,7 @@ title-bar controls). The rules that matter most:
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
`node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`).
`node tools/vista-midi-mock.js 5104` (a network MIDI / RTP-MIDI session for Vista Cues; point its first sender at `127.0.0.1:5104` and paste the cue sheet it prints; `--invite 127.0.0.1:5040` joins from its side, `--name`, `--channel`, `--every`, `--notes 2,23,8`).
## Reference code (read-only forks — don't develop these here)
+34 -1
View File
@@ -550,6 +550,36 @@ below. Nothing is installed without someone clicking.
soon as it does) and **Marks** (how long a mark lives).
Tile settings: campuses, card or list layout, and a **Show** group for the
day, role, resi source, campuses fed, notes and the updated line.
- **Vista Cues** — which Vista 3 cue lists are live, for a booth where
nobody runs lighting and ProPresenter and the tracks rig fire Vista's cue
lists over network MIDI. Vista sends no feedback of any kind (its OSC only
takes commands), so the module listens instead: it joins the senders'
network MIDI sessions as one more participant, the way Vista does, hears
every note Vista hears, and turns each one into "Go on Walk-in", "Release
Worship 1"… from the cue sheet pasted into admin. The tile shows the list
fired last, large, with how long ago and who fired it; the other lists
still live; the recent notes; and which senders are connected. What it
shows is what Vista was *told*: if Vista is closed or someone works the
desk by hand, the two can differ, and the title bar's eraser forgets every
live list. Plain JavaScript (RTP-MIDI over UDP), no helper; on macOS it
advertises itself over Bonjour so it shows up in Audio MIDI Setup's
Directory. Admin config: up to three senders (a name, and the host and
port of each machine's network session — ProPresenter, the tracks rig,
Companion; ProdDash keeps inviting itself in, so each session's "Who may
connect to me" must be Anyone, or connect from the sender's Directory
instead), the cue sheet (one line per note: `0 = Pre Service`,
`61 release Walk-in`, `127 release all`, `30 flash Blinder Flash 1` for a
momentary hit, also pause, back, note names, a channel, program changes),
middle C naming, whether a Go releases the others, and a folded **This
machine** group for the session name, port (UDP 5040 and 5041) and
Bonjour. A sheet with no release notes uses groups: a line `[Color]`
starts one, a Go in a group replaces that group's live list, and the
first group is the tile's headline — so the tile reads "Message" with
Color · Blue and House · House Lights Message beside it. A Go in that
first group (the main scene) takes over the look and clears the rest, as
Vista's last-takes-precedence does; FX fired after it layer on again
(the **Main group takes over** switch). Tile settings: other lists,
recent, senders, unmapped notes — text size is − / + in the title bar.
- **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors.
@@ -565,7 +595,10 @@ timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js`
run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and
`node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup
board — a plan, its team members, birthdays and photos — run ProdDash with
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and
`node tools/vista-midi-mock.js 5104` (a network MIDI session playing a cue
sheet's notes, as ProPresenter does for Vista — point Vista Cues' first
sender at `127.0.0.1:5104` and paste the sheet it prints). See the testing section of
the [module guide](docs/MODULE-GUIDE.md).
## Install as a desktop web app
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "resi-cues",
"name": "Resi Cues",
"version": "1.0.0",
"version": "1.0.3",
"proddash": ">=1.4.0",
"description": "Time until the next Resi cue point, on the board and as a timer",
"provides": ["timers"],
+89 -14
View File
@@ -39,6 +39,9 @@ function msToPosition(total) {
const WINDOW_MS = 120000; // how long a reading stays useful
const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek
const LOGIN_BACKOFF_MS = 15000; // first wait after a failed Resi login
const LOGIN_BACKOFF_MAX_MS = 300000; // longest wait between login attempts
const SESSION_FRESH_MS = 60000; // a refusal younger than this is a permission, not an expired session
/**
* Resi republishes a decoder's `position` only every few seconds, on an irregular
@@ -61,6 +64,8 @@ function createPlayhead(options) {
let offsets = []; // { at, off } — off is mediaMs - wall time it was read
let spreads = []; // how far apart honest readings sit: this link's noise
let gaps = []; // how long between the playhead actually moving
let changes = 0; // readings that differed from the one before
let rejected = false; // the last gap looked like a pause and was skipped
let base; // the trusted offset
let lastMedia;
let lastNewAt;
@@ -90,15 +95,25 @@ function createPlayhead(options) {
spreads = spreads.concat(-delta).slice(-20);
}
}
if (lastNewAt !== undefined) {
// The first reading lands at an arbitrary point in Resi's cycle, so the gap
// from it to the next one is only a fraction of the real cadence. Learning
// from it locked in a 2s cadence that rejected every honest 30s gap.
if (lastNewAt !== undefined && changes > 0) {
const gap = t - lastNewAt;
// The gap either side of a pause is not Resi's cadence — recording it
// would inflate the estimate and make the next pause slow to notice.
// But a pause is one gap; twice in a row is the cadence having changed.
const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) {
if (gap > 0 && gap < 60000) {
if (!worst || gap <= worst * 3 || rejected) {
gaps = gaps.concat(gap).slice(-8);
rejected = false;
} else {
rejected = true;
}
}
}
if (lastNewAt !== undefined) changes++;
offsets = offsets.concat({ at: t, off });
lastMedia = mediaMs;
lastNewAt = t;
@@ -118,7 +133,9 @@ function createPlayhead(options) {
// Only call it paused once the playhead has been still for clearly longer than
// Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a
// countdown that freezes mid-service is the worse failure of the two.
const pauseAfter = Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000));
// Until a full gap has been measured the cadence is unknown; assume the slowest
// Resi plausibly is, or a 30s publisher reads as paused 15s after every start.
const pauseAfter = cadenceMs ? Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)) : 45000;
const playing = base !== undefined && t - lastNewAt < pauseAfter;
if (!playing) {
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
@@ -161,13 +178,43 @@ function createResiClient(options) {
return h;
}
/* A failed login used to be retried on the very next poll -- once a second, for
ever. Resi answers that with 429s, and since the retries never let up, the
account never got out of them. So a failure now holds off further attempts,
doubling from 15s to 5min (or as long as Resi's Retry-After asks), and every
call in between fails at once with the same reason instead of signing in. */
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
let loginFailures = 0;
let loginBlockedUntil = 0;
let loginError = '';
async function login() {
const res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
if (now() < loginBlockedUntil) {
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
throw new Error(`${loginError} — retrying in ${wait}s`);
}
let res;
try {
res = await doFetch(`${RESI}/api/v3/login?newToken=true`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ userName: opts.username, password: opts.password }),
});
if (!res.ok) throw new Error(`Resi login failed (${res.status})`);
} catch (err) {
holdOff(`Resi unreachable (${(err && err.message) || err})`, 0);
throw new Error(loginError);
}
if (!res.ok) {
const reason = res.status === 429 ? 'Resi is rate-limiting this login (429)'
: res.status === 400 || res.status === 401 ? `Resi rejected the login (${res.status}) — check the password`
: `Resi login failed (${res.status})`;
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
holdOff(reason, retryAfter * 1000);
throw new Error(reason);
}
loginFailures = 0;
loginBlockedUntil = 0;
loginError = '';
const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie())
|| (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []);
const jar = {};
@@ -179,24 +226,49 @@ function createResiClient(options) {
token = jar.livingas1 || '';
if (!token) throw new Error('Resi login returned no session cookie');
cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; ');
loggedInAt = Date.now();
loggedInAt = now();
const me = await get('/api_v2.svc/users/me');
customerId = (me && me.customerId) || '';
if (!customerId) throw new Error('This Resi account has no customerId');
return me;
}
async function ensureSession() {
if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login();
function holdOff(reason, askedMs) {
loginFailures++;
const backoff = Math.min(LOGIN_BACKOFF_MAX_MS, LOGIN_BACKOFF_MS * 2 ** (loginFailures - 1));
loginBlockedUntil = now() + Math.max(backoff, askedMs || 0);
loginError = reason;
}
async function ensureSession() {
if (!token || now() - loggedInAt > 50 * 60 * 1000) await login();
}
/* The same hold-off covers every request, not just the login. A 401/403 used to
sign in again on the spot, every time: for a login that simply isn't allowed to
see a venue, that was a fresh sign-in on every poll. Now a refusal straight
after signing in is taken as what it is -- a permission -- and held off like a
failed login; only an older session is renewed. A 429 holds off too. */
async function get(path) {
const res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
if (res.status === 401 || res.status === 403) {
if (now() < loginBlockedUntil) {
const wait = Math.ceil((loginBlockedUntil - now()) / 1000);
throw new Error(`${loginError} — retrying in ${wait}s`);
}
let res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
if ((res.status === 401 || res.status === 403) && now() - loggedInAt > SESSION_FRESH_MS) {
await login();
const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() });
if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`);
return retry.json();
res = await doFetch(RESI + path, { method: 'GET', headers: headers() });
}
if (res.status === 401 || res.status === 403) {
const where = path.includes('/venues') ? 'its venues'
: path.includes('/players') ? "this venue's decoders" : 'part of the account';
holdOff(`This Resi login isn't allowed to see ${where} (${res.status}) — ask a Resi admin for access`, 0);
throw new Error(loginError);
}
if (res.status === 429) {
const retryAfter = Number(res.headers && res.headers.get && res.headers.get('retry-after')) || 0;
holdOff('Resi is rate-limiting requests (429)', retryAfter * 1000);
throw new Error(loginError);
}
if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`);
return res.json();
@@ -216,7 +288,10 @@ function createResiClient(options) {
try {
return (await get(`${path}?_=${Date.now()}`)) || [];
} catch (err) {
if (!/\((4\d\d)\)/.test(String(err.message))) throw err;
// Only an objection to the parameter itself; a refusal or a "slow down"
// (401/403/429) is about the login, and dropping it then lost fresh readings.
const code = (String(err.message).match(/\((4\d\d)\)/) || [])[1];
if (!code || ['401', '403', '429'].includes(code)) throw err;
cacheBust = false;
}
}
+26
View File
@@ -197,6 +197,32 @@ test('surroundingCues puts a cue you are exactly on behind you', () => {
assert.strictEqual(surroundingCues([], 1000).next, null);
});
/* ── starting partway through Resi's cycle ─────────────────────────────── */
// Seen live at North Attleboro: Resi publishing every ~30s, and the board starting a
// few seconds before a publish. The first "gap" it measured was 2s (9s after a
// reboot), and once that was the cadence every honest 30s gap was thrown out as a
// pause gap -- so playback read as paused 15s into every update, the countdown froze,
// and the next cue (and its name) stopped moving on.
for (const [into, burst] of [[28000, false], [21000, false], [0, true]]) {
test(`30s Resi, started ${into / 1000}s into its cycle${burst ? ', 2s burst after a seek' : ''}: never falsely paused`, () => {
const w = makeWorld({ cadence: 30000, lag: 1000, jitter: 2000, net: 500 });
w.lastPublish = w.t - into;
const head = createPlayhead({ now: () => w.t });
const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); };
let paused = 0;
for (let i = 0; i < 100; i++) if (!second().playing) paused++;
if (burst) {
w.media += 15 * 60000; // a seek, published at once...
w.lastPublish = w.t - w.nextCadence;
second(); second();
w.lastPublish = w.t - w.nextCadence; // ...and again 2s later
}
for (let i = 0; i < 300; i++) if (!second().playing) paused++;
assert.strictEqual(paused, 0, `read as paused for ${paused}s while playing`);
});
}
/* ── the matrix ────────────────────────────────────────────────────────── */
const argv = process.argv.slice(2).map(Number);
+20 -4
View File
@@ -70,6 +70,7 @@ function emptyTimers(now) {
}
let current = null; // the live handle, shared with routes()
let pickerClient = null; // { key, client } for the decoder picker when the module isn't running those credentials
module.exports = {
init({ config, log }) {
@@ -146,7 +147,9 @@ module.exports = {
elapsedMs: null,
remainingMs,
targetMs: Math.max(1000, state.next.ms - from),
status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' },
// The label already is the cue's name, and it is what the card is titled
// with; repeating it on the status line only crowded the card.
status: { text: state.playing ? 'Next cue' : 'Held', tone: state.playing ? 'ok' : 'warn' },
detail: state.event || '',
});
}
@@ -271,6 +274,7 @@ module.exports = {
stateStreams,
timerStreams,
resi,
credentialsKey: `${username}\n${password}`,
configured: Boolean(username && password),
stop() {
stopped = true;
@@ -332,9 +336,21 @@ module.exports = {
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
}
try {
const client = createResiClient({
username: String(config.username), password: String(config.password),
});
// The running module's client when it has these credentials, else one kept
// per login: a fresh client per request signed in on every admin page load,
// and its own hold-off never applied -- which kept Resi rate-limiting.
const username = String(config.username).trim();
const key = `${username}\n${config.password}`;
let client;
if (current && current.resi && current.credentialsKey === key) client = current.resi;
else {
if (!pickerClient || pickerClient.key !== key) {
pickerClient = { key, client: createResiClient({
username, password: String(config.password),
}) };
}
client = pickerClient.client;
}
const options = [];
for (const v of await client.venues()) {
for (const p of await client.players(v.uuid)) {
+93 -14
View File
@@ -18,8 +18,9 @@
const assert = require('assert');
let passed = 0;
function test(name, fn) {
fn();
// Awaited, so an async test finishes before the next one changes the world under it.
async function test(name, fn) {
await fn();
passed++;
console.log(` ok ${name}`);
}
@@ -101,7 +102,7 @@ function collect(route, req) {
const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
test('signs in once and finds the configured decoder', () => {
await test('signs in once and finds the configured decoder', () => {
assert.strictEqual(world.logins, 1, 'should sign in exactly once');
const state = handle.state;
assert.strictEqual(state.status, 'ok', state.message);
@@ -110,20 +111,20 @@ function collect(route, req) {
assert.strictEqual(state.cues.length, 3);
});
test('works out the cue either side of the playhead', () => {
await test('works out the cue either side of the playhead', () => {
const state = handle.state;
assert.strictEqual(state.next.name, 'Campus Host');
assert.strictEqual(state.prev.name, 'Welcome');
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
});
test('/state answers the same snapshot as the stream', () => {
await test('/state answers the same snapshot as the stream', () => {
const res = collect(routes['GET /state']);
assert.strictEqual(res.code, 200);
assert.deepStrictEqual(JSON.parse(res.body), handle.state);
});
test('/stream opens with an event so a new tile paints at once', () => {
await test('/stream opens with an event so a new tile paints at once', () => {
const res = collect(routes['GET /stream']);
assert.strictEqual(res.code, 200);
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
@@ -131,7 +132,7 @@ function collect(route, req) {
assert.ok(res.body.includes('event: state'), 'should send an initial state event');
});
test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
await test('/timers offers the countdown and the playhead, shaped as the guide says', () => {
const snap = JSON.parse(collect(routes['GET /timers']).body);
assert.strictEqual(snap.source, 'resi-cues');
assert.strictEqual(snap.label, 'Resi');
@@ -145,7 +146,7 @@ function collect(route, req) {
assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`);
// the countdown runs from the cue before it, so a consumer can colour progress
assert.strictEqual(next.targetMs, 450000);
assert.strictEqual(next.status.text, 'Campus Host');
assert.strictEqual(next.status.text, 'Next cue'); // the name is the label; not repeated
assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
assert.strictEqual(playhead.id, 'playhead');
@@ -154,7 +155,7 @@ function collect(route, req) {
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
});
test('a cue fired at the host moves the countdown on the next poll', async () => {
await test('a cue fired at the host moves the countdown on the next poll', async () => {
world.position = '00:12:40.000'; // jumped past Campus Host
tickFn();
await settle();
@@ -162,10 +163,10 @@ function collect(route, req) {
assert.strictEqual(state.next.name, 'Message');
assert.strictEqual(state.prev.name, 'Campus Host');
const snap = JSON.parse(collect(routes['GET /timers']).body);
assert.strictEqual(snap.timers[0].label, 'Message');
assert.strictEqual(snap.timers[0].label, 'Message'); // the card's name follows the cue
});
test('a decoder with no event reports nothing playing rather than stale cues', async () => {
await test('a decoder with no event reports nothing playing rather than stale cues', async () => {
world.eventId = '';
tickFn();
await settle();
@@ -174,13 +175,13 @@ function collect(route, req) {
assert.strictEqual(handle.state.event, '');
});
test('health speaks about the upstream, not about itself', () => {
await test('health speaks about the upstream, not about itself', () => {
const h = handle.health();
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
assert.ok(h.message.length > 0, 'health should say something useful');
});
test('an unconfigured module idles instead of hammering Resi', () => {
await test('an unconfigured module idles instead of hammering Resi', () => {
const before = world.logins;
const idle = server.init({ config: {}, log: () => {} });
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
@@ -189,12 +190,90 @@ function collect(route, req) {
idle.stop();
});
test('stop() leaves nothing running', () => {
await test('stop() leaves nothing running', () => {
handle.stop();
const res = collect(routes['GET /state']);
assert.strictEqual(JSON.parse(res.body).status, 'error');
});
// Seen live at North Attleboro: a failed login was retried on every 1s poll, so
// Resi answered 429 and kept answering it, and the decoder picker stayed empty.
await test('a failed login holds off instead of retrying every poll', async () => {
const { createResiClient } = require('./resi');
let t = 1700000000000;
let logins = 0;
let limited = true;
const client = createResiClient({
username: 'a@b.c',
password: 'secret',
now: () => t,
fetch: async (url) => {
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
if (path === '/api/v3/login') {
logins++;
if (limited) return reply({}, { ok: false, status: 429 });
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
}
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1' }]);
return reply({}, { ok: false, status: 404 });
},
});
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
assert.match(await poll(), /rate-limiting/);
for (let i = 0; i < 60; i++) { t += 1000; await poll(); } // a minute of 1s polls
assert.ok(logins <= 3, `signed in ${logins} times in a minute while rate-limited`);
assert.match(await poll(), /retrying in \d+s/, 'the wait is shown, not hidden');
limited = false;
t += 5 * 60000; // past the longest hold-off
assert.strictEqual(await poll(), 'ok', 'recovers once Resi lets it in');
const after = logins;
for (let i = 0; i < 30; i++) { t += 1000; await poll(); }
assert.strictEqual(logins, after, 'a good session is not signed in again');
});
await test('a rejected password says so', async () => {
const { createResiClient } = require('./resi');
const client = createResiClient({
username: 'a@b.c', password: 'wrong', now: () => 0,
fetch: async () => reply({}, { ok: false, status: 400 }),
});
await assert.rejects(client.venues(), /check the password/);
});
// Seen live: media@waters.church signs in fine but may not list venues (403). The
// client answered every 403 by signing in again -- a fresh login on every poll.
await test('a login that may not see venues is held off, not signed in every poll', async () => {
const { createResiClient } = require('./resi');
let t = 1700000000000;
let logins = 0;
let venueCalls = 0;
const client = createResiClient({
username: 'a@b.c', password: 'secret', now: () => t,
fetch: async (url) => {
const path = String(url).replace('https://central.resi.io', '').split('?')[0];
if (path === '/api/v3/login') {
logins++;
return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=T; Path=/'] } });
}
if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST' });
if (path === '/api/v3/customers/CUST/venues') { venueCalls++; return reply({}, { ok: false, status: 403 }); }
return reply({}, { ok: false, status: 404 });
},
});
const poll = async () => { try { await client.venues(); return 'ok'; } catch (e) { return e.message; } };
assert.match(await poll(), /isn't allowed to see its venues \(403\)/);
for (let i = 0; i < 120; i++) { t += 1000; await poll(); } // two minutes of 1s polls
// At most one renewal once the session is over a minute old (in case it really
// had expired), then the hold-off keeps growing -- not one sign-in per poll.
assert.ok(logins <= 2, `signed in ${logins} times in two minutes for a permission problem`);
assert.ok(venueCalls <= 8, `asked for venues ${venueCalls} times in two minutes (a poll each would be 121)`);
assert.match(await poll(), /ask a Resi admin/);
});
globalThis.setInterval = realSetInterval;
globalThis.clearInterval = realClearInterval;
console.log(`\n${passed} tests passed`);
+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);
});
+177
View File
@@ -0,0 +1,177 @@
#!/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);
});