// 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? = 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 ─────────────────────────────── */ /// 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, _ 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..= 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, _ 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 ") } 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, 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()