AB's meter appeared to halt for a day and a half. Nothing had halted: the capture child had run continuously since the machine was restarted, never stalled, never exited, and was delivering twenty frames a second. Every one of them was digital silence, because Dante Via stopped passing audio after the reboot — the Timers module's LTC listener, on channel 16 of the same device, had been reporting no signal for exactly as long. The module's part in that was reporting "ok" through all of it and drawing a steady -83, which is the -200 floor plus the +117 calibration offset. A plausible number on a wall is believed, and a green dot beside it says the number is worth believing. That is worse than showing nothing. So silence is now a state the module names. Below -150 dBFS unweighted the input is not quiet, it is absent: a microphone in a silent room still reads about -83, and even a muted converter dithers. Five seconds of that on every channel and health() goes to error naming the device and how long it has been dead, the tiles stop drawing a level and say "No audio" with how long, and the status dot goes red. Verified both directions, which is the half that matters: BlackHole with nothing routed into it is caught in five seconds, and a real microphone in a quiet room at -49 dBFS is never flagged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
449 lines
17 KiB
JavaScript
449 lines
17 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 } = 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,
|
||
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 (!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.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) => {
|
||
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 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);
|
||
}
|