Merge pull request 'SPL Meter: measure the room, not just watch it' (#10) from claude/spl-meter-module into main
This commit was merged in pull request #10.
This commit is contained in:
@@ -14,3 +14,6 @@ modules/propresenter-timers/ltc-capture
|
|||||||
# the launcher's build output, and its cache of the Node runtime it embeds
|
# the launcher's build output, and its cache of the Node runtime it embeds
|
||||||
launcher/macos/build/
|
launcher/macos/build/
|
||||||
launcher/macos/.cache/
|
launcher/macos/.cache/
|
||||||
|
|
||||||
|
# Compiled per-machine, source sits next to it (see spl-capture.swift).
|
||||||
|
modules/spl-meter/spl-capture
|
||||||
|
|||||||
@@ -0,0 +1,531 @@
|
|||||||
|
/**
|
||||||
|
* SPL Meter tiles.
|
||||||
|
*
|
||||||
|
* Four kinds, all fed by one SSE stream from the module's server:
|
||||||
|
*
|
||||||
|
* readout the number, big
|
||||||
|
* bars one level bar per channel, with peak hold
|
||||||
|
* rta spectrum now, folded to the banding this tile wants
|
||||||
|
* spectrogram the same bands over time, either axis
|
||||||
|
*
|
||||||
|
* Nothing here measures anything. The capture child already sent every
|
||||||
|
* averaging and the finest banding, and each tile folds that down to the
|
||||||
|
* view it was configured for. That is the whole design: two dashboards can
|
||||||
|
* watch one room through different settings without either disturbing the
|
||||||
|
* measurement or the other dashboard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FLOOR = -200;
|
||||||
|
|
||||||
|
/** Retention per frame for the RTA's smoothing setting. */
|
||||||
|
const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 };
|
||||||
|
|
||||||
|
export default function create({ root, moduleApi }) {
|
||||||
|
const variant = moduleApi.variant || 'readout';
|
||||||
|
let stream = null;
|
||||||
|
let latest = null;
|
||||||
|
let raf = 0;
|
||||||
|
|
||||||
|
root.classList.add('spl');
|
||||||
|
root.dataset.variant = variant;
|
||||||
|
|
||||||
|
const view = buildView(variant, root, moduleApi);
|
||||||
|
|
||||||
|
function onState(payload) {
|
||||||
|
latest = payload;
|
||||||
|
if (payload.error) {
|
||||||
|
moduleApi.setStatus('error', payload.error);
|
||||||
|
} else if (!payload.frame) {
|
||||||
|
moduleApi.setStatus('connecting', 'waiting for the first reading');
|
||||||
|
} else if (!payload.calibrated) {
|
||||||
|
moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL');
|
||||||
|
} else {
|
||||||
|
moduleApi.setStatus('ok', '');
|
||||||
|
}
|
||||||
|
// Frames land 20×/s; paint on a frame the browser was going to draw
|
||||||
|
// anyway rather than fighting it for every one.
|
||||||
|
if (!raf) raf = requestAnimationFrame(() => { raf = 0; view.render(latest); });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
moduleApi.setStatus('connecting', 'connecting');
|
||||||
|
moduleApi.fetch('/state')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then(onState)
|
||||||
|
.catch(() => { /* the stream will bring it */ });
|
||||||
|
// Named events go under `events` — a top-level `state(ev)` is silently
|
||||||
|
// never registered, and the tile just sits on whatever /state gave it.
|
||||||
|
stream = moduleApi.sse('/stream', {
|
||||||
|
events: {
|
||||||
|
state(ev) {
|
||||||
|
try { onState(JSON.parse(ev.data)); } catch { /* keep the last good frame */ }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error() { moduleApi.setStatus('connecting', 'reconnecting'); },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
if (stream) { stream.close(); stream = null; }
|
||||||
|
if (raf) { cancelAnimationFrame(raf); raf = 0; }
|
||||||
|
view.destroy();
|
||||||
|
},
|
||||||
|
onResize() { view.resize(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── shared helpers ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function pickChannel(payload, settings) {
|
||||||
|
if (!payload || !payload.frame) return null;
|
||||||
|
const want = Number(settings.channel);
|
||||||
|
return payload.frame.ch.find((c) => c.i === want) || payload.frame.ch[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The level this tile asked for: an averaging, or one of the fixed metrics.
|
||||||
|
*
|
||||||
|
* Tiles saved by an earlier version carry metrics that no longer exist —
|
||||||
|
* "spl" became an averaging, "leq" became Inf. A layout on a wall somewhere
|
||||||
|
* must not start reading dashes because a setting was renamed, so those map
|
||||||
|
* forward instead of falling through to undefined.
|
||||||
|
*/
|
||||||
|
const LEGACY_METRIC = { spl: 'level', leq: 'level' };
|
||||||
|
const LEGACY_AVERAGING = { leq: 'inf' };
|
||||||
|
|
||||||
|
function readValue(ch, settings) {
|
||||||
|
const raw = settings.metric || 'level';
|
||||||
|
const metric = LEGACY_METRIC[raw] || raw;
|
||||||
|
if (metric !== 'level') {
|
||||||
|
return metric in ch ? ch[metric] : ch.fast;
|
||||||
|
}
|
||||||
|
const avg = LEGACY_AVERAGING[raw] || settings.averaging || 'fast';
|
||||||
|
return avg in ch ? ch[avg] : ch.fast;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(db) {
|
||||||
|
if (!Number.isFinite(db) || db <= FLOOR) return '––.–';
|
||||||
|
return db.toFixed(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeColor(el, name, fallback) {
|
||||||
|
const v = getComputedStyle(el).getPropertyValue(name).trim();
|
||||||
|
return v || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hzLabel(f) {
|
||||||
|
if (f >= 1000) {
|
||||||
|
const k = f / 1000;
|
||||||
|
return (k >= 10 ? Math.round(k) : Math.round(k * 10) / 10) + 'k';
|
||||||
|
}
|
||||||
|
return String(f >= 100 ? Math.round(f) : Math.round(f * 10) / 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold the 1/48-octave bands on the wire down to this tile's banding.
|
||||||
|
*
|
||||||
|
* Every banding offered divides 48, so a coarser band is an exact sum of
|
||||||
|
* finer ones in the power domain — no interpolation, no resampling. Groups
|
||||||
|
* are keyed on the 1 kHz band (n = 0), which is what keeps folded centres on
|
||||||
|
* the values an RTA is expected to show.
|
||||||
|
*
|
||||||
|
* @returns {{freq:number[], db:number[]}}
|
||||||
|
*/
|
||||||
|
function foldBands(bands, bandN0, perOctave, denom) {
|
||||||
|
const g = Math.max(1, Math.round(perOctave / denom));
|
||||||
|
const power = new Map();
|
||||||
|
for (let i = 0; i < bands.length; i++) {
|
||||||
|
const n = bandN0 + i;
|
||||||
|
const k = Math.round(n / g);
|
||||||
|
const p = 10 ** (bands[i] / 10);
|
||||||
|
power.set(k, (power.get(k) || 0) + p);
|
||||||
|
}
|
||||||
|
const keys = [...power.keys()].sort((a, b) => a - b);
|
||||||
|
return {
|
||||||
|
freq: keys.map((k) => 1000 * 2 ** ((k * g) / perOctave)),
|
||||||
|
db: keys.map((k) => 10 * Math.log10(Math.max(power.get(k), 1e-20))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which frequency labels fit, most important first.
|
||||||
|
*
|
||||||
|
* Decades earn their place before octaves, octaves before thirds, and
|
||||||
|
* anything that would collide with a label already placed is dropped. That
|
||||||
|
* is what makes the axis thin out as a tile shrinks instead of turning into
|
||||||
|
* a smear.
|
||||||
|
*/
|
||||||
|
const LABEL_TIERS = [
|
||||||
|
[100, 1000, 10000],
|
||||||
|
[31.5, 63, 250, 500, 2000, 4000, 8000, 20000],
|
||||||
|
[20, 40, 50, 80, 125, 160, 200, 315, 400, 630, 800, 1250, 1600, 2500, 3150, 5000, 6300, 12500, 16000],
|
||||||
|
];
|
||||||
|
|
||||||
|
function chooseLabels(freqs, positionOf, extent, minGapPx) {
|
||||||
|
const placed = [];
|
||||||
|
for (const tier of LABEL_TIERS) {
|
||||||
|
for (const f of tier) {
|
||||||
|
if (f < freqs[0] * 0.92 || f > freqs[freqs.length - 1] * 1.08) continue;
|
||||||
|
const pos = positionOf(f);
|
||||||
|
if (pos < 0 || pos > extent) continue;
|
||||||
|
if (placed.some((p) => Math.abs(p.pos - pos) < minGapPx)) continue;
|
||||||
|
placed.push({ f, pos });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return placed.sort((a, b) => a.pos - b.pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── views ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function buildView(variant, root, moduleApi) {
|
||||||
|
switch (variant) {
|
||||||
|
case 'bars': return barsView(root, moduleApi);
|
||||||
|
case 'rta': return spectrumView(root, moduleApi, false);
|
||||||
|
case 'spectrogram': return spectrumView(root, moduleApi, true);
|
||||||
|
default: return readoutView(root, moduleApi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readoutView(root, moduleApi) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="spl-readout">
|
||||||
|
<div class="spl-value"><span class="spl-num">––.–</span><span class="spl-unit">dB</span></div>
|
||||||
|
<div class="spl-sub"></div>
|
||||||
|
</div>`;
|
||||||
|
const num = root.querySelector('.spl-num');
|
||||||
|
const unit = root.querySelector('.spl-unit');
|
||||||
|
const sub = root.querySelector('.spl-sub');
|
||||||
|
const box = root.querySelector('.spl-readout');
|
||||||
|
const value = root.querySelector('.spl-value');
|
||||||
|
let fitKey = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grow the number until it fills the tile, measuring the text we actually
|
||||||
|
* have rather than assuming a digit count.
|
||||||
|
*
|
||||||
|
* The CSS can only guess at width — it has to pick a cqw coefficient for
|
||||||
|
* some number of characters, and then "-100.5" overflows the tile that
|
||||||
|
* "-45.9" merely filled. Measuring costs one layout read, and only when
|
||||||
|
* the tile size or the length of the reading changes.
|
||||||
|
*/
|
||||||
|
function fit() {
|
||||||
|
const w = root.clientWidth, h = root.clientHeight;
|
||||||
|
const key = `${w}x${h}:${num.textContent.length}:${unit.textContent}:${sub.hidden}`;
|
||||||
|
if (key === fitKey || w < 2 || h < 2) return;
|
||||||
|
fitKey = key;
|
||||||
|
|
||||||
|
const TRIAL = 100;
|
||||||
|
num.style.fontSize = `${TRIAL}px`;
|
||||||
|
unit.style.fontSize = `${TRIAL * 0.26}px`;
|
||||||
|
const natural = value.scrollWidth || 1;
|
||||||
|
|
||||||
|
// Leave the caption its room when it is showing, and a little air either
|
||||||
|
// way so descenders and the tile's padding never clip.
|
||||||
|
const subH = sub.offsetParent ? sub.getBoundingClientRect().height + 6 : 0;
|
||||||
|
const byHeight = (h - subH) * 0.92;
|
||||||
|
const byWidth = ((w - 8) * TRIAL) / natural;
|
||||||
|
const size = Math.max(12, Math.min(byHeight, byWidth));
|
||||||
|
num.style.fontSize = `${size}px`;
|
||||||
|
unit.style.fontSize = `${size * 0.26}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
render(payload) {
|
||||||
|
const s = moduleApi.instanceSettings;
|
||||||
|
const ch = pickChannel(payload, s);
|
||||||
|
if (!payload || payload.error || !ch) {
|
||||||
|
num.textContent = '––.–';
|
||||||
|
sub.textContent = payload && payload.error ? payload.error : '';
|
||||||
|
box.dataset.level = 'idle';
|
||||||
|
fit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = readValue(ch, s);
|
||||||
|
num.textContent = fmt(value);
|
||||||
|
unit.textContent = payload.calibrated ? 'dB' : 'dBFS';
|
||||||
|
|
||||||
|
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
||||||
|
box.dataset.level = !payload.calibrated ? 'idle'
|
||||||
|
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
||||||
|
|
||||||
|
const raw = s.metric || 'level';
|
||||||
|
const metric = LEGACY_METRIC[raw] || raw;
|
||||||
|
const avg = LEGACY_AVERAGING[raw] || s.averaging || 'fast';
|
||||||
|
const label = metric === 'level'
|
||||||
|
? ({ fast: 'Fast', slow: 'Slow', inf: 'Inf', none: 'No avg' }[avg] || 'Fast')
|
||||||
|
: ({ leq1m: 'Leq 1 min', leq10m: 'Leq 10 min', peakC: 'Peak C' }[metric] || metric);
|
||||||
|
const parts = [`${payload.weighting || '?'} · ${label}`, `ch ${ch.i}`];
|
||||||
|
if (ch.clip) parts.push('CLIP');
|
||||||
|
if (!payload.calibrated) parts.push('uncalibrated');
|
||||||
|
sub.hidden = s.caption === false;
|
||||||
|
sub.textContent = parts.join(' · ');
|
||||||
|
fit();
|
||||||
|
},
|
||||||
|
resize() { fitKey = ''; fit(); },
|
||||||
|
destroy() { root.innerHTML = ''; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function barsView(root, moduleApi) {
|
||||||
|
root.innerHTML = '<div class="spl-bars"></div>';
|
||||||
|
const host = root.querySelector('.spl-bars');
|
||||||
|
let built = '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
render(payload) {
|
||||||
|
if (!payload || !payload.frame) { host.textContent = payload?.error || ''; built = ''; return; }
|
||||||
|
const chans = payload.frame.ch;
|
||||||
|
const key = chans.map((c) => c.i).join(',');
|
||||||
|
if (key !== built) {
|
||||||
|
built = key;
|
||||||
|
host.innerHTML = chans.map((c) => `
|
||||||
|
<div class="spl-bar" data-ch="${c.i}">
|
||||||
|
<div class="spl-bar-label">${c.i}</div>
|
||||||
|
<div class="spl-bar-track"><div class="spl-bar-fill"></div><div class="spl-bar-peak"></div></div>
|
||||||
|
<div class="spl-bar-value">––.–</div>
|
||||||
|
</div>`).join('');
|
||||||
|
}
|
||||||
|
const s = moduleApi.instanceSettings;
|
||||||
|
const warn = Number(s.warnAt), alarm = Number(s.alarmAt);
|
||||||
|
const showPeak = s.peakHold !== false;
|
||||||
|
const lo = payload.calibrated ? 40 : -80;
|
||||||
|
const hi = payload.calibrated ? 110 : 0;
|
||||||
|
for (const c of chans) {
|
||||||
|
const el = host.querySelector(`.spl-bar[data-ch="${c.i}"]`);
|
||||||
|
if (!el) continue;
|
||||||
|
const value = readValue(c, { averaging: s.averaging });
|
||||||
|
const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
|
||||||
|
el.querySelector('.spl-bar-fill').style.width = `${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'
|
||||||
|
: value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok';
|
||||||
|
el.classList.toggle('clip', !!c.clip);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resize() {},
|
||||||
|
destroy() { root.innerHTML = ''; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RTA bars, or the same folded bands scrolling as a spectrogram.
|
||||||
|
*
|
||||||
|
* Canvas for both, because both redraw wholesale many times a second and a
|
||||||
|
* few hundred animating DOM nodes is how a dashboard starts dropping frames
|
||||||
|
* on the machine that is also running the service.
|
||||||
|
*/
|
||||||
|
function spectrumView(root, moduleApi, scrolling) {
|
||||||
|
root.innerHTML = '<canvas class="spl-canvas"></canvas>';
|
||||||
|
const canvas = root.querySelector('.spl-canvas');
|
||||||
|
const ctx = canvas.getContext('2d', { alpha: false });
|
||||||
|
let painted = false; // spectrogram: has the background been laid down
|
||||||
|
let smoothed = null; // RTA: displayed values, damped toward the real ones
|
||||||
|
let smoothedFor = '';
|
||||||
|
let axisBtn = null;
|
||||||
|
let axisOverride = null; // set by the title-bar toggle, which never remounts us
|
||||||
|
|
||||||
|
function axis() {
|
||||||
|
return axisOverride || moduleApi.instanceSettings.axis || 'x';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrolling) {
|
||||||
|
// The axis lives here and nowhere else (rule 11): a tile does not offer
|
||||||
|
// the same action twice. Icon only, tooltip carries the words (rule 9).
|
||||||
|
// saveInstanceSettings deliberately does not remount us, so the new
|
||||||
|
// value is held here as well and read back by axis().
|
||||||
|
const swapIcon = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" '
|
||||||
|
+ 'stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">'
|
||||||
|
+ '<path d="M2 5h9M8.5 2 11.5 5 8.5 8"/><path d="M14 11H5M7.5 8 4.5 11 7.5 14"/></svg>';
|
||||||
|
const describe = () => (axis() === 'x' ? 'Time left to right' : 'Time top to bottom');
|
||||||
|
axisBtn = moduleApi.header.addButton({
|
||||||
|
icon: swapIcon,
|
||||||
|
title: describe(),
|
||||||
|
onClick() {
|
||||||
|
axisOverride = axis() === 'x' ? 'y' : 'x';
|
||||||
|
moduleApi.saveInstanceSettings({ axis: axisOverride });
|
||||||
|
painted = false; // the picture means nothing in the other orientation
|
||||||
|
axisBtn.title = describe();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sizeCanvas() {
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const w = Math.max(1, Math.floor(root.clientWidth));
|
||||||
|
const h = Math.max(1, Math.floor(root.clientHeight));
|
||||||
|
if (canvas.width === Math.floor(w * dpr) && canvas.height === Math.floor(h * dpr)) return false;
|
||||||
|
canvas.width = Math.floor(w * dpr);
|
||||||
|
canvas.height = Math.floor(h * dpr);
|
||||||
|
canvas.style.width = `${w}px`;
|
||||||
|
canvas.style.height = `${h}px`;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
painted = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
sizeCanvas();
|
||||||
|
|
||||||
|
function norm(db, calibrated) {
|
||||||
|
const lo = calibrated ? 30 : -100;
|
||||||
|
const hi = calibrated ? 110 : -10;
|
||||||
|
return Math.max(0, Math.min(1, (db - lo) / (hi - lo)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function heat(v) {
|
||||||
|
const stops = [[10, 12, 20], [30, 90, 150], [90, 180, 140], [230, 190, 80], [220, 80, 60]];
|
||||||
|
const x = Math.max(0, Math.min(0.999, v)) * (stops.length - 1);
|
||||||
|
const i = Math.floor(x), t = x - i;
|
||||||
|
const a = stops[i], b = stops[i + 1] || stops[i];
|
||||||
|
return `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(a[2] + (b[2] - a[2]) * t)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
render(payload) {
|
||||||
|
sizeCanvas();
|
||||||
|
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||||
|
const bg = themeColor(root, '--panel', '#141414');
|
||||||
|
const muted = themeColor(root, '--muted', '#888');
|
||||||
|
const border = themeColor(root, '--border', '#333');
|
||||||
|
|
||||||
|
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 raw = payload.frame.bands[idx] || payload.frame.bands[0] || [];
|
||||||
|
if (!raw.length) return;
|
||||||
|
|
||||||
|
const denom = Number(s.banding) || 3;
|
||||||
|
const folded = foldBands(raw, payload.bandN0 || 0, payload.bandsPerOctave || 48, denom);
|
||||||
|
const freqs = folded.freq;
|
||||||
|
let values = folded.db;
|
||||||
|
const grid = s.gridlines === true;
|
||||||
|
const showLabels = s.labels !== false;
|
||||||
|
const axisPad = showLabels || grid ? 13 : 0;
|
||||||
|
|
||||||
|
if (!scrolling) {
|
||||||
|
// Damp the bars toward the measurement. Cosmetic: `values` is what
|
||||||
|
// gets drawn, never what gets reported.
|
||||||
|
const keep = SMOOTHING[s.smoothing] ?? SMOOTHING.medium;
|
||||||
|
const key = `${denom}:${values.length}`;
|
||||||
|
if (smoothedFor !== key || !smoothed || smoothed.length !== values.length) {
|
||||||
|
smoothed = values.slice();
|
||||||
|
smoothedFor = key;
|
||||||
|
} else if (keep > 0) {
|
||||||
|
for (let i = 0; i < values.length; i++) {
|
||||||
|
smoothed[i] = smoothed[i] * keep + values[i] * (1 - keep);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
smoothed = values.slice();
|
||||||
|
}
|
||||||
|
values = smoothed;
|
||||||
|
|
||||||
|
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||||
|
const plotH = h - axisPad;
|
||||||
|
const bw = w / values.length;
|
||||||
|
|
||||||
|
const xOf = (f) => (Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0])) * w;
|
||||||
|
const labels = chooseLabels(freqs, xOf, w, 34);
|
||||||
|
|
||||||
|
if (grid) {
|
||||||
|
ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.5;
|
||||||
|
ctx.beginPath();
|
||||||
|
for (const l of labels) {
|
||||||
|
const x = Math.round(l.pos) + 0.5;
|
||||||
|
ctx.moveTo(x, 0); ctx.lineTo(x, plotH);
|
||||||
|
}
|
||||||
|
for (let g = 0.2; g < 1; g += 0.2) {
|
||||||
|
const y = Math.round(plotH * g) + 0.5;
|
||||||
|
ctx.moveTo(0, y); ctx.lineTo(w, y);
|
||||||
|
}
|
||||||
|
ctx.stroke(); ctx.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < values.length; i++) {
|
||||||
|
const v = norm(values[i], payload.calibrated);
|
||||||
|
const bh = v * plotH;
|
||||||
|
ctx.fillStyle = heat(v);
|
||||||
|
ctx.fillRect(i * bw, plotH - bh, Math.max(1, bw - (bw > 3 ? 1 : 0)), bh);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showLabels) {
|
||||||
|
ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center';
|
||||||
|
for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, h - 3);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── spectrogram ──
|
||||||
|
const timeX = axis() === 'x';
|
||||||
|
const colW = 2;
|
||||||
|
const gutter = showLabels ? axisPad : 0;
|
||||||
|
// Frequency occupies whichever axis time does not.
|
||||||
|
const freqExtent = timeX ? h - gutter : w - gutter;
|
||||||
|
const timeExtent = timeX ? w : h;
|
||||||
|
|
||||||
|
if (!painted) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); painted = true; }
|
||||||
|
|
||||||
|
// Source rect in device pixels, destination in CSS pixels: the context
|
||||||
|
// carries a devicePixelRatio transform but the canvas as an image
|
||||||
|
// source does not, and letting drawImage infer them scales the picture
|
||||||
|
// up and pushes every column off the canvas.
|
||||||
|
if (timeX) {
|
||||||
|
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h);
|
||||||
|
} else {
|
||||||
|
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, -colW, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bandExtent = freqExtent / values.length;
|
||||||
|
for (let i = 0; i < values.length; i++) {
|
||||||
|
ctx.fillStyle = heat(norm(values[i], payload.calibrated));
|
||||||
|
if (timeX) {
|
||||||
|
// Low frequencies at the bottom, as every spectrogram has them.
|
||||||
|
ctx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent,
|
||||||
|
colW, Math.ceil(bandExtent) + 1);
|
||||||
|
} else {
|
||||||
|
// Low frequencies at the left.
|
||||||
|
ctx.fillRect(gutter + i * bandExtent, timeExtent - colW,
|
||||||
|
Math.ceil(bandExtent) + 1, colW);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grid || showLabels) {
|
||||||
|
const posOf = (f) => {
|
||||||
|
const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]);
|
||||||
|
return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent;
|
||||||
|
};
|
||||||
|
const labels = chooseLabels(freqs, posOf, timeX ? freqExtent : w, timeX ? 16 : 34);
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
if (timeX) ctx.fillRect(0, h - gutter, w, gutter);
|
||||||
|
else ctx.fillRect(0, 0, gutter, h);
|
||||||
|
ctx.fillStyle = muted; ctx.font = '9px system-ui';
|
||||||
|
ctx.strokeStyle = border; ctx.globalAlpha = grid ? 0.35 : 0; ctx.beginPath();
|
||||||
|
for (const l of labels) {
|
||||||
|
if (timeX) {
|
||||||
|
const y = Math.round(l.pos) + 0.5;
|
||||||
|
ctx.moveTo(0, y); ctx.lineTo(w, y);
|
||||||
|
} else {
|
||||||
|
const x = Math.round(l.pos) + 0.5;
|
||||||
|
ctx.moveTo(x, 0); ctx.lineTo(x, h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.stroke(); ctx.globalAlpha = 1;
|
||||||
|
ctx.textAlign = timeX ? 'left' : 'center';
|
||||||
|
for (const l of showLabels ? labels : []) {
|
||||||
|
if (timeX) ctx.fillText(hzLabel(l.f), 2, Math.min(h - gutter - 2, l.pos + 3));
|
||||||
|
else ctx.fillText(hzLabel(l.f), l.pos, h - 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resize() { sizeCanvas(); },
|
||||||
|
destroy() { root.innerHTML = ''; },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{
|
||||||
|
"id": "spl-meter",
|
||||||
|
"name": "SPL Meter",
|
||||||
|
"version": "0.3.1",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"client": "client.js",
|
||||||
|
"server": "server.js",
|
||||||
|
"style": "style.css",
|
||||||
|
"minSize": {
|
||||||
|
"w": 2,
|
||||||
|
"h": 1
|
||||||
|
},
|
||||||
|
"defaultSize": {
|
||||||
|
"w": 3,
|
||||||
|
"h": 2
|
||||||
|
},
|
||||||
|
"configGroups": {
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configSchema": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Sound level measurement",
|
||||||
|
"group": "Input",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"device": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Audio device",
|
||||||
|
"group": "Input",
|
||||||
|
"showWhen": "enabled",
|
||||||
|
"default": "",
|
||||||
|
"optionsRoute": "/devices"
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"type": "string",
|
||||||
|
"label": "Channels on that device (1-based, comma separated)",
|
||||||
|
"group": "Input",
|
||||||
|
"showWhen": "enabled",
|
||||||
|
"default": "1",
|
||||||
|
"help": "One meter per channel — \"1\" for a single measurement mic, \"1,2\" for a stereo pair."
|
||||||
|
},
|
||||||
|
"weighting": {
|
||||||
|
"type": "select",
|
||||||
|
"label": "Weighting",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bands": {
|
||||||
|
"type": "switch",
|
||||||
|
"label": "Spectrum analysis",
|
||||||
|
"group": "Measurement",
|
||||||
|
"default": true,
|
||||||
|
"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",
|
||||||
|
"label": "Offsets in dB, one per channel",
|
||||||
|
"group": "Calibration",
|
||||||
|
"default": "",
|
||||||
|
"help": "Leave empty and every reading is dB relative to full scale, labelled dBFS so nobody mistakes it for real SPL."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPL Meter server entry.
|
||||||
|
*
|
||||||
|
* Owns one capture child (spl-listener.js) and fans its frames out to every
|
||||||
|
* tile over SSE. Browsers never see the audio device, only the numbers.
|
||||||
|
*
|
||||||
|
* GET /stream → SSE, `state` events at the capture rate
|
||||||
|
* GET /state → the latest frame, for an instant first paint
|
||||||
|
* GET /devices → input devices, for the admin page's picker
|
||||||
|
* GET /channels → configured channels, for the tile gear picker
|
||||||
|
* POST /reset → clear Leq and peak hold
|
||||||
|
* GET /measure → what a calibrator is reading right now, so the
|
||||||
|
* admin page can tell the operator what to type
|
||||||
|
*
|
||||||
|
* Frames arrive 20×/s. That is the right rate for a meter to feel live and
|
||||||
|
* the wrong rate to re-broadcast blindly, so the SSE fan-out drops a frame
|
||||||
|
* whose numbers round to what the last one said.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const { createSplListener } = require('./spl-listener');
|
||||||
|
|
||||||
|
/** Reference level of a sound level calibrator. Effectively always 94 dB. */
|
||||||
|
const CALIBRATOR_DB = 94;
|
||||||
|
/** How long /measure averages for. Long enough to ride out a wobble, short
|
||||||
|
enough that nobody standing at the microphone gets bored. */
|
||||||
|
const MEASURE_MS = 3000;
|
||||||
|
/** SSE comment ping — keeps idle connections alive through sleepy Wi-Fi. */
|
||||||
|
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' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
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(',')
|
||||||
|
.map((s) => Number(s.trim()))
|
||||||
|
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCalibration(raw) {
|
||||||
|
if (!String(raw || '').trim()) return [];
|
||||||
|
return String(raw)
|
||||||
|
.split(',')
|
||||||
|
.map((s) => Number(s.trim()))
|
||||||
|
.filter((n) => Number.isFinite(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
init({ config, log }) {
|
||||||
|
const channels = parseChannels(config.channels);
|
||||||
|
const calibration = parseCalibration(config.calibration);
|
||||||
|
const moduleDir = __dirname;
|
||||||
|
|
||||||
|
state = {
|
||||||
|
channels,
|
||||||
|
calibrated: calibration.length === channels.length && channels.length > 0,
|
||||||
|
frame: null,
|
||||||
|
meta: null,
|
||||||
|
error: '',
|
||||||
|
streams: new Set(),
|
||||||
|
listener: null,
|
||||||
|
lastSent: '',
|
||||||
|
pinger: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!config.enabled) {
|
||||||
|
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
|
||||||
|
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||||
|
}
|
||||||
|
if (!config.device) {
|
||||||
|
state.error = 'No audio device chosen yet';
|
||||||
|
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||||
|
}
|
||||||
|
if (!channels.length) {
|
||||||
|
state.error = `Cannot read "${config.channels}" as a channel list — try 1, or 1,2`;
|
||||||
|
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||||
|
}
|
||||||
|
if (calibration.length && calibration.length !== channels.length) {
|
||||||
|
state.error = `${channels.length} channel(s) but ${calibration.length} calibration offset(s) — `
|
||||||
|
+ 'give one per channel, or leave the offsets empty';
|
||||||
|
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
state.listener = createSplListener({
|
||||||
|
device: config.device,
|
||||||
|
channels,
|
||||||
|
weighting: config.weighting || 'A',
|
||||||
|
bands: config.bands !== false,
|
||||||
|
calibration,
|
||||||
|
moduleDir,
|
||||||
|
log,
|
||||||
|
onUpdate(update) {
|
||||||
|
if (!state) return; // a frame in flight when the module unmounted
|
||||||
|
if (update.error) {
|
||||||
|
if (update.error !== state.error) log(update.error);
|
||||||
|
state.error = update.error;
|
||||||
|
state.frame = null;
|
||||||
|
broadcast();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.error = '';
|
||||||
|
state.frame = update.frame;
|
||||||
|
state.meta = update.meta;
|
||||||
|
broadcast();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mineStreams = state.streams;
|
||||||
|
state.pinger = setInterval(() => {
|
||||||
|
for (const res of mineStreams) {
|
||||||
|
try { res.write(': ping\n\n'); } catch { mineStreams.delete(res); }
|
||||||
|
}
|
||||||
|
}, PING_MS);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stop() { teardown(); },
|
||||||
|
health() {
|
||||||
|
if (state.error) return { status: 'error', message: state.error };
|
||||||
|
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
|
||||||
|
const n = state.channels.length;
|
||||||
|
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
|
||||||
|
return state.calibrated
|
||||||
|
? { status: 'ok', message: what }
|
||||||
|
: { status: 'ok', message: `${what} — uncalibrated, reading dBFS` };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 },
|
||||||
|
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 1 min' },
|
||||||
|
{ value: 'leq10m', label: 'Leq 10 min' },
|
||||||
|
{ value: 'peakC', label: 'Peak C' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
caption: { type: 'switch', label: 'Caption', default: true },
|
||||||
|
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',
|
||||||
|
options: [
|
||||||
|
{ value: 'off', label: 'Off' },
|
||||||
|
{ value: 'light', label: 'Light' },
|
||||||
|
{ value: 'medium', label: 'Medium' },
|
||||||
|
{ value: 'heavy', label: 'Heavy' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
labels: { type: 'switch', label: 'Labels', default: true },
|
||||||
|
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,
|
||||||
|
labels: { type: 'switch', label: 'Labels', default: true },
|
||||||
|
gridlines: { type: 'switch', label: 'Gridlines', default: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
routes({ config, log }) {
|
||||||
|
return {
|
||||||
|
'GET /state': (req, res) => {
|
||||||
|
json(res, 200, snapshot());
|
||||||
|
},
|
||||||
|
|
||||||
|
'GET /stream': (req, res) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
});
|
||||||
|
res.write('retry: 3000\n\n');
|
||||||
|
try {
|
||||||
|
res.write(`event: state\ndata: ${JSON.stringify(snapshot())}\n\n`);
|
||||||
|
} catch { return; }
|
||||||
|
// Hold this mount's set, not `state`. teardown() nulls `state` and
|
||||||
|
// then ends every stream, which fires these close handlers — a
|
||||||
|
// handler that reached through `state` threw on null and took the
|
||||||
|
// whole server down with it, on any re-init with a browser
|
||||||
|
// connected. Deleting from a set nobody reads any more is harmless.
|
||||||
|
const streams = state.streams;
|
||||||
|
streams.add(res);
|
||||||
|
req.on('close', () => streams.delete(res));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Device list for the admin page. Runs the capture tool's --list,
|
||||||
|
which is the only thing that knows what CoreAudio can see. */
|
||||||
|
'GET /devices': async (req, res) => {
|
||||||
|
try {
|
||||||
|
const devices = await listDevices();
|
||||||
|
json(res, 200, {
|
||||||
|
options: devices.map((d) => ({
|
||||||
|
value: d.name,
|
||||||
|
label: `${d.name} — ${d.channels} ch @ ${Math.round(d.sampleRate)} Hz`,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log(`device list failed: ${err.message}`);
|
||||||
|
json(res, 200, { options: [] }); // the saved value stays selectable
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Channel list for a tile's gear popover. */
|
||||||
|
'GET /channels': (req, res) => {
|
||||||
|
json(res, 200, {
|
||||||
|
options: state.channels.map((c) => ({ value: String(c), label: `Channel ${c}` })),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
'POST /reset': (req, res) => {
|
||||||
|
if (state.listener) state.listener.reset();
|
||||||
|
json(res, 200, { ok: true });
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the microphone is hearing right now, unweighted and
|
||||||
|
* uncalibrated, plus the offset that would make it read 94 dB.
|
||||||
|
*
|
||||||
|
* The module cannot write its own admin config, so this does not save
|
||||||
|
* anything — it hands back a number for the operator to paste into the
|
||||||
|
* Calibration box. Honest, if one step longer than it should be.
|
||||||
|
*/
|
||||||
|
'GET /measure': (req, res) => {
|
||||||
|
if (!state.listener || state.error) {
|
||||||
|
json(res, 409, { error: state.error || 'not measuring' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wanted = Number(new URLSearchParams(req.search || '').get('channel'))
|
||||||
|
|| state.channels[0];
|
||||||
|
const samples = [];
|
||||||
|
const started = Date.now();
|
||||||
|
const tick = setInterval(() => {
|
||||||
|
if (!state) { clearInterval(tick); return; } // module unmounted mid-measure
|
||||||
|
const ch = state.frame && state.frame.ch.find((c) => c.i === wanted);
|
||||||
|
if (ch && Number.isFinite(ch.dbfs) && ch.dbfs > -200) samples.push(ch.dbfs);
|
||||||
|
if (Date.now() - started < MEASURE_MS) return;
|
||||||
|
clearInterval(tick);
|
||||||
|
if (samples.length < 5) {
|
||||||
|
json(res, 200, {
|
||||||
|
channel: wanted,
|
||||||
|
error: 'No steady tone on that channel — check the calibrator is on and seated',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Mean of the linear powers, not of the decibels: averaging dB
|
||||||
|
// would quietly under-read a signal that wobbles at all.
|
||||||
|
const power = samples.reduce((s, db) => s + 10 ** (db / 10), 0) / samples.length;
|
||||||
|
const measured = 10 * Math.log10(power);
|
||||||
|
json(res, 200, {
|
||||||
|
channel: wanted,
|
||||||
|
measured: Math.round(measured * 100) / 100,
|
||||||
|
reference: CALIBRATOR_DB,
|
||||||
|
offset: Math.round((CALIBRATOR_DB - measured) * 100) / 100,
|
||||||
|
samples: samples.length,
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
req.on('close', () => clearInterval(tick));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── helpers ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function snapshot() {
|
||||||
|
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null };
|
||||||
|
return {
|
||||||
|
error: state.error,
|
||||||
|
calibrated: state.calibrated,
|
||||||
|
channels: state.channels,
|
||||||
|
weighting: state.meta ? state.meta.weighting : '',
|
||||||
|
bandCentres: state.meta ? state.meta.bands : [],
|
||||||
|
bandN0: state.meta ? state.meta.bandN0 : 0,
|
||||||
|
bandsPerOctave: state.meta ? state.meta.bandsPerOctave : 48,
|
||||||
|
frame: state.frame,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast() {
|
||||||
|
if (!state) return; // a listener callback landing after teardown
|
||||||
|
const payload = snapshot();
|
||||||
|
const line = JSON.stringify(payload);
|
||||||
|
if (line === state.lastSent) return; // nothing moved since last frame
|
||||||
|
state.lastSent = line;
|
||||||
|
for (const res of state.streams) {
|
||||||
|
try { res.write(`event: state\ndata: ${line}\n\n`); } catch { state.streams.delete(res); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function teardown() {
|
||||||
|
if (!state) return;
|
||||||
|
if (state.listener) state.listener.stop();
|
||||||
|
clearInterval(state.pinger);
|
||||||
|
for (const res of state.streams) { try { res.end(); } catch { /* already gone */ } }
|
||||||
|
state.streams.clear();
|
||||||
|
state = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listDevices() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 },
|
||||||
|
(err, stdout) => {
|
||||||
|
if (err && !stdout) { reject(err); return; }
|
||||||
|
try { resolve(JSON.parse(stdout).devices || []); }
|
||||||
|
catch (e) { reject(e); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(res, code, body) {
|
||||||
|
const text = JSON.stringify(body);
|
||||||
|
res.writeHead(code, {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(text),
|
||||||
|
});
|
||||||
|
res.end(text);
|
||||||
|
}
|
||||||
@@ -0,0 +1,735 @@
|
|||||||
|
// spl-capture — calibrated sound-level measurement from a CoreAudio input.
|
||||||
|
//
|
||||||
|
// Sibling of the Timers module's ltc-capture: same device enumeration, same
|
||||||
|
// "stdout is data, stderr is talk" contract, same one-file-no-dependencies
|
||||||
|
// rule. The difference is where the work happens. LTC ships raw samples to
|
||||||
|
// Node because the decode is cheap and single-channel; sound level is
|
||||||
|
// filtering and FFTs over every channel at the device's rate, so the
|
||||||
|
// measurement is done here and only the answers cross the pipe.
|
||||||
|
//
|
||||||
|
// Everything below uses Accelerate, which ships with macOS. No third-party
|
||||||
|
// code, no package manager, ~2 s to build:
|
||||||
|
//
|
||||||
|
// swiftc -O -o modules/spl-meter/spl-capture \
|
||||||
|
// modules/spl-meter/spl-capture.swift
|
||||||
|
//
|
||||||
|
// spl-capture --list
|
||||||
|
// every input-capable device: name, input channels, sample rate
|
||||||
|
// spl-capture --device "Dante Via 16 Channel" --channels 1,2 [--cal 0,0]
|
||||||
|
// measures those 1-based channels and writes one JSON line per frame
|
||||||
|
// to stdout, 20×/s. --cal takes one dB offset per channel, in the same
|
||||||
|
// order, from the calibration route (94 dB reference minus what we
|
||||||
|
// measured). With no --cal the numbers are dBFS-relative and the line
|
||||||
|
// says calibrated:false, so nothing downstream can mistake them for
|
||||||
|
// real SPL.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
// Verified against the standard's table: within 0.15 dB from 31.5 Hz to
|
||||||
|
// 4 kHz. The top octave is where the bilinear transform runs out of room —
|
||||||
|
// at 48 kHz the 16 kHz point sits about 3 dB low. That is a rounding error
|
||||||
|
// on a broadband programme number (A-weighting has already thrown 6.6 dB
|
||||||
|
// away up there) but it is a real limit, and the fix if it ever matters is
|
||||||
|
// to run the weighting at 96 kHz. `--selftest` prints the table so this
|
||||||
|
// claim can be checked rather than believed.
|
||||||
|
//
|
||||||
|
// macOS asks for microphone permission on first use, and the prompt belongs
|
||||||
|
// to whatever launched this — the ProdDash launcher, or Terminal. A server
|
||||||
|
// started over SSH gets SILENT ZEROS from every input with no error at all,
|
||||||
|
// which shows up downstream as a meter pinned at the floor.
|
||||||
|
|
||||||
|
import AVFoundation
|
||||||
|
import Accelerate
|
||||||
|
import CoreAudio
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
func fail(_ msg: String) -> Never {
|
||||||
|
FileHandle.standardError.write(("spl-capture: " + msg + "\n").data(using: .utf8)!)
|
||||||
|
exit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func note(_ msg: String) {
|
||||||
|
FileHandle.standardError.write(("spl-capture: " + msg + "\n").data(using: .utf8)!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CoreAudio device enumeration ───────────────────────────────────── */
|
||||||
|
|
||||||
|
struct InputDevice {
|
||||||
|
let id: AudioDeviceID
|
||||||
|
let name: String
|
||||||
|
let uid: String
|
||||||
|
let channels: Int
|
||||||
|
let sampleRate: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringProperty(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var ref: Unmanaged<CFString>? = nil
|
||||||
|
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
|
||||||
|
let err = withUnsafeMutablePointer(to: &ref) {
|
||||||
|
AudioObjectGetPropertyData(id, &addr, 0, nil, &size, $0)
|
||||||
|
}
|
||||||
|
guard err == noErr, let value = ref?.takeRetainedValue() else { return "" }
|
||||||
|
return value as String
|
||||||
|
}
|
||||||
|
|
||||||
|
func sampleRateProperty(_ id: AudioObjectID) -> Double {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyNominalSampleRate,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var rate: Float64 = 0
|
||||||
|
var size = UInt32(MemoryLayout<Float64>.size)
|
||||||
|
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, &rate) == noErr else { return 0 }
|
||||||
|
return rate
|
||||||
|
}
|
||||||
|
|
||||||
|
func inputChannelCount(_ id: AudioDeviceID) -> Int {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioDevicePropertyStreamConfiguration,
|
||||||
|
mScope: kAudioDevicePropertyScopeInput,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(id, &addr, 0, nil, &size) == noErr, size > 0 else { return 0 }
|
||||||
|
let listPtr = UnsafeMutableRawPointer.allocate(
|
||||||
|
byteCount: Int(size), alignment: MemoryLayout<AudioBufferList>.alignment)
|
||||||
|
defer { listPtr.deallocate() }
|
||||||
|
guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, listPtr) == noErr else { return 0 }
|
||||||
|
let buffers = UnsafeMutableAudioBufferListPointer(
|
||||||
|
listPtr.assumingMemoryBound(to: AudioBufferList.self))
|
||||||
|
return buffers.reduce(0) { $0 + Int($1.mNumberChannels) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func listInputDevices() -> [InputDevice] {
|
||||||
|
var addr = AudioObjectPropertyAddress(
|
||||||
|
mSelector: kAudioHardwarePropertyDevices,
|
||||||
|
mScope: kAudioObjectPropertyScopeGlobal,
|
||||||
|
mElement: kAudioObjectPropertyElementMain)
|
||||||
|
var size: UInt32 = 0
|
||||||
|
guard AudioObjectGetPropertyDataSize(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size) == noErr else { return [] }
|
||||||
|
var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout<AudioDeviceID>.size)
|
||||||
|
guard AudioObjectGetPropertyData(
|
||||||
|
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids) == noErr else { return [] }
|
||||||
|
|
||||||
|
var out: [InputDevice] = []
|
||||||
|
for id in ids {
|
||||||
|
let channels = inputChannelCount(id)
|
||||||
|
if channels == 0 { continue }
|
||||||
|
out.append(InputDevice(id: id,
|
||||||
|
name: stringProperty(id, kAudioObjectPropertyName),
|
||||||
|
uid: stringProperty(id, kAudioDevicePropertyDeviceUID),
|
||||||
|
channels: channels,
|
||||||
|
sampleRate: sampleRateProperty(id)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── frequency weighting (IEC 61672) ────────────────────────────────── */
|
||||||
|
|
||||||
|
/// Pole frequencies from the standard.
|
||||||
|
private let wf1 = 2 * Double.pi * 20.598997
|
||||||
|
private let wf2 = 2 * Double.pi * 107.65265
|
||||||
|
private let wf3 = 2 * Double.pi * 737.86223
|
||||||
|
private let wf4 = 2 * Double.pi * 12194.217
|
||||||
|
|
||||||
|
/// Bilinear-transform one analogue biquad into [b0, b1, b2, a1, a2].
|
||||||
|
///
|
||||||
|
/// `warpAt` prewarps the transform so that section's critical frequency
|
||||||
|
/// lands where the analogue prototype put it. Without it the plain
|
||||||
|
/// `c = 2·fs` mapping squeezes the top octave badly — it is worth about
|
||||||
|
/// 3 dB at 16 kHz for the 12.2 kHz pole pair.
|
||||||
|
func bilinear(_ b: (Double, Double, Double), _ a: (Double, Double, Double),
|
||||||
|
fs: Double, warpAt: Double = 0) -> [Double] {
|
||||||
|
let c = warpAt > 0 ? warpAt / tan(warpAt / (2 * fs)) : 2 * fs
|
||||||
|
let c2 = c * c
|
||||||
|
let B0 = b.0 * c2 + b.1 * c + b.2
|
||||||
|
let B1 = 2 * b.2 - 2 * b.0 * c2
|
||||||
|
let B2 = b.0 * c2 - b.1 * c + b.2
|
||||||
|
let A0 = a.0 * c2 + a.1 * c + a.2
|
||||||
|
let A1 = 2 * a.2 - 2 * a.0 * c2
|
||||||
|
let A2 = a.0 * c2 - a.1 * c + a.2
|
||||||
|
return [B0 / A0, B1 / A0, B2 / A0, A1 / A0, A2 / A0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// |H(f)| of a cascade of biquad sections, used to normalise gain at 1 kHz
|
||||||
|
/// and to print the verification table.
|
||||||
|
func cascadeResponse(_ secs: [Double], _ f: Double, fs: Double) -> Double {
|
||||||
|
let th = 2 * Double.pi * f / fs
|
||||||
|
var reT = 1.0, imT = 0.0
|
||||||
|
for s in stride(from: 0, to: secs.count, by: 5) {
|
||||||
|
let (b0, b1, b2, a1, a2) = (secs[s], secs[s+1], secs[s+2], secs[s+3], secs[s+4])
|
||||||
|
let nr = b0 + b1 * cos(th) + b2 * cos(2 * th)
|
||||||
|
let ni = -(b1 * sin(th) + b2 * sin(2 * th))
|
||||||
|
let dr = 1 + a1 * cos(th) + a2 * cos(2 * th)
|
||||||
|
let di = -(a1 * sin(th) + a2 * sin(2 * th))
|
||||||
|
let dd = dr * dr + di * di
|
||||||
|
guard dd > 0 else { return 0 }
|
||||||
|
let qr = (nr * dr + ni * di) / dd, qi = (ni * dr - nr * di) / dd
|
||||||
|
let t = reT * qr - imT * qi
|
||||||
|
imT = reT * qi + imT * qr
|
||||||
|
reT = t
|
||||||
|
}
|
||||||
|
return (reT * reT + imT * imT).squareRoot()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Weighting: String {
|
||||||
|
case a = "A", c = "C", z = "Z"
|
||||||
|
|
||||||
|
/// Biquad sections for this weighting at `fs`, normalised to 0 dB at 1 kHz.
|
||||||
|
func sections(fs: Double) -> [Double] {
|
||||||
|
var s: [Double]
|
||||||
|
switch self {
|
||||||
|
case .a:
|
||||||
|
s = bilinear((1, 0, 0), (1, 2 * wf1, wf1 * wf1), fs: fs)
|
||||||
|
+ bilinear((1, 0, 0), (1, wf2 + wf3, wf2 * wf3), fs: fs)
|
||||||
|
+ bilinear((0, 0, 1), (1, 2 * wf4, wf4 * wf4), fs: fs, warpAt: wf4)
|
||||||
|
case .c:
|
||||||
|
s = bilinear((1, 0, 0), (1, 2 * wf1, wf1 * wf1), fs: fs)
|
||||||
|
+ bilinear((0, 0, 1), (1, 2 * wf4, wf4 * wf4), fs: fs, warpAt: wf4)
|
||||||
|
case .z:
|
||||||
|
return [1, 0, 0, 0, 0] // flat, one pass-through section
|
||||||
|
}
|
||||||
|
let g = 1.0 / cascadeResponse(s, 1000.0, fs: fs)
|
||||||
|
s[0] *= g; s[1] *= g; s[2] *= g
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A weighting filter bound to one channel. The coefficients could be shared
|
||||||
|
/// but the delay line cannot, and a setup per channel costs a few hundred
|
||||||
|
/// bytes — not worth the aliasing risk of sharing one.
|
||||||
|
final class WeightingFilter {
|
||||||
|
private let setup: vDSP_biquad_Setup
|
||||||
|
private var delay: [Float]
|
||||||
|
private let sections: vDSP_Length
|
||||||
|
|
||||||
|
init(_ weighting: Weighting, fs: Double) {
|
||||||
|
let coeffs = weighting.sections(fs: fs)
|
||||||
|
sections = vDSP_Length(coeffs.count / 5)
|
||||||
|
guard let s = vDSP_biquad_CreateSetup(coeffs, sections) else {
|
||||||
|
fail("cannot build \(weighting.rawValue)-weighting filter")
|
||||||
|
}
|
||||||
|
setup = s
|
||||||
|
delay = [Float](repeating: 0, count: Int(sections) * 2 + 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { vDSP_biquad_DestroySetup(setup) }
|
||||||
|
|
||||||
|
func process(_ input: UnsafePointer<Float>, _ output: inout [Float], _ n: Int) {
|
||||||
|
vDSP_biquad(setup, &delay, input, 1, &output, 1, vDSP_Length(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── third-octave band plan for the RTA ─────────────────────────────── */
|
||||||
|
|
||||||
|
/// 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 per band, clamped to what the transform can actually
|
||||||
|
/// resolve.
|
||||||
|
///
|
||||||
|
/// This is the one caveat a reader of the RTA needs and the gear is not
|
||||||
|
/// allowed to carry (module rule 5, no explanatory text in a tile's
|
||||||
|
/// settings): 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 / 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)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── per-channel measurement state ──────────────────────────────────── */
|
||||||
|
|
||||||
|
/// Everything the standard asks for on one channel.
|
||||||
|
///
|
||||||
|
/// SPL is an exponential moving average of weighted mean square — Fast is a
|
||||||
|
/// 125 ms time constant, Slow 1 s, exactly as IEC 61672 defines them. Leq is
|
||||||
|
/// a plain energy average with no time constant at all, which is why it is
|
||||||
|
/// accumulated separately rather than read off the integrator.
|
||||||
|
final class ChannelMeter {
|
||||||
|
let index: Int // 1-based, as the operator counts them
|
||||||
|
private let fs: Double
|
||||||
|
private let calibration: Double // dB added to turn dBFS into dB SPL
|
||||||
|
|
||||||
|
private let weightFilter: WeightingFilter
|
||||||
|
private let cFilter: WeightingFilter
|
||||||
|
private var weighted: [Float]
|
||||||
|
private var cWeighted: [Float]
|
||||||
|
|
||||||
|
// 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
|
||||||
|
private var leqCount: Double = 0
|
||||||
|
private var ring: [Double] = [] // one mean-square per emitted frame
|
||||||
|
private let ringCapacity: Int
|
||||||
|
|
||||||
|
private var peakC: Double = 0 // linear, since reset
|
||||||
|
private var peakZ: Double = 0
|
||||||
|
private var clipped = false
|
||||||
|
|
||||||
|
init(index: Int, fs: Double, weighting: Weighting, calibration: Double,
|
||||||
|
framesPerSecond: Double) {
|
||||||
|
self.index = index
|
||||||
|
self.fs = fs
|
||||||
|
self.calibration = calibration
|
||||||
|
weightFilter = WeightingFilter(weighting, fs: fs)
|
||||||
|
cFilter = WeightingFilter(.c, fs: fs)
|
||||||
|
weighted = []
|
||||||
|
cWeighted = []
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Feed one tap buffer for this channel.
|
||||||
|
func push(_ samples: UnsafePointer<Float>, _ n: Int) {
|
||||||
|
if weighted.count < n {
|
||||||
|
weighted = [Float](repeating: 0, count: n)
|
||||||
|
cWeighted = [Float](repeating: 0, count: n)
|
||||||
|
}
|
||||||
|
weightFilter.process(samples, &weighted, n)
|
||||||
|
cFilter.process(samples, &cWeighted, n)
|
||||||
|
|
||||||
|
// Exponential integration over the weighted signal, sample by sample
|
||||||
|
// 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 fast = msFast, slow = msSlow, flat = flatMeanSquare
|
||||||
|
weighted.withUnsafeBufferPointer { w in
|
||||||
|
for i in 0..<n {
|
||||||
|
let v = Double(w[i]) * Double(w[i])
|
||||||
|
fast += alphaFast * (v - fast)
|
||||||
|
slow += alphaSlow * (v - slow)
|
||||||
|
let r = Double(samples[i])
|
||||||
|
flat += alphaFast * (r * r - flat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msFast = fast; msSlow = slow; flatMeanSquare = flat
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
var cPeak: Float = 0, zPeak: Float = 0
|
||||||
|
cWeighted.withUnsafeBufferPointer { c in
|
||||||
|
vDSP_maxmgv(c.baseAddress!, 1, &cPeak, vDSP_Length(n))
|
||||||
|
}
|
||||||
|
vDSP_maxmgv(samples, 1, &zPeak, vDSP_Length(n))
|
||||||
|
peakC = max(peakC, Double(cPeak))
|
||||||
|
peakZ = max(peakZ, Double(zPeak))
|
||||||
|
if zPeak >= 0.999 { clipped = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// dB SPL from a linear mean-square, with the calibration offset applied.
|
||||||
|
private func db(_ power: Double) -> Double {
|
||||||
|
20 * log10(max(power, 1e-20).squareRoot()) + calibration
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called once per emitted frame; also advances the rolling Leq window.
|
||||||
|
///
|
||||||
|
/// Returns a JSON object built by hand. JSONSerialization renders a
|
||||||
|
/// rounded Double as `-70.120000000000005`, which is both ugly on the
|
||||||
|
/// wire and noise in a diff — `%.2f` says what a meter can actually
|
||||||
|
/// resolve and nothing more.
|
||||||
|
func snapshot() -> String {
|
||||||
|
ring.append(msNone)
|
||||||
|
if ring.count > ringCapacity { ring.removeFirst(ring.count - ringCapacity) }
|
||||||
|
|
||||||
|
let fps = Double(ringCapacity) / 600.0
|
||||||
|
func rollingLeq(seconds: Double) -> Double {
|
||||||
|
let want = max(1, Int((seconds * fps).rounded()))
|
||||||
|
let slice = ring.suffix(want)
|
||||||
|
guard !slice.isEmpty else { return -200 }
|
||||||
|
return db(slice.reduce(0, +) / Double(slice.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
return "{\"i\":\(index)"
|
||||||
|
+ 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))
|
||||||
|
+ f("peakZ", db(peakZ * peakZ))
|
||||||
|
+ f("dbfs", 20 * log10(max(flatMeanSquare, 1e-20).squareRoot()))
|
||||||
|
+ ",\"clip\":\(clipped)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears Leq, peaks and the clip flag. The exponential integrator is
|
||||||
|
/// deliberately left alone — it is a live reading, not an accumulation,
|
||||||
|
/// and zeroing it would just make the meter dive for a moment.
|
||||||
|
func reset() {
|
||||||
|
leqSum = 0; leqCount = 0; ring.removeAll(keepingCapacity: true)
|
||||||
|
peakC = 0; peakZ = 0; clipped = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `,"key":-12.34` — two decimals, and a floor instead of -inf or NaN so a
|
||||||
|
/// silent input can never put a non-number on the wire.
|
||||||
|
func f(_ key: String, _ v: Double) -> String {
|
||||||
|
",\"\(key)\":" + num(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func num(_ v: Double) -> String {
|
||||||
|
guard v.isFinite else { return "-200.00" }
|
||||||
|
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 {
|
||||||
|
private let fftSize: Int
|
||||||
|
private let log2n: vDSP_Length
|
||||||
|
private let setup: FFTSetup
|
||||||
|
private var window: [Float]
|
||||||
|
private var accum: [Float] // sliding input buffer
|
||||||
|
private var filled = 0
|
||||||
|
private let bins: [(Int, Int)]
|
||||||
|
private let windowPower: Double
|
||||||
|
|
||||||
|
init(fs: Double, fftSize: Int) {
|
||||||
|
self.fftSize = fftSize
|
||||||
|
log2n = vDSP_Length(log2(Double(fftSize)).rounded())
|
||||||
|
guard let s = vDSP_create_fftsetup(log2n, FFTRadix(kFFTRadix2)) else {
|
||||||
|
fail("cannot create FFT setup")
|
||||||
|
}
|
||||||
|
setup = s
|
||||||
|
window = [Float](repeating: 0, count: fftSize)
|
||||||
|
vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_DENORM))
|
||||||
|
accum = [Float](repeating: 0, count: fftSize)
|
||||||
|
bins = bandBins(fs: fs, fftSize: fftSize)
|
||||||
|
// Coherent gain of the Hann window, so band levels are not 6 dB low.
|
||||||
|
windowPower = window.reduce(0.0) { $0 + Double($1) * Double($1) } / Double(fftSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit { vDSP_destroy_fftsetup(setup) }
|
||||||
|
|
||||||
|
/// Keep the most recent `fftSize` samples, shifting the window along.
|
||||||
|
/// Buffers larger than the window just overwrite it outright.
|
||||||
|
func push(_ samples: UnsafePointer<Float>, _ n: Int) {
|
||||||
|
if n >= fftSize {
|
||||||
|
accum.withUnsafeMutableBufferPointer { a in
|
||||||
|
a.baseAddress!.update(from: samples + (n - fftSize), count: fftSize)
|
||||||
|
}
|
||||||
|
filled = fftSize
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accum.withUnsafeMutableBufferPointer { a in
|
||||||
|
let base = a.baseAddress!
|
||||||
|
// Slide the old tail down, then append the new block.
|
||||||
|
base.update(from: base + n, count: fftSize - n)
|
||||||
|
(base + fftSize - n).update(from: samples, count: n)
|
||||||
|
}
|
||||||
|
filled = min(fftSize, filled + n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Third-octave band levels in dB relative to full scale, plus the
|
||||||
|
/// calibration offset applied by the caller.
|
||||||
|
func bands(calibration: Double) -> [Double] {
|
||||||
|
guard filled >= fftSize else { return bins.map { _ in -200 } }
|
||||||
|
var windowed = [Float](repeating: 0, count: fftSize)
|
||||||
|
vDSP_vmul(accum, 1, window, 1, &windowed, 1, vDSP_Length(fftSize))
|
||||||
|
|
||||||
|
let half = fftSize / 2
|
||||||
|
var re = [Float](repeating: 0, count: half)
|
||||||
|
var im = [Float](repeating: 0, count: half)
|
||||||
|
var mag = [Float](repeating: 0, count: half)
|
||||||
|
re.withUnsafeMutableBufferPointer { rp in
|
||||||
|
im.withUnsafeMutableBufferPointer { ip in
|
||||||
|
var split = DSPSplitComplex(realp: rp.baseAddress!, imagp: ip.baseAddress!)
|
||||||
|
windowed.withUnsafeBytes { raw in
|
||||||
|
vDSP_ctoz(raw.bindMemory(to: DSPComplex.self).baseAddress!, 2,
|
||||||
|
&split, 1, vDSP_Length(half))
|
||||||
|
}
|
||||||
|
vDSP_fft_zrip(setup, &split, 1, log2n, FFTDirection(FFT_FORWARD))
|
||||||
|
vDSP_zvmags(&split, 1, &mag, 1, vDSP_Length(half))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// vDSP's real FFT carries a factor of 2, and zvmags squares it.
|
||||||
|
let scale = 1.0 / (4.0 * Double(fftSize) * Double(fftSize) * windowPower)
|
||||||
|
return bins.map { (lo, hi) in
|
||||||
|
var power = 0.0
|
||||||
|
for b in lo...hi { power += Double(mag[b]) }
|
||||||
|
power *= scale * 2.0 // one-sided spectrum
|
||||||
|
return 10 * log10(max(power, 1e-20)) + calibration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── args ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
var wantList = false
|
||||||
|
var wantSelftest = false
|
||||||
|
var wantDevice: String? = nil
|
||||||
|
var wantChannels: [Int] = [1]
|
||||||
|
var wantCal: [Double] = []
|
||||||
|
var wantWeighting = Weighting.a
|
||||||
|
var wantBands = false
|
||||||
|
var framesPerSecond = 20.0
|
||||||
|
|
||||||
|
let argv = CommandLine.arguments
|
||||||
|
var argIdx = 1
|
||||||
|
func nextArg(_ flag: String) -> String {
|
||||||
|
argIdx += 1
|
||||||
|
guard argIdx < argv.count else { fail("\(flag) needs a value") }
|
||||||
|
return argv[argIdx]
|
||||||
|
}
|
||||||
|
while argIdx < argv.count {
|
||||||
|
switch argv[argIdx] {
|
||||||
|
case "--list": wantList = true
|
||||||
|
case "--selftest": wantSelftest = true
|
||||||
|
case "--device": wantDevice = nextArg("--device")
|
||||||
|
case "--channels":
|
||||||
|
wantChannels = nextArg("--channels").split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
|
||||||
|
if wantChannels.isEmpty { fail("--channels needs at least one 1-based channel number") }
|
||||||
|
case "--cal":
|
||||||
|
wantCal = nextArg("--cal").split(separator: ",").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) }
|
||||||
|
case "--weighting":
|
||||||
|
let raw = nextArg("--weighting").uppercased()
|
||||||
|
guard let w = Weighting(rawValue: raw) else { fail("--weighting must be A, C or Z") }
|
||||||
|
wantWeighting = w
|
||||||
|
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)") }
|
||||||
|
framesPerSecond = r
|
||||||
|
default: fail("unknown flag \(argv[argIdx]) — try --list")
|
||||||
|
}
|
||||||
|
argIdx += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── selftest: prove the weighting curves before trusting a reading ─── */
|
||||||
|
|
||||||
|
if wantSelftest {
|
||||||
|
// IEC 61672-1 table 3, nominal A- and C-weighting.
|
||||||
|
let table: [(Double, Double, Double)] = [
|
||||||
|
(31.5, -39.4, -3.0), (63, -26.2, -0.8), (125, -16.1, -0.2), (250, -8.6, 0.0),
|
||||||
|
(500, -3.2, 0.0), (1000, 0.0, 0.0), (2000, 1.2, -0.2), (4000, 1.0, -0.8),
|
||||||
|
(8000, -1.1, -3.0), (16000, -6.6, -8.5),
|
||||||
|
]
|
||||||
|
for fs in [48000.0, 96000.0] {
|
||||||
|
let a = Weighting.a.sections(fs: fs), c = Weighting.c.sections(fs: fs)
|
||||||
|
print("— \(Int(fs)) Hz —")
|
||||||
|
print(" Hz A IEC err C IEC err")
|
||||||
|
var worstA = 0.0, worstC = 0.0
|
||||||
|
for (f, wantA, wantC) in table where f < fs / 2 {
|
||||||
|
let gotA = 20 * log10(cascadeResponse(a, f, fs: fs))
|
||||||
|
let gotC = 20 * log10(cascadeResponse(c, f, fs: fs))
|
||||||
|
worstA = max(worstA, abs(gotA - wantA)); worstC = max(worstC, abs(gotC - wantC))
|
||||||
|
print(String(format: "%7.0f %+7.2f %+7.2f %+6.2f %+7.2f %+7.2f %+6.2f",
|
||||||
|
f, gotA, wantA, gotA - wantA, gotC, wantC, gotC - wantC))
|
||||||
|
}
|
||||||
|
print(String(format: " worst: A %.2f dB, C %.2f dB\n", worstA, worstC))
|
||||||
|
}
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── device selection ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
let devices = listInputDevices()
|
||||||
|
|
||||||
|
if wantList || wantDevice == nil {
|
||||||
|
if devices.isEmpty { fail("no input-capable audio devices found") }
|
||||||
|
// stdout, as JSON: the module's /devices route feeds this straight to
|
||||||
|
// the admin page's device picker.
|
||||||
|
let payload = devices.map { ["name": $0.name, "uid": $0.uid,
|
||||||
|
"channels": $0.channels, "sampleRate": $0.sampleRate] }
|
||||||
|
let data = try! JSONSerialization.data(withJSONObject: ["devices": payload])
|
||||||
|
FileHandle.standardOutput.write(data)
|
||||||
|
FileHandle.standardOutput.write("\n".data(using: .utf8)!)
|
||||||
|
if wantDevice == nil && !wantList { note("pick one with --device <name substring>") }
|
||||||
|
exit(wantList ? 0 : 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = wantDevice!.lowercased()
|
||||||
|
let exact = devices.filter { $0.name.lowercased() == query || $0.uid.lowercased() == query }
|
||||||
|
let matches = exact.count == 1 ? exact
|
||||||
|
: devices.filter { $0.name.lowercased().contains(query) || $0.uid.lowercased().contains(query) }
|
||||||
|
guard matches.count == 1 else {
|
||||||
|
if matches.isEmpty { fail("no input device matches \"\(wantDevice!)\" — see --list") }
|
||||||
|
fail("\"\(wantDevice!)\" is ambiguous: " + matches.map { $0.name }.joined(separator: ", "))
|
||||||
|
}
|
||||||
|
let device = matches[0]
|
||||||
|
|
||||||
|
/* ── capture ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
let engine = AVAudioEngine()
|
||||||
|
guard let unit = engine.inputNode.audioUnit else { fail("no input audio unit") }
|
||||||
|
var deviceID = device.id
|
||||||
|
let setErr = AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice,
|
||||||
|
kAudioUnitScope_Global, 0, &deviceID,
|
||||||
|
UInt32(MemoryLayout<AudioDeviceID>.size))
|
||||||
|
guard setErr == noErr else { fail("cannot select \"\(device.name)\" (error \(setErr))") }
|
||||||
|
|
||||||
|
let format = engine.inputNode.inputFormat(forBus: 0)
|
||||||
|
let channelCount = Int(format.channelCount)
|
||||||
|
let sampleRate = format.sampleRate
|
||||||
|
guard channelCount > 0, sampleRate > 0 else {
|
||||||
|
fail("\"\(device.name)\" reports no usable input format — microphone access may be denied "
|
||||||
|
+ "(System Settings → Privacy & Security → Microphone)")
|
||||||
|
}
|
||||||
|
for ch in wantChannels where ch < 1 || ch > channelCount {
|
||||||
|
fail("channel \(ch) is out of range — \"\(device.name)\" has \(channelCount)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let calibrated = !wantCal.isEmpty
|
||||||
|
if calibrated && wantCal.count != wantChannels.count {
|
||||||
|
fail("--cal needs one offset per channel (\(wantChannels.count) given \(wantCal.count))")
|
||||||
|
}
|
||||||
|
|
||||||
|
let meters = wantChannels.enumerated().map { (i, ch) in
|
||||||
|
ChannelMeter(index: ch, fs: sampleRate, weighting: wantWeighting,
|
||||||
|
calibration: calibrated ? wantCal[i] : 0, framesPerSecond: framesPerSecond)
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
+ (calibrated ? "" : ", UNCALIBRATED"))
|
||||||
|
|
||||||
|
// Announced once so the reader knows the band plan without hardcoding it.
|
||||||
|
let hello: [String: Any] = [
|
||||||
|
"hello": 1,
|
||||||
|
"device": device.name,
|
||||||
|
"sampleRate": sampleRate,
|
||||||
|
"deviceChannels": channelCount,
|
||||||
|
"channels": wantChannels,
|
||||||
|
"weighting": wantWeighting.rawValue,
|
||||||
|
"calibrated": calibrated,
|
||||||
|
// 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) {
|
||||||
|
do {
|
||||||
|
try stdoutHandle.write(contentsOf: Data((json + "\n").utf8))
|
||||||
|
} catch { exit(0) } // downstream pipe closed — nothing to say
|
||||||
|
}
|
||||||
|
if let data = try? JSONSerialization.data(withJSONObject: hello),
|
||||||
|
let line = String(data: data, encoding: .utf8) {
|
||||||
|
emitLine(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reset is asked for on stdin (one line, "reset") rather than a signal, so
|
||||||
|
// the listener can clear Leq and peaks without restarting capture and losing
|
||||||
|
// the filters' settling.
|
||||||
|
let resetQueue = DispatchQueue(label: "spl-capture.reset")
|
||||||
|
var resetRequested = false
|
||||||
|
DispatchQueue.global().async {
|
||||||
|
while let line = readLine(strippingNewline: true) {
|
||||||
|
if line.trimmingCharacters(in: .whitespaces).lowercased() == "reset" {
|
||||||
|
resetQueue.sync { resetRequested = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let tapQueue = DispatchQueue(label: "spl-capture.tap")
|
||||||
|
engine.inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { buffer, _ in
|
||||||
|
let frames = Int(buffer.frameLength)
|
||||||
|
guard frames > 0, let floats = buffer.floatChannelData else { return }
|
||||||
|
tapQueue.sync {
|
||||||
|
for (i, ch) in wantChannels.enumerated() {
|
||||||
|
let src = floats[ch - 1] // tap data is per-channel, not interleaved
|
||||||
|
meters[i].push(src, frames)
|
||||||
|
if wantBands { spectra[i].push(src, frames) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do { try engine.start() } catch {
|
||||||
|
fail("engine start failed: \(error.localizedDescription) — if this is a permission problem, "
|
||||||
|
+ "approve microphone access for the launching app and retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global())
|
||||||
|
timer.schedule(deadline: .now() + 1.0 / framesPerSecond,
|
||||||
|
repeating: 1.0 / framesPerSecond)
|
||||||
|
timer.setEventHandler {
|
||||||
|
var wantReset = false
|
||||||
|
resetQueue.sync { wantReset = resetRequested; resetRequested = false }
|
||||||
|
|
||||||
|
var line = "{\"t\":\(Int(Date().timeIntervalSince1970 * 1000))"
|
||||||
|
tapQueue.sync {
|
||||||
|
if wantReset { meters.forEach { $0.reset() } }
|
||||||
|
line += ",\"ch\":[" + meters.map { $0.snapshot() }.joined(separator: ",") + "]"
|
||||||
|
if wantBands {
|
||||||
|
let sets = spectra.enumerated().map { (i, s) -> String in
|
||||||
|
"[" + s.bands(calibration: calibrated ? wantCal[i] : 0)
|
||||||
|
.map(num1).joined(separator: ",") + "]"
|
||||||
|
}
|
||||||
|
line += ",\"bands\":[" + sets.joined(separator: ",") + "]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emitLine(line + "}")
|
||||||
|
}
|
||||||
|
timer.resume()
|
||||||
|
|
||||||
|
signal(SIGINT) { _ in exit(0) }
|
||||||
|
signal(SIGTERM) { _ in exit(0) }
|
||||||
|
RunLoop.main.run()
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sound level listener — owns the native capture child.
|
||||||
|
*
|
||||||
|
* Spawns spl-capture (compiled from spl-capture.swift beside it) for the
|
||||||
|
* admin-configured device and channels, reads its JSON lines, and hands the
|
||||||
|
* latest frame upward. All measurement happens in the child; this file is
|
||||||
|
* plumbing and supervision, nothing more.
|
||||||
|
*
|
||||||
|
* Reports via onUpdate:
|
||||||
|
*
|
||||||
|
* { frame: {...}, meta: {...} } measuring
|
||||||
|
* { error: '<what is wrong and how to fix it>' } not measuring
|
||||||
|
*
|
||||||
|
* The capture binary is per-machine and gitignored, exactly like the Timers
|
||||||
|
* module's ltc-capture. It normally arrives already built — compiled beside
|
||||||
|
* the app, or copied from any Mac of the same architecture. When it is
|
||||||
|
* missing this will build it, but in the background and never on the event
|
||||||
|
* loop: see ensureBinary for what that cost the first time it was wrong.
|
||||||
|
*
|
||||||
|
* The same macOS gotcha applies as for LTC, and is worth repeating because
|
||||||
|
* it costs an afternoon every time: a ProdDash launched over SSH or from a
|
||||||
|
* bare launchd job gets SILENT ZEROS from every input, with no error
|
||||||
|
* anywhere. Here that shows up as a meter pinned at -200. Launch from a
|
||||||
|
* logged-in GUI context and approve the microphone prompt once.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
|
const RESTART_MS = 5000;
|
||||||
|
/** An immediate exit is config-shaped (bad device, channel out of range) —
|
||||||
|
retrying every 5 s would only fill the log with the same sentence. */
|
||||||
|
const BAD_CONFIG_RETRY_MS = 30000;
|
||||||
|
/** No frame for this long means the child is alive but not delivering. */
|
||||||
|
const STALL_MS = 3000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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, bands, calibration, moduleDir, log, onUpdate } = opts;
|
||||||
|
const bin = path.join(moduleDir, 'spl-capture');
|
||||||
|
|
||||||
|
let child = null;
|
||||||
|
let stopped = false;
|
||||||
|
let restartTimer = null;
|
||||||
|
let stallTimer = null;
|
||||||
|
let startedAt = 0;
|
||||||
|
let meta = null;
|
||||||
|
let building = false;
|
||||||
|
let buildFailed = false;
|
||||||
|
let lastFrameWall = 0;
|
||||||
|
|
||||||
|
const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with '
|
||||||
|
+ '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` '
|
||||||
|
+ 'and copy it next to spl-capture.swift';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the capture tool if it is missing, WITHOUT blocking.
|
||||||
|
*
|
||||||
|
* This used to be a spawnSync, copied from the LTC listener where the
|
||||||
|
* source is a third the size and builds in a few seconds. Here it took
|
||||||
|
* nearly two minutes on the production Mac and froze the entire ProdDash
|
||||||
|
* server for every second of it — one module's optional helper took the
|
||||||
|
* whole dashboard down, then did it again on every retry. Nothing that
|
||||||
|
* runs while a service might be on is allowed to hold the event loop.
|
||||||
|
*
|
||||||
|
* The build is a fallback for a module installed from the catalog on a
|
||||||
|
* machine with a Swift toolchain. The normal path is a binary built
|
||||||
|
* alongside the app, or copied from any same-architecture Mac.
|
||||||
|
*
|
||||||
|
* @returns {string} '' when the binary is ready, otherwise what to say.
|
||||||
|
*/
|
||||||
|
function ensureBinary() {
|
||||||
|
if (fs.existsSync(bin)) return '';
|
||||||
|
const src = bin + '.swift';
|
||||||
|
if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder';
|
||||||
|
if (building) return 'Building the capture tool — this takes a minute or two';
|
||||||
|
if (buildFailed) return BUILD_HELP;
|
||||||
|
|
||||||
|
building = true;
|
||||||
|
log('spl-capture binary missing — building it in the background');
|
||||||
|
const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
let buildErr = '';
|
||||||
|
build.stderr.setEncoding('utf8');
|
||||||
|
build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); });
|
||||||
|
const giveUp = setTimeout(() => {
|
||||||
|
try { build.kill('SIGKILL'); } catch { /* already gone */ }
|
||||||
|
}, 300000);
|
||||||
|
build.on('error', (err) => {
|
||||||
|
clearTimeout(giveUp);
|
||||||
|
building = false; buildFailed = true;
|
||||||
|
log(`swiftc could not be run: ${err.message}`);
|
||||||
|
});
|
||||||
|
build.on('exit', (code) => {
|
||||||
|
clearTimeout(giveUp);
|
||||||
|
building = false;
|
||||||
|
if (code === 0 && fs.existsSync(bin)) {
|
||||||
|
log('spl-capture built');
|
||||||
|
buildFailed = false;
|
||||||
|
if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buildFailed = true;
|
||||||
|
log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : ''));
|
||||||
|
});
|
||||||
|
return 'Building the capture tool — this takes a minute or two';
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRestart(ms) {
|
||||||
|
if (stopped || restartTimer) return;
|
||||||
|
restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function armStallWatch() {
|
||||||
|
clearTimeout(stallTimer);
|
||||||
|
stallTimer = setTimeout(() => {
|
||||||
|
if (stopped || !child) return;
|
||||||
|
if (Date.now() - lastFrameWall >= STALL_MS) {
|
||||||
|
log('no frames for 3 s — restarting capture');
|
||||||
|
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
||||||
|
}
|
||||||
|
}, STALL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (stopped) return;
|
||||||
|
|
||||||
|
const missing = ensureBinary();
|
||||||
|
if (missing) {
|
||||||
|
onUpdate({ error: missing });
|
||||||
|
// A build in flight calls start() itself when it lands; anything else
|
||||||
|
// is config-shaped and should back off hard rather than spin.
|
||||||
|
if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
|
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, '--rate', '20'];
|
||||||
|
if (bands) args.push('--bands');
|
||||||
|
if (calibration.length === channels.length) args.push('--cal', calibration.join(','));
|
||||||
|
|
||||||
|
startedAt = Date.now();
|
||||||
|
meta = null;
|
||||||
|
let stderrTail = '';
|
||||||
|
let buf = '';
|
||||||
|
|
||||||
|
child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||||
|
|
||||||
|
child.stderr.setEncoding('utf8');
|
||||||
|
child.stderr.on('data', (text) => {
|
||||||
|
stderrTail = (stderrTail + text).slice(-500);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout.setEncoding('utf8');
|
||||||
|
child.stdout.on('data', (text) => {
|
||||||
|
buf += text;
|
||||||
|
let i;
|
||||||
|
while ((i = buf.indexOf('\n')) >= 0) {
|
||||||
|
const line = buf.slice(0, i).trim();
|
||||||
|
buf = buf.slice(i + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
let obj;
|
||||||
|
try { obj = JSON.parse(line); } catch { continue; }
|
||||||
|
if (obj.hello) {
|
||||||
|
meta = obj;
|
||||||
|
log(`measuring ${obj.device} ch ${obj.channels.join(',')} `
|
||||||
|
+ `${obj.weighting}-weighted`
|
||||||
|
+ (obj.calibrated ? '' : ' (uncalibrated)'));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!obj.ch) continue;
|
||||||
|
lastFrameWall = Date.now();
|
||||||
|
armStallWatch();
|
||||||
|
onUpdate({ frame: obj, meta });
|
||||||
|
}
|
||||||
|
// A run of junk with no newline would grow forever; the child never
|
||||||
|
// emits a line this long, so anything that does is a bug upstream.
|
||||||
|
if (buf.length > 65536) buf = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
onUpdate({ error: `cannot run spl-capture: ${err.message}` });
|
||||||
|
child = null;
|
||||||
|
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
child = null;
|
||||||
|
clearTimeout(stallTimer);
|
||||||
|
if (stopped) return;
|
||||||
|
const ranFor = Date.now() - startedAt;
|
||||||
|
const why = stderrTail.trim().split('\n').pop() || `exit ${code}${signal ? ' ' + signal : ''}`;
|
||||||
|
// Exiting almost immediately means it never got as far as capturing —
|
||||||
|
// an unknown device, an out-of-range channel, a denied microphone.
|
||||||
|
// Those do not fix themselves, so back off hard and say why.
|
||||||
|
if (ranFor < 2000) {
|
||||||
|
onUpdate({ error: why });
|
||||||
|
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||||
|
} else {
|
||||||
|
log(`capture exited after ${Math.round(ranFor / 1000)}s: ${why}`);
|
||||||
|
scheduleRestart(RESTART_MS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
armStallWatch();
|
||||||
|
}
|
||||||
|
|
||||||
|
start();
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Clear Leq and peak hold without restarting capture — the filters keep
|
||||||
|
their settling, so the live number never dips. */
|
||||||
|
reset() {
|
||||||
|
if (child && child.stdin.writable) {
|
||||||
|
try { child.stdin.write('reset\n'); } catch { /* child going away */ }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(restartTimer); restartTimer = null;
|
||||||
|
clearTimeout(stallTimer); stallTimer = null;
|
||||||
|
if (child) {
|
||||||
|
try { child.kill('SIGTERM'); } catch { /* already gone */ }
|
||||||
|
child = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createSplListener };
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/* SPL Meter — every colour comes from the shell's variables so themes and
|
||||||
|
per-tile tints reach in. Nothing here is hardcoded except the canvas
|
||||||
|
heat ramp, which client.js owns because it has to interpolate. */
|
||||||
|
|
||||||
|
[data-module="spl-meter"] .spl {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── readout ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-module="spl-meter"] .spl-readout {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.25em;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="spl-meter"] .spl-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.2em;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A first-paint size only. client.js measures the text and sets the real
|
||||||
|
one inline, because CSS has to assume a digit count and gets it wrong the
|
||||||
|
moment the reading needs a sixth character: the coefficient that fills a
|
||||||
|
tile for "-45.9" pushes "-100.5" straight out through the side. */
|
||||||
|
[data-module="spl-meter"] .spl-num {
|
||||||
|
font-size: clamp(1.25rem, min(26cqw, 60cqh), 100rem);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="spl-meter"] .spl-unit {
|
||||||
|
font-size: clamp(0.65rem, min(7cqw, 15cqh), 26rem);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="spl-meter"] .spl-sub {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="spl-meter"] .spl-readout[data-level="warn"] .spl-num { color: var(--warn); }
|
||||||
|
[data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); }
|
||||||
|
[data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); }
|
||||||
|
|
||||||
|
/* ── level bars ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-module="spl-meter"] .spl-bars {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.4rem;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="spl-meter"] .spl-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4em 1fr 3.4em;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="spl-meter"] .spl-bar-label { color: var(--muted); text-align: right; }
|
||||||
|
|
||||||
|
[data-module="spl-meter"] .spl-bar-track {
|
||||||
|
position: relative;
|
||||||
|
height: 0.65rem;
|
||||||
|
border-radius: 0.35rem;
|
||||||
|
background: color-mix(in srgb, var(--border) 60%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="spl-meter"] .spl-bar-fill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
width: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 60ms linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); }
|
||||||
|
[data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); }
|
||||||
|
[data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); }
|
||||||
|
|
||||||
|
/* Peak hold sits on the track rather than inside the fill, so it stays
|
||||||
|
visible when the bar falls away underneath it. */
|
||||||
|
[data-module="spl-meter"] .spl-bar-peak {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--text);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="spl-meter"] .spl-bar-value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-module="spl-meter"] .spl-bar.clip .spl-bar-value { color: var(--danger); }
|
||||||
|
|
||||||
|
/* ── canvas tiles ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
[data-module="spl-meter"] .spl-canvas {
|
||||||
|
flex: 1;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The readout scales off both of the tile's dimensions, so the container
|
||||||
|
has to be sized on both axes — inline-size alone leaves cqh undefined. */
|
||||||
|
[data-module="spl-meter"] .spl { container-type: size; }
|
||||||
|
|
||||||
|
/* The sub-line is the first thing to go when the tile gets short: the
|
||||||
|
number is the point, and a clipped caption under it is worse than none. */
|
||||||
|
[data-module="spl-meter"] .spl-sub {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-height: 90px) {
|
||||||
|
[data-module="spl-meter"] .spl-sub { display: none; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user