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``. @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, emitting ``CapturingEvent/interrupted`` through ``events`` /// when the system began interrupting an ongoing recording. The end of an interruption is deliberately ignored: the capture is /// never resumed without the user asking for it. /// /// - Parameter notification: The interruption notification posted by the audio session. func handleInterruption( _ notification: Notification ) { guard recorder != nil, let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, AVAudioSession.InterruptionType(rawValue: typeValue) == .began else { return } continuation.yield(.interrupted) } } #endif // 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") } }