import Foundation import Combine import AVFoundation /// Real audio recorder using AVAudioRecorder for production recording. /// Requests microphone permissions, configures AVAudioSession, and outputs AAC (.m4a) files. final class RealAudioRecorder: AudioRecorderProtocol { // MARK: - Published Properties @Published var isRecording: Bool = false @Published var elapsedTime: TimeInterval = 0 @Published var currentAmplitude: Float = 0 @Published var waveformSamples: [Float] = [] @Published var outputFileURL: URL? = nil @Published var isPaused: Bool = false // MARK: - Private Properties private var audioRecorder: AVAudioRecorder? private var timer: Timer? private let timerInterval: TimeInterval = 0.05 private let maxSampleCount = 200 init() { NotificationCenter.default.addObserver( self, selector: #selector(handleInterruption), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance() ) } // MARK: - AudioRecorderProtocol func startRecording() { guard !isRecording else { return } // Reset state elapsedTime = 0 currentAmplitude = 0 waveformSamples = [] outputFileURL = nil // Request Permission let session = AVAudioSession.sharedInstance() session.requestRecordPermission { [weak self] granted in guard let self else { return } if granted { DispatchQueue.main.async { self.setupAndRecord() } } else { print("[RealAudioRecorder] Microphone permission denied") } } } private func setupAndRecord() { let session = AVAudioSession.sharedInstance() do { // Set up audio session category and mode for recording, ducking other apps try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetooth, .duckOthers]) try session.setActive(true) // Create target file URL let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let filename = "recording_\(UUID().uuidString).m4a" let fileURL = documents.appendingPathComponent(filename) self.outputFileURL = fileURL // Configure AAC recorder settings let settings: [String: Any] = [ AVFormatIDKey: Int(kAudioFormatMPEG4AAC), AVSampleRateKey: 12000.0, AVNumberOfChannelsKey: 1, AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue ] let recorder = try AVAudioRecorder(url: fileURL, settings: settings) recorder.isMeteringEnabled = true if recorder.prepareToRecord() { recorder.record() self.audioRecorder = recorder self.isRecording = true self.isPaused = false self.startTimer() print("[RealAudioRecorder] Recording started: \(fileURL.lastPathComponent)") } else { print("[RealAudioRecorder] Prepare to record failed") } } catch { print("[RealAudioRecorder] Setup failed: \(error.localizedDescription)") } } func stopRecording() { guard isRecording else { return } invalidateTimer() audioRecorder?.stop() audioRecorder = nil isRecording = false isPaused = false currentAmplitude = 0 // Deactivate audio session try? AVAudioSession.sharedInstance().setActive(false) print("[RealAudioRecorder] Recording stopped. File saved.") } func pauseRecording() { guard isRecording else { return } audioRecorder?.pause() isPaused = true invalidateTimer() } func resumeRecording() { guard isRecording else { return } try? AVAudioSession.sharedInstance().setActive(true) audioRecorder?.record() isPaused = false startTimer() } // MARK: - Private Helpers private func startTimer() { invalidateTimer() timer = Timer.scheduledTimer(withTimeInterval: timerInterval, repeats: true) { [weak self] _ in self?.tick() } if let timer { RunLoop.current.add(timer, forMode: .common) } } private func invalidateTimer() { timer?.invalidate() timer = nil } private func tick() { guard let recorder = audioRecorder, recorder.isRecording else { return } // Update metering recorder.updateMeters() elapsedTime = recorder.currentTime // Get average decibels for channel 0 let power = recorder.averagePower(forChannel: 0) // Map decibels (-50.0 dB to 0.0 dB) to 0.0 - 1.0 amplitude let minDb: Float = -50.0 let level: Float if power < minDb { level = 0 } else if power >= 0 { level = 1 } else { level = (power - minDb) / -minDb } currentAmplitude = level waveformSamples.append(level) if waveformSamples.count > maxSampleCount { waveformSamples.removeFirst(waveformSamples.count - maxSampleCount) } } @objc private func handleInterruption(notification: Notification) { guard let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } switch type { case .began: DispatchQueue.main.async { [weak self] in guard let self else { return } if self.isRecording && !self.isPaused { self.pauseRecording() print("[RealAudioRecorder] Audio session interrupted. Recording automatically paused.") } } case .ended: guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return } let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) if options.contains(.shouldResume) { DispatchQueue.main.async { [weak self] in guard let self else { return } if self.isRecording && self.isPaused { self.resumeRecording() print("[RealAudioRecorder] Interruption ended. Recording automatically resumed.") } } } @unknown default: break } } deinit { NotificationCenter.default.removeObserver(self) invalidateTimer() audioRecorder?.stop() } }