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:
Waters Church
2026-09-17 15:56:27 -04:00
co-authored by Claude Opus 5
parent e90ae51495
commit d2d4207fe4
5 changed files with 496 additions and 169 deletions
+256 -63
View File
@@ -3,18 +3,23 @@
* *
* Four kinds, all fed by one SSE stream from the module's server: * 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 * bars one level bar per channel, with peak hold
* rta third-octave bars * rta spectrum now, folded to the banding this tile wants
* spectrogram the same bands, scrolling * spectrogram the same bands over time, either axis
* *
* Nothing here computes a level. The capture child measured it; this draws * Nothing here measures anything. The capture child already sent every
* it. That split is deliberate — the browser has no access to the audio * averaging and the finest banding, and each tile folds that down to the
* device and no business pretending otherwise. * 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; 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 }) { export default function create({ root, moduleApi }) {
const variant = moduleApi.variant || 'readout'; const variant = moduleApi.variant || 'readout';
let stream = null; let stream = null;
@@ -37,8 +42,8 @@ export default function create({ root, moduleApi }) {
} else { } else {
moduleApi.setStatus('ok', ''); moduleApi.setStatus('ok', '');
} }
// Frames land 20×/s; paint on the next frame the browser was going to // Frames land 20×/s; paint on a frame the browser was going to draw
// draw anyway rather than fighting it for every one. // anyway rather than fighting it for every one.
if (!raf) raf = requestAnimationFrame(() => { raf = 0; view.render(latest); }); if (!raf) raf = requestAnimationFrame(() => { raf = 0; view.render(latest); });
} }
@@ -71,24 +76,105 @@ export default function create({ root, moduleApi }) {
/* ── shared helpers ─────────────────────────────────────────────────── */ /* ── shared helpers ─────────────────────────────────────────────────── */
/** The channel this tile is showing, falling back to the first configured. */
function pickChannel(payload, settings) { function pickChannel(payload, settings) {
if (!payload || !payload.frame) return null; if (!payload || !payload.frame) return null;
const want = Number(settings.channel); const want = Number(settings.channel);
return payload.frame.ch.find((c) => c.i === want) || payload.frame.ch[0] || null; 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) { function fmt(db) {
if (!Number.isFinite(db) || db <= FLOOR) return '––.–'; if (!Number.isFinite(db) || db <= FLOOR) return '––.–';
return db.toFixed(1); return db.toFixed(1);
} }
/** Read a CSS custom property off the tile so canvases follow the theme. */
function themeColor(el, name, fallback) { function themeColor(el, name, fallback) {
const v = getComputedStyle(el).getPropertyValue(name).trim(); const v = getComputedStyle(el).getPropertyValue(name).trim();
return v || fallback; 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 ──────────────────────────────────────────────────────────── */ /* ── views ──────────────────────────────────────────────────────────── */
function buildView(variant, root, moduleApi) { function buildView(variant, root, moduleApi) {
@@ -100,7 +186,6 @@ function buildView(variant, root, moduleApi) {
} }
} }
/** The big number. */
function readoutView(root, moduleApi) { function readoutView(root, moduleApi) {
root.innerHTML = ` root.innerHTML = `
<div class="spl-readout"> <div class="spl-readout">
@@ -122,8 +207,7 @@ function readoutView(root, moduleApi) {
box.dataset.level = 'idle'; box.dataset.level = 'idle';
return; return;
} }
const metric = s.metric || 'spl'; const value = readValue(ch, s);
const value = ch[metric];
num.textContent = fmt(value); num.textContent = fmt(value);
unit.textContent = payload.calibrated ? 'dB' : 'dBFS'; unit.textContent = payload.calibrated ? 'dB' : 'dBFS';
@@ -131,10 +215,13 @@ function readoutView(root, moduleApi) {
box.dataset.level = !payload.calibrated ? 'idle' box.dataset.level = !payload.calibrated ? 'idle'
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok'; : value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
const label = { spl: 'SPL', leq: 'Leq', leq1m: 'Leq 1 min', leq10m: 'Leq 10 min', const raw = s.metric || 'level';
peakC: 'Peak C' }[metric] || metric; const metric = LEGACY_METRIC[raw] || raw;
const parts = [`${label} · ${payload.weighting || '?'} ${payload.filter || ''}`.trim(), const avg = LEGACY_AVERAGING[raw] || s.averaging || 'fast';
`ch ${ch.i}`]; 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 (ch.clip) parts.push('CLIP');
if (!payload.calibrated) parts.push('uncalibrated'); if (!payload.calibrated) parts.push('uncalibrated');
sub.textContent = parts.join(' · '); sub.textContent = parts.join(' · ');
@@ -144,7 +231,6 @@ function readoutView(root, moduleApi) {
}; };
} }
/** One bar per channel, peak hold marked. */
function barsView(root, moduleApi) { function barsView(root, moduleApi) {
root.innerHTML = '<div class="spl-bars"></div>'; root.innerHTML = '<div class="spl-bars"></div>';
const host = root.querySelector('.spl-bars'); const host = root.querySelector('.spl-bars');
@@ -166,19 +252,21 @@ function barsView(root, moduleApi) {
} }
const s = moduleApi.instanceSettings; const s = moduleApi.instanceSettings;
const warn = Number(s.warnAt), alarm = Number(s.alarmAt); const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
// Uncalibrated the scale is dBFS (-80..0); calibrated it is dB SPL const showPeak = s.peakHold !== false;
// over a range a room actually occupies.
const lo = payload.calibrated ? 40 : -80; const lo = payload.calibrated ? 40 : -80;
const hi = payload.calibrated ? 110 : 0; const hi = payload.calibrated ? 110 : 0;
for (const c of chans) { for (const c of chans) {
const el = host.querySelector(`.spl-bar[data-ch="${c.i}"]`); const el = host.querySelector(`.spl-bar[data-ch="${c.i}"]`);
if (!el) continue; if (!el) continue;
const value = readValue(c, { averaging: s.averaging });
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100)); 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-fill').style.width = `${pct(value)}%`;
el.querySelector('.spl-bar-peak').style.left = `${pct(c.peakC)}%`; const peak = el.querySelector('.spl-bar-peak');
el.querySelector('.spl-bar-value').textContent = fmt(c.spl); 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' 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); 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 * Canvas for both, because both redraw wholesale many times a second and a
* thirty DOM nodes animating at 20 Hz is how a dashboard starts dropping * few hundred animating DOM nodes is how a dashboard starts dropping frames
* frames on the machine that is also running the service. * on the machine that is also running the service.
*/ */
function spectrumView(root, moduleApi, scrolling) { function spectrumView(root, moduleApi, scrolling) {
root.innerHTML = '<canvas class="spl-canvas"></canvas>'; root.innerHTML = '<canvas class="spl-canvas"></canvas>';
const canvas = root.querySelector('.spl-canvas'); const canvas = root.querySelector('.spl-canvas');
const ctx = canvas.getContext('2d', { alpha: false }); 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() { function sizeCanvas() {
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
@@ -210,12 +323,11 @@ function spectrumView(root, moduleApi, scrolling) {
canvas.style.width = `${w}px`; canvas.style.width = `${w}px`;
canvas.style.height = `${h}px`; canvas.style.height = `${h}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
history = null; // geometry changed — start the scroll again painted = false;
return true; return true;
} }
sizeCanvas(); sizeCanvas();
/** dB → 0..1 over the window a room actually uses. */
function norm(db, calibrated) { function norm(db, calibrated) {
const lo = calibrated ? 30 : -100; const lo = calibrated ? 30 : -100;
const hi = calibrated ? 110 : -10; const hi = calibrated ? 110 : -10;
@@ -223,8 +335,6 @@ function spectrumView(root, moduleApi, scrolling) {
} }
function heat(v) { 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 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 x = Math.max(0, Math.min(0.999, v)) * (stops.length - 1);
const i = Math.floor(x), t = x - i; 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 w = canvas.clientWidth, h = canvas.clientHeight;
const bg = themeColor(root, '--panel', '#141414'); const bg = themeColor(root, '--panel', '#141414');
const muted = themeColor(root, '--muted', '#888'); const muted = themeColor(root, '--muted', '#888');
const border = themeColor(root, '--border', '#333');
if (!payload || !payload.frame || !payload.frame.bands) { if (!payload || !payload.frame || !payload.frame.bands) {
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center'; ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
ctx.fillText(payload?.error || 'Spectrum analysis is off', w / 2, h / 2); ctx.fillText(payload?.error || 'Spectrum analysis is off', w / 2, h / 2);
painted = false;
return; return;
} }
const s = moduleApi.instanceSettings; const s = moduleApi.instanceSettings;
const want = Number(s.channel); const want = Number(s.channel);
const idx = Math.max(0, payload.frame.ch.findIndex((c) => c.i === want)); const idx = Math.max(0, payload.frame.ch.findIndex((c) => c.i === want));
const bands = payload.frame.bands[idx] || payload.frame.bands[0] || []; const raw = payload.frame.bands[idx] || payload.frame.bands[0] || [];
const centres = payload.bandCentres || []; if (!raw.length) return;
if (!bands.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) { 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); ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
const gap = 1; const plotH = h - axisPad;
const bw = w / bands.length; const bw = w / values.length;
for (let i = 0; i < bands.length; i++) {
const v = norm(bands[i], payload.calibrated); const xOf = (f) => (Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0])) * w;
const bh = v * (h - 14); 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.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'; ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center';
for (let i = 0; i < centres.length; i++) { for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, h - 3);
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);
}
return; return;
} }
// Spectrogram: shift the image left, draw the newest column on the // ── spectrogram ──
// right. Cheaper than keeping our own ring of columns. const timeX = axis() === 'x';
//
// 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".
const colW = 2; const colW = 2;
if (!history) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); history = true; } const gutter = grid ? axisPad : 0;
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h); // Frequency occupies whichever axis time does not.
const bh = h / bands.length; const freqExtent = timeX ? h - gutter : w - gutter;
for (let i = 0; i < bands.length; i++) { const timeExtent = timeX ? w : h;
ctx.fillStyle = heat(norm(bands[i], payload.calibrated));
// Low frequencies at the bottom, as every spectrogram has them. if (!painted) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); painted = true; }
ctx.fillRect(w - colW, h - (i + 1) * bh, colW, Math.ceil(bh) + 1);
// 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);
}
} }
}, },
resize() { sizeCanvas(); }, resize() { sizeCanvas(); },
+32 -45
View File
@@ -1,20 +1,33 @@
{ {
"id": "spl-meter", "id": "spl-meter",
"name": "SPL Meter", "name": "SPL Meter",
"version": "0.1.0", "version": "0.2.0",
"proddash": ">=1.5.0", "proddash": ">=1.5.0",
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
"permissions": [ "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", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
"minSize": { "w": 2, "h": 1 }, "minSize": {
"defaultSize": { "w": 3, "h": 2 }, "w": 2,
"h": 1
},
"defaultSize": {
"w": 3,
"h": 2
},
"configGroups": { "configGroups": {
"Input": { "toggle": "enabled" }, "Input": {
"Measurement": { "columns": 2 }, "toggle": "enabled"
},
"Measurement": {
"columns": 2
},
"Calibration": { "Calibration": {
"collapsed": "whenSet", "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." "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", "group": "Measurement",
"default": "A", "default": "A",
"options": [ "options": [
{ "value": "A", "label": "A — how loud it sounds" }, {
{ "value": "C", "label": "C — keeps the low end" }, "value": "A",
{ "value": "Z", "label": "Z — no weighting at all" } "label": "A — how loud it sounds"
] },
}, {
"filter": { "value": "C",
"type": "select", "label": "C — keeps the low end"
"label": "Time constant", },
"group": "Measurement", {
"default": "fast", "value": "Z",
"options": [ "label": "Z — no weighting at all"
{ "value": "fast", "label": "Fast — 125 ms" }, }
{ "value": "slow", "label": "Slow — 1 s" }
] ]
}, },
"bands": { "bands": {
@@ -69,7 +81,7 @@
"label": "Spectrum analysis", "label": "Spectrum analysis",
"group": "Measurement", "group": "Measurement",
"default": true, "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": { "calibration": {
"type": "string", "type": "string",
@@ -78,30 +90,5 @@
"default": "", "default": "",
"help": "Leave empty and every reading is dB relative to full scale, labelled dBFS so nobody mistakes it for real SPL." "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
View File
@@ -33,6 +33,48 @@ const PING_MS = 15000;
/** Set by init(), read by routes() — both are rebuilt together on re-init. */ /** Set by init(), read by routes() — both are rebuilt together on re-init. */
let state = null; 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) { function parseChannels(raw) {
return String(raw || '1') return String(raw || '1')
.split(',') .split(',')
@@ -88,7 +130,6 @@ module.exports = {
device: config.device, device: config.device,
channels, channels,
weighting: config.weighting || 'A', weighting: config.weighting || 'A',
filter: config.filter || 'fast',
bands: config.bands !== false, bands: config.bands !== false,
calibration, calibration,
moduleDir, moduleDir,
@@ -129,20 +170,86 @@ module.exports = {
}, },
/** /**
* Four tile kinds rather than one tile with a mode switch: an operator * Four tile kinds, each with only the settings that do something to it.
* picking "+ SPL" wants to choose what they are putting on the wall, not *
* place a box and then configure it into being useful. * 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() { tiles() {
return [ return [
{ id: 'readout', name: 'SPL', description: 'The level, big', {
defaultSize: { w: 2, h: 1 }, minSize: { w: 1, h: 1 } }, id: 'readout', name: 'SPL', description: 'The level, big',
{ id: 'bars', name: 'SPL Levels', description: 'One bar per channel, with peak hold', defaultSize: { w: 2, h: 1 }, minSize: { w: 1, h: 1 },
defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } }, instanceGroups: { Thresholds: { columns: 2 } },
{ id: 'rta', name: 'RTA', description: 'Third-octave bars', instanceSchema: {
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 } }, channel: CHANNEL_FIELD,
{ id: 'spectrogram', name: 'Spectrogram', description: 'Third-octave bands over time', averaging: averagingField(),
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 } }, 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, calibrated: state.calibrated,
channels: state.channels, channels: state.channels,
weighting: state.meta ? state.meta.weighting : '', weighting: state.meta ? state.meta.weighting : '',
filter: state.meta ? state.meta.filter : '',
bandCentres: state.meta ? state.meta.bands : [], bandCentres: state.meta ? state.meta.bands : [],
bandN0: state.meta ? state.meta.bandN0 : 0,
bandsPerOctave: state.meta ? state.meta.bandsPerOctave : 48,
frame: state.frame, frame: state.frame,
}; };
} }
+81 -44
View File
@@ -23,11 +23,17 @@
// says calibrated:false, so nothing downstream can mistake them for // says calibrated:false, so nothing downstream can mistake them for
// real SPL. // real SPL.
// //
// Per channel, per frame: SPL at the chosen weighting and time constant, // Per channel, per frame: the level at EVERY averaging a dashboard might
// Leq (running, 1-minute and 10-minute), C- and Z-weighted peak, raw dBFS, // ask for — fast (125 ms), slow (1 s), inf (Leq since reset) and none (this
// and a clip flag. Optionally a third-octave band set for the RTA and // block alone) — plus rolling 1- and 10-minute Leq, C- and Z-weighted peak,
// spectrograph tiles — only when --bands is passed, so an SPL-only dashboard // raw dBFS and a clip flag. Optionally a 1/48-octave band set for the RTA
// doesn't pay for FFTs it never draws. // 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 // Weighting filters are IEC 61672 pole/zero prototypes, bilinear-transformed
// with the top section prewarped, then normalised to exactly 0 dB at 1 kHz. // 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 ─────────────────────────────── */ /* ── third-octave band plan for the RTA ─────────────────────────────── */
/// Nominal third-octave centres. We start at 25 Hz rather than 20: with an /// The wire always carries 1/48-octave bands, and a tile folds them down to
/// 8192-point FFT at 48 kHz the bins are 5.9 Hz apart, and the 20 Hz band is /// whatever banding it wants to draw.
/// 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 /// That works exactly because every banding the dashboard offers divides 48:
/// problem, and the bands we do show are honest. /// 1/3 is sixteen of these summed, 1/6 is eight, 1/12 four, 1/24 two. Powers
let bandCentres: [Double] = [25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, /// are additive, so a fold is a sum and nothing is approximated. The
500, 630, 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000, /// alternative — the capture re-banding on request — would mean one dashboard
5000, 6300, 8000, 10000, 12500, 16000, 20000] /// 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)] { func bandBins(fs: Double, fftSize: Int) -> [(Int, Int)] {
let binHz = fs / Double(fftSize) let binHz = fs / Double(fftSize)
let maxBin = fftSize / 2 - 1 let maxBin = fftSize / 2 - 1
return bandCentres.map { fc in return bandCentres.map { fc in
let lo = 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 / 6.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))) 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 weighted: [Float]
private var cWeighted: [Float] private var cWeighted: [Float]
private var meanSquare: Double = 0 // exponential integrator, linear power // One integrator per averaging the dashboard might ask for. They all run
private var flatMeanSquare: Double = 0 // same integrator, unweighted — for dBFS // all the time and all go on the wire: a tile choosing "Slow" must not
private let alphaPerSample: Double // 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. // Leq accumulators: running since reset, plus rolling 1 and 10 minute.
private var leqSum: Double = 0 private var leqSum: Double = 0
@@ -283,7 +309,7 @@ final class ChannelMeter {
private var peakZ: Double = 0 private var peakZ: Double = 0
private var clipped = false 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) { framesPerSecond: Double) {
self.index = index self.index = index
self.fs = fs self.fs = fs
@@ -292,8 +318,9 @@ final class ChannelMeter {
cFilter = WeightingFilter(.c, fs: fs) cFilter = WeightingFilter(.c, fs: fs)
weighted = [] weighted = []
cWeighted = [] cWeighted = []
let tau = fast ? 0.125 : 1.0 // The time constants IEC 61672 names: Fast is 125 ms, Slow is 1 s.
alphaPerSample = 1.0 - exp(-1.0 / (tau * fs)) 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 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 // 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 // this is a few hundred thousand multiply-adds a second — noise next
// to the filtering above. // to the filtering above.
var ms = meanSquare var fast = msFast, slow = msSlow, flat = flatMeanSquare
var flat = flatMeanSquare
weighted.withUnsafeBufferPointer { w in weighted.withUnsafeBufferPointer { w in
for i in 0..<n { for i in 0..<n {
let v = Double(w[i]) let v = Double(w[i]) * Double(w[i])
ms += alphaPerSample * (v * v - ms) fast += alphaFast * (v - fast)
slow += alphaSlow * (v - slow)
let r = Double(samples[i]) let r = Double(samples[i])
flat += alphaPerSample * (r * r - flat) flat += alphaFast * (r * r - flat)
} }
} }
meanSquare = ms msFast = fast; msSlow = slow; flatMeanSquare = flat
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 var sumSq: Float = 0
weighted.withUnsafeBufferPointer { w in weighted.withUnsafeBufferPointer { w in
vDSP_measqv(w.baseAddress!, 1, &sumSq, vDSP_Length(n)) vDSP_measqv(w.baseAddress!, 1, &sumSq, vDSP_Length(n))
} }
msNone = Double(sumSq)
leqSum += Double(sumSq) * Double(n) leqSum += Double(sumSq) * Double(n)
leqCount += 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 /// wire and noise in a diff — `%.2f` says what a meter can actually
/// resolve and nothing more. /// resolve and nothing more.
func snapshot() -> String { func snapshot() -> String {
ring.append(meanSquare) ring.append(msNone)
if ring.count > ringCapacity { ring.removeFirst(ring.count - ringCapacity) } if ring.count > ringCapacity { ring.removeFirst(ring.count - ringCapacity) }
let fps = Double(ringCapacity) / 600.0 let fps = Double(ringCapacity) / 600.0
@@ -365,8 +394,10 @@ final class ChannelMeter {
} }
return "{\"i\":\(index)" return "{\"i\":\(index)"
+ f("spl", db(meanSquare)) + f("fast", db(msFast))
+ f("leq", leqCount > 0 ? db(leqSum / leqCount) : -200) + f("slow", db(msSlow))
+ f("inf", leqCount > 0 ? db(leqSum / leqCount) : -200)
+ f("none", db(msNone))
+ f("leq1m", rollingLeq(seconds: 60)) + f("leq1m", rollingLeq(seconds: 60))
+ f("leq10m", rollingLeq(seconds: 600)) + f("leq10m", rollingLeq(seconds: 600))
+ f("peakC", db(peakC * peakC)) + f("peakC", db(peakC * peakC))
@@ -395,6 +426,14 @@ func num(_ v: Double) -> String {
return String(format: "%.2f", max(v, -200)) 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 ──────────────────────── */ /* ── spectrum for the RTA / spectrograph tiles ──────────────────────── */
final class Spectrum { final class Spectrum {
@@ -484,7 +523,6 @@ var wantDevice: String? = nil
var wantChannels: [Int] = [1] var wantChannels: [Int] = [1]
var wantCal: [Double] = [] var wantCal: [Double] = []
var wantWeighting = Weighting.a var wantWeighting = Weighting.a
var wantFast = true
var wantBands = false var wantBands = false
var framesPerSecond = 20.0 var framesPerSecond = 20.0
@@ -509,10 +547,6 @@ while argIdx < argv.count {
let raw = nextArg("--weighting").uppercased() let raw = nextArg("--weighting").uppercased()
guard let w = Weighting(rawValue: raw) else { fail("--weighting must be A, C or Z") } guard let w = Weighting(rawValue: raw) else { fail("--weighting must be A, C or Z") }
wantWeighting = w 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 "--bands": wantBands = true
case "--rate": case "--rate":
guard let r = Double(nextArg("--rate")), r >= 1, r <= 50 else { fail("--rate must be 1-50 (frames per second)") } 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 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) 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, " note("capturing \"\(device.name)\": \(channelCount) channels @ \(Int(sampleRate)) Hz, "
+ "measuring \(wantChannels.map(String.init).joined(separator: ",")) " + "measuring \(wantChannels.map(String.init).joined(separator: ",")) "
+ "\(wantWeighting.rawValue)-weighted \(wantFast ? "fast" : "slow")" + "\(wantWeighting.rawValue)-weighted"
+ (calibrated ? "" : ", UNCALIBRATED")) + (calibrated ? "" : ", UNCALIBRATED"))
// Announced once so the reader knows the band plan without hardcoding it. // Announced once so the reader knows the band plan without hardcoding it.
@@ -620,9 +654,12 @@ let hello: [String: Any] = [
"deviceChannels": channelCount, "deviceChannels": channelCount,
"channels": wantChannels, "channels": wantChannels,
"weighting": wantWeighting.rawValue, "weighting": wantWeighting.rawValue,
"filter": wantFast ? "fast" : "slow",
"calibrated": calibrated, "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 let stdoutHandle = FileHandle.standardOutput
func emitLine(_ json: String) { func emitLine(_ json: String) {
@@ -680,7 +717,7 @@ timer.setEventHandler {
if wantBands { if wantBands {
let sets = spectra.enumerated().map { (i, s) -> String in let sets = spectra.enumerated().map { (i, s) -> String in
"[" + s.bands(calibration: calibrated ? wantCal[i] : 0) "[" + s.bands(calibration: calibrated ? wantCal[i] : 0)
.map(num).joined(separator: ",") + "]" .map(num1).joined(separator: ",") + "]"
} }
line += ",\"bands\":[" + sets.joined(separator: ",") + "]" line += ",\"bands\":[" + sets.joined(separator: ",") + "]"
} }
+6 -4
View File
@@ -38,13 +38,13 @@ const BAD_CONFIG_RETRY_MS = 30000;
const STALL_MS = 3000; 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, * bands:boolean, calibration:number[], moduleDir:string,
* log:Function, onUpdate:(u:object)=>void}} opts * log:Function, onUpdate:(u:object)=>void}} opts
* @returns {{stop:()=>void, reset:()=>void}} * @returns {{stop:()=>void, reset:()=>void}}
*/ */
function createSplListener(opts) { 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'); const bin = path.join(moduleDir, 'spl-capture');
let child = null; let child = null;
@@ -141,8 +141,10 @@ function createSplListener(opts) {
return; 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(','), const args = ['--device', device, '--channels', channels.join(','),
'--weighting', weighting, '--filter', filter, '--rate', '20']; '--weighting', weighting, '--rate', '20'];
if (bands) args.push('--bands'); if (bands) args.push('--bands');
if (calibration.length === channels.length) args.push('--cal', calibration.join(',')); if (calibration.length === channels.length) args.push('--cal', calibration.join(','));
@@ -171,7 +173,7 @@ function createSplListener(opts) {
if (obj.hello) { if (obj.hello) {
meta = obj; meta = obj;
log(`measuring ${obj.device} ch ${obj.channels.join(',')} ` log(`measuring ${obj.device} ch ${obj.channels.join(',')} `
+ `${obj.weighting}-weighted ${obj.filter}` + `${obj.weighting}-weighted`
+ (obj.calibrated ? '' : ' (uncalibrated)')); + (obj.calibrated ? '' : ' (uncalibrated)'));
continue; continue;
} }