SPL Meter module: measure the room, not just watch it

A sound level meter that lives in ProdDash rather than beside it. Takes an
audio input the way the Timers module's LTC reader does, and offers four
tiles: the level as a big number, per-channel bars with peak hold, a
third-octave RTA, and a scrolling spectrogram.

The measurement happens in spl-capture.swift, not in Node and not in the
browser. LTC gets away with piping raw samples upstream because the decode
is cheap and single-channel; sound level is filtering and FFTs over every
channel at the device's rate, so the child does the work and only the
answers cross the pipe. That costs nothing in dependencies — Accelerate
ships with macOS, and the whole thing builds in two seconds with a plain
swiftc, exactly like ltc-capture.

The weighting filters are the part worth checking rather than trusting, so
`spl-capture --selftest` prints them against the IEC 61672 table. A- and
C-weighting land within 0.15 dB from 31.5 Hz to 4 kHz and exactly 0 dB at
1 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 even with the pole pair
prewarped, and at 96 kHz that falls to 0.6 dB. It is a rounding error on a
broadband number and a real limit all the same, which is why the selftest
prints both rates instead of a pass mark.

Cross-checked against REW on the same microphone: after removing the fixed
calibration offset the two agree to a standard deviation of 0.5 dB on live
room noise.

Uncalibrated is a state the module says out loud — every tile reads dBFS
and labels it, so nobody mistakes a relative number for sound pressure. The
/measure route averages the calibrator tone in the power domain and hands
back the offset to paste into settings; a module cannot write its own admin
config, so that last step stays manual on purpose.

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 5025e12b71
commit 188d1c878d
7 changed files with 1735 additions and 0 deletions
+296
View File
@@ -0,0 +1,296 @@
/**
* SPL Meter tiles.
*
* Four kinds, all fed by one SSE stream from the module's server:
*
* readout the number, big — what most dashboards leave up
* bars one level bar per channel, with peak hold
* rta third-octave bars
* spectrogram the same bands, scrolling
*
* 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.
*/
const FLOOR = -200;
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 the next 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 ─────────────────────────────────────────────────── */
/** 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;
}
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;
}
/* ── 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);
}
}
/** The big number. */
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');
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';
return;
}
const metric = s.metric || 'spl';
const value = ch[metric];
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 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}`];
if (ch.clip) parts.push('CLIP');
if (!payload.calibrated) parts.push('uncalibrated');
sub.textContent = parts.join(' · ');
},
resize() {},
destroy() { root.innerHTML = ''; },
};
}
/** One bar per channel, peak hold marked. */
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);
// Uncalibrated the scale is dBFS (-80..0); calibrated it is dB SPL
// over a range a room actually occupies.
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 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.dataset.level = !payload.calibrated ? 'idle'
: c.spl >= alarm ? 'alarm' : c.spl >= warn ? 'warn' : 'ok';
el.classList.toggle('clip', !!c.clip);
}
},
resize() {},
destroy() { root.innerHTML = ''; },
};
}
/**
* RTA bars, or the same 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.
*/
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
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);
history = null; // geometry changed — start the scroll again
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;
return Math.max(0, Math.min(1, (db - lo) / (hi - lo)));
}
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;
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');
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);
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;
if (!scrolling) {
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);
ctx.fillStyle = heat(v);
ctx.fillRect(i * bw + gap / 2, h - 14 - bh, bw - gap, 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);
}
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".
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);
}
},
resize() { sizeCanvas(); },
destroy() { root.innerHTML = ''; },
};
}