| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211 |
- import Foundation
- import Combine
- import AVFoundation
- /// 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
-
- // MARK: - Private Properties
-
- private var audioRecorder: AVAudioRecorder?
- private var timer: Timer?
- private let timerInterval: TimeInterval = 0.05
- private let maxSampleCount = 200
-
- 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
-
- // Request Permission
- let session = AVAudioSession.sharedInstance()
- session.requestRecordPermission { [weak self] granted in
- guard let self else { return }
- if granted {
- DispatchQueue.main.async {
- self.setupAndRecord()
- }
- } else {
- 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, .allowBluetooth, .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: 12000.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.startTimer()
- print("[RealAudioRecorder] Recording started: \(fileURL.lastPathComponent)")
- } else {
- print("[RealAudioRecorder] Prepare to record failed")
- }
- } catch {
- print("[RealAudioRecorder] Setup failed: \(error.localizedDescription)")
- }
- }
-
- func stopRecording() {
- guard isRecording else { return }
-
- invalidateTimer()
- audioRecorder?.stop()
- audioRecorder = nil
- isRecording = false
- isPaused = false
- currentAmplitude = 0
-
- // Deactivate audio session
- try? AVAudioSession.sharedInstance().setActive(false)
- print("[RealAudioRecorder] Recording stopped. File saved.")
- }
-
- func pauseRecording() {
- guard isRecording else { return }
- audioRecorder?.pause()
- isPaused = true
- invalidateTimer()
- }
-
- func resumeRecording() {
- guard isRecording else { return }
- try? AVAudioSession.sharedInstance().setActive(true)
- audioRecorder?.record()
- isPaused = false
- startTimer()
- }
-
- // MARK: - Private Helpers
-
- 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)
-
- // Map decibels (-50.0 dB to 0.0 dB) to 0.0 - 1.0 amplitude
- let minDb: Float = -50.0
- let level: Float
- if power < minDb {
- level = 0
- } else if power >= 0 {
- level = 1
- } else {
- level = (power - minDb) / -minDb
- }
-
- 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:
- DispatchQueue.main.async { [weak self] in
- guard let self else { return }
- if self.isRecording && !self.isPaused {
- self.pauseRecording()
- print("[RealAudioRecorder] Audio session interrupted. Recording automatically paused.")
- }
- }
- case .ended:
- guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
- let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
- if options.contains(.shouldResume) {
- DispatchQueue.main.async { [weak self] in
- guard let self else { return }
- if self.isRecording && self.isPaused {
- self.resumeRecording()
- print("[RealAudioRecorder] Interruption ended. Recording automatically resumed.")
- }
- }
- }
- @unknown default:
- break
- }
- }
-
- deinit {
- NotificationCenter.default.removeObserver(self)
- invalidateTimer()
- audioRecorder?.stop()
- }
- }
|