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 AVAudioApplication.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, .allowBluetoothHFP, .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: 44100.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 currentAmplitude = 0 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) // Keep live and playback waveform levels on the same scale. let level = AudioLevelNormalizer.normalizedLevel(decibels: power) 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() } } // MARK: - AudioMerger /// Utility struct to merge existing audio recording file with newly recorded audio segment. struct AudioMerger { /// Merges an existing audio file and a newly recorded audio segment into a single unified .m4a file. /// Uses modern async/await AVFoundation APIs to ensure tracks and durations are fully loaded /// before building the composition, preventing empty-track or indefinite-duration bugs. /// - Parameters: /// - firstURL: The original audio file URL (can be nil or non-existent). /// - secondURL: The newly recorded audio segment URL. /// - Returns: The resulting merged (or fallback) file URL. @MainActor static func mergeAudioFiles(firstURL: URL?, secondURL: URL) async -> URL { let resolvedFirst = AudioPathHelper.resolveURL(for: firstURL?.path) let resolvedSecond = AudioPathHelper.resolveURL(for: secondURL.path) ?? secondURL guard let validFirstURL = resolvedFirst else { return resolvedSecond } guard FileManager.default.fileExists(atPath: resolvedSecond.path) else { return validFirstURL } let composition = AVMutableComposition() guard let compositionTrack = composition.addMutableTrack( withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid ) else { print("[AudioMerger] Failed to create composition audio track") return resolvedSecond } let asset1 = AVURLAsset(url: validFirstURL) let asset2 = AVURLAsset(url: resolvedSecond) do { // Load durations and tracks asynchronously to ensure they are ready let duration1 = try await asset1.load(.duration) let duration2 = try await asset2.load(.duration) let tracks1 = try await asset1.loadTracks(withMediaType: .audio) let tracks2 = try await asset2.loadTracks(withMediaType: .audio) if let track1 = tracks1.first { try compositionTrack.insertTimeRange( CMTimeRange(start: .zero, duration: duration1), of: track1, at: .zero ) print("[AudioMerger] Inserted first track: \(CMTimeGetSeconds(duration1))s") } else { print("[AudioMerger] Warning: No audio track found in first asset") } // Insert second track right after the first let insertionPoint = compositionTrack.timeRange.duration if let track2 = tracks2.first { try compositionTrack.insertTimeRange( CMTimeRange(start: .zero, duration: duration2), of: track2, at: insertionPoint ) print("[AudioMerger] Inserted second track: \(CMTimeGetSeconds(duration2))s at offset \(CMTimeGetSeconds(insertionPoint))s") } else { print("[AudioMerger] Warning: No audio track found in second asset") } } catch { print("[AudioMerger] Error loading/inserting tracks: \(error.localizedDescription)") return resolvedSecond } // Export merged composition let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] let mergedURL = documents.appendingPathComponent("merged_\(UUID().uuidString).m4a") guard let exportSession = AVAssetExportSession( asset: composition, presetName: AVAssetExportPresetAppleM4A ) else { print("[AudioMerger] Failed to create AVAssetExportSession") return resolvedSecond } exportSession.outputURL = mergedURL exportSession.outputFileType = .m4a await exportSession.export() switch exportSession.status { case .completed: let mergedDuration = try? await AVURLAsset(url: mergedURL).load(.duration) let mergedSeconds = mergedDuration.map(CMTimeGetSeconds) ?? 0 print("[AudioMerger] Successfully merged audio to: \(mergedURL.lastPathComponent) (\(mergedSeconds)s)") return mergedURL default: print("[AudioMerger] Audio export failed: \(String(describing: exportSession.error))") return resolvedSecond } } }