'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); }