RealAudioRecorder.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. import Foundation
  2. import Combine
  3. import AVFoundation
  4. /// Real audio recorder using AVAudioRecorder for production recording.
  5. /// Requests microphone permissions, configures AVAudioSession, and outputs AAC (.m4a) files.
  6. final class RealAudioRecorder: AudioRecorderProtocol {
  7. // MARK: - Published Properties
  8. @Published var isRecording: Bool = false
  9. @Published var elapsedTime: TimeInterval = 0
  10. @Published var currentAmplitude: Float = 0
  11. @Published var waveformSamples: [Float] = []
  12. @Published var outputFileURL: URL? = nil
  13. @Published var isPaused: Bool = false
  14. // MARK: - Private Properties
  15. private var audioRecorder: AVAudioRecorder?
  16. private var timer: Timer?
  17. private let timerInterval: TimeInterval = 0.05
  18. private let maxSampleCount = 200
  19. init() {
  20. NotificationCenter.default.addObserver(
  21. self,
  22. selector: #selector(handleInterruption),
  23. name: AVAudioSession.interruptionNotification,
  24. object: AVAudioSession.sharedInstance()
  25. )
  26. }
  27. // MARK: - AudioRecorderProtocol
  28. func startRecording() {
  29. guard !isRecording else { return }
  30. // Reset state
  31. elapsedTime = 0
  32. currentAmplitude = 0
  33. waveformSamples = []
  34. outputFileURL = nil
  35. // Request Permission
  36. AVAudioApplication.requestRecordPermission { [weak self] granted in
  37. guard let self else { return }
  38. if granted {
  39. DispatchQueue.main.async {
  40. self.setupAndRecord()
  41. }
  42. } else {
  43. print("[RealAudioRecorder] Microphone permission denied")
  44. }
  45. }
  46. }
  47. private func setupAndRecord() {
  48. let session = AVAudioSession.sharedInstance()
  49. do {
  50. // Set up audio session category and mode for recording, ducking other apps
  51. try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothHFP, .duckOthers])
  52. try session.setActive(true)
  53. // Create target file URL
  54. let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  55. let filename = "recording_\(UUID().uuidString).m4a"
  56. let fileURL = documents.appendingPathComponent(filename)
  57. self.outputFileURL = fileURL
  58. // Configure AAC recorder settings
  59. let settings: [String: Any] = [
  60. AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
  61. AVSampleRateKey: 44100.0,
  62. AVNumberOfChannelsKey: 1,
  63. AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
  64. ]
  65. let recorder = try AVAudioRecorder(url: fileURL, settings: settings)
  66. recorder.isMeteringEnabled = true
  67. if recorder.prepareToRecord() {
  68. recorder.record()
  69. self.audioRecorder = recorder
  70. self.isRecording = true
  71. self.isPaused = false
  72. self.startTimer()
  73. print("[RealAudioRecorder] Recording started: \(fileURL.lastPathComponent)")
  74. } else {
  75. print("[RealAudioRecorder] Prepare to record failed")
  76. }
  77. } catch {
  78. print("[RealAudioRecorder] Setup failed: \(error.localizedDescription)")
  79. }
  80. }
  81. func stopRecording() {
  82. guard isRecording else { return }
  83. invalidateTimer()
  84. audioRecorder?.stop()
  85. audioRecorder = nil
  86. isRecording = false
  87. isPaused = false
  88. currentAmplitude = 0
  89. // Deactivate audio session
  90. try? AVAudioSession.sharedInstance().setActive(false)
  91. print("[RealAudioRecorder] Recording stopped. File saved.")
  92. }
  93. func pauseRecording() {
  94. guard isRecording else { return }
  95. audioRecorder?.pause()
  96. isPaused = true
  97. currentAmplitude = 0
  98. invalidateTimer()
  99. }
  100. func resumeRecording() {
  101. guard isRecording else { return }
  102. try? AVAudioSession.sharedInstance().setActive(true)
  103. audioRecorder?.record()
  104. isPaused = false
  105. startTimer()
  106. }
  107. // MARK: - Private Helpers
  108. private func startTimer() {
  109. invalidateTimer()
  110. timer = Timer.scheduledTimer(withTimeInterval: timerInterval, repeats: true) { [weak self] _ in
  111. self?.tick()
  112. }
  113. if let timer {
  114. RunLoop.current.add(timer, forMode: .common)
  115. }
  116. }
  117. private func invalidateTimer() {
  118. timer?.invalidate()
  119. timer = nil
  120. }
  121. private func tick() {
  122. guard let recorder = audioRecorder, recorder.isRecording else { return }
  123. // Update metering
  124. recorder.updateMeters()
  125. elapsedTime = recorder.currentTime
  126. // Get average decibels for channel 0
  127. let power = recorder.averagePower(forChannel: 0)
  128. // Keep live and playback waveform levels on the same scale.
  129. let level = AudioLevelNormalizer.normalizedLevel(decibels: power)
  130. currentAmplitude = level
  131. waveformSamples.append(level)
  132. if waveformSamples.count > maxSampleCount {
  133. waveformSamples.removeFirst(waveformSamples.count - maxSampleCount)
  134. }
  135. }
  136. @objc private func handleInterruption(notification: Notification) {
  137. guard let userInfo = notification.userInfo,
  138. let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
  139. let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
  140. return
  141. }
  142. switch type {
  143. case .began:
  144. DispatchQueue.main.async { [weak self] in
  145. guard let self else { return }
  146. if self.isRecording && !self.isPaused {
  147. self.pauseRecording()
  148. print("[RealAudioRecorder] Audio session interrupted. Recording automatically paused.")
  149. }
  150. }
  151. case .ended:
  152. guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
  153. let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
  154. if options.contains(.shouldResume) {
  155. DispatchQueue.main.async { [weak self] in
  156. guard let self else { return }
  157. if self.isRecording && self.isPaused {
  158. self.resumeRecording()
  159. print("[RealAudioRecorder] Interruption ended. Recording automatically resumed.")
  160. }
  161. }
  162. }
  163. @unknown default:
  164. break
  165. }
  166. }
  167. deinit {
  168. NotificationCenter.default.removeObserver(self)
  169. invalidateTimer()
  170. audioRecorder?.stop()
  171. }
  172. }
  173. // MARK: - AudioMerger
  174. /// Utility struct to merge existing audio recording file with newly recorded audio segment.
  175. struct AudioMerger {
  176. /// Merges an existing audio file and a newly recorded audio segment into a single unified .m4a file.
  177. /// Uses modern async/await AVFoundation APIs to ensure tracks and durations are fully loaded
  178. /// before building the composition, preventing empty-track or indefinite-duration bugs.
  179. /// - Parameters:
  180. /// - firstURL: The original audio file URL (can be nil or non-existent).
  181. /// - secondURL: The newly recorded audio segment URL.
  182. /// - Returns: The resulting merged (or fallback) file URL.
  183. @MainActor
  184. static func mergeAudioFiles(firstURL: URL?, secondURL: URL) async -> URL {
  185. let resolvedFirst = AudioPathHelper.resolveURL(for: firstURL?.path)
  186. let resolvedSecond = AudioPathHelper.resolveURL(for: secondURL.path) ?? secondURL
  187. guard let validFirstURL = resolvedFirst else {
  188. return resolvedSecond
  189. }
  190. guard FileManager.default.fileExists(atPath: resolvedSecond.path) else {
  191. return validFirstURL
  192. }
  193. let composition = AVMutableComposition()
  194. guard let compositionTrack = composition.addMutableTrack(
  195. withMediaType: .audio,
  196. preferredTrackID: kCMPersistentTrackID_Invalid
  197. ) else {
  198. print("[AudioMerger] Failed to create composition audio track")
  199. return resolvedSecond
  200. }
  201. let asset1 = AVURLAsset(url: validFirstURL)
  202. let asset2 = AVURLAsset(url: resolvedSecond)
  203. do {
  204. // Load durations and tracks asynchronously to ensure they are ready
  205. let duration1 = try await asset1.load(.duration)
  206. let duration2 = try await asset2.load(.duration)
  207. let tracks1 = try await asset1.loadTracks(withMediaType: .audio)
  208. let tracks2 = try await asset2.loadTracks(withMediaType: .audio)
  209. if let track1 = tracks1.first {
  210. try compositionTrack.insertTimeRange(
  211. CMTimeRange(start: .zero, duration: duration1),
  212. of: track1,
  213. at: .zero
  214. )
  215. print("[AudioMerger] Inserted first track: \(CMTimeGetSeconds(duration1))s")
  216. } else {
  217. print("[AudioMerger] Warning: No audio track found in first asset")
  218. }
  219. // Insert second track right after the first
  220. let insertionPoint = compositionTrack.timeRange.duration
  221. if let track2 = tracks2.first {
  222. try compositionTrack.insertTimeRange(
  223. CMTimeRange(start: .zero, duration: duration2),
  224. of: track2,
  225. at: insertionPoint
  226. )
  227. print("[AudioMerger] Inserted second track: \(CMTimeGetSeconds(duration2))s at offset \(CMTimeGetSeconds(insertionPoint))s")
  228. } else {
  229. print("[AudioMerger] Warning: No audio track found in second asset")
  230. }
  231. } catch {
  232. print("[AudioMerger] Error loading/inserting tracks: \(error.localizedDescription)")
  233. return resolvedSecond
  234. }
  235. // Export merged composition
  236. let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  237. let mergedURL = documents.appendingPathComponent("merged_\(UUID().uuidString).m4a")
  238. guard let exportSession = AVAssetExportSession(
  239. asset: composition,
  240. presetName: AVAssetExportPresetAppleM4A
  241. ) else {
  242. print("[AudioMerger] Failed to create AVAssetExportSession")
  243. return resolvedSecond
  244. }
  245. exportSession.outputURL = mergedURL
  246. exportSession.outputFileType = .m4a
  247. await exportSession.export()
  248. switch exportSession.status {
  249. case .completed:
  250. let mergedDuration = try? await AVURLAsset(url: mergedURL).load(.duration)
  251. let mergedSeconds = mergedDuration.map(CMTimeGetSeconds) ?? 0
  252. print("[AudioMerger] Successfully merged audio to: \(mergedURL.lastPathComponent) (\(mergedSeconds)s)")
  253. return mergedURL
  254. default:
  255. print("[AudioMerger] Audio export failed: \(String(describing: exportSession.error))")
  256. return resolvedSecond
  257. }
  258. }
  259. }