import AVFoundation /// The recording service that captures real audio from the device's microphone. /// /// The service records into a temporary `.m4a` file through an `AVAudioRecorder`, and returns the file's location when the recording stops. /// The user's permission to record is requested before a recording starts and, on the platforms that require it, the shared audio session is /// configured for recording while the capture is in progress. When the system interrupts the session while a recording is in progress — /// because of a phone call or Siri, for example — the service emits ``CapturingEvent/interrupted`` through ``events``, followed by /// ``CapturingEvent/interruptionEnded(shouldResume:)`` once the interruption is over. @MainActor public final class AudioCapturing: Capturing { // MARK: Properties /// The stream of events the service emits outside its method calls. public let events: AsyncStream /// The continuation that feeds ``events``. private let continuation: AsyncStream.Continuation /// The recorder that captures the audio from the microphone into a temporary file. private var recorder: AVAudioRecorder? #if os(iOS) || os(visionOS) /// The task that observes the interruptions of the audio session while the service lives. private var taskInterruptions: Task? #endif // MARK: Initializers /// Creates an audio recording service. public init() { (events, continuation) = AsyncStream.makeStream(of: CapturingEvent.self) #if os(iOS) || os(visionOS) taskInterruptions = Task { [weak self] in let notifications = NotificationCenter.default.notifications( named: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance() ) for await notification in notifications { guard let self else { return } self.handleInterruption(notification) } } #endif } deinit { #if os(iOS) || os(visionOS) taskInterruptions?.cancel() #endif continuation.finish() } // MARK: Methods /// Starts a new audio recording from the microphone. /// /// On the platforms that require an audio session, the session is deactivated again when the recorder fails to start after /// the session was activated. /// /// - Throws: ``AudioCapturingError/permissionNotGranted`` when the user denies the app access to the microphone, /// ``AudioCapturingError/captureNotStarted`` when the recorder fails to start, or any error thrown while configuring /// the audio session or creating the recorder. public func start() async throws { guard await AVAudioApplication.requestRecordPermission() else { throw AudioCapturingError.permissionNotGranted } #if os(iOS) || os(visionOS) let session = AVAudioSession.sharedInstance() try session.setCategory(.record, mode: .default) try session.setActive(true) #endif do { let recorder = try AVAudioRecorder( url: Constant.File.url, settings: Constant.Audio.settings ) guard recorder.record() else { throw AudioCapturingError.captureNotStarted } self.recorder = recorder } catch { #if os(iOS) || os(visionOS) try? AVAudioSession.sharedInstance().setActive(false) #endif throw error } } /// Pauses the ongoing recording. /// /// - Throws: ``AudioCapturingError/noOngoingRecording`` when no recording is in progress. public func pause() async throws { guard let recorder else { throw AudioCapturingError.noOngoingRecording } recorder.pause() } /// Resumes a paused recording. /// /// - Throws: ``AudioCapturingError/noOngoingRecording`` when no recording is in progress, or /// ``AudioCapturingError/captureNotStarted`` when the recorder fails to resume. public func resume() async throws { guard let recorder else { throw AudioCapturingError.noOngoingRecording } guard recorder.record() else { throw AudioCapturingError.captureNotStarted } } /// Stops the recording, handing the temporary audio file over to the caller, which becomes responsible for deleting it. /// /// - Returns: The location of the audio file captured since the recording started. /// - Throws: ``AudioCapturingError/noOngoingRecording`` when no recording is in progress. public func stop() async throws -> URL { guard let recorder else { throw AudioCapturingError.noOngoingRecording } recorder.stop() self.recorder = nil #if os(iOS) || os(visionOS) try? AVAudioSession.sharedInstance().setActive(false) #endif return Constant.File.url } } #if os(iOS) || os(visionOS) // MARK: - Helpers private extension AudioCapturing { /// Handles an interruption notification of the audio session while a recording exists, emitting the event the notification /// describes through ``events``: the beginning of an interruption — which pauses the capture — emits /// ``CapturingEvent/interrupted``, and its end emits ``CapturingEvent/interruptionEnded(shouldResume:)`` with the /// system's hint on whether the capture may resume right away. /// /// - Parameter notification: The interruption notification posted by the audio session. func handleInterruption( _ notification: Notification ) { guard recorder != nil, let event = CapturingEvent(interruption: notification.userInfo) else { return } continuation.yield(event) } } #endif // MARK: - CapturingEvent+Interruptions extension CapturingEvent { /// Creates the event described by the user info of an audio session interruption notification, or `nil` when the user info /// describes no known interruption. /// /// - Parameter userInfo: The user info dictionary of the interruption notification. init?( interruption userInfo: [AnyHashable: Any]? ) { guard let typeValue = userInfo?[Constant.Interruption.keyType] as? UInt else { return nil } switch typeValue { case Constant.Interruption.typeBegan: self = .interrupted case Constant.Interruption.typeEnded: let optionsValue = userInfo?[Constant.Interruption.keyOptions] as? UInt ?? 0 self = .interruptionEnded(shouldResume: optionsValue & Constant.Interruption.optionShouldResume != 0) default: return nil } } } // MARK: - Constants /// The constant values used across the audio recording service. private enum Constant { /// The audio constants. enum Audio { /// The settings of the recorded audio: single-channel AAC at a 44.1 kHz sample rate, encoded in high quality. @MainActor static let settings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVNumberOfChannelsKey: 1, AVSampleRateKey: 44_100.0, AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue, ] } /// The file constants. enum File { /// The location of the temporary file the audio is captured into. static let url = FileManager.default.temporaryDirectory.appending(path: "recording.m4a") } /// The interruption constants, matching AVFoundation's audio session interruption keys and values — mirrored as literals on /// the platforms without an audio session, so the interruption parsing stays compilable, and therefore testable, everywhere. enum Interruption { #if os(iOS) || os(visionOS) /// The user info key of the interruption options. static let keyOptions = AVAudioSessionInterruptionOptionKey /// The user info key of the interruption type. static let keyType = AVAudioSessionInterruptionTypeKey /// The raw value of the interruption option hinting the capture may resume. static let optionShouldResume = AVAudioSession.InterruptionOptions.shouldResume.rawValue /// The raw value of the beginning of an interruption. static let typeBegan = AVAudioSession.InterruptionType.began.rawValue /// The raw value of the end of an interruption. static let typeEnded = AVAudioSession.InterruptionType.ended.rawValue #else /// The user info key of the interruption options. static let keyOptions = "AVAudioSessionInterruptionOptionKey" /// The user info key of the interruption type. static let keyType = "AVAudioSessionInterruptionTypeKey" /// The raw value of the interruption option hinting the capture may resume. static let optionShouldResume: UInt = 1 /// The raw value of the beginning of an interruption. static let typeBegan: UInt = 1 /// The raw value of the end of an interruption. static let typeEnded: UInt = 0 #endif } }