/** * SPL Meter tiles. * * Four kinds, all fed by one SSE stream from the module's server: * * readout the number, big * bars one level bar per channel, with peak hold * rta spectrum now, folded to the banding this tile wants * spectrogram the same bands over time, either axis * * Nothing here measures anything. The capture child already sent every * averaging and the finest banding, and each tile folds that down to the * view it was configured for. That is the whole design: two dashboards can * watch one room through different settings without either disturbing the * measurement or the other dashboard. */ 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 }; 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 (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 { moduleApi.setStatus('ok', ''); } // Frames land 20×/s; paint on a 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 ─────────────────────────────────────────────────── */ 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; } /** * The level this tile asked for: an averaging, or one of the fixed metrics. * * Tiles saved by an earlier version carry metrics that no longer exist — * "spl" became an averaging, "leq" became Inf. A layout on a wall somewhere * must not start reading dashes because a setting was renamed, so those map * forward instead of falling through to undefined. */ const LEGACY_METRIC = { spl: 'level', leq: 'level' }; const LEGACY_AVERAGING = { leq: 'inf' }; function readValue(ch, settings) { const raw = settings.metric || 'level'; const metric = LEGACY_METRIC[raw] || raw; if (metric !== 'level') { return metric in ch ? ch[metric] : ch.fast; } const avg = LEGACY_AVERAGING[raw] || settings.averaging || 'fast'; return avg in ch ? ch[avg] : ch.fast; } function fmt(db) { if (!Number.isFinite(db) || db <= FLOOR) return '––.–'; return db.toFixed(1); } function themeColor(el, name, fallback) { const v = getComputedStyle(el).getPropertyValue(name).trim(); return v || fallback; } function hzLabel(f) { if (f >= 1000) { const k = f / 1000; return (k >= 10 ? Math.round(k) : Math.round(k * 10) / 10) + 'k'; } return String(f >= 100 ? Math.round(f) : Math.round(f * 10) / 10); } /** * Fold the 1/48-octave bands on the wire down to this tile's banding. * * Every banding offered divides 48, so a coarser band is an exact sum of * finer ones in the power domain — no interpolation, no resampling. Groups * are keyed on the 1 kHz band (n = 0), which is what keeps folded centres on * the values an RTA is expected to show. * * @returns {{freq:number[], db:number[]}} */ function foldBands(bands, bandN0, perOctave, denom) { const g = Math.max(1, Math.round(perOctave / denom)); const power = new Map(); for (let i = 0; i < bands.length; i++) { const n = bandN0 + i; const k = Math.round(n / g); const p = 10 ** (bands[i] / 10); power.set(k, (power.get(k) || 0) + p); } const keys = [...power.keys()].sort((a, b) => a - b); return { freq: keys.map((k) => 1000 * 2 ** ((k * g) / perOctave)), db: keys.map((k) => 10 * Math.log10(Math.max(power.get(k), 1e-20))), }; } /** * Which frequency labels fit, most important first. * * Decades earn their place before octaves, octaves before thirds, and * anything that would collide with a label already placed is dropped. That * is what makes the axis thin out as a tile shrinks instead of turning into * a smear. */ const LABEL_TIERS = [ [100, 1000, 10000], [31.5, 63, 250, 500, 2000, 4000, 8000, 20000], [20, 40, 50, 80, 125, 160, 200, 315, 400, 630, 800, 1250, 1600, 2500, 3150, 5000, 6300, 12500, 16000], ]; function chooseLabels(freqs, positionOf, extent, minGapPx) { const placed = []; for (const tier of LABEL_TIERS) { for (const f of tier) { if (f < freqs[0] * 0.92 || f > freqs[freqs.length - 1] * 1.08) continue; const pos = positionOf(f); if (pos < 0 || pos > extent) continue; if (placed.some((p) => Math.abs(p.pos - pos) < minGapPx)) continue; placed.push({ f, pos }); } } return placed.sort((a, b) => a.pos - b.pos); } /* ── 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); } } 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'); const value = root.querySelector('.spl-value'); let fitKey = ''; /** * Grow the number until it fills the tile, measuring the text we actually * have rather than assuming a digit count. * * The CSS can only guess at width — it has to pick a cqw coefficient for * some number of characters, and then "-100.5" overflows the tile that * "-45.9" merely filled. Measuring costs one layout read, and only when * the tile size or the length of the reading changes. */ function fit() { const w = root.clientWidth, h = root.clientHeight; const key = `${w}x${h}:${num.textContent.length}:${unit.textContent}:${sub.hidden}`; if (key === fitKey || w < 2 || h < 2) return; fitKey = key; const TRIAL = 100; num.style.fontSize = `${TRIAL}px`; unit.style.fontSize = `${TRIAL * 0.26}px`; const natural = value.scrollWidth || 1; // Leave the caption its room when it is showing, and a little air either // way so descenders and the tile's padding never clip. const subH = sub.offsetParent ? sub.getBoundingClientRect().height + 6 : 0; const byHeight = (h - subH) * 0.92; const byWidth = ((w - 8) * TRIAL) / natural; const size = Math.max(12, Math.min(byHeight, byWidth)); num.style.fontSize = `${size}px`; unit.style.fontSize = `${size * 0.26}px`; } return { 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 : ''; box.dataset.level = 'idle'; fit(); return; } const value = readValue(ch, s); 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 raw = s.metric || 'level'; const metric = LEGACY_METRIC[raw] || raw; const avg = LEGACY_AVERAGING[raw] || s.averaging || 'fast'; const label = metric === 'level' ? ({ fast: 'Fast', slow: 'Slow', inf: 'Inf', none: 'No avg' }[avg] || 'Fast') : ({ leq1m: 'Leq 1 min', leq10m: 'Leq 10 min', peakC: 'Peak C' }[metric] || metric); const parts = [`${payload.weighting || '?'} · ${label}`, `ch ${ch.i}`]; if (ch.clip) parts.push('CLIP'); if (!payload.calibrated) parts.push('uncalibrated'); sub.hidden = s.caption === false; sub.textContent = parts.join(' · '); fit(); }, resize() { fitKey = ''; fit(); }, destroy() { root.innerHTML = ''; }, }; } 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 quiet = silentFor(payload); const warn = Number(s.warnAt), alarm = Number(s.alarmAt); const showPeak = s.peakHold !== false; 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 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 = 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 = quiet ? '––.–' : fmt(value); el.dataset.level = quiet ? 'dead' : !payload.calibrated ? 'idle' : value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok'; el.classList.toggle('clip', !!c.clip); } }, resize() {}, destroy() { root.innerHTML = ''; }, }; } /** * RTA bars, or the same folded bands scrolling as a spectrogram. * * Canvas for both, because both redraw wholesale many times a second and a * few hundred animating DOM nodes 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 painted = false; // spectrogram: has the background been laid down let smoothed = null; // RTA: displayed values, damped toward the real ones let smoothedFor = ''; let axisBtn = null; let axisOverride = null; // set by the title-bar toggle, which never remounts us function axis() { return axisOverride || moduleApi.instanceSettings.axis || 'x'; } if (scrolling) { // The axis lives here and nowhere else (rule 11): a tile does not offer // the same action twice. Icon only, tooltip carries the words (rule 9). // saveInstanceSettings deliberately does not remount us, so the new // value is held here as well and read back by axis(). const swapIcon = '' + ''; const describe = () => (axis() === 'x' ? 'Time left to right' : 'Time top to bottom'); axisBtn = moduleApi.header.addButton({ icon: swapIcon, title: describe(), onClick() { axisOverride = axis() === 'x' ? 'y' : 'x'; moduleApi.saveInstanceSettings({ axis: axisOverride }); painted = false; // the picture means nothing in the other orientation axisBtn.title = describe(); }, }); } 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); painted = false; return true; } sizeCanvas(); 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) { 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'); const border = themeColor(root, '--border', '#333'); 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(quiet ? silentText(quiet) : (payload?.error || 'Spectrum analysis is off'), w / 2, h / 2); painted = false; return; } const s = moduleApi.instanceSettings; const want = Number(s.channel); const idx = Math.max(0, payload.frame.ch.findIndex((c) => c.i === want)); const raw = payload.frame.bands[idx] || payload.frame.bands[0] || []; if (!raw.length) return; const denom = Number(s.banding) || 3; const folded = foldBands(raw, payload.bandN0 || 0, payload.bandsPerOctave || 48, denom); const freqs = folded.freq; let values = folded.db; const grid = s.gridlines === true; const showLabels = s.labels !== false; const axisPad = showLabels || grid ? 13 : 0; if (!scrolling) { // Damp the bars toward the measurement. Cosmetic: `values` is what // gets drawn, never what gets reported. const keep = SMOOTHING[s.smoothing] ?? SMOOTHING.medium; const key = `${denom}:${values.length}`; if (smoothedFor !== key || !smoothed || smoothed.length !== values.length) { smoothed = values.slice(); smoothedFor = key; } else if (keep > 0) { for (let i = 0; i < values.length; i++) { smoothed[i] = smoothed[i] * keep + values[i] * (1 - keep); } } else { smoothed = values.slice(); } values = smoothed; ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); const plotH = h - axisPad; const bw = w / values.length; const xOf = (f) => (Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0])) * w; const labels = chooseLabels(freqs, xOf, w, 34); if (grid) { ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.5; ctx.beginPath(); for (const l of labels) { const x = Math.round(l.pos) + 0.5; ctx.moveTo(x, 0); ctx.lineTo(x, plotH); } for (let g = 0.2; g < 1; g += 0.2) { const y = Math.round(plotH * g) + 0.5; ctx.moveTo(0, y); ctx.lineTo(w, y); } ctx.stroke(); ctx.globalAlpha = 1; } for (let i = 0; i < values.length; i++) { const v = norm(values[i], payload.calibrated); const bh = v * plotH; ctx.fillStyle = heat(v); ctx.fillRect(i * bw, plotH - bh, Math.max(1, bw - (bw > 3 ? 1 : 0)), bh); } if (showLabels) { ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center'; for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, h - 3); } return; } // ── 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; 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); } const bandExtent = freqExtent / values.length; for (let i = 0; i < values.length; i++) { ctx.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); } else { // Low frequencies at the left. ctx.fillRect(gutter + 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(); for (const l of labels) { if (timeX) { const y = Math.round(l.pos) + 0.5; ctx.moveTo(0, y); ctx.lineTo(w, y); } else { const x = Math.round(l.pos) + 0.5; ctx.moveTo(x, 0); ctx.lineTo(x, h); } } 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); } } }, resize() { sizeCanvas(); }, destroy() { root.innerHTML = ''; }, }; }