/**
* 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 = `
`;
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 = '';
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) => `
`).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 = '';
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 = ''; },
};
}