SPL Meter module: measure the room, not just watch it
A sound level meter that lives in ProdDash rather than beside it. Takes an audio input the way the Timers module's LTC reader does, and offers four tiles: the level as a big number, per-channel bars with peak hold, a third-octave RTA, and a scrolling spectrogram. The measurement happens in spl-capture.swift, not in Node and not in the browser. LTC gets away with piping raw samples upstream because the decode is cheap and single-channel; sound level is filtering and FFTs over every channel at the device's rate, so the child does the work and only the answers cross the pipe. That costs nothing in dependencies — Accelerate ships with macOS, and the whole thing builds in two seconds with a plain swiftc, exactly like ltc-capture. The weighting filters are the part worth checking rather than trusting, so `spl-capture --selftest` prints them against the IEC 61672 table. A- and C-weighting land within 0.15 dB from 31.5 Hz to 4 kHz and exactly 0 dB at 1 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 even with the pole pair prewarped, and at 96 kHz that falls to 0.6 dB. It is a rounding error on a broadband number and a real limit all the same, which is why the selftest prints both rates instead of a pass mark. Cross-checked against REW on the same microphone: after removing the fixed calibration offset the two agree to a standard deviation of 0.5 dB on live room noise. Uncalibrated is a state the module says out loud — every tile reads dBFS and labels it, so nobody mistakes a relative number for sound pressure. The /measure route averages the calibrator tone in the power domain and hands back the offset to paste into settings; a module cannot write its own admin config, so that last step stays manual on purpose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5025e12b71
commit
188d1c878d
@@ -14,3 +14,6 @@ modules/propresenter-timers/ltc-capture
|
||||
# the launcher's build output, and its cache of the Node runtime it embeds
|
||||
launcher/macos/build/
|
||||
launcher/macos/.cache/
|
||||
|
||||
# Compiled per-machine, source sits next to it (see spl-capture.swift).
|
||||
modules/spl-meter/spl-capture
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* SPL Meter tiles.
|
||||
*
|
||||
* Four kinds, all fed by one SSE stream from the module's server:
|
||||
*
|
||||
* readout the number, big — what most dashboards leave up
|
||||
* bars one level bar per channel, with peak hold
|
||||
* rta third-octave bars
|
||||
* spectrogram the same bands, scrolling
|
||||
*
|
||||
* Nothing here computes a level. The capture child measured it; this draws
|
||||
* it. That split is deliberate — the browser has no access to the audio
|
||||
* device and no business pretending otherwise.
|
||||
*/
|
||||
|
||||
const FLOOR = -200;
|
||||
|
||||
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 the next 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 ─────────────────────────────────────────────────── */
|
||||
|
||||
/** The channel this tile is showing, falling back to the first configured. */
|
||||
function pickChannel(payload, settings) {
|
||||
if (!payload || !payload.frame) return null;
|
||||
const want = Number(settings.channel);
|
||||
return payload.frame.ch.find((c) => c.i === want) || payload.frame.ch[0] || null;
|
||||
}
|
||||
|
||||
function fmt(db) {
|
||||
if (!Number.isFinite(db) || db <= FLOOR) return '––.–';
|
||||
return db.toFixed(1);
|
||||
}
|
||||
|
||||
/** Read a CSS custom property off the tile so canvases follow the theme. */
|
||||
function themeColor(el, name, fallback) {
|
||||
const v = getComputedStyle(el).getPropertyValue(name).trim();
|
||||
return v || fallback;
|
||||
}
|
||||
|
||||
/* ── 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);
|
||||
}
|
||||
}
|
||||
|
||||
/** The big number. */
|
||||
function readoutView(root, moduleApi) {
|
||||
root.innerHTML = `
|
||||
<div class="spl-readout">
|
||||
<div class="spl-value"><span class="spl-num">––.–</span><span class="spl-unit">dB</span></div>
|
||||
<div class="spl-sub"></div>
|
||||
</div>`;
|
||||
const num = root.querySelector('.spl-num');
|
||||
const unit = root.querySelector('.spl-unit');
|
||||
const sub = root.querySelector('.spl-sub');
|
||||
const box = root.querySelector('.spl-readout');
|
||||
|
||||
return {
|
||||
render(payload) {
|
||||
const s = moduleApi.instanceSettings;
|
||||
const ch = pickChannel(payload, s);
|
||||
if (!payload || payload.error || !ch) {
|
||||
num.textContent = '––.–';
|
||||
sub.textContent = payload && payload.error ? payload.error : '';
|
||||
box.dataset.level = 'idle';
|
||||
return;
|
||||
}
|
||||
const metric = s.metric || 'spl';
|
||||
const value = ch[metric];
|
||||
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 label = { spl: 'SPL', leq: 'Leq', leq1m: 'Leq 1 min', leq10m: 'Leq 10 min',
|
||||
peakC: 'Peak C' }[metric] || metric;
|
||||
const parts = [`${label} · ${payload.weighting || '?'} ${payload.filter || ''}`.trim(),
|
||||
`ch ${ch.i}`];
|
||||
if (ch.clip) parts.push('CLIP');
|
||||
if (!payload.calibrated) parts.push('uncalibrated');
|
||||
sub.textContent = parts.join(' · ');
|
||||
},
|
||||
resize() {},
|
||||
destroy() { root.innerHTML = ''; },
|
||||
};
|
||||
}
|
||||
|
||||
/** One bar per channel, peak hold marked. */
|
||||
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);
|
||||
// Uncalibrated the scale is dBFS (-80..0); calibrated it is dB SPL
|
||||
// over a range a room actually occupies.
|
||||
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 pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
|
||||
el.querySelector('.spl-bar-fill').style.width = `${pct(c.spl)}%`;
|
||||
el.querySelector('.spl-bar-peak').style.left = `${pct(c.peakC)}%`;
|
||||
el.querySelector('.spl-bar-value').textContent = fmt(c.spl);
|
||||
el.dataset.level = !payload.calibrated ? 'idle'
|
||||
: c.spl >= alarm ? 'alarm' : c.spl >= warn ? 'warn' : 'ok';
|
||||
el.classList.toggle('clip', !!c.clip);
|
||||
}
|
||||
},
|
||||
resize() {},
|
||||
destroy() { root.innerHTML = ''; },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* RTA bars, or the same bands scrolling as a spectrogram.
|
||||
*
|
||||
* Both are canvas because both redraw wholesale many times a second, and
|
||||
* thirty DOM nodes animating at 20 Hz is how a dashboard starts dropping
|
||||
* frames on the machine that is also running the service.
|
||||
*/
|
||||
function spectrumView(root, moduleApi, scrolling) {
|
||||
root.innerHTML = '<canvas class="spl-canvas"></canvas>';
|
||||
const canvas = root.querySelector('.spl-canvas');
|
||||
const ctx = canvas.getContext('2d', { alpha: false });
|
||||
let history = null; // spectrogram only: ImageData column store
|
||||
|
||||
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);
|
||||
history = null; // geometry changed — start the scroll again
|
||||
return true;
|
||||
}
|
||||
sizeCanvas();
|
||||
|
||||
/** dB → 0..1 over the window a room actually uses. */
|
||||
function norm(db, calibrated) {
|
||||
const lo = calibrated ? 30 : -100;
|
||||
const hi = calibrated ? 110 : -10;
|
||||
return Math.max(0, Math.min(1, (db - lo) / (hi - lo)));
|
||||
}
|
||||
|
||||
function heat(v) {
|
||||
// Dark → accent → warn → danger, so a hot band reads the same way the
|
||||
// readout's colours do.
|
||||
const stops = [[10, 12, 20], [30, 90, 150], [90, 180, 140], [230, 190, 80], [220, 80, 60]];
|
||||
const x = Math.max(0, Math.min(0.999, v)) * (stops.length - 1);
|
||||
const i = Math.floor(x), t = x - i;
|
||||
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');
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
const s = moduleApi.instanceSettings;
|
||||
const want = Number(s.channel);
|
||||
const idx = Math.max(0, payload.frame.ch.findIndex((c) => c.i === want));
|
||||
const bands = payload.frame.bands[idx] || payload.frame.bands[0] || [];
|
||||
const centres = payload.bandCentres || [];
|
||||
if (!bands.length) return;
|
||||
|
||||
if (!scrolling) {
|
||||
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
|
||||
const gap = 1;
|
||||
const bw = w / bands.length;
|
||||
for (let i = 0; i < bands.length; i++) {
|
||||
const v = norm(bands[i], payload.calibrated);
|
||||
const bh = v * (h - 14);
|
||||
ctx.fillStyle = heat(v);
|
||||
ctx.fillRect(i * bw + gap / 2, h - 14 - bh, bw - gap, bh);
|
||||
}
|
||||
// Only decade labels — thirty numbers across a tile is unreadable.
|
||||
ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center';
|
||||
for (let i = 0; i < centres.length; i++) {
|
||||
const f = centres[i];
|
||||
if (![100, 1000, 10000].includes(f)) continue;
|
||||
ctx.fillText(f >= 1000 ? `${f / 1000}k` : String(f), i * bw + bw / 2, h - 3);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Spectrogram: shift the image left, draw the newest column on the
|
||||
// right. Cheaper than keeping our own ring of columns.
|
||||
//
|
||||
// The source rectangle has to be given in device pixels and the
|
||||
// destination in CSS pixels: the context carries a devicePixelRatio
|
||||
// transform, but the canvas used as an image source does not. Letting
|
||||
// drawImage infer them blows the picture up by the ratio and pushes
|
||||
// every column off the canvas, which looks exactly like "not drawing".
|
||||
const colW = 2;
|
||||
if (!history) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); history = true; }
|
||||
ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h);
|
||||
const bh = h / bands.length;
|
||||
for (let i = 0; i < bands.length; i++) {
|
||||
ctx.fillStyle = heat(norm(bands[i], payload.calibrated));
|
||||
// Low frequencies at the bottom, as every spectrogram has them.
|
||||
ctx.fillRect(w - colW, h - (i + 1) * bh, colW, Math.ceil(bh) + 1);
|
||||
}
|
||||
},
|
||||
resize() { sizeCanvas(); },
|
||||
destroy() { root.innerHTML = ''; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"id": "spl-meter",
|
||||
"name": "SPL Meter",
|
||||
"version": "0.1.0",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Calibrated sound level, Leq and RTA from an audio input on this machine",
|
||||
"permissions": [
|
||||
{ "kind": "microphone", "reason": "Measuring sound level from an audio input on this machine" }
|
||||
],
|
||||
"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" }
|
||||
]
|
||||
},
|
||||
"filter": {
|
||||
"type": "select",
|
||||
"label": "Time constant",
|
||||
"group": "Measurement",
|
||||
"default": "fast",
|
||||
"options": [
|
||||
{ "value": "fast", "label": "Fast — 125 ms" },
|
||||
{ "value": "slow", "label": "Slow — 1 s" }
|
||||
]
|
||||
},
|
||||
"bands": {
|
||||
"type": "switch",
|
||||
"label": "Spectrum analysis",
|
||||
"group": "Measurement",
|
||||
"default": true,
|
||||
"help": "Third-octave bands for the RTA and spectrograph tiles. Turn it off if you only ever show the number."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"instanceGroups": {
|
||||
"Thresholds": { "columns": 2, "help": "The readout turns amber then red as it crosses these. Set them to whatever this room has agreed is loud, not to a legal limit — this is not a compliance meter." }
|
||||
},
|
||||
"instanceSchema": {
|
||||
"channel": {
|
||||
"type": "select",
|
||||
"label": "Channel",
|
||||
"default": "",
|
||||
"optionsRoute": "/channels"
|
||||
},
|
||||
"metric": {
|
||||
"type": "select",
|
||||
"label": "Reading",
|
||||
"default": "spl",
|
||||
"options": [
|
||||
{ "value": "spl", "label": "SPL — right now" },
|
||||
{ "value": "leq", "label": "Leq — average since reset" },
|
||||
{ "value": "leq1m", "label": "Leq 1 min" },
|
||||
{ "value": "leq10m", "label": "Leq 10 min" },
|
||||
{ "value": "peakC", "label": "Peak, C-weighted" }
|
||||
]
|
||||
},
|
||||
"warnAt": { "type": "number", "label": "Amber above", "group": "Thresholds", "default": 95 },
|
||||
"alarmAt": { "type": "number", "label": "Red above", "group": "Thresholds", "default": 100 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
'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;
|
||||
|
||||
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',
|
||||
filter: config.filter || 'fast',
|
||||
bands: config.bands !== false,
|
||||
calibration,
|
||||
moduleDir,
|
||||
log,
|
||||
onUpdate(update) {
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
state.pinger = setInterval(() => {
|
||||
for (const res of state.streams) {
|
||||
try { res.write(': ping\n\n'); } catch { state.streams.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 rather than one tile with a mode switch: an operator
|
||||
* picking "+ SPL" wants to choose what they are putting on the wall, not
|
||||
* place a box and then configure it into being useful.
|
||||
*/
|
||||
tiles() {
|
||||
return [
|
||||
{ id: 'readout', name: 'SPL', description: 'The level, big',
|
||||
defaultSize: { w: 2, h: 1 }, minSize: { w: 1, h: 1 } },
|
||||
{ id: 'bars', name: 'SPL Levels', description: 'One bar per channel, with peak hold',
|
||||
defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } },
|
||||
{ id: 'rta', name: 'RTA', description: 'Third-octave bars',
|
||||
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 } },
|
||||
{ id: 'spectrogram', name: 'Spectrogram', description: 'Third-octave bands over time',
|
||||
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 } },
|
||||
];
|
||||
},
|
||||
|
||||
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; }
|
||||
state.streams.add(res);
|
||||
req.on('close', () => state.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(() => {
|
||||
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() {
|
||||
return {
|
||||
error: state.error,
|
||||
calibrated: state.calibrated,
|
||||
channels: state.channels,
|
||||
weighting: state.meta ? state.meta.weighting : '',
|
||||
filter: state.meta ? state.meta.filter : '',
|
||||
bandCentres: state.meta ? state.meta.bands : [],
|
||||
frame: state.frame,
|
||||
};
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
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,694 @@
|
||||
// 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: SPL at the chosen weighting and time constant,
|
||||
// Leq (running, 1-minute and 10-minute), C- and Z-weighted peak, raw dBFS,
|
||||
// and a clip flag. Optionally a third-octave band set for the RTA and
|
||||
// spectrograph tiles — only when --bands is passed, so an SPL-only dashboard
|
||||
// doesn't pay for FFTs it never draws.
|
||||
//
|
||||
// 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 ─────────────────────────────── */
|
||||
|
||||
/// Nominal third-octave centres. We start at 25 Hz rather than 20: with an
|
||||
/// 8192-point FFT at 48 kHz the bins are 5.9 Hz apart, and the 20 Hz band is
|
||||
/// only 4.6 Hz wide — one bin, sometimes none. Claiming a number there would
|
||||
/// be drawing noise. A filter bank would reach lower; that is a later
|
||||
/// problem, and the bands we do show are honest.
|
||||
let bandCentres: [Double] = [25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400,
|
||||
500, 630, 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000,
|
||||
5000, 6300, 8000, 10000, 12500, 16000, 20000]
|
||||
|
||||
/// FFT bin ranges for each band, clamped to what the transform can resolve.
|
||||
func bandBins(fs: Double, fftSize: Int) -> [(Int, Int)] {
|
||||
let binHz = fs / Double(fftSize)
|
||||
let maxBin = fftSize / 2 - 1
|
||||
return bandCentres.map { fc in
|
||||
let lo = Int((fc * pow(2, -1.0 / 6.0) / binHz).rounded())
|
||||
let hi = Int((fc * pow(2, 1.0 / 6.0) / binHz).rounded())
|
||||
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]
|
||||
|
||||
private var meanSquare: Double = 0 // exponential integrator, linear power
|
||||
private var flatMeanSquare: Double = 0 // same integrator, unweighted — for dBFS
|
||||
private let alphaPerSample: 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, fast: Bool, 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 = []
|
||||
let tau = fast ? 0.125 : 1.0
|
||||
alphaPerSample = 1.0 - exp(-1.0 / (tau * 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 ms = meanSquare
|
||||
var flat = flatMeanSquare
|
||||
weighted.withUnsafeBufferPointer { w in
|
||||
for i in 0..<n {
|
||||
let v = Double(w[i])
|
||||
ms += alphaPerSample * (v * v - ms)
|
||||
let r = Double(samples[i])
|
||||
flat += alphaPerSample * (r * r - flat)
|
||||
}
|
||||
}
|
||||
meanSquare = ms
|
||||
flatMeanSquare = flat
|
||||
|
||||
// Leq wants plain energy, no time constant.
|
||||
var sumSq: Float = 0
|
||||
weighted.withUnsafeBufferPointer { w in
|
||||
vDSP_measqv(w.baseAddress!, 1, &sumSq, vDSP_Length(n))
|
||||
}
|
||||
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(meanSquare)
|
||||
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("spl", db(meanSquare))
|
||||
+ f("leq", leqCount > 0 ? db(leqSum / leqCount) : -200)
|
||||
+ 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))
|
||||
}
|
||||
|
||||
/* ── 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 wantFast = true
|
||||
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 "--filter":
|
||||
let raw = nextArg("--filter").lowercased()
|
||||
guard raw == "fast" || raw == "slow" else { fail("--filter must be fast or slow") }
|
||||
wantFast = (raw == "fast")
|
||||
case "--bands": wantBands = true
|
||||
case "--rate":
|
||||
guard let r = Double(nextArg("--rate")), r >= 1, r <= 50 else { fail("--rate must be 1-50 (frames per second)") }
|
||||
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, fast: wantFast,
|
||||
calibration: calibrated ? wantCal[i] : 0, framesPerSecond: framesPerSecond)
|
||||
}
|
||||
let spectra = wantBands ? wantChannels.map { _ in Spectrum(fs: sampleRate, fftSize: 8192) } : []
|
||||
|
||||
note("capturing \"\(device.name)\": \(channelCount) channels @ \(Int(sampleRate)) Hz, "
|
||||
+ "measuring \(wantChannels.map(String.init).joined(separator: ",")) "
|
||||
+ "\(wantWeighting.rawValue)-weighted \(wantFast ? "fast" : "slow")"
|
||||
+ (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,
|
||||
"filter": wantFast ? "fast" : "slow",
|
||||
"calibrated": calibrated,
|
||||
"bands": wantBands ? bandCentres : [],
|
||||
]
|
||||
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(num).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,191 @@
|
||||
'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: if it is missing this tries one swiftc build of the
|
||||
* source next to it, and otherwise says how to build it elsewhere and copy
|
||||
* it over.
|
||||
*
|
||||
* 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, spawnSync } = 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, filter:string,
|
||||
* bands:boolean, calibration:number[], moduleDir:string,
|
||||
* log:Function, onUpdate:(u:object)=>void}} opts
|
||||
* @returns {{stop:()=>void, reset:()=>void}}
|
||||
*/
|
||||
function createSplListener(opts) {
|
||||
const { device, channels, weighting, filter, bands, calibration, moduleDir, log, onUpdate } = opts;
|
||||
const 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 lastFrameWall = 0;
|
||||
|
||||
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';
|
||||
log('spl-capture binary missing — trying a one-time swiftc build');
|
||||
const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 180000 });
|
||||
if (built.status === 0 && fs.existsSync(bin)) {
|
||||
log('spl-capture compiled');
|
||||
return '';
|
||||
}
|
||||
return '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 here';
|
||||
}
|
||||
|
||||
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 });
|
||||
scheduleRestart(BAD_CONFIG_RETRY_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
const args = ['--device', device, '--channels', channels.join(','),
|
||||
'--weighting', weighting, '--filter', filter, '--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.filter}`
|
||||
+ (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,147 @@
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* Sized off the tile, so one cell and six cells both read well. Height has
|
||||
to be in the min() as well as width: a 2x1 tile is wide and short, and
|
||||
sizing on width alone pushes the digits straight out through the top. */
|
||||
[data-module="spl-meter"] .spl-num {
|
||||
font-size: clamp(1.25rem, min(20cqw, 42cqh), 6rem);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
[data-module="spl-meter"] .spl-unit {
|
||||
font-size: clamp(0.65rem, min(6cqw, 13cqh), 1.5rem);
|
||||
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