/** * 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 = `
––.–dB
`; 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) => `
${c.i}
––.–
`).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 = ''; }, }; }