RealAudioRecorder.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  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. invalidateTimer()
  98. }
  99. func resumeRecording() {
  100. guard isRecording else { return }
  101. try? AVAudioSession.sharedInstance().setActive(true)
  102. audioRecorder?.record()
  103. isPaused = false
  104. startTimer()
  105. }
  106. // MARK: - Private Helpers
  107. private func startTimer() {
  108. invalidateTimer()
  109. timer = Timer.scheduledTimer(withTimeInterval: timerInterval, repeats: true) { [weak self] _ in
  110. self?.tick()
  111. }
  112. if let timer {
  113. RunLoop.current.add(timer, forMode: .common)
  114. }
  115. }
  116. private func invalidateTimer() {
  117. timer?.invalidate()
  118. timer = nil
  119. }
  120. private func tick() {
  121. guard let recorder = audioRecorder, recorder.isRecording else { return }
  122. // Update metering
  123. recorder.updateMeters()
  124. elapsedTime = recorder.currentTime
  125. // Get average decibels for channel 0
  126. let power = recorder.averagePower(forChannel: 0)
  127. // Map decibels (-50.0 dB to 0.0 dB) to 0.0 - 1.0 amplitude
  128. let minDb: Float = -50.0
  129. let level: Float
  130. if power < minDb {
  131. level = 0
  132. } else if power >= 0 {
  133. level = 1
  134. } else {
  135. level = (power - minDb) / -minDb
  136. }
  137. currentAmplitude = level
  138. waveformSamples.append(level)
  139. if waveformSamples.count > maxSampleCount {
  140. waveformSamples.removeFirst(waveformSamples.count - maxSampleCount)
  141. }
  142. }
  143. @objc private func handleInterruption(notification: Notification) {
  144. guard let userInfo = notification.userInfo,
  145. let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
  146. let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
  147. return
  148. }
  149. switch type {
  150. case .began:
  151. DispatchQueue.main.async { [weak self] in
  152. guard let self else { return }
  153. if self.isRecording && !self.isPaused {
  154. self.pauseRecording()
  155. print("[RealAudioRecorder] Audio session interrupted. Recording automatically paused.")
  156. }
  157. }
  158. case .ended:
  159. guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
  160. let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
  161. if options.contains(.shouldResume) {
  162. DispatchQueue.main.async { [weak self] in
  163. guard let self else { return }
  164. if self.isRecording && self.isPaused {
  165. self.resumeRecording()
  166. print("[RealAudioRecorder] Interruption ended. Recording automatically resumed.")
  167. }
  168. }
  169. }
  170. @unknown default:
  171. break
  172. }
  173. }
  174. deinit {
  175. NotificationCenter.default.removeObserver(self)
  176. invalidateTimer()
  177. audioRecorder?.stop()
  178. }
  179. }
  180. // MARK: - AudioMerger
  181. /// Utility struct to merge existing audio recording file with newly recorded audio segment.
  182. struct AudioMerger {
  183. /// Merges an existing audio file and a newly recorded audio segment into a single unified .m4a file.
  184. /// Uses modern async/await AVFoundation APIs to ensure tracks and durations are fully loaded
  185. /// before building the composition, preventing empty-track or indefinite-duration bugs.
  186. /// - Parameters:
  187. /// - firstURL: The original audio file URL (can be nil or non-existent).
  188. /// - secondURL: The newly recorded audio segment URL.
  189. /// - Returns: The resulting merged (or fallback) file URL.
  190. @MainActor
  191. static func mergeAudioFiles(firstURL: URL?, secondURL: URL) async -> URL {
  192. let resolvedFirst = AudioPathHelper.resolveURL(for: firstURL?.path)
  193. let resolvedSecond = AudioPathHelper.resolveURL(for: secondURL.path) ?? secondURL
  194. guard let validFirstURL = resolvedFirst else {
  195. return resolvedSecond
  196. }
  197. guard FileManager.default.fileExists(atPath: resolvedSecond.path) else {
  198. return validFirstURL
  199. }
  200. let composition = AVMutableComposition()
  201. guard let compositionTrack = composition.addMutableTrack(
  202. withMediaType: .audio,
  203. preferredTrackID: kCMPersistentTrackID_Invalid
  204. ) else {
  205. print("[AudioMerger] Failed to create composition audio track")
  206. return resolvedSecond
  207. }
  208. let asset1 = AVURLAsset(url: validFirstURL)
  209. let asset2 = AVURLAsset(url: resolvedSecond)
  210. do {
  211. // Load durations and tracks asynchronously to ensure they are ready
  212. let duration1 = try await asset1.load(.duration)
  213. let duration2 = try await asset2.load(.duration)
  214. let tracks1 = try await asset1.loadTracks(withMediaType: .audio)
  215. let tracks2 = try await asset2.loadTracks(withMediaType: .audio)
  216. if let track1 = tracks1.first {
  217. try compositionTrack.insertTimeRange(
  218. CMTimeRange(start: .zero, duration: duration1),
  219. of: track1,
  220. at: .zero
  221. )
  222. print("[AudioMerger] Inserted first track: \(CMTimeGetSeconds(duration1))s")
  223. } else {
  224. print("[AudioMerger] Warning: No audio track found in first asset")
  225. }
  226. // Insert second track right after the first
  227. let insertionPoint = compositionTrack.timeRange.duration
  228. if let track2 = tracks2.first {
  229. try compositionTrack.insertTimeRange(
  230. CMTimeRange(start: .zero, duration: duration2),
  231. of: track2,
  232. at: insertionPoint
  233. )
  234. print("[AudioMerger] Inserted second track: \(CMTimeGetSeconds(duration2))s at offset \(CMTimeGetSeconds(insertionPoint))s")
  235. } else {
  236. print("[AudioMerger] Warning: No audio track found in second asset")
  237. }
  238. } catch {
  239. print("[AudioMerger] Error loading/inserting tracks: \(error.localizedDescription)")
  240. return resolvedSecond
  241. }
  242. // Export merged composition
  243. let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  244. let mergedURL = documents.appendingPathComponent("merged_\(UUID().uuidString).m4a")
  245. guard let exportSession = AVAssetExportSession(
  246. asset: composition,
  247. presetName: AVAssetExportPresetAppleM4A
  248. ) else {
  249. print("[AudioMerger] Failed to create AVAssetExportSession")
  250. return resolvedSecond
  251. }
  252. exportSession.outputURL = mergedURL
  253. exportSession.outputFileType = .m4a
  254. await exportSession.export()
  255. switch exportSession.status {
  256. case .completed:
  257. let mergedDuration = try? await AVURLAsset(url: mergedURL).load(.duration)
  258. let mergedSeconds = mergedDuration.map(CMTimeGetSeconds) ?? 0
  259. print("[AudioMerger] Successfully merged audio to: \(mergedURL.lastPathComponent) (\(mergedSeconds)s)")
  260. return mergedURL
  261. default:
  262. print("[AudioMerger] Audio export failed: \(String(describing: exportSession.error))")
  263. return resolvedSecond
  264. }
  265. }
  266. }