import AVFoundation import Foundation enum AudioCaptureState: Equatable { case idle case starting case recording case failed(String) } @MainActor final class AudioCaptureManager: ObservableObject { @Published private(set) var state: AudioCaptureState = .idle var onPCMChunk: ((Data) -> Void)? private let engine = AVAudioEngine() private var converter: AVAudioConverter? private var inputFormat: AVAudioFormat? private let targetFormat = AVAudioFormat( commonFormat: .pcmFormatInt16, sampleRate: 16_000, channels: 1, interleaved: true )! func requestPermission() async -> Bool { await withCheckedContinuation { continuation in AVAudioSession.sharedInstance().requestRecordPermission { granted in continuation.resume(returning: granted) } } } func startRecording() async { guard state != .recording && state != .starting else { return } state = .starting guard await requestPermission() else { state = .failed("Microphone permission was denied.") return } do { try configureSession() try configureEngine() try engine.start() state = .recording } catch { stopRecording() state = .failed(error.localizedDescription) } } func stopRecording() { guard state == .recording || state == .starting else { return } engine.inputNode.removeTap(onBus: 0) engine.stop() converter = nil inputFormat = nil state = .idle } private func configureSession() throws { let session = AVAudioSession.sharedInstance() try session.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker]) try session.setPreferredSampleRate(16_000) try session.setPreferredIOBufferDuration(0.02) try session.setActive(true) } private func configureEngine() throws { let inputNode = engine.inputNode let hardwareFormat = inputNode.outputFormat(forBus: 0) inputFormat = hardwareFormat converter = AVAudioConverter(from: hardwareFormat, to: targetFormat) inputNode.removeTap(onBus: 0) inputNode.installTap(onBus: 0, bufferSize: 1024, format: hardwareFormat) { [weak self] buffer, _ in Task { @MainActor in guard let self else { return } guard let data = self.convertToTargetPCM(buffer) else { return } self.onPCMChunk?(data) } } engine.prepare() } private func convertToTargetPCM(_ inputBuffer: AVAudioPCMBuffer) -> Data? { guard let converter else { return nil } let ratio = targetFormat.sampleRate / inputBuffer.format.sampleRate let targetFrameCapacity = AVAudioFrameCount(Double(inputBuffer.frameLength) * ratio) + 1 guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: targetFormat, frameCapacity: targetFrameCapacity) else { return nil } var didProvideInput = false var conversionError: NSError? converter.convert(to: outputBuffer, error: &conversionError) { _, status in if didProvideInput { status.pointee = .noDataNow return nil } didProvideInput = true status.pointee = .haveData return inputBuffer } guard conversionError == nil else { return nil } return outputBuffer.interleavedPCMData() } } private extension AVAudioPCMBuffer { func interleavedPCMData() -> Data? { let audioBuffer = audioBufferList.pointee.mBuffers guard let source = audioBuffer.mData else { return nil } return Data(bytes: source, count: Int(audioBuffer.mDataByteSize)) } }