SPL Meter module: measure the room, not just watch it

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 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-17 15:56:27 -04:00
co-authored by Claude Opus 5
parent 5025e12b71
commit 188d1c878d
7 changed files with 1735 additions and 0 deletions
+147
View File
@@ -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; }
}