import Foundation import Combine import AVFoundation /// Tracks whether a recording was actively running when a system interruption /// began. Keeping this separate from `isPaused` prevents a user-paused /// recording from being resumed automatically after a phone call or alarm. struct AudioInterruptionRecoveryState { private(set) var isInterrupted = false private var shouldResumeRecording = false mutating func interruptionBegan(isRecording: Bool, isPaused: Bool) -> Bool { guard !isInterrupted else { return false } isInterrupted = true shouldResumeRecording = isRecording && !isPaused return shouldResumeRecording } mutating func interruptionEnded(systemRecommendsResume: Bool) -> Bool { guard isInterrupted else { return false } isInterrupted = false defer { shouldResumeRecording = false } return shouldResumeRecording && systemRecommendsResume } mutating func cancelAutomaticResume() { shouldResumeRecording = false } mutating func reset() { isInterrupted = false shouldResumeRecording = false } } /// Forces the bytes already emitted by `AVAudioRecorder` through the file /// system cache. `pause()`/`stop()` remains responsible for flushing the AAC /// encoder; this is an additional persistence fallback for interruptions. enum AudioRecordingWriteProtector { static func synchronizeFile(at url: URL) throws { let handle = try FileHandle(forUpdating: url) do { try handle.synchronize() try handle.close() } catch { try? handle.close() throw error } } } /// 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 @Published var statusMessage: String = "正在准备" @Published var errorMessage: String? // MARK: - Private Properties private var audioRecorder: AVAudioRecorder? private var timer: Timer? private let timerInterval: TimeInterval = 0.05 private let maxSampleCount = 200 private var interruptionState = AudioInterruptionRecoveryState() 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 statusMessage = "正在准备" errorMessage = nil interruptionState.reset() // Request Permission AVAudioApplication.requestRecordPermission { [weak self] granted in guard let self else { return } if granted { DispatchQueue.main.async { self.setupAndRecord() } } else { DispatchQueue.main.async { self.statusMessage = "无法开始录音" self.errorMessage = "未获得麦克风权限,请在系统设置中允许访问。" } 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.statusMessage = "正在记录" self.startTimer() print("[RealAudioRecorder] Recording started: \(fileURL.lastPathComponent)") } else { try? session.setActive(false, options: .notifyOthersOnDeactivation) statusMessage = "无法开始录音" errorMessage = "录音器准备失败,请检查当前音频设备后重试。" print("[RealAudioRecorder] Failed to prepare or start recording") } } catch { statusMessage = "无法开始录音" errorMessage = "录音启动失败:\(error.localizedDescription)" print("[RealAudioRecorder] Setup failed: \(error.localizedDescription)") } } func stopRecording() { if case .failure(let error) = stopRecordingAndProtect() { errorMessage = "录音已停止,但写盘保护失败:\(error.localizedDescription)" } } func stopRecording(completion: @escaping (Result) -> Void) { completion(stopRecordingAndProtect()) } func pauseRecording() { guard isRecording else { return } interruptionState.cancelAutomaticResume() audioRecorder?.pause() captureLatestRecorderTime() isPaused = true currentAmplitude = 0 statusMessage = "已暂停" invalidateTimer() } func resumeRecording() { guard isRecording else { return } guard !interruptionState.isInterrupted else { statusMessage = "系统音频仍在占用,等待中断结束" return } interruptionState.cancelAutomaticResume() _ = resumePausedRecording() } // MARK: - Private Helpers private func stopRecordingAndProtect() -> Result { if audioRecorder != nil { captureLatestRecorderTime() invalidateTimer() audioRecorder?.stop() audioRecorder = nil isRecording = false isPaused = false currentAmplitude = 0 statusMessage = "已保存" interruptionState.reset() try? AVAudioSession.sharedInstance().setActive( false, options: .notifyOthersOnDeactivation ) print("[RealAudioRecorder] Recording stopped. File saved.") } guard let outputFileURL else { return .success(nil) } do { try AudioRecordingWriteProtector.synchronizeFile(at: outputFileURL) return .success(outputFileURL) } catch { return .failure(error) } } @discardableResult private func resumePausedRecording() -> Bool { do { try AVAudioSession.sharedInstance().setActive(true) guard audioRecorder?.record() == true else { statusMessage = "恢复录音失败" errorMessage = "系统音频已恢复,但录音器未能继续,请手动重试。" print("[RealAudioRecorder] Failed to resume recording") return false } isPaused = false statusMessage = "正在记录" errorMessage = nil startTimer() return true } catch { statusMessage = "恢复录音失败" errorMessage = "重新激活音频会话失败:\(error.localizedDescription)" print("[RealAudioRecorder] Failed to reactivate audio session: \(error.localizedDescription)") return false } } private func captureLatestRecorderTime() { guard let recorder = audioRecorder else { return } elapsedTime = max(elapsedTime, recorder.currentTime) } private func protectInterruptedRecordingTail() { guard let outputFileURL else { return } do { try AudioRecordingWriteProtector.synchronizeFile(at: outputFileURL) statusMessage = "系统中断,已保护已录内容" print("[RealAudioRecorder] Interrupted recording bytes synchronized.") } catch { statusMessage = "系统中断,录音已暂停" errorMessage = "录音尾段写盘保护失败:\(error.localizedDescription)" print("[RealAudioRecorder] Failed to synchronize interrupted recording: \(error.localizedDescription)") } } 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: guard interruptionState.interruptionBegan( isRecording: isRecording, isPaused: isPaused ) else { return } captureLatestRecorderTime() audioRecorder?.pause() isPaused = true currentAmplitude = 0 invalidateTimer() statusMessage = "系统中断,正在保护已录内容" protectInterruptedRecordingTail() print("[RealAudioRecorder] Audio session interrupted. Recording protected and automatically paused.") case .ended: let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0 let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) let shouldResume = interruptionState.interruptionEnded( systemRecommendsResume: options.contains(.shouldResume) ) if shouldResume, isRecording, isPaused { if resumePausedRecording() { print("[RealAudioRecorder] Interruption ended. Recording automatically resumed.") } } else if isRecording, isPaused { statusMessage = "系统中断已结束,请手动继续录音" } @unknown default: break } } deinit { NotificationCenter.default.removeObserver(self) invalidateTimer() audioRecorder?.stop() if let outputFileURL { try? AudioRecordingWriteProtector.synchronizeFile(at: outputFileURL) } } } // 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 } } }