AB's meter appeared to halt for a day and a half. Nothing had halted: the capture child had run continuously since the machine was restarted, never stalled, never exited, and was delivering twenty frames a second. Every one of them was digital silence, because Dante Via stopped passing audio after the reboot — the Timers module's LTC listener, on channel 16 of the same device, had been reporting no signal for exactly as long. The module's part in that was reporting "ok" through all of it and drawing a steady -83, which is the -200 floor plus the +117 calibration offset. A plausible number on a wall is believed, and a green dot beside it says the number is worth believing. That is worse than showing nothing. So silence is now a state the module names. Below -150 dBFS unweighted the input is not quiet, it is absent: a microphone in a silent room still reads about -83, and even a muted converter dithers. Five seconds of that on every channel and health() goes to error naming the device and how long it has been dead, the tiles stop drawing a level and say "No audio" with how long, and the status dot goes red. Verified both directions, which is the half that matters: BlackHole with nothing routed into it is caught in five seconds, and a real microphone in a quiet room at -49 dBFS is never flagged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
563 lines
21 KiB
JavaScript
563 lines
21 KiB
JavaScript
/**
|
||
* 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 });
|
||
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 = '<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;
|
||
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 = ''; },
|
||
};
|
||
}
|