RealAudioRecorder.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312
  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. let session = AVAudioSession.sharedInstance()
  37. session.requestRecordPermission { [weak self] granted in
  38. guard let self else { return }
  39. if granted {
  40. DispatchQueue.main.async {
  41. self.setupAndRecord()
  42. }
  43. } else {
  44. print("[RealAudioRecorder] Microphone permission denied")
  45. }
  46. }
  47. }
  48. private func setupAndRecord() {
  49. let session = AVAudioSession.sharedInstance()
  50. do {
  51. // Set up audio session category and mode for recording, ducking other apps
  52. try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetooth, .duckOthers])
  53. try session.setActive(true)
  54. // Create target file URL
  55. let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  56. let filename = "recording_\(UUID().uuidString).m4a"
  57. let fileURL = documents.appendingPathComponent(filename)
  58. self.outputFileURL = fileURL
  59. // Configure AAC recorder settings
  60. let settings: [String: Any] = [
  61. AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
  62. AVSampleRateKey: 44100.0,
  63. AVNumberOfChannelsKey: 1,
  64. AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
  65. ]
  66. let recorder = try AVAudioRecorder(url: fileURL, settings: settings)
  67. recorder.isMeteringEnabled = true
  68. if recorder.prepareToRecord() {
  69. recorder.record()
  70. self.audioRecorder = recorder
  71. self.isRecording = true
  72. self.isPaused = false
  73. self.startTimer()
  74. print("[RealAudioRecorder] Recording started: \(fileURL.lastPathComponent)")
  75. } else {
  76. print("[RealAudioRecorder] Prepare to record failed")
  77. }
  78. } catch {
  79. print("[RealAudioRecorder] Setup failed: \(error.localizedDescription)")
  80. }
  81. }
  82. func stopRecording() {
  83. guard isRecording else { return }
  84. invalidateTimer()
  85. audioRecorder?.stop()
  86. audioRecorder = nil
  87. isRecording = false
  88. isPaused = false
  89. currentAmplitude = 0
  90. // Deactivate audio session
  91. try? AVAudioSession.sharedInstance().setActive(false)
  92. print("[RealAudioRecorder] Recording stopped. File saved.")
  93. }
  94. func pauseRecording() {
  95. guard isRecording else { return }
  96. audioRecorder?.pause()
  97. isPaused = true
  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. // Map decibels (-50.0 dB to 0.0 dB) to 0.0 - 1.0 amplitude
  129. let minDb: Float = -50.0
  130. let level: Float
  131. if power < minDb {
  132. level = 0
  133. } else if power >= 0 {
  134. level = 1
  135. } else {
  136. level = (power - minDb) / -minDb
  137. }
  138. currentAmplitude = level
  139. waveformSamples.append(level)
  140. if waveformSamples.count > maxSampleCount {
  141. waveformSamples.removeFirst(waveformSamples.count - maxSampleCount)
  142. }
  143. }
  144. @objc private func handleInterruption(notification: Notification) {
  145. guard let userInfo = notification.userInfo,
  146. let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
  147. let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
  148. return
  149. }
  150. switch type {
  151. case .began:
  152. DispatchQueue.main.async { [weak self] in
  153. guard let self else { return }
  154. if self.isRecording && !self.isPaused {
  155. self.pauseRecording()
  156. print("[RealAudioRecorder] Audio session interrupted. Recording automatically paused.")
  157. }
  158. }
  159. case .ended:
  160. guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
  161. let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
  162. if options.contains(.shouldResume) {
  163. DispatchQueue.main.async { [weak self] in
  164. guard let self else { return }
  165. if self.isRecording && self.isPaused {
  166. self.resumeRecording()
  167. print("[RealAudioRecorder] Interruption ended. Recording automatically resumed.")
  168. }
  169. }
  170. }
  171. @unknown default:
  172. break
  173. }
  174. }
  175. deinit {
  176. NotificationCenter.default.removeObserver(self)
  177. invalidateTimer()
  178. audioRecorder?.stop()
  179. }
  180. }
  181. // MARK: - AudioMerger
  182. /// Utility struct to merge existing audio recording file with newly recorded audio segment.
  183. struct AudioMerger {
  184. /// Merges an existing audio file and a newly recorded audio segment into a single unified .m4a file.
  185. /// Uses modern async/await AVFoundation APIs to ensure tracks and durations are fully loaded
  186. /// before building the composition, preventing empty-track or indefinite-duration bugs.
  187. /// - Parameters:
  188. /// - firstURL: The original audio file URL (can be nil or non-existent).
  189. /// - secondURL: The newly recorded audio segment URL.
  190. /// - Returns: The resulting merged (or fallback) file URL.
  191. @MainActor
  192. static func mergeAudioFiles(firstURL: URL?, secondURL: URL) async -> URL {
  193. let resolvedFirst = AudioPathHelper.resolveURL(for: firstURL?.path)
  194. let resolvedSecond = AudioPathHelper.resolveURL(for: secondURL.path) ?? secondURL
  195. guard let validFirstURL = resolvedFirst else {
  196. return resolvedSecond
  197. }
  198. guard FileManager.default.fileExists(atPath: resolvedSecond.path) else {
  199. return validFirstURL
  200. }
  201. let composition = AVMutableComposition()
  202. guard let compositionTrack = composition.addMutableTrack(
  203. withMediaType: .audio,
  204. preferredTrackID: kCMPersistentTrackID_Invalid
  205. ) else {
  206. print("[AudioMerger] Failed to create composition audio track")
  207. return resolvedSecond
  208. }
  209. let asset1 = AVURLAsset(url: validFirstURL)
  210. let asset2 = AVURLAsset(url: resolvedSecond)
  211. do {
  212. // Load durations and tracks asynchronously to ensure they are ready
  213. let duration1 = try await asset1.load(.duration)
  214. let duration2 = try await asset2.load(.duration)
  215. let tracks1 = try await asset1.loadTracks(withMediaType: .audio)
  216. let tracks2 = try await asset2.loadTracks(withMediaType: .audio)
  217. if let track1 = tracks1.first {
  218. try compositionTrack.insertTimeRange(
  219. CMTimeRange(start: .zero, duration: duration1),
  220. of: track1,
  221. at: .zero
  222. )
  223. print("[AudioMerger] Inserted first track: \(CMTimeGetSeconds(duration1))s")
  224. } else {
  225. print("[AudioMerger] Warning: No audio track found in first asset")
  226. }
  227. // Insert second track right after the first
  228. let insertionPoint = compositionTrack.timeRange.duration
  229. if let track2 = tracks2.first {
  230. try compositionTrack.insertTimeRange(
  231. CMTimeRange(start: .zero, duration: duration2),
  232. of: track2,
  233. at: insertionPoint
  234. )
  235. print("[AudioMerger] Inserted second track: \(CMTimeGetSeconds(duration2))s at offset \(CMTimeGetSeconds(insertionPoint))s")
  236. } else {
  237. print("[AudioMerger] Warning: No audio track found in second asset")
  238. }
  239. } catch {
  240. print("[AudioMerger] Error loading/inserting tracks: \(error.localizedDescription)")
  241. return resolvedSecond
  242. }
  243. // Export merged composition
  244. let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  245. let mergedURL = documents.appendingPathComponent("merged_\(UUID().uuidString).m4a")
  246. guard let exportSession = AVAssetExportSession(
  247. asset: composition,
  248. presetName: AVAssetExportPresetAppleM4A
  249. ) else {
  250. print("[AudioMerger] Failed to create AVAssetExportSession")
  251. return resolvedSecond
  252. }
  253. exportSession.outputURL = mergedURL
  254. exportSession.outputFileType = .m4a
  255. await exportSession.export()
  256. switch exportSession.status {
  257. case .completed:
  258. let mergedDuration = CMTimeGetSeconds(AVURLAsset(url: mergedURL).duration)
  259. print("[AudioMerger] Successfully merged audio to: \(mergedURL.lastPathComponent) (\(mergedDuration)s)")
  260. return mergedURL
  261. default:
  262. print("[AudioMerger] Audio export failed: \(String(describing: exportSession.error))")
  263. return resolvedSecond
  264. }
  265. }
  266. }