@preconcurrency import AVFoundation import Foundation import TermindApplication import TermindInfrastructure /// macOS microphone permission and AVAudioEngine lifecycle for Apple Speech. @MainActor final class MacOSSpeechAudioInput: SpeechAudioInput { private let audioEngine = AVAudioEngine() private var inputTapInstalled = false func requestAuthorization() async -> Bool { switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: return true case .notDetermined: return await withCheckedContinuation { continuation in AVCaptureDevice.requestAccess(for: .audio) { allowed in continuation.resume(returning: allowed) } } case .denied, .restricted: return false @unknown default: return false } } func start( bufferHandler: @escaping SpeechAudioBufferHandler, levelHandler: @escaping SpeechAudioLevelHandler ) throws { stop() let inputNode = audioEngine.inputNode let format = inputNode.outputFormat(forBus: 0) guard format.sampleRate > 0, format.channelCount > 0 else { throw SpeechTranscriptionFailure(.audioInputUnavailable) } // AVAudioEngine invokes this tap on its realtime audio queue, so the // closure must be created outside MainActor isolation. inputNode.installTap( onBus: 0, bufferSize: 1_024, format: format, block: Self.makeAudioTapHandler( bufferHandler: bufferHandler, levelHandler: levelHandler ) ) inputTapInstalled = true audioEngine.prepare() do { try audioEngine.start() } catch { stop() throw SpeechTranscriptionFailure( .audioInputUnavailable, detail: error.localizedDescription ) } } func stop() { if audioEngine.isRunning { audioEngine.stop() } if inputTapInstalled { audioEngine.inputNode.removeTap(onBus: 0) inputTapInstalled = false } } nonisolated private static func makeAudioTapHandler( bufferHandler: @escaping SpeechAudioBufferHandler, levelHandler: @escaping SpeechAudioLevelHandler ) -> AVAudioNodeTapBlock { let levelGate = AudioLevelDeliveryGate(maximumUpdatesPerSecond: 30) return { buffer, _ in bufferHandler(buffer) guard levelGate.shouldDeliver() else { return } levelHandler(normalizedLevel(from: buffer)) } } nonisolated private static func normalizedLevel(from buffer: AVAudioPCMBuffer) -> Float { guard let channels = buffer.floatChannelData, buffer.frameLength > 0 else { return 0.04 } let samples = channels[0] var sum: Float = 0 for index in 0.. Bool { lock.lock() defer { lock.unlock() } guard now - lastDeliveryTime >= minimumInterval else { return false } lastDeliveryTime = now return true } }