From c8e16145e16d8e9494df56e0c2be69541cb6995e Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Thu, 1 Oct 2026 15:00:40 +0200 Subject: [PATCH] feat: AudioLevel helper and AudioRecorder.onLevel Co-Authored-By: Claude Sonnet 5.5 --- Sources/SstCore/AudioLevel.swift | 24 ++++++++++++++++++ Sources/SstCore/AudioRecorder.swift | 9 ++++++- Sources/SstProbe/RecordProbe.swift | 17 +++++++++++-- Tests/SstCoreTests/AudioLevelTests.swift | 31 ++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 Sources/SstCore/AudioLevel.swift create mode 100644 Tests/SstCoreTests/AudioLevelTests.swift diff --git a/Sources/SstCore/AudioLevel.swift b/Sources/SstCore/AudioLevel.swift new file mode 100644 index 0000000..804e1f0 --- /dev/null +++ b/Sources/SstCore/AudioLevel.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum AudioLevel { + /// Reported for empty or silent buffers (log of zero would be -infinity). + public static let silenceFloorDbfs: Float = -120 + + public static func rmsDbfs(_ samples: [Float]) -> Float { + samples.withUnsafeBufferPointer { rmsDbfs($0) } + } + + public static func rmsDbfs(_ samples: UnsafeBufferPointer) -> Float { + guard !samples.isEmpty else { return silenceFloorDbfs } + var sum: Float = 0 + for sample in samples { sum += sample * sample } + let rms = (sum / Float(samples.count)).squareRoot() + guard rms > 0 else { return silenceFloorDbfs } + return max(silenceFloorDbfs, 20 * log10(rms)) + } + + /// VU reading for a dBFS level, where `referenceDbfs` is 0 VU. + public static func vu(fromDbfs dbfs: Float, referenceDbfs: Float = -20) -> Float { + dbfs - referenceDbfs + } +} diff --git a/Sources/SstCore/AudioRecorder.swift b/Sources/SstCore/AudioRecorder.swift index b537465..1e31c4a 100644 --- a/Sources/SstCore/AudioRecorder.swift +++ b/Sources/SstCore/AudioRecorder.swift @@ -12,6 +12,10 @@ public final class AudioRecorder: AudioRecording { private let engine = AVAudioEngine() private let lock = NSLock() private var buffer: [Float] = [] + + /// RMS level (dBFS) of every delivered input buffer. Called on the audio thread; set before `start()`. + public var onLevel: ((Float) -> Void)? + private let targetFormat = AVAudioFormat( commonFormat: .pcmFormatFloat32, sampleRate: 16_000, @@ -32,8 +36,11 @@ public final class AudioRecorder: AudioRecording { let ratio = targetFormat.sampleRate / inputFormat.sampleRate input.removeTap(onBus: 0) - input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] pcm, _ in + input.installTap(onBus: 0, bufferSize: 1024, format: inputFormat) { [weak self] pcm, _ in guard let self else { return } + if let onLevel = self.onLevel, let channel = pcm.floatChannelData?[0] { + onLevel(AudioLevel.rmsDbfs(UnsafeBufferPointer(start: channel, count: Int(pcm.frameLength)))) + } let capacity = AVAudioFrameCount(Double(pcm.frameLength) * ratio) + 1 guard let out = AVAudioPCMBuffer(pcmFormat: self.targetFormat, frameCapacity: capacity) else { return } diff --git a/Sources/SstProbe/RecordProbe.swift b/Sources/SstProbe/RecordProbe.swift index e8e3c3f..0566a31 100644 --- a/Sources/SstProbe/RecordProbe.swift +++ b/Sources/SstProbe/RecordProbe.swift @@ -2,12 +2,13 @@ import AVFoundation import SstCore /// Records `seconds` of microphone audio; returns nil if access is denied or recording fails. -func recordSamples(seconds: Double) async -> [Float]? { +func recordSamples(seconds: Double, onLevel: ((Float) -> Void)? = nil) async -> [Float]? { guard await AVCaptureDevice.requestAccess(for: .audio) else { print("Microfoon-toegang geweigerd (Systeeminstellingen → Privacy → Microfoon).") return nil } let recorder = AudioRecorder() + recorder.onLevel = onLevel do { try recorder.start() } catch { print("start mislukt:", error.localizedDescription) return nil @@ -18,7 +19,19 @@ func recordSamples(seconds: Double) async -> [Float]? { } func runRecordProbe() async { - guard let samples = await recordSamples(seconds: 3) else { return } + final class Levels: @unchecked Sendable { + let lock = NSLock() + var values: [Float] = [] + func add(_ v: Float) { lock.withLock { values.append(v) } } + } + let levels = Levels() + guard let samples = await recordSamples(seconds: 3, onLevel: { levels.add($0) }) else { return } let peak = samples.map { abs($0) }.max() ?? 0 print("samples: \(samples.count) (verwacht ≈ 48000), piek: \(peak)") + let values = levels.lock.withLock { levels.values } + if let lo = values.min(), let hi = values.max() { + print(String(format: "niveau-meldingen: %d (≈ %.0f per s), bereik %.1f … %.1f dBFS", values.count, Double(values.count) / 3, lo, hi)) + } else { + print("geen niveau-meldingen ontvangen") + } } diff --git a/Tests/SstCoreTests/AudioLevelTests.swift b/Tests/SstCoreTests/AudioLevelTests.swift new file mode 100644 index 0000000..6561bf4 --- /dev/null +++ b/Tests/SstCoreTests/AudioLevelTests.swift @@ -0,0 +1,31 @@ +import Testing +@testable import SstCore + +struct AudioLevelTests { + @Test func constantTenthIsMinusTwentyDbfs() { + let level = AudioLevel.rmsDbfs([Float](repeating: 0.1, count: 1_000)) + #expect(abs(level - (-20)) < 0.01) + } + + @Test func fullScaleIsZeroDbfs() { + let level = AudioLevel.rmsDbfs([Float](repeating: 1.0, count: 100)) + #expect(abs(level) < 0.01) + } + + @Test func negativeSamplesCountLikePositiveOnes() { + let level = AudioLevel.rmsDbfs([Float](repeating: -0.1, count: 1_000)) + #expect(abs(level - (-20)) < 0.01) + } + + @Test func emptyAndSilentBuffersHitTheFloor() { + #expect(AudioLevel.rmsDbfs([]) == AudioLevel.silenceFloorDbfs) + #expect(AudioLevel.rmsDbfs([Float](repeating: 0, count: 64)) == AudioLevel.silenceFloorDbfs) + } + + @Test func vuIsRelativeToTheReference() { + #expect(AudioLevel.vu(fromDbfs: -20) == 0) + #expect(AudioLevel.vu(fromDbfs: -40) == -20) + #expect(AudioLevel.vu(fromDbfs: -17) == 3) + #expect(AudioLevel.vu(fromDbfs: -30, referenceDbfs: -18) == -12) + } +}