Files
ProdDash/modules/spl-meter/client.js
Waters ChurchandClaude Opus 5 c8b51c6c2f Spectrogram: stop dragging the labels along with the picture
The spectrogram scrolled by copying the visible canvas onto itself one step
along. That canvas already held the frequency labels and gridlines, because
they were drawn straight onto it, so every frame smeared them sideways and
left a trail. Arrived with the labels in 0.3.0; the gridlines did it too.

Only measured pixels belong on a surface that scrolls. The history now lives
on its own offscreen canvas that holds nothing but spectrum, and the visible
canvas is repainted from scratch each frame — history first, annotations
over the top. Nothing drawn as an annotation can reach the scroll buffer by
construction, rather than by remembering to clear the right strip.

The layout was muddled in the same place and is fixed with it: the gutter
used to be reserved along the bottom while the labels were drawn down the
left, overlapping the plot. Labels now sit beside the axis they describe —
down the left when frequency runs vertically, with room for "12.5k", and
along the bottom when it runs horizontally.

Verified both orientations by fingerprinting the gutter and the plot two and
a half seconds apart: gutter identical, plot changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 09:58:32 -04:00

611 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 = `
<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');
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 = '<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 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 = '<canvas class="spl-canvas"></canvas>';
const canvas = root.querySelector('.spl-canvas');
const ctx = canvas.getContext('2d', { alpha: false });
/**
* The spectrogram's scrolling history, on its own surface.
*
* It used to scroll by copying the visible canvas onto itself, which also
* copied whatever had been drawn on top of it — so every frame dragged the
* frequency labels and gridlines one step further along and left a smear.
* Only measured pixels belong on a surface that scrolls; annotations are
* drawn fresh over the top of it, every frame, and never into it.
*/
let history = null;
let historyCtx = null;
let painted = false; // spectrogram: has the history been cleared yet
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 = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" '
+ 'stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">'
+ '<path d="M2 5h9M8.5 2 11.5 5 8.5 8"/><path d="M14 11H5M7.5 8 4.5 11 7.5 14"/></svg>';
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;
// A label along the bottom needs only its line height; one down the
// side has to fit "12.5k" beside the plot rather than on top of it.
const gutter = (showLabels || grid) ? (timeX ? 30 : axisPad) : 0;
// Labels belong beside the frequency axis: down the left when frequency
// runs vertically, along the bottom when it runs horizontally.
const plotX = timeX ? gutter : 0;
const plotW = timeX ? w - gutter : w;
const plotH = timeX ? h : h - gutter;
const freqExtent = timeX ? plotH : plotW;
const timeExtent = timeX ? plotW : plotH;
if (plotW < 4 || plotH < 4) return;
// The history surface is sized to the plot, in device pixels, and is
// thrown away whenever that geometry changes — a scroll buffer means
// nothing once it is being stretched into a different shape.
const dpr = window.devicePixelRatio || 1;
const needW = Math.max(1, Math.round(plotW * dpr));
const needH = Math.max(1, Math.round(plotH * dpr));
if (!history || history.width !== needW || history.height !== needH) {
history = document.createElement('canvas');
history.width = needW;
history.height = needH;
historyCtx = history.getContext('2d', { alpha: false });
historyCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
painted = false;
}
if (!painted) {
historyCtx.fillStyle = bg;
historyCtx.fillRect(0, 0, plotW, plotH);
painted = true;
}
// Scroll the history, then add the newest column. The source rectangle
// is in device pixels and the destination in CSS pixels, because the
// context carries the dpr transform but the canvas as an image source
// does not.
historyCtx.drawImage(history, 0, 0, history.width, history.height,
timeX ? -colW : 0, timeX ? 0 : -colW, plotW, plotH);
const bandExtent = freqExtent / values.length;
for (let i = 0; i < values.length; i++) {
historyCtx.fillStyle = heat(norm(values[i], payload.calibrated));
if (timeX) {
// Low frequencies at the bottom, as every spectrogram has them.
historyCtx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
colW, Math.ceil(bandExtent) + 1);
} else {
// Low frequencies at the left.
historyCtx.fillRect(i * bandExtent, timeExtent - colW,
Math.ceil(bandExtent) + 1, colW);
}
}
// Repaint the visible canvas from scratch every frame: history first,
// annotations over it. Nothing drawn here can ever reach the scroll.
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
ctx.drawImage(history, 0, 0, history.width, history.height,
plotX, 0, plotW, plotH);
if (!grid && !showLabels) return;
const span = Math.log2(freqs[freqs.length - 1] / freqs[0]);
const posOf = (f) => {
const t = Math.log2(f / freqs[0]) / span;
return timeX ? plotH - t * plotH : plotX + t * plotW;
};
const labels = chooseLabels(freqs, posOf, timeX ? plotH : w, timeX ? 14 : 34);
if (grid) {
ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.35;
ctx.beginPath();
for (const l of labels) {
if (timeX) {
const y = Math.round(l.pos) + 0.5;
ctx.moveTo(plotX, y); ctx.lineTo(w, y);
} else {
const x = Math.round(l.pos) + 0.5;
ctx.moveTo(x, 0); ctx.lineTo(x, plotH);
}
}
ctx.stroke(); ctx.globalAlpha = 1;
}
if (showLabels) {
ctx.fillStyle = muted; ctx.font = '9px system-ui';
ctx.textAlign = timeX ? 'right' : 'center';
for (const l of labels) {
if (timeX) {
ctx.fillText(hzLabel(l.f), gutter - 2,
Math.max(7, Math.min(h - 2, l.pos + 3)));
} else {
ctx.fillText(hzLabel(l.f), l.pos, h - 3);
}
}
}
},
resize() { sizeCanvas(); },
destroy() { root.innerHTML = ''; },
};
}