Files
ProdDash/modules/spl-meter/server.js
T
Waters ChurchandClaude Opus 5 9313c9e42a SPL Meter 0.4.2: the device picker can build the tool it needs
NA could not list a single audio interface. The module was installed from
the catalog, which ships source only because the capture binary is
gitignored, and the picker shells out to that binary with --list. So it
returned nothing, forever:

  [spl-meter] device list failed: spawn .../spl-capture ENOENT

The missing-binary build existed, but only inside the listener, and the
listener only starts once a device has been picked. No tool meant no device
list, and no device meant no listener to build the tool. Nothing was going
to break that on its own.

The build now lives at module scope and both callers use it: init() asks for
it on mount whether or not a device is configured, and /devices asks again
each time it is called, so opening the admin page is enough to start it. It
is still a plain spawn that never touches the event loop, and health() now
names the situation instead of leaving an empty dropdown unexplained.

Also removed two per-listener build flags left shadowing the module-scope
ones, which would have made the "is a build already running" test always
false and let a second build start under the first.

Tested the way it actually failed: a data directory holding only the six
source files, no binary. The build starts on mount and /devices answers with
the real device list moments later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 15:25:43 -04:00

471 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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, ensureCaptureBinary } = 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;
/**
* Below this, unweighted, the input is not quiet — it is not there.
*
* A real input always carries something: a microphone in a silent room still
* reads about -83 dBFS, and even a muted converter dithers around -120. Only
* a stream of actual zeros reaches the capture tool's -200 floor, and that
* means the device handed us nothing at all.
*/
const SILENCE_DBFS = -150;
/** How long that has to hold before saying so, so a gap between buffers or a
device changing sample rate mid-stream doesn't cry wolf. */
const SILENCE_GRACE_MS = 5000;
/** Set by init(), read by routes() — both are rebuilt together on re-init. */
let state = null;
/* ── shared tile settings ───────────────────────────────────────────── */
const CHANNEL_FIELD = {
type: 'select', label: 'Channel', default: '', optionsRoute: '/channels',
};
/**
* Averaging is a view choice, not a measurement one: the capture child
* computes all four every frame and sends all four, so one dashboard on Fast
* and another on Inf are reading the same measurement two ways.
*/
function averagingField() {
return {
type: 'select', label: 'Averaging', default: 'fast',
options: [
{ value: 'fast', label: 'Fast · 125 ms' },
{ value: 'slow', label: 'Slow · 1 s' },
{ value: 'inf', label: 'Inf · since reset' },
{ value: 'none', label: 'None' },
],
};
}
/**
* Banding folds the 1/48-octave bands on the wire down to something
* readable. Coarser is an exact sum of finer, so this costs nothing and
* disturbs no other tile.
*/
const BANDING_FIELD = {
type: 'select', label: 'Banding', default: '3',
options: [
{ value: '3', label: '1/3 octave' },
{ value: '6', label: '1/6 octave' },
{ value: '12', label: '1/12 octave' },
{ value: '24', label: '1/24 octave' },
{ value: '48', label: '1/48 octave' },
],
};
function parseChannels(raw) {
return String(raw || '1')
.split(',')
.map((s) => Number(s.trim()))
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 64);
}
function parseCalibration(raw) {
if (!String(raw || '').trim()) return [];
return String(raw)
.split(',')
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n));
}
module.exports = {
init({ config, log }) {
const channels = parseChannels(config.channels);
const calibration = parseCalibration(config.calibration);
const moduleDir = __dirname;
state = {
channels,
calibrated: calibration.length === channels.length && channels.length > 0,
frame: null,
meta: null,
error: '',
streams: new Set(),
silentSince: null,
toolMissing: '',
listener: null,
lastSent: '',
pinger: null,
};
// Before anything else: the device picker shells out to this tool, so a
// machine that installed the module from the catalog (source only — the
// binary is gitignored) needs the build kicked off here. Waiting for a
// listener would never happen: no tool means no device list, and no
// device means no listener.
state.toolMissing = ensureCaptureBinary({ moduleDir, log });
if (!config.enabled) {
state.error = 'Sound level measurement is off — turn it on in ProdDash settings';
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
}
if (!config.device) {
state.error = 'No audio device chosen yet';
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
}
if (!channels.length) {
state.error = `Cannot read "${config.channels}" as a channel list — try 1, or 1,2`;
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
}
if (calibration.length && calibration.length !== channels.length) {
state.error = `${channels.length} channel(s) but ${calibration.length} calibration offset(s) — `
+ 'give one per channel, or leave the offsets empty';
return { stop() { teardown(); }, health: () => ({ status: 'error', message: state.error }) };
}
state.listener = createSplListener({
device: config.device,
channels,
weighting: config.weighting || 'A',
bands: config.bands !== false,
calibration,
moduleDir,
log,
onUpdate(update) {
if (!state) return; // a frame in flight when the module unmounted
if (update.error) {
if (update.error !== state.error) log(update.error);
state.error = update.error;
state.frame = null;
broadcast();
return;
}
state.error = '';
state.frame = update.frame;
state.meta = update.meta;
// Digital silence on every channel is a dead input, not a reading.
// Saying "ok" through this is what made a stopped Dante device look
// like a frozen module for a day and a half.
const dead = update.frame.ch.every(
(c) => !Number.isFinite(c.dbfs) || c.dbfs <= SILENCE_DBFS);
state.silentSince = dead ? (state.silentSince || Date.now()) : null;
broadcast();
},
});
const mineStreams = state.streams;
state.pinger = setInterval(() => {
for (const res of mineStreams) {
try { res.write(': ping\n\n'); } catch { mineStreams.delete(res); }
}
}, PING_MS);
return {
stop() { teardown(); },
health() {
if (state.toolMissing && !fsExists(path.join(moduleDir, 'spl-capture'))) {
return { status: 'error', message: state.toolMissing };
}
if (state.error) return { status: 'error', message: state.error };
if (!state.frame) return { status: 'connecting', message: 'waiting for the first reading' };
const quiet = silentMs();
if (quiet > SILENCE_GRACE_MS) {
return { status: 'error',
message: `No audio from ${config.device} — digital silence for ${describe(quiet)}` };
}
const n = state.channels.length;
const what = `${n} channel${n === 1 ? '' : 's'} on ${config.device}`;
return state.calibrated
? { status: 'ok', message: what }
: { status: 'ok', message: `${what} — uncalibrated, reading dBFS` };
},
};
},
/**
* Four tile kinds, each with only the settings that do something to it.
*
* An entry's own instanceSchema replaces the module's, which is the point:
* an RTA has no use for an alarm threshold and a readout has no use for a
* banding, and offering either is how a gear popover stops being read.
*
* Every setting here is a view choice. None of them reach the capture
* child — it already sends every averaging and the finest banding, so two
* dashboards can disagree about how to draw the same room.
*/
tiles() {
return [
{
id: 'readout', name: 'SPL', description: 'The level, big',
defaultSize: { w: 2, h: 1 }, minSize: { w: 1, h: 1 },
instanceGroups: { Thresholds: { columns: 2 } },
instanceSchema: {
channel: CHANNEL_FIELD,
averaging: averagingField(),
metric: {
type: 'select', label: 'Show', default: 'level',
options: [
{ value: 'level', label: 'Level' },
{ value: 'leq1m', label: 'Leq 1 min' },
{ value: 'leq10m', label: 'Leq 10 min' },
{ value: 'peakC', label: 'Peak C' },
],
},
caption: { type: 'switch', label: 'Caption', default: true },
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
},
},
{
id: 'bars', name: 'SPL Levels', description: 'One bar per channel, with peak hold',
defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 },
instanceGroups: { Thresholds: { columns: 2 } },
instanceSchema: {
averaging: averagingField(),
peakHold: { type: 'switch', label: 'Peak hold', default: true },
warnAt: { type: 'number', label: 'Amber above', group: 'Thresholds', default: 95 },
alarmAt: { type: 'number', label: 'Red above', group: 'Thresholds', default: 100 },
},
},
{
id: 'rta', name: 'RTA', description: 'Spectrum now, at your choice of banding',
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
instanceSchema: {
channel: CHANNEL_FIELD,
averaging: averagingField(),
banding: BANDING_FIELD,
smoothing: {
type: 'select', label: 'Smoothing', default: 'medium',
options: [
{ value: 'off', label: 'Off' },
{ value: 'light', label: 'Light' },
{ value: 'medium', label: 'Medium' },
{ value: 'heavy', label: 'Heavy' },
],
},
labels: { type: 'switch', label: 'Labels', default: true },
gridlines: { type: 'switch', label: 'Gridlines', default: false },
},
},
{
id: 'spectrogram', name: 'Spectrogram', description: 'Spectrum over time',
defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 },
instanceSchema: {
channel: CHANNEL_FIELD,
banding: BANDING_FIELD,
labels: { type: 'switch', label: 'Labels', default: true },
gridlines: { type: 'switch', label: 'Gridlines', default: false },
},
},
];
},
routes({ config, log }) {
return {
'GET /state': (req, res) => {
json(res, 200, snapshot());
},
'GET /stream': (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write('retry: 3000\n\n');
try {
res.write(`event: state\ndata: ${JSON.stringify(snapshot())}\n\n`);
} catch { return; }
// Hold this mount's set, not `state`. teardown() nulls `state` and
// then ends every stream, which fires these close handlers — a
// handler that reached through `state` threw on null and took the
// whole server down with it, on any re-init with a browser
// connected. Deleting from a set nobody reads any more is harmless.
const streams = state.streams;
streams.add(res);
req.on('close', () => streams.delete(res));
},
/** Device list for the admin page. Runs the capture tool's --list,
which is the only thing that knows what CoreAudio can see. */
'GET /devices': async (req, res) => {
const missing = ensureCaptureBinary({ moduleDir: __dirname, log });
if (missing) {
if (state) state.toolMissing = missing;
log(`device list unavailable — ${missing}`);
json(res, 200, { options: [] });
return;
}
try {
const devices = await listDevices();
json(res, 200, {
options: devices.map((d) => ({
value: d.name,
label: `${d.name} — ${d.channels} ch @ ${Math.round(d.sampleRate)} Hz`,
})),
});
} catch (err) {
log(`device list failed: ${err.message}`);
json(res, 200, { options: [] }); // the saved value stays selectable
}
},
/** Channel list for a tile's gear popover. */
'GET /channels': (req, res) => {
json(res, 200, {
options: state.channels.map((c) => ({ value: String(c), label: `Channel ${c}` })),
});
},
'POST /reset': (req, res) => {
if (state.listener) state.listener.reset();
json(res, 200, { ok: true });
},
/**
* What the microphone is hearing right now, unweighted and
* uncalibrated, plus the offset that would make it read 94 dB.
*
* The module cannot write its own admin config, so this does not save
* anything — it hands back a number for the operator to paste into the
* Calibration box. Honest, if one step longer than it should be.
*/
'GET /measure': (req, res) => {
if (!state.listener || state.error) {
json(res, 409, { error: state.error || 'not measuring' });
return;
}
const wanted = Number(new URLSearchParams(req.search || '').get('channel'))
|| state.channels[0];
const samples = [];
const started = Date.now();
const tick = setInterval(() => {
if (!state) { clearInterval(tick); return; } // module unmounted mid-measure
const ch = state.frame && state.frame.ch.find((c) => c.i === wanted);
if (ch && Number.isFinite(ch.dbfs) && ch.dbfs > -200) samples.push(ch.dbfs);
if (Date.now() - started < MEASURE_MS) return;
clearInterval(tick);
if (samples.length < 5) {
json(res, 200, {
channel: wanted,
error: 'No steady tone on that channel — check the calibrator is on and seated',
});
return;
}
// Mean of the linear powers, not of the decibels: averaging dB
// would quietly under-read a signal that wobbles at all.
const power = samples.reduce((s, db) => s + 10 ** (db / 10), 0) / samples.length;
const measured = 10 * Math.log10(power);
json(res, 200, {
channel: wanted,
measured: Math.round(measured * 100) / 100,
reference: CALIBRATOR_DB,
offset: Math.round((CALIBRATOR_DB - measured) * 100) / 100,
samples: samples.length,
});
}, 100);
req.on('close', () => clearInterval(tick));
},
};
},
};
/* ── helpers ────────────────────────────────────────────────────────── */
/** How long every channel has been at digital silence, in ms. */
function silentMs() {
return state && state.silentSince ? Date.now() - state.silentSince : 0;
}
function describe(ms) {
const s = Math.round(ms / 1000);
if (s < 90) return `${s} s`;
const m = Math.round(s / 60);
return m < 90 ? `${m} min` : `${(m / 60).toFixed(1)} hours`;
}
function snapshot() {
if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null, silentSec: 0 };
// Seconds, not milliseconds: this goes on the wire 20×/s and a value that
// changed every frame would make the fan-out's change check useless.
const quiet = silentMs();
return {
error: state.error,
silentSec: quiet > SILENCE_GRACE_MS ? Math.round(quiet / 1000) : 0,
calibrated: state.calibrated,
channels: state.channels,
weighting: state.meta ? state.meta.weighting : '',
bandCentres: state.meta ? state.meta.bands : [],
bandN0: state.meta ? state.meta.bandN0 : 0,
bandsPerOctave: state.meta ? state.meta.bandsPerOctave : 48,
frame: state.frame,
};
}
function broadcast() {
if (!state) return; // a listener callback landing after teardown
const payload = snapshot();
const line = JSON.stringify(payload);
if (line === state.lastSent) return; // nothing moved since last frame
state.lastSent = line;
for (const res of state.streams) {
try { res.write(`event: state\ndata: ${line}\n\n`); } catch { state.streams.delete(res); }
}
}
function teardown() {
if (!state) return;
if (state.listener) state.listener.stop();
clearInterval(state.pinger);
for (const res of state.streams) { try { res.end(); } catch { /* already gone */ } }
state.streams.clear();
state = null;
}
function listDevices() {
return new Promise((resolve, reject) => {
const { execFile } = require('child_process');
execFile(path.join(__dirname, 'spl-capture'), ['--list'], { timeout: 10000 },
(err, stdout) => {
if (err && !stdout) { reject(err); return; }
try { resolve(JSON.parse(stdout).devices || []); }
catch (e) { reject(e); }
});
});
}
function fsExists(p) {
try { return require('fs').existsSync(p); } catch { return false; }
}
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);
}