2026-09-17 10:03:45 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* SPL Meter tiles.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Four kinds, all fed by one SSE stream from the module's server:
|
|
|
|
|
|
*
|
2026-09-17 12:47:42 -04:00
|
|
|
|
* readout the number, big
|
2026-09-17 10:03:45 -04:00
|
|
|
|
* bars one level bar per channel, with peak hold
|
2026-09-17 12:47:42 -04:00
|
|
|
|
* rta spectrum now, folded to the banding this tile wants
|
|
|
|
|
|
* spectrogram the same bands over time, either axis
|
2026-09-17 10:03:45 -04:00
|
|
|
|
*
|
2026-09-17 12:47:42 -04:00
|
|
|
|
* 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.
|
2026-09-17 10:03:45 -04:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
const FLOOR = -200;
|
|
|
|
|
|
|
2026-09-17 12:47:42 -04:00
|
|
|
|
/** Retention per frame for the RTA's smoothing setting. */
|
|
|
|
|
|
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
|
|
|
|
|
|
|
2026-09-17 10:03:45 -04:00
|
|
|
|
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', '');
|
|
|
|
|
|
}
|
2026-09-17 12:47:42 -04:00
|
|
|
|
// Frames land 20×/s; paint on a frame the browser was going to draw
|
|
|
|
|
|
// anyway rather than fighting it for every one.
|
2026-09-17 10:03:45 -04:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 12:47:42 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 10:03:45 -04:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 12:47:42 -04:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 10:03:45 -04:00
|
|
|
|
/* ── 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');
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-09-17 12:47:42 -04:00
|
|
|
|
const value = readValue(ch, s);
|
2026-09-17 10:03:45 -04:00
|
|
|
|
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';
|
|
|
|
|
|
|
2026-09-17 12:47:42 -04:00
|
|
|
|
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}`];
|
2026-09-17 10:03:45 -04:00
|
|
|
|
if (ch.clip) parts.push('CLIP');
|
|
|
|
|
|
if (!payload.calibrated) parts.push('uncalibrated');
|
|
|
|
|
|
sub.textContent = parts.join(' · ');
|
|
|
|
|
|
},
|
|
|
|
|
|
resize() {},
|
|
|
|
|
|
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 warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
2026-09-17 12:47:42 -04:00
|
|
|
|
const showPeak = s.peakHold !== false;
|
2026-09-17 10:03:45 -04:00
|
|
|
|
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;
|
2026-09-17 12:47:42 -04:00
|
|
|
|
const value = readValue(c, { averaging: s.averaging });
|
2026-09-17 10:03:45 -04:00
|
|
|
|
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
|
2026-09-17 12:47:42 -04:00
|
|
|
|
el.querySelector('.spl-bar-fill').style.width = `${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 = fmt(value);
|
2026-09-17 10:03:45 -04:00
|
|
|
|
el.dataset.level = !payload.calibrated ? 'idle'
|
2026-09-17 12:47:42 -04:00
|
|
|
|
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
2026-09-17 10:03:45 -04:00
|
|
|
|
el.classList.toggle('clip', !!c.clip);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
resize() {},
|
|
|
|
|
|
destroy() { root.innerHTML = ''; },
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-17 12:47:42 -04:00
|
|
|
|
* RTA bars, or the same folded bands scrolling as a spectrogram.
|
2026-09-17 10:03:45 -04:00
|
|
|
|
*
|
2026-09-17 12:47:42 -04:00
|
|
|
|
* 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.
|
2026-09-17 10:03:45 -04:00
|
|
|
|
*/
|
|
|
|
|
|
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 });
|
2026-09-17 12:47:42 -04:00
|
|
|
|
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) {
|
|
|
|
|
|
// A title-bar toggle so the axis can be flipped while watching, without
|
|
|
|
|
|
// opening the gear. It writes the same setting the gear does, so the two
|
|
|
|
|
|
// never disagree — but saveInstanceSettings deliberately does not remount
|
|
|
|
|
|
// us, so the new value is held here too and read back by axis().
|
|
|
|
|
|
axisBtn = moduleApi.header.addButton({
|
|
|
|
|
|
label: '⇆',
|
|
|
|
|
|
title: 'Swap axes',
|
|
|
|
|
|
onClick() {
|
|
|
|
|
|
axisOverride = axis() === 'x' ? 'y' : 'x';
|
|
|
|
|
|
moduleApi.saveInstanceSettings({ axis: axisOverride });
|
|
|
|
|
|
painted = false; // the picture means nothing in the other orientation
|
|
|
|
|
|
axisBtn.title = axis() === 'x' ? 'Time left to right' : 'Time top to bottom';
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-09-17 10:03:45 -04:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-09-17 12:47:42 -04:00
|
|
|
|
painted = false;
|
2026-09-17 10:03:45 -04:00
|
|
|
|
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');
|
2026-09-17 12:47:42 -04:00
|
|
|
|
const border = themeColor(root, '--border', '#333');
|
2026-09-17 10:03:45 -04:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-09-17 12:47:42 -04:00
|
|
|
|
painted = false;
|
2026-09-17 10:03:45 -04:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-17 12:47:42 -04:00
|
|
|
|
|
2026-09-17 10:03:45 -04:00
|
|
|
|
const s = moduleApi.instanceSettings;
|
|
|
|
|
|
const want = Number(s.channel);
|
|
|
|
|
|
const idx = Math.max(0, payload.frame.ch.findIndex((c) => c.i === want));
|
2026-09-17 12:47:42 -04:00
|
|
|
|
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 axisPad = 13;
|
2026-09-17 10:03:45 -04:00
|
|
|
|
|
|
|
|
|
|
if (!scrolling) {
|
2026-09-17 12:47:42 -04:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2026-09-17 10:03:45 -04:00
|
|
|
|
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
2026-09-17 12:47:42 -04:00
|
|
|
|
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;
|
2026-09-17 10:03:45 -04:00
|
|
|
|
ctx.fillStyle = heat(v);
|
2026-09-17 12:47:42 -04:00
|
|
|
|
ctx.fillRect(i * bw, plotH - bh, Math.max(1, bw - (bw > 3 ? 1 : 0)), bh);
|
2026-09-17 10:03:45 -04:00
|
|
|
|
}
|
2026-09-17 12:47:42 -04:00
|
|
|
|
|
2026-09-17 10:03:45 -04:00
|
|
|
|
ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center';
|
2026-09-17 12:47:42 -04:00
|
|
|
|
for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, h - 3);
|
2026-09-17 10:03:45 -04:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 12:47:42 -04:00
|
|
|
|
// ── spectrogram ──
|
|
|
|
|
|
const timeX = axis() === 'x';
|
2026-09-17 10:03:45 -04:00
|
|
|
|
const colW = 2;
|
2026-09-17 12:47:42 -04:00
|
|
|
|
const gutter = grid ? 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) {
|
|
|
|
|
|
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 = 0.35; 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 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);
|
|
|
|
|
|
}
|
2026-09-17 10:03:45 -04:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
resize() { sizeCanvas(); },
|
|
|
|
|
|
destroy() { root.innerHTML = ''; },
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|