SPL Meter 0.2.0: one measurement, many views

Every control asked for here is a view choice, so none of them may reach
the capture child — two dashboards watching the same room must be able to
disagree about how to draw it without disturbing the measurement or each
other. That set the shape of the change: the child now sends everything
anyone might ask for, once, and each tile folds it down.

Averaging (was "Reading") is Fast, Slow, Inf and None, and the child
computes and sends all four every frame. Fast and Slow are the IEC time
constants, Inf is Leq since reset, None is the block with no time constant
at all. The admin-wide time constant is gone — it was never a property of
the room.

Banding is 1/3 through 1/48 octave. The wire always carries 1/48, and
because every banding offered divides 48 a coarser band is an exact sum of
finer ones in the power domain. Nothing is resampled and no tile's choice
is visible to another. The transform grew to 16384 points to have bins fine
enough to mean it; below roughly 200 Hz a 1/48 band is still narrower than
one bin, which the Banding help says out loud rather than pretending.

Each tile kind now carries its own settings, which is the fix for the
complaint that started this: the RTA was offering an alarm threshold and a
metric picker that did nothing to it. An entry's instanceSchema replaces the
module's, so a gear popover only holds controls that change that tile.

Also: frequency labels in three tiers that thin out as a tile narrows
instead of smearing, gridlines off by default on both graphs, RTA smoothing
as a damping factor on the drawn value only, a title-bar toggle to swap the
spectrogram's axes, and the threshold blurb removed.

