SPL Meter 0.4.1: name a dead input, and stop the spectrogram smearing its labels #16
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -389,10 +418,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "spl-meter",
|
"id": "spl-meter",
|
||||||
"name": "SPL Meter",
|
"name": "SPL Meter",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"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": [
|
||||||
|
|||||||
@@ -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 : '',
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user