| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449 |
- 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<URL?, Error>) -> 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<URL?, Error> {
- 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
- }
- }
- }
|