// 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? = nil var size = UInt32(MemoryLayout?>.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.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.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.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, _ 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, _ 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..= 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, _ 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 ") } 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.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()