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>
695 lines
30 KiB
Swift
695 lines
30 KiB
Swift
// 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: 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.
|
||
//
|
||
// 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 ─────────────────────────────── */
|
||
|
||
/// 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]
|
||
|
||
/// FFT bin ranges for each band, clamped to what the transform can resolve.
|
||
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())
|
||
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]
|
||
|
||
private var meanSquare: Double = 0 // exponential integrator, linear power
|
||
private var flatMeanSquare: Double = 0 // same integrator, unweighted — for dBFS
|
||
private let alphaPerSample: 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, fast: Bool, 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 = []
|
||
let tau = fast ? 0.125 : 1.0
|
||
alphaPerSample = 1.0 - exp(-1.0 / (tau * 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 ms = meanSquare
|
||
var flat = flatMeanSquare
|
||
weighted.withUnsafeBufferPointer { w in
|
||
for i in 0..<n {
|
||
let v = Double(w[i])
|
||
ms += alphaPerSample * (v * v - ms)
|
||
let r = Double(samples[i])
|
||
flat += alphaPerSample * (r * r - flat)
|
||
}
|
||
}
|
||
meanSquare = ms
|
||
flatMeanSquare = flat
|
||
|
||
// Leq wants plain energy, no time constant.
|
||
var sumSq: Float = 0
|
||
weighted.withUnsafeBufferPointer { w in
|
||
vDSP_measqv(w.baseAddress!, 1, &sumSq, vDSP_Length(n))
|
||
}
|
||
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(meanSquare)
|
||
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("spl", db(meanSquare))
|
||
+ f("leq", leqCount > 0 ? db(leqSum / leqCount) : -200)
|
||
+ 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))
|
||
}
|
||
|
||
/* ── 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 wantFast = true
|
||
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 "--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)") }
|
||
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, fast: wantFast,
|
||
calibration: calibrated ? wantCal[i] : 0, framesPerSecond: framesPerSecond)
|
||
}
|
||
let spectra = wantBands ? wantChannels.map { _ in Spectrum(fs: sampleRate, fftSize: 8192) } : []
|
||
|
||
note("capturing \"\(device.name)\": \(channelCount) channels @ \(Int(sampleRate)) Hz, "
|
||
+ "measuring \(wantChannels.map(String.init).joined(separator: ",")) "
|
||
+ "\(wantWeighting.rawValue)-weighted \(wantFast ? "fast" : "slow")"
|
||
+ (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,
|
||
"filter": wantFast ? "fast" : "slow",
|
||
"calibrated": calibrated,
|
||
"bands": wantBands ? bandCentres : [],
|
||
]
|
||
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(num).joined(separator: ",") + "]"
|
||
}
|
||
line += ",\"bands\":[" + sets.joined(separator: ",") + "]"
|
||
}
|
||
}
|
||
emitLine(line + "}")
|
||
}
|
||
timer.resume()
|
||
|
||
signal(SIGINT) { _ in exit(0) }
|
||
signal(SIGTERM) { _ in exit(0) }
|
||
RunLoop.main.run()
|