Files
ProdDash/modules/spl-meter/native/spl-capture.swift
Waters ChurchandClaude Fable 5.1 0929af46ca SPL Meter 0.5.0: the capture tool ships prebuilt, for Apple silicon and Intel
Same move as Timers 1.7.0. The capture tool existed only where the macOS
app build had compiled it, with the module trying swiftc on the booth as a
fallback — NA has been logging "spawn spl-capture ENOENT" since its
catalog install. Under the module guide's rule it now ships built:
bin/spl-capture-macos-arm64 and bin/spl-capture-macos-x86_64, signed with
the Developer ID (hardened runtime, audio-input entitlement), from
native/spl-capture.swift via native/build.sh. captureTool() picks the
binary for this platform; where there is none — Windows, until a Windows
implementation exists — measurement reports itself off in the health line
and the device picker while the module stays mounted. The runtime build
and its "building…" state are gone, and the old top-level binary is no
longer gitignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 10:12:42 -04:00

737 lines
32 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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. It ships prebuilt in the module's bin/ (one
// binary per platform, committed — a booth never builds anything). To
// rebuild after a change here, on any Mac with the Xcode command line tools:
//
// modules/spl-meter/native/build.sh (both architectures)
//
// 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()