Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Opus 5 9313c9e42a SPL Meter 0.4.2: the device picker can build the tool it needs
NA could not list a single audio interface. The module was installed from
the catalog, which ships source only because the capture binary is
gitignored, and the picker shells out to that binary with --list. So it
returned nothing, forever:

  [spl-meter] device list failed: spawn .../spl-capture ENOENT

The missing-binary build existed, but only inside the listener, and the
listener only starts once a device has been picked. No tool meant no device
list, and no device meant no listener to build the tool. Nothing was going
to break that on its own.

The build now lives at module scope and both callers use it: init() asks for
it on mount whether or not a device is configured, and /devices asks again
each time it is called, so opening the admin page is enough to start it. It
is still a plain spawn that never touches the event loop, and health() now
names the situation instead of leaving an empty dropdown unexplained.

Also removed two per-listener build flags left shadowing the module-scope
ones, which would have made the "is a build already running" test always
false and let a second build start under the first.

Tested the way it actually failed: a data directory holding only the six
source files, no binary. The build starts on mount and /devices answers with
the real device list moments later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:25:43 -04:00
michael 0e618ea5d3 Merge pull request 'SPL Meter 0.4.1: name a dead input, and stop the spectrogram smearing its labels' (#16) from claude/spl-silent-input into main 2026-09-23 10:03:23 -04:00
Waters ChurchandClaude Opus 5 c8b51c6c2f Spectrogram: stop dragging the labels along with the picture
The spectrogram scrolled by copying the visible canvas onto itself one step
along. That canvas already held the frequency labels and gridlines, because
they were drawn straight onto it, so every frame smeared them sideways and
left a trail. Arrived with the labels in 0.3.0; the gridlines did it too.

Only measured pixels belong on a surface that scrolls. The history now lives
on its own offscreen canvas that holds nothing but spectrum, and the visible
canvas is repainted from scratch each frame — history first, annotations
over the top. Nothing drawn as an annotation can reach the scroll buffer by
construction, rather than by remembering to clear the right strip.

The layout was muddled in the same place and is fixed with it: the gutter
used to be reserved along the bottom while the labels were drawn down the
left, overlapping the plot. Labels now sit beside the axis they describe —
down the left when frequency runs vertically, with room for "12.5k", and
along the bottom when it runs horizontally.

Verified both orientations by fingerprinting the gutter and the plot two and
a half seconds apart: gutter identical, plot changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 09:58:32 -04:00
Waters ChurchandClaude Opus 5 c464e5b4ca SPL Meter 0.4.0: a dead input must not look like a quiet room
AB's meter appeared to halt for a day and a half. Nothing had halted: the
capture child had run continuously since the machine was restarted, never
stalled, never exited, and was delivering twenty frames a second. Every one
of them was digital silence, because Dante Via stopped passing audio after
the reboot — the Timers module's LTC listener, on channel 16 of the same
device, had been reporting no signal for exactly as long.

The module's part in that was reporting "ok" through all of it and drawing
a steady -83, which is the -200 floor plus the +117 calibration offset. A
plausible number on a wall is believed, and a green dot beside it says the
number is worth believing. That is worse than showing nothing.

So silence is now a state the module names. Below -150 dBFS unweighted the
input is not quiet, it is absent: a microphone in a silent room still reads
about -83, and even a muted converter dithers. Five seconds of that on every
channel and health() goes to error naming the device and how long it has
been dead, the tiles stop drawing a level and say "No audio" with how long,
and the status dot goes red.

Verified both directions, which is the half that matters: BlackHole with
nothing routed into it is caught in five seconds, and a real microphone in a
quiet room at -49 dBFS is never flagged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 09:54:27 -04:00
michael f4d50f5e95 Merge pull request 'PCO Plan 1.6.0: the message document is matched by the plan's own name' (#15) from claude/pco-message-match into main 2026-09-22 16:56:49 -04:00
Waters ChurchandClaude Fable 5.1 c73d0a3aac PCO Plan 1.6.0: the message document is matched by the plan's own name
The current item followed the live ProPresenter document by title alone,
and a sermon's document is named after the sermon — Waters names them
"YYMMDD Series Title - Part #" — never after its plan item. So the last
song stayed current through the whole message: North Attleboro on 20 Sept
spent 65 minutes on one song in two of its three services.

A live document that reads like the plan itself — its date code, series
title and part — now lands on the plan's message item: an item titled
Message, Sermon or Teaching, else the first item under such a header. A
date code naming another day is a firm no, so last week's sermon stays
unplaced. Position in the ProPresenter playlist breaks a tie between
equal names, only while the playlist mirrors the plan item for item, and
never places a document on its own.

The log now says what it never said: every feed drop and reconnect, which
item the message document will land on, what placed each item and how,
and a live document that is not in the plan.

matching.test.js: 27 checks built from both campuses' real 27 Sept plans
and ProPresenter playlists.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 13:06:53 -04:00
michael 441cfe3882 Merge pull request 'Grid density: cut the board finer or coarser, per browser' (#14) from claude/grid-size into main 2026-09-22 11:45:51 -04:00
10 changed files with 804 additions and 142 deletions
+8 -1
View File
@@ -328,7 +328,14 @@ below. Nothing is installed without someone clicking.
description and item notes. When the ProPresenter module is running it description and item notes. When the ProPresenter module is running it
follows that module's live item, highlights the matching plan item, dims follows that module's live item, highlights the matching plan item, dims
what's done and shows the elapsed vs planned runtime and how far what's done and shows the elapsed vs planned runtime and how far
ahead/behind the service is. Admin config is the connection: Planning ahead/behind the service is. Items are matched by name — a document named
like the Planning Center item or its song, tags and all. The message is
the exception: a sermon's document is named after the sermon, so a live
document that reads like the plan itself — its date code, series title
and part, as in `260927 Where is Your Faith? - Part 6` — is taken as the
message and lands on the plan's message item (an item titled Message,
Sermon or Teaching, else the first item under such a header). Admin
config is the connection: Planning
Center credentials (Personal Access Token app ID + secret, or an OAuth Center credentials (Personal Access Token app ID + secret, or an OAuth
token), the service type (a list grouped by your Planning Center folders) token), the service type (a list grouped by your Planning Center folders)
and an optional pinned plan. Everything about the display is per tile and an optional pinned plan. Everything about the display is per tile
+177 -3
View File
@@ -2,10 +2,12 @@
/** /**
* Title matching for the PCO Plan module — the one answer to "is this * Title matching for the PCO Plan module — the one answer to "is this
* ProPresenter document that Planning Center thing?", used twice: * ProPresenter document that Planning Center thing?", used three ways:
* *
* - mapping the document ProPresenter has live to a plan item (the * - mapping the document ProPresenter has live to a plan item (the
* highlight and the item runtimes), and * highlight and the item runtimes) — placeDocument(), below,
* - recognising the message document, which is named after the sermon
* and never after its plan item — the message rule, below, and
* - recognising the documents an admin picked as the service start / end * - recognising the documents an admin picked as the service start / end
* cues (see service-timing.js). * cues (see service-timing.js).
* *
@@ -82,4 +84,176 @@ function isCue(liveName, cueDocument) {
return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD; return Boolean(cueDocument) && similarity(liveName, cueDocument) >= TRIGGER_THRESHOLD;
} }
module.exports = { MATCH_THRESHOLD, TRIGGER_THRESHOLD, cleanTitle, normalize, similarity, isCue }; /* ── the message rule ──────────────────────────────────────────────────
A sermon's ProPresenter document is named after the sermon, never after
the plan item that stands for it ("Message", "Resi Message"), so no name
comparison can place it — and the previous song stayed "current" through
a whole message. What the document's name does carry is the plan's own
identity. Waters names them "YYMMDD Series Title - Part #": the plan's
date, its series title and its title. A live document that reads like
the plan is the plan's message, and lands on the plan's message item,
however that item happens to be titled. */
const MESSAGE_WORDS = /\b(message|sermon|teaching|homily)\b/i;
const MESSAGE_WORD_ONLY = /^\s*(message|sermon|teaching|homily)\s*$/i;
/** Undated, the name must read like "Series Title Part #" at least this closely… */
const MESSAGE_THRESHOLD = 0.75;
/** …dated with this plan's date, it need only resemble the series or the title. */
const DATED_MESSAGE_THRESHOLD = 0.6;
/** The leading "YYMMDD" / "YYYYMMDD" of a document name as a calendar day, or null. */
function leadingDateCode(raw) {
const m = /^\s*(\d{8}|\d{6})(?!\d)/.exec(String(raw || ''));
if (!m) return null;
const code = m[1];
const y = code.length === 8 ? Number(code.slice(0, 4)) : 2000 + Number(code.slice(0, 2));
const mo = Number(code.slice(-4, -2));
const d = Number(code.slice(-2));
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
return { y, m: mo, d };
}
/** The local calendar day of an epoch-ms instant, in the same shape. */
function dayOf(ms) {
const t = new Date(Number(ms));
if (!Number.isFinite(t.getTime())) return null;
return { y: t.getFullYear(), m: t.getMonth() + 1, d: t.getDate() };
}
function sameDay(a, b) {
return Boolean(a && b) && a.y === b.y && a.m === b.m && a.d === b.d;
}
/**
* Does this live document read like the plan itself — "260927 Where is Your
* Faith? - Part 6" for the plan "Where is Your Faith?" / "Part 6" dated
* 27 Sept 2026? Returns { score, dated } or null.
*
* A date code naming another day is a firm no: last week's sermon is not
* this plan's message. A date code naming the plan's day is strong evidence,
* so the rest need only resemble the series or the title. Without a date
* the name must read like series + title together — and carry the title
* when the plan has both, so a series bumper ("Where is Your Faith?") is
* not taken for the sermon.
*/
function messageDocumentScore(name, plan) {
const series = String(plan?.seriesTitle || '').trim();
const title = String(plan?.title || '').trim();
if (!series && !title) return null;
const code = leadingDateCode(name);
const planDay = plan?.sortDate ? dayOf(plan.sortDate) : null;
if (code && planDay && !sameDay(code, planDay)) return null;
const dated = Boolean(code && planDay);
const full = [series, title].filter(Boolean).join(' ');
const fullScore = similarity(name, full);
if (dated) {
const score = Math.max(fullScore, series ? similarity(name, series) : 0, title ? similarity(name, title) : 0);
return score >= DATED_MESSAGE_THRESHOLD ? { score, dated: true } : null;
}
if (fullScore < MESSAGE_THRESHOLD) return null;
if (series && title && similarity(name, title) < DATED_MESSAGE_THRESHOLD) return null;
return { score: fullScore, dated: false };
}
/**
* The plan item that stands for the message: an item titled with the word
* ("Message", "Resi Message" — a bare "Message" first), else the first item
* under a header so titled (a "Sermon" section whose one item is the sermon's
* own title). Null when the plan has no such slot.
*/
function messageItemOf(items) {
let best = null;
let inSection = false;
for (const it of Array.isArray(items) ? items : []) {
if (!it) continue;
const title = String(it.title || '');
if (it.itemType === 'header') {
inSection = MESSAGE_WORDS.test(title);
continue;
}
const rank = MESSAGE_WORD_ONLY.test(title) ? 3 : MESSAGE_WORDS.test(title) ? 2 : inSection ? 1 : 0;
if (rank && (!best || rank > best.rank)) best = { item: it, rank };
if (rank === 3) break;
}
return best ? best.item : null;
}
/**
* Does ProPresenter's playlist mirror the plan item for item? ProPresenter
* builds a Planning Center playlist that way — every plan item, headers
* included, each flagged as PCO — until an operator adds something of their
* own (a song tag, say), which shifts every position after it. Same length,
* headers in the same places and every item PCO-flagged is the test.
*/
function playlistMirrorsPlan(ppItems, items) {
if (!Array.isArray(ppItems) || !Array.isArray(items) || !items.length || ppItems.length !== items.length) return false;
return ppItems.every((p, i) => Boolean(p?.isPco) && (p?.type === 'header') === (items[i]?.itemType === 'header'));
}
/** Best plan item by title, the ones ahead of the current item winning ties. */
function bestByName(name, items, all, ctx) {
const currentIdx = items.findIndex((it) => it.id === ctx?.currentItemId);
const hint = ctx?.hint;
const wanted = hint && hint.mirror && Number.isInteger(hint.index) && hint.index >= 0 ? all[hint.index] : null;
let best = null;
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0);
if (score < MATCH_THRESHOLD) continue;
// Distance ahead of the current item (wrapping so items behind rank last).
const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length;
const atPosition = Boolean(wanted && item.id === wanted.id);
const candidate = { item, score, dist, atPosition };
if (!best || score > best.score + 0.001) {
best = candidate;
continue;
}
if (Math.abs(score - best.score) > 0.001) continue;
if ((atPosition && !best.atPosition) || (atPosition === best.atPosition && dist < best.dist)) best = candidate;
}
return best ? { item: best.item, score: best.score, via: 'name' } : null;
}
/**
* Where a live ProPresenter document sits in the plan: { item, score, via }
* or null. `ctx` is { items, plan, currentItemId, hint }; `hint` (optional)
* is { index, mirror } — the document's position in the ProPresenter
* playlist and whether that playlist mirrors the plan (playlistMirrorsPlan).
*
* By name first: the item (or song) title most like the document's name,
* the ones ahead of the current item winning ties — a near miss lands on the
* right row. Then the message rule: a document that reads like the plan
* itself lands on the plan's message item. It overrides a name hit that is
* anything short of identical — a song called "Faith" must not catch
* "260927 Where is Your Faith? - Part 6" — outright when the document is
* dated with the plan's day, and when it reads better otherwise. Position
* is never a match on its own: it only breaks a tie between equally good
* names, and only while the playlist mirrors the plan.
*/
function placeDocument(name, ctx) {
const all = Array.isArray(ctx?.items) ? ctx.items : [];
const items = all.filter((it) => it && it.itemType !== 'header');
if (!items.length) return null;
const byName = bestByName(name, items, all, ctx);
const asMessage = messageDocumentScore(name, ctx?.plan);
const messageItem = asMessage ? messageItemOf(all) : null;
if (messageItem && (!byName || (byName.score < 0.999 && (asMessage.dated || asMessage.score > byName.score)))) {
return { item: messageItem, score: asMessage.score, via: asMessage.dated ? 'message document, dated' : 'message document' };
}
return byName;
}
module.exports = {
MATCH_THRESHOLD,
TRIGGER_THRESHOLD,
MESSAGE_THRESHOLD,
cleanTitle,
normalize,
similarity,
isCue,
leadingDateCode,
messageDocumentScore,
messageItemOf,
playlistMirrorsPlan,
placeDocument,
};
+280
View File
@@ -0,0 +1,280 @@
'use strict';
/**
* Unit tests for the PCO Plan module's document matching — placing what
* ProPresenter has live onto a plan item, and the message rule.
*
* node modules/pco-plan/matching.test.js
*
* Plain Node, no dependencies; exits non-zero on the first failure. The two
* plans are the campuses' real 27 Sept 2026 plans and ProPresenter playlists
* as they stood on 22 Sept, so the expectations read like a Sunday would.
*/
const assert = require('assert');
const {
MESSAGE_THRESHOLD,
leadingDateCode,
messageDocumentScore,
messageItemOf,
playlistMirrorsPlan,
placeDocument,
} = require('./matching');
let passed = 0;
function test(name, fn) {
try {
fn();
passed += 1;
console.log(` ok ${name}`);
} catch (err) {
console.log(` FAIL ${name}\n ${err.message}`);
process.exitCode = 1;
throw err;
}
}
const SUNDAY = new Date(2026, 8, 27, 9, 30).getTime(); // 27 Sept 2026, 9:30 local — the plan's sort date
const PLAN = { id: '91274523', title: 'Part 6', seriesTitle: 'Where is Your Faith?', sortDate: SUNDAY };
const header = (id, title) => ({ id, itemType: 'header', title, song: null });
const item = (id, title, itemType = 'item', song = null) => ({ id, itemType, title, song: song ? { title: song } : null });
const pp = (name, type = 'presentation', isPco = true) => ({ name, type, isPco, isHidden: false });
// North Attleboro — the message is an item called "Message", linked in
// ProPresenter to the bumper video; the sermon slides are their own document.
const NA = [
header('h0', 'Doors Open'),
item('1', 'Preservice'),
item('2', '5 Minute Countdown', 'media'),
item('3', 'MC Announcements', 'media'),
header('h4', 'Worship'),
item('5', 'Call To Worship', 'media'),
item('6', 'Alleluia', 'song', 'Alleluia'),
item('7', 'What a God', 'song', 'What a God'),
item('8', 'Baptisms', 'media'),
item('9', 'Promised Land', 'song', 'Promised Land'),
item('10', 'Be Praised', 'song', 'Be Praised'),
header('h11', 'Message'),
item('12', 'Message'),
item('13', 'Standing Salvation Moment', 'media'),
header('h14', 'Closing'),
item('15', 'Postservice', 'song', '-Postservice'),
];
const NA_PP = [
pp('Doors Open', 'header'), pp('Preservice'), pp('5 Minute Countdown'), pp('MC Announcements'),
pp('Worship', 'header'), pp('Call To Worship'), pp('Alleluia - [ Full ]'), pp('What a God - [ Full ]'),
pp('Baptisms'), pp('Promised Land - [ Full ]'), pp('Be Praised - [ Full ]'),
pp('Message', 'header'), pp('Message Bumper'), pp('Standing Salvation Moment'),
pp('Closing', 'header'), pp('Postservice'),
];
// Apollo Beach — the message is the media item "Resi Message".
const AB = [
item('0', 'Pre Service Meeting', 'media'),
header('h1', 'Doors Open'),
item('2', 'Pre Service', 'media'),
item('3', '5 Minute Countdown', 'media'),
item('4', 'MC Announcements', 'media'),
header('h5', 'Worship'),
item('6', 'Call To Worship', 'media'),
item('7', 'Alleluia', 'song', 'Alleluia'),
item('8', 'What a God', 'song', 'What a God'),
item('9', 'The Blood', 'song', 'The Blood'),
item('10', 'Be Praised (feat. Kymberli Joye)', 'song', 'Be Praised'),
header('h11', 'Message'),
item('12', 'Resi Message', 'media'),
item('13', 'Standing Salvation Moment', 'media'),
header('h14', 'Closing'),
item('15', 'Post Service', 'media'),
];
const SERMON = '260927 Where is Your Faith? - Part 6';
const place = (name, items, extra = {}) => placeDocument(name, { items, plan: PLAN, currentItemId: '', ...extra });
const titleOf = (hit) => (hit ? hit.item.title : null);
console.log('date codes');
test('YYMMDD and YYYYMMDD lead a document name; anything else is not a date', () => {
assert.deepStrictEqual(leadingDateCode(SERMON), { y: 2026, m: 9, d: 27 });
assert.deepStrictEqual(leadingDateCode('20260927 Where is Your Faith? - Part 6'), { y: 2026, m: 9, d: 27 });
assert.strictEqual(leadingDateCode('9-27-26 Where is Your Faith?'), null);
assert.strictEqual(leadingDateCode('1234567 seven digits'), null);
assert.strictEqual(leadingDateCode('261340 no such month'), null);
assert.strictEqual(leadingDateCode('Alleluia'), null);
assert.strictEqual(leadingDateCode(''), null);
});
console.log('the message rule');
test('a document named like the plan, dated with its day, is the message', () => {
const hit = messageDocumentScore(SERMON, PLAN);
assert.ok(hit, 'recognised');
assert.strictEqual(hit.dated, true);
assert.strictEqual(hit.score, 1);
});
test("last week's document is not this plan's message", () => {
assert.strictEqual(messageDocumentScore('260920 Where is Your Faith? - Part 5', PLAN), null);
assert.strictEqual(messageDocumentScore('260920 Where is Your Faith? - Part 6', PLAN), null, 'even with the right part: the date names another day');
});
test('undated, series and part together still read as the message', () => {
const plain = messageDocumentScore('Where is Your Faith? - Part 6', PLAN);
assert.ok(plain && plain.dated === false && plain.score === 1);
const tagged = messageDocumentScore('Where Is Your Faith - Part 6 - [ Notes ]', PLAN);
assert.ok(tagged && tagged.score >= MESSAGE_THRESHOLD, 'tags and punctuation do not matter');
});
test('a series bumper is not the sermon', () => {
assert.strictEqual(messageDocumentScore('Where is Your Faith?', PLAN), null, 'the series alone, undated');
assert.strictEqual(messageDocumentScore('Where is Your Faith? Bumper', PLAN), null);
});
test("dated with the plan's day, the part alone is enough", () => {
const hit = messageDocumentScore('260927 Part 6', PLAN);
assert.ok(hit && hit.dated);
});
test('a dated document about something else is not the message', () => {
assert.strictEqual(messageDocumentScore('260927 Announcements', PLAN), null);
assert.strictEqual(messageDocumentScore('260927 Baptism Video', PLAN), null);
});
test('songs are not the message', () => {
assert.strictEqual(messageDocumentScore('What a God - [ Full ]', PLAN), null);
assert.strictEqual(messageDocumentScore('Alleluia', PLAN), null);
assert.strictEqual(messageDocumentScore('Message Bumper', PLAN), null);
});
test('a plan with neither series nor title has no message document', () => {
assert.strictEqual(messageDocumentScore(SERMON, { sortDate: SUNDAY }), null);
assert.strictEqual(messageDocumentScore(SERMON, null), null);
});
test('a plan without a date still recognises its own name', () => {
const hit = messageDocumentScore(SERMON, { title: 'Part 6', seriesTitle: 'Where is Your Faith?' });
assert.ok(hit && hit.dated === false && hit.score === 1);
});
console.log('the message item');
test('an item called Message is the message item; so is Resi Message', () => {
assert.strictEqual(messageItemOf(NA).id, '12');
assert.strictEqual(messageItemOf(AB).id, '12');
});
test('a bare "Message" beats an item that only contains the word', () => {
const withBumper = [...NA.slice(0, 12), item('12a', 'Message Bumper', 'media'), ...NA.slice(12)];
assert.strictEqual(messageItemOf(withBumper).id, '12');
assert.strictEqual(messageItemOf([item('a', 'Message Bumper', 'media')]).id, 'a', 'alone, it is the slot');
});
test('under a Sermon header, the first item is the message whatever it is called', () => {
const plan = [header('h', 'Worship'), item('1', 'Alleluia', 'song', 'Alleluia'), header('s', 'Sermon'), item('2', 'Living Hope'), item('3', 'Response')];
assert.strictEqual(messageItemOf(plan).id, '2');
});
test('a plan without a message slot has none', () => {
assert.strictEqual(messageItemOf([header('h', 'Worship'), item('1', 'Alleluia', 'song', 'Alleluia')]), null);
assert.strictEqual(messageItemOf([header('m', 'Message')]), null, 'a header with nothing under it');
assert.strictEqual(messageItemOf([]), null);
assert.strictEqual(messageItemOf(null), null);
});
console.log('the playlist mirror');
test("ProPresenter's Planning Center playlist mirrors the plan, headers and all", () => {
assert.strictEqual(playlistMirrorsPlan(NA_PP, NA), true);
});
test('an item the operator added, a header out of place or a non-PCO item breaks the mirror', () => {
const extra = [...NA_PP.slice(0, 10), pp('The Blood TAG'), ...NA_PP.slice(10)];
assert.strictEqual(playlistMirrorsPlan(extra, NA), false, 'a song tag shifts everything after it');
const shifted = [...NA_PP];
[shifted[3], shifted[4]] = [shifted[4], shifted[3]];
assert.strictEqual(playlistMirrorsPlan(shifted, NA), false);
const local = NA_PP.map((x, i) => (i === 8 ? pp('Baptisms', 'presentation', false) : x));
assert.strictEqual(playlistMirrorsPlan(local, NA), false);
assert.strictEqual(playlistMirrorsPlan([], []), false);
assert.strictEqual(playlistMirrorsPlan(null, NA), false);
});
console.log('placing what is live');
test('songs and media land by name, tags and all', () => {
const god = place('What a God - [ Full ]', NA);
assert.strictEqual(titleOf(god), 'What a God');
assert.strictEqual(god.via, 'name');
assert.strictEqual(titleOf(place('Resi Message - [ MEDIA ]', AB)), 'Resi Message');
assert.strictEqual(titleOf(place('Greatly To Be Praised - [ Full ]', AB)), 'Be Praised (feat. Kymberli Joye)', 'by the song title');
assert.strictEqual(titleOf(place('Pre Service - [ Sunday ]', AB)), 'Pre Service');
});
test('the sermon document lands on the message item at both campuses', () => {
const na = place(SERMON, NA);
assert.strictEqual(titleOf(na), 'Message');
assert.strictEqual(na.via, 'message document, dated');
assert.strictEqual(titleOf(place(SERMON, AB)), 'Resi Message');
assert.strictEqual(place('Where is Your Faith? - Part 6', NA).via, 'message document', 'undated still lands, and says so');
});
test('the bumper lands on the message item too, by name', () => {
const hit = place('Message Bumper', NA);
assert.strictEqual(titleOf(hit), 'Message');
assert.strictEqual(hit.via, 'name');
});
test('a song called Faith does not catch the sermon', () => {
const trap = [...NA.slice(0, 11), item('f', 'Faith', 'song', 'Faith'), ...NA.slice(11)];
assert.strictEqual(titleOf(place('Faith - [ Full ]', trap)), 'Faith', 'the song itself still lands');
assert.strictEqual(titleOf(place(SERMON, trap)), 'Message', 'dated: the message wins outright');
assert.strictEqual(titleOf(place('Where is Your Faith? - Part 6', trap)), 'Message', 'undated: it reads better than the song');
});
test('an item titled exactly like the document wins over the message slot', () => {
const titled = [...NA.slice(0, 12), item('s', 'Where is Your Faith? - Part 6'), ...NA.slice(12)];
assert.strictEqual(place(SERMON, titled).item.id, 's');
});
test('a plan without a message slot leaves the sermon document unplaced', () => {
const noSlot = NA.filter((it) => it.id !== '12' && it.id !== 'h11');
assert.strictEqual(place(SERMON, noSlot), null);
});
test("an unplanned document, or last week's sermon, lands nowhere", () => {
assert.strictEqual(place('Offering Slide', NA), null);
assert.strictEqual(place('260920 Where is Your Faith? - Part 5', NA), null);
});
test('nothing to place on an empty plan', () => {
assert.strictEqual(place(SERMON, []), null);
assert.strictEqual(place(SERMON, [header('h', 'Message')]), null);
});
// The same song twice: a reprise after "What a God".
const REPRISE = [...NA.slice(0, 8), item('6b', 'Alleluia', 'song', 'Alleluia'), ...NA.slice(8)];
const REPRISE_PP = [...NA_PP.slice(0, 8), pp('Alleluia - [ Reprise ]'), ...NA_PP.slice(8)];
test('a tie between equal names goes to the one ahead of the current item', () => {
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE).item.id, '6', 'nothing current yet: the first');
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '7' }).item.id, '6b', 'after What a God: the reprise');
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '6b' }).item.id, '6b', 'the current one keeps a tie');
});
test('position breaks a tie only while the playlist mirrors the plan', () => {
const mirror = playlistMirrorsPlan(REPRISE_PP, REPRISE);
assert.strictEqual(mirror, true);
assert.strictEqual(place('Alleluia - [ Reprise ]', REPRISE, { hint: { index: 8, mirror } }).item.id, '6b');
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { hint: { index: 6, mirror } }).item.id, '6');
assert.strictEqual(place('Alleluia - [ Full ]', REPRISE, { currentItemId: '7', hint: { index: 6, mirror } }).item.id, '6', 'position wins the tie over "ahead"');
assert.strictEqual(place('Alleluia - [ Reprise ]', REPRISE, { hint: { index: 8, mirror: false } }).item.id, '6', 'no mirror: back to "ahead"');
});
test('position never places a document on its own', () => {
const mirror = playlistMirrorsPlan(NA_PP, NA);
assert.strictEqual(place('Offering Slide', NA, { hint: { index: 8, mirror } }), null);
assert.strictEqual(titleOf(place('What a God - [ Full ]', NA, { hint: { index: 12, mirror } })), 'What a God', 'a wrong position cannot move a clear name');
});
console.log(`\n${passed} tests passed`);
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "pco-plan", "id": "pco-plan",
"name": "PCO Plan", "name": "PCO Plan",
"version": "1.5.0", "version": "1.6.0",
"proddash": ">=1.4.0", "proddash": ">=1.4.0",
"description": "Order of service from Planning Center Services", "description": "Order of service from Planning Center Services",
"provides": [ "provides": [
+65 -30
View File
@@ -33,8 +33,12 @@
* Following ProPresenter: instead of talking to ProPresenter a second time, * Following ProPresenter: instead of talking to ProPresenter a second time,
* this module reads the propresenter-now-next module's own SSE feed over * this module reads the propresenter-now-next module's own SSE feed over
* loopback (/api/modules/propresenter-now-next/stream, on shell.port) and * loopback (/api/modules/propresenter-now-next/stream, on shell.port) and
* matches the live presentation name to a plan item. Item start/end * places the live document on a plan item (matching.js placeDocument): by
* timestamps are tracked here, once, so every tile agrees on the runtime. * name, or — for the sermon, whose document is named after the sermon and
* never after its plan item — by reading like the plan itself ("YYMMDD
* Series Title - Part #"), which lands it on the plan's message item. Item
* start/end timestamps are tracked here, once, so every tile agrees on the
* runtime.
* *
* Service timing: a plan's services (9:00, 11:00…) take turns owning the * Service timing: a plan's services (9:00, 11:00…) take turns owning the
* service clock — the rules live in service-timing.js. A service starts at * service clock — the rules live in service-timing.js. A service starts at
@@ -52,7 +56,7 @@ const http = require('http');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const { MATCH_THRESHOLD, similarity } = require('./matching'); const { placeDocument, messageItemOf, playlistMirrorsPlan } = require('./matching');
const { const {
MINUTE, MINUTE,
serviceTimes, serviceTimes,
@@ -434,6 +438,8 @@ module.exports = {
let inFlight = null; let inFlight = null;
let lastFrame = ''; let lastFrame = '';
let lastTimersSig = ''; let lastTimersSig = '';
let lastMessageItemId = null;
let lastMatchError = '';
/* — broadcasting — */ /* — broadcasting — */
@@ -582,6 +588,7 @@ module.exports = {
} }
state.plan = plan; state.plan = plan;
state.items = items; state.items = items;
noteMessageItem(items);
state.reachable = true; state.reachable = true;
state.lastError = ''; state.lastError = '';
state.lastFetched = Date.now(); state.lastFetched = Date.now();
@@ -596,6 +603,17 @@ module.exports = {
syncServiceSafe(Date.now(), null); syncServiceSafe(Date.now(), null);
} }
/** Say, once per plan, which item a document named like the plan will land on. */
function noteMessageItem(items) {
const msg = messageItemOf(items);
const id = msg ? msg.id : '';
if (id === lastMessageItemId) return;
lastMessageItemId = id;
log(msg
? `message item: "${msg.title}" — a live document named like this plan (its date, series and part) lands here`
: 'no message item in this plan — a document named like the plan has nowhere to land');
}
/* — item note categories (a tile's "Item notes" → Categories switches) — */ /* — item note categories (a tile's "Item notes" → Categories switches) — */
let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center let noteCategories = { at: 0, options: [] }; // the last good answer from Planning Center
@@ -996,36 +1014,29 @@ module.exports = {
}; };
lastLiveName = keep.activeName || ''; lastLiveName = keep.activeName || '';
if (follow && keep.activeName) { if (follow && keep.activeName) {
const hit = matchItem(keep.activeName); const hit = matchItem(keep.activeName, hintFrom(lastView));
state.live.matched = Boolean(hit); state.live.matched = Boolean(hit);
if (hit) { if (hit) {
state.live.currentScore = hit.score; state.live.currentScore = hit.score;
startItem(hit.item.id); startItem(hit.item.id, keep.activeName, hit);
} }
} }
} }
/** Best plan item for a live presentation name, preferring the ones ahead of us. */ /**
function matchItem(name) { * The plan item a live document stands for — { item, score, via } or
const items = state.items.filter((it) => it.itemType !== 'header'); * null. The rules live in matching.js (placeDocument): by name, then the
if (!items.length) return null; * message rule for a document named like the plan itself; the playlist
const currentIdx = items.findIndex((it) => it.id === state.live.currentItemId); * position only breaks ties, and only while the playlist mirrors the plan.
let best = null; */
for (let i = 0; i < items.length; i += 1) { function matchItem(name, hint) {
const item = items[i]; return placeDocument(name, { items: state.items, plan: state.plan, currentItemId: state.live.currentItemId, hint });
const score = Math.max(similarity(name, item.title), item.song ? similarity(name, item.song.title) : 0);
if (score < MATCH_THRESHOLD) continue;
// Distance ahead of the current item (wrapping so items behind rank last).
const dist = currentIdx < 0 ? i : (i - currentIdx + items.length) % items.length;
if (
!best
|| score > best.score + 0.001
|| (Math.abs(score - best.score) <= 0.001 && dist < best.dist)
) {
best = { item, score, dist };
} }
}
return best; /** The live item's place in ProPresenter's playlist, and whether that playlist mirrors the plan. */
function hintFrom(view) {
const index = Number.isInteger(view?.activeIndex) ? view.activeIndex : -1;
return { index, mirror: index >= 0 && playlistMirrorsPlan(view?.items, state.items) };
} }
/** Remember a document ProPresenter put live, most recent first, for the admin selects. */ /** Remember a document ProPresenter put live, most recent first, for the admin selects. */
@@ -1077,6 +1088,7 @@ module.exports = {
function applyLiveView(view) { function applyLiveView(view) {
lastView = view && typeof view === 'object' ? view : null; lastView = view && typeof view === 'object' ? view : null;
const live = state.live; const live = state.live;
if (!live.available) log('following the ProPresenter module feed');
live.available = true; live.available = true;
live.reason = ''; live.reason = '';
live.ppEnabled = Boolean(view?.enabled); live.ppEnabled = Boolean(view?.enabled);
@@ -1095,23 +1107,40 @@ module.exports = {
broadcast(); // reachability may have changed; the frame check keeps it quiet otherwise broadcast(); // reachability may have changed; the frame check keeps it quiet otherwise
return; return;
} }
lastLiveName = name;
rememberDocument(name); rememberDocument(name);
if (follow) { if (follow) {
const hit = matchItem(name); try {
const hit = matchItem(name, hintFrom(view));
live.matched = Boolean(hit); live.matched = Boolean(hit);
if (hit) { if (hit) {
live.currentScore = hit.score; live.currentScore = hit.score;
if (hit.item.id !== live.currentItemId) startItem(hit.item.id); if (hit.item.id !== live.currentItemId) startItem(hit.item.id, name, hit);
} else {
// Said in the log, because on the wall it looks like the plan
// stopped following: the highlight stays where it was.
const staying = state.items.find((it) => it.id === live.currentItemId);
log(`live document "${name}" is not in the plan — ${staying ? `"${staying.title}" stays current` : 'no current item'}`);
}
lastMatchError = '';
} catch (err) {
// Never silent: a matcher that throws would otherwise leave the
// highlight stuck with nothing in the log to say why.
const msg = err?.message || String(err);
if (msg !== lastMatchError) {
lastMatchError = msg;
log(`matching "${name}" failed: ${msg}`);
} }
} }
}
lastLiveName = name;
// The same document is also a possible service start / end cue. // The same document is also a possible service start / end cue.
syncServiceSafe(Date.now(), name); syncServiceSafe(Date.now(), name);
broadcast(); broadcast();
broadcastTimers(); broadcastTimers();
} }
function startItem(itemId) { /** Make an item current; `liveName` and `hit` say what placed it, for the log. */
function startItem(itemId, liveName, hit) {
const live = state.live; const live = state.live;
const now = Date.now(); const now = Date.now();
if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) { if (live.currentItemId && live.history[live.currentItemId] && !live.history[live.currentItemId].endedAt) {
@@ -1120,12 +1149,18 @@ module.exports = {
live.currentItemId = itemId; live.currentItemId = itemId;
live.history[itemId] = { startedAt: now, endedAt: 0 }; live.history[itemId] = { startedAt: now, endedAt: 0 };
const item = state.items.find((it) => it.id === itemId); const item = state.items.find((it) => it.id === itemId);
log(`live item → ${item?.title || itemId}`); const how = hit
? ` (${hit.via}${hit.via === 'name' ? ` ${Number(hit.score).toFixed(2)}` : ''}${liveName ? `: "${liveName}"` : ''})`
: '';
log(`live item → ${item?.title || itemId}${how}`);
} }
function nowNextUnavailable(reason, retryMs) { function nowNextUnavailable(reason, retryMs) {
const live = state.live; const live = state.live;
if (live.available || live.reason !== reason) { if (live.available || live.reason !== reason) {
// Every drop is in the log: a highlight that stopped moving is
// otherwise indistinguishable from a feed that went away.
log(`ProPresenter module feed unavailable — ${reason}`);
live.available = false; live.available = false;
live.reason = reason; live.reason = reason;
live.ppReachable = false; live.ppReachable = false;
+117 -38
View File
@@ -17,6 +17,21 @@
const FLOOR = -200; const FLOOR = -200;
/**
* A dead input reads as a number, and a number on a wall is believed. Every
* tile therefore refuses to draw a level while the server says the input is
* digitally silent, and says why instead.
*/
function silentFor(payload) {
return payload && payload.silentSec ? payload.silentSec : 0;
}
function silentText(sec) {
if (sec < 90) return `No audio — ${sec} s`;
const m = Math.round(sec / 60);
return m < 90 ? `No audio — ${m} min` : `No audio — ${(m / 60).toFixed(1)} hours`;
}
/** Retention per frame for the RTA's smoothing setting. */ /** Retention per frame for the RTA's smoothing setting. */
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 }; const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
@@ -37,6 +52,9 @@ export default function create({ root, moduleApi }) {
moduleApi.setStatus('error', payload.error); moduleApi.setStatus('error', payload.error);
} else if (!payload.frame) { } else if (!payload.frame) {
moduleApi.setStatus('connecting', 'waiting for the first reading'); moduleApi.setStatus('connecting', 'waiting for the first reading');
} else if (silentFor(payload)) {
moduleApi.setStatus('error',
`No audio on the chosen input — digital silence for ${silentText(silentFor(payload)).replace('No audio — ', '')}`);
} else if (!payload.calibrated) { } else if (!payload.calibrated) {
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL'); moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
} else { } else {
@@ -233,6 +251,16 @@ function readoutView(root, moduleApi) {
render(payload) { render(payload) {
const s = moduleApi.instanceSettings; const s = moduleApi.instanceSettings;
const ch = pickChannel(payload, s); const ch = pickChannel(payload, s);
const quiet = silentFor(payload);
if (quiet && ch) {
num.textContent = '––.–';
unit.textContent = '';
sub.hidden = false;
sub.textContent = silentText(quiet);
box.dataset.level = 'dead';
fit();
return;
}
if (!payload || payload.error || !ch) { if (!payload || payload.error || !ch) {
num.textContent = '––.–'; num.textContent = '––.–';
sub.textContent = payload && payload.error ? payload.error : ''; sub.textContent = payload && payload.error ? payload.error : '';
@@ -286,6 +314,7 @@ function barsView(root, moduleApi) {
</div>`).join(''); </div>`).join('');
} }
const s = moduleApi.instanceSettings; const s = moduleApi.instanceSettings;
const quiet = silentFor(payload);
const warn = Number(s.warnAt), alarm = Number(s.alarmAt); const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
const showPeak = s.peakHold !== false; const showPeak = s.peakHold !== false;
const lo = payload.calibrated ? 40 : -80; const lo = payload.calibrated ? 40 : -80;
@@ -295,12 +324,12 @@ function barsView(root, moduleApi) {
if (!el) continue; if (!el) continue;
const value = readValue(c, { averaging: s.averaging }); const value = readValue(c, { averaging: s.averaging });
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100)); const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
el.querySelector('.spl-bar-fill').style.width = `${pct(value)}%`; el.querySelector('.spl-bar-fill').style.width = quiet ? '0%' : `${pct(value)}%`;
const peak = el.querySelector('.spl-bar-peak'); const peak = el.querySelector('.spl-bar-peak');
peak.style.display = showPeak ? '' : 'none'; peak.style.display = showPeak ? '' : 'none';
if (showPeak) peak.style.left = `${pct(c.peakC)}%`; if (showPeak) peak.style.left = `${pct(c.peakC)}%`;
el.querySelector('.spl-bar-value').textContent = fmt(value); el.querySelector('.spl-bar-value').textContent = quiet ? '––.–' : fmt(value);
el.dataset.level = !payload.calibrated ? 'idle' el.dataset.level = quiet ? 'dead' : !payload.calibrated ? 'idle'
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok'; : value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
el.classList.toggle('clip', !!c.clip); el.classList.toggle('clip', !!c.clip);
} }
@@ -321,7 +350,18 @@ function spectrumView(root, moduleApi, scrolling) {
root.innerHTML = '<canvas class="spl-canvas"></canvas>'; root.innerHTML = '<canvas class="spl-canvas"></canvas>';
const canvas = root.querySelector('.spl-canvas'); const canvas = root.querySelector('.spl-canvas');
const ctx = canvas.getContext('2d', { alpha: false }); const ctx = canvas.getContext('2d', { alpha: false });
let painted = false; // spectrogram: has the background been laid down /**
* The spectrogram's scrolling history, on its own surface.
*
* It used to scroll by copying the visible canvas onto itself, which also
* copied whatever had been drawn on top of it — so every frame dragged the
* frequency labels and gridlines one step further along and left a smear.
* Only measured pixels belong on a surface that scrolls; annotations are
* drawn fresh over the top of it, every frame, and never into it.
*/
let history = null;
let historyCtx = null;
let painted = false; // spectrogram: has the history been cleared yet
let smoothed = null; // RTA: displayed values, damped toward the real ones let smoothed = null; // RTA: displayed values, damped toward the real ones
let smoothedFor = ''; let smoothedFor = '';
let axisBtn = null; let axisBtn = null;
@@ -389,10 +429,12 @@ function spectrumView(root, moduleApi, scrolling) {
const muted = themeColor(root, '--muted', '#888'); const muted = themeColor(root, '--muted', '#888');
const border = themeColor(root, '--border', '#333'); const border = themeColor(root, '--border', '#333');
if (!payload || !payload.frame || !payload.frame.bands) { const quiet = silentFor(payload);
if (quiet || !payload || !payload.frame || !payload.frame.bands) {
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center'; ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
ctx.fillText(payload?.error || 'Spectrum analysis is off', w / 2, h / 2); ctx.fillText(quiet ? silentText(quiet)
: (payload?.error || 'Spectrum analysis is off'), w / 2, h / 2);
painted = false; painted = false;
return; return;
} }
@@ -466,62 +508,99 @@ function spectrumView(root, moduleApi, scrolling) {
// ── spectrogram ── // ── spectrogram ──
const timeX = axis() === 'x'; const timeX = axis() === 'x';
const colW = 2; const colW = 2;
const gutter = showLabels ? axisPad : 0; // A label along the bottom needs only its line height; one down the
// Frequency occupies whichever axis time does not. // side has to fit "12.5k" beside the plot rather than on top of it.
const freqExtent = timeX ? h - gutter : w - gutter; const gutter = (showLabels || grid) ? (timeX ? 30 : axisPad) : 0;
const timeExtent = timeX ? w : h; // Labels belong beside the frequency axis: down the left when frequency
// runs vertically, along the bottom when it runs horizontally.
const plotX = timeX ? gutter : 0;
const plotW = timeX ? w - gutter : w;
const plotH = timeX ? h : h - gutter;
const freqExtent = timeX ? plotH : plotW;
const timeExtent = timeX ? plotW : plotH;
if (plotW < 4 || plotH < 4) return;
if (!painted) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); painted = true; } // The history surface is sized to the plot, in device pixels, and is
// thrown away whenever that geometry changes — a scroll buffer means
// Source rect in device pixels, destination in CSS pixels: the context // nothing once it is being stretched into a different shape.
// carries a devicePixelRatio transform but the canvas as an image const dpr = window.devicePixelRatio || 1;
// source does not, and letting drawImage infer them scales the picture const needW = Math.max(1, Math.round(plotW * dpr));
// up and pushes every column off the canvas. const needH = Math.max(1, Math.round(plotH * dpr));
if (timeX) { if (!history || history.width !== needW || history.height !== needH) {
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h); history = document.createElement('canvas');
} else { history.width = needW;
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, -colW, w, h); history.height = needH;
historyCtx = history.getContext('2d', { alpha: false });
historyCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
painted = false;
} }
if (!painted) {
historyCtx.fillStyle = bg;
historyCtx.fillRect(0, 0, plotW, plotH);
painted = true;
}
// Scroll the history, then add the newest column. The source rectangle
// is in device pixels and the destination in CSS pixels, because the
// context carries the dpr transform but the canvas as an image source
// does not.
historyCtx.drawImage(history, 0, 0, history.width, history.height,
timeX ? -colW : 0, timeX ? 0 : -colW, plotW, plotH);
const bandExtent = freqExtent / values.length; const bandExtent = freqExtent / values.length;
for (let i = 0; i < values.length; i++) { for (let i = 0; i < values.length; i++) {
ctx.fillStyle = heat(norm(values[i], payload.calibrated)); historyCtx.fillStyle = heat(norm(values[i], payload.calibrated));
if (timeX) { if (timeX) {
// Low frequencies at the bottom, as every spectrogram has them. // Low frequencies at the bottom, as every spectrogram has them.
ctx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent, historyCtx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
colW, Math.ceil(bandExtent) + 1); colW, Math.ceil(bandExtent) + 1);
} else { } else {
// Low frequencies at the left. // Low frequencies at the left.
ctx.fillRect(gutter + i * bandExtent, timeExtent - colW, historyCtx.fillRect(i * bandExtent, timeExtent - colW,
Math.ceil(bandExtent) + 1, colW); Math.ceil(bandExtent) + 1, colW);
} }
} }
if (grid || showLabels) { // Repaint the visible canvas from scratch every frame: history first,
// annotations over it. Nothing drawn here can ever reach the scroll.
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
ctx.drawImage(history, 0, 0, history.width, history.height,
plotX, 0, plotW, plotH);
if (!grid && !showLabels) return;
const span = Math.log2(freqs[freqs.length - 1] / freqs[0]);
const posOf = (f) => { const posOf = (f) => {
const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]); const t = Math.log2(f / freqs[0]) / span;
return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent; return timeX ? plotH - t * plotH : plotX + t * plotW;
}; };
const labels = chooseLabels(freqs, posOf, timeX ? freqExtent : w, timeX ? 16 : 34); const labels = chooseLabels(freqs, posOf, timeX ? plotH : w, timeX ? 14 : 34);
ctx.fillStyle = bg;
if (timeX) ctx.fillRect(0, h - gutter, w, gutter); if (grid) {
else ctx.fillRect(0, 0, gutter, h); ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.35;
ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.beginPath();
ctx.strokeStyle = border; ctx.globalAlpha = grid ? 0.35 : 0; ctx.beginPath();
for (const l of labels) { for (const l of labels) {
if (timeX) { if (timeX) {
const y = Math.round(l.pos) + 0.5; const y = Math.round(l.pos) + 0.5;
ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.moveTo(plotX, y); ctx.lineTo(w, y);
} else { } else {
const x = Math.round(l.pos) + 0.5; const x = Math.round(l.pos) + 0.5;
ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.moveTo(x, 0); ctx.lineTo(x, plotH);
} }
} }
ctx.stroke(); ctx.globalAlpha = 1; ctx.stroke(); ctx.globalAlpha = 1;
ctx.textAlign = timeX ? 'left' : 'center'; }
for (const l of showLabels ? labels : []) {
if (timeX) ctx.fillText(hzLabel(l.f), 2, Math.min(h - gutter - 2, l.pos + 3)); if (showLabels) {
else ctx.fillText(hzLabel(l.f), l.pos, h - 3); ctx.fillStyle = muted; ctx.font = '9px system-ui';
ctx.textAlign = timeX ? 'right' : 'center';
for (const l of labels) {
if (timeX) {
ctx.fillText(hzLabel(l.f), gutter - 2,
Math.max(7, Math.min(h - 2, l.pos + 3)));
} else {
ctx.fillText(hzLabel(l.f), l.pos, h - 3);
}
} }
} }
}, },
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "spl-meter", "id": "spl-meter",
"name": "SPL Meter", "name": "SPL Meter",
"version": "0.3.1", "version": "0.4.2",
"proddash": ">=1.5.0", "proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [ "permissions": [
+65 -2
View File
@@ -20,7 +20,7 @@
*/ */
const path = require('path'); const path = require('path');
const { createSplListener } = require('./spl-listener'); const { createSplListener, ensureCaptureBinary } = require('./spl-listener');
/** Reference level of a sound level calibrator. Effectively always 94 dB. */ /** Reference level of a sound level calibrator. Effectively always 94 dB. */
const CALIBRATOR_DB = 94; const CALIBRATOR_DB = 94;
@@ -30,6 +30,19 @@ const MEASURE_MS = 3000;
/** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */ /** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */
const PING_MS = 15000; const PING_MS = 15000;
/**
* Below this, unweighted, the input is not quiet — it is not there.
*
* A real input always carries something: a microphone in a silent room still
* reads about -83 dBFS, and even a muted converter dithers around -120. Only
* a stream of actual zeros reaches the capture tool's -200 floor, and that
* means the device handed us nothing at all.
*/
const SILENCE_DBFS = -150;
/** How long that has to hold before saying so, so a gap between buffers or a
device changing sample rate mid-stream doesn't cry wolf. */
const SILENCE_GRACE_MS = 5000;
/** Set by init(), read by routes() — both are rebuilt together on re-init. */ /** Set by init(), read by routes() — both are rebuilt together on re-init. */
let state = null; let state = null;
@@ -100,11 +113,20 @@ module.exports = {
meta: null, meta: null,
error: '', error: '',
streams: new Set(), streams: new Set(),
silentSince: null,
toolMissing: '',
listener: null, listener: null,
lastSent: '', lastSent: '',
pinger: null, pinger: null,
}; };
// Before anything else: the device picker shells out to this tool, so a
// machine that installed the module from the catalog (source only — the
// binary is gitignored) needs the build kicked off here. Waiting for a
// listener would never happen: no tool means no device list, and no
// device means no listener.
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
if (!config.enabled) { if (!config.enabled) {
state.error = 'Sound level measurement is off — turn it on in ProdDash settings'; state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) }; return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
@@ -143,6 +165,12 @@ module.exports = {
state.error = ''; state.error = '';
state.frame = update.frame; state.frame = update.frame;
state.meta = update.meta; state.meta = update.meta;
// Digital silence on every channel is a dead input, not a reading.
// Saying "ok" through this is what made a stopped Dante device look
// like a frozen module for a day and a half.
const dead = update.frame.ch.every(
(c) => !Number.isFinite(c.dbfs) || c.dbfs <= SILENCE_DBFS);
state.silentSince = dead ? (state.silentSince || Date.now()) : null;
broadcast(); broadcast();
}, },
}); });
@@ -157,8 +185,16 @@ module.exports = {
return { return {
stop() { teardown(); }, stop() { teardown(); },
health() { health() {
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
return { status: 'error', message: state.toolMissing };
}
if (state.error) return { status: 'error', message: state.error }; if (state.error) return { status: 'error', message: state.error };
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' }; if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
const quiet = silentMs();
if (quiet > SILENCE_GRACE_MS) {
return { status: 'error',
message: `No audio from ${config.device} — digital silence for ${describe(quiet)}` };
}
const n = state.channels.length; const n = state.channels.length;
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`; const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
return state.calibrated return state.calibrated
@@ -275,6 +311,13 @@ module.exports = {
/** Device list for the admin page. Runs the capture tool's --list, /** Device list for the admin page. Runs the capture tool's --list,
which is the only thing that knows what CoreAudio can see. */ which is the only thing that knows what CoreAudio can see. */
'GET /devices': async (req, res) => { 'GET /devices': async (req, res) => {
const missing = ensureCaptureBinary({ moduleDir: __dirname, log });
if (missing) {
if (state) state.toolMissing = missing;
log(`device list unavailable — ${missing}`);
json(res, 200, { options: [] });
return;
}
try { try {
const devices = await listDevices(); const devices = await listDevices();
json(res, 200, { json(res, 200, {
@@ -351,10 +394,26 @@ module.exports = {
/* ── helpers ────────────────────────────────────────────────────────── */ /* ── helpers ────────────────────────────────────────────────────────── */
/** How long every channel has been at digital silence, in ms. */
function silentMs() {
return state && state.silentSince ? Date.now() - state.silentSince : 0;
}
function describe(ms) {
const s = Math.round(ms / 1000);
if (s < 90) return `${s} s`;
const m = Math.round(s / 60);
return m < 90 ? `${m} min` : `${(m / 60).toFixed(1)} hours`;
}
function snapshot() { function snapshot() {
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null }; if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null, silentSec: 0 };
// Seconds, not milliseconds: this goes on the wire 20×/s and a value that
// changed every frame would make the fan-out's change check useless.
const quiet = silentMs();
return { return {
error: state.error, error: state.error,
silentSec: quiet > SILENCE_GRACE_MS ? Math.round(quiet / 1000) : 0,
calibrated: state.calibrated, calibrated: state.calibrated,
channels: state.channels, channels: state.channels,
weighting: state.meta ? state.meta.weighting : '', weighting: state.meta ? state.meta.weighting : '',
@@ -397,6 +456,10 @@ function listDevices() {
}); });
} }
function fsExists(p) {
try { return require('fs').existsSync(p); } catch { return false; }
}
function json(res, code, body) { function json(res, code, body) {
const text = JSON.stringify(body); const text = JSON.stringify(body);
res.writeHead(code, { res.writeHead(code, {
+75 -57
View File
@@ -17,7 +17,8 @@
* module's ltc-capture. It normally arrives already built — compiled beside * module's ltc-capture. It normally arrives already built — compiled beside
* the app, or copied from any Mac of the same architecture. When it is * the app, or copied from any Mac of the same architecture. When it is
* missing this will build it, but in the background and never on the event * missing this will build it, but in the background and never on the event
* loop: see ensureBinary for what that cost the first time it was wrong. * loop, and from module scope so the device picker can trigger it too — see
* ensureCaptureBinary for what both of those cost the first time round.
* *
* The same macOS gotcha applies as for LTC, and is worth repeating because * The same macOS gotcha applies as for LTC, and is worth repeating because
* it costs an afternoon every time: a ProdDash launched over SSH or from a * it costs an afternoon every time: a ProdDash launched over SSH or from a
@@ -30,6 +31,74 @@ const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
+ 'and copy it next to spl-capture.swift';
const BUILDING = 'Building the capture tool — this takes a minute or two';
/**
* Build the capture tool if it is missing, WITHOUT blocking, and shared by
* everything that needs it.
*
* This lives at module scope rather than inside a listener because of a trap
* the first version walked into: the admin page's device picker runs the tool
* with --list, but the build only ran when a listener started, and a listener
* only starts once a device has been picked. On a machine that got the module
* from the catalog — source only, the binary being gitignored — the picker
* was empty, and nothing would ever fill it.
*
* It also must never block. It was a spawnSync once, copied from the LTC
* listener where the source is a third the size and builds in seconds. Here
* it took nearly two minutes on the production Mac and held the Node event
* loop for all of it, freezing every other module, then did it again on the
* next retry.
*
* The binary should normally already be there: built beside the app, or
* copied from any Mac of the same architecture. This is the fallback.
*
* @returns {string} '' when the tool is ready, otherwise what to say.
*/
let building = false;
let buildFailed = false;
function ensureCaptureBinary({ moduleDir, log, onReady }) {
const bin = path.join(moduleDir, 'spl-capture');
if (fs.existsSync(bin)) return '';
const src = bin + '.swift';
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
if (building) return BUILDING;
if (buildFailed) return BUILD_HELP;
building = true;
log('spl-capture binary missing — building it in the background');
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
let buildErr = '';
build.stderr.setEncoding('utf8');
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
const giveUp = setTimeout(() => {
try { build.kill('SIGKILL'); } catch { /* already gone */ }
}, 300000);
build.on('error', (err) => {
clearTimeout(giveUp);
building = false; buildFailed = true;
log(`swiftc could not be run: ${err.message}`);
});
build.on('exit', (code) => {
clearTimeout(giveUp);
building = false;
if (code === 0 && fs.existsSync(bin)) {
log('spl-capture built');
buildFailed = false;
if (typeof onReady === 'function') onReady();
return;
}
buildFailed = true;
log(`spl-capture build failed (exit ${code})`
+ (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
});
return BUILDING;
}
const RESTART_MS = 5000; const RESTART_MS = 5000;
/** An immediate exit is config-shaped (bad device, channel out of range) — /** An immediate exit is config-shaped (bad device, channel out of range) —
retrying every 5 s would only fill the log with the same sentence. */ retrying every 5 s would only fill the log with the same sentence. */
@@ -53,64 +122,13 @@ function createSplListener(opts) {
let stallTimer = null; let stallTimer = null;
let startedAt = 0; let startedAt = 0;
let meta = null; let meta = null;
let building = false;
let buildFailed = false;
let lastFrameWall = 0; let lastFrameWall = 0;
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with ' /** '' when the tool is ready, otherwise what to say about it. */
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
+ 'and copy it next to spl-capture.swift';
/**
* Build the capture tool if it is missing, WITHOUT blocking.
*
* This used to be a spawnSync, copied from the LTC listener where the
* source is a third the size and builds in a few seconds. Here it took
* nearly two minutes on the production Mac and froze the entire ProdDash
* server for every second of it — one module's optional helper took the
* whole dashboard down, then did it again on every retry. Nothing that
* runs while a service might be on is allowed to hold the event loop.
*
* The build is a fallback for a module installed from the catalog on a
* machine with a Swift toolchain. The normal path is a binary built
* alongside the app, or copied from any same-architecture Mac.
*
* @returns {string} '' when the binary is ready, otherwise what to say.
*/
function ensureBinary() { function ensureBinary() {
if (fs.existsSync(bin)) return ''; return ensureCaptureBinary({ moduleDir, log, onReady() {
const src = bin + '.swift';
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
if (building) return 'Building the capture tool — this takes a minute or two';
if (buildFailed) return BUILD_HELP;
building = true;
log('spl-capture binary missing — building it in the background');
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
let buildErr = '';
build.stderr.setEncoding('utf8');
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
const giveUp = setTimeout(() => {
try { build.kill('SIGKILL'); } catch { /* already gone */ }
}, 300000);
build.on('error', (err) => {
clearTimeout(giveUp);
building = false; buildFailed = true;
log(`swiftc could not be run: ${err.message}`);
});
build.on('exit', (code) => {
clearTimeout(giveUp);
building = false;
if (code === 0 && fs.existsSync(bin)) {
log('spl-capture built');
buildFailed = false;
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); } if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
return; } });
}
buildFailed = true;
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
});
return 'Building the capture tool — this takes a minute or two';
} }
function scheduleRestart(ms) { function scheduleRestart(ms) {
@@ -137,7 +155,7 @@ function createSplListener(opts) {
onUpdate({ error: missing }); onUpdate({ error: missing });
// A build in flight calls start() itself when it lands; anything else // A build in flight calls start() itself when it lands; anything else
// is config-shaped and should back off hard rather than spin. // is config-shaped and should back off hard rather than spin.
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS); if (missing !== BUILDING) scheduleRestart(BAD_CONFIG_RETRY_MS);
return; return;
} }
@@ -236,4 +254,4 @@ function createSplListener(opts) {
}; };
} }
module.exports = { createSplListener }; module.exports = { createSplListener, ensureCaptureBinary };
+6
View File
@@ -57,6 +57,11 @@
[data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); } [data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); }
[data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); } [data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); }
/* A dead input reads danger, not muted: silence that is a fault should not
look like silence that is a quiet room. */
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-num { color: var(--danger); }
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-sub { color: var(--danger); }
/* ── level bars ───────────────────────────────────────────────────── */ /* ── level bars ───────────────────────────────────────────────────── */
[data-module="spl-meter"] .spl-bars { [data-module="spl-meter"] .spl-bars {
@@ -100,6 +105,7 @@
[data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); } [data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); }
[data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); } [data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); }
[data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); } [data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); }
[data-module="spl-meter"] .spl-bar[data-level="dead"] .spl-bar-value { color: var(--danger); }
/* Peak hold sits on the track rather than inside the fill, so it stays /* Peak hold sits on the track rather than inside the fill, so it stays
visible when the bar falls away underneath it. */ visible when the bar falls away underneath it. */