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
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user