import AVFoundation import Foundation enum RecordingError: LocalizedError { case microphoneDenied case couldNotStart var errorDescription: String? { switch self { case .microphoneDenied: "Microphone access is off. Enable it in System Settings." case .couldNotStart: "The microphone could not start recording." } } } @MainActor final class AudioRecorder: NSObject, AVAudioRecorderDelegate { private var recorder: AVAudioRecorder? private var meterTimer: Timer? private(set) var fileURL: URL? var onLevel: ((Double) -> Void)? func start() async throws { guard await Self.requestPermission() else { throw RecordingError.microphoneDenied } let fileURL = FileManager.default.temporaryDirectory .appendingPathComponent("transui-\(UUID().uuidString).wav") self.fileURL = fileURL let settings: [String: Any] = [ AVFormatIDKey: Int(kAudioFormatLinearPCM), AVSampleRateKey: 16_000, AVNumberOfChannelsKey: 1, AVLinearPCMBitDepthKey: 16, AVLinearPCMIsFloatKey: false, AVLinearPCMIsBigEndianKey: false, ] let recorder = try AVAudioRecorder(url: fileURL, settings: settings) recorder.delegate = self recorder.isMeteringEnabled = true guard recorder.prepareToRecord(), recorder.record() else { throw RecordingError.couldNotStart } self.recorder = recorder meterTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in Task { @MainActor in self?.updateLevel() } } } func stop() -> URL? { meterTimer?.invalidate() meterTimer = nil recorder?.stop() recorder = nil onLevel?(0) let url = fileURL fileURL = nil return url } func cancel() { let url = stop() if let url { try? FileManager.default.removeItem(at: url) } fileURL = nil } private func updateLevel() { guard let recorder else { return } recorder.updateMeters() let decibels = Double(recorder.averagePower(forChannel: 0)) let normalized = max(0, min(1, pow(10, decibels / 32))) onLevel?(normalized) } private static func requestPermission() async -> Bool { switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: true case .denied, .restricted: false case .notDetermined: await withCheckedContinuation { continuation in AVCaptureDevice.requestAccess(for: .audio) { allowed in continuation.resume(returning: allowed) } } @unknown default: false } } }