feat: AudioLevel helper and AudioRecorder.onLevel
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ public final class AudioRecorder: AudioRecording {
|
|||||||
private let engine = AVAudioEngine()
|
private let engine = AVAudioEngine()
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var buffer: [Float] = []
|
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(
|
private let targetFormat = AVAudioFormat(
|
||||||
commonFormat: .pcmFormatFloat32,
|
commonFormat: .pcmFormatFloat32,
|
||||||
sampleRate: 16_000,
|
sampleRate: 16_000,
|
||||||
@@ -32,8 +36,11 @@ public final class AudioRecorder: AudioRecording {
|
|||||||
let ratio = targetFormat.sampleRate / inputFormat.sampleRate
|
let ratio = targetFormat.sampleRate / inputFormat.sampleRate
|
||||||
|
|
||||||
input.removeTap(onBus: 0)
|
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 }
|
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
|
let capacity = AVAudioFrameCount(Double(pcm.frameLength) * ratio) + 1
|
||||||
guard let out = AVAudioPCMBuffer(pcmFormat: self.targetFormat, frameCapacity: capacity) else { return }
|
guard let out = AVAudioPCMBuffer(pcmFormat: self.targetFormat, frameCapacity: capacity) else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import AVFoundation
|
|||||||
import SstCore
|
import SstCore
|
||||||
|
|
||||||
/// Records `seconds` of microphone audio; returns nil if access is denied or recording fails.
|
/// 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 {
|
guard await AVCaptureDevice.requestAccess(for: .audio) else {
|
||||||
print("Microfoon-toegang geweigerd (Systeeminstellingen → Privacy → Microfoon).")
|
print("Microfoon-toegang geweigerd (Systeeminstellingen → Privacy → Microfoon).")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
let recorder = AudioRecorder()
|
let recorder = AudioRecorder()
|
||||||
|
recorder.onLevel = onLevel
|
||||||
do { try recorder.start() } catch {
|
do { try recorder.start() } catch {
|
||||||
print("start mislukt:", error.localizedDescription)
|
print("start mislukt:", error.localizedDescription)
|
||||||
return nil
|
return nil
|
||||||
@@ -18,7 +19,19 @@ func recordSamples(seconds: Double) async -> [Float]? {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runRecordProbe() async {
|
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
|
let peak = samples.map { abs($0) }.max() ?? 0
|
||||||
print("samples: \(samples.count) (verwacht ≈ 48000), piek: \(peak)")
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user