Rebasing onto main landed rules 5 to 11, which this module predates and broke in three places. Rule 5 — no explanatory text in a tile's gear: the Banding and Smoothing fields each carried a paragraph. The Banding one said something true and worth knowing, that below roughly 200 Hz the window length limits real resolution however fine the setting, so it moves to the source where the band plan is built rather than being lost. Option labels shortened with it. Rule 11 — one control per action: the spectrogram offered its axis both as a gear field and as a title-bar button. The button stays, since that is what was asked for, and the field is gone. The setting still persists with the tile; saveInstanceSettings keeps keys that no schema declares. Rule 9 — title-bar controls are icons: the axis button was drawing a "⇆" as a label. It is an inline SVG now, with the tooltip carrying the words and naming the current direction rather than the action. Rule 8 also asks that a tile can switch off what it draws, so the readout gained a Caption toggle and both graphs gained Labels. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
397 lines
14 KiB
JavaScript
397 lines
14 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;
|
||
|
||
/** 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(),
|
||
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` };
|
||
},
|
||
};
|
||
},
|
||
|
||
/**
|
||
* 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; }
|
||
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 : [],
|
||
bandN0: state.meta ? state.meta.bandN0 : 0,
|
||
bandsPerOctave: state.meta ? state.meta.bandsPerOctave : 48,
|
||
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);
|
||
}
|