2026-09-17 10:03:45 -04:00
|
|
|
|
'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;
|
|
|
|
|
|
|
2026-09-17 12:47:42 -04:00
|
|
|
|
/* ── shared tile settings ───────────────────────────────────────────── */
|
|
|
|
|
|
|
|
|
|
|
|
const CHANNEL_FIELD = {
|
|
|
|
|
|
type: 'select', label: 'Channel', default: '', optionsRoute: '/channels',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Averaging is a view choice, not a measurement one: the capture child
|
|
|
|
|
|
* computes all four every frame and sends all four, so one dashboard on Fast
|
|
|
|
|
|
* and another on Inf are reading the same measurement two ways.
|
|
|
|
|
|
*/
|
|
|
|
|
|
function averagingField() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
type: 'select', label: 'Averaging', default: 'fast',
|
|
|
|
|
|
options: [
|
|
|
|
|
|
{ value: 'fast', label: 'Fast — 125 ms' },
|
|
|
|
|
|
{ value: 'slow', label: 'Slow — 1 s' },
|
|
|
|
|
|
{ value: 'inf', label: 'Inf — since reset' },
|
|
|
|
|
|
{ value: 'none', label: 'None — no averaging' },
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Banding folds the 1/48-octave bands on the wire down to something
|
|
|
|
|
|
* readable. Coarser is an exact sum of finer, so this costs nothing and
|
|
|
|
|
|
* disturbs no other tile.
|
|
|
|
|
|
*/
|
|
|
|
|
|
const BANDING_FIELD = {
|
|
|
|
|
|
type: 'select', label: 'Banding', default: '3',
|
|
|
|
|
|
help: 'Finer bandings need a wide tile to be worth drawing, and below '
|
|
|
|
|
|
+ 'roughly 200 Hz the window length limits real resolution however fine '
|
|
|
|
|
|
+ 'this is set.',
|
|
|
|
|
|
options: [
|
|
|
|
|
|
{ value: '3', label: '1/3 octave' },
|
|
|
|
|
|
{ value: '6', label: '1/6 octave' },
|
|
|
|
|
|
{ value: '12', label: '1/12 octave' },
|
|
|
|
|
|
{ value: '24', label: '1/24 octave' },
|
|
|
|
|
|
{ value: '48', label: '1/48 octave' },
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-17 10:03:45 -04:00
|
|
|
|
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 (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` };
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-17 12:47:42 -04:00
|
|
|
|
* 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.
|
2026-09-17 10:03:45 -04:00
|
|
|
|
*/
|
|
|
|
|
|
tiles() {
|
|
|
|
|
|
return [
|
2026-09-17 12:47:42 -04:00
|
|
|
|
{
|
|
|
|
|
|
id: 'readout', name: 'SPL', description: 'The level, big',
|
|
|
|
|
|
defaultSize: { w: 2, h: 1 }, minSize: { w: 1, h: 1 },
|
|
|
|
|
|
instanceGroups: { Thresholds: { columns: 2 } },
|
|
|
|
|
|
instanceSchema: {
|
|
|
|
|
|
channel: CHANNEL_FIELD,
|
|
|
|
|
|
averaging: averagingField(),
|
|
|
|
|
|
metric: {
|
|
|
|
|
|
type: 'select', label: 'Show', default: 'level',
|
|
|
|
|
|
options: [
|
|
|
|
|
|
{ value: 'level', label: 'Level' },
|
|
|
|
|
|
{ value: 'leq1m', label: 'Leq, last minute' },
|
|
|
|
|
|
{ value: 'leq10m', label: 'Leq, last 10 minutes' },
|
|
|
|
|
|
{ value: 'peakC', label: 'Peak, C-weighted' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
|
|
|
|
|
|
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'bars', name: 'SPL Levels', description: 'One bar per channel, with peak hold',
|
|
|
|
|
|
defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 },
|
|
|
|
|
|
instanceGroups: { Thresholds: { columns: 2 } },
|
|
|
|
|
|
instanceSchema: {
|
|
|
|
|
|
averaging: averagingField(),
|
|
|
|
|
|
peakHold: { type: 'switch', label: 'Peak hold', default: true },
|
|
|
|
|
|
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
|
|
|
|
|
|
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'rta', name: 'RTA', description: 'Spectrum now, at your choice of banding',
|
|
|
|
|
|
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
|
|
|
|
|
|
instanceSchema: {
|
|
|
|
|
|
channel: CHANNEL_FIELD,
|
|
|
|
|
|
averaging: averagingField(),
|
|
|
|
|
|
banding: BANDING_FIELD,
|
|
|
|
|
|
smoothing: {
|
|
|
|
|
|
type: 'select', label: 'Smoothing', default: 'medium',
|
|
|
|
|
|
help: 'How hard the bars are damped between frames. Cosmetic only — '
|
|
|
|
|
|
+ 'it changes the animation, never the measurement.',
|
|
|
|
|
|
options: [
|
|
|
|
|
|
{ value: 'off', label: 'Off — every frame as measured' },
|
|
|
|
|
|
{ value: 'light', label: 'Light' },
|
|
|
|
|
|
{ value: 'medium', label: 'Medium' },
|
|
|
|
|
|
{ value: 'heavy', label: 'Heavy' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
gridlines: { type: 'switch', label: 'Gridlines', default: false },
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'spectrogram', name: 'Spectrogram', description: 'Spectrum over time',
|
|
|
|
|
|
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
|
|
|
|
|
|
instanceSchema: {
|
|
|
|
|
|
channel: CHANNEL_FIELD,
|
|
|
|
|
|
banding: BANDING_FIELD,
|
|
|
|
|
|
axis: {
|
|
|
|
|
|
type: 'select', label: 'Time runs', default: 'x',
|
|
|
|
|
|
options: [
|
|
|
|
|
|
{ value: 'x', label: 'Left to right' },
|
|
|
|
|
|
{ value: 'y', label: 'Top to bottom' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
gridlines: { type: 'switch', label: 'Gridlines', default: false },
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-09-17 10:03:45 -04:00
|
|
|
|
];
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
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 : '',
|
|
|
|
|
|
bandCentres: state.meta ? state.meta.bands : [],
|
2026-09-17 12:47:42 -04:00
|
|
|
|
bandN0: state.meta ? state.meta.bandN0 : 0,
|
|
|
|
|
|
bandsPerOctave: state.meta ? state.meta.bandsPerOctave : 48,
|
2026-09-17 10:03:45 -04:00
|
|
|
|
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);
|
|
|
|
|
|
}
|