RealAudioRecorder.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. import Foundation
  2. import Combine
  3. import AVFoundation
  4. /// Tracks whether a recording was actively running when a system interruption
  5. /// began. Keeping this separate from `isPaused` prevents a user-paused
  6. /// recording from being resumed automatically after a phone call or alarm.
  7. struct AudioInterruptionRecoveryState {
  8. private(set) var isInterrupted = false
  9. private var shouldResumeRecording = false
  10. mutating func interruptionBegan(isRecording: Bool, isPaused: Bool) -> Bool {
  11. guard !isInterrupted else { return false }
  12. isInterrupted = true
  13. shouldResumeRecording = isRecording && !isPaused
  14. return shouldResumeRecording
  15. }
  16. mutating func interruptionEnded(systemRecommendsResume: Bool) -> Bool {
  17. guard isInterrupted else { return false }
  18. isInterrupted = false
  19. defer { shouldResumeRecording = false }
  20. return shouldResumeRecording && systemRecommendsResume
  21. }
  22. mutating func cancelAutomaticResume() {
  23. shouldResumeRecording = false
  24. }
  25. mutating func reset() {
  26. isInterrupted = false
  27. shouldResumeRecording = false
  28. }
  29. }
  30. /// Forces the bytes already emitted by `AVAudioRecorder` through the file
  31. /// system cache. `pause()`/`stop()` remains responsible for flushing the AAC
  32. /// encoder; this is an additional persistence fallback for interruptions.
  33. enum AudioRecordingWriteProtector {
  34. static func synchronizeFile(at url: URL) throws {
  35. let handle = try FileHandle(forUpdating: url)
  36. do {
  37. try handle.synchronize()
  38. try handle.close()
  39. } catch {
  40. try? handle.close()
  41. throw error
  42. }
  43. }
  44. }
  45. /// Real audio recorder using AVAudioRecorder for production recording.
  46. /// Requests microphone permissions, configures AVAudioSession, and outputs AAC (.m4a) files.
  47. final class RealAudioRecorder: AudioRecorderProtocol {
  48. // MARK: - Published Properties
  49. @Published var isRecording: Bool = false
  50. @Published var elapsedTime: TimeInterval = 0
  51. @Published var currentAmplitude: Float = 0
  52. @Published var waveformSamples: [Float] = []
  53. @Published var outputFileURL: URL? = nil
  54. @Published var isPaused: Bool = false
  55. @Published var statusMessage: String = "正在准备"
  56. @Published var errorMessage: String?
  57. // MARK: - Private Properties
  58. private var audioRecorder: AVAudioRecorder?
  59. private var timer: Timer?
  60. private let timerInterval: TimeInterval = 0.05
  61. private let maxSampleCount = 200
  62. private var interruptionState = AudioInterruptionRecoveryState()
  63. init() {
  64. NotificationCenter.default.addObserver(
  65. self,
  66. selector: #selector(handleInterruption),
  67. name: AVAudioSession.interruptionNotification,
  68. object: AVAudioSession.sharedInstance()
  69. )
  70. }
  71. // MARK: - AudioRecorderProtocol
  72. func startRecording() {
  73. guard !isRecording else { return }
  74. // Reset state
  75. elapsedTime = 0
  76. currentAmplitude = 0
  77. waveformSamples = []
  78. outputFileURL = nil
  79. statusMessage = "正在准备"
  80. errorMessage = nil
  81. interruptionState.reset()
  82. // Request Permission
  83. AVAudioApplication.requestRecordPermission { [weak self] granted in
  84. guard let self else { return }
  85. if granted {
  86. DispatchQueue.main.async {
  87. self.setupAndRecord()
  88. }
  89. } else {
  90. DispatchQueue.main.async {
  91. self.statusMessage = "无法开始录音"
  92. self.errorMessage = "未获得麦克风权限,请在系统设置中允许访问。"
  93. }
  94. print("[RealAudioRecorder] Microphone permission denied")
  95. }
  96. }
  97. }
  98. private func setupAndRecord() {
  99. let session = AVAudioSession.sharedInstance()
  100. do {
  101. // Set up audio session category and mode for recording, ducking other apps
  102. try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothHFP, .duckOthers])
  103. try session.setActive(true)
  104. // Create target file URL
  105. let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  106. let filename = "recording_\(UUID().uuidString).m4a"
  107. let fileURL = documents.appendingPathComponent(filename)
  108. self.outputFileURL = fileURL
  109. // Configure AAC recorder settings
  110. let settings: [String: Any] = [
  111. AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
  112. AVSampleRateKey: 44100.0,
  113. AVNumberOfChannelsKey: 1,
  114. AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
  115. ]
  116. let recorder = try AVAudioRecorder(url: fileURL, settings: settings)
  117. recorder.isMeteringEnabled = true
  118. if recorder.prepareToRecord(), recorder.record() {
  119. self.audioRecorder = recorder
  120. self.isRecording = true
  121. self.isPaused = false
  122. self.statusMessage = "正在记录"
  123. self.startTimer()
  124. print("[RealAudioRecorder] Recording started: \(fileURL.lastPathComponent)")
  125. } else {
  126. try? session.setActive(false, options: .notifyOthersOnDeactivation)
  127. statusMessage = "无法开始录音"
  128. errorMessage = "录音器准备失败,请检查当前音频设备后重试。"
  129. print("[RealAudioRecorder] Failed to prepare or start recording")
  130. }
  131. } catch {
  132. statusMessage = "无法开始录音"
  133. errorMessage = "录音启动失败:\(error.localizedDescription)"
  134. print("[RealAudioRecorder] Setup failed: \(error.localizedDescription)")
  135. }
  136. }
  137. func stopRecording() {
  138. if case .failure(let error) = stopRecordingAndProtect() {
  139. errorMessage = "录音已停止,但写盘保护失败:\(error.localizedDescription)"
  140. }
  141. }
  142. func stopRecording(completion: @escaping (Result<URL?, Error>) -> Void) {
  143. completion(stopRecordingAndProtect())
  144. }
  145. func pauseRecording() {
  146. guard isRecording else { return }
  147. interruptionState.cancelAutomaticResume()
  148. audioRecorder?.pause()
  149. captureLatestRecorderTime()
  150. isPaused = true
  151. currentAmplitude = 0
  152. statusMessage = "已暂停"
  153. invalidateTimer()
  154. }
  155. func resumeRecording() {
  156. guard isRecording else { return }
  157. guard !interruptionState.isInterrupted else {
  158. statusMessage = "系统音频仍在占用,等待中断结束"
  159. return
  160. }
  161. interruptionState.cancelAutomaticResume()
  162. _ = resumePausedRecording()
  163. }
  164. // MARK: - Private Helpers
  165. private func stopRecordingAndProtect() -> Result<URL?, Error> {
  166. if audioRecorder != nil {
  167. captureLatestRecorderTime()
  168. invalidateTimer()
  169. audioRecorder?.stop()
  170. audioRecorder = nil
  171. isRecording = false
  172. isPaused = false
  173. currentAmplitude = 0
  174. statusMessage = "已保存"
  175. interruptionState.reset()
  176. try? AVAudioSession.sharedInstance().setActive(
  177. false,
  178. options: .notifyOthersOnDeactivation
  179. )
  180. print("[RealAudioRecorder] Recording stopped. File saved.")
  181. }
  182. guard let outputFileURL else { return .success(nil) }
  183. do {
  184. try AudioRecordingWriteProtector.synchronizeFile(at: outputFileURL)
  185. return .success(outputFileURL)
  186. } catch {
  187. return .failure(error)
  188. }
  189. }
  190. @discardableResult
  191. private func resumePausedRecording() -> Bool {
  192. do {
  193. try AVAudioSession.sharedInstance().setActive(true)
  194. guard audioRecorder?.record() == true else {
  195. statusMessage = "恢复录音失败"
  196. errorMessage = "系统音频已恢复,但录音器未能继续,请手动重试。"
  197. print("[RealAudioRecorder] Failed to resume recording")
  198. return false
  199. }
  200. isPaused = false
  201. statusMessage = "正在记录"
  202. errorMessage = nil
  203. startTimer()
  204. return true
  205. } catch {
  206. statusMessage = "恢复录音失败"
  207. errorMessage = "重新激活音频会话失败:\(error.localizedDescription)"
  208. print("[RealAudioRecorder] Failed to reactivate audio session: \(error.localizedDescription)")
  209. return false
  210. }
  211. }
  212. private func captureLatestRecorderTime() {
  213. guard let recorder = audioRecorder else { return }
  214. elapsedTime = max(elapsedTime, recorder.currentTime)
  215. }
  216. private func protectInterruptedRecordingTail() {
  217. guard let outputFileURL else { return }
  218. do {
  219. try AudioRecordingWriteProtector.synchronizeFile(at: outputFileURL)
  220. statusMessage = "系统中断,已保护已录内容"
  221. print("[RealAudioRecorder] Interrupted recording bytes synchronized.")
  222. } catch {
  223. statusMessage = "系统中断,录音已暂停"
  224. errorMessage = "录音尾段写盘保护失败:\(error.localizedDescription)"
  225. print("[RealAudioRecorder] Failed to synchronize interrupted recording: \(error.localizedDescription)")
  226. }
  227. }
  228. private func startTimer() {
  229. invalidateTimer()
  230. timer = Timer.scheduledTimer(withTimeInterval: timerInterval, repeats: true) { [weak self] _ in
  231. self?.tick()
  232. }
  233. if let timer {
  234. RunLoop.current.add(timer, forMode: .common)
  235. }
  236. }
  237. private func invalidateTimer() {
  238. timer?.invalidate()
  239. timer = nil
  240. }
  241. private func tick() {
  242. guard let recorder = audioRecorder, recorder.isRecording else { return }
  243. // Update metering
  244. recorder.updateMeters()
  245. elapsedTime = recorder.currentTime
  246. // Get average decibels for channel 0
  247. let power = recorder.averagePower(forChannel: 0)
  248. // Keep live and playback waveform levels on the same scale.
  249. let level = AudioLevelNormalizer.normalizedLevel(decibels: power)
  250. currentAmplitude = level
  251. waveformSamples.append(level)
  252. if waveformSamples.count > maxSampleCount {
  253. waveformSamples.removeFirst(waveformSamples.count - maxSampleCount)
  254. }
  255. }
  256. @objc private func handleInterruption(notification: Notification) {
  257. guard let userInfo = notification.userInfo,
  258. let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
  259. let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
  260. return
  261. }
  262. switch type {
  263. case .began:
  264. guard interruptionState.interruptionBegan(
  265. isRecording: isRecording,
  266. isPaused: isPaused
  267. ) else {
  268. return
  269. }
  270. captureLatestRecorderTime()
  271. audioRecorder?.pause()
  272. isPaused = true
  273. currentAmplitude = 0
  274. invalidateTimer()
  275. statusMessage = "系统中断,正在保护已录内容"
  276. protectInterruptedRecordingTail()
  277. print("[RealAudioRecorder] Audio session interrupted. Recording protected and automatically paused.")
  278. case .ended:
  279. let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
  280. let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
  281. let shouldResume = interruptionState.interruptionEnded(
  282. systemRecommendsResume: options.contains(.shouldResume)
  283. )
  284. if shouldResume, isRecording, isPaused {
  285. if resumePausedRecording() {
  286. print("[RealAudioRecorder] Interruption ended. Recording automatically resumed.")
  287. }
  288. } else if isRecording, isPaused {
  289. statusMessage = "系统中断已结束,请手动继续录音"
  290. }
  291. @unknown default:
  292. break
  293. }
  294. }
  295. deinit {
  296. NotificationCenter.default.removeObserver(self)
  297. invalidateTimer()
  298. audioRecorder?.stop()
  299. if let outputFileURL {
  300. try? AudioRecordingWriteProtector.synchronizeFile(at: outputFileURL)
  301. }
  302. }
  303. }
  304. // MARK: - AudioMerger
  305. /// Utility struct to merge existing audio recording file with newly recorded audio segment.
  306. struct AudioMerger {
  307. /// Merges an existing audio file and a newly recorded audio segment into a single unified .m4a file.
  308. /// Uses modern async/await AVFoundation APIs to ensure tracks and durations are fully loaded
  309. /// before building the composition, preventing empty-track or indefinite-duration bugs.
  310. /// - Parameters:
  311. /// - firstURL: The original audio file URL (can be nil or non-existent).
  312. /// - secondURL: The newly recorded audio segment URL.
  313. /// - Returns: The resulting merged (or fallback) file URL.
  314. @MainActor
  315. static func mergeAudioFiles(firstURL: URL?, secondURL: URL) async -> URL {
  316. let resolvedFirst = AudioPathHelper.resolveURL(for: firstURL?.path)
  317. let resolvedSecond = AudioPathHelper.resolveURL(for: secondURL.path) ?? secondURL
  318. guard let validFirstURL = resolvedFirst else {
  319. return resolvedSecond
  320. }
  321. guard FileManager.default.fileExists(atPath: resolvedSecond.path) else {
  322. return validFirstURL
  323. }
  324. let composition = AVMutableComposition()
  325. guard let compositionTrack = composition.addMutableTrack(
  326. withMediaType: .audio,
  327. preferredTrackID: kCMPersistentTrackID_Invalid
  328. ) else {
  329. print("[AudioMerger] Failed to create composition audio track")
  330. return resolvedSecond
  331. }
  332. let asset1 = AVURLAsset(url: validFirstURL)
  333. let asset2 = AVURLAsset(url: resolvedSecond)
  334. do {
  335. // Load durations and tracks asynchronously to ensure they are ready
  336. let duration1 = try await asset1.load(.duration)
  337. let duration2 = try await asset2.load(.duration)
  338. let tracks1 = try await asset1.loadTracks(withMediaType: .audio)
  339. let tracks2 = try await asset2.loadTracks(withMediaType: .audio)
  340. if let track1 = tracks1.first {
  341. try compositionTrack.insertTimeRange(
  342. CMTimeRange(start: .zero, duration: duration1),
  343. of: track1,
  344. at: .zero
  345. )
  346. print("[AudioMerger] Inserted first track: \(CMTimeGetSeconds(duration1))s")
  347. } else {
  348. print("[AudioMerger] Warning: No audio track found in first asset")
  349. }
  350. // Insert second track right after the first
  351. let insertionPoint = compositionTrack.timeRange.duration
  352. if let track2 = tracks2.first {
  353. try compositionTrack.insertTimeRange(
  354. CMTimeRange(start: .zero, duration: duration2),
  355. of: track2,
  356. at: insertionPoint
  357. )
  358. print("[AudioMerger] Inserted second track: \(CMTimeGetSeconds(duration2))s at offset \(CMTimeGetSeconds(insertionPoint))s")
  359. } else {
  360. print("[AudioMerger] Warning: No audio track found in second asset")
  361. }
  362. } catch {
  363. print("[AudioMerger] Error loading/inserting tracks: \(error.localizedDescription)")
  364. return resolvedSecond
  365. }
  366. // Export merged composition
  367. let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  368. let mergedURL = documents.appendingPathComponent("merged_\(UUID().uuidString).m4a")
  369. guard let exportSession = AVAssetExportSession(
  370. asset: composition,
  371. presetName: AVAssetExportPresetAppleM4A
  372. ) else {
  373. print("[AudioMerger] Failed to create AVAssetExportSession")
  374. return resolvedSecond
  375. }
  376. exportSession.outputURL = mergedURL
  377. exportSession.outputFileType = .m4a
  378. await exportSession.export()
  379. switch exportSession.status {
  380. case .completed:
  381. let mergedDuration = try? await AVURLAsset(url: mergedURL).load(.duration)
  382. let mergedSeconds = mergedDuration.map(CMTimeGetSeconds) ?? 0
  383. print("[AudioMerger] Successfully merged audio to: \(mergedURL.lastPathComponent) (\(mergedSeconds)s)")
  384. return mergedURL
  385. default:
  386. print("[AudioMerger] Audio export failed: \(String(describing: exportSession.error))")
  387. return resolvedSecond
  388. }
  389. }
  390. }