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 contents 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. @MainActor public final class AudioCapturing: Capturing { // MARK: Properties /// The recorder that captures the audio from the microphone into a temporary file. private var recorder: AVAudioRecorder? // MARK: Initializers /// Creates an audio recording service. public init() {} // MARK: Methods /// Starts a new audio recording from the microphone. /// /// - 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 let recorder = try AVAudioRecorder( url: Constant.File.url, settings: Constant.Audio.settings ) guard recorder.record() else { throw AudioCapturingError.captureNotStarted } self.recorder = recorder } /// 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, deleting the temporary audio file after its contents are read. /// /// - Returns: The audio captured since the recording started. /// - Throws: ``AudioCapturingError/noOngoingRecording`` when no recording is in progress, or any error thrown /// while reading the recorded audio file. public func stop() async throws -> Data { guard let recorder else { throw AudioCapturingError.noOngoingRecording } recorder.stop() self.recorder = nil #if os(iOS) || os(visionOS) try? AVAudioSession.sharedInstance().setActive(false) #endif defer { try? FileManager.default.removeItem(at: Constant.File.url) } return try Data(contentsOf: Constant.File.url) } } // MARK: - Errors /// The errors thrown by ``AudioCapturing``. public enum AudioCapturingError: Error { /// The recorder failed to start or resume the audio capture. case captureNotStarted /// No recording is in progress. case noOngoingRecording /// The user denied the app permission to record audio from the microphone. case permissionNotGranted } // 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") } }