From 0f0adb4c60d34b72ad089f7d1a374c2c0b3f0c36 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 6 Oct 2026 14:59:12 -0400 Subject: [PATCH] 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 --- modules/resi-cues/module.json | 2 +- modules/resi-cues/resi.js | 22 ++++++++++++++++++---- modules/resi-cues/resi.test.js | 26 ++++++++++++++++++++++++++ modules/resi-cues/server.js | 4 +++- modules/resi-cues/server.test.js | 29 +++++++++++++++-------------- 5 files changed, 63 insertions(+), 20 deletions(-) diff --git a/modules/resi-cues/module.json b/modules/resi-cues/module.json index c97dab3..32d8348 100644 --- a/modules/resi-cues/module.json +++ b/modules/resi-cues/module.json @@ -1,7 +1,7 @@ { "id": "resi-cues", "name": "Resi Cues", - "version": "1.0.0", + "version": "1.0.1", "proddash": ">=1.4.0", "description": "Time until the next Resi cue point, on the board and as a timer", "provides": ["timers"], diff --git a/modules/resi-cues/resi.js b/modules/resi-cues/resi.js index 54de680..d47e7be 100644 --- a/modules/resi-cues/resi.js +++ b/modules/resi-cues/resi.js @@ -61,6 +61,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 +92,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)) { - gaps = gaps.concat(gap).slice(-8); + 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 +130,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 }; diff --git a/modules/resi-cues/resi.test.js b/modules/resi-cues/resi.test.js index dd11352..6022e5b 100644 --- a/modules/resi-cues/resi.test.js +++ b/modules/resi-cues/resi.test.js @@ -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); diff --git a/modules/resi-cues/server.js b/modules/resi-cues/server.js index 0fad3ef..3fe385b 100644 --- a/modules/resi-cues/server.js +++ b/modules/resi-cues/server.js @@ -146,7 +146,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 || '', }); } diff --git a/modules/resi-cues/server.test.js b/modules/resi-cues/server.test.js index 40a2100..ddb43a3 100644 --- a/modules/resi-cues/server.test.js +++ b/modules/resi-cues/server.test.js @@ -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,7 +190,7 @@ 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');