SPL Meter 0.2.0: one measurement, many views

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 <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 e90ae51495
commit d2d4207fe4
5 changed files with 496 additions and 169 deletions
+81 -44
View File
@@ -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..<n {
let v = Double(w[i])
ms += alphaPerSample * (v * v - ms)
let v = Double(w[i]) * Double(w[i])
fast += alphaFast * (v - fast)
slow += alphaSlow * (v - slow)
let r = Double(samples[i])
flat += alphaPerSample * (r * r - flat)
flat += alphaFast * (r * r - flat)
}
}
meanSquare = ms
flatMeanSquare = flat
msFast = fast; msSlow = slow; flatMeanSquare = flat
// Leq wants plain energy, no time constant.
// Mean square of just this block — "None", the reading with no time
// constant at all. Also what Leq accumulates, since Leq is a plain
// energy average and has no time constant either.
var sumSq: Float = 0
weighted.withUnsafeBufferPointer { w in
vDSP_measqv(w.baseAddress!, 1, &sumSq, vDSP_Length(n))
}
msNone = Double(sumSq)
leqSum += Double(sumSq) * Double(n)
leqCount += Double(n)
@@ -353,7 +382,7 @@ final class ChannelMeter {
/// wire and noise in a diff — `%.2f` says what a meter can actually
/// resolve and nothing more.
func snapshot() -> 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: ",") + "]"
}