From 28515d9c877bd7c74940c1915262d85654dc5206 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Thu, 1 Oct 2026 09:46:15 +0200 Subject: [PATCH] feat: add AudioRecorder (16 kHz mono) and record probe Co-Authored-By: Claude Sonnet 5.5 --- Sources/SstCore/AudioRecorder.swift | 67 +++++++++++++++++++++++++++++ Sources/SstProbe/RecordProbe.swift | 24 +++++++++++ Sources/SstProbe/main.swift | 4 +- 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 Sources/SstCore/AudioRecorder.swift create mode 100644 Sources/SstProbe/RecordProbe.swift diff --git a/Sources/SstCore/AudioRecorder.swift b/Sources/SstCore/AudioRecorder.swift new file mode 100644 index 0000000..83373ed --- /dev/null +++ b/Sources/SstCore/AudioRecorder.swift @@ -0,0 +1,67 @@ +import AVFoundation + +public final class AudioRecorder: AudioRecording { + public enum RecorderError: Error, LocalizedError { + case converterUnavailable + + public var errorDescription: String? { + "Kon de microfoon niet omzetten naar 16 kHz mono." + } + } + + private let engine = AVAudioEngine() + private let lock = NSLock() + private var buffer: [Float] = [] + private let targetFormat = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: 16_000, + channels: 1, + interleaved: false + )! + + public init() {} + + public func start() throws { + lock.withLock { buffer.removeAll(keepingCapacity: true) } + + let input = engine.inputNode + let inputFormat = input.outputFormat(forBus: 0) + guard let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else { + throw RecorderError.converterUnavailable + } + let ratio = targetFormat.sampleRate / inputFormat.sampleRate + + input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] pcm, _ in + guard let self else { return } + let capacity = AVAudioFrameCount(Double(pcm.frameLength) * ratio) + 1 + guard let out = AVAudioPCMBuffer(pcmFormat: self.targetFormat, frameCapacity: capacity) else { return } + + var consumed = false + var error: NSError? + converter.convert(to: out, error: &error) { _, status in + if consumed { + status.pointee = .noDataNow + return nil + } + consumed = true + status.pointee = .haveData + return pcm + } + guard error == nil, let channel = out.floatChannelData?[0] else { return } + let samples = Array(UnsafeBufferPointer(start: channel, count: Int(out.frameLength))) + self.lock.withLock { self.buffer.append(contentsOf: samples) } + } + engine.prepare() + try engine.start() + } + + public func stop() -> [Float] { + engine.inputNode.removeTap(onBus: 0) + engine.stop() + return lock.withLock { + let samples = buffer + buffer = [] + return samples + } + } +} diff --git a/Sources/SstProbe/RecordProbe.swift b/Sources/SstProbe/RecordProbe.swift new file mode 100644 index 0000000..e8e3c3f --- /dev/null +++ b/Sources/SstProbe/RecordProbe.swift @@ -0,0 +1,24 @@ +import AVFoundation +import SstCore + +/// Records `seconds` of microphone audio; returns nil if access is denied or recording fails. +func recordSamples(seconds: Double) async -> [Float]? { + guard await AVCaptureDevice.requestAccess(for: .audio) else { + print("Microfoon-toegang geweigerd (Systeeminstellingen → Privacy → Microfoon).") + return nil + } + let recorder = AudioRecorder() + do { try recorder.start() } catch { + print("start mislukt:", error.localizedDescription) + return nil + } + print("Neem \(Int(seconds)) s op — praat nu") + try? await Task.sleep(for: .seconds(seconds)) + return recorder.stop() +} + +func runRecordProbe() async { + guard let samples = await recordSamples(seconds: 3) else { return } + let peak = samples.map { abs($0) }.max() ?? 0 + print("samples: \(samples.count) (verwacht ≈ 48000), piek: \(peak)") +} diff --git a/Sources/SstProbe/main.swift b/Sources/SstProbe/main.swift index a58363b..70723b1 100644 --- a/Sources/SstProbe/main.swift +++ b/Sources/SstProbe/main.swift @@ -4,6 +4,8 @@ let command = CommandLine.arguments.dropFirst().first ?? "help" switch command { case "hotkey": runHotkeyProbe() +case "record": + await runRecordProbe() default: - print("usage: sst-probe hotkey") + print("usage: sst-probe hotkey|record") }