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:
co-authored by
Claude Opus 5
parent
e90ae51495
commit
d2d4207fe4
@@ -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: ",") + "]"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user