SPL Meter 0.4.1: name a dead input, and stop the spectrogram smearing its labels #16

Merged
michael merged 2 commits from claude/spl-silent-input into main 2026-09-23 10:03:28 -04:00
4 changed files with 170 additions and 44 deletions
+121 -42
View File
@@ -17,6 +17,21 @@
const FLOOR = -200; const FLOOR = -200;
/**
* A dead input reads as a number, and a number on a wall is believed. Every
* tile therefore refuses to draw a level while the server says the input is
* digitally silent, and says why instead.
*/
function silentFor(payload) {
return payload && payload.silentSec ? payload.silentSec : 0;
}
function silentText(sec) {
if (sec < 90) return `No audio — ${sec} s`;
const m = Math.round(sec / 60);
return m < 90 ? `No audio — ${m} min` : `No audio — ${(m / 60).toFixed(1)} hours`;
}
/** Retention per frame for the RTA's smoothing setting. */ /** Retention per frame for the RTA's smoothing setting. */
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 }; const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
@@ -37,6 +52,9 @@ export default function create({ root, moduleApi }) {
moduleApi.setStatus('error', payload.error); moduleApi.setStatus('error', payload.error);
} else if (!payload.frame) { } else if (!payload.frame) {
moduleApi.setStatus('connecting', 'waiting for the first reading'); moduleApi.setStatus('connecting', 'waiting for the first reading');
} else if (silentFor(payload)) {
moduleApi.setStatus('error',
`No audio on the chosen input — digital silence for ${silentText(silentFor(payload)).replace('No audio — ', '')}`);
} else if (!payload.calibrated) { } else if (!payload.calibrated) {
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL'); moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
} else { } else {
@@ -233,6 +251,16 @@ function readoutView(root, moduleApi) {
render(payload) { render(payload) {
const s = moduleApi.instanceSettings; const s = moduleApi.instanceSettings;
const ch = pickChannel(payload, s); const ch = pickChannel(payload, s);
const quiet = silentFor(payload);
if (quiet && ch) {
num.textContent = '––.–';
unit.textContent = '';
sub.hidden = false;
sub.textContent = silentText(quiet);
box.dataset.level = 'dead';
fit();
return;
}
if (!payload || payload.error || !ch) { if (!payload || payload.error || !ch) {
num.textContent = '––.–'; num.textContent = '––.–';
sub.textContent = payload && payload.error ? payload.error : ''; sub.textContent = payload && payload.error ? payload.error : '';
@@ -286,6 +314,7 @@ function barsView(root, moduleApi) {
</div>`).join(''); </div>`).join('');
} }
const s = moduleApi.instanceSettings; const s = moduleApi.instanceSettings;
const quiet = silentFor(payload);
const warn = Number(s.warnAt), alarm = Number(s.alarmAt); const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
const showPeak = s.peakHold !== false; const showPeak = s.peakHold !== false;
const lo = payload.calibrated ? 40 : -80; const lo = payload.calibrated ? 40 : -80;
@@ -295,12 +324,12 @@ function barsView(root, moduleApi) {
if (!el) continue; if (!el) continue;
const value = readValue(c, { averaging: s.averaging }); const value = readValue(c, { averaging: s.averaging });
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100)); const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
el.querySelector('.spl-bar-fill').style.width = `${pct(value)}%`; el.querySelector('.spl-bar-fill').style.width = quiet ? '0%' : `${pct(value)}%`;
const peak = el.querySelector('.spl-bar-peak'); const peak = el.querySelector('.spl-bar-peak');
peak.style.display = showPeak ? '' : 'none'; peak.style.display = showPeak ? '' : 'none';
if (showPeak) peak.style.left = `${pct(c.peakC)}%`; if (showPeak) peak.style.left = `${pct(c.peakC)}%`;
el.querySelector('.spl-bar-value').textContent = fmt(value); el.querySelector('.spl-bar-value').textContent = quiet ? '––.–' : fmt(value);
el.dataset.level = !payload.calibrated ? 'idle' el.dataset.level = quiet ? 'dead' : !payload.calibrated ? 'idle'
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok'; : value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
el.classList.toggle('clip', !!c.clip); el.classList.toggle('clip', !!c.clip);
} }
@@ -321,7 +350,18 @@ function spectrumView(root, moduleApi, scrolling) {
root.innerHTML = '<canvas class="spl-canvas"></canvas>'; root.innerHTML = '<canvas class="spl-canvas"></canvas>';
const canvas = root.querySelector('.spl-canvas'); const canvas = root.querySelector('.spl-canvas');
const ctx = canvas.getContext('2d', { alpha: false }); const ctx = canvas.getContext('2d', { alpha: false });
let painted = false; // spectrogram: has the background been laid down /**
* The spectrogram's scrolling history, on its own surface.
*
* It used to scroll by copying the visible canvas onto itself, which also
* copied whatever had been drawn on top of it — so every frame dragged the
* frequency labels and gridlines one step further along and left a smear.
* Only measured pixels belong on a surface that scrolls; annotations are
* drawn fresh over the top of it, every frame, and never into it.
*/
let history = null;
let historyCtx = null;
let painted = false; // spectrogram: has the history been cleared yet
let smoothed = null; // RTA: displayed values, damped toward the real ones let smoothed = null; // RTA: displayed values, damped toward the real ones
let smoothedFor = ''; let smoothedFor = '';
let axisBtn = null; let axisBtn = null;
@@ -389,10 +429,12 @@ function spectrumView(root, moduleApi, scrolling) {
const muted = themeColor(root, '--muted', '#888'); const muted = themeColor(root, '--muted', '#888');
const border = themeColor(root, '--border', '#333'); const border = themeColor(root, '--border', '#333');
if (!payload || !payload.frame || !payload.frame.bands) { const quiet = silentFor(payload);
if (quiet || !payload || !payload.frame || !payload.frame.bands) {
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center'; ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
ctx.fillText(payload?.error || 'Spectrum analysis is off', w / 2, h / 2); ctx.fillText(quiet ? silentText(quiet)
: (payload?.error || 'Spectrum analysis is off'), w / 2, h / 2);
painted = false; painted = false;
return; return;
} }
@@ -466,62 +508,99 @@ function spectrumView(root, moduleApi, scrolling) {
// ── spectrogram ── // ── spectrogram ──
const timeX = axis() === 'x'; const timeX = axis() === 'x';
const colW = 2; const colW = 2;
const gutter = showLabels ? axisPad : 0; // A label along the bottom needs only its line height; one down the
// Frequency occupies whichever axis time does not. // side has to fit "12.5k" beside the plot rather than on top of it.
const freqExtent = timeX ? h - gutter : w - gutter; const gutter = (showLabels || grid) ? (timeX ? 30 : axisPad) : 0;
const timeExtent = timeX ? w : h; // Labels belong beside the frequency axis: down the left when frequency
// runs vertically, along the bottom when it runs horizontally.
const plotX = timeX ? gutter : 0;
const plotW = timeX ? w - gutter : w;
const plotH = timeX ? h : h - gutter;
const freqExtent = timeX ? plotH : plotW;
const timeExtent = timeX ? plotW : plotH;
if (plotW < 4 || plotH < 4) return;
if (!painted) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); painted = true; } // The history surface is sized to the plot, in device pixels, and is
// thrown away whenever that geometry changes — a scroll buffer means
// Source rect in device pixels, destination in CSS pixels: the context // nothing once it is being stretched into a different shape.
// carries a devicePixelRatio transform but the canvas as an image const dpr = window.devicePixelRatio || 1;
// source does not, and letting drawImage infer them scales the picture const needW = Math.max(1, Math.round(plotW * dpr));
// up and pushes every column off the canvas. const needH = Math.max(1, Math.round(plotH * dpr));
if (timeX) { if (!history || history.width !== needW || history.height !== needH) {
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h); history = document.createElement('canvas');
} else { history.width = needW;
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, -colW, w, h); history.height = needH;
historyCtx = history.getContext('2d', { alpha: false });
historyCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
painted = false;
} }
if (!painted) {
historyCtx.fillStyle = bg;
historyCtx.fillRect(0, 0, plotW, plotH);
painted = true;
}
// Scroll the history, then add the newest column. The source rectangle
// is in device pixels and the destination in CSS pixels, because the
// context carries the dpr transform but the canvas as an image source
// does not.
historyCtx.drawImage(history, 0, 0, history.width, history.height,
timeX ? -colW : 0, timeX ? 0 : -colW, plotW, plotH);
const bandExtent = freqExtent / values.length; const bandExtent = freqExtent / values.length;
for (let i = 0; i < values.length; i++) { for (let i = 0; i < values.length; i++) {
ctx.fillStyle = heat(norm(values[i], payload.calibrated)); historyCtx.fillStyle = heat(norm(values[i], payload.calibrated));
if (timeX) { if (timeX) {
// Low frequencies at the bottom, as every spectrogram has them. // Low frequencies at the bottom, as every spectrogram has them.
ctx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent, historyCtx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
colW, Math.ceil(bandExtent) + 1); colW, Math.ceil(bandExtent) + 1);
} else { } else {
// Low frequencies at the left. // Low frequencies at the left.
ctx.fillRect(gutter + i * bandExtent, timeExtent - colW, historyCtx.fillRect(i * bandExtent, timeExtent - colW,
Math.ceil(bandExtent) + 1, colW); Math.ceil(bandExtent) + 1, colW);
} }
} }
if (grid || showLabels) { // Repaint the visible canvas from scratch every frame: history first,
const posOf = (f) => { // annotations over it. Nothing drawn here can ever reach the scroll.
const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]); ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent; ctx.drawImage(history, 0, 0, history.width, history.height,
}; plotX, 0, plotW, plotH);
const labels = chooseLabels(freqs, posOf, timeX ? freqExtent : w, timeX ? 16 : 34);
ctx.fillStyle = bg; if (!grid && !showLabels) return;
if (timeX) ctx.fillRect(0, h - gutter, w, gutter);
else ctx.fillRect(0, 0, gutter, h); const span = Math.log2(freqs[freqs.length - 1] / freqs[0]);
ctx.fillStyle = muted; ctx.font = '9px system-ui'; const posOf = (f) => {
ctx.strokeStyle = border; ctx.globalAlpha = grid ? 0.35 : 0; ctx.beginPath(); const t = Math.log2(f / freqs[0]) / span;
return timeX ? plotH - t * plotH : plotX + t * plotW;
};
const labels = chooseLabels(freqs, posOf, timeX ? plotH : w, timeX ? 14 : 34);
if (grid) {
ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.35;
ctx.beginPath();
for (const l of labels) { for (const l of labels) {
if (timeX) { if (timeX) {
const y = Math.round(l.pos) + 0.5; const y = Math.round(l.pos) + 0.5;
ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.moveTo(plotX, y); ctx.lineTo(w, y);
} else { } else {
const x = Math.round(l.pos) + 0.5; const x = Math.round(l.pos) + 0.5;
ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.moveTo(x, 0); ctx.lineTo(x, plotH);
} }
} }
ctx.stroke(); ctx.globalAlpha = 1; ctx.stroke(); ctx.globalAlpha = 1;
ctx.textAlign = timeX ? 'left' : 'center'; }
for (const l of showLabels ? labels : []) {
if (timeX) ctx.fillText(hzLabel(l.f), 2, Math.min(h - gutter - 2, l.pos + 3)); if (showLabels) {
else ctx.fillText(hzLabel(l.f), l.pos, h - 3); ctx.fillStyle = muted; ctx.font = '9px system-ui';
ctx.textAlign = timeX ? 'right' : 'center';
for (const l of labels) {
if (timeX) {
ctx.fillText(hzLabel(l.f), gutter - 2,
Math.max(7, Math.min(h - 2, l.pos + 3)));
} else {
ctx.fillText(hzLabel(l.f), l.pos, h - 3);
}
} }
} }
}, },
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "spl-meter", "id": "spl-meter",
"name": "SPL Meter", "name": "SPL Meter",
"version": "0.3.1", "version": "0.4.1",
"proddash": ">=1.5.0", "proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [ "permissions": [
+42 -1
View File
@@ -30,6 +30,19 @@ const MEASURE_MS = 3000;
/** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */ /** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */
const PING_MS = 15000; const PING_MS = 15000;
/**
* Below this, unweighted, the input is not quiet — it is not there.
*
* A real input always carries something: a microphone in a silent room still
* reads about -83 dBFS, and even a muted converter dithers around -120. Only
* a stream of actual zeros reaches the capture tool's -200 floor, and that
* means the device handed us nothing at all.
*/
const SILENCE_DBFS = -150;
/** How long that has to hold before saying so, so a gap between buffers or a
device changing sample rate mid-stream doesn't cry wolf. */
const SILENCE_GRACE_MS = 5000;
/** Set by init(), read by routes() — both are rebuilt together on re-init. */ /** Set by init(), read by routes() — both are rebuilt together on re-init. */
let state = null; let state = null;
@@ -100,6 +113,7 @@ module.exports = {
meta: null, meta: null,
error: '', error: '',
streams: new Set(), streams: new Set(),
silentSince: null,
listener: null, listener: null,
lastSent: '', lastSent: '',
pinger: null, pinger: null,
@@ -143,6 +157,12 @@ module.exports = {
state.error = ''; state.error = '';
state.frame = update.frame; state.frame = update.frame;
state.meta = update.meta; state.meta = update.meta;
// Digital silence on every channel is a dead input, not a reading.
// Saying "ok" through this is what made a stopped Dante device look
// like a frozen module for a day and a half.
const dead = update.frame.ch.every(
(c) => !Number.isFinite(c.dbfs) || c.dbfs <= SILENCE_DBFS);
state.silentSince = dead ? (state.silentSince || Date.now()) : null;
broadcast(); broadcast();
}, },
}); });
@@ -159,6 +179,11 @@ module.exports = {
health() { health() {
if (state.error) return { status: 'error', message: state.error }; if (state.error) return { status: 'error', message: state.error };
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' }; if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
const quiet = silentMs();
if (quiet > SILENCE_GRACE_MS) {
return { status: 'error',
message: `No audio from ${config.device} — digital silence for ${describe(quiet)}` };
}
const n = state.channels.length; const n = state.channels.length;
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`; const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
return state.calibrated return state.calibrated
@@ -351,10 +376,26 @@ module.exports = {
/* ── helpers ────────────────────────────────────────────────────────── */ /* ── helpers ────────────────────────────────────────────────────────── */
/** How long every channel has been at digital silence, in ms. */
function silentMs() {
return state && state.silentSince ? Date.now() - state.silentSince : 0;
}
function describe(ms) {
const s = Math.round(ms / 1000);
if (s < 90) return `${s} s`;
const m = Math.round(s / 60);
return m < 90 ? `${m} min` : `${(m / 60).toFixed(1)} hours`;
}
function snapshot() { function snapshot() {
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null }; if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null, silentSec: 0 };
// Seconds, not milliseconds: this goes on the wire 20×/s and a value that
// changed every frame would make the fan-out's change check useless.
const quiet = silentMs();
return { return {
error: state.error, error: state.error,
silentSec: quiet > SILENCE_GRACE_MS ? Math.round(quiet / 1000) : 0,
calibrated: state.calibrated, calibrated: state.calibrated,
channels: state.channels, channels: state.channels,
weighting: state.meta ? state.meta.weighting : '', weighting: state.meta ? state.meta.weighting : '',
+6
View File
@@ -57,6 +57,11 @@
[data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); } [data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); }
[data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); } [data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); }
/* A dead input reads danger, not muted: silence that is a fault should not
look like silence that is a quiet room. */
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-num { color: var(--danger); }
[data-module="spl-meter"] .spl-readout[data-level="dead"] .spl-sub { color: var(--danger); }
/* ── level bars ───────────────────────────────────────────────────── */ /* ── level bars ───────────────────────────────────────────────────── */
[data-module="spl-meter"] .spl-bars { [data-module="spl-meter"] .spl-bars {
@@ -100,6 +105,7 @@
[data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); } [data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); }
[data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); } [data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); }
[data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); } [data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); }
[data-module="spl-meter"] .spl-bar[data-level="dead"] .spl-bar-value { color: var(--danger); }
/* Peak hold sits on the track rather than inside the fill, so it stays /* Peak hold sits on the track rather than inside the fill, so it stays
visible when the bar falls away underneath it. */ visible when the bar falls away underneath it. */