SPL Meter 0.4.0: a dead input must not look like a quiet room

AB's meter appeared to halt for a day and a half. Nothing had halted: the
capture child had run continuously since the machine was restarted, never
stalled, never exited, and was delivering twenty frames a second. Every one
of them was digital silence, because Dante Via stopped passing audio after
the reboot — the Timers module's LTC listener, on channel 16 of the same
device, had been reporting no signal for exactly as long.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-23 09:54:27 -04:00
co-authored by Claude Opus 5
parent f4d50f5e95
commit c464e5b4ca
4 changed files with 85 additions and 7 deletions
+36 -5
View File
@@ -17,6 +17,21 @@
const FLOOR = -200;
/**
* A dead input reads as a number, and a number on a wall is believed. Every
* tile therefore refuses to draw a level while the server says the input is
* digitally silent, and says why instead.
*/
function silentFor(payload) {
return payload && payload.silentSec ? payload.silentSec : 0;
}
function silentText(sec) {
if (sec < 90) return `No audio — ${sec} s`;
const m = Math.round(sec / 60);
return m < 90 ? `No audio — ${m} min` : `No audio — ${(m / 60).toFixed(1)} hours`;
}
/** Retention per frame for the RTA's smoothing setting. */
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
@@ -37,6 +52,9 @@ export default function create({ root, moduleApi }) {
moduleApi.setStatus('error', payload.error);
} else if (!payload.frame) {
moduleApi.setStatus('connecting', 'waiting for the first reading');
} else if (silentFor(payload)) {
moduleApi.setStatus('error',
`No audio on the chosen input — digital silence for ${silentText(silentFor(payload)).replace('No audio — ', '')}`);
} else if (!payload.calibrated) {
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
} else {
@@ -233,6 +251,16 @@ function readoutView(root, moduleApi) {
render(payload) {
const s = moduleApi.instanceSettings;
const ch = pickChannel(payload, s);
const quiet = silentFor(payload);
if (quiet && ch) {
num.textContent = '––.–';
unit.textContent = '';
sub.hidden = false;
sub.textContent = silentText(quiet);
box.dataset.level = 'dead';
fit();
return;
}
if (!payload || payload.error || !ch) {
num.textContent = '––.–';
sub.textContent = payload && payload.error ? payload.error : '';
@@ -286,6 +314,7 @@ function barsView(root, moduleApi) {
</div>`).join('');
}
const s = moduleApi.instanceSettings;
const quiet = silentFor(payload);
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
const showPeak = s.peakHold !== false;
const lo = payload.calibrated ? 40 : -80;
@@ -295,12 +324,12 @@ function barsView(root, moduleApi) {
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)}%`;
el.querySelector('.spl-bar-fill').style.width = quiet ? '0%' : `${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'
el.querySelector('.spl-bar-value').textContent = quiet ? '––.–' : fmt(value);
el.dataset.level = quiet ? 'dead' : !payload.calibrated ? 'idle'
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
el.classList.toggle('clip', !!c.clip);
}
@@ -389,10 +418,12 @@ function spectrumView(root, moduleApi, scrolling) {
const muted = themeColor(root, '--muted', '#888');
const border = themeColor(root, '--border', '#333');
if (!payload || !payload.frame || !payload.frame.bands) {
const quiet = silentFor(payload);
if (quiet || !payload || !payload.frame || !payload.frame.bands) {
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
ctx.fillText(payload?.error || 'Spectrum analysis is off', w / 2, h / 2);
ctx.fillText(quiet ? silentText(quiet)
: (payload?.error || 'Spectrum analysis is off'), w / 2, h / 2);
painted = false;
return;
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "spl-meter",
"name": "SPL Meter",
"version": "0.3.1",
"version": "0.4.0",
"proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [
+42 -1
View File
@@ -30,6 +30,19 @@ const MEASURE_MS = 3000;
/** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */
const PING_MS = 15000;
/**
* Below this, unweighted, the input is not quiet — it is not there.
*
* A real input always carries something: a microphone in a silent room still
* reads about -83 dBFS, and even a muted converter dithers around -120. Only
* a stream of actual zeros reaches the capture tool's -200 floor, and that
* means the device handed us nothing at all.
*/
const SILENCE_DBFS = -150;
/** How long that has to hold before saying so, so a gap between buffers or a
device changing sample rate mid-stream doesn't cry wolf. */
const SILENCE_GRACE_MS = 5000;
/** Set by init(), read by routes() — both are rebuilt together on re-init. */
let state = null;
@@ -100,6 +113,7 @@ module.exports = {
meta: null,
error: '',
streams: new Set(),
silentSince: null,
listener: null,
lastSent: '',
pinger: null,
@@ -143,6 +157,12 @@ module.exports = {
state.error = '';
state.frame = update.frame;
state.meta = update.meta;
// Digital silence on every channel is a dead input, not a reading.
// Saying "ok" through this is what made a stopped Dante device look
// like a frozen module for a day and a half.
const dead = update.frame.ch.every(
(c) => !Number.isFinite(c.dbfs) || c.dbfs <= SILENCE_DBFS);
state.silentSince = dead ? (state.silentSince || Date.now()) : null;
broadcast();
},
});
@@ -159,6 +179,11 @@ module.exports = {
health() {
if (state.error) return { status: 'error', message: state.error };
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
const quiet = silentMs();
if (quiet > SILENCE_GRACE_MS) {
return { status: 'error',
message: `No audio from ${config.device} — digital silence for ${describe(quiet)}` };
}
const n = state.channels.length;
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
return state.calibrated
@@ -351,10 +376,26 @@ module.exports = {
/* ── helpers ────────────────────────────────────────────────────────── */
/** How long every channel has been at digital silence, in ms. */
function silentMs() {
return state && state.silentSince ? Date.now() - state.silentSince : 0;
}
function describe(ms) {
const s = Math.round(ms / 1000);
if (s < 90) return `${s} s`;
const m = Math.round(s / 60);
return m < 90 ? `${m} min` : `${(m / 60).toFixed(1)} hours`;
}
function snapshot() {
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null };
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null, silentSec: 0 };
// Seconds, not milliseconds: this goes on the wire 20×/s and a value that
// changed every frame would make the fan-out's change check useless.
const quiet = silentMs();
return {
error: state.error,
silentSec: quiet > SILENCE_GRACE_MS ? Math.round(quiet / 1000) : 0,
calibrated: state.calibrated,
channels: state.channels,
weighting: state.meta ? state.meta.weighting : '',
+6
View File
@@ -57,6 +57,11 @@
[data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); }
[data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); }
/* A dead input reads danger, not muted: silence that is a fault should not
look like silence that is a quiet room. */
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-num { color: var(--danger); }
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-sub { color: var(--danger); }
/* ── level bars ───────────────────────────────────────────────────── */
[data-module="spl-meter"] .spl-bars {
@@ -100,6 +105,7 @@
[data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); }
[data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); }
[data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); }
[data-module="spl-meter"] .spl-bar[data-level="dead"] .spl-bar-value { color: var(--danger); }
/* Peak hold sits on the track rather than inside the fill, so it stays
visible when the bar falls away underneath it. */