import AVFoundation import Foundation /// Microphone capture with on-the-fly conversion to whatever format the speech engine wants. /// /// The tap runs on a real-time audio thread, so everything it touches lives behind /// `nonisolated(unsafe)` and is only ever mutated from that one thread. public final class AudioCapture: @unchecked Sendable { public init() {} private let engine = AVAudioEngine() private nonisolated(unsafe) var converter: AVAudioConverter? private nonisolated(unsafe) var outputFormat: AVAudioFormat? private var isRunning = false /// Called on the audio thread with each converted buffer. private nonisolated(unsafe) var onBuffer: (@Sendable (AudioChunk) -> Void)? /// Called on the audio thread with a 0…1 RMS level, for the HUD waveform. private nonisolated(unsafe) var onLevel: (@Sendable (Float) -> Void)? public func start( outputFormat: AVAudioFormat, onBuffer: @escaping @Sendable (AudioChunk) -> Void, onLevel: @escaping @Sendable (Float) -> Void ) throws { guard !isRunning else { return } self.onBuffer = onBuffer self.onLevel = onLevel self.outputFormat = outputFormat let input = engine.inputNode let nativeFormat = input.outputFormat(forBus: 0) converter = nativeFormat == outputFormat ? nil : AVAudioConverter(from: nativeFormat, to: outputFormat) input.removeTap(onBus: 0) input.installTap(onBus: 0, bufferSize: 2048, format: nativeFormat) { [weak self] buffer, _ in self?.handle(buffer) } engine.prepare() try engine.start() isRunning = true Log.audio.info("capture started — native \(nativeFormat.sampleRate)Hz → engine \(outputFormat.sampleRate)Hz") } public func stop() { guard isRunning else { return } engine.inputNode.removeTap(onBus: 0) engine.stop() isRunning = false converter = nil onBuffer = nil onLevel = nil Log.audio.info("capture stopped") } // MARK: - Audio thread private func handle(_ buffer: AVAudioPCMBuffer) { onLevel?(Self.rms(of: buffer)) guard let outputFormat else { return } // AVAudioEngine reuses the tap's buffer as soon as this returns, so the engine // must never see it directly — copy when no conversion would otherwise allocate. guard let converter else { if let copy = Self.copy(buffer) { onBuffer?(AudioChunk(buffer: copy)) } return } // Output frame count scales with the sample-rate ratio; round up so we never clip. let ratio = outputFormat.sampleRate / buffer.format.sampleRate let capacity = AVAudioFrameCount((Double(buffer.frameLength) * ratio).rounded(.up)) + 64 guard let converted = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return } // The input block runs synchronously inside `convert`, on this thread. nonisolated(unsafe) let input = buffer let consumed = Latch() var error: NSError? let status = converter.convert(to: converted, error: &error) { _, outStatus in guard !consumed.take() else { outStatus.pointee = .noDataNow return nil } outStatus.pointee = .haveData return input } if let error { Log.audio.error("conversion failed: \(error.localizedDescription)") return } guard status != .error, converted.frameLength > 0 else { return } onBuffer?(AudioChunk(buffer: converted)) } /// Deep-copies a tap buffer into storage we own. private static func copy(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? { guard buffer.frameLength > 0, let copy = AVAudioPCMBuffer(pcmFormat: buffer.format, frameCapacity: buffer.frameLength) else { return nil } copy.frameLength = buffer.frameLength let channels = Int(buffer.format.channelCount) let frames = Int(buffer.frameLength) if let source = buffer.floatChannelData, let destination = copy.floatChannelData { for channel in 0.. Bool { defer { fired = true } return fired } } private static func rms(of buffer: AVAudioPCMBuffer) -> Float { guard let channel = buffer.floatChannelData?[0] else { return 0 } let count = Int(buffer.frameLength) guard count > 0 else { return 0 } var sum: Float = 0 for i in 0..