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:
co-authored by
Claude Opus 5
parent
5025e12b71
commit
188d1c878d
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user