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>
471 lines
18 KiB
JavaScript
471 lines
18 KiB
JavaScript
'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);
|
||
}
|