Files
ProdDash/modules/spl-meter/spl-capture.swift
T

736 lines
32 KiB
Swift
Raw Normal View History

// 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: 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.
// 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<CFString>? = nil
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.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<Float64>.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<AudioBufferList>.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<AudioDeviceID>.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<Float>, _ output: inout [Float], _ n: Int) {
vDSP_biquad(setup, &delay, input, 1, &output, 1, vDSP_Length(n))
}
}
/* ── third-octave band plan for the RTA ─────────────────────────────── */
/// 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 per band, clamped to what the transform can actually
/// 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
/// 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 / 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)))
}
}
/* ── 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]
// 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
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, 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 = []
// 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
}
/// Feed one tap buffer for this channel.
func push(_ samples: UnsafePointer<Float>, _ 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 fast = msFast, slow = msSlow, flat = flatMeanSquare
weighted.withUnsafeBufferPointer { w in
for i in 0..<n {
let v = Double(w[i]) * Double(w[i])
fast += alphaFast * (v - fast)
slow += alphaSlow * (v - slow)
let r = Double(samples[i])
flat += alphaFast * (r * r - flat)
}
}
msFast = fast; msSlow = slow; flatMeanSquare = flat
// 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)
var cPeak: Float = 0, zPeak: Float = 0
cWeighted.withUnsafeBufferPointer { c in
vDSP_maxmgv(c.baseAddress!, 1, &cPeak, vDSP_Length(n))
}
vDSP_maxmgv(samples, 1, &zPeak, vDSP_Length(n))
peakC = max(peakC, Double(cPeak))
peakZ = max(peakZ, Double(zPeak))
if zPeak >= 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(msNone)
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("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))
+ 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))
}
/// 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 {
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<Float>, _ 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 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 "--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 <name substring>") }
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<AudioDeviceID>.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,
calibration: calibrated ? wantCal[i] : 0, framesPerSecond: framesPerSecond)
}
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"
+ (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,
"calibrated": calibrated,
// 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) {
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(num1).joined(separator: ",") + "]"
}
line += ",\"bands\":[" + sets.joined(separator: ",") + "]"
}
}
emitLine(line + "}")
}
timer.resume()
signal(SIGINT) { _ in exit(0) }
signal(SIGTERM) { _ in exit(0) }
RunLoop.main.run()