SPL Meter 0.2.0: one measurement, many views
Every control asked for here is a view choice, so none of them may reach the capture child — two dashboards watching the same room must be able to disagree about how to draw it without disturbing the measurement or each other. That set the shape of the change: the child now sends everything anyone might ask for, once, and each tile folds it down. Averaging (was "Reading") is Fast, Slow, Inf and None, and the child computes and sends all four every frame. Fast and Slow are the IEC time constants, Inf is Leq since reset, None is the block with no time constant at all. The admin-wide time constant is gone — it was never a property of the room. Banding is 1/3 through 1/48 octave. The wire always carries 1/48, and because every banding offered divides 48 a coarser band is an exact sum of finer ones in the power domain. Nothing is resampled and no tile's choice is visible to another. The transform grew to 16384 points to have bins fine enough to mean it; below roughly 200 Hz a 1/48 band is still narrower than one bin, which the Banding help says out loud rather than pretending. Each tile kind now carries its own settings, which is the fix for the complaint that started this: the RTA was offering an alarm threshold and a metric picker that did nothing to it. An entry's instanceSchema replaces the module's, so a gear popover only holds controls that change that tile. Also: frequency labels in three tiers that thin out as a tile narrows instead of smearing, gridlines off by default on both graphs, RTA smoothing as a damping factor on the drawn value only, a title-bar toggle to swap the spectrogram's axes, and the threshold blurb removed. Settings saved by 0.1.0 are mapped forward — a layout on a wall should not start reading dashes because a field was renamed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e90ae51495
commit
d2d4207fe4
+254
-61
@@ -3,18 +3,23 @@
|
||||
*
|
||||
* Four kinds, all fed by one SSE stream from the module's server:
|
||||
*
|
||||
* readout the number, big — what most dashboards leave up
|
||||
* readout the number, big
|
||||
* bars one level bar per channel, with peak hold
|
||||
* rta third-octave bars
|
||||
* spectrogram the same bands, scrolling
|
||||
* rta spectrum now, folded to the banding this tile wants
|
||||
* spectrogram the same bands over time, either axis
|
||||
*
|
||||
* 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.
|
||||
* 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;
|
||||
|
||||
/** 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;
|
||||
@@ -37,8 +42,8 @@ export default function create({ root, moduleApi }) {
|
||||
} 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.
|
||||
// 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); });
|
||||
}
|
||||
|
||||
@@ -71,24 +76,105 @@ export default function create({ root, moduleApi }) {
|
||||
|
||||
/* ── 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -100,7 +186,6 @@ function buildView(variant, root, moduleApi) {
|
||||
}
|
||||
}
|
||||
|
||||
/** The big number. */
|
||||
function readoutView(root, moduleApi) {
|
||||
root.innerHTML = `
|
||||
<div class="spl-readout">
|
||||
@@ -122,8 +207,7 @@ function readoutView(root, moduleApi) {
|
||||
box.dataset.level = 'idle';
|
||||
return;
|
||||
}
|
||||
const metric = s.metric || 'spl';
|
||||
const value = ch[metric];
|
||||
const value = readValue(ch, s);
|
||||
num.textContent = fmt(value);
|
||||
unit.textContent = payload.calibrated ? 'dB' : 'dBFS';
|
||||
|
||||
@@ -131,10 +215,13 @@ function readoutView(root, moduleApi) {
|
||||
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}`];
|
||||
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.textContent = parts.join(' · ');
|
||||
@@ -144,7 +231,6 @@ function readoutView(root, moduleApi) {
|
||||
};
|
||||
}
|
||||
|
||||
/** One bar per channel, peak hold marked. */
|
||||
function barsView(root, moduleApi) {
|
||||
root.innerHTML = '<div class="spl-bars"></div>';
|
||||
const host = root.querySelector('.spl-bars');
|
||||
@@ -166,19 +252,21 @@ function barsView(root, moduleApi) {
|
||||
}
|
||||
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 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 = `${pct(c.spl)}%`;
|
||||
el.querySelector('.spl-bar-peak').style.left = `${pct(c.peakC)}%`;
|
||||
el.querySelector('.spl-bar-value').textContent = fmt(c.spl);
|
||||
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);
|
||||
el.dataset.level = !payload.calibrated ? 'idle'
|
||||
: c.spl >= alarm ? 'alarm' : c.spl >= warn ? 'warn' : 'ok';
|
||||
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
||||
el.classList.toggle('clip', !!c.clip);
|
||||
}
|
||||
},
|
||||
@@ -188,17 +276,42 @@ function barsView(root, moduleApi) {
|
||||
}
|
||||
|
||||
/**
|
||||
* RTA bars, or the same bands scrolling as a spectrogram.
|
||||
* RTA bars, or the same folded 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.
|
||||
* 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 history = null; // spectrogram only: ImageData column store
|
||||
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';
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sizeCanvas() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
@@ -210,12 +323,11 @@ function spectrumView(root, moduleApi, scrolling) {
|
||||
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
|
||||
painted = false;
|
||||
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;
|
||||
@@ -223,8 +335,6 @@ function spectrumView(root, moduleApi, scrolling) {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -238,56 +348,139 @@ function spectrumView(root, moduleApi, scrolling) {
|
||||
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');
|
||||
|
||||
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);
|
||||
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 bands = payload.frame.bands[idx] || payload.frame.bands[0] || [];
|
||||
const centres = payload.bandCentres || [];
|
||||
if (!bands.length) return;
|
||||
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;
|
||||
|
||||
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 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);
|
||||
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 + gap / 2, h - 14 - bh, bw - gap, bh);
|
||||
ctx.fillRect(i * bw, plotH - bh, Math.max(1, bw - (bw > 3 ? 1 : 0)), 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);
|
||||
}
|
||||
for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, 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".
|
||||
// ── spectrogram ──
|
||||
const timeX = axis() === 'x';
|
||||
const colW = 2;
|
||||
if (!history) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); history = true; }
|
||||
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);
|
||||
const bh = h / bands.length;
|
||||
for (let i = 0; i < bands.length; i++) {
|
||||
ctx.fillStyle = heat(norm(bands[i], payload.calibrated));
|
||||
} 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(w - colW, h - (i + 1) * bh, colW, Math.ceil(bh) + 1);
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
resize() { sizeCanvas(); },
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
{
|
||||
"id": "spl-meter",
|
||||
"name": "SPL Meter",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||
"permissions": [
|
||||
{ "kind": "microphone", "reason": "Measuring sound level from an audio input on this machine" }
|
||||
{
|
||||
"kind": "microphone",
|
||||
"reason": "Measuring sound level from an audio input on this machine"
|
||||
}
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": { "w": 2, "h": 1 },
|
||||
"defaultSize": { "w": 3, "h": 2 },
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 1
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 3,
|
||||
"h": 2
|
||||
},
|
||||
"configGroups": {
|
||||
"Input": { "toggle": "enabled" },
|
||||
"Measurement": { "columns": 2 },
|
||||
"Input": {
|
||||
"toggle": "enabled"
|
||||
},
|
||||
"Measurement": {
|
||||
"columns": 2
|
||||
},
|
||||
"Calibration": {
|
||||
"collapsed": "whenSet",
|
||||
"help": "A sound level calibrator fits over the microphone capsule and emits a known level, almost always 94 dB at 1 kHz. Put it on, press Measure beside the channel, and copy the offset it reports into the box. Without this the meter still works, but it reads in dB relative to full scale and every tile says so."
|
||||
@@ -49,19 +62,18 @@
|
||||
"group": "Measurement",
|
||||
"default": "A",
|
||||
"options": [
|
||||
{ "value": "A", "label": "A — how loud it sounds" },
|
||||
{ "value": "C", "label": "C — keeps the low end" },
|
||||
{ "value": "Z", "label": "Z — no weighting at all" }
|
||||
]
|
||||
{
|
||||
"value": "A",
|
||||
"label": "A — how loud it sounds"
|
||||
},
|
||||
"filter": {
|
||||
"type": "select",
|
||||
"label": "Time constant",
|
||||
"group": "Measurement",
|
||||
"default": "fast",
|
||||
"options": [
|
||||
{ "value": "fast", "label": "Fast — 125 ms" },
|
||||
{ "value": "slow", "label": "Slow — 1 s" }
|
||||
{
|
||||
"value": "C",
|
||||
"label": "C — keeps the low end"
|
||||
},
|
||||
{
|
||||
"value": "Z",
|
||||
"label": "Z — no weighting at all"
|
||||
}
|
||||
]
|
||||
},
|
||||
"bands": {
|
||||
@@ -69,7 +81,7 @@
|
||||
"label": "Spectrum analysis",
|
||||
"group": "Measurement",
|
||||
"default": true,
|
||||
"help": "Third-octave bands for the RTA and spectrograph tiles. Turn it off if you only ever show the number."
|
||||
"help": "1/48-octave bands for the RTA and spectrogram tiles, which each fold them to their own banding. Turn it off if you only ever show the number."
|
||||
},
|
||||
"calibration": {
|
||||
"type": "string",
|
||||
@@ -78,30 +90,5 @@
|
||||
"default": "",
|
||||
"help": "Leave empty and every reading is dB relative to full scale, labelled dBFS so nobody mistakes it for real SPL."
|
||||
}
|
||||
},
|
||||
"instanceGroups": {
|
||||
"Thresholds": { "columns": 2, "help": "The readout turns amber then red as it crosses these. Set them to whatever this room has agreed is loud, not to a legal limit — this is not a compliance meter." }
|
||||
},
|
||||
"instanceSchema": {
|
||||
"channel": {
|
||||
"type": "select",
|
||||
"label": "Channel",
|
||||
"default": "",
|
||||
"optionsRoute": "/channels"
|
||||
},
|
||||
"metric": {
|
||||
"type": "select",
|
||||
"label": "Reading",
|
||||
"default": "spl",
|
||||
"options": [
|
||||
{ "value": "spl", "label": "SPL — right now" },
|
||||
{ "value": "leq", "label": "Leq — average since reset" },
|
||||
{ "value": "leq1m", "label": "Leq 1 min" },
|
||||
{ "value": "leq10m", "label": "Leq 10 min" },
|
||||
{ "value": "peakC", "label": "Peak, C-weighted" }
|
||||
]
|
||||
},
|
||||
"warnAt": { "type": "number", "label": "Amber above", "group": "Thresholds", "default": 95 },
|
||||
"alarmAt": { "type": "number", "label": "Red above", "group": "Thresholds", "default": 100 }
|
||||
}
|
||||
}
|
||||
|
||||
+121
-13
@@ -33,6 +33,48 @@ const PING_MS = 15000;
|
||||
/** Set by init(), read by routes() — both are rebuilt together on re-init. */
|
||||
let state = null;
|
||||
|
||||
/* ── shared tile settings ───────────────────────────────────────────── */
|
||||
|
||||
const CHANNEL_FIELD = {
|
||||
type: 'select', label: 'Channel', default: '', optionsRoute: '/channels',
|
||||
};
|
||||
|
||||
/**
|
||||
* Averaging is a view choice, not a measurement one: the capture child
|
||||
* computes all four every frame and sends all four, so one dashboard on Fast
|
||||
* and another on Inf are reading the same measurement two ways.
|
||||
*/
|
||||
function averagingField() {
|
||||
return {
|
||||
type: 'select', label: 'Averaging', default: 'fast',
|
||||
options: [
|
||||
{ value: 'fast', label: 'Fast — 125 ms' },
|
||||
{ value: 'slow', label: 'Slow — 1 s' },
|
||||
{ value: 'inf', label: 'Inf — since reset' },
|
||||
{ value: 'none', label: 'None — no averaging' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Banding folds the 1/48-octave bands on the wire down to something
|
||||
* readable. Coarser is an exact sum of finer, so this costs nothing and
|
||||
* disturbs no other tile.
|
||||
*/
|
||||
const BANDING_FIELD = {
|
||||
type: 'select', label: 'Banding', default: '3',
|
||||
help: 'Finer bandings need a wide tile to be worth drawing, and below '
|
||||
+ 'roughly 200 Hz the window length limits real resolution however fine '
|
||||
+ 'this is set.',
|
||||
options: [
|
||||
{ value: '3', label: '1/3 octave' },
|
||||
{ value: '6', label: '1/6 octave' },
|
||||
{ value: '12', label: '1/12 octave' },
|
||||
{ value: '24', label: '1/24 octave' },
|
||||
{ value: '48', label: '1/48 octave' },
|
||||
],
|
||||
};
|
||||
|
||||
function parseChannels(raw) {
|
||||
return String(raw || '1')
|
||||
.split(',')
|
||||
@@ -88,7 +130,6 @@ module.exports = {
|
||||
device: config.device,
|
||||
channels,
|
||||
weighting: config.weighting || 'A',
|
||||
filter: config.filter || 'fast',
|
||||
bands: config.bands !== false,
|
||||
calibration,
|
||||
moduleDir,
|
||||
@@ -129,20 +170,86 @@ module.exports = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Four tile kinds rather than one tile with a mode switch: an operator
|
||||
* picking "+ SPL" wants to choose what they are putting on the wall, not
|
||||
* place a box and then configure it into being useful.
|
||||
* Four tile kinds, each with only the settings that do something to it.
|
||||
*
|
||||
* An entry's own instanceSchema replaces the module's, which is the point:
|
||||
* an RTA has no use for an alarm threshold and a readout has no use for a
|
||||
* banding, and offering either is how a gear popover stops being read.
|
||||
*
|
||||
* Every setting here is a view choice. None of them reach the capture
|
||||
* child — it already sends every averaging and the finest banding, so two
|
||||
* dashboards can disagree about how to draw the same room.
|
||||
*/
|
||||
tiles() {
|
||||
return [
|
||||
{ id: 'readout', name: 'SPL', description: 'The level, big',
|
||||
defaultSize: { w: 2, h: 1 }, minSize: { w: 1, h: 1 } },
|
||||
{ id: 'bars', name: 'SPL Levels', description: 'One bar per channel, with peak hold',
|
||||
defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } },
|
||||
{ id: 'rta', name: 'RTA', description: 'Third-octave bars',
|
||||
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 } },
|
||||
{ id: 'spectrogram', name: 'Spectrogram', description: 'Third-octave bands over time',
|
||||
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 } },
|
||||
{
|
||||
id: 'readout', name: 'SPL', description: 'The level, big',
|
||||
defaultSize: { w: 2, h: 1 }, minSize: { w: 1, h: 1 },
|
||||
instanceGroups: { Thresholds: { columns: 2 } },
|
||||
instanceSchema: {
|
||||
channel: CHANNEL_FIELD,
|
||||
averaging: averagingField(),
|
||||
metric: {
|
||||
type: 'select', label: 'Show', default: 'level',
|
||||
options: [
|
||||
{ value: 'level', label: 'Level' },
|
||||
{ value: 'leq1m', label: 'Leq, last minute' },
|
||||
{ value: 'leq10m', label: 'Leq, last 10 minutes' },
|
||||
{ value: 'peakC', label: 'Peak, C-weighted' },
|
||||
],
|
||||
},
|
||||
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
|
||||
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'bars', name: 'SPL Levels', description: 'One bar per channel, with peak hold',
|
||||
defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 },
|
||||
instanceGroups: { Thresholds: { columns: 2 } },
|
||||
instanceSchema: {
|
||||
averaging: averagingField(),
|
||||
peakHold: { type: 'switch', label: 'Peak hold', default: true },
|
||||
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
|
||||
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rta', name: 'RTA', description: 'Spectrum now, at your choice of banding',
|
||||
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
|
||||
instanceSchema: {
|
||||
channel: CHANNEL_FIELD,
|
||||
averaging: averagingField(),
|
||||
banding: BANDING_FIELD,
|
||||
smoothing: {
|
||||
type: 'select', label: 'Smoothing', default: 'medium',
|
||||
help: 'How hard the bars are damped between frames. Cosmetic only — '
|
||||
+ 'it changes the animation, never the measurement.',
|
||||
options: [
|
||||
{ value: 'off', label: 'Off — every frame as measured' },
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'heavy', label: 'Heavy' },
|
||||
],
|
||||
},
|
||||
gridlines: { type: 'switch', label: 'Gridlines', default: false },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'spectrogram', name: 'Spectrogram', description: 'Spectrum over time',
|
||||
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
|
||||
instanceSchema: {
|
||||
channel: CHANNEL_FIELD,
|
||||
banding: BANDING_FIELD,
|
||||
axis: {
|
||||
type: 'select', label: 'Time runs', default: 'x',
|
||||
options: [
|
||||
{ value: 'x', label: 'Left to right' },
|
||||
{ value: 'y', label: 'Top to bottom' },
|
||||
],
|
||||
},
|
||||
gridlines: { type: 'switch', label: 'Gridlines', default: false },
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -250,8 +357,9 @@ function snapshot() {
|
||||
calibrated: state.calibrated,
|
||||
channels: state.channels,
|
||||
weighting: state.meta ? state.meta.weighting : '',
|
||||
filter: state.meta ? state.meta.filter : '',
|
||||
bandCentres: state.meta ? state.meta.bands : [],
|
||||
bandN0: state.meta ? state.meta.bandN0 : 0,
|
||||
bandsPerOctave: state.meta ? state.meta.bandsPerOctave : 48,
|
||||
frame: state.frame,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,11 +23,17 @@
|
||||
// says calibrated:false, so nothing downstream can mistake them for
|
||||
// real SPL.
|
||||
//
|
||||
// Per channel, per frame: SPL at the chosen weighting and time constant,
|
||||
// Leq (running, 1-minute and 10-minute), C- and Z-weighted peak, raw dBFS,
|
||||
// and a clip flag. Optionally a third-octave band set for the RTA and
|
||||
// spectrograph tiles — only when --bands is passed, so an SPL-only dashboard
|
||||
// doesn't pay for FFTs it never draws.
|
||||
// Per channel, per frame: the level at EVERY averaging a dashboard might
|
||||
// ask for — fast (125 ms), slow (1 s), inf (Leq since reset) and none (this
|
||||
// block alone) — plus rolling 1- and 10-minute Leq, C- and Z-weighted peak,
|
||||
// raw dBFS and a clip flag. Optionally a 1/48-octave band set for the RTA
|
||||
// and spectrogram tiles, only when --bands is passed.
|
||||
//
|
||||
// Everything is sent every frame on purpose. Two dashboards watching the
|
||||
// same room may want different averaging and different banding, and neither
|
||||
// choice may disturb the other or restart capture. So the measurement is
|
||||
// made once, at the finest resolution anyone asked for, and each tile folds
|
||||
// it down to the view it wants. Nothing here is per-viewer state.
|
||||
//
|
||||
// Weighting filters are IEC 61672 pole/zero prototypes, bilinear-transformed
|
||||
// with the top section prewarped, then normalised to exactly 0 dB at 1 kHz.
|
||||
@@ -231,22 +237,34 @@ final class WeightingFilter {
|
||||
|
||||
/* ── third-octave band plan for the RTA ─────────────────────────────── */
|
||||
|
||||
/// Nominal third-octave centres. We start at 25 Hz rather than 20: with an
|
||||
/// 8192-point FFT at 48 kHz the bins are 5.9 Hz apart, and the 20 Hz band is
|
||||
/// only 4.6 Hz wide — one bin, sometimes none. Claiming a number there would
|
||||
/// be drawing noise. A filter bank would reach lower; that is a later
|
||||
/// problem, and the bands we do show are honest.
|
||||
let bandCentres: [Double] = [25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400,
|
||||
500, 630, 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000,
|
||||
5000, 6300, 8000, 10000, 12500, 16000, 20000]
|
||||
/// The wire always carries 1/48-octave bands, and a tile folds them down to
|
||||
/// whatever banding it wants to draw.
|
||||
///
|
||||
/// That works exactly because every banding the dashboard offers divides 48:
|
||||
/// 1/3 is sixteen of these summed, 1/6 is eight, 1/12 four, 1/24 two. Powers
|
||||
/// are additive, so a fold is a sum and nothing is approximated. The
|
||||
/// alternative — the capture re-banding on request — would mean one dashboard
|
||||
/// choosing 1/6 changes what every other dashboard sees.
|
||||
///
|
||||
/// Band n is centred on 1000 · 2^(n/48); n = 0 is 1 kHz, which is what keeps
|
||||
/// the folded centres on the standard values.
|
||||
let bandN0 = -271 // ≈ 20 Hz
|
||||
let bandN1 = 207 // ≈ 20 kHz
|
||||
let bandCentres: [Double] = (bandN0...bandN1).map { 1000 * pow(2, Double($0) / 48) }
|
||||
|
||||
/// FFT bin ranges for each band, clamped to what the transform can resolve.
|
||||
/// FFT bin ranges per band, clamped to what the transform can actually
|
||||
/// resolve. Below roughly 200 Hz a 1/48-octave band is narrower than one
|
||||
/// bin, so neighbouring bands start sharing bins and the low end reads
|
||||
/// smoother than it really is. That is the window length talking, not the
|
||||
/// room. Folding to 1/6 or 1/3 puts it right, and a multi-resolution
|
||||
/// transform is the real fix if the bottom octaves ever have to be read at
|
||||
/// full resolution.
|
||||
func bandBins(fs: Double, fftSize: Int) -> [(Int, Int)] {
|
||||
let binHz = fs / Double(fftSize)
|
||||
let maxBin = fftSize / 2 - 1
|
||||
return bandCentres.map { fc in
|
||||
let lo = Int((fc * pow(2, -1.0 / 6.0) / binHz).rounded())
|
||||
let hi = Int((fc * pow(2, 1.0 / 6.0) / binHz).rounded())
|
||||
let lo = Int((fc * pow(2, -1.0 / 96.0) / binHz).rounded())
|
||||
let hi = Int((fc * pow(2, 1.0 / 96.0) / binHz).rounded())
|
||||
return (max(1, min(lo, maxBin)), max(1, min(max(hi, lo), maxBin)))
|
||||
}
|
||||
}
|
||||
@@ -269,9 +287,17 @@ final class ChannelMeter {
|
||||
private var weighted: [Float]
|
||||
private var cWeighted: [Float]
|
||||
|
||||
private var meanSquare: Double = 0 // exponential integrator, linear power
|
||||
private var flatMeanSquare: Double = 0 // same integrator, unweighted — for dBFS
|
||||
private let alphaPerSample: Double
|
||||
// One integrator per averaging the dashboard might ask for. They all run
|
||||
// all the time and all go on the wire: a tile choosing "Slow" must not
|
||||
// restart capture, because the next tile wants "Fast" off the same
|
||||
// stream and a third wants Inf. Four extra doubles per channel is a
|
||||
// rounding error next to the filtering; re-measuring per viewer is not.
|
||||
private var msFast: Double = 0
|
||||
private var msSlow: Double = 0
|
||||
private var msNone: Double = 0 // this block only, no time constant
|
||||
private var flatMeanSquare: Double = 0 // unweighted, Fast — for dBFS
|
||||
private let alphaFast: Double
|
||||
private let alphaSlow: Double
|
||||
|
||||
// Leq accumulators: running since reset, plus rolling 1 and 10 minute.
|
||||
private var leqSum: Double = 0
|
||||
@@ -283,7 +309,7 @@ final class ChannelMeter {
|
||||
private var peakZ: Double = 0
|
||||
private var clipped = false
|
||||
|
||||
init(index: Int, fs: Double, weighting: Weighting, fast: Bool, calibration: Double,
|
||||
init(index: Int, fs: Double, weighting: Weighting, calibration: Double,
|
||||
framesPerSecond: Double) {
|
||||
self.index = index
|
||||
self.fs = fs
|
||||
@@ -292,8 +318,9 @@ final class ChannelMeter {
|
||||
cFilter = WeightingFilter(.c, fs: fs)
|
||||
weighted = []
|
||||
cWeighted = []
|
||||
let tau = fast ? 0.125 : 1.0
|
||||
alphaPerSample = 1.0 - exp(-1.0 / (tau * fs))
|
||||
// The time constants IEC 61672 names: Fast is 125 ms, Slow is 1 s.
|
||||
alphaFast = 1.0 - exp(-1.0 / (0.125 * fs))
|
||||
alphaSlow = 1.0 - exp(-1.0 / (1.0 * fs))
|
||||
ringCapacity = Int((600.0 * framesPerSecond).rounded()) // 10 minutes
|
||||
}
|
||||
|
||||
@@ -310,24 +337,26 @@ final class ChannelMeter {
|
||||
// in the power domain. vDSP has no one-pole on squares, and at 48 kHz
|
||||
// this is a few hundred thousand multiply-adds a second — noise next
|
||||
// to the filtering above.
|
||||
var ms = meanSquare
|
||||
var flat = flatMeanSquare
|
||||
var fast = msFast, slow = msSlow, flat = flatMeanSquare
|
||||
weighted.withUnsafeBufferPointer { w in
|
||||
for i in 0..<n {
|
||||
let v = Double(w[i])
|
||||
ms += alphaPerSample * (v * v - ms)
|
||||
let v = Double(w[i]) * Double(w[i])
|
||||
fast += alphaFast * (v - fast)
|
||||
slow += alphaSlow * (v - slow)
|
||||
let r = Double(samples[i])
|
||||
flat += alphaPerSample * (r * r - flat)
|
||||
flat += alphaFast * (r * r - flat)
|
||||
}
|
||||
}
|
||||
meanSquare = ms
|
||||
flatMeanSquare = flat
|
||||
msFast = fast; msSlow = slow; flatMeanSquare = flat
|
||||
|
||||
// Leq wants plain energy, no time constant.
|
||||
// Mean square of just this block — "None", the reading with no time
|
||||
// constant at all. Also what Leq accumulates, since Leq is a plain
|
||||
// energy average and has no time constant either.
|
||||
var sumSq: Float = 0
|
||||
weighted.withUnsafeBufferPointer { w in
|
||||
vDSP_measqv(w.baseAddress!, 1, &sumSq, vDSP_Length(n))
|
||||
}
|
||||
msNone = Double(sumSq)
|
||||
leqSum += Double(sumSq) * Double(n)
|
||||
leqCount += Double(n)
|
||||
|
||||
@@ -353,7 +382,7 @@ final class ChannelMeter {
|
||||
/// wire and noise in a diff — `%.2f` says what a meter can actually
|
||||
/// resolve and nothing more.
|
||||
func snapshot() -> String {
|
||||
ring.append(meanSquare)
|
||||
ring.append(msNone)
|
||||
if ring.count > ringCapacity { ring.removeFirst(ring.count - ringCapacity) }
|
||||
|
||||
let fps = Double(ringCapacity) / 600.0
|
||||
@@ -365,8 +394,10 @@ final class ChannelMeter {
|
||||
}
|
||||
|
||||
return "{\"i\":\(index)"
|
||||
+ f("spl", db(meanSquare))
|
||||
+ f("leq", leqCount > 0 ? db(leqSum / leqCount) : -200)
|
||||
+ f("fast", db(msFast))
|
||||
+ f("slow", db(msSlow))
|
||||
+ f("inf", leqCount > 0 ? db(leqSum / leqCount) : -200)
|
||||
+ f("none", db(msNone))
|
||||
+ f("leq1m", rollingLeq(seconds: 60))
|
||||
+ f("leq10m", rollingLeq(seconds: 600))
|
||||
+ f("peakC", db(peakC * peakC))
|
||||
@@ -395,6 +426,14 @@ func num(_ v: Double) -> String {
|
||||
return String(format: "%.2f", max(v, -200))
|
||||
}
|
||||
|
||||
/// One decimal, for band arrays. 479 bands per channel per frame is the
|
||||
/// bulk of the wire; a second decimal on a bar nobody can read that finely
|
||||
/// is 15% of the bandwidth for nothing.
|
||||
func num1(_ v: Double) -> String {
|
||||
guard v.isFinite else { return "-200" }
|
||||
return String(format: "%.1f", max(v, -200))
|
||||
}
|
||||
|
||||
/* ── spectrum for the RTA / spectrograph tiles ──────────────────────── */
|
||||
|
||||
final class Spectrum {
|
||||
@@ -484,7 +523,6 @@ var wantDevice: String? = nil
|
||||
var wantChannels: [Int] = [1]
|
||||
var wantCal: [Double] = []
|
||||
var wantWeighting = Weighting.a
|
||||
var wantFast = true
|
||||
var wantBands = false
|
||||
var framesPerSecond = 20.0
|
||||
|
||||
@@ -509,10 +547,6 @@ while argIdx < argv.count {
|
||||
let raw = nextArg("--weighting").uppercased()
|
||||
guard let w = Weighting(rawValue: raw) else { fail("--weighting must be A, C or Z") }
|
||||
wantWeighting = w
|
||||
case "--filter":
|
||||
let raw = nextArg("--filter").lowercased()
|
||||
guard raw == "fast" || raw == "slow" else { fail("--filter must be fast or slow") }
|
||||
wantFast = (raw == "fast")
|
||||
case "--bands": wantBands = true
|
||||
case "--rate":
|
||||
guard let r = Double(nextArg("--rate")), r >= 1, r <= 50 else { fail("--rate must be 1-50 (frames per second)") }
|
||||
@@ -602,14 +636,14 @@ if calibrated && wantCal.count != wantChannels.count {
|
||||
}
|
||||
|
||||
let meters = wantChannels.enumerated().map { (i, ch) in
|
||||
ChannelMeter(index: ch, fs: sampleRate, weighting: wantWeighting, fast: wantFast,
|
||||
ChannelMeter(index: ch, fs: sampleRate, weighting: wantWeighting,
|
||||
calibration: calibrated ? wantCal[i] : 0, framesPerSecond: framesPerSecond)
|
||||
}
|
||||
let spectra = wantBands ? wantChannels.map { _ in Spectrum(fs: sampleRate, fftSize: 8192) } : []
|
||||
let spectra = wantBands ? wantChannels.map { _ in Spectrum(fs: sampleRate, fftSize: 16384) } : []
|
||||
|
||||
note("capturing \"\(device.name)\": \(channelCount) channels @ \(Int(sampleRate)) Hz, "
|
||||
+ "measuring \(wantChannels.map(String.init).joined(separator: ",")) "
|
||||
+ "\(wantWeighting.rawValue)-weighted \(wantFast ? "fast" : "slow")"
|
||||
+ "\(wantWeighting.rawValue)-weighted"
|
||||
+ (calibrated ? "" : ", UNCALIBRATED"))
|
||||
|
||||
// Announced once so the reader knows the band plan without hardcoding it.
|
||||
@@ -620,9 +654,12 @@ let hello: [String: Any] = [
|
||||
"deviceChannels": channelCount,
|
||||
"channels": wantChannels,
|
||||
"weighting": wantWeighting.rawValue,
|
||||
"filter": wantFast ? "fast" : "slow",
|
||||
"calibrated": calibrated,
|
||||
"bands": wantBands ? bandCentres : [],
|
||||
// Centres and the index of the 1 kHz band, so a tile can fold to any
|
||||
// banding without hardcoding the plan.
|
||||
"bands": wantBands ? bandCentres.map { round($0 * 100) / 100 } : [],
|
||||
"bandN0": bandN0,
|
||||
"bandsPerOctave": 48,
|
||||
]
|
||||
let stdoutHandle = FileHandle.standardOutput
|
||||
func emitLine(_ json: String) {
|
||||
@@ -680,7 +717,7 @@ timer.setEventHandler {
|
||||
if wantBands {
|
||||
let sets = spectra.enumerated().map { (i, s) -> String in
|
||||
"[" + s.bands(calibration: calibrated ? wantCal[i] : 0)
|
||||
.map(num).joined(separator: ",") + "]"
|
||||
.map(num1).joined(separator: ",") + "]"
|
||||
}
|
||||
line += ",\"bands\":[" + sets.joined(separator: ",") + "]"
|
||||
}
|
||||
|
||||
@@ -38,13 +38,13 @@ const BAD_CONFIG_RETRY_MS = 30000;
|
||||
const STALL_MS = 3000;
|
||||
|
||||
/**
|
||||
* @param {{device:string, channels:number[], weighting:string, filter:string,
|
||||
* @param {{device:string, channels:number[], weighting:string,
|
||||
* bands:boolean, calibration:number[], moduleDir:string,
|
||||
* log:Function, onUpdate:(u:object)=>void}} opts
|
||||
* @returns {{stop:()=>void, reset:()=>void}}
|
||||
*/
|
||||
function createSplListener(opts) {
|
||||
const { device, channels, weighting, filter, bands, calibration, moduleDir, log, onUpdate } = opts;
|
||||
const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts;
|
||||
const bin = path.join(moduleDir, 'spl-capture');
|
||||
|
||||
let child = null;
|
||||
@@ -141,8 +141,10 @@ function createSplListener(opts) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No averaging or banding flag: the child computes every averaging and
|
||||
// the finest banding regardless, so tiles can differ without a restart.
|
||||
const args = ['--device', device, '--channels', channels.join(','),
|
||||
'--weighting', weighting, '--filter', filter, '--rate', '20'];
|
||||
'--weighting', weighting, '--rate', '20'];
|
||||
if (bands) args.push('--bands');
|
||||
if (calibration.length === channels.length) args.push('--cal', calibration.join(','));
|
||||
|
||||
@@ -171,7 +173,7 @@ function createSplListener(opts) {
|
||||
if (obj.hello) {
|
||||
meta = obj;
|
||||
log(`measuring ${obj.device} ch ${obj.channels.join(',')} `
|
||||
+ `${obj.weighting}-weighted ${obj.filter}`
|
||||
+ `${obj.weighting}-weighted`
|
||||
+ (obj.calibrated ? '' : ' (uncalibrated)'));
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user