2026-09-17 10:03:45 -04:00
|
|
|
|
// 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.
|
|
|
|
|
|
//
|
2026-09-17 12:47:42 -04:00
|
|
|
|
// 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.
|
2026-09-17 10:03:45 -04:00
|
|
|
|
//
|
|
|
|
|
|
// 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 ─────────────────────────────── */
|
|
|
|
|
|
|
2026-09-17 12:47:42 -04:00
|
|
|
|
/// 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) }
|
2026-09-17 10:03:45 -04:00
|
|
|
|
|
2026-09-17 12:47:42 -04:00
|
|
|
|
/// FFT bin ranges per band, clamped to what the transform can actually
|
2026-09-17 16:00:34 -04:00
|
|
|
|
/// 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
|
2026-09-17 12:47:42 -04:00
|
|
|
|
/// 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.
|
2026-09-17 10:03:45 -04:00
|
|
|
|
func bandBins(fs: Double, fftSize: Int) -> [(Int, Int)] {
|
|
|
|
|
|
let binHz = fs / Double(fftSize)
|
|
|
|
|
|
let maxBin = fftSize / 2 - 1
|
|
|
|
|
|
return bandCentres.map { fc in
|
2026-09-17 12:47:42 -04:00
|
|
|
|
let lo = Int((fc * pow(2, -1.0 / 96.0) / binHz).rounded())
|
|
|
|
|
|
let hi = Int((fc * pow(2, 1.0 / 96.0) / binHz).rounded())
|
2026-09-17 10:03:45 -04:00
|
|
|
|
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]
|
|
|
|
|
|
|
2026-09-17 12:47:42 -04:00
|
|
|
|
// 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
|
2026-09-17 10:03:45 -04:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
|
2026-09-17 12:47:42 -04:00
|
|
|
|
init(index: Int, fs: Double, weighting: Weighting, calibration: Double,
|
2026-09-17 10:03:45 -04:00
|
|
|
|
framesPerSecond: Double) {
|
|
|
|
|
|
self.index = index
|
|
|
|
|
|
self.fs = fs
|
|
|
|
|
|
self.calibration = calibration
|
|
|
|
|
|
weightFilter = WeightingFilter(weighting, fs: fs)
|
|
|
|
|
|
cFilter = WeightingFilter(.c, fs: fs)
|
|
|
|
|
|
weighted = []
|
|
|
|
|
|
cWeighted = []
|
2026-09-17 12:47:42 -04:00
|
|
|
|
// 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))
|
2026-09-17 10:03:45 -04:00
|
|
|
|
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.
|
2026-09-17 12:47:42 -04:00
|
|
|
|
var fast = msFast, slow = msSlow, flat = flatMeanSquare
|
2026-09-17 10:03:45 -04:00
|
|
|
|
weighted.withUnsafeBufferPointer { w in
|
|
|
|
|
|
for i in 0..<n {
|
2026-09-17 12:47:42 -04:00
|
|
|
|
let v = Double(w[i]) * Double(w[i])
|
|
|
|
|
|
fast += alphaFast * (v - fast)
|
|
|
|
|
|
slow += alphaSlow * (v - slow)
|
2026-09-17 10:03:45 -04:00
|
|
|
|
let r = Double(samples[i])
|
2026-09-17 12:47:42 -04:00
|
|
|
|
flat += alphaFast * (r * r - flat)
|
2026-09-17 10:03:45 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-17 12:47:42 -04:00
|
|
|
|
msFast = fast; msSlow = slow; flatMeanSquare = flat
|
2026-09-17 10:03:45 -04:00
|
|
|
|
|
2026-09-17 12:47:42 -04:00
|
|
|
|
// 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.
|
2026-09-17 10:03:45 -04:00
|
|
|
|
var sumSq: Float = 0
|
|
|
|
|
|
weighted.withUnsafeBufferPointer { w in
|
|
|
|
|
|
vDSP_measqv(w.baseAddress!, 1, &sumSq, vDSP_Length(n))
|
|
|
|
|
|
}
|
2026-09-17 12:47:42 -04:00
|
|
|
|
msNone = Double(sumSq)
|
2026-09-17 10:03:45 -04:00
|
|
|
|
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 {
|
2026-09-17 12:47:42 -04:00
|
|
|
|
ring.append(msNone)
|
2026-09-17 10:03:45 -04:00
|
|
|
|
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)"
|
2026-09-17 12:47:42 -04:00
|
|
|
|
+ f("fast", db(msFast))
|
|
|
|
|
|
+ f("slow", db(msSlow))
|
|
|
|
|
|
+ f("inf", leqCount > 0 ? db(leqSum / leqCount) : -200)
|
|
|
|
|
|
+ f("none", db(msNone))
|
2026-09-17 10:03:45 -04:00
|
|
|
|
+ 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 12:47:42 -04:00
|
|
|
|
/// 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-17 10:03:45 -04:00
|
|
|
|
/* ── 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
|
2026-09-17 12:47:42 -04:00
|
|
|
|
ChannelMeter(index: ch, fs: sampleRate, weighting: wantWeighting,
|
2026-09-17 10:03:45 -04:00
|
|
|
|
calibration: calibrated ? wantCal[i] : 0, framesPerSecond: framesPerSecond)
|
|
|
|
|
|
}
|
2026-09-17 12:47:42 -04:00
|
|
|
|
let spectra = wantBands ? wantChannels.map { _ in Spectrum(fs: sampleRate, fftSize: 16384) } : []
|
2026-09-17 10:03:45 -04:00
|
|
|
|
|
|
|
|
|
|
note("capturing \"\(device.name)\": \(channelCount) channels @ \(Int(sampleRate)) Hz, "
|
|
|
|
|
|
+ "measuring \(wantChannels.map(String.init).joined(separator: ",")) "
|
2026-09-17 12:47:42 -04:00
|
|
|
|
+ "\(wantWeighting.rawValue)-weighted"
|
2026-09-17 10:03:45 -04:00
|
|
|
|
+ (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,
|
2026-09-17 12:47:42 -04:00
|
|
|
|
// 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,
|
2026-09-17 10:03:45 -04:00
|
|
|
|
]
|
|
|
|
|
|
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)
|
2026-09-17 12:47:42 -04:00
|
|
|
|
.map(num1).joined(separator: ",") + "]"
|
2026-09-17 10:03:45 -04:00
|
|
|
|
}
|
|
|
|
|
|
line += ",\"bands\":[" + sets.joined(separator: ",") + "]"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
emitLine(line + "}")
|
|
|
|
|
|
}
|
|
|
|
|
|
timer.resume()
|
|
|
|
|
|
|
|
|
|
|
|
signal(SIGINT) { _ in exit(0) }
|
|
|
|
|
|
signal(SIGTERM) { _ in exit(0) }
|
|
|
|
|
|
RunLoop.main.run()
|