Settings saved by 0.1.0 are mapped forward — a layout on a wall should not
start reading dashes because a field was renamed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-17 15:56:27 -04:00
co-authored by Claude Opus 5
parent e90ae51495
commit d2d4207fe4
5 changed files with 496 additions and 169 deletions
+256 -63
View File
@@ -3,18 +3,23 @@
*
* Four kinds, all fed by one SSE stream from the module's server:
*
* readout the number, big — what most dashboards leave up
* readout the number, big
* bars one level bar per channel, with peak hold
* rta third-octave bars
* spectrogram the same bands, scrolling
* rta spectrum now, folded to the banding this tile wants
* spectrogram the same bands over time, either axis
*
* Nothing here computes a level. The capture child measured it; this draws
* it. That split is deliberate — the browser has no access to the audio
* device and no business pretending otherwise.
* 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;
@@ -37,8 +42,8 @@ export default function create({ root, moduleApi }) {
} else {
moduleApi.setStatus('ok', '');
}
// Frames land 20×/s; paint on the next frame the browser was going to
// draw anyway rather than fighting it for every one.
// 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); });
}
@@ -71,24 +76,105 @@ export default function create({ root, moduleApi }) {
/* ── shared helpers ─────────────────────────────────────────────────── */
/** The channel this tile is showing, falling back to the first configured. */
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);
}
/** Read a CSS custom property off the tile so canvases follow the theme. */
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) {
@@ -100,7 +186,6 @@ function buildView(variant, root, moduleApi) {
}
}
/** The big number. */
function readoutView(root, moduleApi) {
root.innerHTML = `
<div class="spl-readout">
@@ -122,8 +207,7 @@ function readoutView(root, moduleApi) {
box.dataset.level = 'idle';
return;
}
const metric = s.metric || 'spl';
const value = ch[metric];
const value = readValue(ch, s);
num.textContent = fmt(value);
unit.textContent = payload.calibrated ? 'dB' : 'dBFS';
@@ -131,10 +215,13 @@ function readoutView(root, moduleApi) {
box.dataset.level = !payload.calibrated ? 'idle'
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
const label = { spl: 'SPL', leq: 'Leq', leq1m: 'Leq 1 min', leq10m: 'Leq 10 min',
peakC: 'Peak C' }[metric] || metric;
const parts = [`${label} · ${payload.weighting || '?'} ${payload.filter || ''}`.trim(),
`ch ${ch.i}`];
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.textContent = parts.join(' · ');
@@ -144,7 +231,6 @@ function readoutView(root, moduleApi) {
};
}
/** One bar per channel, peak hold marked. */
function barsView(root, moduleApi) {
root.innerHTML = '<div class="spl-bars"></div>';
const host = root.querySelector('.spl-bars');
@@ -166,19 +252,21 @@ function barsView(root, moduleApi) {
}
const s = moduleApi.instanceSettings;
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
// Uncalibrated the scale is dBFS (-80..0); calibrated it is dB SPL
// over a range a room actually occupies.
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(c.spl)}%`;
el.querySelector('.spl-bar-peak').style.left = `${pct(c.peakC)}%`;
el.querySelector('.spl-bar-value').textContent = fmt(c.spl);
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'
: c.spl >= alarm ? 'alarm' : c.spl >= warn ? 'warn' : 'ok';
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
el.classList.toggle('clip', !!c.clip);
}
},
@@ -188,17 +276,42 @@ function barsView(root, moduleApi) {
}
/**
* RTA bars, or the same bands scrolling as a spectrogram.
* RTA bars, or the same folded bands scrolling as a spectrogram.
*
* Both are canvas because both redraw wholesale many times a second, and
* thirty DOM nodes animating at 20 Hz is how a dashboard starts dropping
* frames on the machine that is also running the service.
* 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 history = null; // spectrogram only: ImageData column store
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) {
// A title-bar toggle so the axis can be flipped while watching, without
// opening the gear. It writes the same setting the gear does, so the two
// never disagree — but saveInstanceSettings deliberately does not remount
// us, so the new value is held here too and read back by axis().
axisBtn = moduleApi.header.addButton({
label: '⇆',
title: 'Swap axes',
onClick() {
axisOverride = axis() === 'x' ? 'y' : 'x';
moduleApi.saveInstanceSettings({ axis: axisOverride });
painted = false; // the picture means nothing in the other orientation
axisBtn.title = axis() === 'x' ? 'Time left to right' : 'Time top to bottom';
},
});
}
function sizeCanvas() {
const dpr = window.devicePixelRatio || 1;
@@ -210,12 +323,11 @@ function spectrumView(root, moduleApi, scrolling) {
canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
history = null; // geometry changed — start the scroll again
painted = false;
return true;
}
sizeCanvas();
/** dB → 0..1 over the window a room actually uses. */
function norm(db, calibrated) {
const lo = calibrated ? 30 : -100;
const hi = calibrated ? 110 : -10;
@@ -223,8 +335,6 @@ function spectrumView(root, moduleApi, scrolling) {
}
function heat(v) {
// Dark → accent → warn → danger, so a hot band reads the same way the
// readout's colours do.
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;
@@ -238,56 +348,139 @@ function spectrumView(root, moduleApi, scrolling) {
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 bands = payload.frame.bands[idx] || payload.frame.bands[0] || [];
const centres = payload.bandCentres || [];
if (!bands.length) return;
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 axisPad = 13;
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 gap = 1;
const bw = w / bands.length;
for (let i = 0; i < bands.length; i++) {
const v = norm(bands[i], payload.calibrated);
const bh = v * (h - 14);
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 + gap / 2, h - 14 - bh, bw - gap, bh);
ctx.fillRect(i * bw, plotH - bh, Math.max(1, bw - (bw > 3 ? 1 : 0)), bh);
}
// Only decade labels — thirty numbers across a tile is unreadable.
ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center';
for (let i = 0; i < centres.length; i++) {
const f = centres[i];
if (![100, 1000, 10000].includes(f)) continue;
ctx.fillText(f >= 1000 ? `${f / 1000}k` : String(f), i * bw + bw / 2, h - 3);
}
for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, h - 3);
return;
}
// Spectrogram: shift the image left, draw the newest column on the
// right. Cheaper than keeping our own ring of columns.
//
// The source rectangle has to be given in device pixels and the
// destination in CSS pixels: the context carries a devicePixelRatio
// transform, but the canvas used as an image source does not. Letting
// drawImage infer them blows the picture up by the ratio and pushes
// every column off the canvas, which looks exactly like "not drawing".
// ── spectrogram ──
const timeX = axis() === 'x';
const colW = 2;
if (!history) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); history = true; }
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h);
const bh = h / bands.length;
for (let i = 0; i < bands.length; i++) {
ctx.fillStyle = heat(norm(bands[i], payload.calibrated));
// Low frequencies at the bottom, as every spectrogram has them.
ctx.fillRect(w - colW, h - (i + 1) * bh, colW, Math.ceil(bh) + 1);
const gutter = grid ? 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) {
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 = 0.35; 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 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(); },