From c464e5b4caaee8d03e6024826a5936131480c944 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Tue, 22 Sep 2026 13:07:24 -0400 Subject: [PATCH 1/2] SPL Meter 0.4.0: a dead input must not look like a quiet room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/spl-meter/client.js | 41 +++++++++++++++++++++++++++++---- modules/spl-meter/module.json | 2 +- modules/spl-meter/server.js | 43 ++++++++++++++++++++++++++++++++++- modules/spl-meter/style.css | 6 +++++ 4 files changed, 85 insertions(+), 7 deletions(-) diff --git a/modules/spl-meter/client.js b/modules/spl-meter/client.js index b865899..90304c3 100644 --- a/modules/spl-meter/client.js +++ b/modules/spl-meter/client.js @@ -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) { `).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; } diff --git a/modules/spl-meter/module.json b/modules/spl-meter/module.json index 520978a..141ae5a 100644 --- a/modules/spl-meter/module.json +++ b/modules/spl-meter/module.json @@ -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": [ diff --git a/modules/spl-meter/server.js b/modules/spl-meter/server.js index 98fb47c..a735d03 100644 --- a/modules/spl-meter/server.js +++ b/modules/spl-meter/server.js @@ -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 : '', diff --git a/modules/spl-meter/style.css b/modules/spl-meter/style.css index a120f46..59098f6 100644 --- a/modules/spl-meter/style.css +++ b/modules/spl-meter/style.css @@ -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. */ From c8b51c6c2f73993aaab2119d6d353319b201d9dc Mon Sep 17 00:00:00 2001 From: Waters Church Date: Wed, 23 Sep 2026 09:58:32 -0400 Subject: [PATCH 2/2] Spectrogram: stop dragging the labels along with the picture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spectrogram scrolled by copying the visible canvas onto itself one step along. That canvas already held the frequency labels and gridlines, because they were drawn straight onto it, so every frame smeared them sideways and left a trail. Arrived with the labels in 0.3.0; the gridlines did it too. Only measured pixels belong on a surface that scrolls. The history now lives on its own offscreen canvas that holds nothing but spectrum, and the visible canvas is repainted from scratch each frame — history first, annotations over the top. Nothing drawn as an annotation can reach the scroll buffer by construction, rather than by remembering to clear the right strip. The layout was muddled in the same place and is fixed with it: the gutter used to be reserved along the bottom while the labels were drawn down the left, overlapping the plot. Labels now sit beside the axis they describe — down the left when frequency runs vertically, with room for "12.5k", and along the bottom when it runs horizontally. Verified both orientations by fingerprinting the gutter and the plot two and a half seconds apart: gutter identical, plot changed. Co-Authored-By: Claude Opus 5 --- modules/spl-meter/client.js | 122 +++++++++++++++++++++++----------- modules/spl-meter/module.json | 2 +- 2 files changed, 86 insertions(+), 38 deletions(-) diff --git a/modules/spl-meter/client.js b/modules/spl-meter/client.js index 90304c3..d0359b4 100644 --- a/modules/spl-meter/client.js +++ b/modules/spl-meter/client.js @@ -350,7 +350,18 @@ function spectrumView(root, moduleApi, scrolling) { root.innerHTML = ''; const canvas = root.querySelector('.spl-canvas'); 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 smoothedFor = ''; let axisBtn = null; @@ -497,62 +508,99 @@ function spectrumView(root, moduleApi, scrolling) { // ── 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; + // A label along the bottom needs only its line height; one down the + // side has to fit "12.5k" beside the plot rather than on top of it. + const gutter = (showLabels || grid) ? (timeX ? 30 : axisPad) : 0; + // 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; } - - // 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); + // The history surface is sized to the plot, in device pixels, and is + // thrown away whenever that geometry changes — a scroll buffer means + // nothing once it is being stretched into a different shape. + const dpr = window.devicePixelRatio || 1; + const needW = Math.max(1, Math.round(plotW * dpr)); + const needH = Math.max(1, Math.round(plotH * dpr)); + if (!history || history.width !== needW || history.height !== needH) { + history = document.createElement('canvas'); + history.width = needW; + 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; 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) { // Low frequencies at the bottom, as every spectrogram has them. - ctx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent, - colW, Math.ceil(bandExtent) + 1); + historyCtx.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); + historyCtx.fillRect(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(); + // Repaint the visible canvas from scratch every frame: history first, + // annotations over it. Nothing drawn here can ever reach the scroll. + ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); + ctx.drawImage(history, 0, 0, history.width, history.height, + plotX, 0, plotW, plotH); + + if (!grid && !showLabels) return; + + const span = Math.log2(freqs[freqs.length - 1] / freqs[0]); + const posOf = (f) => { + 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) { if (timeX) { 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 { 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.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); + } + + if (showLabels) { + 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); + } } } }, diff --git a/modules/spl-meter/module.json b/modules/spl-meter/module.json index 141ae5a..2e07058 100644 --- a/modules/spl-meter/module.json +++ b/modules/spl-meter/module.json @@ -1,7 +1,7 @@ { "id": "spl-meter", "name": "SPL Meter", - "version": "0.4.0", + "version": "0.4.1", "proddash": ">=1.5.0", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "permissions": [