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>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "resi-cues",
|
"id": "resi-cues",
|
||||||
"name": "Resi Cues",
|
"name": "Resi Cues",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Time until the next Resi cue point, on the board and as a timer",
|
"description": "Time until the next Resi cue point, on the board and as a timer",
|
||||||
"provides": ["timers"],
|
"provides": ["timers"],
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ function createPlayhead(options) {
|
|||||||
let offsets = []; // { at, off } — off is mediaMs - wall time it was read
|
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 spreads = []; // how far apart honest readings sit: this link's noise
|
||||||
let gaps = []; // how long between the playhead actually moving
|
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 base; // the trusted offset
|
||||||
let lastMedia;
|
let lastMedia;
|
||||||
let lastNewAt;
|
let lastNewAt;
|
||||||
@@ -90,15 +92,25 @@ function createPlayhead(options) {
|
|||||||
spreads = spreads.concat(-delta).slice(-20);
|
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;
|
const gap = t - lastNewAt;
|
||||||
// The gap either side of a pause is not Resi's cadence — recording it
|
// 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.
|
// 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;
|
const worst = gaps.length ? Math.max.apply(null, gaps) : 0;
|
||||||
if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) {
|
if (gap > 0 && gap < 60000) {
|
||||||
gaps = gaps.concat(gap).slice(-8);
|
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 });
|
offsets = offsets.concat({ at: t, off });
|
||||||
lastMedia = mediaMs;
|
lastMedia = mediaMs;
|
||||||
lastNewAt = t;
|
lastNewAt = t;
|
||||||
@@ -118,7 +130,9 @@ function createPlayhead(options) {
|
|||||||
// Only call it paused once the playhead has been still for clearly longer than
|
// 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
|
// 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.
|
// 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;
|
const playing = base !== undefined && t - lastNewAt < pauseAfter;
|
||||||
if (!playing) {
|
if (!playing) {
|
||||||
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
|
return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs };
|
||||||
|
|||||||
@@ -197,6 +197,32 @@ test('surroundingCues puts a cue you are exactly on behind you', () => {
|
|||||||
assert.strictEqual(surroundingCues([], 1000).next, null);
|
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 ────────────────────────────────────────────────────────── */
|
/* ── the matrix ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const argv = process.argv.slice(2).map(Number);
|
const argv = process.argv.slice(2).map(Number);
|
||||||
|
|||||||
@@ -146,7 +146,9 @@ module.exports = {
|
|||||||
elapsedMs: null,
|
elapsedMs: null,
|
||||||
remainingMs,
|
remainingMs,
|
||||||
targetMs: Math.max(1000, state.next.ms - from),
|
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 || '',
|
detail: state.event || '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@
|
|||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
|
|
||||||
let passed = 0;
|
let passed = 0;
|
||||||
function test(name, fn) {
|
// Awaited, so an async test finishes before the next one changes the world under it.
|
||||||
fn();
|
async function test(name, fn) {
|
||||||
|
await fn();
|
||||||
passed++;
|
passed++;
|
||||||
console.log(` ok ${name}`);
|
console.log(` ok ${name}`);
|
||||||
}
|
}
|
||||||
@@ -101,7 +102,7 @@ function collect(route, req) {
|
|||||||
|
|
||||||
const routes = server.routes({ config: {}, log: (m) => logged.push(m) });
|
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');
|
assert.strictEqual(world.logins, 1, 'should sign in exactly once');
|
||||||
const state = handle.state;
|
const state = handle.state;
|
||||||
assert.strictEqual(state.status, 'ok', state.message);
|
assert.strictEqual(state.status, 'ok', state.message);
|
||||||
@@ -110,20 +111,20 @@ function collect(route, req) {
|
|||||||
assert.strictEqual(state.cues.length, 3);
|
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;
|
const state = handle.state;
|
||||||
assert.strictEqual(state.next.name, 'Campus Host');
|
assert.strictEqual(state.next.name, 'Campus Host');
|
||||||
assert.strictEqual(state.prev.name, 'Welcome');
|
assert.strictEqual(state.prev.name, 'Welcome');
|
||||||
assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`);
|
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']);
|
const res = collect(routes['GET /state']);
|
||||||
assert.strictEqual(res.code, 200);
|
assert.strictEqual(res.code, 200);
|
||||||
assert.deepStrictEqual(JSON.parse(res.body), handle.state);
|
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']);
|
const res = collect(routes['GET /stream']);
|
||||||
assert.strictEqual(res.code, 200);
|
assert.strictEqual(res.code, 200);
|
||||||
assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8');
|
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');
|
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);
|
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
||||||
assert.strictEqual(snap.source, 'resi-cues');
|
assert.strictEqual(snap.source, 'resi-cues');
|
||||||
assert.strictEqual(snap.label, 'Resi');
|
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}`);
|
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
|
// the countdown runs from the cue before it, so a consumer can colour progress
|
||||||
assert.strictEqual(next.targetMs, 450000);
|
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.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past');
|
||||||
|
|
||||||
assert.strictEqual(playhead.id, 'playhead');
|
assert.strictEqual(playhead.id, 'playhead');
|
||||||
@@ -154,7 +155,7 @@ function collect(route, req) {
|
|||||||
assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000);
|
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
|
world.position = '00:12:40.000'; // jumped past Campus Host
|
||||||
tickFn();
|
tickFn();
|
||||||
await settle();
|
await settle();
|
||||||
@@ -162,10 +163,10 @@ function collect(route, req) {
|
|||||||
assert.strictEqual(state.next.name, 'Message');
|
assert.strictEqual(state.next.name, 'Message');
|
||||||
assert.strictEqual(state.prev.name, 'Campus Host');
|
assert.strictEqual(state.prev.name, 'Campus Host');
|
||||||
const snap = JSON.parse(collect(routes['GET /timers']).body);
|
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 = '';
|
world.eventId = '';
|
||||||
tickFn();
|
tickFn();
|
||||||
await settle();
|
await settle();
|
||||||
@@ -174,13 +175,13 @@ function collect(route, req) {
|
|||||||
assert.strictEqual(handle.state.event, '');
|
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();
|
const h = handle.health();
|
||||||
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
|
assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status);
|
||||||
assert.ok(h.message.length > 0, 'health should say something useful');
|
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 before = world.logins;
|
||||||
const idle = server.init({ config: {}, log: () => {} });
|
const idle = server.init({ config: {}, log: () => {} });
|
||||||
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
|
assert.strictEqual(world.logins, before, 'must not sign in with no credentials');
|
||||||
@@ -189,7 +190,7 @@ function collect(route, req) {
|
|||||||
idle.stop();
|
idle.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('stop() leaves nothing running', () => {
|
await test('stop() leaves nothing running', () => {
|
||||||
handle.stop();
|
handle.stop();
|
||||||
const res = collect(routes['GET /state']);
|
const res = collect(routes['GET /state']);
|
||||||
assert.strictEqual(JSON.parse(res.body).status, 'error');
|
assert.strictEqual(JSON.parse(res.body).status, 'error');
|
||||||
|
|||||||
Reference in New Issue
Block a user