Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7e40ee1a8 | ||
|
|
c075655817 | ||
|
|
48d0f5a04a | ||
|
|
12f6e22187 | ||
|
|
87929bfd0b | ||
|
|
46d44d494b | ||
|
|
44fd0b10ba | ||
|
|
543795f9fd | ||
|
|
d2d4207fe4 | ||
|
|
e90ae51495 | ||
|
|
188d1c878d | ||
|
|
5025e12b71 | ||
|
|
3009b39453 | ||
|
|
41f64f3a13 | ||
|
|
f72170b4e1 | ||
|
|
ea1eac1c28 | ||
|
|
ea8be2ac76 |
@@ -14,3 +14,6 @@ modules/propresenter-timers/ltc-capture
|
||||
# the launcher's build output, and its cache of the Node runtime it embeds
|
||||
launcher/macos/build/
|
||||
launcher/macos/.cache/
|
||||
|
||||
# Compiled per-machine, source sits next to it (see spl-capture.swift).
|
||||
modules/spl-meter/spl-capture
|
||||
|
||||
@@ -102,7 +102,7 @@ with this machine's shell settings — the same keys as the checked-in
|
||||
| `adminPin` | The admin PIN, as a scrypt hash. **Set it in the admin page's Admin PIN section**, not by hand — 6 digits, and only the hash is stored. Absent means admin is open to anyone on the network. |
|
||||
| `adminPasscode` | An older free-text passcode. Still honoured, so an existing install keeps working; new installs should use `adminPin`. |
|
||||
| `forge` | Where updates and module installs are fetched from. Absent or `"github"` uses GitHub; a Gitea or Forgejo base URL such as `"http://10.3.8.90:3000"` uses a server of your own. Set it per machine in the data directory, alongside `repo`. |
|
||||
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember` or `daylight`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
||||
| `theme` | The dashboard palette: `booth` (default), `harbor`, `graphite`, `ember`, `daylight`, `midnight`, `moss`, `plum`, `sepia`, `blackout`, `paper` or `iron`. The admin page's **Theme** section writes this one — see [Themes](#themes). |
|
||||
|
||||
**Locked out?** Delete `adminPin` from the data directory's `proddash.json`
|
||||
and restart, or start the server with `PRODDASH_PASSCODE` set — that passcode
|
||||
@@ -140,12 +140,26 @@ ignored from then on.
|
||||
|
||||
The whole dashboard — every tile, on every browser — draws with one palette,
|
||||
chosen in the admin page's **Theme** section and switched live: pick a swatch
|
||||
and open dashboards change without a reload. Five presets ship: **Booth**
|
||||
(the default dark, mint accent), **Harbor** (deep navy, cyan), **Graphite**
|
||||
(neutral charcoal, electric blue), **Ember** (warm broadcast black, orange)
|
||||
and **Daylight**, a light palette for a front-of-house desk in daytime. Each
|
||||
keeps the status colours apart — the accent is never near the danger red or
|
||||
the warning amber — and muted text readable in a dark booth. The choice is
|
||||
and open dashboards change without a reload. The section starts folded —
|
||||
a building picks its palette once — and holds twelve presets:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Booth** | the default dark, mint accent |
|
||||
| **Harbor** | deep navy, cyan |
|
||||
| **Graphite** | neutral charcoal, electric blue |
|
||||
| **Ember** | warm broadcast black, orange |
|
||||
| **Daylight** | a cool light palette, for a front-of-house desk in daytime |
|
||||
| **Midnight** | indigo night, violet |
|
||||
| **Moss** | forest floor, lime |
|
||||
| **Plum** | aubergine, purple |
|
||||
| **Sepia** | warm brown room, cool teal — Ember's arrangement reversed |
|
||||
| **Blackout** | true black, cyan, for a dark room where any glow carries |
|
||||
| **Paper** | the warm light one, indigo accent |
|
||||
| **Iron** | no colour of its own: a near-white accent, so the only hues on screen are a module's own and the red and amber that mean something |
|
||||
|
||||
Each keeps the status colours apart — the accent is never near the danger red
|
||||
or the warning amber — and muted text readable in a dark booth. The choice is
|
||||
stored as `theme` in the data directory's `proddash.json` (table above), so
|
||||
it holds across restarts and updates; a browser remembers the last theme it
|
||||
saw and applies it before the server answers, so nothing flashes. Modules
|
||||
@@ -186,7 +200,18 @@ below. Nothing is installed without someone clicking.
|
||||
no local changes — otherwise the banner says why and you update with git).
|
||||
A plain folder (no `.git`) has the repo archive unpacked over it; files
|
||||
removed upstream are left behind but harmless. Settings and layouts are
|
||||
never touched. **What changed** opens the commit comparison on GitHub.
|
||||
never touched — nothing inside the data directory is written, even on an
|
||||
install where that directory *is* the app's own `config/`. **What changed**
|
||||
opens the commit comparison on the forge.
|
||||
- **If an update goes wrong**: the download has to parse before anything is
|
||||
swapped in, so a truncated or mangled `server.js` is refused with the
|
||||
reason and the running copy is left exactly as it was. Past that, the copy
|
||||
being replaced is set aside as `app.previous` next to the app folder, and
|
||||
dropped once the new version has stayed up for ninety seconds — so that
|
||||
folder existing means "the last update has not proven itself yet". The
|
||||
macOS app watches for a version that will not stay up (four stops in two
|
||||
minutes) and puts the old one back by itself, once, with a notification.
|
||||
Running some other way, the way back is a rename: `mv app.previous app`.
|
||||
- **Restarting**: after an update the server exits with code 75 and starts
|
||||
its own replacement, which waits for the port to free up. If you supervise
|
||||
ProdDash with your own launcher, set `PRODDASH_LAUNCHER=1` so the server
|
||||
@@ -241,7 +266,14 @@ below. Nothing is installed without someone clicking.
|
||||
display options) — these belong to this browser's layout, not the server.
|
||||
- The **grid icon** — save the current arrangement to the server under a
|
||||
name, load a named layout (it becomes a local copy — browsers are never
|
||||
linked), or reset this browser's dashboard.
|
||||
linked), or reset this browser's dashboard. Loading says what it did —
|
||||
*Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right
|
||||
now* — with **Undo** in the message to get back the arrangement you had.
|
||||
A tile whose module or picker entry isn't offered at the moment keeps its
|
||||
place and shows nothing, then appears when it is; it is never replaced by
|
||||
the module's default tile. Saving over an existing name asks first, and
|
||||
the server keeps the copy it replaced: **Restore previous** in Admin →
|
||||
Saved layouts swaps it back (and swapping again undoes that).
|
||||
- Every tile has a **status dot**: green = live, amber = connecting,
|
||||
red = upstream unreachable (hover it for details). Tiles reconnect
|
||||
automatically.
|
||||
|
||||
+11
-4
@@ -592,10 +592,17 @@ module.exports = {
|
||||
- When the user adds an entry, its `settings` overlay your `instanceSchema`
|
||||
defaults in that tile's instance settings, the entry's `name` becomes the
|
||||
tile's title, and `moduleApi.variant` carries its `id`.
|
||||
- The tile keeps its variant id and title even if the entry later vanishes
|
||||
from your list (a timer deleted in ProPresenter, a page removed in admin).
|
||||
**Your client decides what that means** — show a clear "no longer
|
||||
configured" state rather than erroring.
|
||||
- **A tile is shown only while its entry is offered.** The shell mounts a
|
||||
tile added from an entry only when that entry (or one of its `aliases`) is
|
||||
in your list right now; otherwise the tile keeps its place in the layout,
|
||||
draws nothing, and appears the moment your list offers it again — a
|
||||
Service timer waits for PCO Plan, a transcript for its channel. So your
|
||||
client is never asked to render an entry you did not offer at mount time.
|
||||
One case is still yours: an entry that vanishes *after* the tile mounted
|
||||
(a timer deleted in ProPresenter mid-service). Render nothing for it — no
|
||||
placeholder, no error — and the shell folds the tile away at its next
|
||||
registry refresh. Never fall back to your default view in its place: five
|
||||
different timers turning into five "All timers" is worse than five gaps.
|
||||
- A client-only module can declare a fixed list as a `tiles` array in
|
||||
`module.json` instead (same entry shape).
|
||||
- An empty list (or no `tiles` at all) keeps today's behavior: one picker
|
||||
|
||||
@@ -103,6 +103,45 @@ enum ProdDash {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Go back to the copy ProdDash set aside before its last update.
|
||||
|
||||
A version that will not stay up is the one state this app cannot talk its
|
||||
way out of: the admin page went down with it, and installing a fresh
|
||||
`.app` does nothing, because seeding only replaces an *older* copy and a
|
||||
broken one carries a higher version. So ProdDash writes `app.previous`
|
||||
before it overwrites itself and drops it again once the new version has
|
||||
stayed up — meaning that folder exists only while an update is still
|
||||
unproven. This puts it back.
|
||||
|
||||
Returns the version restored, or nil when there is nothing to go back to.
|
||||
*/
|
||||
@discardableResult
|
||||
static func restorePrevious(root: URL, log: (String) -> Void) -> String? {
|
||||
let fm = FileManager.default
|
||||
let previous = URL(fileURLWithPath: root.path + ".previous")
|
||||
guard isRoot(previous) else { return nil }
|
||||
let version = shellVersion(root: previous)
|
||||
let named = version.isEmpty ? "(unknown version)" : version
|
||||
let broken = URL(fileURLWithPath: root.path + ".broken")
|
||||
do {
|
||||
try? fm.removeItem(at: broken)
|
||||
if fm.fileExists(atPath: root.path) { try fm.moveItem(at: root, to: broken) }
|
||||
try fm.moveItem(at: previous, to: root)
|
||||
makeWritable(root)
|
||||
try? fm.removeItem(at: broken)
|
||||
log("rolled ProdDash back to \(named) — the copy it replaced")
|
||||
return named
|
||||
} catch {
|
||||
log("could not roll back to the previous copy: \(error.localizedDescription)")
|
||||
// Leave something runnable behind rather than nothing at all.
|
||||
if !fm.fileExists(atPath: root.path), fm.fileExists(atPath: broken.path) {
|
||||
try? fm.moveItem(at: broken, to: root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Files copied out of a bundle can arrive read-only, and this copy is
|
||||
/// exactly where ProdDash writes its own updates.
|
||||
private static func makeWritable(_ root: URL) {
|
||||
|
||||
@@ -65,6 +65,8 @@ final class ServerController: ObservableObject {
|
||||
private var restartAfterExit = false
|
||||
private var startedAt: Date?
|
||||
private var crashTimes: [Date] = []
|
||||
/// One roll-back per bad update: a second would just swap copies forever.
|
||||
private var rolledBack = false
|
||||
private var pendingRestart: DispatchWorkItem?
|
||||
private var healthTimer: Timer?
|
||||
private var healthMisses = 0
|
||||
@@ -299,7 +301,7 @@ final class ServerController: ObservableObject {
|
||||
child = nil
|
||||
healthy = false
|
||||
let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0
|
||||
if ranFor > 60 { crashTimes.removeAll() } // a long healthy run clears the slate
|
||||
if ranFor > 60 { crashTimes.removeAll(); rolledBack = false } // a long healthy run clears the slate
|
||||
|
||||
if restartAfterExit {
|
||||
restartAfterExit = false
|
||||
@@ -328,6 +330,22 @@ final class ServerController: ObservableObject {
|
||||
crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 }
|
||||
|
||||
guard settings.restartOnCrash, crashTimes.count < 4 else {
|
||||
// Before giving up: if an update is still unproven, the copy it
|
||||
// replaced is sitting right there. Going back beats sitting dark,
|
||||
// and only once — if the old one will not run either, the problem
|
||||
// is not the update.
|
||||
if settings.restartOnCrash, !rolledBack, let root = rootURL,
|
||||
let restored = ProdDash.restorePrevious(root: root, log: { [log] in log.launcher($0) }) {
|
||||
rolledBack = true
|
||||
crashTimes.removeAll()
|
||||
state = .stopped
|
||||
message = "Rolled back to ProdDash \(restored) — starting…"
|
||||
Notifier.shared.post(title: "ProdDash rolled back",
|
||||
body: "The new version would not stay up. Back on \(restored).")
|
||||
refreshEnvironment()
|
||||
start()
|
||||
return
|
||||
}
|
||||
let why = settings.restartOnCrash
|
||||
? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped."
|
||||
: "ProdDash \(how)."
|
||||
|
||||
@@ -239,8 +239,6 @@ export default function create({ root, moduleApi }) {
|
||||
: variant.startsWith('timer:') ? `propresenter:${variant.slice('timer:'.length)}`
|
||||
: variant.startsWith('solo:') ? variant.slice('solo:'.length)
|
||||
: '';
|
||||
/** Last label seen for a solo timer, so its card stays labeled if the timer is later deleted upstream. */
|
||||
let lastSoloLabel = '';
|
||||
|
||||
/* ── DOM skeleton ───────────────────────────────────────────────── */
|
||||
|
||||
@@ -376,22 +374,21 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
/* ── state → what this tile shows ───────────────────────────────── */
|
||||
|
||||
/** [{ id, source, items: [{ key, source, timer } | { key, missing }] }] */
|
||||
/** [{ id, source, items: [{ key, source, timer }] }] */
|
||||
function visibleGroups() {
|
||||
if (!state) return [];
|
||||
if (solo) {
|
||||
for (const source of state.sources) {
|
||||
for (const timer of source.timers) {
|
||||
if (keyOf(source, timer) !== solo) continue;
|
||||
lastSoloLabel = timer.label;
|
||||
return [{ id: source.id, source, items: [{ key: solo, source, timer }] }];
|
||||
}
|
||||
}
|
||||
// The timer this tile was added for isn't offered (yet, or any more) —
|
||||
// keep a clearly-labeled card up rather than erroring.
|
||||
const sourceId = solo.slice(0, solo.indexOf(':'));
|
||||
const source = state.sources.find((s) => s.id === sourceId) || null;
|
||||
return [{ id: 'missing', source, items: [{ key: solo, missing: true, source }] }];
|
||||
// The timer this tile was added for is not offered right now. Nothing is
|
||||
// drawn for it — no placeholder that reads like a timer, and never the
|
||||
// full list in its place. The shell folds the tile away on its next
|
||||
// registry refresh (module guide, Presenting multiple tiles).
|
||||
return [];
|
||||
}
|
||||
const groups = [];
|
||||
for (const source of state.sources) {
|
||||
@@ -451,7 +448,7 @@ export default function create({ root, moduleApi }) {
|
||||
/** The parts of a card that only change with the snapshot (label, kind). */
|
||||
function updateCard(card, item) {
|
||||
card.item = item;
|
||||
card.isClock = !item.missing && item.source?.kind === 'clock';
|
||||
card.isClock = item.source?.kind === 'clock';
|
||||
const isLtc = item.source?.kind === 'ltc';
|
||||
const badge = card.nameEl.querySelector('.tm-ltc-badge');
|
||||
if (isLtc && !badge) {
|
||||
@@ -462,29 +459,13 @@ export default function create({ root, moduleApi }) {
|
||||
} else if (!isLtc && badge) {
|
||||
badge.remove();
|
||||
}
|
||||
const label = item.missing ? (lastSoloLabel || 'Timer') : card.isClock ? '' : item.timer.label;
|
||||
const label = card.isClock ? '' : item.timer.label;
|
||||
if (card.labelEl.textContent !== label) {
|
||||
setText(card.labelEl, label);
|
||||
needFit = true; // the title's size follows its length
|
||||
}
|
||||
}
|
||||
|
||||
function updateMissingCard(card) {
|
||||
const source = card.item.source;
|
||||
setText(card.timeEl, '—');
|
||||
card.chars = 5;
|
||||
let note;
|
||||
if (!source) note = 'Source not available — check Admin';
|
||||
else if (source.status === 'ok') note = `Not offered by ${source.label}`;
|
||||
else note = '—';
|
||||
setText(card.statusEl, note);
|
||||
card.statusEl.hidden = false;
|
||||
card.statusEl.className = 'tm-status is-note';
|
||||
card.statusEl.dataset.tone = source && source.status === 'ok' ? 'warn' : 'muted';
|
||||
card.detailEl.hidden = true;
|
||||
setCardClass(card, 'tm-card is-muted is-missing');
|
||||
}
|
||||
|
||||
/* ── clocks ─────────────────────────────────────────────────────── */
|
||||
|
||||
function clockOptions() {
|
||||
@@ -633,10 +614,6 @@ export default function create({ root, moduleApi }) {
|
||||
let charsChanged = false;
|
||||
for (const card of cards.values()) {
|
||||
const { item } = card;
|
||||
if (item.missing) {
|
||||
updateMissingCard(card);
|
||||
continue;
|
||||
}
|
||||
const { source, timer } = item;
|
||||
let text;
|
||||
let tone;
|
||||
@@ -735,8 +712,9 @@ export default function create({ root, moduleApi }) {
|
||||
moduleApi.setStatus('connecting', say(waiting));
|
||||
return;
|
||||
}
|
||||
const n = groups.reduce((a, g) => a + g.items.filter((i) => !i.missing).length, 0);
|
||||
moduleApi.setStatus('ok', `${n} timer${n === 1 ? '' : 's'} from ${shown.length} source${shown.length === 1 ? '' : 's'}`);
|
||||
const n = groups.reduce((a, g) => a + g.items.length, 0);
|
||||
if (solo && n === 0) moduleApi.setStatus('connecting', 'Not offered right now');
|
||||
else moduleApi.setStatus('ok', `${n} timer${n === 1 ? '' : 's'} from ${shown.length} source${shown.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
/* ── main render: diff groups and cards, update in place ────────── */
|
||||
@@ -791,7 +769,7 @@ export default function create({ root, moduleApi }) {
|
||||
let hint = '';
|
||||
const groups = visibleGroups();
|
||||
if (state && !state.sources.length) hint = 'Every source is switched off — turn one on in Admin.';
|
||||
else if (state && !groups.length) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the title bar’s timers menu.';
|
||||
else if (state && !groups.length && !solo) hint = 'Nothing selected — switch a source on in this tile’s settings, or pick timers in the title bar’s timers menu.';
|
||||
hintEl.hidden = !hint;
|
||||
hintEl.textContent = hint;
|
||||
groupsEl.hidden = Boolean(hint);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "propresenter-timers",
|
||||
"name": "Timers",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Timers, Clocks, and LTC From configured Modules",
|
||||
"provides": [
|
||||
|
||||
@@ -295,7 +295,6 @@
|
||||
}
|
||||
|
||||
/* A solo tile whose timer is no longer offered. */
|
||||
[data-module="propresenter-timers"] .tm-card.is-missing { border-style: dashed; }
|
||||
|
||||
/* ── the LTC card: visually its own thing ── */
|
||||
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* SPL Meter tiles.
|
||||
*
|
||||
* Four kinds, all fed by one SSE stream from the module's server:
|
||||
*
|
||||
* readout the number, big
|
||||
* bars one level bar per channel, with peak hold
|
||||
* rta spectrum now, folded to the banding this tile wants
|
||||
* spectrogram the same bands over time, either axis
|
||||
*
|
||||
* Nothing here measures anything. The capture child already sent every
|
||||
* averaging and the finest banding, and each tile folds that down to the
|
||||
* view it was configured for. That is the whole design: two dashboards can
|
||||
* watch one room through different settings without either disturbing the
|
||||
* measurement or the other dashboard.
|
||||
*/
|
||||
|
||||
const FLOOR = -200;
|
||||
|
||||
/** Retention per frame for the RTA's smoothing setting. */
|
||||
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
const variant = moduleApi.variant || 'readout';
|
||||
let stream = null;
|
||||
let latest = null;
|
||||
let raf = 0;
|
||||
|
||||
root.classList.add('spl');
|
||||
root.dataset.variant = variant;
|
||||
|
||||
const view = buildView(variant, root, moduleApi);
|
||||
|
||||
function onState(payload) {
|
||||
latest = payload;
|
||||
if (payload.error) {
|
||||
moduleApi.setStatus('error', payload.error);
|
||||
} else if (!payload.frame) {
|
||||
moduleApi.setStatus('connecting', 'waiting for the first reading');
|
||||
} else if (!payload.calibrated) {
|
||||
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
|
||||
} else {
|
||||
moduleApi.setStatus('ok', '');
|
||||
}
|
||||
// Frames land 20×/s; paint on a frame the browser was going to draw
|
||||
// anyway rather than fighting it for every one.
|
||||
if (!raf) raf = requestAnimationFrame(() => { raf = 0; view.render(latest); });
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
moduleApi.setStatus('connecting', 'connecting');
|
||||
moduleApi.fetch('/state')
|
||||
.then((r) => r.json())
|
||||
.then(onState)
|
||||
.catch(() => { /* the stream will bring it */ });
|
||||
// Named events go under `events` — a top-level `state(ev)` is silently
|
||||
// never registered, and the tile just sits on whatever /state gave it.
|
||||
stream = moduleApi.sse('/stream', {
|
||||
events: {
|
||||
state(ev) {
|
||||
try { onState(JSON.parse(ev.data)); } catch { /* keep the last good frame */ }
|
||||
},
|
||||
},
|
||||
error() { moduleApi.setStatus('connecting', 'reconnecting'); },
|
||||
});
|
||||
},
|
||||
stop() {
|
||||
if (stream) { stream.close(); stream = null; }
|
||||
if (raf) { cancelAnimationFrame(raf); raf = 0; }
|
||||
view.destroy();
|
||||
},
|
||||
onResize() { view.resize(); },
|
||||
};
|
||||
}
|
||||
|
||||
/* ── shared helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
function pickChannel(payload, settings) {
|
||||
if (!payload || !payload.frame) return null;
|
||||
const want = Number(settings.channel);
|
||||
return payload.frame.ch.find((c) => c.i === want) || payload.frame.ch[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The level this tile asked for: an averaging, or one of the fixed metrics.
|
||||
*
|
||||
* Tiles saved by an earlier version carry metrics that no longer exist —
|
||||
* "spl" became an averaging, "leq" became Inf. A layout on a wall somewhere
|
||||
* must not start reading dashes because a setting was renamed, so those map
|
||||
* forward instead of falling through to undefined.
|
||||
*/
|
||||
const LEGACY_METRIC = { spl: 'level', leq: 'level' };
|
||||
const LEGACY_AVERAGING = { leq: 'inf' };
|
||||
|
||||
function readValue(ch, settings) {
|
||||
const raw = settings.metric || 'level';
|
||||
const metric = LEGACY_METRIC[raw] || raw;
|
||||
if (metric !== 'level') {
|
||||
return metric in ch ? ch[metric] : ch.fast;
|
||||
}
|
||||
const avg = LEGACY_AVERAGING[raw] || settings.averaging || 'fast';
|
||||
return avg in ch ? ch[avg] : ch.fast;
|
||||
}
|
||||
|
||||
function fmt(db) {
|
||||
if (!Number.isFinite(db) || db <= FLOOR) return '––.–';
|
||||
return db.toFixed(1);
|
||||
}
|
||||
|
||||
function themeColor(el, name, fallback) {
|
||||
const v = getComputedStyle(el).getPropertyValue(name).trim();
|
||||
return v || fallback;
|
||||
}
|
||||
|
||||
function hzLabel(f) {
|
||||
if (f >= 1000) {
|
||||
const k = f / 1000;
|
||||
return (k >= 10 ? Math.round(k) : Math.round(k * 10) / 10) + 'k';
|
||||
}
|
||||
return String(f >= 100 ? Math.round(f) : Math.round(f * 10) / 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the 1/48-octave bands on the wire down to this tile's banding.
|
||||
*
|
||||
* Every banding offered divides 48, so a coarser band is an exact sum of
|
||||
* finer ones in the power domain — no interpolation, no resampling. Groups
|
||||
* are keyed on the 1 kHz band (n = 0), which is what keeps folded centres on
|
||||
* the values an RTA is expected to show.
|
||||
*
|
||||
* @returns {{freq:number[], db:number[]}}
|
||||
*/
|
||||
function foldBands(bands, bandN0, perOctave, denom) {
|
||||
const g = Math.max(1, Math.round(perOctave / denom));
|
||||
const power = new Map();
|
||||
for (let i = 0; i < bands.length; i++) {
|
||||
const n = bandN0 + i;
|
||||
const k = Math.round(n / g);
|
||||
const p = 10 ** (bands[i] / 10);
|
||||
power.set(k, (power.get(k) || 0) + p);
|
||||
}
|
||||
const keys = [...power.keys()].sort((a, b) => a - b);
|
||||
return {
|
||||
freq: keys.map((k) => 1000 * 2 ** ((k * g) / perOctave)),
|
||||
db: keys.map((k) => 10 * Math.log10(Math.max(power.get(k), 1e-20))),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which frequency labels fit, most important first.
|
||||
*
|
||||
* Decades earn their place before octaves, octaves before thirds, and
|
||||
* anything that would collide with a label already placed is dropped. That
|
||||
* is what makes the axis thin out as a tile shrinks instead of turning into
|
||||
* a smear.
|
||||
*/
|
||||
const LABEL_TIERS = [
|
||||
[100, 1000, 10000],
|
||||
[31.5, 63, 250, 500, 2000, 4000, 8000, 20000],
|
||||
[20, 40, 50, 80, 125, 160, 200, 315, 400, 630, 800, 1250, 1600, 2500, 3150, 5000, 6300, 12500, 16000],
|
||||
];
|
||||
|
||||
function chooseLabels(freqs, positionOf, extent, minGapPx) {
|
||||
const placed = [];
|
||||
for (const tier of LABEL_TIERS) {
|
||||
for (const f of tier) {
|
||||
if (f < freqs[0] * 0.92 || f > freqs[freqs.length - 1] * 1.08) continue;
|
||||
const pos = positionOf(f);
|
||||
if (pos < 0 || pos > extent) continue;
|
||||
if (placed.some((p) => Math.abs(p.pos - pos) < minGapPx)) continue;
|
||||
placed.push({ f, pos });
|
||||
}
|
||||
}
|
||||
return placed.sort((a, b) => a.pos - b.pos);
|
||||
}
|
||||
|
||||
/* ── views ──────────────────────────────────────────────────────────── */
|
||||
|
||||
function buildView(variant, root, moduleApi) {
|
||||
switch (variant) {
|
||||
case 'bars': return barsView(root, moduleApi);
|
||||
case 'rta': return spectrumView(root, moduleApi, false);
|
||||
case 'spectrogram': return spectrumView(root, moduleApi, true);
|
||||
default: return readoutView(root, moduleApi);
|
||||
}
|
||||
}
|
||||
|
||||
function readoutView(root, moduleApi) {
|
||||
root.innerHTML = `
|
||||
<div class="spl-readout">
|
||||
<div class="spl-value"><span class="spl-num">––.–</span><span class="spl-unit">dB</span></div>
|
||||
<div class="spl-sub"></div>
|
||||
</div>`;
|
||||
const num = root.querySelector('.spl-num');
|
||||
const unit = root.querySelector('.spl-unit');
|
||||
const sub = root.querySelector('.spl-sub');
|
||||
const box = root.querySelector('.spl-readout');
|
||||
const value = root.querySelector('.spl-value');
|
||||
let fitKey = '';
|
||||
|
||||
/**
|
||||
* Grow the number until it fills the tile, measuring the text we actually
|
||||
* have rather than assuming a digit count.
|
||||
*
|
||||
* The CSS can only guess at width — it has to pick a cqw coefficient for
|
||||
* some number of characters, and then "-100.5" overflows the tile that
|
||||
* "-45.9" merely filled. Measuring costs one layout read, and only when
|
||||
* the tile size or the length of the reading changes.
|
||||
*/
|
||||
function fit() {
|
||||
const w = root.clientWidth, h = root.clientHeight;
|
||||
const key = `${w}x${h}:${num.textContent.length}:${unit.textContent}:${sub.hidden}`;
|
||||
if (key === fitKey || w < 2 || h < 2) return;
|
||||
fitKey = key;
|
||||
|
||||
const TRIAL = 100;
|
||||
num.style.fontSize = `${TRIAL}px`;
|
||||
unit.style.fontSize = `${TRIAL * 0.26}px`;
|
||||
const natural = value.scrollWidth || 1;
|
||||
|
||||
// Leave the caption its room when it is showing, and a little air either
|
||||
// way so descenders and the tile's padding never clip.
|
||||
const subH = sub.offsetParent ? sub.getBoundingClientRect().height + 6 : 0;
|
||||
const byHeight = (h - subH) * 0.92;
|
||||
const byWidth = ((w - 8) * TRIAL) / natural;
|
||||
const size = Math.max(12, Math.min(byHeight, byWidth));
|
||||
num.style.fontSize = `${size}px`;
|
||||
unit.style.fontSize = `${size * 0.26}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
render(payload) {
|
||||
const s = moduleApi.instanceSettings;
|
||||
const ch = pickChannel(payload, s);
|
||||
if (!payload || payload.error || !ch) {
|
||||
num.textContent = '––.–';
|
||||
sub.textContent = payload && payload.error ? payload.error : '';
|
||||
box.dataset.level = 'idle';
|
||||
fit();
|
||||
return;
|
||||
}
|
||||
const value = readValue(ch, s);
|
||||
num.textContent = fmt(value);
|
||||
unit.textContent = payload.calibrated ? 'dB' : 'dBFS';
|
||||
|
||||
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
||||
box.dataset.level = !payload.calibrated ? 'idle'
|
||||
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
||||
|
||||
const raw = s.metric || 'level';
|
||||
const metric = LEGACY_METRIC[raw] || raw;
|
||||
const avg = LEGACY_AVERAGING[raw] || s.averaging || 'fast';
|
||||
const label = metric === 'level'
|
||||
? ({ fast: 'Fast', slow: 'Slow', inf: 'Inf', none: 'No avg' }[avg] || 'Fast')
|
||||
: ({ leq1m: 'Leq 1 min', leq10m: 'Leq 10 min', peakC: 'Peak C' }[metric] || metric);
|
||||
const parts = [`${payload.weighting || '?'} · ${label}`, `ch ${ch.i}`];
|
||||
if (ch.clip) parts.push('CLIP');
|
||||
if (!payload.calibrated) parts.push('uncalibrated');
|
||||
sub.hidden = s.caption === false;
|
||||
sub.textContent = parts.join(' · ');
|
||||
fit();
|
||||
},
|
||||
resize() { fitKey = ''; fit(); },
|
||||
destroy() { root.innerHTML = ''; },
|
||||
};
|
||||
}
|
||||
|
||||
function barsView(root, moduleApi) {
|
||||
root.innerHTML = '<div class="spl-bars"></div>';
|
||||
const host = root.querySelector('.spl-bars');
|
||||
let built = '';
|
||||
|
||||
return {
|
||||
render(payload) {
|
||||
if (!payload || !payload.frame) { host.textContent = payload?.error || ''; built = ''; return; }
|
||||
const chans = payload.frame.ch;
|
||||
const key = chans.map((c) => c.i).join(',');
|
||||
if (key !== built) {
|
||||
built = key;
|
||||
host.innerHTML = chans.map((c) => `
|
||||
<div class="spl-bar" data-ch="${c.i}">
|
||||
<div class="spl-bar-label">${c.i}</div>
|
||||
<div class="spl-bar-track"><div class="spl-bar-fill"></div><div class="spl-bar-peak"></div></div>
|
||||
<div class="spl-bar-value">––.–</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
const s = moduleApi.instanceSettings;
|
||||
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
||||
const showPeak = s.peakHold !== false;
|
||||
const lo = payload.calibrated ? 40 : -80;
|
||||
const hi = payload.calibrated ? 110 : 0;
|
||||
for (const c of chans) {
|
||||
const el = host.querySelector(`.spl-bar[data-ch="${c.i}"]`);
|
||||
if (!el) continue;
|
||||
const value = readValue(c, { averaging: s.averaging });
|
||||
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
|
||||
el.querySelector('.spl-bar-fill').style.width = `${pct(value)}%`;
|
||||
const peak = el.querySelector('.spl-bar-peak');
|
||||
peak.style.display = showPeak ? '' : 'none';
|
||||
if (showPeak) peak.style.left = `${pct(c.peakC)}%`;
|
||||
el.querySelector('.spl-bar-value').textContent = fmt(value);
|
||||
el.dataset.level = !payload.calibrated ? 'idle'
|
||||
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
||||
el.classList.toggle('clip', !!c.clip);
|
||||
}
|
||||
},
|
||||
resize() {},
|
||||
destroy() { root.innerHTML = ''; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RTA bars, or the same folded bands scrolling as a spectrogram.
|
||||
*
|
||||
* Canvas for both, because both redraw wholesale many times a second and a
|
||||
* few hundred animating DOM nodes is how a dashboard starts dropping frames
|
||||
* on the machine that is also running the service.
|
||||
*/
|
||||
function spectrumView(root, moduleApi, scrolling) {
|
||||
root.innerHTML = '<canvas class="spl-canvas"></canvas>';
|
||||
const canvas = root.querySelector('.spl-canvas');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
let painted = false; // spectrogram: has the background been laid down
|
||||
let smoothed = null; // RTA: displayed values, damped toward the real ones
|
||||
let smoothedFor = '';
|
||||
let axisBtn = null;
|
||||
let axisOverride = null; // set by the title-bar toggle, which never remounts us
|
||||
|
||||
function axis() {
|
||||
return axisOverride || moduleApi.instanceSettings.axis || 'x';
|
||||
}
|
||||
|
||||
if (scrolling) {
|
||||
// The axis lives here and nowhere else (rule 11): a tile does not offer
|
||||
// the same action twice. Icon only, tooltip carries the words (rule 9).
|
||||
// saveInstanceSettings deliberately does not remount us, so the new
|
||||
// value is held here as well and read back by axis().
|
||||
const swapIcon = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" '
|
||||
+ 'stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">'
|
||||
+ '<path d="M2 5h9M8.5 2 11.5 5 8.5 8"/><path d="M14 11H5M7.5 8 4.5 11 7.5 14"/></svg>';
|
||||
const describe = () => (axis() === 'x' ? 'Time left to right' : 'Time top to bottom');
|
||||
axisBtn = moduleApi.header.addButton({
|
||||
icon: swapIcon,
|
||||
title: describe(),
|
||||
onClick() {
|
||||
axisOverride = axis() === 'x' ? 'y' : 'x';
|
||||
moduleApi.saveInstanceSettings({ axis: axisOverride });
|
||||
painted = false; // the picture means nothing in the other orientation
|
||||
axisBtn.title = describe();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sizeCanvas() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = Math.max(1, Math.floor(root.clientWidth));
|
||||
const h = Math.max(1, Math.floor(root.clientHeight));
|
||||
if (canvas.width === Math.floor(w * dpr) && canvas.height === Math.floor(h * dpr)) return false;
|
||||
canvas.width = Math.floor(w * dpr);
|
||||
canvas.height = Math.floor(h * dpr);
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
painted = false;
|
||||
return true;
|
||||
}
|
||||
sizeCanvas();
|
||||
|
||||
function norm(db, calibrated) {
|
||||
const lo = calibrated ? 30 : -100;
|
||||
const hi = calibrated ? 110 : -10;
|
||||
return Math.max(0, Math.min(1, (db - lo) / (hi - lo)));
|
||||
}
|
||||
|
||||
function heat(v) {
|
||||
const stops = [[10, 12, 20], [30, 90, 150], [90, 180, 140], [230, 190, 80], [220, 80, 60]];
|
||||
const x = Math.max(0, Math.min(0.999, v)) * (stops.length - 1);
|
||||
const i = Math.floor(x), t = x - i;
|
||||
const a = stops[i], b = stops[i + 1] || stops[i];
|
||||
return `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(a[2] + (b[2] - a[2]) * t)})`;
|
||||
}
|
||||
|
||||
return {
|
||||
render(payload) {
|
||||
sizeCanvas();
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
const bg = themeColor(root, '--panel', '#141414');
|
||||
const muted = themeColor(root, '--muted', '#888');
|
||||
const border = themeColor(root, '--border', '#333');
|
||||
|
||||
if (!payload || !payload.frame || !payload.frame.bands) {
|
||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||
ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
|
||||
ctx.fillText(payload?.error || 'Spectrum analysis is off', w / 2, h / 2);
|
||||
painted = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const s = moduleApi.instanceSettings;
|
||||
const want = Number(s.channel);
|
||||
const idx = Math.max(0, payload.frame.ch.findIndex((c) => c.i === want));
|
||||
const raw = payload.frame.bands[idx] || payload.frame.bands[0] || [];
|
||||
if (!raw.length) return;
|
||||
|
||||
const denom = Number(s.banding) || 3;
|
||||
const folded = foldBands(raw, payload.bandN0 || 0, payload.bandsPerOctave || 48, denom);
|
||||
const freqs = folded.freq;
|
||||
let values = folded.db;
|
||||
const grid = s.gridlines === true;
|
||||
const showLabels = s.labels !== false;
|
||||
const axisPad = showLabels || grid ? 13 : 0;
|
||||
|
||||
if (!scrolling) {
|
||||
// Damp the bars toward the measurement. Cosmetic: `values` is what
|
||||
// gets drawn, never what gets reported.
|
||||
const keep = SMOOTHING[s.smoothing] ?? SMOOTHING.medium;
|
||||
const key = `${denom}:${values.length}`;
|
||||
if (smoothedFor !== key || !smoothed || smoothed.length !== values.length) {
|
||||
smoothed = values.slice();
|
||||
smoothedFor = key;
|
||||
} else if (keep > 0) {
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
smoothed[i] = smoothed[i] * keep + values[i] * (1 - keep);
|
||||
}
|
||||
} else {
|
||||
smoothed = values.slice();
|
||||
}
|
||||
values = smoothed;
|
||||
|
||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||
const plotH = h - axisPad;
|
||||
const bw = w / values.length;
|
||||
|
||||
const xOf = (f) => (Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0])) * w;
|
||||
const labels = chooseLabels(freqs, xOf, w, 34);
|
||||
|
||||
if (grid) {
|
||||
ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.5;
|
||||
ctx.beginPath();
|
||||
for (const l of labels) {
|
||||
const x = Math.round(l.pos) + 0.5;
|
||||
ctx.moveTo(x, 0); ctx.lineTo(x, plotH);
|
||||
}
|
||||
for (let g = 0.2; g < 1; g += 0.2) {
|
||||
const y = Math.round(plotH * g) + 0.5;
|
||||
ctx.moveTo(0, y); ctx.lineTo(w, y);
|
||||
}
|
||||
ctx.stroke(); ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const v = norm(values[i], payload.calibrated);
|
||||
const bh = v * plotH;
|
||||
ctx.fillStyle = heat(v);
|
||||
ctx.fillRect(i * bw, plotH - bh, Math.max(1, bw - (bw > 3 ? 1 : 0)), bh);
|
||||
}
|
||||
|
||||
if (showLabels) {
|
||||
ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center';
|
||||
for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, h - 3);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── spectrogram ──
|
||||
const timeX = axis() === 'x';
|
||||
const colW = 2;
|
||||
const gutter = showLabels ? axisPad : 0;
|
||||
// Frequency occupies whichever axis time does not.
|
||||
const freqExtent = timeX ? h - gutter : w - gutter;
|
||||
const timeExtent = timeX ? w : h;
|
||||
|
||||
if (!painted) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); painted = true; }
|
||||
|
||||
// Source rect in device pixels, destination in CSS pixels: the context
|
||||
// carries a devicePixelRatio transform but the canvas as an image
|
||||
// source does not, and letting drawImage infer them scales the picture
|
||||
// up and pushes every column off the canvas.
|
||||
if (timeX) {
|
||||
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h);
|
||||
} else {
|
||||
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, -colW, w, h);
|
||||
}
|
||||
|
||||
const bandExtent = freqExtent / values.length;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
ctx.fillStyle = heat(norm(values[i], payload.calibrated));
|
||||
if (timeX) {
|
||||
// Low frequencies at the bottom, as every spectrogram has them.
|
||||
ctx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
|
||||
colW, Math.ceil(bandExtent) + 1);
|
||||
} else {
|
||||
// Low frequencies at the left.
|
||||
ctx.fillRect(gutter + i * bandExtent, timeExtent - colW,
|
||||
Math.ceil(bandExtent) + 1, colW);
|
||||
}
|
||||
}
|
||||
|
||||
if (grid || showLabels) {
|
||||
const posOf = (f) => {
|
||||
const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]);
|
||||
return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent;
|
||||
};
|
||||
const labels = chooseLabels(freqs, posOf, timeX ? freqExtent : w, timeX ? 16 : 34);
|
||||
ctx.fillStyle = bg;
|
||||
if (timeX) ctx.fillRect(0, h - gutter, w, gutter);
|
||||
else ctx.fillRect(0, 0, gutter, h);
|
||||
ctx.fillStyle = muted; ctx.font = '9px system-ui';
|
||||
ctx.strokeStyle = border; ctx.globalAlpha = grid ? 0.35 : 0; ctx.beginPath();
|
||||
for (const l of labels) {
|
||||
if (timeX) {
|
||||
const y = Math.round(l.pos) + 0.5;
|
||||
ctx.moveTo(0, y); ctx.lineTo(w, y);
|
||||
} else {
|
||||
const x = Math.round(l.pos) + 0.5;
|
||||
ctx.moveTo(x, 0); ctx.lineTo(x, h);
|
||||
}
|
||||
}
|
||||
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));
|
||||
else ctx.fillText(hzLabel(l.f), l.pos, h - 3);
|
||||
}
|
||||
}
|
||||
},
|
||||
resize() { sizeCanvas(); },
|
||||
destroy() { root.innerHTML = ''; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"id": "spl-meter",
|
||||
"name": "SPL Meter",
|
||||
"version": "0.3.1",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||
"permissions": [
|
||||
{
|
||||
"kind": "microphone",
|
||||
"reason": "Measuring sound level from an audio input on this machine"
|
||||
}
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 1
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 3,
|
||||
"h": 2
|
||||
},
|
||||
"configGroups": {
|
||||
"Input": {
|
||||
"toggle": "enabled"
|
||||
},
|
||||
"Measurement": {
|
||||
"columns": 2
|
||||
},
|
||||
"Calibration": {
|
||||
"collapsed": "whenSet",
|
||||
"help": "A sound level calibrator fits over the microphone capsule and emits a known level, almost always 94 dB at 1 kHz. Put it on, press Measure beside the channel, and copy the offset it reports into the box. Without this the meter still works, but it reads in dB relative to full scale and every tile says so."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"enabled": {
|
||||
"type": "switch",
|
||||
"label": "Sound level measurement",
|
||||
"group": "Input",
|
||||
"default": false
|
||||
},
|
||||
"device": {
|
||||
"type": "select",
|
||||
"label": "Audio device",
|
||||
"group": "Input",
|
||||
"showWhen": "enabled",
|
||||
"default": "",
|
||||
"optionsRoute": "/devices"
|
||||
},
|
||||
"channels": {
|
||||
"type": "string",
|
||||
"label": "Channels on that device (1-based, comma separated)",
|
||||
"group": "Input",
|
||||
"showWhen": "enabled",
|
||||
"default": "1",
|
||||
"help": "One meter per channel — \"1\" for a single measurement mic, \"1,2\" for a stereo pair."
|
||||
},
|
||||
"weighting": {
|
||||
"type": "select",
|
||||
"label": "Weighting",
|
||||
"group": "Measurement",
|
||||
"default": "A",
|
||||
"options": [
|
||||
{
|
||||
"value": "A",
|
||||
"label": "A — how loud it sounds"
|
||||
},
|
||||
{
|
||||
"value": "C",
|
||||
"label": "C — keeps the low end"
|
||||
},
|
||||
{
|
||||
"value": "Z",
|
||||
"label": "Z — no weighting at all"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bands": {
|
||||
"type": "switch",
|
||||
"label": "Spectrum analysis",
|
||||
"group": "Measurement",
|
||||
"default": true,
|
||||
"help": "1/48-octave bands for the RTA and spectrogram tiles, which each fold them to their own banding. Turn it off if you only ever show the number."
|
||||
},
|
||||
"calibration": {
|
||||
"type": "string",
|
||||
"label": "Offsets in dB, one per channel",
|
||||
"group": "Calibration",
|
||||
"default": "",
|
||||
"help": "Leave empty and every reading is dB relative to full scale, labelled dBFS so nobody mistakes it for real SPL."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* SPL Meter server entry.
|
||||
*
|
||||
* Owns one capture child (spl-listener.js) and fans its frames out to every
|
||||
* tile over SSE. Browsers never see the audio device, only the numbers.
|
||||
*
|
||||
* GET /stream → SSE, `state` events at the capture rate
|
||||
* GET /state → the latest frame, for an instant first paint
|
||||
* GET /devices → input devices, for the admin page's picker
|
||||
* GET /channels → configured channels, for the tile gear picker
|
||||
* POST /reset → clear Leq and peak hold
|
||||
* GET /measure → what a calibrator is reading right now, so the
|
||||
* admin page can tell the operator what to type
|
||||
*
|
||||
* Frames arrive 20×/s. That is the right rate for a meter to feel live and
|
||||
* the wrong rate to re-broadcast blindly, so the SSE fan-out drops a frame
|
||||
* whose numbers round to what the last one said.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { createSplListener } = require('./spl-listener');
|
||||
|
||||
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
||||
const CALIBRATOR_DB = 94;
|
||||
/** How long /measure averages for. Long enough to ride out a wobble, short
|
||||
enough that nobody standing at the microphone gets bored. */
|
||||
const MEASURE_MS = 3000;
|
||||
/** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */
|
||||
const PING_MS = 15000;
|
||||
|
||||
/** Set by init(), read by routes() — both are rebuilt together on re-init. */
|
||||
let state = null;
|
||||
|
||||
/* ── shared tile settings ───────────────────────────────────────────── */
|
||||
|
||||
const CHANNEL_FIELD = {
|
||||
type: 'select', label: 'Channel', default: '', optionsRoute: '/channels',
|
||||
};
|
||||
|
||||
/**
|
||||
* Averaging is a view choice, not a measurement one: the capture child
|
||||
* computes all four every frame and sends all four, so one dashboard on Fast
|
||||
* and another on Inf are reading the same measurement two ways.
|
||||
*/
|
||||
function averagingField() {
|
||||
return {
|
||||
type: 'select', label: 'Averaging', default: 'fast',
|
||||
options: [
|
||||
{ value: 'fast', label: 'Fast · 125 ms' },
|
||||
{ value: 'slow', label: 'Slow · 1 s' },
|
||||
{ value: 'inf', label: 'Inf · since reset' },
|
||||
{ value: 'none', label: 'None' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Banding folds the 1/48-octave bands on the wire down to something
|
||||
* readable. Coarser is an exact sum of finer, so this costs nothing and
|
||||
* disturbs no other tile.
|
||||
*/
|
||||
const BANDING_FIELD = {
|
||||
type: 'select', label: 'Banding', default: '3',
|
||||
options: [
|
||||
{ value: '3', label: '1/3 octave' },
|
||||
{ value: '6', label: '1/6 octave' },
|
||||
{ value: '12', label: '1/12 octave' },
|
||||
{ value: '24', label: '1/24 octave' },
|
||||
{ value: '48', label: '1/48 octave' },
|
||||
],
|
||||
};
|
||||
|
||||
function parseChannels(raw) {
|
||||
return String(raw || '1')
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 64);
|
||||
}
|
||||
|
||||
function parseCalibration(raw) {
|
||||
if (!String(raw || '').trim()) return [];
|
||||
return String(raw)
|
||||
.split(',')
|
||||
.map((s) => Number(s.trim()))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const channels = parseChannels(config.channels);
|
||||
const calibration = parseCalibration(config.calibration);
|
||||
const moduleDir = __dirname;
|
||||
|
||||
state = {
|
||||
channels,
|
||||
calibrated: calibration.length === channels.length && channels.length > 0,
|
||||
frame: null,
|
||||
meta: null,
|
||||
error: '',
|
||||
streams: new Set(),
|
||||
listener: null,
|
||||
lastSent: '',
|
||||
pinger: null,
|
||||
};
|
||||
|
||||
if (!config.enabled) {
|
||||
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
}
|
||||
if (!config.device) {
|
||||
state.error = 'No audio device chosen yet';
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
}
|
||||
if (!channels.length) {
|
||||
state.error = `Cannot read "${config.channels}" as a channel list — try 1, or 1,2`;
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
}
|
||||
if (calibration.length && calibration.length !== channels.length) {
|
||||
state.error = `${channels.length} channel(s) but ${calibration.length} calibration offset(s) — `
|
||||
+ 'give one per channel, or leave the offsets empty';
|
||||
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||
}
|
||||
|
||||
state.listener = createSplListener({
|
||||
device: config.device,
|
||||
channels,
|
||||
weighting: config.weighting || 'A',
|
||||
bands: config.bands !== false,
|
||||
calibration,
|
||||
moduleDir,
|
||||
log,
|
||||
onUpdate(update) {
|
||||
if (!state) return; // a frame in flight when the module unmounted
|
||||
if (update.error) {
|
||||
if (update.error !== state.error) log(update.error);
|
||||
state.error = update.error;
|
||||
state.frame = null;
|
||||
broadcast();
|
||||
return;
|
||||
}
|
||||
state.error = '';
|
||||
state.frame = update.frame;
|
||||
state.meta = update.meta;
|
||||
broadcast();
|
||||
},
|
||||
});
|
||||
|
||||
const mineStreams = state.streams;
|
||||
state.pinger = setInterval(() => {
|
||||
for (const res of mineStreams) {
|
||||
try { res.write(': ping\n\n'); } catch { mineStreams.delete(res); }
|
||||
}
|
||||
}, PING_MS);
|
||||
|
||||
return {
|
||||
stop() { teardown(); },
|
||||
health() {
|
||||
if (state.error) return { status: 'error', message: state.error };
|
||||
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
||||
const n = state.channels.length;
|
||||
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
|
||||
return state.calibrated
|
||||
? { status: 'ok', message: what }
|
||||
: { status: 'ok', message: `${what} — uncalibrated, reading dBFS` };
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Four tile kinds, each with only the settings that do something to it.
|
||||
*
|
||||
* An entry's own instanceSchema replaces the module's, which is the point:
|
||||
* an RTA has no use for an alarm threshold and a readout has no use for a
|
||||
* banding, and offering either is how a gear popover stops being read.
|
||||
*
|
||||
* Every setting here is a view choice. None of them reach the capture
|
||||
* child — it already sends every averaging and the finest banding, so two
|
||||
* dashboards can disagree about how to draw the same room.
|
||||
*/
|
||||
tiles() {
|
||||
return [
|
||||
{
|
||||
id: 'readout', name: 'SPL', description: 'The level, big',
|
||||
defaultSize: { w: 2, h: 1 }, minSize: { w: 1, h: 1 },
|
||||
instanceGroups: { Thresholds: { columns: 2 } },
|
||||
instanceSchema: {
|
||||
channel: CHANNEL_FIELD,
|
||||
averaging: averagingField(),
|
||||
metric: {
|
||||
type: 'select', label: 'Show', default: 'level',
|
||||
options: [
|
||||
{ value: 'level', label: 'Level' },
|
||||
{ value: 'leq1m', label: 'Leq 1 min' },
|
||||
{ value: 'leq10m', label: 'Leq 10 min' },
|
||||
{ value: 'peakC', label: 'Peak C' },
|
||||
],
|
||||
},
|
||||
caption: { type: 'switch', label: 'Caption', default: true },
|
||||
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
|
||||
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bars', name: 'SPL Levels', description: 'One bar per channel, with peak hold',
|
||||
defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 },
|
||||
instanceGroups: { Thresholds: { columns: 2 } },
|
||||
instanceSchema: {
|
||||
averaging: averagingField(),
|
||||
peakHold: { type: 'switch', label: 'Peak hold', default: true },
|
||||
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
|
||||
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rta', name: 'RTA', description: 'Spectrum now, at your choice of banding',
|
||||
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
|
||||
instanceSchema: {
|
||||
channel: CHANNEL_FIELD,
|
||||
averaging: averagingField(),
|
||||
banding: BANDING_FIELD,
|
||||
smoothing: {
|
||||
type: 'select', label: 'Smoothing', default: 'medium',
|
||||
options: [
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'heavy', label: 'Heavy' },
|
||||
],
|
||||
},
|
||||
labels: { type: 'switch', label: 'Labels', default: true },
|
||||
gridlines: { type: 'switch', label: 'Gridlines', default: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'spectrogram', name: 'Spectrogram', description: 'Spectrum over time',
|
||||
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
|
||||
instanceSchema: {
|
||||
channel: CHANNEL_FIELD,
|
||||
banding: BANDING_FIELD,
|
||||
labels: { type: 'switch', label: 'Labels', default: true },
|
||||
gridlines: { type: 'switch', label: 'Gridlines', default: false },
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
routes({ config, log }) {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
json(res, 200, snapshot());
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
try {
|
||||
res.write(`event: state\ndata: ${JSON.stringify(snapshot())}\n\n`);
|
||||
} catch { return; }
|
||||
// Hold this mount's set, not `state`. teardown() nulls `state` and
|
||||
// then ends every stream, which fires these close handlers — a
|
||||
// handler that reached through `state` threw on null and took the
|
||||
// whole server down with it, on any re-init with a browser
|
||||
// connected. Deleting from a set nobody reads any more is harmless.
|
||||
const streams = state.streams;
|
||||
streams.add(res);
|
||||
req.on('close', () => streams.delete(res));
|
||||
},
|
||||
|
||||
/** Device list for the admin page. Runs the capture tool's --list,
|
||||
which is the only thing that knows what CoreAudio can see. */
|
||||
'GET /devices': async (req, res) => {
|
||||
try {
|
||||
const devices = await listDevices();
|
||||
json(res, 200, {
|
||||
options: devices.map((d) => ({
|
||||
value: d.name,
|
||||
label: `${d.name} — ${d.channels} ch @ ${Math.round(d.sampleRate)} Hz`,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
log(`device list failed: ${err.message}`);
|
||||
json(res, 200, { options: [] }); // the saved value stays selectable
|
||||
}
|
||||
},
|
||||
|
||||
/** Channel list for a tile's gear popover. */
|
||||
'GET /channels': (req, res) => {
|
||||
json(res, 200, {
|
||||
options: state.channels.map((c) => ({ value: String(c), label: `Channel ${c}` })),
|
||||
});
|
||||
},
|
||||
|
||||
'POST /reset': (req, res) => {
|
||||
if (state.listener) state.listener.reset();
|
||||
json(res, 200, { ok: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* What the microphone is hearing right now, unweighted and
|
||||
* uncalibrated, plus the offset that would make it read 94 dB.
|
||||
*
|
||||
* The module cannot write its own admin config, so this does not save
|
||||
* anything — it hands back a number for the operator to paste into the
|
||||
* Calibration box. Honest, if one step longer than it should be.
|
||||
*/
|
||||
'GET /measure': (req, res) => {
|
||||
if (!state.listener || state.error) {
|
||||
json(res, 409, { error: state.error || 'not measuring' });
|
||||
return;
|
||||
}
|
||||
const wanted = Number(new URLSearchParams(req.search || '').get('channel'))
|
||||
|| state.channels[0];
|
||||
const samples = [];
|
||||
const started = Date.now();
|
||||
const tick = setInterval(() => {
|
||||
if (!state) { clearInterval(tick); return; } // module unmounted mid-measure
|
||||
const ch = state.frame && state.frame.ch.find((c) => c.i === wanted);
|
||||
if (ch && Number.isFinite(ch.dbfs) && ch.dbfs > -200) samples.push(ch.dbfs);
|
||||
if (Date.now() - started < MEASURE_MS) return;
|
||||
clearInterval(tick);
|
||||
if (samples.length < 5) {
|
||||
json(res, 200, {
|
||||
channel: wanted,
|
||||
error: 'No steady tone on that channel — check the calibrator is on and seated',
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Mean of the linear powers, not of the decibels: averaging dB
|
||||
// would quietly under-read a signal that wobbles at all.
|
||||
const power = samples.reduce((s, db) => s + 10 ** (db / 10), 0) / samples.length;
|
||||
const measured = 10 * Math.log10(power);
|
||||
json(res, 200, {
|
||||
channel: wanted,
|
||||
measured: Math.round(measured * 100) / 100,
|
||||
reference: CALIBRATOR_DB,
|
||||
offset: Math.round((CALIBRATOR_DB - measured) * 100) / 100,
|
||||
samples: samples.length,
|
||||
});
|
||||
}, 100);
|
||||
req.on('close', () => clearInterval(tick));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/* ── helpers ────────────────────────────────────────────────────────── */
|
||||
|
||||
function snapshot() {
|
||||
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null };
|
||||
return {
|
||||
error: state.error,
|
||||
calibrated: state.calibrated,
|
||||
channels: state.channels,
|
||||
weighting: state.meta ? state.meta.weighting : '',
|
||||
bandCentres: state.meta ? state.meta.bands : [],
|
||||
bandN0: state.meta ? state.meta.bandN0 : 0,
|
||||
bandsPerOctave: state.meta ? state.meta.bandsPerOctave : 48,
|
||||
frame: state.frame,
|
||||
};
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
if (!state) return; // a listener callback landing after teardown
|
||||
const payload = snapshot();
|
||||
const line = JSON.stringify(payload);
|
||||
if (line === state.lastSent) return; // nothing moved since last frame
|
||||
state.lastSent = line;
|
||||
for (const res of state.streams) {
|
||||
try { res.write(`event: state\ndata: ${line}\n\n`); } catch { state.streams.delete(res); }
|
||||
}
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
if (!state) return;
|
||||
if (state.listener) state.listener.stop();
|
||||
clearInterval(state.pinger);
|
||||
for (const res of state.streams) { try { res.end(); } catch { /* already gone */ } }
|
||||
state.streams.clear();
|
||||
state = null;
|
||||
}
|
||||
|
||||
function listDevices() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { execFile } = require('child_process');
|
||||
execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 },
|
||||
(err, stdout) => {
|
||||
if (err && !stdout) { reject(err); return; }
|
||||
try { resolve(JSON.parse(stdout).devices || []); }
|
||||
catch (e) { reject(e); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function json(res, code, body) {
|
||||
const text = JSON.stringify(body);
|
||||
res.writeHead(code, {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Content-Length': Buffer.byteLength(text),
|
||||
});
|
||||
res.end(text);
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
// spl-capture — calibrated sound-level measurement from a CoreAudio input.
|
||||
//
|
||||
// Sibling of the Timers module's ltc-capture: same device enumeration, same
|
||||
// "stdout is data, stderr is talk" contract, same one-file-no-dependencies
|
||||
// rule. The difference is where the work happens. LTC ships raw samples to
|
||||
// Node because the decode is cheap and single-channel; sound level is
|
||||
// filtering and FFTs over every channel at the device's rate, so the
|
||||
// measurement is done here and only the answers cross the pipe.
|
||||
//
|
||||
// Everything below uses Accelerate, which ships with macOS. No third-party
|
||||
// code, no package manager, ~2 s to build:
|
||||
//
|
||||
// swiftc -O -o modules/spl-meter/spl-capture \
|
||||
// modules/spl-meter/spl-capture.swift
|
||||
//
|
||||
// spl-capture --list
|
||||
// every input-capable device: name, input channels, sample rate
|
||||
// spl-capture --device "Dante Via 16 Channel" --channels 1,2 [--cal 0,0]
|
||||
// measures those 1-based channels and writes one JSON line per frame
|
||||
// to stdout, 20×/s. --cal takes one dB offset per channel, in the same
|
||||
// order, from the calibration route (94 dB reference minus what we
|
||||
// measured). With no --cal the numbers are dBFS-relative and the line
|
||||
// says calibrated:false, so nothing downstream can mistake them for
|
||||
// real SPL.
|
||||
//
|
||||
// Per channel, per frame: the level at EVERY averaging a dashboard might
|
||||
// ask for — fast (125 ms), slow (1 s), inf (Leq since reset) and none (this
|
||||
// block alone) — plus rolling 1- and 10-minute Leq, C- and Z-weighted peak,
|
||||
// raw dBFS and a clip flag. Optionally a 1/48-octave band set for the RTA
|
||||
// and spectrogram tiles, only when --bands is passed.
|
||||
//
|
||||
// Everything is sent every frame on purpose. Two dashboards watching the
|
||||
// same room may want different averaging and different banding, and neither
|
||||
// choice may disturb the other or restart capture. So the measurement is
|
||||
// made once, at the finest resolution anyone asked for, and each tile folds
|
||||
// it down to the view it wants. Nothing here is per-viewer state.
|
||||
//
|
||||
// Weighting filters are IEC 61672 pole/zero prototypes, bilinear-transformed
|
||||
// with the top section prewarped, then normalised to exactly 0 dB at 1 kHz.
|
||||
// Verified against the standard's table: within 0.15 dB from 31.5 Hz to
|
||||
// 4 kHz. The top octave is where the bilinear transform runs out of room —
|
||||
// at 48 kHz the 16 kHz point sits about 3 dB low. That is a rounding error
|
||||
// on a broadband programme number (A-weighting has already thrown 6.6 dB
|
||||
// away up there) but it is a real limit, and the fix if it ever matters is
|
||||
// to run the weighting at 96 kHz. `--selftest` prints the table so this
|
||||
// claim can be checked rather than believed.
|
||||
//
|
||||
// macOS asks for microphone permission on first use, and the prompt belongs
|
||||
// to whatever launched this — the ProdDash launcher, or Terminal. A server
|
||||
// started over SSH gets SILENT ZEROS from every input with no error at all,
|
||||
// which shows up downstream as a meter pinned at the floor.
|
||||
|
||||
import AVFoundation
|
||||
import Accelerate
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
func fail(_ msg: String) -> Never {
|
||||
FileHandle.standardError.write(("spl-capture: " + msg + "\n").data(using: .utf8)!)
|
||||
exit(2)
|
||||
}
|
||||
|
||||
func note(_ msg: String) {
|
||||
FileHandle.standardError.write(("spl-capture: " + msg + "\n").data(using: .utf8)!)
|
||||
}
|
||||
|
||||
/* ── CoreAudio device enumeration ───────────────────────────────────── */
|
||||
|
||||
struct InputDevice {
|
||||
let id: AudioDeviceID
|
||||
let name: String
|
||||
let uid: String
|
||||
let channels: Int
|
||||
let sampleRate: Double
|
||||
}
|
||||
|
||||
func stringProperty(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var ref: Unmanaged<CFString>? = nil
|
||||
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
||||
let err = withUnsafeMutablePointer(to: &ref) {
|
||||
AudioObjectGetPropertyData(id, &addr, 0, nil, &size, $0)
|
||||
}
|
||||
guard err == noErr, let value = ref?.takeRetainedValue() else { return "" }
|
||||
return value as String
|
||||
}
|
||||
|
||||
func sampleRateProperty(_ id: AudioObjectID) -> Double {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyNominalSampleRate,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var rate: Float64 = 0
|
||||
var size = UInt32(MemoryLayout<Float64>.size)
|
||||
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, &rate) == noErr else { return 0 }
|
||||
return rate
|
||||
}
|
||||
|
||||
func inputChannelCount(_ id: AudioDeviceID) -> Int {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||
mScope: kAudioDevicePropertyScopeInput,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var size: UInt32 = 0
|
||||
guard AudioObjectGetPropertyDataSize(id, &addr, 0, nil, &size) == noErr, size > 0 else { return 0 }
|
||||
let listPtr = UnsafeMutableRawPointer.allocate(
|
||||
byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||
defer { listPtr.deallocate() }
|
||||
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, listPtr) == noErr else { return 0 }
|
||||
let buffers = UnsafeMutableAudioBufferListPointer(
|
||||
listPtr.assumingMemoryBound(to: AudioBufferList.self))
|
||||
return buffers.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||
}
|
||||
|
||||
func listInputDevices() -> [InputDevice] {
|
||||
var addr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDevices,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
var size: UInt32 = 0
|
||||
guard AudioObjectGetPropertyDataSize(
|
||||
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size) == noErr else { return [] }
|
||||
var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
|
||||
guard AudioObjectGetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids) == noErr else { return [] }
|
||||
|
||||
var out: [InputDevice] = []
|
||||
for id in ids {
|
||||
let channels = inputChannelCount(id)
|
||||
if channels == 0 { continue }
|
||||
out.append(InputDevice(id: id,
|
||||
name: stringProperty(id, kAudioObjectPropertyName),
|
||||
uid: stringProperty(id, kAudioDevicePropertyDeviceUID),
|
||||
channels: channels,
|
||||
sampleRate: sampleRateProperty(id)))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/* ── frequency weighting (IEC 61672) ────────────────────────────────── */
|
||||
|
||||
/// Pole frequencies from the standard.
|
||||
private let wf1 = 2 * Double.pi * 20.598997
|
||||
private let wf2 = 2 * Double.pi * 107.65265
|
||||
private let wf3 = 2 * Double.pi * 737.86223
|
||||
private let wf4 = 2 * Double.pi * 12194.217
|
||||
|
||||
/// Bilinear-transform one analogue biquad into [b0, b1, b2, a1, a2].
|
||||
///
|
||||
/// `warpAt` prewarps the transform so that section's critical frequency
|
||||
/// lands where the analogue prototype put it. Without it the plain
|
||||
/// `c = 2·fs` mapping squeezes the top octave badly — it is worth about
|
||||
/// 3 dB at 16 kHz for the 12.2 kHz pole pair.
|
||||
func bilinear(_ b: (Double, Double, Double), _ a: (Double, Double, Double),
|
||||
fs: Double, warpAt: Double = 0) -> [Double] {
|
||||
let c = warpAt > 0 ? warpAt / tan(warpAt / (2 * fs)) : 2 * fs
|
||||
let c2 = c * c
|
||||
let B0 = b.0 * c2 + b.1 * c + b.2
|
||||
let B1 = 2 * b.2 - 2 * b.0 * c2
|
||||
let B2 = b.0 * c2 - b.1 * c + b.2
|
||||
let A0 = a.0 * c2 + a.1 * c + a.2
|
||||
let A1 = 2 * a.2 - 2 * a.0 * c2
|
||||
let A2 = a.0 * c2 - a.1 * c + a.2
|
||||
return [B0 / A0, B1 / A0, B2 / A0, A1 / A0, A2 / A0]
|
||||
}
|
||||
|
||||
/// |H(f)| of a cascade of biquad sections, used to normalise gain at 1 kHz
|
||||
/// and to print the verification table.
|
||||
func cascadeResponse(_ secs: [Double], _ f: Double, fs: Double) -> Double {
|
||||
let th = 2 * Double.pi * f / fs
|
||||
var reT = 1.0, imT = 0.0
|
||||
for s in stride(from: 0, to: secs.count, by: 5) {
|
||||
let (b0, b1, b2, a1, a2) = (secs[s], secs[s+1], secs[s+2], secs[s+3], secs[s+4])
|
||||
let nr = b0 + b1 * cos(th) + b2 * cos(2 * th)
|
||||
let ni = -(b1 * sin(th) + b2 * sin(2 * th))
|
||||
let dr = 1 + a1 * cos(th) + a2 * cos(2 * th)
|
||||
let di = -(a1 * sin(th) + a2 * sin(2 * th))
|
||||
let dd = dr * dr + di * di
|
||||
guard dd > 0 else { return 0 }
|
||||
let qr = (nr * dr + ni * di) / dd, qi = (ni * dr - nr * di) / dd
|
||||
let t = reT * qr - imT * qi
|
||||
imT = reT * qi + imT * qr
|
||||
reT = t
|
||||
}
|
||||
return (reT * reT + imT * imT).squareRoot()
|
||||
}
|
||||
|
||||
enum Weighting: String {
|
||||
case a = "A", c = "C", z = "Z"
|
||||
|
||||
/// Biquad sections for this weighting at `fs`, normalised to 0 dB at 1 kHz.
|
||||
func sections(fs: Double) -> [Double] {
|
||||
var s: [Double]
|
||||
switch self {
|
||||
case .a:
|
||||
s = bilinear((1, 0, 0), (1, 2 * wf1, wf1 * wf1), fs: fs)
|
||||
+ bilinear((1, 0, 0), (1, wf2 + wf3, wf2 * wf3), fs: fs)
|
||||
+ bilinear((0, 0, 1), (1, 2 * wf4, wf4 * wf4), fs: fs, warpAt: wf4)
|
||||
case .c:
|
||||
s = bilinear((1, 0, 0), (1, 2 * wf1, wf1 * wf1), fs: fs)
|
||||
+ bilinear((0, 0, 1), (1, 2 * wf4, wf4 * wf4), fs: fs, warpAt: wf4)
|
||||
case .z:
|
||||
return [1, 0, 0, 0, 0] // flat, one pass-through section
|
||||
}
|
||||
let g = 1.0 / cascadeResponse(s, 1000.0, fs: fs)
|
||||
s[0] *= g; s[1] *= g; s[2] *= g
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
/// A weighting filter bound to one channel. The coefficients could be shared
|
||||
/// but the delay line cannot, and a setup per channel costs a few hundred
|
||||
/// bytes — not worth the aliasing risk of sharing one.
|
||||
final class WeightingFilter {
|
||||
private let setup: vDSP_biquad_Setup
|
||||
private var delay: [Float]
|
||||
private let sections: vDSP_Length
|
||||
|
||||
init(_ weighting: Weighting, fs: Double) {
|
||||
let coeffs = weighting.sections(fs: fs)
|
||||
sections = vDSP_Length(coeffs.count / 5)
|
||||
guard let s = vDSP_biquad_CreateSetup(coeffs, sections) else {
|
||||
fail("cannot build \(weighting.rawValue)-weighting filter")
|
||||
}
|
||||
setup = s
|
||||
delay = [Float](repeating: 0, count: Int(sections) * 2 + 2)
|
||||
}
|
||||
|
||||
deinit { vDSP_biquad_DestroySetup(setup) }
|
||||
|
||||
func process(_ input: UnsafePointer<Float>, _ output: inout [Float], _ n: Int) {
|
||||
vDSP_biquad(setup, &delay, input, 1, &output, 1, vDSP_Length(n))
|
||||
}
|
||||
}
|
||||
|
||||
/* ── third-octave band plan for the RTA ─────────────────────────────── */
|
||||
|
||||
/// The wire always carries 1/48-octave bands, and a tile folds them down to
|
||||
/// whatever banding it wants to draw.
|
||||
///
|
||||
/// That works exactly because every banding the dashboard offers divides 48:
|
||||
/// 1/3 is sixteen of these summed, 1/6 is eight, 1/12 four, 1/24 two. Powers
|
||||
/// are additive, so a fold is a sum and nothing is approximated. The
|
||||
/// alternative — the capture re-banding on request — would mean one dashboard
|
||||
/// choosing 1/6 changes what every other dashboard sees.
|
||||
///
|
||||
/// Band n is centred on 1000 · 2^(n/48); n = 0 is 1 kHz, which is what keeps
|
||||
/// the folded centres on the standard values.
|
||||
let bandN0 = -271 // ≈ 20 Hz
|
||||
let bandN1 = 207 // ≈ 20 kHz
|
||||
let bandCentres: [Double] = (bandN0...bandN1).map { 1000 * pow(2, Double($0) / 48) }
|
||||
|
||||
/// FFT bin ranges per band, clamped to what the transform can actually
|
||||
/// resolve.
|
||||
///
|
||||
/// This is the one caveat a reader of the RTA needs and the gear is not
|
||||
/// allowed to carry (module rule 5, no explanatory text in a tile's
|
||||
/// settings): below roughly 200 Hz a 1/48-octave band is narrower than one
|
||||
/// bin, so neighbouring bands start sharing bins and the low end reads
|
||||
/// smoother than it really is. That is the window length talking, not the
|
||||
/// room. Folding to 1/6 or 1/3 puts it right, and a multi-resolution
|
||||
/// transform is the real fix if the bottom octaves ever have to be read at
|
||||
/// full resolution.
|
||||
func bandBins(fs: Double, fftSize: Int) -> [(Int, Int)] {
|
||||
let binHz = fs / Double(fftSize)
|
||||
let maxBin = fftSize / 2 - 1
|
||||
return bandCentres.map { fc in
|
||||
let lo = Int((fc * pow(2, -1.0 / 96.0) / binHz).rounded())
|
||||
let hi = Int((fc * pow(2, 1.0 / 96.0) / binHz).rounded())
|
||||
return (max(1, min(lo, maxBin)), max(1, min(max(hi, lo), maxBin)))
|
||||
}
|
||||
}
|
||||
|
||||
/* ── per-channel measurement state ──────────────────────────────────── */
|
||||
|
||||
/// Everything the standard asks for on one channel.
|
||||
///
|
||||
/// SPL is an exponential moving average of weighted mean square — Fast is a
|
||||
/// 125 ms time constant, Slow 1 s, exactly as IEC 61672 defines them. Leq is
|
||||
/// a plain energy average with no time constant at all, which is why it is
|
||||
/// accumulated separately rather than read off the integrator.
|
||||
final class ChannelMeter {
|
||||
let index: Int // 1-based, as the operator counts them
|
||||
private let fs: Double
|
||||
private let calibration: Double // dB added to turn dBFS into dB SPL
|
||||
|
||||
private let weightFilter: WeightingFilter
|
||||
private let cFilter: WeightingFilter
|
||||
private var weighted: [Float]
|
||||
private var cWeighted: [Float]
|
||||
|
||||
// One integrator per averaging the dashboard might ask for. They all run
|
||||
// all the time and all go on the wire: a tile choosing "Slow" must not
|
||||
// restart capture, because the next tile wants "Fast" off the same
|
||||
// stream and a third wants Inf. Four extra doubles per channel is a
|
||||
// rounding error next to the filtering; re-measuring per viewer is not.
|
||||
private var msFast: Double = 0
|
||||
private var msSlow: Double = 0
|
||||
private var msNone: Double = 0 // this block only, no time constant
|
||||
private var flatMeanSquare: Double = 0 // unweighted, Fast — for dBFS
|
||||
private let alphaFast: Double
|
||||
private let alphaSlow: Double
|
||||
|
||||
// Leq accumulators: running since reset, plus rolling 1 and 10 minute.
|
||||
private var leqSum: Double = 0
|
||||
private var leqCount: Double = 0
|
||||
private var ring: [Double] = [] // one mean-square per emitted frame
|
||||
private let ringCapacity: Int
|
||||
|
||||
private var peakC: Double = 0 // linear, since reset
|
||||
private var peakZ: Double = 0
|
||||
private var clipped = false
|
||||
|
||||
init(index: Int, fs: Double, weighting: Weighting, calibration: Double,
|
||||
framesPerSecond: Double) {
|
||||
self.index = index
|
||||
self.fs = fs
|
||||
self.calibration = calibration
|
||||
weightFilter = WeightingFilter(weighting, fs: fs)
|
||||
cFilter = WeightingFilter(.c, fs: fs)
|
||||
weighted = []
|
||||
cWeighted = []
|
||||
// The time constants IEC 61672 names: Fast is 125 ms, Slow is 1 s.
|
||||
alphaFast = 1.0 - exp(-1.0 / (0.125 * fs))
|
||||
alphaSlow = 1.0 - exp(-1.0 / (1.0 * fs))
|
||||
ringCapacity = Int((600.0 * framesPerSecond).rounded()) // 10 minutes
|
||||
}
|
||||
|
||||
/// Feed one tap buffer for this channel.
|
||||
func push(_ samples: UnsafePointer<Float>, _ n: Int) {
|
||||
if weighted.count < n {
|
||||
weighted = [Float](repeating: 0, count: n)
|
||||
cWeighted = [Float](repeating: 0, count: n)
|
||||
}
|
||||
weightFilter.process(samples, &weighted, n)
|
||||
cFilter.process(samples, &cWeighted, n)
|
||||
|
||||
// Exponential integration over the weighted signal, sample by sample
|
||||
// in the power domain. vDSP has no one-pole on squares, and at 48 kHz
|
||||
// this is a few hundred thousand multiply-adds a second — noise next
|
||||
// to the filtering above.
|
||||
var fast = msFast, slow = msSlow, flat = flatMeanSquare
|
||||
weighted.withUnsafeBufferPointer { w in
|
||||
for i in 0..<n {
|
||||
let v = Double(w[i]) * Double(w[i])
|
||||
fast += alphaFast * (v - fast)
|
||||
slow += alphaSlow * (v - slow)
|
||||
let r = Double(samples[i])
|
||||
flat += alphaFast * (r * r - flat)
|
||||
}
|
||||
}
|
||||
msFast = fast; msSlow = slow; flatMeanSquare = flat
|
||||
|
||||
// Mean square of just this block — "None", the reading with no time
|
||||
// constant at all. Also what Leq accumulates, since Leq is a plain
|
||||
// energy average and has no time constant either.
|
||||
var sumSq: Float = 0
|
||||
weighted.withUnsafeBufferPointer { w in
|
||||
vDSP_measqv(w.baseAddress!, 1, &sumSq, vDSP_Length(n))
|
||||
}
|
||||
msNone = Double(sumSq)
|
||||
leqSum += Double(sumSq) * Double(n)
|
||||
leqCount += Double(n)
|
||||
|
||||
var cPeak: Float = 0, zPeak: Float = 0
|
||||
cWeighted.withUnsafeBufferPointer { c in
|
||||
vDSP_maxmgv(c.baseAddress!, 1, &cPeak, vDSP_Length(n))
|
||||
}
|
||||
vDSP_maxmgv(samples, 1, &zPeak, vDSP_Length(n))
|
||||
peakC = max(peakC, Double(cPeak))
|
||||
peakZ = max(peakZ, Double(zPeak))
|
||||
if zPeak >= 0.999 { clipped = true }
|
||||
}
|
||||
|
||||
/// dB SPL from a linear mean-square, with the calibration offset applied.
|
||||
private func db(_ power: Double) -> Double {
|
||||
20 * log10(max(power, 1e-20).squareRoot()) + calibration
|
||||
}
|
||||
|
||||
/// Called once per emitted frame; also advances the rolling Leq window.
|
||||
///
|
||||
/// Returns a JSON object built by hand. JSONSerialization renders a
|
||||
/// rounded Double as `-70.120000000000005`, which is both ugly on the
|
||||
/// wire and noise in a diff — `%.2f` says what a meter can actually
|
||||
/// resolve and nothing more.
|
||||
func snapshot() -> String {
|
||||
ring.append(msNone)
|
||||
if ring.count > ringCapacity { ring.removeFirst(ring.count - ringCapacity) }
|
||||
|
||||
let fps = Double(ringCapacity) / 600.0
|
||||
func rollingLeq(seconds: Double) -> Double {
|
||||
let want = max(1, Int((seconds * fps).rounded()))
|
||||
let slice = ring.suffix(want)
|
||||
guard !slice.isEmpty else { return -200 }
|
||||
return db(slice.reduce(0, +) / Double(slice.count))
|
||||
}
|
||||
|
||||
return "{\"i\":\(index)"
|
||||
+ f("fast", db(msFast))
|
||||
+ f("slow", db(msSlow))
|
||||
+ f("inf", leqCount > 0 ? db(leqSum / leqCount) : -200)
|
||||
+ f("none", db(msNone))
|
||||
+ f("leq1m", rollingLeq(seconds: 60))
|
||||
+ f("leq10m", rollingLeq(seconds: 600))
|
||||
+ f("peakC", db(peakC * peakC))
|
||||
+ f("peakZ", db(peakZ * peakZ))
|
||||
+ f("dbfs", 20 * log10(max(flatMeanSquare, 1e-20).squareRoot()))
|
||||
+ ",\"clip\":\(clipped)}"
|
||||
}
|
||||
|
||||
/// Clears Leq, peaks and the clip flag. The exponential integrator is
|
||||
/// deliberately left alone — it is a live reading, not an accumulation,
|
||||
/// and zeroing it would just make the meter dive for a moment.
|
||||
func reset() {
|
||||
leqSum = 0; leqCount = 0; ring.removeAll(keepingCapacity: true)
|
||||
peakC = 0; peakZ = 0; clipped = false
|
||||
}
|
||||
}
|
||||
|
||||
/// `,"key":-12.34` — two decimals, and a floor instead of -inf or NaN so a
|
||||
/// silent input can never put a non-number on the wire.
|
||||
func f(_ key: String, _ v: Double) -> String {
|
||||
",\"\(key)\":" + num(v)
|
||||
}
|
||||
|
||||
func num(_ v: Double) -> String {
|
||||
guard v.isFinite else { return "-200.00" }
|
||||
return String(format: "%.2f", max(v, -200))
|
||||
}
|
||||
|
||||
/// One decimal, for band arrays. 479 bands per channel per frame is the
|
||||
/// bulk of the wire; a second decimal on a bar nobody can read that finely
|
||||
/// is 15% of the bandwidth for nothing.
|
||||
func num1(_ v: Double) -> String {
|
||||
guard v.isFinite else { return "-200" }
|
||||
return String(format: "%.1f", max(v, -200))
|
||||
}
|
||||
|
||||
/* ── spectrum for the RTA / spectrograph tiles ──────────────────────── */
|
||||
|
||||
final class Spectrum {
|
||||
private let fftSize: Int
|
||||
private let log2n: vDSP_Length
|
||||
private let setup: FFTSetup
|
||||
private var window: [Float]
|
||||
private var accum: [Float] // sliding input buffer
|
||||
private var filled = 0
|
||||
private let bins: [(Int, Int)]
|
||||
private let windowPower: Double
|
||||
|
||||
init(fs: Double, fftSize: Int) {
|
||||
self.fftSize = fftSize
|
||||
log2n = vDSP_Length(log2(Double(fftSize)).rounded())
|
||||
guard let s = vDSP_create_fftsetup(log2n, FFTRadix(kFFTRadix2)) else {
|
||||
fail("cannot create FFT setup")
|
||||
}
|
||||
setup = s
|
||||
window = [Float](repeating: 0, count: fftSize)
|
||||
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_DENORM))
|
||||
accum = [Float](repeating: 0, count: fftSize)
|
||||
bins = bandBins(fs: fs, fftSize: fftSize)
|
||||
// Coherent gain of the Hann window, so band levels are not 6 dB low.
|
||||
windowPower = window.reduce(0.0) { $0 + Double($1) * Double($1) } / Double(fftSize)
|
||||
}
|
||||
|
||||
deinit { vDSP_destroy_fftsetup(setup) }
|
||||
|
||||
/// Keep the most recent `fftSize` samples, shifting the window along.
|
||||
/// Buffers larger than the window just overwrite it outright.
|
||||
func push(_ samples: UnsafePointer<Float>, _ n: Int) {
|
||||
if n >= fftSize {
|
||||
accum.withUnsafeMutableBufferPointer { a in
|
||||
a.baseAddress!.update(from: samples + (n - fftSize), count: fftSize)
|
||||
}
|
||||
filled = fftSize
|
||||
return
|
||||
}
|
||||
accum.withUnsafeMutableBufferPointer { a in
|
||||
let base = a.baseAddress!
|
||||
// Slide the old tail down, then append the new block.
|
||||
base.update(from: base + n, count: fftSize - n)
|
||||
(base + fftSize - n).update(from: samples, count: n)
|
||||
}
|
||||
filled = min(fftSize, filled + n)
|
||||
}
|
||||
|
||||
/// Third-octave band levels in dB relative to full scale, plus the
|
||||
/// calibration offset applied by the caller.
|
||||
func bands(calibration: Double) -> [Double] {
|
||||
guard filled >= fftSize else { return bins.map { _ in -200 } }
|
||||
var windowed = [Float](repeating: 0, count: fftSize)
|
||||
vDSP_vmul(accum, 1, window, 1, &windowed, 1, vDSP_Length(fftSize))
|
||||
|
||||
let half = fftSize / 2
|
||||
var re = [Float](repeating: 0, count: half)
|
||||
var im = [Float](repeating: 0, count: half)
|
||||
var mag = [Float](repeating: 0, count: half)
|
||||
re.withUnsafeMutableBufferPointer { rp in
|
||||
im.withUnsafeMutableBufferPointer { ip in
|
||||
var split = DSPSplitComplex(realp: rp.baseAddress!, imagp: ip.baseAddress!)
|
||||
windowed.withUnsafeBytes { raw in
|
||||
vDSP_ctoz(raw.bindMemory(to: DSPComplex.self).baseAddress!, 2,
|
||||
&split, 1, vDSP_Length(half))
|
||||
}
|
||||
vDSP_fft_zrip(setup, &split, 1, log2n, FFTDirection(FFT_FORWARD))
|
||||
vDSP_zvmags(&split, 1, &mag, 1, vDSP_Length(half))
|
||||
}
|
||||
}
|
||||
// vDSP's real FFT carries a factor of 2, and zvmags squares it.
|
||||
let scale = 1.0 / (4.0 * Double(fftSize) * Double(fftSize) * windowPower)
|
||||
return bins.map { (lo, hi) in
|
||||
var power = 0.0
|
||||
for b in lo...hi { power += Double(mag[b]) }
|
||||
power *= scale * 2.0 // one-sided spectrum
|
||||
return 10 * log10(max(power, 1e-20)) + calibration
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── args ───────────────────────────────────────────────────────────── */
|
||||
|
||||
var wantList = false
|
||||
var wantSelftest = false
|
||||
var wantDevice: String? = nil
|
||||
var wantChannels: [Int] = [1]
|
||||
var wantCal: [Double] = []
|
||||
var wantWeighting = Weighting.a
|
||||
var wantBands = false
|
||||
var framesPerSecond = 20.0
|
||||
|
||||
let argv = CommandLine.arguments
|
||||
var argIdx = 1
|
||||
func nextArg(_ flag: String) -> String {
|
||||
argIdx += 1
|
||||
guard argIdx < argv.count else { fail("\(flag) needs a value") }
|
||||
return argv[argIdx]
|
||||
}
|
||||
while argIdx < argv.count {
|
||||
switch argv[argIdx] {
|
||||
case "--list": wantList = true
|
||||
case "--selftest": wantSelftest = true
|
||||
case "--device": wantDevice = nextArg("--device")
|
||||
case "--channels":
|
||||
wantChannels = nextArg("--channels").split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
|
||||
if wantChannels.isEmpty { fail("--channels needs at least one 1-based channel number") }
|
||||
case "--cal":
|
||||
wantCal = nextArg("--cal").split(separator: ",").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }
|
||||
case "--weighting":
|
||||
let raw = nextArg("--weighting").uppercased()
|
||||
guard let w = Weighting(rawValue: raw) else { fail("--weighting must be A, C or Z") }
|
||||
wantWeighting = w
|
||||
case "--bands": wantBands = true
|
||||
case "--rate":
|
||||
guard let r = Double(nextArg("--rate")), r >= 1, r <= 50 else { fail("--rate must be 1-50 (frames per second)") }
|
||||
framesPerSecond = r
|
||||
default: fail("unknown flag \(argv[argIdx]) — try --list")
|
||||
}
|
||||
argIdx += 1
|
||||
}
|
||||
|
||||
/* ── selftest: prove the weighting curves before trusting a reading ─── */
|
||||
|
||||
if wantSelftest {
|
||||
// IEC 61672-1 table 3, nominal A- and C-weighting.
|
||||
let table: [(Double, Double, Double)] = [
|
||||
(31.5, -39.4, -3.0), (63, -26.2, -0.8), (125, -16.1, -0.2), (250, -8.6, 0.0),
|
||||
(500, -3.2, 0.0), (1000, 0.0, 0.0), (2000, 1.2, -0.2), (4000, 1.0, -0.8),
|
||||
(8000, -1.1, -3.0), (16000, -6.6, -8.5),
|
||||
]
|
||||
for fs in [48000.0, 96000.0] {
|
||||
let a = Weighting.a.sections(fs: fs), c = Weighting.c.sections(fs: fs)
|
||||
print("— \(Int(fs)) Hz —")
|
||||
print(" Hz A IEC err C IEC err")
|
||||
var worstA = 0.0, worstC = 0.0
|
||||
for (f, wantA, wantC) in table where f < fs / 2 {
|
||||
let gotA = 20 * log10(cascadeResponse(a, f, fs: fs))
|
||||
let gotC = 20 * log10(cascadeResponse(c, f, fs: fs))
|
||||
worstA = max(worstA, abs(gotA - wantA)); worstC = max(worstC, abs(gotC - wantC))
|
||||
print(String(format: "%7.0f %+7.2f %+7.2f %+6.2f %+7.2f %+7.2f %+6.2f",
|
||||
f, gotA, wantA, gotA - wantA, gotC, wantC, gotC - wantC))
|
||||
}
|
||||
print(String(format: " worst: A %.2f dB, C %.2f dB\n", worstA, worstC))
|
||||
}
|
||||
exit(0)
|
||||
}
|
||||
|
||||
/* ── device selection ───────────────────────────────────────────────── */
|
||||
|
||||
let devices = listInputDevices()
|
||||
|
||||
if wantList || wantDevice == nil {
|
||||
if devices.isEmpty { fail("no input-capable audio devices found") }
|
||||
// stdout, as JSON: the module's /devices route feeds this straight to
|
||||
// the admin page's device picker.
|
||||
let payload = devices.map { ["name": $0.name, "uid": $0.uid,
|
||||
"channels": $0.channels, "sampleRate": $0.sampleRate] }
|
||||
let data = try! JSONSerialization.data(withJSONObject: ["devices": payload])
|
||||
FileHandle.standardOutput.write(data)
|
||||
FileHandle.standardOutput.write("\n".data(using: .utf8)!)
|
||||
if wantDevice == nil && !wantList { note("pick one with --device <name substring>") }
|
||||
exit(wantList ? 0 : 2)
|
||||
}
|
||||
|
||||
let query = wantDevice!.lowercased()
|
||||
let exact = devices.filter { $0.name.lowercased() == query || $0.uid.lowercased() == query }
|
||||
let matches = exact.count == 1 ? exact
|
||||
: devices.filter { $0.name.lowercased().contains(query) || $0.uid.lowercased().contains(query) }
|
||||
guard matches.count == 1 else {
|
||||
if matches.isEmpty { fail("no input device matches \"\(wantDevice!)\" — see --list") }
|
||||
fail("\"\(wantDevice!)\" is ambiguous: " + matches.map { $0.name }.joined(separator: ", "))
|
||||
}
|
||||
let device = matches[0]
|
||||
|
||||
/* ── capture ────────────────────────────────────────────────────────── */
|
||||
|
||||
let engine = AVAudioEngine()
|
||||
guard let unit = engine.inputNode.audioUnit else { fail("no input audio unit") }
|
||||
var deviceID = device.id
|
||||
let setErr = AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice,
|
||||
kAudioUnitScope_Global, 0, &deviceID,
|
||||
UInt32(MemoryLayout<AudioDeviceID>.size))
|
||||
guard setErr == noErr else { fail("cannot select \"\(device.name)\" (error \(setErr))") }
|
||||
|
||||
let format = engine.inputNode.inputFormat(forBus: 0)
|
||||
let channelCount = Int(format.channelCount)
|
||||
let sampleRate = format.sampleRate
|
||||
guard channelCount > 0, sampleRate > 0 else {
|
||||
fail("\"\(device.name)\" reports no usable input format — microphone access may be denied "
|
||||
+ "(System Settings → Privacy & Security → Microphone)")
|
||||
}
|
||||
for ch in wantChannels where ch < 1 || ch > channelCount {
|
||||
fail("channel \(ch) is out of range — \"\(device.name)\" has \(channelCount)")
|
||||
}
|
||||
|
||||
let calibrated = !wantCal.isEmpty
|
||||
if calibrated && wantCal.count != wantChannels.count {
|
||||
fail("--cal needs one offset per channel (\(wantChannels.count) given \(wantCal.count))")
|
||||
}
|
||||
|
||||
let meters = wantChannels.enumerated().map { (i, ch) in
|
||||
ChannelMeter(index: ch, fs: sampleRate, weighting: wantWeighting,
|
||||
calibration: calibrated ? wantCal[i] : 0, framesPerSecond: framesPerSecond)
|
||||
}
|
||||
let spectra = wantBands ? wantChannels.map { _ in Spectrum(fs: sampleRate, fftSize: 16384) } : []
|
||||
|
||||
note("capturing \"\(device.name)\": \(channelCount) channels @ \(Int(sampleRate)) Hz, "
|
||||
+ "measuring \(wantChannels.map(String.init).joined(separator: ",")) "
|
||||
+ "\(wantWeighting.rawValue)-weighted"
|
||||
+ (calibrated ? "" : ", UNCALIBRATED"))
|
||||
|
||||
// Announced once so the reader knows the band plan without hardcoding it.
|
||||
let hello: [String: Any] = [
|
||||
"hello": 1,
|
||||
"device": device.name,
|
||||
"sampleRate": sampleRate,
|
||||
"deviceChannels": channelCount,
|
||||
"channels": wantChannels,
|
||||
"weighting": wantWeighting.rawValue,
|
||||
"calibrated": calibrated,
|
||||
// Centres and the index of the 1 kHz band, so a tile can fold to any
|
||||
// banding without hardcoding the plan.
|
||||
"bands": wantBands ? bandCentres.map { round($0 * 100) / 100 } : [],
|
||||
"bandN0": bandN0,
|
||||
"bandsPerOctave": 48,
|
||||
]
|
||||
let stdoutHandle = FileHandle.standardOutput
|
||||
func emitLine(_ json: String) {
|
||||
do {
|
||||
try stdoutHandle.write(contentsOf: Data((json + "\n").utf8))
|
||||
} catch { exit(0) } // downstream pipe closed — nothing to say
|
||||
}
|
||||
if let data = try? JSONSerialization.data(withJSONObject: hello),
|
||||
let line = String(data: data, encoding: .utf8) {
|
||||
emitLine(line)
|
||||
}
|
||||
|
||||
// A reset is asked for on stdin (one line, "reset") rather than a signal, so
|
||||
// the listener can clear Leq and peaks without restarting capture and losing
|
||||
// the filters' settling.
|
||||
let resetQueue = DispatchQueue(label: "spl-capture.reset")
|
||||
var resetRequested = false
|
||||
DispatchQueue.global().async {
|
||||
while let line = readLine(strippingNewline: true) {
|
||||
if line.trimmingCharacters(in: .whitespaces).lowercased() == "reset" {
|
||||
resetQueue.sync { resetRequested = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tapQueue = DispatchQueue(label: "spl-capture.tap")
|
||||
engine.inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { buffer, _ in
|
||||
let frames = Int(buffer.frameLength)
|
||||
guard frames > 0, let floats = buffer.floatChannelData else { return }
|
||||
tapQueue.sync {
|
||||
for (i, ch) in wantChannels.enumerated() {
|
||||
let src = floats[ch - 1] // tap data is per-channel, not interleaved
|
||||
meters[i].push(src, frames)
|
||||
if wantBands { spectra[i].push(src, frames) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do { try engine.start() } catch {
|
||||
fail("engine start failed: \(error.localizedDescription) — if this is a permission problem, "
|
||||
+ "approve microphone access for the launching app and retry")
|
||||
}
|
||||
|
||||
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
|
||||
timer.schedule(deadline: .now() + 1.0 / framesPerSecond,
|
||||
repeating: 1.0 / framesPerSecond)
|
||||
timer.setEventHandler {
|
||||
var wantReset = false
|
||||
resetQueue.sync { wantReset = resetRequested; resetRequested = false }
|
||||
|
||||
var line = "{\"t\":\(Int(Date().timeIntervalSince1970 * 1000))"
|
||||
tapQueue.sync {
|
||||
if wantReset { meters.forEach { $0.reset() } }
|
||||
line += ",\"ch\":[" + meters.map { $0.snapshot() }.joined(separator: ",") + "]"
|
||||
if wantBands {
|
||||
let sets = spectra.enumerated().map { (i, s) -> String in
|
||||
"[" + s.bands(calibration: calibrated ? wantCal[i] : 0)
|
||||
.map(num1).joined(separator: ",") + "]"
|
||||
}
|
||||
line += ",\"bands\":[" + sets.joined(separator: ",") + "]"
|
||||
}
|
||||
}
|
||||
emitLine(line + "}")
|
||||
}
|
||||
timer.resume()
|
||||
|
||||
signal(SIGINT) { _ in exit(0) }
|
||||
signal(SIGTERM) { _ in exit(0) }
|
||||
RunLoop.main.run()
|
||||
@@ -0,0 +1,239 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Sound level listener — owns the native capture child.
|
||||
*
|
||||
* Spawns spl-capture (compiled from spl-capture.swift beside it) for the
|
||||
* admin-configured device and channels, reads its JSON lines, and hands the
|
||||
* latest frame upward. All measurement happens in the child; this file is
|
||||
* plumbing and supervision, nothing more.
|
||||
*
|
||||
* Reports via onUpdate:
|
||||
*
|
||||
* { frame: {...}, meta: {...} } measuring
|
||||
* { error: '<what is wrong and how to fix it>' } not measuring
|
||||
*
|
||||
* The capture binary is per-machine and gitignored, exactly like the Timers
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* bare launchd job gets SILENT ZEROS from every input, with no error
|
||||
* anywhere. Here that shows up as a meter pinned at -200. Launch from a
|
||||
* logged-in GUI context and approve the microphone prompt once.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const RESTART_MS = 5000;
|
||||
/** 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. */
|
||||
const BAD_CONFIG_RETRY_MS = 30000;
|
||||
/** No frame for this long means the child is alive but not delivering. */
|
||||
const STALL_MS = 3000;
|
||||
|
||||
/**
|
||||
* @param {{device:string, channels:number[], weighting:string,
|
||||
* bands:boolean, calibration:number[], moduleDir:string,
|
||||
* log:Function, onUpdate:(u:object)=>void}} opts
|
||||
* @returns {{stop:()=>void, reset:()=>void}}
|
||||
*/
|
||||
function createSplListener(opts) {
|
||||
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
|
||||
const bin = path.join(moduleDir, 'spl-capture');
|
||||
|
||||
let child = null;
|
||||
let stopped = false;
|
||||
let restartTimer = null;
|
||||
let stallTimer = null;
|
||||
let startedAt = 0;
|
||||
let meta = null;
|
||||
let building = false;
|
||||
let buildFailed = false;
|
||||
let lastFrameWall = 0;
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
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 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(); }
|
||||
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) {
|
||||
if (stopped || restartTimer) return;
|
||||
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
|
||||
}
|
||||
|
||||
function armStallWatch() {
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = setTimeout(() => {
|
||||
if (stopped || !child) return;
|
||||
if (Date.now() - lastFrameWall >= STALL_MS) {
|
||||
log('no frames for 3 s — restarting capture');
|
||||
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
||||
}
|
||||
}, STALL_MS);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (stopped) return;
|
||||
|
||||
const missing = ensureBinary();
|
||||
if (missing) {
|
||||
onUpdate({ error: missing });
|
||||
// A build in flight calls start() itself when it lands; anything else
|
||||
// is config-shaped and should back off hard rather than spin.
|
||||
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
// No averaging or banding flag: the child computes every averaging and
|
||||
// the finest banding regardless, so tiles can differ without a restart.
|
||||
const args = ['--device', device, '--channels', channels.join(','),
|
||||
'--weighting', weighting, '--rate', '20'];
|
||||
if (bands) args.push('--bands');
|
||||
if (calibration.length === channels.length) args.push('--cal', calibration.join(','));
|
||||
|
||||
startedAt = Date.now();
|
||||
meta = null;
|
||||
let stderrTail = '';
|
||||
let buf = '';
|
||||
|
||||
child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stderr.on('data', (text) => {
|
||||
stderrTail = (stderrTail + text).slice(-500);
|
||||
});
|
||||
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stdout.on('data', (text) => {
|
||||
buf += text;
|
||||
let i;
|
||||
while ((i = buf.indexOf('\n')) >= 0) {
|
||||
const line = buf.slice(0, i).trim();
|
||||
buf = buf.slice(i + 1);
|
||||
if (!line) continue;
|
||||
let obj;
|
||||
try { obj = JSON.parse(line); } catch { continue; }
|
||||
if (obj.hello) {
|
||||
meta = obj;
|
||||
log(`measuring ${obj.device} ch ${obj.channels.join(',')} `
|
||||
+ `${obj.weighting}-weighted`
|
||||
+ (obj.calibrated ? '' : ' (uncalibrated)'));
|
||||
continue;
|
||||
}
|
||||
if (!obj.ch) continue;
|
||||
lastFrameWall = Date.now();
|
||||
armStallWatch();
|
||||
onUpdate({ frame: obj, meta });
|
||||
}
|
||||
// A run of junk with no newline would grow forever; the child never
|
||||
// emits a line this long, so anything that does is a bug upstream.
|
||||
if (buf.length > 65536) buf = '';
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
onUpdate({ error: `cannot run spl-capture: ${err.message}` });
|
||||
child = null;
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
child = null;
|
||||
clearTimeout(stallTimer);
|
||||
if (stopped) return;
|
||||
const ranFor = Date.now() - startedAt;
|
||||
const why = stderrTail.trim().split('\n').pop() || `exit ${code}${signal ? ' ' + signal : ''}`;
|
||||
// Exiting almost immediately means it never got as far as capturing —
|
||||
// an unknown device, an out-of-range channel, a denied microphone.
|
||||
// Those do not fix themselves, so back off hard and say why.
|
||||
if (ranFor < 2000) {
|
||||
onUpdate({ error: why });
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
} else {
|
||||
log(`capture exited after ${Math.round(ranFor / 1000)}s: ${why}`);
|
||||
scheduleRestart(RESTART_MS);
|
||||
}
|
||||
});
|
||||
|
||||
armStallWatch();
|
||||
}
|
||||
|
||||
start();
|
||||
|
||||
return {
|
||||
/** Clear Leq and peak hold without restarting capture — the filters keep
|
||||
their settling, so the live number never dips. */
|
||||
reset() {
|
||||
if (child && child.stdin.writable) {
|
||||
try { child.stdin.write('reset\n'); } catch { /* child going away */ }
|
||||
}
|
||||
},
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(restartTimer); restartTimer = null;
|
||||
clearTimeout(stallTimer); stallTimer = null;
|
||||
if (child) {
|
||||
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
||||
child = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSplListener };
|
||||
@@ -0,0 +1,148 @@
|
||||
/* SPL Meter — every colour comes from the shell's variables so themes and
|
||||
per-tile tints reach in. Nothing here is hardcoded except the canvas
|
||||
heat ramp, which client.js owns because it has to interpolate. */
|
||||
|
||||
[data-module="spl-meter"] .spl {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── readout ──────────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="spl-meter"] .spl-readout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25em;
|
||||
min-height: 0;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.2em;
|
||||
line-height: 1;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* A first-paint size only. client.js measures the text and sets the real
|
||||
one inline, because CSS has to assume a digit count and gets it wrong the
|
||||
moment the reading needs a sixth character: the coefficient that fills a
|
||||
tile for "-45.9" pushes "-100.5" straight out through the side. */
|
||||
[data-module="spl-meter"] .spl-num {
|
||||
font-size: clamp(1.25rem, min(26cqw, 60cqh), 100rem);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-unit {
|
||||
font-size: clamp(0.65rem, min(7cqw, 15cqh), 26rem);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-sub {
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-readout[data-level="warn"] .spl-num { color: var(--warn); }
|
||||
[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); }
|
||||
|
||||
/* ── level bars ───────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="spl-meter"] .spl-bars {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4em 1fr 3.4em;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar-label { color: var(--muted); text-align: right; }
|
||||
|
||||
[data-module="spl-meter"] .spl-bar-track {
|
||||
position: relative;
|
||||
height: 0.65rem;
|
||||
border-radius: 0.35rem;
|
||||
background: color-mix(in srgb, var(--border) 60%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar-fill {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 0;
|
||||
border-radius: inherit;
|
||||
background: var(--accent);
|
||||
transition: width 60ms linear;
|
||||
}
|
||||
|
||||
[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="idle"] .spl-bar-fill { background: var(--muted); }
|
||||
|
||||
/* Peak hold sits on the track rather than inside the fill, so it stays
|
||||
visible when the bar falls away underneath it. */
|
||||
[data-module="spl-meter"] .spl-bar-peak {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--text);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar-value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-bar.clip .spl-bar-value { color: var(--danger); }
|
||||
|
||||
/* ── canvas tiles ─────────────────────────────────────────────────── */
|
||||
|
||||
[data-module="spl-meter"] .spl-canvas {
|
||||
flex: 1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* The readout scales off both of the tile's dimensions, so the container
|
||||
has to be sized on both axes — inline-size alone leaves cqh undefined. */
|
||||
[data-module="spl-meter"] .spl { container-type: size; }
|
||||
|
||||
/* The sub-line is the first thing to go when the tile gets short: the
|
||||
number is the point, and a clipped caption under it is worse than none. */
|
||||
[data-module="spl-meter"] .spl-sub {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@container (max-height: 90px) {
|
||||
[data-module="spl-meter"] .spl-sub { display: none; }
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proddash",
|
||||
"version": "1.6.0",
|
||||
"version": "1.8.0",
|
||||
"private": true,
|
||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
+30
-8
@@ -451,6 +451,36 @@ a.btn { text-decoration: none; }
|
||||
|
||||
/* one swatch per preset: a miniature dashboard (page, a tile on it, the
|
||||
accent as a status dot) and the name; the current one wears the accent */
|
||||
/* ── a collapsible admin section ─────────────────────────────────────
|
||||
The pattern for any section of this page that folds. The markup is a
|
||||
<details class="admin-fold"> holding the section's contents, headed by a
|
||||
<summary class="admin-h1 admin-fold-title">: the title keeps the same
|
||||
type as every other section heading — a <summary> would otherwise come
|
||||
out at the normal weight — and the caret follows the words, so a folded
|
||||
section still reads as a heading first. The whole row is the hit target. */
|
||||
details.admin-fold > summary.admin-fold-title {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700; /* matches h1.admin-h1; summary defaults to normal */
|
||||
user-select: none;
|
||||
}
|
||||
details.admin-fold > summary.admin-fold-title::-webkit-details-marker { display: none; }
|
||||
/* after the title, not before it — the heading reads first, the caret says
|
||||
there is more behind it */
|
||||
details.admin-fold > summary.admin-fold-title::after {
|
||||
content: "";
|
||||
width: 0; height: 0;
|
||||
border-left: 6px solid var(--muted);
|
||||
border-top: 5px solid transparent;
|
||||
border-bottom: 5px solid transparent;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
details.admin-fold[open] > summary.admin-fold-title::after { transform: rotate(90deg); }
|
||||
details.admin-fold > summary.admin-fold-title:hover { color: var(--accent); }
|
||||
|
||||
.theme-swatches {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(116px, 1fr));
|
||||
@@ -550,11 +580,3 @@ a.btn { text-decoration: none; }
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.pin-note { margin-top: 12px; }
|
||||
.pin-note code {
|
||||
font-size: 0.92em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
+19
-2
@@ -132,7 +132,6 @@ function renderPin(state) {
|
||||
: set
|
||||
? 'Admin is locked. This page asks for the PIN, and so does every admin request.'
|
||||
: 'Admin is open to anyone on the network. Set a 6-digit PIN to lock it.';
|
||||
if (state.dataDir) document.getElementById('pin-file').textContent = state.dataDir + '/proddash.json';
|
||||
}
|
||||
|
||||
function pinMessage(text, bad = false) {
|
||||
@@ -910,7 +909,25 @@ async function loadLayouts() {
|
||||
else toast(e.message, true);
|
||||
}
|
||||
});
|
||||
row.append(name, meta, rename, del);
|
||||
// The copy the last save replaced, one back. Restoring swaps the two,
|
||||
// so a restore is itself one restore away from being undone.
|
||||
const restore = document.createElement('button');
|
||||
restore.className = 'btn';
|
||||
restore.textContent = 'Restore previous';
|
||||
restore.title = 'Put back the copy the last save replaced';
|
||||
restore.hidden = !layout.hasPrevious;
|
||||
restore.addEventListener('click', async () => {
|
||||
if (!confirm(`Go back to the previous version of “${layout.name}”? The current one becomes its previous version, so this can be undone the same way.`)) return;
|
||||
try {
|
||||
await api(`/api/layouts/${encodeURIComponent(layout.name)}/restore`, { method: 'POST', body: '{}' });
|
||||
toast('Previous version restored');
|
||||
loadLayouts();
|
||||
} catch (e) {
|
||||
if (e.authRequired) showLogin();
|
||||
else toast(e.message, true);
|
||||
}
|
||||
});
|
||||
row.append(name, meta, restore, rename, del);
|
||||
layoutsList.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,11 @@
|
||||
</section>
|
||||
|
||||
<section id="theme-section" hidden>
|
||||
<h1 class="admin-h1">Theme</h1>
|
||||
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
|
||||
<div class="admin-status" id="theme-status"></div>
|
||||
<details class="admin-fold">
|
||||
<summary class="admin-h1 admin-fold-title">Theme</summary>
|
||||
<div class="theme-swatches" id="theme-swatches" role="radiogroup" aria-label="Theme"></div>
|
||||
<div class="admin-status" id="theme-status"></div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section id="modules-section" hidden>
|
||||
@@ -92,10 +94,6 @@
|
||||
<button class="btn" id="pin-clear" hidden>Turn off</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="admin-copy pin-note">Only this page and the admin API ask for it — dashboards stay
|
||||
open, and it is nothing to do with a module's own PIN, such as Slack's send PIN. Forgotten?
|
||||
Remove <code>adminPin</code> from <code id="pin-file">proddash.json</code> in the settings
|
||||
folder and restart.</p>
|
||||
<div class="admin-status" id="pin-status"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+187
-11
@@ -16,12 +16,24 @@ const toastEl = document.getElementById('toast');
|
||||
/* ── tiny helpers ───────────────────────────────────────────────────── */
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(msg, isError = false) {
|
||||
function toast(msg, isError = false, { action = null, ms } = {}) {
|
||||
toastEl.textContent = msg;
|
||||
if (action) {
|
||||
// One button, one verb — "Undo" — for a change worth taking back.
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = action.label;
|
||||
btn.addEventListener('click', () => {
|
||||
clearTimeout(toastTimer);
|
||||
toastEl.hidden = true;
|
||||
action.onClick();
|
||||
});
|
||||
toastEl.appendChild(btn);
|
||||
}
|
||||
toastEl.classList.toggle('error', isError);
|
||||
toastEl.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200);
|
||||
toastTimer = setTimeout(() => { toastEl.hidden = true; }, ms ?? (action ? 8000 : 3200));
|
||||
}
|
||||
|
||||
const autoHideBtn = document.getElementById('autohide-btn');
|
||||
@@ -70,6 +82,41 @@ function promptModal({ title, placeholder = '', value = '', okLabel = 'Save' })
|
||||
});
|
||||
}
|
||||
|
||||
/** A yes-or-no question in the same window: resolves true for the OK button. */
|
||||
function confirmModal({ title, body = '', okLabel = 'OK' }) {
|
||||
return new Promise((resolve) => {
|
||||
modalEl.innerHTML = '';
|
||||
const h = document.createElement('h2');
|
||||
h.textContent = title;
|
||||
const copy = document.createElement('p');
|
||||
copy.className = 'modal-copy';
|
||||
copy.textContent = body;
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'actions';
|
||||
const cancel = document.createElement('button');
|
||||
cancel.className = 'btn';
|
||||
cancel.textContent = 'Cancel';
|
||||
const ok = document.createElement('button');
|
||||
ok.className = 'btn primary';
|
||||
ok.textContent = okLabel;
|
||||
actions.append(cancel, ok);
|
||||
modalEl.append(h, copy, actions);
|
||||
modalBackdrop.hidden = false;
|
||||
ok.focus();
|
||||
const done = (result) => {
|
||||
modalBackdrop.hidden = true;
|
||||
modalEl.innerHTML = '';
|
||||
resolve(result);
|
||||
};
|
||||
cancel.addEventListener('click', () => done(false));
|
||||
ok.addEventListener('click', () => done(true));
|
||||
modalEl.addEventListener('keydown', (e) => { if (e.key === 'Escape') done(false); }, { once: true });
|
||||
modalBackdrop.addEventListener('click', (e) => {
|
||||
if (e.target === modalBackdrop) done(false);
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/* ── dropdown menus ─────────────────────────────────────────────────── */
|
||||
|
||||
/* A menu opens the moment its button is clicked, drawn from what the page
|
||||
@@ -169,17 +216,35 @@ const tileEls = new Map(); // tile id -> { el, body, dot, titleEl, instance }
|
||||
away regardless; this is the windowed equivalent, asked for by hand. */
|
||||
let autoHideHeads = false;
|
||||
|
||||
/* Beside the layout itself: the copy it replaced (restored if the layout
|
||||
ever fails to parse) and, after loading a named layout, the arrangement
|
||||
that was on screen before (what Undo goes back to). */
|
||||
const LS_LAYOUT_BACKUP = LS_LAYOUT + '.backup';
|
||||
const LS_LAYOUT_PREVIOUS = LS_LAYOUT + '.previous';
|
||||
|
||||
function saveLayout() {
|
||||
try {
|
||||
localStorage.setItem(LS_LAYOUT, JSON.stringify({ v: 1, autoHideHeads, tiles }));
|
||||
const next = JSON.stringify({ v: 1, autoHideHeads, tiles });
|
||||
const current = localStorage.getItem(LS_LAYOUT);
|
||||
if (current && current !== next) localStorage.setItem(LS_LAYOUT_BACKUP, current);
|
||||
localStorage.setItem(LS_LAYOUT, next);
|
||||
} catch { /* private mode / full — layout just won't persist */ }
|
||||
}
|
||||
|
||||
function loadLayoutFromStorage() {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(LS_LAYOUT) || 'null');
|
||||
if (raw && Array.isArray(raw.tiles)) return raw;
|
||||
} catch { /* corrupted — start fresh */ }
|
||||
for (const key of [LS_LAYOUT, LS_LAYOUT_BACKUP]) {
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem(key) || 'null');
|
||||
if (raw && Array.isArray(raw.tiles)) {
|
||||
// A layout that would not parse is not a reason for an empty wall.
|
||||
if (key === LS_LAYOUT_BACKUP) {
|
||||
toast('This browser’s layout was unreadable — the last good copy is back', true);
|
||||
try { localStorage.setItem(LS_LAYOUT, JSON.stringify(raw)); } catch { /* fine */ }
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
} catch { /* unreadable — try the copy behind it */ }
|
||||
}
|
||||
return { tiles: [] };
|
||||
}
|
||||
|
||||
@@ -301,6 +366,72 @@ const CLOSE_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
||||
const PIN_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16.5V22"/><path class="pin-head" d="M8.5 3h7l-1.2 7 3.2 3.2H6.5L9.7 10 8.5 3z"/></svg>';
|
||||
const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true"><path d="M 82.94 32.538 c -2.685 0 -5.093 -1.545 -6.327 -3.929 c -0.245 -0.476 -0.504 -0.948 -0.774 -1.416 c -0.27 -0.468 -0.55 -0.928 -0.84 -1.378 c -1.448 -2.26 -1.582 -5.117 -0.239 -7.443 c 1.951 -3.378 0.792 -7.696 -2.584 -9.645 l -9.352 -5.4 c -3.378 -1.951 -7.696 -0.792 -9.645 2.584 l -0.002 0.003 c -1.322 2.291 -3.815 3.641 -6.456 3.512 c -1.149 -0.056 -2.301 -0.057 -3.455 0 c -2.638 0.128 -5.124 -1.226 -6.444 -3.513 l -0.002 -0.002 c -1.951 -3.378 -6.269 -4.535 -9.645 -2.584 l -9.352 5.4 c -3.378 1.951 -4.535 6.269 -2.584 9.645 l 0.002 0.002 c 1.321 2.286 1.25 5.117 -0.18 7.336 c -0.625 0.972 -1.201 1.97 -1.726 2.993 c -1.209 2.353 -3.625 3.836 -6.269 3.836 H 7.063 c -3.9 0 -7.061 3.162 -7.061 7.061 v 10.801 c 0 3.9 3.162 7.061 7.061 7.061 l 0 0 c 2.685 0 5.093 1.545 6.327 3.929 c 0.245 0.475 0.504 0.948 0.774 1.416 c 0.27 0.468 0.55 0.928 0.84 1.378 c 1.448 2.26 1.582 5.117 0.239 7.443 c -1.951 3.378 -0.792 7.696 2.584 9.645 l 9.352 5.4 c 3.378 1.951 7.696 0.792 9.645 -2.584 l 0.002 -0.003 c 1.322 -2.291 3.815 -3.641 6.456 -3.512 c 1.149 0.056 2.301 0.057 3.455 0 c 2.638 -0.128 5.124 1.226 6.444 3.513 l 0.002 0.002 c 1.951 3.378 6.268 4.535 9.645 2.584 l 9.352 -5.4 c 3.378 -1.949 4.535 -6.268 2.584 -9.645 l -0.002 -0.002 c -1.321 -2.286 -1.25 -5.117 0.18 -7.336 c 0.625 -0.972 1.201 -1.97 1.726 -2.993 c 1.209 -2.353 3.625 -3.836 6.269 -3.836 h 0.003 c 3.9 0 7.061 -3.162 7.061 -7.061 V 39.599 C 90.001 35.7 86.84 32.538 82.94 32.538 z M 54.998 62.318 c -9.565 5.523 -21.796 2.245 -27.317 -7.32 c -5.523 -9.565 -2.245 -21.796 7.32 -27.317 s 21.796 -2.245 27.317 7.32 C 67.841 44.566 64.563 56.797 54.998 62.318 z"/></svg>';
|
||||
|
||||
/* ── what a saved tile can be shown as ─────────────────────────────────
|
||||
A layout keeps every tile it was given; the board shows the ones whose
|
||||
module is installed and enabled and — for a tile added from a picker
|
||||
entry — whose entry (or an alias of it) the module offers right now. The
|
||||
rest keep their place and draw nothing, and appear the moment the
|
||||
registry offers them again: a Service timer waits for PCO Plan, a
|
||||
transcript for its channel. Never the module's default in their place —
|
||||
a wall of "All timers" where five different timers were is worse than
|
||||
five gaps that fill themselves in. */
|
||||
|
||||
function tileIsOffered(tile) {
|
||||
const man = registry.get(tile.module);
|
||||
if (!man) return false;
|
||||
const variant = String(tile.variant || '');
|
||||
if (!variant) return true; // the plain module tile — the module says what that means
|
||||
const entries = Array.isArray(man.tiles) ? man.tiles : [];
|
||||
// No list at all is a module whose tiles() did not answer in time (the
|
||||
// server falls back to the classic single entry): mount, and let it sort
|
||||
// its own variant out rather than blank the board over a slow answer.
|
||||
if (!entries.length) return true;
|
||||
return entries.some((e) => String(e.id) === variant || (Array.isArray(e.aliases) && e.aliases.includes(variant)));
|
||||
}
|
||||
|
||||
/** Show tiles that have become offered; fold away tiles that have stopped being. */
|
||||
function reconcileOfferedTiles() {
|
||||
let changed = false;
|
||||
for (const tile of tiles) {
|
||||
const shown = tileEls.has(tile.id);
|
||||
const offered = tileIsOffered(tile);
|
||||
if (offered && !shown) {
|
||||
buildTile(tile);
|
||||
changed = true;
|
||||
} else if (!offered && shown) {
|
||||
unmountModule(tile);
|
||||
tileEls.get(tile.id)?.el.remove();
|
||||
tileEls.delete(tile.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) rebuildDividers();
|
||||
scheduleOfferedRecheck();
|
||||
}
|
||||
|
||||
/* A module's entry list can change without anyone saving admin config: PCO
|
||||
Plan comes back and, a few seconds later, the timers module is offering
|
||||
its timers again. No event marks that moment, so while any tile is folded
|
||||
away the registry is re-read every few seconds until nothing is. It costs
|
||||
one GET while something is hidden and nothing at all otherwise. */
|
||||
const OFFERED_RECHECK_MS = 6000;
|
||||
let offeredRecheck = 0;
|
||||
function scheduleOfferedRecheck() {
|
||||
clearTimeout(offeredRecheck);
|
||||
if (!tiles.some((t) => !tileEls.has(t.id))) return;
|
||||
offeredRecheck = setTimeout(async () => {
|
||||
offeredRecheck = 0;
|
||||
if (await loadRegistry()) reconcileOfferedTiles();
|
||||
else scheduleOfferedRecheck();
|
||||
}, OFFERED_RECHECK_MS);
|
||||
}
|
||||
|
||||
/** The tiles a layout holds that the board is not showing, by name. */
|
||||
function unshownTileNames() {
|
||||
return tiles.filter((t) => !tileEls.has(t.id))
|
||||
.map((t) => t.title || registry.get(t.module)?.name || t.module);
|
||||
}
|
||||
|
||||
function buildTile(tile) {
|
||||
const man = registry.get(tile.module);
|
||||
|
||||
@@ -741,6 +872,7 @@ function rebuildDividers() {
|
||||
for (const L of tiles) {
|
||||
for (const R of tiles) {
|
||||
if (L === R || L.x + L.w !== R.x) continue;
|
||||
if (!tileEls.has(L.id) || !tileEls.has(R.id)) continue;
|
||||
const top = Math.max(L.y, R.y);
|
||||
const bottom = Math.min(L.y + L.h, R.y + R.h);
|
||||
if (bottom <= top) continue;
|
||||
@@ -1861,7 +1993,8 @@ function renderLayout(layout) {
|
||||
clearAllTiles();
|
||||
tiles = list.map(sanitizeTile);
|
||||
if (!Array.isArray(layout)) setAutoHideHeads(Boolean(layout?.autoHideHeads), { save: false });
|
||||
for (const tile of tiles) buildTile(tile);
|
||||
for (const tile of tiles) if (tileIsOffered(tile)) buildTile(tile);
|
||||
scheduleOfferedRecheck();
|
||||
refreshEmptyState();
|
||||
rebuildDividers();
|
||||
if (document.body.classList.contains('fullscreen')) applyFullscreenFit();
|
||||
@@ -1937,6 +2070,21 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
});
|
||||
if (!name) return;
|
||||
try {
|
||||
// Saving over a name is asked about first — and the copy it replaces
|
||||
// is kept one back on the server, so a yes is not the end of it either.
|
||||
const existing = await fetch('/api/layouts')
|
||||
.then((r) => (r.ok ? r.json() : { layouts: [] }))
|
||||
.then((b) => (b.layouts || []).find((l) => l.name === name))
|
||||
.catch(() => null);
|
||||
if (existing) {
|
||||
const when = existing.updated ? new Date(existing.updated).toLocaleString() : 'earlier';
|
||||
const go = await confirmModal({
|
||||
title: `Replace “${name}”?`,
|
||||
body: `It was saved ${when} with ${existing.tiles} tile${existing.tiles === 1 ? '' : 's'}. The copy it replaces is kept as its previous version.`,
|
||||
okLabel: 'Replace',
|
||||
});
|
||||
if (!go) return;
|
||||
}
|
||||
const res = await fetch('/api/layouts/' + encodeURIComponent(name), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1944,7 +2092,9 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error || 'Save failed (' + res.status + ')');
|
||||
toast(`Saved “${name}” — any browser can load it now`);
|
||||
toast(body.replaced
|
||||
? `Replaced “${name}” — the earlier copy is kept as its previous version`
|
||||
: `Saved “${name}” — any browser can load it now`);
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
@@ -1984,9 +2134,31 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
if (!res.ok) throw new Error(body.error || 'Load failed (' + res.status + ')');
|
||||
// Loading copies the layout into this browser's own state — it
|
||||
// does not live-link browsers together.
|
||||
let outgoing = null;
|
||||
try { outgoing = localStorage.getItem(LS_LAYOUT); } catch { /* fine */ }
|
||||
renderLayout(body.layout);
|
||||
saveLayout();
|
||||
toast(`Loaded “${l.name}”`);
|
||||
try { if (outgoing) localStorage.setItem(LS_LAYOUT_PREVIOUS, outgoing); } catch { /* fine */ }
|
||||
const unshown = unshownTileNames();
|
||||
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
|
||||
const note = unshown.length
|
||||
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
|
||||
: '';
|
||||
toast(`Loaded “${l.name}”: ${count}${note}`, false, {
|
||||
ms: unshown.length ? 10000 : 7000,
|
||||
action: outgoing ? {
|
||||
label: 'Undo',
|
||||
onClick() {
|
||||
try {
|
||||
renderLayout(JSON.parse(outgoing));
|
||||
saveLayout();
|
||||
toast('Back to the arrangement you had');
|
||||
} catch {
|
||||
toast('Could not go back', true);
|
||||
}
|
||||
},
|
||||
} : null,
|
||||
});
|
||||
} catch (e) {
|
||||
toast(e.message, true);
|
||||
}
|
||||
@@ -2188,7 +2360,7 @@ function subscribeEvents() {
|
||||
// Rides on the page's one stream connection (see sseSubscribe) as `shell:`.
|
||||
sseSubscribe('/api/events', {
|
||||
events: {
|
||||
'modules-changed': () => handleModulesChanged(),
|
||||
'modules-changed': () => handleModulesChanged().then(reconcileOfferedTiles),
|
||||
theme: handleThemeEvent,
|
||||
},
|
||||
});
|
||||
@@ -2270,6 +2442,10 @@ async function boot() {
|
||||
}
|
||||
renderLayout(loadLayoutFromStorage());
|
||||
subscribeEvents();
|
||||
// Entries a module builds at runtime (a timer per discovered source) can
|
||||
// land a beat after the first registry read; look once more shortly after
|
||||
// boot so a tile hidden only by that timing appears without a nudge.
|
||||
setTimeout(async () => { if (await loadRegistry()) reconcileOfferedTiles(); }, 5000);
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
+119
-2
@@ -906,6 +906,24 @@ body.fullscreen #grid { padding-bottom: 0; }
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
#toast[hidden] { display: none; }
|
||||
#toast button {
|
||||
margin-left: 12px;
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 5px;
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
#toast button:hover { background: var(--accent); color: var(--accent-text); }
|
||||
#modal .modal-copy {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin: 6px 0 14px;
|
||||
}
|
||||
#toast.error { border-color: var(--danger); }
|
||||
|
||||
/* ── scrollbars ────────────────────────────────────────────────────── */
|
||||
@@ -941,8 +959,10 @@ body.fullscreen #grid { padding-bottom: 0; }
|
||||
every shell element follows, and a colour a tile is configured with (set
|
||||
on the tile itself) still wins. The :root block at the top of this file is
|
||||
"booth", the default; the list of ids and names lives in server.js
|
||||
(GET /api/theme). All four are booth-grade: high contrast, the accent
|
||||
nowhere near the danger red or the warning amber, muted still readable. */
|
||||
(GET /api/theme). Every one is booth-grade: high contrast, the accent
|
||||
nowhere near the danger red or the warning amber, muted still readable.
|
||||
Each sets --accent-text too when the default (a near-black green, for
|
||||
booth's mint) would look wrong against its own accent. */
|
||||
|
||||
/* Harbor — deep navy, cyan accent */
|
||||
html[data-theme="harbor"] {
|
||||
@@ -993,3 +1013,100 @@ html[data-theme="daylight"] {
|
||||
--danger: #d1242f;
|
||||
--warn: #b26a00;
|
||||
}
|
||||
|
||||
/* Midnight — indigo night, violet accent */
|
||||
html[data-theme="midnight"] {
|
||||
--bg: #0a0b18;
|
||||
--panel: #14162b;
|
||||
--border: #272a4a;
|
||||
--text: #e8e9fb;
|
||||
--muted: #9a9cc8;
|
||||
--accent: #9d8cff;
|
||||
--accent-text: #12103a;
|
||||
--danger: #ff5a72;
|
||||
--warn: #f5b93a;
|
||||
}
|
||||
|
||||
/* Moss — forest floor, lime accent (warn pushed orange so the two greens
|
||||
and the amber never read as one another) */
|
||||
html[data-theme="moss"] {
|
||||
--bg: #08120c;
|
||||
--panel: #101e15;
|
||||
--border: #1f3728;
|
||||
--text: #e9f4ea;
|
||||
--muted: #8fb09a;
|
||||
--accent: #8fdb3f;
|
||||
--accent-text: #15250a;
|
||||
--danger: #ff5a52;
|
||||
--warn: #ff9f1c;
|
||||
}
|
||||
|
||||
/* Plum — aubergine, purple accent */
|
||||
html[data-theme="plum"] {
|
||||
--bg: #110a17;
|
||||
--panel: #1d1228;
|
||||
--border: #332042;
|
||||
--text: #f2e9f8;
|
||||
--muted: #b195c2;
|
||||
--accent: #c084fc;
|
||||
--accent-text: #260a37;
|
||||
--danger: #ff4d4f;
|
||||
--warn: #f7b53b;
|
||||
}
|
||||
|
||||
/* Sepia — warm brown room, cool teal accent: the opposite arrangement to
|
||||
Ember, which is warm throughout */
|
||||
html[data-theme="sepia"] {
|
||||
--bg: #12100b;
|
||||
--panel: #1d1913;
|
||||
--border: #33291d;
|
||||
--text: #f4ede0;
|
||||
--muted: #b3a48b;
|
||||
--accent: #5eead4;
|
||||
--accent-text: #06231e;
|
||||
--danger: #ff5a52;
|
||||
--warn: #ffc247;
|
||||
}
|
||||
|
||||
/* Blackout — true black for a dark room, where any glow off the screen
|
||||
carries; everything else is as bright as it can be against it */
|
||||
html[data-theme="blackout"] {
|
||||
--bg: #000000;
|
||||
--panel: #0a0a0a;
|
||||
--border: #1f1f1f;
|
||||
--text: #ffffff;
|
||||
--muted: #9e9e9e;
|
||||
--accent: #00e5ff;
|
||||
--accent-text: #00212a;
|
||||
--danger: #ff3b30;
|
||||
--warn: #ffcc00;
|
||||
}
|
||||
|
||||
/* Paper — the warm light one, next to Daylight's cool white. Danger and warn
|
||||
darken, as they must on a light background, and the indigo accent takes
|
||||
white text. */
|
||||
html[data-theme="paper"] {
|
||||
--bg: #f5f1e8;
|
||||
--panel: #fffdf8;
|
||||
--border: #ddd5c4;
|
||||
--text: #1c1a15;
|
||||
--muted: #6b6455;
|
||||
--accent: #4f46e5;
|
||||
--accent-text: #ffffff;
|
||||
--danger: #c8102e;
|
||||
--warn: #9a6700;
|
||||
}
|
||||
|
||||
/* Iron — no colour of its own: the accent is near-white, so the only hues on
|
||||
screen are a module's own and the red/amber that mean something */
|
||||
html[data-theme="iron"] {
|
||||
--bg: #0c0c0d;
|
||||
--panel: #161617;
|
||||
--border: #2b2b2d;
|
||||
--text: #f2f2f3;
|
||||
--muted: #9a9a9d;
|
||||
--accent: #d8d8dc;
|
||||
--accent-text: #131314;
|
||||
--danger: #ff4d4f;
|
||||
--warn: #ffb224;
|
||||
}
|
||||
|
||||
@@ -712,6 +712,13 @@ const THEMES = [
|
||||
{ id: 'graphite', name: 'Graphite', colors: { bg: '#0f1012', panel: '#17191d', accent: '#6cb4ff' } },
|
||||
{ id: 'ember', name: 'Ember', colors: { bg: '#120c0a', panel: '#1d1411', accent: '#ff8f3a' } },
|
||||
{ id: 'daylight', name: 'Daylight', colors: { bg: '#eef1f5', panel: '#ffffff', accent: '#0b8a5f' } },
|
||||
{ id: 'midnight', name: 'Midnight', colors: { bg: '#0a0b18', panel: '#14162b', accent: '#9d8cff' } },
|
||||
{ id: 'moss', name: 'Moss', colors: { bg: '#08120c', panel: '#101e15', accent: '#8fdb3f' } },
|
||||
{ id: 'plum', name: 'Plum', colors: { bg: '#110a17', panel: '#1d1228', accent: '#c084fc' } },
|
||||
{ id: 'sepia', name: 'Sepia', colors: { bg: '#12100b', panel: '#1d1913', accent: '#5eead4' } },
|
||||
{ id: 'blackout', name: 'Blackout', colors: { bg: '#000000', panel: '#0a0a0a', accent: '#00e5ff' } },
|
||||
{ id: 'paper', name: 'Paper', colors: { bg: '#f5f1e8', panel: '#fffdf8', accent: '#4f46e5' } },
|
||||
{ id: 'iron', name: 'Iron', colors: { bg: '#0c0c0d', panel: '#161617', accent: '#d8d8dc' } },
|
||||
];
|
||||
const DEFAULT_THEME = THEMES[0].id;
|
||||
|
||||
@@ -1097,12 +1104,13 @@ function uninstallModule(id) {
|
||||
|
||||
/* — updating the shell itself — */
|
||||
|
||||
function copyTree(src, dst) {
|
||||
function copyTree(src, dst, include = null) {
|
||||
fs.mkdirSync(dst, { recursive: true });
|
||||
for (const e of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
if (include && !include(e.name)) continue;
|
||||
const s = path.join(src, e.name);
|
||||
const d = path.join(dst, e.name);
|
||||
if (e.isDirectory()) copyTree(s, d);
|
||||
if (e.isDirectory()) copyTree(s, d, include);
|
||||
else fs.copyFileSync(s, d);
|
||||
}
|
||||
}
|
||||
@@ -1122,25 +1130,83 @@ async function updateShell() {
|
||||
const out = git(['pull', '--ff-only', 'origin', REPO.branch], 120000);
|
||||
console.log('[proddash] git pull:', out.split('\n').filter(Boolean).slice(-2).join(' | '));
|
||||
} else {
|
||||
const keep = (rel) => rel === 'config/modules.json' || rel.startsWith('config/layouts/') || rel.startsWith('.git/');
|
||||
const staging = path.join(ROOT, '.proddash-update');
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
for (const [rel, data] of remote.files) {
|
||||
if (!safeRelPath(rel) || keep(rel)) continue;
|
||||
if (!safeRelPath(rel) || isKeptOnUpdate(rel)) continue;
|
||||
const target = path.join(staging, rel);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, data);
|
||||
}
|
||||
if (!fs.existsSync(path.join(staging, 'server.js'))) {
|
||||
const newServer = path.join(staging, 'server.js');
|
||||
if (!fs.existsSync(newServer)) {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
throw httpError(502, 'The downloaded archive does not look like ProdDash (no server.js).');
|
||||
}
|
||||
// Nothing is swapped in until the new server at least parses. A file that
|
||||
// arrived truncated or mangled would otherwise be found only by a process
|
||||
// that cannot start — the one state this app cannot talk its way out of.
|
||||
try {
|
||||
execFileSync(process.execPath, ['--check', newServer], { timeout: 30000, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
} catch (err) {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
// node --check leads with the staging path and a caret diagram; the
|
||||
// line that names the fault is the one worth repeating.
|
||||
const lines = String(err?.stderr || err?.message || err).split('\n').map((l) => l.trim());
|
||||
const why = lines.find((l) => /^[A-Za-z]*Error\b/.test(l)) || 'it does not parse';
|
||||
throw httpError(502, `The downloaded ProdDash will not run — nothing was changed (${why}).`);
|
||||
}
|
||||
keepPreviousCopy();
|
||||
copyTree(staging, ROOT);
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
return { from: SHELL_VERSION, to: remote.version || '', method: status.method };
|
||||
}
|
||||
|
||||
/* ── what an update must not touch ───────────────────────────────────
|
||||
Anything inside the data directory. Normally it sits well outside the app
|
||||
folder and none of this applies, but when it cannot (an unwritable
|
||||
Application Support, or PRODDASH_DATA_DIR aimed inside the app) the data
|
||||
directory IS the app's config/ — and an update would then write the repo's
|
||||
shipped defaults over this machine's own proddash.json, taking the admin
|
||||
PIN, the theme and the forge with it. The two legacy names stay listed for
|
||||
an install whose state never moved out. */
|
||||
|
||||
const DATA_DIR_INSIDE_ROOT = (() => {
|
||||
const rel = path.relative(ROOT, DATA_DIR);
|
||||
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return '';
|
||||
return rel.split(path.sep).join('/') + '/';
|
||||
})();
|
||||
|
||||
function isKeptOnUpdate(rel) {
|
||||
if (rel.startsWith('.git/')) return true;
|
||||
if (rel === 'config/modules.json' || rel.startsWith('config/layouts/')) return true;
|
||||
return Boolean(DATA_DIR_INSIDE_ROOT) && `${rel}/`.startsWith(DATA_DIR_INSIDE_ROOT);
|
||||
}
|
||||
|
||||
/* ── the way back ────────────────────────────────────────────────────
|
||||
The copy being replaced is set aside first, so there is always one known
|
||||
version to return to. The launcher restores it if the new one will not
|
||||
stay up (see ServerController); by hand it is a rename. It is replaced by
|
||||
the next update, never accumulated. */
|
||||
|
||||
function previousCopyPath() {
|
||||
return `${ROOT}.previous`;
|
||||
}
|
||||
|
||||
function keepPreviousCopy() {
|
||||
const previous = previousCopyPath();
|
||||
try {
|
||||
fs.rmSync(previous, { recursive: true, force: true });
|
||||
copyTree(ROOT, previous, (rel) => rel !== '.proddash-update' && rel !== '.git');
|
||||
fs.writeFileSync(path.join(previous, '.proddash-version'), SHELL_VERSION);
|
||||
console.log(`[proddash] kept ${SHELL_VERSION} at ${previous} in case the new one will not run`);
|
||||
} catch (err) {
|
||||
// Worth saying, not worth refusing the update over.
|
||||
console.warn('[proddash] could not set the current copy aside:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit so the new code runs. Exit code 75 tells a launcher loop to start us
|
||||
* again; when nothing is supervising (PRODDASH_LAUNCHER unset) we start our
|
||||
@@ -1535,10 +1601,46 @@ function layoutPath(name) {
|
||||
return path.join(LAYOUTS_DIR, name + '.json');
|
||||
}
|
||||
|
||||
/** Keep only the fields a layout is made of; everything else is dropped. */
|
||||
/** The copy a save replaced — one back, kept until the next save replaces it.
|
||||
Names cannot contain a dot (layoutNameOk), so this can never be a real
|
||||
layout's own file. */
|
||||
function previousLayoutPath(name) {
|
||||
return path.join(LAYOUTS_DIR, name + '.previous.json');
|
||||
}
|
||||
|
||||
/* A saved layout is `{ v, autoHideHeads, tiles }`. The fields the shell knows
|
||||
are checked for shape; anything else a tile carries rides along untouched.
|
||||
That second part is the lesson of a bug: this function used to rebuild each
|
||||
tile from the fields it knew and drop the rest, and for seventeen days it
|
||||
dropped `variant` — every solo timer saved to a server came back as "All
|
||||
timers". The server is the part of ProdDash that lags (a booth updates
|
||||
weeks after the dev machine), so it must never be the one deciding which
|
||||
tile fields exist. Retired fields are the one exception: they are dropped
|
||||
so an old file does not haunt a new client. */
|
||||
|
||||
const LAYOUT_VERSION = 2;
|
||||
const MAX_LAYOUT_TILES = 100;
|
||||
const KNOWN_TILE_FIELDS = new Set(['id', 'module', 'x', 'y', 'w', 'h', 'settings', 'variant', 'title', 'headPinned', 'minSize']);
|
||||
const RETIRED_TILE_FIELDS = new Set(['headHidden']);
|
||||
|
||||
/** An unknown tile field is kept when it is a sane key holding small, plain JSON. */
|
||||
function keepUnknownTileField(key, value) {
|
||||
if (!/^[A-Za-z][A-Za-z0-9_]{0,39}$/.test(key)) return false;
|
||||
switch (typeof value) {
|
||||
case 'string': return value.length <= 2000;
|
||||
case 'number': return Number.isFinite(value);
|
||||
case 'boolean': return true;
|
||||
case 'object':
|
||||
if (value === null) return false;
|
||||
try { return JSON.stringify(value).length <= 20000; } catch { return false; }
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeLayout(raw) {
|
||||
const tiles = Array.isArray(raw?.tiles) ? raw.tiles.slice(0, 100) : [];
|
||||
const tiles = Array.isArray(raw?.tiles) ? raw.tiles : [];
|
||||
return {
|
||||
v: LAYOUT_VERSION,
|
||||
// Whether tile title bars auto-hide is part of how a dashboard is
|
||||
// arranged, so it travels with the layout like the tiles themselves.
|
||||
autoHideHeads: Boolean(raw?.autoHideHeads),
|
||||
@@ -1552,8 +1654,6 @@ function sanitizeLayout(raw) {
|
||||
h: Number(t?.h) || 1,
|
||||
settings: t?.settings && typeof t.settings === 'object' ? t.settings : {},
|
||||
// Which picker entry the tile came from, and what it was called.
|
||||
// Without these a saved "Now" tile comes back as the full Now/Next
|
||||
// card, and a solo timer comes back with a full tile's settings.
|
||||
variant: String(t?.variant || ''),
|
||||
title: String(t?.title || ''),
|
||||
headPinned: Boolean(t?.headPinned),
|
||||
@@ -1561,6 +1661,10 @@ function sanitizeLayout(raw) {
|
||||
if (Number(t?.minSize?.w) > 0 && Number(t?.minSize?.h) > 0) {
|
||||
tile.minSize = { w: Number(t.minSize.w), h: Number(t.minSize.h) };
|
||||
}
|
||||
for (const [key, value] of Object.entries(t && typeof t === 'object' ? t : {})) {
|
||||
if (KNOWN_TILE_FIELDS.has(key) || RETIRED_TILE_FIELDS.has(key)) continue;
|
||||
if (keepUnknownTileField(key, value)) tile[key] = value;
|
||||
}
|
||||
return tile;
|
||||
}),
|
||||
};
|
||||
@@ -1574,17 +1678,23 @@ async function handleLayoutsApi(req, res, urlPath) {
|
||||
} catch { /* no layouts dir yet */ }
|
||||
const layouts = [];
|
||||
for (const file of files.sort()) {
|
||||
if (file.endsWith('.previous.json')) continue; // a save's one-back copy, listed on its layout
|
||||
const name = file.slice(0, -5);
|
||||
const data = readJson(path.join(LAYOUTS_DIR, file), null);
|
||||
if (!data) continue;
|
||||
let updated = null;
|
||||
try { updated = fs.statSync(path.join(LAYOUTS_DIR, file)).mtime.toISOString(); } catch { /* fine */ }
|
||||
layouts.push({ name, tiles: Array.isArray(data.tiles) ? data.tiles.length : 0, updated });
|
||||
layouts.push({
|
||||
name,
|
||||
tiles: Array.isArray(data.tiles) ? data.tiles.length : 0,
|
||||
updated,
|
||||
hasPrevious: fs.existsSync(previousLayoutPath(name)),
|
||||
});
|
||||
}
|
||||
return sendJson(res, 200, { layouts });
|
||||
}
|
||||
|
||||
const m = urlPath.match(/^\/api\/layouts\/([^/]+)(\/rename)?$/);
|
||||
const m = urlPath.match(/^\/api\/layouts\/([^/]+)(\/rename|\/restore)?$/);
|
||||
if (!m) return sendJson(res, 404, { error: 'Unknown endpoint.' });
|
||||
let name;
|
||||
try {
|
||||
@@ -1617,12 +1727,20 @@ async function handleLayoutsApi(req, res, urlPath) {
|
||||
}
|
||||
const layout = sanitizeLayout(body.layout);
|
||||
if (!layout.tiles.length) return sendJson(res, 400, { error: 'Refusing to save an empty layout.' });
|
||||
if (layout.tiles.length > MAX_LAYOUT_TILES) {
|
||||
// Saying no beats quietly saving the first hundred and losing the rest.
|
||||
return sendJson(res, 400, { error: `A layout can hold ${MAX_LAYOUT_TILES} tiles; this one has ${layout.tiles.length}.` });
|
||||
}
|
||||
const replaced = fs.existsSync(layoutPath(name));
|
||||
try {
|
||||
// The copy being replaced is kept one back, so a save over the wrong
|
||||
// name is a restore away rather than gone.
|
||||
if (replaced) fs.copyFileSync(layoutPath(name), previousLayoutPath(name));
|
||||
writeJson(layoutPath(name), layout);
|
||||
} catch (err) {
|
||||
return sendJson(res, 500, { error: `Could not save ${layoutPath(name)}: ${err?.message || err}` });
|
||||
}
|
||||
return sendJson(res, 200, { ok: true, name });
|
||||
return sendJson(res, 200, { ok: true, name, replaced });
|
||||
}
|
||||
|
||||
if (!m[2] && req.method === 'DELETE') {
|
||||
@@ -1632,10 +1750,11 @@ async function handleLayoutsApi(req, res, urlPath) {
|
||||
} catch {
|
||||
return sendJson(res, 404, { error: `No layout named "${name}".` });
|
||||
}
|
||||
fs.rmSync(previousLayoutPath(name), { force: true });
|
||||
return sendJson(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (m[2] && req.method === 'POST') {
|
||||
if (m[2] === '/rename' && req.method === 'POST') {
|
||||
if (refuseAdminWrite(req, res)) return;
|
||||
let body = {};
|
||||
try {
|
||||
@@ -1659,6 +1778,25 @@ async function handleLayoutsApi(req, res, urlPath) {
|
||||
return sendJson(res, 200, { ok: true, name: next });
|
||||
}
|
||||
|
||||
// Put back the copy the last save replaced. The two swap, so a restore is
|
||||
// itself one restore away from being undone.
|
||||
if (m[2] === '/restore' && req.method === 'POST') {
|
||||
if (refuseAdminWrite(req, res)) return;
|
||||
const current = layoutPath(name);
|
||||
const previous = previousLayoutPath(name);
|
||||
if (!fs.existsSync(previous)) return sendJson(res, 404, { error: `"${name}" has no previous version to go back to.` });
|
||||
try {
|
||||
const swapTmp = current + '.swap';
|
||||
const hadCurrent = fs.existsSync(current);
|
||||
if (hadCurrent) fs.renameSync(current, swapTmp);
|
||||
fs.renameSync(previous, current);
|
||||
if (hadCurrent) fs.renameSync(swapTmp, previous);
|
||||
} catch (err) {
|
||||
return sendJson(res, 500, { error: 'Restore failed: ' + (err?.message || err) });
|
||||
}
|
||||
return sendJson(res, 200, { ok: true, name });
|
||||
}
|
||||
|
||||
sendJson(res, 405, { error: 'Method not allowed.' });
|
||||
}
|
||||
|
||||
@@ -1911,6 +2049,22 @@ server.on('error', (err) => {
|
||||
|
||||
server.listen(PORT, '0.0.0.0');
|
||||
|
||||
/* An update proves itself by staying up. Once this version has, the copy it
|
||||
replaced is dropped — so `app.previous` existing always means "the last
|
||||
update has not proven itself yet", which is exactly what the launcher
|
||||
should roll back to and nothing more. */
|
||||
const UPDATE_PROVEN_AFTER_MS = 90000;
|
||||
setTimeout(() => {
|
||||
const previous = previousCopyPath();
|
||||
if (!fs.existsSync(previous)) return;
|
||||
try {
|
||||
fs.rmSync(previous, { recursive: true, force: true });
|
||||
console.log(`[proddash] ${SHELL_VERSION} has stayed up — dropped the copy it replaced`);
|
||||
} catch (err) {
|
||||
console.warn('[proddash] could not drop the previous copy:', err?.message || err);
|
||||
}
|
||||
}, UPDATE_PROVEN_AFTER_MS).unref();
|
||||
|
||||
// Update check: shortly after boot, then every few hours (the admin page
|
||||
// shows the result; nothing is installed without someone clicking).
|
||||
if (!process.env.PRODDASH_NO_REMOTE_CHECK) {
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Round-trips a layout through the server's layouts API and checks that
|
||||
* nothing is lost on the way: a fully-populated tile, a tile field the server
|
||||
* has never heard of, the one-back copy a save leaves behind, restore, the
|
||||
* tile cap and delete.
|
||||
*
|
||||
* node tools/layout-roundtrip.test.js
|
||||
*
|
||||
* Plain Node, no dependencies. Starts server.js on a spare port with a
|
||||
* temporary data directory, talks to it over HTTP, exits non-zero on the
|
||||
* first failure and always stops the server it started.
|
||||
*
|
||||
* It exists because of one bug: for seventeen days the server rebuilt each
|
||||
* saved tile from the fields it knew and dropped the rest — including
|
||||
* `variant`, so every solo timer saved to a booth came back as "All timers".
|
||||
* A field added on the client must survive a server that has not heard of
|
||||
* it, and this is where that is checked.
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawn } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const net = require('node:net');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const probe = net.createServer();
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const { port } = probe.address();
|
||||
probe.close(() => resolve(port));
|
||||
});
|
||||
probe.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(url, ms = 15000) {
|
||||
const deadline = Date.now() + ms;
|
||||
let last = '';
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (r.ok) return;
|
||||
last = 'HTTP ' + r.status;
|
||||
} catch (err) {
|
||||
last = err.message;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error('server did not come up: ' + last);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proddash-layout-test-'));
|
||||
const port = await freePort();
|
||||
const child = spawn(process.execPath, ['server.js'], {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, PRODDASH_DATA_DIR: dataDir, PORT: String(port), PRODDASH_NO_REMOTE_CHECK: '1', PRODDASH_LAUNCHER: '1' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let log = '';
|
||||
child.stdout.on('data', (d) => { log += d; });
|
||||
child.stderr.on('data', (d) => { log += d; });
|
||||
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const api = async (p, init = {}) => {
|
||||
const r = await fetch(base + p, { headers: { 'Content-Type': 'application/json' }, ...init });
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) };
|
||||
};
|
||||
const layoutsDir = path.join(dataDir, 'layouts');
|
||||
let failed = false;
|
||||
|
||||
try {
|
||||
await waitFor(base + '/api/theme');
|
||||
|
||||
// Every field the shell writes today, one it will write tomorrow, and one
|
||||
// it has stopped writing.
|
||||
const tile = {
|
||||
id: 't1', module: 'propresenter-timers', x: 4, y: 2, w: 3, h: 2,
|
||||
settings: { showNames: false, __tint: '#112233' },
|
||||
variant: 'solo:pco-plan:service', title: 'Service', headPinned: true, minSize: { w: 2, h: 1 },
|
||||
futureField: 'kept', futureFlag: true, futureBox: { a: 1 },
|
||||
headHidden: true,
|
||||
};
|
||||
|
||||
let r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { autoHideHeads: true, tiles: [tile] } }) });
|
||||
assert.equal(r.status, 200, 'save: ' + JSON.stringify(r.body));
|
||||
assert.equal(r.body.replaced, false, 'a first save replaces nothing');
|
||||
|
||||
r = await api('/api/layouts/Booth%20A');
|
||||
assert.equal(r.status, 200);
|
||||
const got = r.body.layout;
|
||||
assert.equal(got.v, 2, 'the file carries a schema version');
|
||||
assert.equal(got.autoHideHeads, true);
|
||||
const t = got.tiles[0];
|
||||
for (const k of ['id', 'module', 'x', 'y', 'w', 'h', 'variant', 'title', 'headPinned']) assert.deepEqual(t[k], tile[k], k);
|
||||
assert.deepEqual(t.settings, tile.settings, 'settings');
|
||||
assert.deepEqual(t.minSize, tile.minSize, 'minSize');
|
||||
assert.equal(t.futureField, 'kept', 'an unknown string field survives');
|
||||
assert.equal(t.futureFlag, true, 'an unknown boolean survives');
|
||||
assert.deepEqual(t.futureBox, { a: 1 }, 'an unknown object survives');
|
||||
assert.equal('headHidden' in t, false, 'a retired field is dropped');
|
||||
|
||||
// Saving over the name keeps the copy it replaced.
|
||||
r = await api('/api/layouts/Booth%20A', { method: 'PUT', body: JSON.stringify({ layout: { tiles: [{ ...tile, title: 'Service v2' }] } }) });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.replaced, true, 'the second save reports a replacement');
|
||||
assert.ok(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), 'the replaced copy is kept');
|
||||
r = await api('/api/layouts');
|
||||
assert.deepEqual(r.body.layouts.map((l) => [l.name, l.hasPrevious]), [['Booth A', true]],
|
||||
'the listing shows one layout with a previous copy — not two layouts');
|
||||
|
||||
// Restore swaps them, so a second restore undoes the first. Admin is open
|
||||
// here (no PIN), which is what a fresh data directory gives.
|
||||
r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' });
|
||||
assert.equal(r.status, 200, 'restore: ' + JSON.stringify(r.body));
|
||||
r = await api('/api/layouts/Booth%20A');
|
||||
assert.equal(r.body.layout.tiles[0].title, 'Service', 'restore brings the earlier save back');
|
||||
r = await api('/api/layouts/Booth%20A/restore', { method: 'POST', body: '{}' });
|
||||
assert.equal(r.status, 200);
|
||||
r = await api('/api/layouts/Booth%20A');
|
||||
assert.equal(r.body.layout.tiles[0].title, 'Service v2', 'restoring again goes forward again');
|
||||
r = await api('/api/layouts/Never%20Saved/restore', { method: 'POST', body: '{}' });
|
||||
assert.equal(r.status, 404, 'nothing to restore is said, not faked');
|
||||
|
||||
// Too many tiles is refused, not trimmed.
|
||||
const many = Array.from({ length: 101 }, (_, i) => ({ ...tile, id: 't' + i }));
|
||||
r = await api('/api/layouts/Too%20Big', { method: 'PUT', body: JSON.stringify({ layout: { tiles: many } }) });
|
||||
assert.equal(r.status, 400);
|
||||
assert.match(String(r.body.error || ''), /100 tiles/, 'the refusal names the cap');
|
||||
assert.equal(fs.existsSync(path.join(layoutsDir, 'Too Big.json')), false, 'nothing was written');
|
||||
|
||||
// Delete takes the previous copy with it.
|
||||
r = await api('/api/layouts/Booth%20A', { method: 'DELETE' });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.json')), false);
|
||||
assert.equal(fs.existsSync(path.join(layoutsDir, 'Booth A.previous.json')), false, 'the previous copy is gone too');
|
||||
r = await api('/api/layouts');
|
||||
assert.deepEqual(r.body.layouts, [], 'nothing left listed');
|
||||
|
||||
console.log('layout round-trip: every check passed');
|
||||
} catch (err) {
|
||||
failed = true;
|
||||
console.error('FAIL:', err.message);
|
||||
console.error('--- last of the server log ---\n' + log.slice(-2000));
|
||||
} finally {
|
||||
child.kill('SIGTERM');
|
||||
await new Promise((r) => setTimeout(r, 400));
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
process.exit(failed ? 1 : 0);
|
||||
})();
|
||||
Reference in New Issue
Block a user