From 188d1c878da18d643fc70a97ad161142f1d4c6e7 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 17 Sep 2026 10:03:45 -0400 Subject: [PATCH 1/7] SPL Meter module: measure the room, not just watch it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sound level meter that lives in ProdDash rather than beside it. Takes an audio input the way the Timers module's LTC reader does, and offers four tiles: the level as a big number, per-channel bars with peak hold, a third-octave RTA, and a scrolling spectrogram. The measurement happens in spl-capture.swift, not in Node and not in the browser. LTC gets away with piping raw samples upstream because the decode is cheap and single-channel; sound level is filtering and FFTs over every channel at the device's rate, so the child does the work and only the answers cross the pipe. That costs nothing in dependencies — Accelerate ships with macOS, and the whole thing builds in two seconds with a plain swiftc, exactly like ltc-capture. The weighting filters are the part worth checking rather than trusting, so `spl-capture --selftest` prints them against the IEC 61672 table. A- and C-weighting land within 0.15 dB from 31.5 Hz to 4 kHz and exactly 0 dB at 1 kHz. The top octave is where the bilinear transform runs out of room: at 48 kHz the 16 kHz point sits about 3 dB low even with the pole pair prewarped, and at 96 kHz that falls to 0.6 dB. It is a rounding error on a broadband number and a real limit all the same, which is why the selftest prints both rates instead of a pass mark. Cross-checked against REW on the same microphone: after removing the fixed calibration offset the two agree to a standard deviation of 0.5 dB on live room noise. Uncalibrated is a state the module says out loud — every tile reads dBFS and labels it, so nobody mistakes a relative number for sound pressure. The /measure route averages the calibrator tone in the power domain and hands back the offset to paste into settings; a module cannot write its own admin config, so that last step stays manual on purpose. Co-Authored-By: Claude Opus 5 --- .gitignore | 3 + modules/spl-meter/client.js | 296 ++++++++++++ modules/spl-meter/module.json | 107 +++++ modules/spl-meter/server.js | 297 ++++++++++++ modules/spl-meter/spl-capture.swift | 694 ++++++++++++++++++++++++++++ modules/spl-meter/spl-listener.js | 191 ++++++++ modules/spl-meter/style.css | 147 ++++++ 7 files changed, 1735 insertions(+) create mode 100644 modules/spl-meter/client.js create mode 100644 modules/spl-meter/module.json create mode 100644 modules/spl-meter/server.js create mode 100644 modules/spl-meter/spl-capture.swift create mode 100644 modules/spl-meter/spl-listener.js create mode 100644 modules/spl-meter/style.css diff --git a/.gitignore b/.gitignore index 1e30fc9..e3910f3 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ modules/propresenter-timers/ltc-capture # the launcher's build output, and its cache of the Node runtime it embeds launcher/macos/build/ launcher/macos/.cache/ + +# Compiled per-machine, source sits next to it (see spl-capture.swift). +modules/spl-meter/spl-capture diff --git a/modules/spl-meter/client.js b/modules/spl-meter/client.js new file mode 100644 index 0000000..453b2f0 --- /dev/null +++ b/modules/spl-meter/client.js @@ -0,0 +1,296 @@ +/** + * SPL Meter tiles. + * + * Four kinds, all fed by one SSE stream from the module's server: + * + * readout the number, big — what most dashboards leave up + * bars one level bar per channel, with peak hold + * rta third-octave bars + * spectrogram the same bands, scrolling + * + * Nothing here computes a level. The capture child measured it; this draws + * it. That split is deliberate — the browser has no access to the audio + * device and no business pretending otherwise. + */ + +const FLOOR = -200; + +export default function create({ root, moduleApi }) { + const variant = moduleApi.variant || 'readout'; + let stream = null; + let latest = null; + let raf = 0; + + root.classList.add('spl'); + root.dataset.variant = variant; + + const view = buildView(variant, root, moduleApi); + + function onState(payload) { + latest = payload; + if (payload.error) { + moduleApi.setStatus('error', payload.error); + } else if (!payload.frame) { + moduleApi.setStatus('connecting', 'waiting for the first reading'); + } else if (!payload.calibrated) { + moduleApi.setStatus('ok', 'Measuring, but uncalibrated — these are dBFS, not dB SPL'); + } else { + moduleApi.setStatus('ok', ''); + } + // Frames land 20×/s; paint on the next frame the browser was going to + // draw anyway rather than fighting it for every one. + if (!raf) raf = requestAnimationFrame(() => { raf = 0; view.render(latest); }); + } + + return { + start() { + moduleApi.setStatus('connecting', 'connecting'); + moduleApi.fetch('/state') + .then((r) => r.json()) + .then(onState) + .catch(() => { /* the stream will bring it */ }); + // Named events go under `events` — a top-level `state(ev)` is silently + // never registered, and the tile just sits on whatever /state gave it. + stream = moduleApi.sse('/stream', { + events: { + state(ev) { + try { onState(JSON.parse(ev.data)); } catch { /* keep the last good frame */ } + }, + }, + error() { moduleApi.setStatus('connecting', 'reconnecting'); }, + }); + }, + stop() { + if (stream) { stream.close(); stream = null; } + if (raf) { cancelAnimationFrame(raf); raf = 0; } + view.destroy(); + }, + onResize() { view.resize(); }, + }; +} + +/* ── shared helpers ─────────────────────────────────────────────────── */ + +/** The channel this tile is showing, falling back to the first configured. */ +function pickChannel(payload, settings) { + if (!payload || !payload.frame) return null; + const want = Number(settings.channel); + return payload.frame.ch.find((c) => c.i === want) || payload.frame.ch[0] || null; +} + +function fmt(db) { + if (!Number.isFinite(db) || db <= FLOOR) return '––.–'; + return db.toFixed(1); +} + +/** Read a CSS custom property off the tile so canvases follow the theme. */ +function themeColor(el, name, fallback) { + const v = getComputedStyle(el).getPropertyValue(name).trim(); + return v || fallback; +} + +/* ── views ──────────────────────────────────────────────────────────── */ + +function buildView(variant, root, moduleApi) { + switch (variant) { + case 'bars': return barsView(root, moduleApi); + case 'rta': return spectrumView(root, moduleApi, false); + case 'spectrogram': return spectrumView(root, moduleApi, true); + default: return readoutView(root, moduleApi); + } +} + +/** The big number. */ +function readoutView(root, moduleApi) { + root.innerHTML = ` +
+
––.–dB
+
+
`; + const num = root.querySelector('.spl-num'); + const unit = root.querySelector('.spl-unit'); + const sub = root.querySelector('.spl-sub'); + const box = root.querySelector('.spl-readout'); + + return { + render(payload) { + const s = moduleApi.instanceSettings; + const ch = pickChannel(payload, s); + if (!payload || payload.error || !ch) { + num.textContent = '––.–'; + sub.textContent = payload && payload.error ? payload.error : ''; + box.dataset.level = 'idle'; + return; + } + const metric = s.metric || 'spl'; + const value = ch[metric]; + num.textContent = fmt(value); + unit.textContent = payload.calibrated ? 'dB' : 'dBFS'; + + const warn = Number(s.warnAt), alarm = Number(s.alarmAt); + box.dataset.level = !payload.calibrated ? 'idle' + : value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok'; + + const label = { spl: 'SPL', leq: 'Leq', leq1m: 'Leq 1 min', leq10m: 'Leq 10 min', + peakC: 'Peak C' }[metric] || metric; + const parts = [`${label} · ${payload.weighting || '?'} ${payload.filter || ''}`.trim(), + `ch ${ch.i}`]; + if (ch.clip) parts.push('CLIP'); + if (!payload.calibrated) parts.push('uncalibrated'); + sub.textContent = parts.join(' · '); + }, + resize() {}, + destroy() { root.innerHTML = ''; }, + }; +} + +/** One bar per channel, peak hold marked. */ +function barsView(root, moduleApi) { + root.innerHTML = '
'; + const host = root.querySelector('.spl-bars'); + let built = ''; + + return { + render(payload) { + if (!payload || !payload.frame) { host.textContent = payload?.error || ''; built = ''; return; } + const chans = payload.frame.ch; + const key = chans.map((c) => c.i).join(','); + if (key !== built) { + built = key; + host.innerHTML = chans.map((c) => ` +
+
${c.i}
+
+
––.–
+
`).join(''); + } + const s = moduleApi.instanceSettings; + const warn = Number(s.warnAt), alarm = Number(s.alarmAt); + // Uncalibrated the scale is dBFS (-80..0); calibrated it is dB SPL + // over a range a room actually occupies. + const lo = payload.calibrated ? 40 : -80; + const hi = payload.calibrated ? 110 : 0; + for (const c of chans) { + const el = host.querySelector(`.spl-bar[data-ch="${c.i}"]`); + if (!el) continue; + const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100)); + el.querySelector('.spl-bar-fill').style.width = `${pct(c.spl)}%`; + el.querySelector('.spl-bar-peak').style.left = `${pct(c.peakC)}%`; + el.querySelector('.spl-bar-value').textContent = fmt(c.spl); + el.dataset.level = !payload.calibrated ? 'idle' + : c.spl >= alarm ? 'alarm' : c.spl >= warn ? 'warn' : 'ok'; + el.classList.toggle('clip', !!c.clip); + } + }, + resize() {}, + destroy() { root.innerHTML = ''; }, + }; +} + +/** + * RTA bars, or the same bands scrolling as a spectrogram. + * + * Both are canvas because both redraw wholesale many times a second, and + * thirty DOM nodes animating at 20 Hz is how a dashboard starts dropping + * frames on the machine that is also running the service. + */ +function spectrumView(root, moduleApi, scrolling) { + root.innerHTML = ''; + const canvas = root.querySelector('.spl-canvas'); + const ctx = canvas.getContext('2d', { alpha: false }); + let history = null; // spectrogram only: ImageData column store + + function sizeCanvas() { + const dpr = window.devicePixelRatio || 1; + const w = Math.max(1, Math.floor(root.clientWidth)); + const h = Math.max(1, Math.floor(root.clientHeight)); + if (canvas.width === Math.floor(w * dpr) && canvas.height === Math.floor(h * dpr)) return false; + canvas.width = Math.floor(w * dpr); + canvas.height = Math.floor(h * dpr); + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + history = null; // geometry changed — start the scroll again + return true; + } + sizeCanvas(); + + /** dB → 0..1 over the window a room actually uses. */ + function norm(db, calibrated) { + const lo = calibrated ? 30 : -100; + const hi = calibrated ? 110 : -10; + return Math.max(0, Math.min(1, (db - lo) / (hi - lo))); + } + + function heat(v) { + // Dark → accent → warn → danger, so a hot band reads the same way the + // readout's colours do. + const stops = [[10, 12, 20], [30, 90, 150], [90, 180, 140], [230, 190, 80], [220, 80, 60]]; + const x = Math.max(0, Math.min(0.999, v)) * (stops.length - 1); + const i = Math.floor(x), t = x - i; + const a = stops[i], b = stops[i + 1] || stops[i]; + return `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round(a[1] + (b[1] - a[1]) * t)},${Math.round(a[2] + (b[2] - a[2]) * t)})`; + } + + return { + render(payload) { + sizeCanvas(); + const w = canvas.clientWidth, h = canvas.clientHeight; + const bg = themeColor(root, '--panel', '#141414'); + const muted = themeColor(root, '--muted', '#888'); + + if (!payload || !payload.frame || !payload.frame.bands) { + ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); + ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center'; + ctx.fillText(payload?.error || 'Spectrum analysis is off', w / 2, h / 2); + return; + } + const s = moduleApi.instanceSettings; + const want = Number(s.channel); + const idx = Math.max(0, payload.frame.ch.findIndex((c) => c.i === want)); + const bands = payload.frame.bands[idx] || payload.frame.bands[0] || []; + const centres = payload.bandCentres || []; + if (!bands.length) return; + + if (!scrolling) { + ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); + const gap = 1; + const bw = w / bands.length; + for (let i = 0; i < bands.length; i++) { + const v = norm(bands[i], payload.calibrated); + const bh = v * (h - 14); + ctx.fillStyle = heat(v); + ctx.fillRect(i * bw + gap / 2, h - 14 - bh, bw - gap, bh); + } + // Only decade labels — thirty numbers across a tile is unreadable. + ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center'; + for (let i = 0; i < centres.length; i++) { + const f = centres[i]; + if (![100, 1000, 10000].includes(f)) continue; + ctx.fillText(f >= 1000 ? `${f / 1000}k` : String(f), i * bw + bw / 2, h - 3); + } + return; + } + + // Spectrogram: shift the image left, draw the newest column on the + // right. Cheaper than keeping our own ring of columns. + // + // The source rectangle has to be given in device pixels and the + // destination in CSS pixels: the context carries a devicePixelRatio + // transform, but the canvas used as an image source does not. Letting + // drawImage infer them blows the picture up by the ratio and pushes + // every column off the canvas, which looks exactly like "not drawing". + const colW = 2; + if (!history) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); history = true; } + ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h); + const bh = h / bands.length; + for (let i = 0; i < bands.length; i++) { + ctx.fillStyle = heat(norm(bands[i], payload.calibrated)); + // Low frequencies at the bottom, as every spectrogram has them. + ctx.fillRect(w - colW, h - (i + 1) * bh, colW, Math.ceil(bh) + 1); + } + }, + resize() { sizeCanvas(); }, + destroy() { root.innerHTML = ''; }, + }; +} diff --git a/modules/spl-meter/module.json b/modules/spl-meter/module.json new file mode 100644 index 0000000..f4800e4 --- /dev/null +++ b/modules/spl-meter/module.json @@ -0,0 +1,107 @@ +{ + "id": "spl-meter", + "name": "SPL Meter", + "version": "0.1.0", + "proddash": ">=1.5.0", + "description": "Calibrated sound level, Leq and RTA from an audio input on this machine", + "permissions": [ + { "kind": "microphone", "reason": "Measuring sound level from an audio input on this machine" } + ], + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { "w": 2, "h": 1 }, + "defaultSize": { "w": 3, "h": 2 }, + "configGroups": { + "Input": { "toggle": "enabled" }, + "Measurement": { "columns": 2 }, + "Calibration": { + "collapsed": "whenSet", + "help": "A sound level calibrator fits over the microphone capsule and emits a known level, almost always 94 dB at 1 kHz. Put it on, press Measure beside the channel, and copy the offset it reports into the box. Without this the meter still works, but it reads in dB relative to full scale and every tile says so." + } + }, + "configSchema": { + "enabled": { + "type": "switch", + "label": "Sound level measurement", + "group": "Input", + "default": false + }, + "device": { + "type": "select", + "label": "Audio device", + "group": "Input", + "showWhen": "enabled", + "default": "", + "optionsRoute": "/devices" + }, + "channels": { + "type": "string", + "label": "Channels on that device (1-based, comma separated)", + "group": "Input", + "showWhen": "enabled", + "default": "1", + "help": "One meter per channel — \"1\" for a single measurement mic, \"1,2\" for a stereo pair." + }, + "weighting": { + "type": "select", + "label": "Weighting", + "group": "Measurement", + "default": "A", + "options": [ + { "value": "A", "label": "A — how loud it sounds" }, + { "value": "C", "label": "C — keeps the low end" }, + { "value": "Z", "label": "Z — no weighting at all" } + ] + }, + "filter": { + "type": "select", + "label": "Time constant", + "group": "Measurement", + "default": "fast", + "options": [ + { "value": "fast", "label": "Fast — 125 ms" }, + { "value": "slow", "label": "Slow — 1 s" } + ] + }, + "bands": { + "type": "switch", + "label": "Spectrum analysis", + "group": "Measurement", + "default": true, + "help": "Third-octave bands for the RTA and spectrograph tiles. Turn it off if you only ever show the number." + }, + "calibration": { + "type": "string", + "label": "Offsets in dB, one per channel", + "group": "Calibration", + "default": "", + "help": "Leave empty and every reading is dB relative to full scale, labelled dBFS so nobody mistakes it for real SPL." + } + }, + "instanceGroups": { + "Thresholds": { "columns": 2, "help": "The readout turns amber then red as it crosses these. Set them to whatever this room has agreed is loud, not to a legal limit — this is not a compliance meter." } + }, + "instanceSchema": { + "channel": { + "type": "select", + "label": "Channel", + "default": "", + "optionsRoute": "/channels" + }, + "metric": { + "type": "select", + "label": "Reading", + "default": "spl", + "options": [ + { "value": "spl", "label": "SPL — right now" }, + { "value": "leq", "label": "Leq — average since reset" }, + { "value": "leq1m", "label": "Leq 1 min" }, + { "value": "leq10m", "label": "Leq 10 min" }, + { "value": "peakC", "label": "Peak, C-weighted" } + ] + }, + "warnAt": { "type": "number", "label": "Amber above", "group": "Thresholds", "default": 95 }, + "alarmAt": { "type": "number", "label": "Red above", "group": "Thresholds", "default": 100 } + } +} diff --git a/modules/spl-meter/server.js b/modules/spl-meter/server.js new file mode 100644 index 0000000..680e7a4 --- /dev/null +++ b/modules/spl-meter/server.js @@ -0,0 +1,297 @@ +'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; + +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', + filter: config.filter || 'fast', + 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 rather than one tile with a mode switch: an operator + * picking "+ SPL" wants to choose what they are putting on the wall, not + * place a box and then configure it into being useful. + */ + tiles() { + return [ + { id: 'readout', name: 'SPL', description: 'The level, big', + defaultSize: { w: 2, h: 1 }, minSize: { w: 1, h: 1 } }, + { id: 'bars', name: 'SPL Levels', description: 'One bar per channel, with peak hold', + defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } }, + { id: 'rta', name: 'RTA', description: 'Third-octave bars', + defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 } }, + { id: 'spectrogram', name: 'Spectrogram', description: 'Third-octave bands over time', + defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 } }, + ]; + }, + + 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 : '', + filter: state.meta ? state.meta.filter : '', + bandCentres: state.meta ? state.meta.bands : [], + 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); +} diff --git a/modules/spl-meter/spl-capture.swift b/modules/spl-meter/spl-capture.swift new file mode 100644 index 0000000..dfb727b --- /dev/null +++ b/modules/spl-meter/spl-capture.swift @@ -0,0 +1,694 @@ +// spl-capture — calibrated sound-level measurement from a CoreAudio input. +// +// Sibling of the Timers module's ltc-capture: same device enumeration, same +// "stdout is data, stderr is talk" contract, same one-file-no-dependencies +// rule. The difference is where the work happens. LTC ships raw samples to +// Node because the decode is cheap and single-channel; sound level is +// filtering and FFTs over every channel at the device's rate, so the +// measurement is done here and only the answers cross the pipe. +// +// Everything below uses Accelerate, which ships with macOS. No third-party +// code, no package manager, ~2 s to build: +// +// swiftc -O -o modules/spl-meter/spl-capture \ +// modules/spl-meter/spl-capture.swift +// +// spl-capture --list +// every input-capable device: name, input channels, sample rate +// spl-capture --device "Dante Via 16 Channel" --channels 1,2 [--cal 0,0] +// measures those 1-based channels and writes one JSON line per frame +// to stdout, 20×/s. --cal takes one dB offset per channel, in the same +// order, from the calibration route (94 dB reference minus what we +// measured). With no --cal the numbers are dBFS-relative and the line +// says calibrated:false, so nothing downstream can mistake them for +// real SPL. +// +// Per channel, per frame: SPL at the chosen weighting and time constant, +// Leq (running, 1-minute and 10-minute), C- and Z-weighted peak, raw dBFS, +// and a clip flag. Optionally a third-octave band set for the RTA and +// spectrograph tiles — only when --bands is passed, so an SPL-only dashboard +// doesn't pay for FFTs it never draws. +// +// Weighting filters are IEC 61672 pole/zero prototypes, bilinear-transformed +// with the top section prewarped, then normalised to exactly 0 dB at 1 kHz. +// Verified against the standard's table: within 0.15 dB from 31.5 Hz to +// 4 kHz. The top octave is where the bilinear transform runs out of room — +// at 48 kHz the 16 kHz point sits about 3 dB low. That is a rounding error +// on a broadband programme number (A-weighting has already thrown 6.6 dB +// away up there) but it is a real limit, and the fix if it ever matters is +// to run the weighting at 96 kHz. `--selftest` prints the table so this +// claim can be checked rather than believed. +// +// macOS asks for microphone permission on first use, and the prompt belongs +// to whatever launched this — the ProdDash launcher, or Terminal. A server +// started over SSH gets SILENT ZEROS from every input with no error at all, +// which shows up downstream as a meter pinned at the floor. + +import AVFoundation +import Accelerate +import CoreAudio +import Foundation + +func fail(_ msg: String) -> Never { + FileHandle.standardError.write(("spl-capture: " + msg + "\n").data(using: .utf8)!) + exit(2) +} + +func note(_ msg: String) { + FileHandle.standardError.write(("spl-capture: " + msg + "\n").data(using: .utf8)!) +} + +/* ── CoreAudio device enumeration ───────────────────────────────────── */ + +struct InputDevice { + let id: AudioDeviceID + let name: String + let uid: String + let channels: Int + let sampleRate: Double +} + +func stringProperty(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String { + var addr = AudioObjectPropertyAddress( + mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var ref: Unmanaged? = nil + var size = UInt32(MemoryLayout?>.size) + let err = withUnsafeMutablePointer(to: &ref) { + AudioObjectGetPropertyData(id, &addr, 0, nil, &size, $0) + } + guard err == noErr, let value = ref?.takeRetainedValue() else { return "" } + return value as String +} + +func sampleRateProperty(_ id: AudioObjectID) -> Double { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyNominalSampleRate, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var rate: Float64 = 0 + var size = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, &rate) == noErr else { return 0 } + return rate +} + +func inputChannelCount(_ id: AudioDeviceID) -> Int { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreamConfiguration, + mScope: kAudioDevicePropertyScopeInput, + mElement: kAudioObjectPropertyElementMain) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(id, &addr, 0, nil, &size) == noErr, size > 0 else { return 0 } + let listPtr = UnsafeMutableRawPointer.allocate( + byteCount: Int(size), alignment: MemoryLayout.alignment) + defer { listPtr.deallocate() } + guard AudioObjectGetPropertyData(id, &addr, 0, nil, &size, listPtr) == noErr else { return 0 } + let buffers = UnsafeMutableAudioBufferListPointer( + listPtr.assumingMemoryBound(to: AudioBufferList.self)) + return buffers.reduce(0) { $0 + Int($1.mNumberChannels) } +} + +func listInputDevices() -> [InputDevice] { + var addr = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize( + AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size) == noErr else { return [] } + var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout.size) + guard AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &ids) == noErr else { return [] } + + var out: [InputDevice] = [] + for id in ids { + let channels = inputChannelCount(id) + if channels == 0 { continue } + out.append(InputDevice(id: id, + name: stringProperty(id, kAudioObjectPropertyName), + uid: stringProperty(id, kAudioDevicePropertyDeviceUID), + channels: channels, + sampleRate: sampleRateProperty(id))) + } + return out +} + +/* ── frequency weighting (IEC 61672) ────────────────────────────────── */ + +/// Pole frequencies from the standard. +private let wf1 = 2 * Double.pi * 20.598997 +private let wf2 = 2 * Double.pi * 107.65265 +private let wf3 = 2 * Double.pi * 737.86223 +private let wf4 = 2 * Double.pi * 12194.217 + +/// Bilinear-transform one analogue biquad into [b0, b1, b2, a1, a2]. +/// +/// `warpAt` prewarps the transform so that section's critical frequency +/// lands where the analogue prototype put it. Without it the plain +/// `c = 2·fs` mapping squeezes the top octave badly — it is worth about +/// 3 dB at 16 kHz for the 12.2 kHz pole pair. +func bilinear(_ b: (Double, Double, Double), _ a: (Double, Double, Double), + fs: Double, warpAt: Double = 0) -> [Double] { + let c = warpAt > 0 ? warpAt / tan(warpAt / (2 * fs)) : 2 * fs + let c2 = c * c + let B0 = b.0 * c2 + b.1 * c + b.2 + let B1 = 2 * b.2 - 2 * b.0 * c2 + let B2 = b.0 * c2 - b.1 * c + b.2 + let A0 = a.0 * c2 + a.1 * c + a.2 + let A1 = 2 * a.2 - 2 * a.0 * c2 + let A2 = a.0 * c2 - a.1 * c + a.2 + return [B0 / A0, B1 / A0, B2 / A0, A1 / A0, A2 / A0] +} + +/// |H(f)| of a cascade of biquad sections, used to normalise gain at 1 kHz +/// and to print the verification table. +func cascadeResponse(_ secs: [Double], _ f: Double, fs: Double) -> Double { + let th = 2 * Double.pi * f / fs + var reT = 1.0, imT = 0.0 + for s in stride(from: 0, to: secs.count, by: 5) { + let (b0, b1, b2, a1, a2) = (secs[s], secs[s+1], secs[s+2], secs[s+3], secs[s+4]) + let nr = b0 + b1 * cos(th) + b2 * cos(2 * th) + let ni = -(b1 * sin(th) + b2 * sin(2 * th)) + let dr = 1 + a1 * cos(th) + a2 * cos(2 * th) + let di = -(a1 * sin(th) + a2 * sin(2 * th)) + let dd = dr * dr + di * di + guard dd > 0 else { return 0 } + let qr = (nr * dr + ni * di) / dd, qi = (ni * dr - nr * di) / dd + let t = reT * qr - imT * qi + imT = reT * qi + imT * qr + reT = t + } + return (reT * reT + imT * imT).squareRoot() +} + +enum Weighting: String { + case a = "A", c = "C", z = "Z" + + /// Biquad sections for this weighting at `fs`, normalised to 0 dB at 1 kHz. + func sections(fs: Double) -> [Double] { + var s: [Double] + switch self { + case .a: + s = bilinear((1, 0, 0), (1, 2 * wf1, wf1 * wf1), fs: fs) + + bilinear((1, 0, 0), (1, wf2 + wf3, wf2 * wf3), fs: fs) + + bilinear((0, 0, 1), (1, 2 * wf4, wf4 * wf4), fs: fs, warpAt: wf4) + case .c: + s = bilinear((1, 0, 0), (1, 2 * wf1, wf1 * wf1), fs: fs) + + bilinear((0, 0, 1), (1, 2 * wf4, wf4 * wf4), fs: fs, warpAt: wf4) + case .z: + return [1, 0, 0, 0, 0] // flat, one pass-through section + } + let g = 1.0 / cascadeResponse(s, 1000.0, fs: fs) + s[0] *= g; s[1] *= g; s[2] *= g + return s + } +} + +/// A weighting filter bound to one channel. The coefficients could be shared +/// but the delay line cannot, and a setup per channel costs a few hundred +/// bytes — not worth the aliasing risk of sharing one. +final class WeightingFilter { + private let setup: vDSP_biquad_Setup + private var delay: [Float] + private let sections: vDSP_Length + + init(_ weighting: Weighting, fs: Double) { + let coeffs = weighting.sections(fs: fs) + sections = vDSP_Length(coeffs.count / 5) + guard let s = vDSP_biquad_CreateSetup(coeffs, sections) else { + fail("cannot build \(weighting.rawValue)-weighting filter") + } + setup = s + delay = [Float](repeating: 0, count: Int(sections) * 2 + 2) + } + + deinit { vDSP_biquad_DestroySetup(setup) } + + func process(_ input: UnsafePointer, _ output: inout [Float], _ n: Int) { + vDSP_biquad(setup, &delay, input, 1, &output, 1, vDSP_Length(n)) + } +} + +/* ── third-octave band plan for the RTA ─────────────────────────────── */ + +/// Nominal third-octave centres. We start at 25 Hz rather than 20: with an +/// 8192-point FFT at 48 kHz the bins are 5.9 Hz apart, and the 20 Hz band is +/// only 4.6 Hz wide — one bin, sometimes none. Claiming a number there would +/// be drawing noise. A filter bank would reach lower; that is a later +/// problem, and the bands we do show are honest. +let bandCentres: [Double] = [25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, + 500, 630, 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000, + 5000, 6300, 8000, 10000, 12500, 16000, 20000] + +/// FFT bin ranges for each band, clamped to what the transform can resolve. +func bandBins(fs: Double, fftSize: Int) -> [(Int, Int)] { + let binHz = fs / Double(fftSize) + let maxBin = fftSize / 2 - 1 + return bandCentres.map { fc in + let lo = Int((fc * pow(2, -1.0 / 6.0) / binHz).rounded()) + let hi = Int((fc * pow(2, 1.0 / 6.0) / binHz).rounded()) + return (max(1, min(lo, maxBin)), max(1, min(max(hi, lo), maxBin))) + } +} + +/* ── per-channel measurement state ──────────────────────────────────── */ + +/// Everything the standard asks for on one channel. +/// +/// SPL is an exponential moving average of weighted mean square — Fast is a +/// 125 ms time constant, Slow 1 s, exactly as IEC 61672 defines them. Leq is +/// a plain energy average with no time constant at all, which is why it is +/// accumulated separately rather than read off the integrator. +final class ChannelMeter { + let index: Int // 1-based, as the operator counts them + private let fs: Double + private let calibration: Double // dB added to turn dBFS into dB SPL + + private let weightFilter: WeightingFilter + private let cFilter: WeightingFilter + private var weighted: [Float] + private var cWeighted: [Float] + + private var meanSquare: Double = 0 // exponential integrator, linear power + private var flatMeanSquare: Double = 0 // same integrator, unweighted — for dBFS + private let alphaPerSample: Double + + // Leq accumulators: running since reset, plus rolling 1 and 10 minute. + private var leqSum: Double = 0 + private var leqCount: Double = 0 + private var ring: [Double] = [] // one mean-square per emitted frame + private let ringCapacity: Int + + private var peakC: Double = 0 // linear, since reset + private var peakZ: Double = 0 + private var clipped = false + + init(index: Int, fs: Double, weighting: Weighting, fast: Bool, calibration: Double, + framesPerSecond: Double) { + self.index = index + self.fs = fs + self.calibration = calibration + weightFilter = WeightingFilter(weighting, fs: fs) + cFilter = WeightingFilter(.c, fs: fs) + weighted = [] + cWeighted = [] + let tau = fast ? 0.125 : 1.0 + alphaPerSample = 1.0 - exp(-1.0 / (tau * fs)) + ringCapacity = Int((600.0 * framesPerSecond).rounded()) // 10 minutes + } + + /// Feed one tap buffer for this channel. + func push(_ samples: UnsafePointer, _ n: Int) { + if weighted.count < n { + weighted = [Float](repeating: 0, count: n) + cWeighted = [Float](repeating: 0, count: n) + } + weightFilter.process(samples, &weighted, n) + cFilter.process(samples, &cWeighted, n) + + // Exponential integration over the weighted signal, sample by sample + // in the power domain. vDSP has no one-pole on squares, and at 48 kHz + // this is a few hundred thousand multiply-adds a second — noise next + // to the filtering above. + var ms = meanSquare + var flat = flatMeanSquare + weighted.withUnsafeBufferPointer { w in + for i in 0..= 0.999 { clipped = true } + } + + /// dB SPL from a linear mean-square, with the calibration offset applied. + private func db(_ power: Double) -> Double { + 20 * log10(max(power, 1e-20).squareRoot()) + calibration + } + + /// Called once per emitted frame; also advances the rolling Leq window. + /// + /// Returns a JSON object built by hand. JSONSerialization renders a + /// rounded Double as `-70.120000000000005`, which is both ugly on the + /// wire and noise in a diff — `%.2f` says what a meter can actually + /// resolve and nothing more. + func snapshot() -> String { + ring.append(meanSquare) + if ring.count > ringCapacity { ring.removeFirst(ring.count - ringCapacity) } + + let fps = Double(ringCapacity) / 600.0 + func rollingLeq(seconds: Double) -> Double { + let want = max(1, Int((seconds * fps).rounded())) + let slice = ring.suffix(want) + guard !slice.isEmpty else { return -200 } + return db(slice.reduce(0, +) / Double(slice.count)) + } + + return "{\"i\":\(index)" + + f("spl", db(meanSquare)) + + f("leq", leqCount > 0 ? db(leqSum / leqCount) : -200) + + f("leq1m", rollingLeq(seconds: 60)) + + f("leq10m", rollingLeq(seconds: 600)) + + f("peakC", db(peakC * peakC)) + + f("peakZ", db(peakZ * peakZ)) + + f("dbfs", 20 * log10(max(flatMeanSquare, 1e-20).squareRoot())) + + ",\"clip\":\(clipped)}" + } + + /// Clears Leq, peaks and the clip flag. The exponential integrator is + /// deliberately left alone — it is a live reading, not an accumulation, + /// and zeroing it would just make the meter dive for a moment. + func reset() { + leqSum = 0; leqCount = 0; ring.removeAll(keepingCapacity: true) + peakC = 0; peakZ = 0; clipped = false + } +} + +/// `,"key":-12.34` — two decimals, and a floor instead of -inf or NaN so a +/// silent input can never put a non-number on the wire. +func f(_ key: String, _ v: Double) -> String { + ",\"\(key)\":" + num(v) +} + +func num(_ v: Double) -> String { + guard v.isFinite else { return "-200.00" } + return String(format: "%.2f", max(v, -200)) +} + +/* ── spectrum for the RTA / spectrograph tiles ──────────────────────── */ + +final class Spectrum { + private let fftSize: Int + private let log2n: vDSP_Length + private let setup: FFTSetup + private var window: [Float] + private var accum: [Float] // sliding input buffer + private var filled = 0 + private let bins: [(Int, Int)] + private let windowPower: Double + + init(fs: Double, fftSize: Int) { + self.fftSize = fftSize + log2n = vDSP_Length(log2(Double(fftSize)).rounded()) + guard let s = vDSP_create_fftsetup(log2n, FFTRadix(kFFTRadix2)) else { + fail("cannot create FFT setup") + } + setup = s + window = [Float](repeating: 0, count: fftSize) + vDSP_hann_window(&window, vDSP_Length(fftSize), Int32(vDSP_HANN_DENORM)) + accum = [Float](repeating: 0, count: fftSize) + bins = bandBins(fs: fs, fftSize: fftSize) + // Coherent gain of the Hann window, so band levels are not 6 dB low. + windowPower = window.reduce(0.0) { $0 + Double($1) * Double($1) } / Double(fftSize) + } + + deinit { vDSP_destroy_fftsetup(setup) } + + /// Keep the most recent `fftSize` samples, shifting the window along. + /// Buffers larger than the window just overwrite it outright. + func push(_ samples: UnsafePointer, _ n: Int) { + if n >= fftSize { + accum.withUnsafeMutableBufferPointer { a in + a.baseAddress!.update(from: samples + (n - fftSize), count: fftSize) + } + filled = fftSize + return + } + accum.withUnsafeMutableBufferPointer { a in + let base = a.baseAddress! + // Slide the old tail down, then append the new block. + base.update(from: base + n, count: fftSize - n) + (base + fftSize - n).update(from: samples, count: n) + } + filled = min(fftSize, filled + n) + } + + /// Third-octave band levels in dB relative to full scale, plus the + /// calibration offset applied by the caller. + func bands(calibration: Double) -> [Double] { + guard filled >= fftSize else { return bins.map { _ in -200 } } + var windowed = [Float](repeating: 0, count: fftSize) + vDSP_vmul(accum, 1, window, 1, &windowed, 1, vDSP_Length(fftSize)) + + let half = fftSize / 2 + var re = [Float](repeating: 0, count: half) + var im = [Float](repeating: 0, count: half) + var mag = [Float](repeating: 0, count: half) + re.withUnsafeMutableBufferPointer { rp in + im.withUnsafeMutableBufferPointer { ip in + var split = DSPSplitComplex(realp: rp.baseAddress!, imagp: ip.baseAddress!) + windowed.withUnsafeBytes { raw in + vDSP_ctoz(raw.bindMemory(to: DSPComplex.self).baseAddress!, 2, + &split, 1, vDSP_Length(half)) + } + vDSP_fft_zrip(setup, &split, 1, log2n, FFTDirection(FFT_FORWARD)) + vDSP_zvmags(&split, 1, &mag, 1, vDSP_Length(half)) + } + } + // vDSP's real FFT carries a factor of 2, and zvmags squares it. + let scale = 1.0 / (4.0 * Double(fftSize) * Double(fftSize) * windowPower) + return bins.map { (lo, hi) in + var power = 0.0 + for b in lo...hi { power += Double(mag[b]) } + power *= scale * 2.0 // one-sided spectrum + return 10 * log10(max(power, 1e-20)) + calibration + } + } +} + +/* ── args ───────────────────────────────────────────────────────────── */ + +var wantList = false +var wantSelftest = false +var wantDevice: String? = nil +var wantChannels: [Int] = [1] +var wantCal: [Double] = [] +var wantWeighting = Weighting.a +var wantFast = true +var wantBands = false +var framesPerSecond = 20.0 + +let argv = CommandLine.arguments +var argIdx = 1 +func nextArg(_ flag: String) -> String { + argIdx += 1 + guard argIdx < argv.count else { fail("\(flag) needs a value") } + return argv[argIdx] +} +while argIdx < argv.count { + switch argv[argIdx] { + case "--list": wantList = true + case "--selftest": wantSelftest = true + case "--device": wantDevice = nextArg("--device") + case "--channels": + wantChannels = nextArg("--channels").split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } + if wantChannels.isEmpty { fail("--channels needs at least one 1-based channel number") } + case "--cal": + wantCal = nextArg("--cal").split(separator: ",").compactMap { Double($0.trimmingCharacters(in: .whitespaces)) } + case "--weighting": + let raw = nextArg("--weighting").uppercased() + guard let w = Weighting(rawValue: raw) else { fail("--weighting must be A, C or Z") } + wantWeighting = w + case "--filter": + let raw = nextArg("--filter").lowercased() + guard raw == "fast" || raw == "slow" else { fail("--filter must be fast or slow") } + wantFast = (raw == "fast") + case "--bands": wantBands = true + case "--rate": + guard let r = Double(nextArg("--rate")), r >= 1, r <= 50 else { fail("--rate must be 1-50 (frames per second)") } + framesPerSecond = r + default: fail("unknown flag \(argv[argIdx]) — try --list") + } + argIdx += 1 +} + +/* ── selftest: prove the weighting curves before trusting a reading ─── */ + +if wantSelftest { + // IEC 61672-1 table 3, nominal A- and C-weighting. + let table: [(Double, Double, Double)] = [ + (31.5, -39.4, -3.0), (63, -26.2, -0.8), (125, -16.1, -0.2), (250, -8.6, 0.0), + (500, -3.2, 0.0), (1000, 0.0, 0.0), (2000, 1.2, -0.2), (4000, 1.0, -0.8), + (8000, -1.1, -3.0), (16000, -6.6, -8.5), + ] + for fs in [48000.0, 96000.0] { + let a = Weighting.a.sections(fs: fs), c = Weighting.c.sections(fs: fs) + print("— \(Int(fs)) Hz —") + print(" Hz A IEC err C IEC err") + var worstA = 0.0, worstC = 0.0 + for (f, wantA, wantC) in table where f < fs / 2 { + let gotA = 20 * log10(cascadeResponse(a, f, fs: fs)) + let gotC = 20 * log10(cascadeResponse(c, f, fs: fs)) + worstA = max(worstA, abs(gotA - wantA)); worstC = max(worstC, abs(gotC - wantC)) + print(String(format: "%7.0f %+7.2f %+7.2f %+6.2f %+7.2f %+7.2f %+6.2f", + f, gotA, wantA, gotA - wantA, gotC, wantC, gotC - wantC)) + } + print(String(format: " worst: A %.2f dB, C %.2f dB\n", worstA, worstC)) + } + exit(0) +} + +/* ── device selection ───────────────────────────────────────────────── */ + +let devices = listInputDevices() + +if wantList || wantDevice == nil { + if devices.isEmpty { fail("no input-capable audio devices found") } + // stdout, as JSON: the module's /devices route feeds this straight to + // the admin page's device picker. + let payload = devices.map { ["name": $0.name, "uid": $0.uid, + "channels": $0.channels, "sampleRate": $0.sampleRate] } + let data = try! JSONSerialization.data(withJSONObject: ["devices": payload]) + FileHandle.standardOutput.write(data) + FileHandle.standardOutput.write("\n".data(using: .utf8)!) + if wantDevice == nil && !wantList { note("pick one with --device ") } + exit(wantList ? 0 : 2) +} + +let query = wantDevice!.lowercased() +let exact = devices.filter { $0.name.lowercased() == query || $0.uid.lowercased() == query } +let matches = exact.count == 1 ? exact + : devices.filter { $0.name.lowercased().contains(query) || $0.uid.lowercased().contains(query) } +guard matches.count == 1 else { + if matches.isEmpty { fail("no input device matches \"\(wantDevice!)\" — see --list") } + fail("\"\(wantDevice!)\" is ambiguous: " + matches.map { $0.name }.joined(separator: ", ")) +} +let device = matches[0] + +/* ── capture ────────────────────────────────────────────────────────── */ + +let engine = AVAudioEngine() +guard let unit = engine.inputNode.audioUnit else { fail("no input audio unit") } +var deviceID = device.id +let setErr = AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice, + kAudioUnitScope_Global, 0, &deviceID, + UInt32(MemoryLayout.size)) +guard setErr == noErr else { fail("cannot select \"\(device.name)\" (error \(setErr))") } + +let format = engine.inputNode.inputFormat(forBus: 0) +let channelCount = Int(format.channelCount) +let sampleRate = format.sampleRate +guard channelCount > 0, sampleRate > 0 else { + fail("\"\(device.name)\" reports no usable input format — microphone access may be denied " + + "(System Settings → Privacy & Security → Microphone)") +} +for ch in wantChannels where ch < 1 || ch > channelCount { + fail("channel \(ch) is out of range — \"\(device.name)\" has \(channelCount)") +} + +let calibrated = !wantCal.isEmpty +if calibrated && wantCal.count != wantChannels.count { + fail("--cal needs one offset per channel (\(wantChannels.count) given \(wantCal.count))") +} + +let meters = wantChannels.enumerated().map { (i, ch) in + ChannelMeter(index: ch, fs: sampleRate, weighting: wantWeighting, fast: wantFast, + calibration: calibrated ? wantCal[i] : 0, framesPerSecond: framesPerSecond) +} +let spectra = wantBands ? wantChannels.map { _ in Spectrum(fs: sampleRate, fftSize: 8192) } : [] + +note("capturing \"\(device.name)\": \(channelCount) channels @ \(Int(sampleRate)) Hz, " + + "measuring \(wantChannels.map(String.init).joined(separator: ",")) " + + "\(wantWeighting.rawValue)-weighted \(wantFast ? "fast" : "slow")" + + (calibrated ? "" : ", UNCALIBRATED")) + +// Announced once so the reader knows the band plan without hardcoding it. +let hello: [String: Any] = [ + "hello": 1, + "device": device.name, + "sampleRate": sampleRate, + "deviceChannels": channelCount, + "channels": wantChannels, + "weighting": wantWeighting.rawValue, + "filter": wantFast ? "fast" : "slow", + "calibrated": calibrated, + "bands": wantBands ? bandCentres : [], +] +let stdoutHandle = FileHandle.standardOutput +func emitLine(_ json: String) { + do { + try stdoutHandle.write(contentsOf: Data((json + "\n").utf8)) + } catch { exit(0) } // downstream pipe closed — nothing to say +} +if let data = try? JSONSerialization.data(withJSONObject: hello), + let line = String(data: data, encoding: .utf8) { + emitLine(line) +} + +// A reset is asked for on stdin (one line, "reset") rather than a signal, so +// the listener can clear Leq and peaks without restarting capture and losing +// the filters' settling. +let resetQueue = DispatchQueue(label: "spl-capture.reset") +var resetRequested = false +DispatchQueue.global().async { + while let line = readLine(strippingNewline: true) { + if line.trimmingCharacters(in: .whitespaces).lowercased() == "reset" { + resetQueue.sync { resetRequested = true } + } + } +} + +let tapQueue = DispatchQueue(label: "spl-capture.tap") +engine.inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { buffer, _ in + let frames = Int(buffer.frameLength) + guard frames > 0, let floats = buffer.floatChannelData else { return } + tapQueue.sync { + for (i, ch) in wantChannels.enumerated() { + let src = floats[ch - 1] // tap data is per-channel, not interleaved + meters[i].push(src, frames) + if wantBands { spectra[i].push(src, frames) } + } + } +} + +do { try engine.start() } catch { + fail("engine start failed: \(error.localizedDescription) — if this is a permission problem, " + + "approve microphone access for the launching app and retry") +} + +let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global()) +timer.schedule(deadline: .now() + 1.0 / framesPerSecond, + repeating: 1.0 / framesPerSecond) +timer.setEventHandler { + var wantReset = false + resetQueue.sync { wantReset = resetRequested; resetRequested = false } + + var line = "{\"t\":\(Int(Date().timeIntervalSince1970 * 1000))" + tapQueue.sync { + if wantReset { meters.forEach { $0.reset() } } + line += ",\"ch\":[" + meters.map { $0.snapshot() }.joined(separator: ",") + "]" + if wantBands { + let sets = spectra.enumerated().map { (i, s) -> String in + "[" + s.bands(calibration: calibrated ? wantCal[i] : 0) + .map(num).joined(separator: ",") + "]" + } + line += ",\"bands\":[" + sets.joined(separator: ",") + "]" + } + } + emitLine(line + "}") +} +timer.resume() + +signal(SIGINT) { _ in exit(0) } +signal(SIGTERM) { _ in exit(0) } +RunLoop.main.run() diff --git a/modules/spl-meter/spl-listener.js b/modules/spl-meter/spl-listener.js new file mode 100644 index 0000000..8893848 --- /dev/null +++ b/modules/spl-meter/spl-listener.js @@ -0,0 +1,191 @@ +'use strict'; + +/** + * Sound level listener — owns the native capture child. + * + * Spawns spl-capture (compiled from spl-capture.swift beside it) for the + * admin-configured device and channels, reads its JSON lines, and hands the + * latest frame upward. All measurement happens in the child; this file is + * plumbing and supervision, nothing more. + * + * Reports via onUpdate: + * + * { frame: {...}, meta: {...} } measuring + * { error: '' } not measuring + * + * The capture binary is per-machine and gitignored, exactly like the Timers + * module's ltc-capture: if it is missing this tries one swiftc build of the + * source next to it, and otherwise says how to build it elsewhere and copy + * it over. + * + * The same macOS gotcha applies as for LTC, and is worth repeating because + * it costs an afternoon every time: a ProdDash launched over SSH or from a + * bare launchd job gets SILENT ZEROS from every input, with no error + * anywhere. Here that shows up as a meter pinned at -200. Launch from a + * logged-in GUI context and approve the microphone prompt once. + */ + +const path = require('path'); +const fs = require('fs'); +const { spawn, spawnSync } = require('child_process'); + +const RESTART_MS = 5000; +/** An immediate exit is config-shaped (bad device, channel out of range) — + retrying every 5 s would only fill the log with the same sentence. */ +const BAD_CONFIG_RETRY_MS = 30000; +/** No frame for this long means the child is alive but not delivering. */ +const STALL_MS = 3000; + +/** + * @param {{device:string, channels:number[], weighting:string, filter:string, + * bands:boolean, calibration:number[], moduleDir:string, + * log:Function, onUpdate:(u:object)=>void}} opts + * @returns {{stop:()=>void, reset:()=>void}} + */ +function createSplListener(opts) { + const { device, channels, weighting, filter, bands, calibration, moduleDir, log, onUpdate } = opts; + const bin = path.join(moduleDir, 'spl-capture'); + + let child = null; + let stopped = false; + let restartTimer = null; + let stallTimer = null; + let startedAt = 0; + let meta = null; + let lastFrameWall = 0; + + function ensureBinary() { + if (fs.existsSync(bin)) return ''; + const src = bin + '.swift'; + if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder'; + log('spl-capture binary missing — trying a one-time swiftc build'); + const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 180000 }); + if (built.status === 0 && fs.existsSync(bin)) { + log('spl-capture compiled'); + return ''; + } + return 'Sound level capture tool missing — build it on any Apple Silicon Mac with ' + + '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` ' + + 'and copy it here'; + } + + function scheduleRestart(ms) { + if (stopped || restartTimer) return; + restartTimer = setTimeout(() => { restartTimer = null; start(); }, ms); + } + + function armStallWatch() { + clearTimeout(stallTimer); + stallTimer = setTimeout(() => { + if (stopped || !child) return; + if (Date.now() - lastFrameWall >= STALL_MS) { + log('no frames for 3 s — restarting capture'); + try { child.kill('SIGTERM'); } catch { /* already gone */ } + } + }, STALL_MS); + } + + function start() { + if (stopped) return; + + const missing = ensureBinary(); + if (missing) { + onUpdate({ error: missing }); + scheduleRestart(BAD_CONFIG_RETRY_MS); + return; + } + + const args = ['--device', device, '--channels', channels.join(','), + '--weighting', weighting, '--filter', filter, '--rate', '20']; + if (bands) args.push('--bands'); + if (calibration.length === channels.length) args.push('--cal', calibration.join(',')); + + startedAt = Date.now(); + meta = null; + let stderrTail = ''; + let buf = ''; + + child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (text) => { + stderrTail = (stderrTail + text).slice(-500); + }); + + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (text) => { + buf += text; + let i; + while ((i = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, i).trim(); + buf = buf.slice(i + 1); + if (!line) continue; + let obj; + try { obj = JSON.parse(line); } catch { continue; } + if (obj.hello) { + meta = obj; + log(`measuring ${obj.device} ch ${obj.channels.join(',')} ` + + `${obj.weighting}-weighted ${obj.filter}` + + (obj.calibrated ? '' : ' (uncalibrated)')); + continue; + } + if (!obj.ch) continue; + lastFrameWall = Date.now(); + armStallWatch(); + onUpdate({ frame: obj, meta }); + } + // A run of junk with no newline would grow forever; the child never + // emits a line this long, so anything that does is a bug upstream. + if (buf.length > 65536) buf = ''; + }); + + child.on('error', (err) => { + onUpdate({ error: `cannot run spl-capture: ${err.message}` }); + child = null; + scheduleRestart(BAD_CONFIG_RETRY_MS); + }); + + child.on('exit', (code, signal) => { + child = null; + clearTimeout(stallTimer); + if (stopped) return; + const ranFor = Date.now() - startedAt; + const why = stderrTail.trim().split('\n').pop() || `exit ${code}${signal ? ' ' + signal : ''}`; + // Exiting almost immediately means it never got as far as capturing — + // an unknown device, an out-of-range channel, a denied microphone. + // Those do not fix themselves, so back off hard and say why. + if (ranFor < 2000) { + onUpdate({ error: why }); + scheduleRestart(BAD_CONFIG_RETRY_MS); + } else { + log(`capture exited after ${Math.round(ranFor / 1000)}s: ${why}`); + scheduleRestart(RESTART_MS); + } + }); + + armStallWatch(); + } + + start(); + + return { + /** Clear Leq and peak hold without restarting capture — the filters keep + their settling, so the live number never dips. */ + reset() { + if (child && child.stdin.writable) { + try { child.stdin.write('reset\n'); } catch { /* child going away */ } + } + }, + stop() { + stopped = true; + clearTimeout(restartTimer); restartTimer = null; + clearTimeout(stallTimer); stallTimer = null; + if (child) { + try { child.kill('SIGTERM'); } catch { /* already gone */ } + child = null; + } + }, + }; +} + +module.exports = { createSplListener }; diff --git a/modules/spl-meter/style.css b/modules/spl-meter/style.css new file mode 100644 index 0000000..98e2dda --- /dev/null +++ b/modules/spl-meter/style.css @@ -0,0 +1,147 @@ +/* SPL Meter — every colour comes from the shell's variables so themes and + per-tile tints reach in. Nothing here is hardcoded except the canvas + heat ramp, which client.js owns because it has to interpolate. */ + +[data-module="spl-meter"] .spl { + height: 100%; + display: flex; + flex-direction: column; + min-height: 0; +} + +/* ── readout ──────────────────────────────────────────────────────── */ + +[data-module="spl-meter"] .spl-readout { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.25em; + min-height: 0; + padding: 0.25rem; +} + +[data-module="spl-meter"] .spl-value { + display: flex; + align-items: baseline; + gap: 0.2em; + line-height: 1; + color: var(--text); +} + +/* Sized off the tile, so one cell and six cells both read well. Height has + to be in the min() as well as width: a 2x1 tile is wide and short, and + sizing on width alone pushes the digits straight out through the top. */ +[data-module="spl-meter"] .spl-num { + font-size: clamp(1.25rem, min(20cqw, 42cqh), 6rem); + font-variant-numeric: tabular-nums; + font-weight: 600; + letter-spacing: -0.02em; +} + +[data-module="spl-meter"] .spl-unit { + font-size: clamp(0.65rem, min(6cqw, 13cqh), 1.5rem); + color: var(--muted); +} + +[data-module="spl-meter"] .spl-sub { + font-size: 0.7rem; + color: var(--muted); + text-align: center; + line-height: 1.3; +} + +[data-module="spl-meter"] .spl-readout[data-level="warn"] .spl-num { color: var(--warn); } +[data-module="spl-meter"] .spl-readout[data-level="alarm"] .spl-num { color: var(--danger); } +[data-module="spl-meter"] .spl-readout[data-level="idle"] .spl-num { color: var(--muted); } + +/* ── level bars ───────────────────────────────────────────────────── */ + +[data-module="spl-meter"] .spl-bars { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.35rem; + padding: 0.4rem; + min-height: 0; + overflow-y: auto; +} + +[data-module="spl-meter"] .spl-bar { + display: grid; + grid-template-columns: 1.4em 1fr 3.4em; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; +} + +[data-module="spl-meter"] .spl-bar-label { color: var(--muted); text-align: right; } + +[data-module="spl-meter"] .spl-bar-track { + position: relative; + height: 0.65rem; + border-radius: 0.35rem; + background: color-mix(in srgb, var(--border) 60%, transparent); + overflow: hidden; +} + +[data-module="spl-meter"] .spl-bar-fill { + position: absolute; + inset: 0 auto 0 0; + width: 0; + border-radius: inherit; + background: var(--accent); + transition: width 60ms linear; +} + +[data-module="spl-meter"] .spl-bar[data-level="warn"] .spl-bar-fill { background: var(--warn); } +[data-module="spl-meter"] .spl-bar[data-level="alarm"] .spl-bar-fill { background: var(--danger); } +[data-module="spl-meter"] .spl-bar[data-level="idle"] .spl-bar-fill { background: var(--muted); } + +/* Peak hold sits on the track rather than inside the fill, so it stays + visible when the bar falls away underneath it. */ +[data-module="spl-meter"] .spl-bar-peak { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: var(--text); + opacity: 0.75; +} + +[data-module="spl-meter"] .spl-bar-value { + font-variant-numeric: tabular-nums; + text-align: right; + color: var(--text); +} + +[data-module="spl-meter"] .spl-bar.clip .spl-bar-value { color: var(--danger); } + +/* ── canvas tiles ─────────────────────────────────────────────────── */ + +[data-module="spl-meter"] .spl-canvas { + flex: 1; + display: block; + width: 100%; + min-height: 0; + border-radius: 0.25rem; +} + +/* The readout scales off both of the tile's dimensions, so the container + has to be sized on both axes — inline-size alone leaves cqh undefined. */ +[data-module="spl-meter"] .spl { container-type: size; } + +/* The sub-line is the first thing to go when the tile gets short: the + number is the point, and a clipped caption under it is worse than none. */ +[data-module="spl-meter"] .spl-sub { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@container (max-height: 90px) { + [data-module="spl-meter"] .spl-sub { display: none; } +} -- 2.54.0 From e90ae514959db0ba86d7d200f538a00852074625 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 17 Sep 2026 11:10:50 -0400 Subject: [PATCH 2/7] Never build the capture tool on the event loop The missing-binary fallback was a spawnSync, copied from the LTC listener where the source is a third the size and builds in a few seconds. On the production Mac this file took nearly two minutes, and spawnSync held the Node event loop for every second of it: one module's optional helper froze the entire dashboard, every other module with it, and then did it again thirty seconds later on retry. The build is now a plain spawn that reports "building" and calls start() itself when it lands, so nothing waits on it. A failed build says so once instead of spinning, and a build that never finishes is given up on after five minutes. The real lesson is the one in the comment: the binary should already be there. It is built beside the app, or copied from any Mac of the same architecture. Building on the machine that might be running a service is the fallback, not the plan. Co-Authored-By: Claude Opus 5 --- modules/spl-meter/spl-listener.js | 74 +++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/modules/spl-meter/spl-listener.js b/modules/spl-meter/spl-listener.js index 8893848..cffe363 100644 --- a/modules/spl-meter/spl-listener.js +++ b/modules/spl-meter/spl-listener.js @@ -14,9 +14,10 @@ * { error: '' } not measuring * * The capture binary is per-machine and gitignored, exactly like the Timers - * module's ltc-capture: if it is missing this tries one swiftc build of the - * source next to it, and otherwise says how to build it elsewhere and copy - * it over. + * module's ltc-capture. It normally arrives already built — compiled beside + * the app, or copied from any Mac of the same architecture. When it is + * missing this will build it, but in the background and never on the event + * loop: see ensureBinary for what that cost the first time it was wrong. * * The same macOS gotcha applies as for LTC, and is worth repeating because * it costs an afternoon every time: a ProdDash launched over SSH or from a @@ -27,7 +28,7 @@ const path = require('path'); const fs = require('fs'); -const { spawn, spawnSync } = require('child_process'); +const { spawn } = require('child_process'); const RESTART_MS = 5000; /** An immediate exit is config-shaped (bad device, channel out of range) — @@ -52,21 +53,64 @@ function createSplListener(opts) { let stallTimer = null; let startedAt = 0; let meta = null; + let building = false; + let buildFailed = false; let lastFrameWall = 0; + const BUILD_HELP = 'Sound level capture tool missing — build it on any Apple Silicon Mac with ' + + '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` ' + + 'and copy it next to spl-capture.swift'; + + /** + * Build the capture tool if it is missing, WITHOUT blocking. + * + * This used to be a spawnSync, copied from the LTC listener where the + * source is a third the size and builds in a few seconds. Here it took + * nearly two minutes on the production Mac and froze the entire ProdDash + * server for every second of it — one module's optional helper took the + * whole dashboard down, then did it again on every retry. Nothing that + * runs while a service might be on is allowed to hold the event loop. + * + * The build is a fallback for a module installed from the catalog on a + * machine with a Swift toolchain. The normal path is a binary built + * alongside the app, or copied from any same-architecture Mac. + * + * @returns {string} '' when the binary is ready, otherwise what to say. + */ function ensureBinary() { if (fs.existsSync(bin)) return ''; const src = bin + '.swift'; if (!fs.existsSync(src)) return 'spl-capture.swift is missing from the module folder'; - log('spl-capture binary missing — trying a one-time swiftc build'); - const built = spawnSync('swiftc', ['-O', '-o', bin, src], { timeout: 180000 }); - if (built.status === 0 && fs.existsSync(bin)) { - log('spl-capture compiled'); - return ''; - } - return 'Sound level capture tool missing — build it on any Apple Silicon Mac with ' - + '`swiftc -O -o modules/spl-meter/spl-capture modules/spl-meter/spl-capture.swift` ' - + 'and copy it here'; + if (building) return 'Building the capture tool — this takes a minute or two'; + if (buildFailed) return BUILD_HELP; + + building = true; + log('spl-capture binary missing — building it in the background'); + const build = spawn('swiftc', ['-O', '-o', bin, src], { stdio: ['ignore', 'ignore', 'pipe'] }); + let buildErr = ''; + build.stderr.setEncoding('utf8'); + build.stderr.on('data', (t) => { buildErr = (buildErr + t).slice(-800); }); + const giveUp = setTimeout(() => { + try { build.kill('SIGKILL'); } catch { /* already gone */ } + }, 300000); + build.on('error', (err) => { + clearTimeout(giveUp); + building = false; buildFailed = true; + log(`swiftc could not be run: ${err.message}`); + }); + build.on('exit', (code) => { + clearTimeout(giveUp); + building = false; + if (code === 0 && fs.existsSync(bin)) { + log('spl-capture built'); + buildFailed = false; + if (!stopped) { clearTimeout(restartTimer); restartTimer = null; start(); } + return; + } + buildFailed = true; + log(`spl-capture build failed (exit ${code})` + (buildErr ? `: ${buildErr.trim().split('\n').pop()}` : '')); + }); + return 'Building the capture tool — this takes a minute or two'; } function scheduleRestart(ms) { @@ -91,7 +135,9 @@ function createSplListener(opts) { const missing = ensureBinary(); if (missing) { onUpdate({ error: missing }); - scheduleRestart(BAD_CONFIG_RETRY_MS); + // A build in flight calls start() itself when it lands; anything else + // is config-shaped and should back off hard rather than spin. + if (!building) scheduleRestart(BAD_CONFIG_RETRY_MS); return; } -- 2.54.0 From d2d4207fe4e1b5aacd9bad98752f20bf70f63063 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 17 Sep 2026 12:47:42 -0400 Subject: [PATCH 3/7] SPL Meter 0.2.0: one measurement, many views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every control asked for here is a view choice, so none of them may reach the capture child — two dashboards watching the same room must be able to disagree about how to draw it without disturbing the measurement or each other. That set the shape of the change: the child now sends everything anyone might ask for, once, and each tile folds it down. Averaging (was "Reading") is Fast, Slow, Inf and None, and the child computes and sends all four every frame. Fast and Slow are the IEC time constants, Inf is Leq since reset, None is the block with no time constant at all. The admin-wide time constant is gone — it was never a property of the room. Banding is 1/3 through 1/48 octave. The wire always carries 1/48, and because every banding offered divides 48 a coarser band is an exact sum of finer ones in the power domain. Nothing is resampled and no tile's choice is visible to another. The transform grew to 16384 points to have bins fine enough to mean it; below roughly 200 Hz a 1/48 band is still narrower than one bin, which the Banding help says out loud rather than pretending. Each tile kind now carries its own settings, which is the fix for the complaint that started this: the RTA was offering an alarm threshold and a metric picker that did nothing to it. An entry's instanceSchema replaces the module's, so a gear popover only holds controls that change that tile. Also: frequency labels in three tiers that thin out as a tile narrows instead of smearing, gridlines off by default on both graphs, RTA smoothing as a damping factor on the drawn value only, a title-bar toggle to swap the spectrogram's axes, and the threshold blurb removed. Settings saved by 0.1.0 are mapped forward — a layout on a wall should not start reading dashes because a field was renamed. Co-Authored-By: Claude Opus 5 --- modules/spl-meter/client.js | 319 ++++++++++++++++++++++------ modules/spl-meter/module.json | 77 +++---- modules/spl-meter/server.js | 134 ++++++++++-- modules/spl-meter/spl-capture.swift | 125 +++++++---- modules/spl-meter/spl-listener.js | 10 +- 5 files changed, 496 insertions(+), 169 deletions(-) diff --git a/modules/spl-meter/client.js b/modules/spl-meter/client.js index 453b2f0..85909ee 100644 --- a/modules/spl-meter/client.js +++ b/modules/spl-meter/client.js @@ -3,18 +3,23 @@ * * Four kinds, all fed by one SSE stream from the module's server: * - * readout the number, big — what most dashboards leave up + * readout the number, big * bars one level bar per channel, with peak hold - * rta third-octave bars - * spectrogram the same bands, scrolling + * rta spectrum now, folded to the banding this tile wants + * spectrogram the same bands over time, either axis * - * Nothing here computes a level. The capture child measured it; this draws - * it. That split is deliberate — the browser has no access to the audio - * device and no business pretending otherwise. + * Nothing here measures anything. The capture child already sent every + * averaging and the finest banding, and each tile folds that down to the + * view it was configured for. That is the whole design: two dashboards can + * watch one room through different settings without either disturbing the + * measurement or the other dashboard. */ const FLOOR = -200; +/** Retention per frame for the RTA's smoothing setting. */ +const SMOOTHING = { off: 0, light: 0.45, medium: 0.72, heavy: 0.9 }; + export default function create({ root, moduleApi }) { const variant = moduleApi.variant || 'readout'; let stream = null; @@ -37,8 +42,8 @@ export default function create({ root, moduleApi }) { } else { moduleApi.setStatus('ok', ''); } - // Frames land 20×/s; paint on the next frame the browser was going to - // draw anyway rather than fighting it for every one. + // Frames land 20×/s; paint on a frame the browser was going to draw + // anyway rather than fighting it for every one. if (!raf) raf = requestAnimationFrame(() => { raf = 0; view.render(latest); }); } @@ -71,24 +76,105 @@ export default function create({ root, moduleApi }) { /* ── shared helpers ─────────────────────────────────────────────────── */ -/** The channel this tile is showing, falling back to the first configured. */ function pickChannel(payload, settings) { if (!payload || !payload.frame) return null; const want = Number(settings.channel); return payload.frame.ch.find((c) => c.i === want) || payload.frame.ch[0] || null; } +/** + * The level this tile asked for: an averaging, or one of the fixed metrics. + * + * Tiles saved by an earlier version carry metrics that no longer exist — + * "spl" became an averaging, "leq" became Inf. A layout on a wall somewhere + * must not start reading dashes because a setting was renamed, so those map + * forward instead of falling through to undefined. + */ +const LEGACY_METRIC = { spl: 'level', leq: 'level' }; +const LEGACY_AVERAGING = { leq: 'inf' }; + +function readValue(ch, settings) { + const raw = settings.metric || 'level'; + const metric = LEGACY_METRIC[raw] || raw; + if (metric !== 'level') { + return metric in ch ? ch[metric] : ch.fast; + } + const avg = LEGACY_AVERAGING[raw] || settings.averaging || 'fast'; + return avg in ch ? ch[avg] : ch.fast; +} + function fmt(db) { if (!Number.isFinite(db) || db <= FLOOR) return '––.–'; return db.toFixed(1); } -/** Read a CSS custom property off the tile so canvases follow the theme. */ function themeColor(el, name, fallback) { const v = getComputedStyle(el).getPropertyValue(name).trim(); return v || fallback; } +function hzLabel(f) { + if (f >= 1000) { + const k = f / 1000; + return (k >= 10 ? Math.round(k) : Math.round(k * 10) / 10) + 'k'; + } + return String(f >= 100 ? Math.round(f) : Math.round(f * 10) / 10); +} + +/** + * Fold the 1/48-octave bands on the wire down to this tile's banding. + * + * Every banding offered divides 48, so a coarser band is an exact sum of + * finer ones in the power domain — no interpolation, no resampling. Groups + * are keyed on the 1 kHz band (n = 0), which is what keeps folded centres on + * the values an RTA is expected to show. + * + * @returns {{freq:number[], db:number[]}} + */ +function foldBands(bands, bandN0, perOctave, denom) { + const g = Math.max(1, Math.round(perOctave / denom)); + const power = new Map(); + for (let i = 0; i < bands.length; i++) { + const n = bandN0 + i; + const k = Math.round(n / g); + const p = 10 ** (bands[i] / 10); + power.set(k, (power.get(k) || 0) + p); + } + const keys = [...power.keys()].sort((a, b) => a - b); + return { + freq: keys.map((k) => 1000 * 2 ** ((k * g) / perOctave)), + db: keys.map((k) => 10 * Math.log10(Math.max(power.get(k), 1e-20))), + }; +} + +/** + * Which frequency labels fit, most important first. + * + * Decades earn their place before octaves, octaves before thirds, and + * anything that would collide with a label already placed is dropped. That + * is what makes the axis thin out as a tile shrinks instead of turning into + * a smear. + */ +const LABEL_TIERS = [ + [100, 1000, 10000], + [31.5, 63, 250, 500, 2000, 4000, 8000, 20000], + [20, 40, 50, 80, 125, 160, 200, 315, 400, 630, 800, 1250, 1600, 2500, 3150, 5000, 6300, 12500, 16000], +]; + +function chooseLabels(freqs, positionOf, extent, minGapPx) { + const placed = []; + for (const tier of LABEL_TIERS) { + for (const f of tier) { + if (f < freqs[0] * 0.92 || f > freqs[freqs.length - 1] * 1.08) continue; + const pos = positionOf(f); + if (pos < 0 || pos > extent) continue; + if (placed.some((p) => Math.abs(p.pos - pos) < minGapPx)) continue; + placed.push({ f, pos }); + } + } + return placed.sort((a, b) => a.pos - b.pos); +} + /* ── views ──────────────────────────────────────────────────────────── */ function buildView(variant, root, moduleApi) { @@ -100,7 +186,6 @@ function buildView(variant, root, moduleApi) { } } -/** The big number. */ function readoutView(root, moduleApi) { root.innerHTML = `
@@ -122,8 +207,7 @@ function readoutView(root, moduleApi) { box.dataset.level = 'idle'; return; } - const metric = s.metric || 'spl'; - const value = ch[metric]; + const value = readValue(ch, s); num.textContent = fmt(value); unit.textContent = payload.calibrated ? 'dB' : 'dBFS'; @@ -131,10 +215,13 @@ function readoutView(root, moduleApi) { box.dataset.level = !payload.calibrated ? 'idle' : value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok'; - const label = { spl: 'SPL', leq: 'Leq', leq1m: 'Leq 1 min', leq10m: 'Leq 10 min', - peakC: 'Peak C' }[metric] || metric; - const parts = [`${label} · ${payload.weighting || '?'} ${payload.filter || ''}`.trim(), - `ch ${ch.i}`]; + const raw = s.metric || 'level'; + const metric = LEGACY_METRIC[raw] || raw; + const avg = LEGACY_AVERAGING[raw] || s.averaging || 'fast'; + const label = metric === 'level' + ? ({ fast: 'Fast', slow: 'Slow', inf: 'Inf', none: 'No avg' }[avg] || 'Fast') + : ({ leq1m: 'Leq 1 min', leq10m: 'Leq 10 min', peakC: 'Peak C' }[metric] || metric); + const parts = [`${payload.weighting || '?'} · ${label}`, `ch ${ch.i}`]; if (ch.clip) parts.push('CLIP'); if (!payload.calibrated) parts.push('uncalibrated'); sub.textContent = parts.join(' · '); @@ -144,7 +231,6 @@ function readoutView(root, moduleApi) { }; } -/** One bar per channel, peak hold marked. */ function barsView(root, moduleApi) { root.innerHTML = '
'; const host = root.querySelector('.spl-bars'); @@ -166,19 +252,21 @@ function barsView(root, moduleApi) { } const s = moduleApi.instanceSettings; const warn = Number(s.warnAt), alarm = Number(s.alarmAt); - // Uncalibrated the scale is dBFS (-80..0); calibrated it is dB SPL - // over a range a room actually occupies. + const showPeak = s.peakHold !== false; const lo = payload.calibrated ? 40 : -80; const hi = payload.calibrated ? 110 : 0; for (const c of chans) { const el = host.querySelector(`.spl-bar[data-ch="${c.i}"]`); if (!el) continue; + const value = readValue(c, { averaging: s.averaging }); const pct = (v) => Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100)); - el.querySelector('.spl-bar-fill').style.width = `${pct(c.spl)}%`; - el.querySelector('.spl-bar-peak').style.left = `${pct(c.peakC)}%`; - el.querySelector('.spl-bar-value').textContent = fmt(c.spl); + el.querySelector('.spl-bar-fill').style.width = `${pct(value)}%`; + const peak = el.querySelector('.spl-bar-peak'); + peak.style.display = showPeak ? '' : 'none'; + if (showPeak) peak.style.left = `${pct(c.peakC)}%`; + el.querySelector('.spl-bar-value').textContent = fmt(value); el.dataset.level = !payload.calibrated ? 'idle' - : c.spl >= alarm ? 'alarm' : c.spl >= warn ? 'warn' : 'ok'; + : value >= alarm ? 'alarm' : value >= warn ? 'warn' : 'ok'; el.classList.toggle('clip', !!c.clip); } }, @@ -188,17 +276,42 @@ function barsView(root, moduleApi) { } /** - * RTA bars, or the same bands scrolling as a spectrogram. + * RTA bars, or the same folded bands scrolling as a spectrogram. * - * Both are canvas because both redraw wholesale many times a second, and - * thirty DOM nodes animating at 20 Hz is how a dashboard starts dropping - * frames on the machine that is also running the service. + * Canvas for both, because both redraw wholesale many times a second and a + * few hundred animating DOM nodes is how a dashboard starts dropping frames + * on the machine that is also running the service. */ function spectrumView(root, moduleApi, scrolling) { root.innerHTML = ''; const canvas = root.querySelector('.spl-canvas'); const ctx = canvas.getContext('2d', { alpha: false }); - let history = null; // spectrogram only: ImageData column store + let painted = false; // spectrogram: has the background been laid down + let smoothed = null; // RTA: displayed values, damped toward the real ones + let smoothedFor = ''; + let axisBtn = null; + let axisOverride = null; // set by the title-bar toggle, which never remounts us + + function axis() { + return axisOverride || moduleApi.instanceSettings.axis || 'x'; + } + + if (scrolling) { + // A title-bar toggle so the axis can be flipped while watching, without + // opening the gear. It writes the same setting the gear does, so the two + // never disagree — but saveInstanceSettings deliberately does not remount + // us, so the new value is held here too and read back by axis(). + axisBtn = moduleApi.header.addButton({ + label: '⇆', + title: 'Swap axes', + onClick() { + axisOverride = axis() === 'x' ? 'y' : 'x'; + moduleApi.saveInstanceSettings({ axis: axisOverride }); + painted = false; // the picture means nothing in the other orientation + axisBtn.title = axis() === 'x' ? 'Time left to right' : 'Time top to bottom'; + }, + }); + } function sizeCanvas() { const dpr = window.devicePixelRatio || 1; @@ -210,12 +323,11 @@ function spectrumView(root, moduleApi, scrolling) { canvas.style.width = `${w}px`; canvas.style.height = `${h}px`; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - history = null; // geometry changed — start the scroll again + painted = false; return true; } sizeCanvas(); - /** dB → 0..1 over the window a room actually uses. */ function norm(db, calibrated) { const lo = calibrated ? 30 : -100; const hi = calibrated ? 110 : -10; @@ -223,8 +335,6 @@ function spectrumView(root, moduleApi, scrolling) { } function heat(v) { - // Dark → accent → warn → danger, so a hot band reads the same way the - // readout's colours do. const stops = [[10, 12, 20], [30, 90, 150], [90, 180, 140], [230, 190, 80], [220, 80, 60]]; const x = Math.max(0, Math.min(0.999, v)) * (stops.length - 1); const i = Math.floor(x), t = x - i; @@ -238,56 +348,139 @@ function spectrumView(root, moduleApi, scrolling) { const w = canvas.clientWidth, h = canvas.clientHeight; const bg = themeColor(root, '--panel', '#141414'); const muted = themeColor(root, '--muted', '#888'); + const border = themeColor(root, '--border', '#333'); if (!payload || !payload.frame || !payload.frame.bands) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); ctx.fillStyle = muted; ctx.font = '12px system-ui'; ctx.textAlign = 'center'; ctx.fillText(payload?.error || 'Spectrum analysis is off', w / 2, h / 2); + painted = false; return; } + const s = moduleApi.instanceSettings; const want = Number(s.channel); const idx = Math.max(0, payload.frame.ch.findIndex((c) => c.i === want)); - const bands = payload.frame.bands[idx] || payload.frame.bands[0] || []; - const centres = payload.bandCentres || []; - if (!bands.length) return; + const raw = payload.frame.bands[idx] || payload.frame.bands[0] || []; + if (!raw.length) return; + + const denom = Number(s.banding) || 3; + const folded = foldBands(raw, payload.bandN0 || 0, payload.bandsPerOctave || 48, denom); + const freqs = folded.freq; + let values = folded.db; + const grid = s.gridlines === true; + const axisPad = 13; if (!scrolling) { + // Damp the bars toward the measurement. Cosmetic: `values` is what + // gets drawn, never what gets reported. + const keep = SMOOTHING[s.smoothing] ?? SMOOTHING.medium; + const key = `${denom}:${values.length}`; + if (smoothedFor !== key || !smoothed || smoothed.length !== values.length) { + smoothed = values.slice(); + smoothedFor = key; + } else if (keep > 0) { + for (let i = 0; i < values.length; i++) { + smoothed[i] = smoothed[i] * keep + values[i] * (1 - keep); + } + } else { + smoothed = values.slice(); + } + values = smoothed; + ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); - const gap = 1; - const bw = w / bands.length; - for (let i = 0; i < bands.length; i++) { - const v = norm(bands[i], payload.calibrated); - const bh = v * (h - 14); + const plotH = h - axisPad; + const bw = w / values.length; + + const xOf = (f) => (Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0])) * w; + const labels = chooseLabels(freqs, xOf, w, 34); + + if (grid) { + ctx.strokeStyle = border; ctx.lineWidth = 1; ctx.globalAlpha = 0.5; + ctx.beginPath(); + for (const l of labels) { + const x = Math.round(l.pos) + 0.5; + ctx.moveTo(x, 0); ctx.lineTo(x, plotH); + } + for (let g = 0.2; g < 1; g += 0.2) { + const y = Math.round(plotH * g) + 0.5; + ctx.moveTo(0, y); ctx.lineTo(w, y); + } + ctx.stroke(); ctx.globalAlpha = 1; + } + + for (let i = 0; i < values.length; i++) { + const v = norm(values[i], payload.calibrated); + const bh = v * plotH; ctx.fillStyle = heat(v); - ctx.fillRect(i * bw + gap / 2, h - 14 - bh, bw - gap, bh); + ctx.fillRect(i * bw, plotH - bh, Math.max(1, bw - (bw > 3 ? 1 : 0)), bh); } - // Only decade labels — thirty numbers across a tile is unreadable. + ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center'; - for (let i = 0; i < centres.length; i++) { - const f = centres[i]; - if (![100, 1000, 10000].includes(f)) continue; - ctx.fillText(f >= 1000 ? `${f / 1000}k` : String(f), i * bw + bw / 2, h - 3); - } + for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, h - 3); return; } - // Spectrogram: shift the image left, draw the newest column on the - // right. Cheaper than keeping our own ring of columns. - // - // The source rectangle has to be given in device pixels and the - // destination in CSS pixels: the context carries a devicePixelRatio - // transform, but the canvas used as an image source does not. Letting - // drawImage infer them blows the picture up by the ratio and pushes - // every column off the canvas, which looks exactly like "not drawing". + // ── spectrogram ── + const timeX = axis() === 'x'; const colW = 2; - if (!history) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); history = true; } - ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h); - const bh = h / bands.length; - for (let i = 0; i < bands.length; i++) { - ctx.fillStyle = heat(norm(bands[i], payload.calibrated)); - // Low frequencies at the bottom, as every spectrogram has them. - ctx.fillRect(w - colW, h - (i + 1) * bh, colW, Math.ceil(bh) + 1); + const gutter = grid ? axisPad : 0; + // Frequency occupies whichever axis time does not. + const freqExtent = timeX ? h - gutter : w - gutter; + const timeExtent = timeX ? w : h; + + if (!painted) { ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h); painted = true; } + + // Source rect in device pixels, destination in CSS pixels: the context + // carries a devicePixelRatio transform but the canvas as an image + // source does not, and letting drawImage infer them scales the picture + // up and pushes every column off the canvas. + if (timeX) { + ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, -colW, 0, w, h); + } else { + ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, -colW, w, h); + } + + const bandExtent = freqExtent / values.length; + for (let i = 0; i < values.length; i++) { + ctx.fillStyle = heat(norm(values[i], payload.calibrated)); + if (timeX) { + // Low frequencies at the bottom, as every spectrogram has them. + ctx.fillRect(timeExtent - colW, freqExtent - (i + 1) * bandExtent, + colW, Math.ceil(bandExtent) + 1); + } else { + // Low frequencies at the left. + ctx.fillRect(gutter + i * bandExtent, timeExtent - colW, + Math.ceil(bandExtent) + 1, colW); + } + } + + if (grid) { + const posOf = (f) => { + const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]); + return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent; + }; + const labels = chooseLabels(freqs, posOf, timeX ? freqExtent : w, timeX ? 16 : 34); + ctx.fillStyle = bg; + if (timeX) ctx.fillRect(0, h - gutter, w, gutter); + else ctx.fillRect(0, 0, gutter, h); + ctx.fillStyle = muted; ctx.font = '9px system-ui'; + ctx.strokeStyle = border; ctx.globalAlpha = 0.35; ctx.beginPath(); + for (const l of labels) { + if (timeX) { + const y = Math.round(l.pos) + 0.5; + ctx.moveTo(0, y); ctx.lineTo(w, y); + } else { + const x = Math.round(l.pos) + 0.5; + ctx.moveTo(x, 0); ctx.lineTo(x, h); + } + } + ctx.stroke(); ctx.globalAlpha = 1; + ctx.textAlign = timeX ? 'left' : 'center'; + for (const l of labels) { + if (timeX) ctx.fillText(hzLabel(l.f), 2, Math.min(h - gutter - 2, l.pos + 3)); + else ctx.fillText(hzLabel(l.f), l.pos, h - 3); + } } }, resize() { sizeCanvas(); }, diff --git a/modules/spl-meter/module.json b/modules/spl-meter/module.json index f4800e4..7a5eb03 100644 --- a/modules/spl-meter/module.json +++ b/modules/spl-meter/module.json @@ -1,20 +1,33 @@ { "id": "spl-meter", "name": "SPL Meter", - "version": "0.1.0", + "version": "0.2.0", "proddash": ">=1.5.0", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "permissions": [ - { "kind": "microphone", "reason": "Measuring sound level from an audio input on this machine" } + { + "kind": "microphone", + "reason": "Measuring sound level from an audio input on this machine" + } ], "client": "client.js", "server": "server.js", "style": "style.css", - "minSize": { "w": 2, "h": 1 }, - "defaultSize": { "w": 3, "h": 2 }, + "minSize": { + "w": 2, + "h": 1 + }, + "defaultSize": { + "w": 3, + "h": 2 + }, "configGroups": { - "Input": { "toggle": "enabled" }, - "Measurement": { "columns": 2 }, + "Input": { + "toggle": "enabled" + }, + "Measurement": { + "columns": 2 + }, "Calibration": { "collapsed": "whenSet", "help": "A sound level calibrator fits over the microphone capsule and emits a known level, almost always 94 dB at 1 kHz. Put it on, press Measure beside the channel, and copy the offset it reports into the box. Without this the meter still works, but it reads in dB relative to full scale and every tile says so." @@ -49,19 +62,18 @@ "group": "Measurement", "default": "A", "options": [ - { "value": "A", "label": "A — how loud it sounds" }, - { "value": "C", "label": "C — keeps the low end" }, - { "value": "Z", "label": "Z — no weighting at all" } - ] - }, - "filter": { - "type": "select", - "label": "Time constant", - "group": "Measurement", - "default": "fast", - "options": [ - { "value": "fast", "label": "Fast — 125 ms" }, - { "value": "slow", "label": "Slow — 1 s" } + { + "value": "A", + "label": "A — how loud it sounds" + }, + { + "value": "C", + "label": "C — keeps the low end" + }, + { + "value": "Z", + "label": "Z — no weighting at all" + } ] }, "bands": { @@ -69,7 +81,7 @@ "label": "Spectrum analysis", "group": "Measurement", "default": true, - "help": "Third-octave bands for the RTA and spectrograph tiles. Turn it off if you only ever show the number." + "help": "1/48-octave bands for the RTA and spectrogram tiles, which each fold them to their own banding. Turn it off if you only ever show the number." }, "calibration": { "type": "string", @@ -78,30 +90,5 @@ "default": "", "help": "Leave empty and every reading is dB relative to full scale, labelled dBFS so nobody mistakes it for real SPL." } - }, - "instanceGroups": { - "Thresholds": { "columns": 2, "help": "The readout turns amber then red as it crosses these. Set them to whatever this room has agreed is loud, not to a legal limit — this is not a compliance meter." } - }, - "instanceSchema": { - "channel": { - "type": "select", - "label": "Channel", - "default": "", - "optionsRoute": "/channels" - }, - "metric": { - "type": "select", - "label": "Reading", - "default": "spl", - "options": [ - { "value": "spl", "label": "SPL — right now" }, - { "value": "leq", "label": "Leq — average since reset" }, - { "value": "leq1m", "label": "Leq 1 min" }, - { "value": "leq10m", "label": "Leq 10 min" }, - { "value": "peakC", "label": "Peak, C-weighted" } - ] - }, - "warnAt": { "type": "number", "label": "Amber above", "group": "Thresholds", "default": 95 }, - "alarmAt": { "type": "number", "label": "Red above", "group": "Thresholds", "default": 100 } } } diff --git a/modules/spl-meter/server.js b/modules/spl-meter/server.js index 680e7a4..68fed3a 100644 --- a/modules/spl-meter/server.js +++ b/modules/spl-meter/server.js @@ -33,6 +33,48 @@ 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 — no averaging' }, + ], + }; +} + +/** + * 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', + help: 'Finer bandings need a wide tile to be worth drawing, and below ' + + 'roughly 200 Hz the window length limits real resolution however fine ' + + 'this is set.', + 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(',') @@ -88,7 +130,6 @@ module.exports = { device: config.device, channels, weighting: config.weighting || 'A', - filter: config.filter || 'fast', bands: config.bands !== false, calibration, moduleDir, @@ -129,20 +170,86 @@ module.exports = { }, /** - * Four tile kinds rather than one tile with a mode switch: an operator - * picking "+ SPL" wants to choose what they are putting on the wall, not - * place a box and then configure it into being useful. + * 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 } }, - { id: 'bars', name: 'SPL Levels', description: 'One bar per channel, with peak hold', - defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } }, - { id: 'rta', name: 'RTA', description: 'Third-octave bars', - defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 } }, - { id: 'spectrogram', name: 'Spectrogram', description: 'Third-octave bands over time', - defaultSize: { w: 5, h: 3 }, minSize: { w: 3, h: 2 } }, + { + 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, last minute' }, + { value: 'leq10m', label: 'Leq, last 10 minutes' }, + { value: 'peakC', label: 'Peak, C-weighted' }, + ], + }, + 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', + help: 'How hard the bars are damped between frames. Cosmetic only — ' + + 'it changes the animation, never the measurement.', + options: [ + { value: 'off', label: 'Off — every frame as measured' }, + { value: 'light', label: 'Light' }, + { value: 'medium', label: 'Medium' }, + { value: 'heavy', label: 'Heavy' }, + ], + }, + 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, + axis: { + type: 'select', label: 'Time runs', default: 'x', + options: [ + { value: 'x', label: 'Left to right' }, + { value: 'y', label: 'Top to bottom' }, + ], + }, + gridlines: { type: 'switch', label: 'Gridlines', default: false }, + }, + }, ]; }, @@ -250,8 +357,9 @@ function snapshot() { calibrated: state.calibrated, channels: state.channels, weighting: state.meta ? state.meta.weighting : '', - filter: state.meta ? state.meta.filter : '', bandCentres: state.meta ? state.meta.bands : [], + bandN0: state.meta ? state.meta.bandN0 : 0, + bandsPerOctave: state.meta ? state.meta.bandsPerOctave : 48, frame: state.frame, }; } diff --git a/modules/spl-meter/spl-capture.swift b/modules/spl-meter/spl-capture.swift index dfb727b..7c20c87 100644 --- a/modules/spl-meter/spl-capture.swift +++ b/modules/spl-meter/spl-capture.swift @@ -23,11 +23,17 @@ // says calibrated:false, so nothing downstream can mistake them for // real SPL. // -// Per channel, per frame: SPL at the chosen weighting and time constant, -// Leq (running, 1-minute and 10-minute), C- and Z-weighted peak, raw dBFS, -// and a clip flag. Optionally a third-octave band set for the RTA and -// spectrograph tiles — only when --bands is passed, so an SPL-only dashboard -// doesn't pay for FFTs it never draws. +// Per channel, per frame: the level at EVERY averaging a dashboard might +// ask for — fast (125 ms), slow (1 s), inf (Leq since reset) and none (this +// block alone) — plus rolling 1- and 10-minute Leq, C- and Z-weighted peak, +// raw dBFS and a clip flag. Optionally a 1/48-octave band set for the RTA +// and spectrogram tiles, only when --bands is passed. +// +// Everything is sent every frame on purpose. Two dashboards watching the +// same room may want different averaging and different banding, and neither +// choice may disturb the other or restart capture. So the measurement is +// made once, at the finest resolution anyone asked for, and each tile folds +// it down to the view it wants. Nothing here is per-viewer state. // // Weighting filters are IEC 61672 pole/zero prototypes, bilinear-transformed // with the top section prewarped, then normalised to exactly 0 dB at 1 kHz. @@ -231,22 +237,34 @@ final class WeightingFilter { /* ── third-octave band plan for the RTA ─────────────────────────────── */ -/// Nominal third-octave centres. We start at 25 Hz rather than 20: with an -/// 8192-point FFT at 48 kHz the bins are 5.9 Hz apart, and the 20 Hz band is -/// only 4.6 Hz wide — one bin, sometimes none. Claiming a number there would -/// be drawing noise. A filter bank would reach lower; that is a later -/// problem, and the bands we do show are honest. -let bandCentres: [Double] = [25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, - 500, 630, 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000, - 5000, 6300, 8000, 10000, 12500, 16000, 20000] +/// The wire always carries 1/48-octave bands, and a tile folds them down to +/// whatever banding it wants to draw. +/// +/// That works exactly because every banding the dashboard offers divides 48: +/// 1/3 is sixteen of these summed, 1/6 is eight, 1/12 four, 1/24 two. Powers +/// are additive, so a fold is a sum and nothing is approximated. The +/// alternative — the capture re-banding on request — would mean one dashboard +/// choosing 1/6 changes what every other dashboard sees. +/// +/// Band n is centred on 1000 · 2^(n/48); n = 0 is 1 kHz, which is what keeps +/// the folded centres on the standard values. +let bandN0 = -271 // ≈ 20 Hz +let bandN1 = 207 // ≈ 20 kHz +let bandCentres: [Double] = (bandN0...bandN1).map { 1000 * pow(2, Double($0) / 48) } -/// FFT bin ranges for each band, clamped to what the transform can resolve. +/// FFT bin ranges per band, clamped to what the transform can actually +/// resolve. Below roughly 200 Hz a 1/48-octave band is narrower than one +/// bin, so neighbouring bands start sharing bins and the low end reads +/// smoother than it really is. That is the window length talking, not the +/// room. Folding to 1/6 or 1/3 puts it right, and a multi-resolution +/// transform is the real fix if the bottom octaves ever have to be read at +/// full resolution. func bandBins(fs: Double, fftSize: Int) -> [(Int, Int)] { let binHz = fs / Double(fftSize) let maxBin = fftSize / 2 - 1 return bandCentres.map { fc in - let lo = Int((fc * pow(2, -1.0 / 6.0) / binHz).rounded()) - let hi = Int((fc * pow(2, 1.0 / 6.0) / binHz).rounded()) + let lo = Int((fc * pow(2, -1.0 / 96.0) / binHz).rounded()) + let hi = Int((fc * pow(2, 1.0 / 96.0) / binHz).rounded()) return (max(1, min(lo, maxBin)), max(1, min(max(hi, lo), maxBin))) } } @@ -269,9 +287,17 @@ final class ChannelMeter { private var weighted: [Float] private var cWeighted: [Float] - private var meanSquare: Double = 0 // exponential integrator, linear power - private var flatMeanSquare: Double = 0 // same integrator, unweighted — for dBFS - private let alphaPerSample: Double + // One integrator per averaging the dashboard might ask for. They all run + // all the time and all go on the wire: a tile choosing "Slow" must not + // restart capture, because the next tile wants "Fast" off the same + // stream and a third wants Inf. Four extra doubles per channel is a + // rounding error next to the filtering; re-measuring per viewer is not. + private var msFast: Double = 0 + private var msSlow: Double = 0 + private var msNone: Double = 0 // this block only, no time constant + private var flatMeanSquare: Double = 0 // unweighted, Fast — for dBFS + private let alphaFast: Double + private let alphaSlow: Double // Leq accumulators: running since reset, plus rolling 1 and 10 minute. private var leqSum: Double = 0 @@ -283,7 +309,7 @@ final class ChannelMeter { private var peakZ: Double = 0 private var clipped = false - init(index: Int, fs: Double, weighting: Weighting, fast: Bool, calibration: Double, + init(index: Int, fs: Double, weighting: Weighting, calibration: Double, framesPerSecond: Double) { self.index = index self.fs = fs @@ -292,8 +318,9 @@ final class ChannelMeter { cFilter = WeightingFilter(.c, fs: fs) weighted = [] cWeighted = [] - let tau = fast ? 0.125 : 1.0 - alphaPerSample = 1.0 - exp(-1.0 / (tau * fs)) + // The time constants IEC 61672 names: Fast is 125 ms, Slow is 1 s. + alphaFast = 1.0 - exp(-1.0 / (0.125 * fs)) + alphaSlow = 1.0 - exp(-1.0 / (1.0 * fs)) ringCapacity = Int((600.0 * framesPerSecond).rounded()) // 10 minutes } @@ -310,24 +337,26 @@ final class ChannelMeter { // in the power domain. vDSP has no one-pole on squares, and at 48 kHz // this is a few hundred thousand multiply-adds a second — noise next // to the filtering above. - var ms = meanSquare - var flat = flatMeanSquare + var fast = msFast, slow = msSlow, flat = flatMeanSquare weighted.withUnsafeBufferPointer { w in for i in 0.. String { - ring.append(meanSquare) + ring.append(msNone) if ring.count > ringCapacity { ring.removeFirst(ring.count - ringCapacity) } let fps = Double(ringCapacity) / 600.0 @@ -365,8 +394,10 @@ final class ChannelMeter { } return "{\"i\":\(index)" - + f("spl", db(meanSquare)) - + f("leq", leqCount > 0 ? db(leqSum / leqCount) : -200) + + f("fast", db(msFast)) + + f("slow", db(msSlow)) + + f("inf", leqCount > 0 ? db(leqSum / leqCount) : -200) + + f("none", db(msNone)) + f("leq1m", rollingLeq(seconds: 60)) + f("leq10m", rollingLeq(seconds: 600)) + f("peakC", db(peakC * peakC)) @@ -395,6 +426,14 @@ func num(_ v: Double) -> String { return String(format: "%.2f", max(v, -200)) } +/// One decimal, for band arrays. 479 bands per channel per frame is the +/// bulk of the wire; a second decimal on a bar nobody can read that finely +/// is 15% of the bandwidth for nothing. +func num1(_ v: Double) -> String { + guard v.isFinite else { return "-200" } + return String(format: "%.1f", max(v, -200)) +} + /* ── spectrum for the RTA / spectrograph tiles ──────────────────────── */ final class Spectrum { @@ -484,7 +523,6 @@ var wantDevice: String? = nil var wantChannels: [Int] = [1] var wantCal: [Double] = [] var wantWeighting = Weighting.a -var wantFast = true var wantBands = false var framesPerSecond = 20.0 @@ -509,10 +547,6 @@ while argIdx < argv.count { let raw = nextArg("--weighting").uppercased() guard let w = Weighting(rawValue: raw) else { fail("--weighting must be A, C or Z") } wantWeighting = w - case "--filter": - let raw = nextArg("--filter").lowercased() - guard raw == "fast" || raw == "slow" else { fail("--filter must be fast or slow") } - wantFast = (raw == "fast") case "--bands": wantBands = true case "--rate": guard let r = Double(nextArg("--rate")), r >= 1, r <= 50 else { fail("--rate must be 1-50 (frames per second)") } @@ -602,14 +636,14 @@ if calibrated && wantCal.count != wantChannels.count { } let meters = wantChannels.enumerated().map { (i, ch) in - ChannelMeter(index: ch, fs: sampleRate, weighting: wantWeighting, fast: wantFast, + ChannelMeter(index: ch, fs: sampleRate, weighting: wantWeighting, calibration: calibrated ? wantCal[i] : 0, framesPerSecond: framesPerSecond) } -let spectra = wantBands ? wantChannels.map { _ in Spectrum(fs: sampleRate, fftSize: 8192) } : [] +let spectra = wantBands ? wantChannels.map { _ in Spectrum(fs: sampleRate, fftSize: 16384) } : [] note("capturing \"\(device.name)\": \(channelCount) channels @ \(Int(sampleRate)) Hz, " + "measuring \(wantChannels.map(String.init).joined(separator: ",")) " - + "\(wantWeighting.rawValue)-weighted \(wantFast ? "fast" : "slow")" + + "\(wantWeighting.rawValue)-weighted" + (calibrated ? "" : ", UNCALIBRATED")) // Announced once so the reader knows the band plan without hardcoding it. @@ -620,9 +654,12 @@ let hello: [String: Any] = [ "deviceChannels": channelCount, "channels": wantChannels, "weighting": wantWeighting.rawValue, - "filter": wantFast ? "fast" : "slow", "calibrated": calibrated, - "bands": wantBands ? bandCentres : [], + // Centres and the index of the 1 kHz band, so a tile can fold to any + // banding without hardcoding the plan. + "bands": wantBands ? bandCentres.map { round($0 * 100) / 100 } : [], + "bandN0": bandN0, + "bandsPerOctave": 48, ] let stdoutHandle = FileHandle.standardOutput func emitLine(_ json: String) { @@ -680,7 +717,7 @@ timer.setEventHandler { if wantBands { let sets = spectra.enumerated().map { (i, s) -> String in "[" + s.bands(calibration: calibrated ? wantCal[i] : 0) - .map(num).joined(separator: ",") + "]" + .map(num1).joined(separator: ",") + "]" } line += ",\"bands\":[" + sets.joined(separator: ",") + "]" } diff --git a/modules/spl-meter/spl-listener.js b/modules/spl-meter/spl-listener.js index cffe363..1a9381b 100644 --- a/modules/spl-meter/spl-listener.js +++ b/modules/spl-meter/spl-listener.js @@ -38,13 +38,13 @@ const BAD_CONFIG_RETRY_MS = 30000; const STALL_MS = 3000; /** - * @param {{device:string, channels:number[], weighting:string, filter:string, + * @param {{device:string, channels:number[], weighting:string, * bands:boolean, calibration:number[], moduleDir:string, * log:Function, onUpdate:(u:object)=>void}} opts * @returns {{stop:()=>void, reset:()=>void}} */ function createSplListener(opts) { - const { device, channels, weighting, filter, bands, calibration, moduleDir, log, onUpdate } = opts; + const { device, channels, weighting, bands, calibration, moduleDir, log, onUpdate } = opts; const bin = path.join(moduleDir, 'spl-capture'); let child = null; @@ -141,8 +141,10 @@ function createSplListener(opts) { return; } + // No averaging or banding flag: the child computes every averaging and + // the finest banding regardless, so tiles can differ without a restart. const args = ['--device', device, '--channels', channels.join(','), - '--weighting', weighting, '--filter', filter, '--rate', '20']; + '--weighting', weighting, '--rate', '20']; if (bands) args.push('--bands'); if (calibration.length === channels.length) args.push('--cal', calibration.join(',')); @@ -171,7 +173,7 @@ function createSplListener(opts) { if (obj.hello) { meta = obj; log(`measuring ${obj.device} ch ${obj.channels.join(',')} ` - + `${obj.weighting}-weighted ${obj.filter}` + + `${obj.weighting}-weighted` + (obj.calibrated ? '' : ' (uncalibrated)')); continue; } -- 2.54.0 From 543795f9fd32c9bb8deee7fdbc3bf52323a3597a Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 17 Sep 2026 13:11:48 -0400 Subject: [PATCH 4/7] Let the SPL readout fill its tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The number was capped at 6rem, so a readout given half a wall stopped growing at the size it reached in a small tile. Removing the cap was not enough on its own. Sizing text off container units means choosing a coefficient for some assumed number of characters, and the value here is not a fixed width: the coefficient that fills a tile nicely for "-45.9" pushes "-100.5" nine percent out through the side. Measured it rather than guessed — the tile now sets a trial size, reads the width the actual text wants, and scales to fit. That runs only when the tile size or the length of the reading changes, not on every frame. It now fills about 98% of the width wherever width is the limit and 92% of the height wherever height is, at every tile size from 180x70 up to 1400x700, with no overflow on either axis. The CSS keeps a conservative size for the first paint before the measurement has run. Co-Authored-By: Claude Opus 5 --- modules/spl-meter/client.js | 36 ++++++++++++++++++++++++++++++++++- modules/spl-meter/module.json | 2 +- modules/spl-meter/style.css | 11 ++++++----- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/modules/spl-meter/client.js b/modules/spl-meter/client.js index 85909ee..59e3765 100644 --- a/modules/spl-meter/client.js +++ b/modules/spl-meter/client.js @@ -196,6 +196,38 @@ function readoutView(root, moduleApi) { const unit = root.querySelector('.spl-unit'); const sub = root.querySelector('.spl-sub'); const box = root.querySelector('.spl-readout'); + const value = root.querySelector('.spl-value'); + let fitKey = ''; + + /** + * Grow the number until it fills the tile, measuring the text we actually + * have rather than assuming a digit count. + * + * The CSS can only guess at width — it has to pick a cqw coefficient for + * some number of characters, and then "-100.5" overflows the tile that + * "-45.9" merely filled. Measuring costs one layout read, and only when + * the tile size or the length of the reading changes. + */ + function fit() { + const w = root.clientWidth, h = root.clientHeight; + const key = `${w}x${h}:${num.textContent.length}:${unit.textContent}`; + if (key === fitKey || w < 2 || h < 2) return; + fitKey = key; + + const TRIAL = 100; + num.style.fontSize = `${TRIAL}px`; + unit.style.fontSize = `${TRIAL * 0.26}px`; + const natural = value.scrollWidth || 1; + + // Leave the caption its room when it is showing, and a little air either + // way so descenders and the tile's padding never clip. + const subH = sub.offsetParent ? sub.getBoundingClientRect().height + 6 : 0; + const byHeight = (h - subH) * 0.92; + const byWidth = ((w - 8) * TRIAL) / natural; + const size = Math.max(12, Math.min(byHeight, byWidth)); + num.style.fontSize = `${size}px`; + unit.style.fontSize = `${size * 0.26}px`; + } return { render(payload) { @@ -205,6 +237,7 @@ function readoutView(root, moduleApi) { num.textContent = '––.–'; sub.textContent = payload && payload.error ? payload.error : ''; box.dataset.level = 'idle'; + fit(); return; } const value = readValue(ch, s); @@ -225,8 +258,9 @@ function readoutView(root, moduleApi) { if (ch.clip) parts.push('CLIP'); if (!payload.calibrated) parts.push('uncalibrated'); sub.textContent = parts.join(' · '); + fit(); }, - resize() {}, + resize() { fitKey = ''; fit(); }, destroy() { root.innerHTML = ''; }, }; } diff --git a/modules/spl-meter/module.json b/modules/spl-meter/module.json index 7a5eb03..f35af77 100644 --- a/modules/spl-meter/module.json +++ b/modules/spl-meter/module.json @@ -1,7 +1,7 @@ { "id": "spl-meter", "name": "SPL Meter", - "version": "0.2.0", + "version": "0.2.1", "proddash": ">=1.5.0", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "permissions": [ diff --git a/modules/spl-meter/style.css b/modules/spl-meter/style.css index 98e2dda..a120f46 100644 --- a/modules/spl-meter/style.css +++ b/modules/spl-meter/style.css @@ -30,18 +30,19 @@ color: var(--text); } -/* Sized off the tile, so one cell and six cells both read well. Height has - to be in the min() as well as width: a 2x1 tile is wide and short, and - sizing on width alone pushes the digits straight out through the top. */ +/* A first-paint size only. client.js measures the text and sets the real + one inline, because CSS has to assume a digit count and gets it wrong the + moment the reading needs a sixth character: the coefficient that fills a + tile for "-45.9" pushes "-100.5" straight out through the side. */ [data-module="spl-meter"] .spl-num { - font-size: clamp(1.25rem, min(20cqw, 42cqh), 6rem); + font-size: clamp(1.25rem, min(26cqw, 60cqh), 100rem); font-variant-numeric: tabular-nums; font-weight: 600; letter-spacing: -0.02em; } [data-module="spl-meter"] .spl-unit { - font-size: clamp(0.65rem, min(6cqw, 13cqh), 1.5rem); + font-size: clamp(0.65rem, min(7cqw, 15cqh), 26rem); color: var(--muted); } -- 2.54.0 From 44fd0b10ba5951fba098ae1581bb9e56150e263a Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 17 Sep 2026 16:00:17 -0400 Subject: [PATCH 5/7] SPL Meter 0.3.0: bring the tiles to the interface rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modules/spl-meter/client.js | 38 +++++++++++++++++++++-------------- modules/spl-meter/module.json | 2 +- modules/spl-meter/server.js | 31 ++++++++++------------------ 3 files changed, 35 insertions(+), 36 deletions(-) diff --git a/modules/spl-meter/client.js b/modules/spl-meter/client.js index 59e3765..b865899 100644 --- a/modules/spl-meter/client.js +++ b/modules/spl-meter/client.js @@ -210,7 +210,7 @@ function readoutView(root, moduleApi) { */ function fit() { const w = root.clientWidth, h = root.clientHeight; - const key = `${w}x${h}:${num.textContent.length}:${unit.textContent}`; + const key = `${w}x${h}:${num.textContent.length}:${unit.textContent}:${sub.hidden}`; if (key === fitKey || w < 2 || h < 2) return; fitKey = key; @@ -257,6 +257,7 @@ function readoutView(root, moduleApi) { const parts = [`${payload.weighting || '?'} · ${label}`, `ch ${ch.i}`]; if (ch.clip) parts.push('CLIP'); if (!payload.calibrated) parts.push('uncalibrated'); + sub.hidden = s.caption === false; sub.textContent = parts.join(' · '); fit(); }, @@ -331,18 +332,22 @@ function spectrumView(root, moduleApi, scrolling) { } if (scrolling) { - // A title-bar toggle so the axis can be flipped while watching, without - // opening the gear. It writes the same setting the gear does, so the two - // never disagree — but saveInstanceSettings deliberately does not remount - // us, so the new value is held here too and read back by axis(). + // The axis lives here and nowhere else (rule 11): a tile does not offer + // the same action twice. Icon only, tooltip carries the words (rule 9). + // saveInstanceSettings deliberately does not remount us, so the new + // value is held here as well and read back by axis(). + const swapIcon = '' + + ''; + const describe = () => (axis() === 'x' ? 'Time left to right' : 'Time top to bottom'); axisBtn = moduleApi.header.addButton({ - label: '⇆', - title: 'Swap axes', + icon: swapIcon, + title: describe(), onClick() { axisOverride = axis() === 'x' ? 'y' : 'x'; moduleApi.saveInstanceSettings({ axis: axisOverride }); painted = false; // the picture means nothing in the other orientation - axisBtn.title = axis() === 'x' ? 'Time left to right' : 'Time top to bottom'; + axisBtn.title = describe(); }, }); } @@ -403,7 +408,8 @@ function spectrumView(root, moduleApi, scrolling) { const freqs = folded.freq; let values = folded.db; const grid = s.gridlines === true; - const axisPad = 13; + const showLabels = s.labels !== false; + const axisPad = showLabels || grid ? 13 : 0; if (!scrolling) { // Damp the bars toward the measurement. Cosmetic: `values` is what @@ -450,15 +456,17 @@ function spectrumView(root, moduleApi, scrolling) { ctx.fillRect(i * bw, plotH - bh, Math.max(1, bw - (bw > 3 ? 1 : 0)), bh); } - ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center'; - for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, h - 3); + if (showLabels) { + ctx.fillStyle = muted; ctx.font = '9px system-ui'; ctx.textAlign = 'center'; + for (const l of labels) ctx.fillText(hzLabel(l.f), l.pos, h - 3); + } return; } // ── spectrogram ── const timeX = axis() === 'x'; const colW = 2; - const gutter = grid ? axisPad : 0; + const gutter = showLabels ? axisPad : 0; // Frequency occupies whichever axis time does not. const freqExtent = timeX ? h - gutter : w - gutter; const timeExtent = timeX ? w : h; @@ -489,7 +497,7 @@ function spectrumView(root, moduleApi, scrolling) { } } - if (grid) { + if (grid || showLabels) { const posOf = (f) => { const t = Math.log2(f / freqs[0]) / Math.log2(freqs[freqs.length - 1] / freqs[0]); return timeX ? freqExtent - t * freqExtent : gutter + t * freqExtent; @@ -499,7 +507,7 @@ function spectrumView(root, moduleApi, scrolling) { if (timeX) ctx.fillRect(0, h - gutter, w, gutter); else ctx.fillRect(0, 0, gutter, h); ctx.fillStyle = muted; ctx.font = '9px system-ui'; - ctx.strokeStyle = border; ctx.globalAlpha = 0.35; ctx.beginPath(); + ctx.strokeStyle = border; ctx.globalAlpha = grid ? 0.35 : 0; ctx.beginPath(); for (const l of labels) { if (timeX) { const y = Math.round(l.pos) + 0.5; @@ -511,7 +519,7 @@ function spectrumView(root, moduleApi, scrolling) { } ctx.stroke(); ctx.globalAlpha = 1; ctx.textAlign = timeX ? 'left' : 'center'; - for (const l of labels) { + for (const l of showLabels ? labels : []) { if (timeX) ctx.fillText(hzLabel(l.f), 2, Math.min(h - gutter - 2, l.pos + 3)); else ctx.fillText(hzLabel(l.f), l.pos, h - 3); } diff --git a/modules/spl-meter/module.json b/modules/spl-meter/module.json index f35af77..e469e98 100644 --- a/modules/spl-meter/module.json +++ b/modules/spl-meter/module.json @@ -1,7 +1,7 @@ { "id": "spl-meter", "name": "SPL Meter", - "version": "0.2.1", + "version": "0.3.0", "proddash": ">=1.5.0", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "permissions": [ diff --git a/modules/spl-meter/server.js b/modules/spl-meter/server.js index 68fed3a..d3609a4 100644 --- a/modules/spl-meter/server.js +++ b/modules/spl-meter/server.js @@ -48,10 +48,10 @@ 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 — no averaging' }, + { value: 'fast', label: 'Fast · 125 ms' }, + { value: 'slow', label: 'Slow · 1 s' }, + { value: 'inf', label: 'Inf · since reset' }, + { value: 'none', label: 'None' }, ], }; } @@ -63,9 +63,6 @@ function averagingField() { */ const BANDING_FIELD = { type: 'select', label: 'Banding', default: '3', - help: 'Finer bandings need a wide tile to be worth drawing, and below ' - + 'roughly 200 Hz the window length limits real resolution however fine ' - + 'this is set.', options: [ { value: '3', label: '1/3 octave' }, { value: '6', label: '1/6 octave' }, @@ -193,11 +190,12 @@ module.exports = { type: 'select', label: 'Show', default: 'level', options: [ { value: 'level', label: 'Level' }, - { value: 'leq1m', label: 'Leq, last minute' }, - { value: 'leq10m', label: 'Leq, last 10 minutes' }, - { value: 'peakC', label: 'Peak, C-weighted' }, + { 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 }, }, @@ -222,15 +220,14 @@ module.exports = { banding: BANDING_FIELD, smoothing: { type: 'select', label: 'Smoothing', default: 'medium', - help: 'How hard the bars are damped between frames. Cosmetic only — ' - + 'it changes the animation, never the measurement.', options: [ - { value: 'off', label: 'Off — every frame as measured' }, + { 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 }, }, }, @@ -240,13 +237,7 @@ module.exports = { instanceSchema: { channel: CHANNEL_FIELD, banding: BANDING_FIELD, - axis: { - type: 'select', label: 'Time runs', default: 'x', - options: [ - { value: 'x', label: 'Left to right' }, - { value: 'y', label: 'Top to bottom' }, - ], - }, + labels: { type: 'switch', label: 'Labels', default: true }, gridlines: { type: 'switch', label: 'Gridlines', default: false }, }, }, -- 2.54.0 From 46d44d494b84f407d0066a511abb19f5bdd3eb14 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 17 Sep 2026 16:00:34 -0400 Subject: [PATCH 6/7] Keep the banding caveat where the band plan is built Co-Authored-By: Claude Opus 5 --- modules/spl-meter/spl-capture.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/spl-meter/spl-capture.swift b/modules/spl-meter/spl-capture.swift index 7c20c87..25a76dc 100644 --- a/modules/spl-meter/spl-capture.swift +++ b/modules/spl-meter/spl-capture.swift @@ -253,7 +253,11 @@ let bandN1 = 207 // ≈ 20 kHz let bandCentres: [Double] = (bandN0...bandN1).map { 1000 * pow(2, Double($0) / 48) } /// FFT bin ranges per band, clamped to what the transform can actually -/// resolve. Below roughly 200 Hz a 1/48-octave band is narrower than one +/// resolve. +/// +/// This is the one caveat a reader of the RTA needs and the gear is not +/// allowed to carry (module rule 5, no explanatory text in a tile's +/// settings): below roughly 200 Hz a 1/48-octave band is narrower than one /// bin, so neighbouring bands start sharing bins and the low end reads /// smoother than it really is. That is the window length talking, not the /// room. Folding to 1/6 or 1/3 puts it right, and a multi-resolution -- 2.54.0 From 87929bfd0b68058a4f24f2930200a585b2d2d1de Mon Sep 17 00:00:00 2001 From: Waters Church Date: Thu, 17 Sep 2026 16:06:47 -0400 Subject: [PATCH 7/7] Don't take the server down when the module unmounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploying 0.3.0 to AB crashed the ProdDash server on restart: TypeError: Cannot read properties of null (reading 'streams') at server.js:273 req.on('close', () => state.streams.delete(res)) teardown() nulls `state` and then ends every open stream, which fires each request's close handler — and the handler reached back through the `state` it had just discarded. Any re-init with a browser connected would do it, so saving admin config while a dashboard was open was enough to stop the whole server, every module with it. The stream handler now closes over this mount's Set rather than the module's state: after teardown it deletes from a set nobody reads, which is harmless. The same hazard existed anywhere a callback could outlive the mount — the keepalive ping, a frame arriving from the listener mid-teardown, and the /measure interval — and each is closed over or guarded now. Reproduced first (open an SSE client, save config, watch it fall over) and confirmed fixed against the same sequence. Co-Authored-By: Claude Opus 5 --- modules/spl-meter/module.json | 2 +- modules/spl-meter/server.js | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/modules/spl-meter/module.json b/modules/spl-meter/module.json index e469e98..520978a 100644 --- a/modules/spl-meter/module.json +++ b/modules/spl-meter/module.json @@ -1,7 +1,7 @@ { "id": "spl-meter", "name": "SPL Meter", - "version": "0.3.0", + "version": "0.3.1", "proddash": ">=1.5.0", "description": "Calibrated sound level, Leq and RTA from an audio input on this machine", "permissions": [ diff --git a/modules/spl-meter/server.js b/modules/spl-meter/server.js index d3609a4..98fb47c 100644 --- a/modules/spl-meter/server.js +++ b/modules/spl-meter/server.js @@ -132,6 +132,7 @@ module.exports = { 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; @@ -146,9 +147,10 @@ module.exports = { }, }); + const mineStreams = state.streams; state.pinger = setInterval(() => { - for (const res of state.streams) { - try { res.write(': ping\n\n'); } catch { state.streams.delete(res); } + for (const res of mineStreams) { + try { res.write(': ping\n\n'); } catch { mineStreams.delete(res); } } }, PING_MS); @@ -260,8 +262,14 @@ module.exports = { try { res.write(`event: state\ndata: ${JSON.stringify(snapshot())}\n\n`); } catch { return; } - state.streams.add(res); - req.on('close', () => state.streams.delete(res)); + // 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, @@ -311,6 +319,7 @@ module.exports = { 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; @@ -343,6 +352,7 @@ module.exports = { /* ── helpers ────────────────────────────────────────────────────────── */ function snapshot() { + if (!state) return { error: 'not measuring', calibrated: false, channels: [], frame: null }; return { error: state.error, calibrated: state.calibrated, @@ -356,6 +366,7 @@ function snapshot() { } 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 -- 2.54.0