diff --git a/modules/spl-meter/client.js b/modules/spl-meter/client.js index b865899..d0359b4 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); } @@ -321,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; @@ -389,10 +429,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; } @@ -466,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 520978a..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.3.1", + "version": "0.4.1", "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. */