297 lines
12 KiB
JavaScript
297 lines
12 KiB
JavaScript
/**
|
||||
|
|
* SPL Meter tiles.
|
|||
|
|
*
|
|||
|
|
* Four kinds, all fed by one SSE stream from the module's server:
|
|||
|
|
*
|
|||
|
|
* readout the number, big — what most dashboards leave up
|
|||
|
|
* bars one level bar per channel, with peak hold
|
|||
|
|
* rta third-octave bars
|
|||
|
|
* spectrogram the same bands, scrolling
|
|||
|
|
*
|
|||
|
|
* Nothing here computes a level. The capture child measured it; this draws
|
|||
|
|
* it. That split is deliberate — the browser has no access to the audio
|
|||
|
|
* device and no business pretending otherwise.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const FLOOR = -200;
|
|||
|
|
|
|||
|
|
export default function create({ root, moduleApi }) {
|
|||
|
|
const variant = moduleApi.variant || 'readout';
|
|||
|
|
let stream = null;
|
|||
|
|
let latest = null;
|
|||
|
|
let raf = 0;
|
|||
|
|
|
|||
|
|
root.classList.add('spl');
|
|||
|
|
root.dataset.variant = variant;
|
|||
|
|
|
|||
|
|
const view = buildView(variant, root, moduleApi);
|
|||
|
|
|
|||
|
|
function onState(payload) {
|
|||
|
|
latest = payload;
|
|||
|
|
if (payload.error) {
|
|||
|
|
moduleApi.setStatus('error', payload.error);
|
|||
|
|
} else if (!payload.frame) {
|
|||
|
|
moduleApi.setStatus('connecting', 'waiting for the first reading');
|
|||
|
|
} else if (!payload.calibrated) {
|
|||
|
|
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
|
|||
|
|
} else {
|
|||
|
|
moduleApi.setStatus('ok', '');
|
|||
|
|
}
|
|||
|
|
// Frames land 20×/s; paint on the next frame the browser was going to
|
|||
|
|
// draw anyway rather than fighting it for every one.
|
|||
|
|
if (!raf) raf = requestAnimationFrame(() => { raf = 0; view.render(latest); });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
start() {
|
|||
|
|
moduleApi.setStatus('connecting', 'connecting');
|
|||
|
|
moduleApi.fetch('/state')
|
|||
|
|
.then((r) => r.json())
|
|||
|
|
.then(onState)
|
|||
|
|
.catch(() => { /* the stream will bring it */ });
|
|||
|
|
// Named events go under `events` — a top-level `state(ev)` is silently
|
|||
|
|
// never registered, and the tile just sits on whatever /state gave it.
|
|||
|
|
stream = moduleApi.sse('/stream', {
|
|||
|
|
events: {
|
|||
|
|
state(ev) {
|
|||
|
|
try { onState(JSON.parse(ev.data)); } catch { /* keep the last good frame */ }
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
error() { moduleApi.setStatus('connecting', 'reconnecting'); },
|
|||
|
|
});
|
|||
|
|
},
|
|||
|
|
stop() {
|
|||
|
|
if (stream) { stream.close(); stream = null; }
|
|||
|
|
if (raf) { cancelAnimationFrame(raf); raf = 0; }
|
|||
|
|
view.destroy();
|
|||
|
|
},
|
|||
|
|
onResize() { view.resize(); },
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── shared helpers ─────────────────────────────────────────────────── */
|
|||
|
|
|
|||
|
|
/** The channel this tile is showing, falling back to the first configured. */
|
|||
|
|
function pickChannel(payload, settings) {
|
|||
|
|
if (!payload || !payload.frame) return null;
|
|||
|
|
const want = Number(settings.channel);
|
|||
|
|
return payload.frame.ch.find((c) => c.i === want) || payload.frame.ch[0] || null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function fmt(db) {
|
|||
|
|
if (!Number.isFinite(db) || db <= FLOOR) return '––.–';
|
|||
|
|
return db.toFixed(1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Read a CSS custom property off the tile so canvases follow the theme. */
|
|||
|
|
function themeColor(el, name, fallback) {
|
|||
|
|
const v = getComputedStyle(el).getPropertyValue(name).trim();
|
|||
|
|
return v || fallback;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── views ──────────────────────────────────────────────────────────── */
|
|||
|
|
|
|||
|
|
function buildView(variant, root, moduleApi) {
|
|||
|
|
switch (variant) {
|
|||
|
|
case 'bars': return barsView(root, moduleApi);
|
|||
|
|
case 'rta': return spectrumView(root, moduleApi, false);
|
|||
|
|
case 'spectrogram': return spectrumView(root, moduleApi, true);
|
|||
|
|
default: return readoutView(root, moduleApi);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The big number. */
|
|||
|
|
function readoutView(root, moduleApi) {
|
|||
|
|
root.innerHTML = `
|
|||
|
|
<div class="spl-readout">
|
|||
|
|
<div class="spl-value"><span class="spl-num">––.–</span><span class="spl-unit">dB</span></div>
|
|||
|
|
<div class="spl-sub"></div>
|
|||
|
|
</div>`;
|
|||
|
|
const num = root.querySelector('.spl-num');
|
|||
|
|
const unit = root.querySelector('.spl-unit');
|
|||
|
|
const sub = root.querySelector('.spl-sub');
|
|||
|
|
const box = root.querySelector('.spl-readout');
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
render(payload) {
|
|||
|
|
const s = moduleApi.instanceSettings;
|
|||
|
|
const ch = pickChannel(payload, s);
|
|||
|
|
if (!payload || payload.error || !ch) {
|
|||
|
|
num.textContent = '––.–';
|
|||
|
|
sub.textContent = payload && payload.error ? payload.error : '';
|
|||
|
|
box.dataset.level = 'idle';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const metric = s.metric || 'spl';
|
|||
|
|
const value = ch[metric];
|
|||
|
|
num.textContent = fmt(value);
|
|||
|
|
unit.textContent = payload.calibrated ? 'dB' : 'dBFS';
|
|||
|
|
|
|||
|
|
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
|||
|
|
box.dataset.level = !payload.calibrated ? 'idle'
|
|||
|
|
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
|||
|
|
|
|||
|
|
const label = { spl: 'SPL', leq: 'Leq', leq1m: 'Leq 1 min', leq10m: 'Leq 10 min',
|
|||
|
|
peakC: 'Peak C' }[metric] || metric;
|
|||
|
|
const parts = [`${label} · ${payload.weighting || '?'} ${payload.filter || ''}`.trim(),
|
|||
|
|
`ch ${ch.i}`];
|
|||
|
|
if (ch.clip) parts.push('CLIP');
|
|||
|
|
if (!payload.calibrated) parts.push('uncalibrated');
|
|||
|
|
sub.textContent = parts.join(' · ');
|
|||
|
|
},
|
|||
|
|
resize() {},
|
|||
|
|
destroy() { root.innerHTML = ''; },
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** One bar per channel, peak hold marked. */
|
|||
|
|
function barsView(root, moduleApi) {
|
|||
|
|
root.innerHTML = '<div class="spl-bars"></div>';
|
|||
|
|
const host = root.querySelector('.spl-bars');
|
|||
|
|
let built = '';
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
render(payload) {
|
|||
|
|
if (!payload || !payload.frame) { host.textContent = payload?.error || ''; built = ''; return; }
|
|||
|
|
const chans = payload.frame.ch;
|
|||
|
|
const key = chans.map((c) => c.i).join(',');
|
|||
|
|
if (key !== built) {
|
|||
|
|
built = key;
|
|||
|
|
host.innerHTML = chans.map((c) => `
|
|||
|
|
<div class="spl-bar" data-ch="${c.i}">
|
|||
|
|
<div class="spl-bar-label">${c.i}</div>
|
|||
|
|
<div class="spl-bar-track"><div class="spl-bar-fill"></div><div class="spl-bar-peak"></div></div>
|
|||
|
|
<div class="spl-bar-value">––.–</div>
|
|||
|
|
</div>`).join('');
|
|||
|
|
}
|
|||
|
|
const s = moduleApi.instanceSettings;
|
|||
|
|
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
|||
|
|
// Uncalibrated the scale is dBFS (-80..0); calibrated it is dB SPL
|
|||
|
|
// over a range a room actually occupies.
|
|||
|
|
const lo = payload.calibrated ? 40 : -80;
|
|||
|
|
const hi = payload.calibrated ? 110 : 0;
|
|||
|
|
for (const c of chans) {
|
|||
|
|
const el = host.querySelector(`.spl-bar[data-ch="${c.i}"]`);
|
|||
|
|
if (!el) continue;
|
|||
|
|
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
|
|||
|
|
el.querySelector('.spl-bar-fill').style.width = `${pct(c.spl)}%`;
|
|||
|
|
el.querySelector('.spl-bar-peak').style.left = `${pct(c.peakC)}%`;
|
|||
|
|
el.querySelector('.spl-bar-value').textContent = fmt(c.spl);
|
|||
|
|
el.dataset.level = !payload.calibrated ? 'idle'
|
|||
|
|
: c.spl >= alarm ? 'alarm' : c.spl >= warn ? 'warn' : 'ok';
|
|||
|
|
el.classList.toggle('clip', !!c.clip);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
resize() {},
|
|||
|
|
destroy() { root.innerHTML = ''; },
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* RTA bars, or the same bands scrolling as a spectrogram.
|
|||
|
|
*
|
|||
|
|
* Both are canvas because both redraw wholesale many times a second, and
|
|||
|
|
* thirty DOM nodes animating at 20 Hz is how a dashboard starts dropping
|
|||
|
|
* frames on the machine that is also running the service.
|
|||
|
|
*/
|
|||
|
|
function spectrumView(root, moduleApi, scrolling) {
|
|||
|
|
root.innerHTML = '<canvas class="spl-canvas"></canvas>';
|
|||
|
|
const canvas = root.querySelector('.spl-canvas');
|
|||
|
|
const ctx = canvas.getContext('2d', { alpha: false });
|
|||
|
|
let history = null; // spectrogram only: ImageData column store
|
|||
|
|
|
|||
|
|
function sizeCanvas() {
|
|||
|
|
const dpr = window.devicePixelRatio || 1;
|
|||
|
|
const w = Math.max(1, Math.floor(root.clientWidth));
|
|||
|
|
const h = Math.max(1, Math.floor(root.clientHeight));
|
|||
|
|
if (canvas.width === Math.floor(w * dpr) && canvas.height === Math.floor(h * dpr)) return false;
|
|||
|
|
canvas.width = Math.floor(w * dpr);
|
|||
|
|
canvas.height = Math.floor(h * dpr);
|
|||
|
|
canvas.style.width = `${w}px`;
|
|||
|
|
canvas.style.height = `${h}px`;
|
|||
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|||
|
|
history = null; // geometry changed — start the scroll again
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
sizeCanvas();
|
|||
|
|
|
|||
|
|
/** dB → 0..1 over the window a room actually uses. */
|
|||
|
|
function norm(db, calibrated) {
|
|||
|
|
const lo = calibrated ? 30 : -100;
|
|||
|
|
const hi = calibrated ? 110 : -10;
|
|||
|
|
return Math.max(0, Math.min(1, (db - lo) / (hi - lo)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function heat(v) {
|
|||
|
|
// Dark → accent → warn → danger, so a hot band reads the same way the
|
|||
|
|
// readout's colours do.
|
|||
|
|
const stops = [[10, 12, 20], [30, 90, 150], [90, 180, 140], [230, 190, 80], [220, 80, 60]];
|
|||
|
|
const x = Math.max(0, Math.min(0.999, v)) * (stops.length - 1);
|
|||
|
|
const i = Math.floor(x), t = x - i;
|
|||
|
|
const a = stops[i], b = stops[i + 1] || stops[i];
|
|||
|
|
return `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(a[2] + (b[2] - a[2]) * t)})`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
render(payload) {
|
|||
|
|
sizeCanvas();
|
|||
|
|
const w = canvas.clientWidth, h = canvas.clientHeight;
|
|||
|
|
const bg = themeColor(root, '--panel', '#141414');
|
|||
|
|
const muted = themeColor(root, '--muted', '#888');
|
|||
|
|
|
|||
|
|
if (!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);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
const s = moduleApi.instanceSettings;
|
|||
|
|
const want = Number(s.channel);
|
|||
|
|
const idx = Math.max(0, payload.frame.ch.findIndex((c) => c.i === want));
|
|||
|
|
const bands = payload.frame.bands[idx] || payload.frame.bands[0] || [];
|
|||
|
|
const centres = payload.bandCentres || [];
|
|||
|
|
if (!bands.length) return;
|
|||
|
|
|
|||
|
|
if (!scrolling) {
|
|||
|
|
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
|||
|
|
const gap = 1;
|
|||
|
|
const bw = w / bands.length;
|
|||
|
|
for (let i = 0; i < bands.length; i++) {
|
|||
|
|
const v = norm(bands[i], payload.calibrated);
|
|||
|
|
const bh = v * (h - 14);
|
|||
|
|
ctx.fillStyle = heat(v);
|
|||
|
|
ctx.fillRect(i * bw + gap / 2, h - 14 - bh, bw - gap, bh);
|
|||
|
|
}
|
|||
|
|
// Only decade labels — thirty numbers across a tile is unreadable.
|
|||
|
|
ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center';
|
|||
|
|
for (let i = 0; i < centres.length; i++) {
|
|||
|
|
const f = centres[i];
|
|||
|
|
if (![100, 1000, 10000].includes(f)) continue;
|
|||
|
|
ctx.fillText(f >= 1000 ? `${f / 1000}k` : String(f), i * bw + bw / 2, h - 3);
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Spectrogram: shift the image left, draw the newest column on the
|
|||
|
|
// right. Cheaper than keeping our own ring of columns.
|
|||
|
|
//
|
|||
|
|
// The source rectangle has to be given in device pixels and the
|
|||
|
|
// destination in CSS pixels: the context carries a devicePixelRatio
|
|||
|
|
// transform, but the canvas used as an image source does not. Letting
|
|||
|
|
// drawImage infer them blows the picture up by the ratio and pushes
|
|||
|
|
// every column off the canvas, which looks exactly like "not drawing".
|
|||
|
|
const colW = 2;
|
|||
|
|
if (!history) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); history = true; }
|
|||
|
|
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h);
|
|||
|
|
const bh = h / bands.length;
|
|||
|
|
for (let i = 0; i < bands.length; i++) {
|
|||
|
|
ctx.fillStyle = heat(norm(bands[i], payload.calibrated));
|
|||
|
|
// Low frequencies at the bottom, as every spectrogram has them.
|
|||
|
|
ctx.fillRect(w - colW, h - (i + 1) * bh, colW, Math.ceil(bh) + 1);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
resize() { sizeCanvas(); },
|
|||
|
|
destroy() { root.innerHTML = ''; },
|
|||
|
|
};
|
|||
|
|
}
|