diff --git a/Apps/Attendi/AudioRecordingService.swift b/Apps/Attendi/AudioRecordingService.swift new file mode 100644 index 0000000..a4f9d43 --- /dev/null +++ b/Apps/Attendi/AudioRecordingService.swift @@ -0,0 +1,136 @@ +import AVFoundation +import Recording + +/// The recording service used by the Attendi sample app, which 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 +final class AudioRecordingService: RecordingService { + + // 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. + init() {} + + // MARK: Methods + + /// Starts a new audio recording from the microphone. + /// + /// - Throws: ``AudioRecordingError/permissionNotGranted`` when the user denies the app access to the microphone, + /// ``AudioRecordingError/captureNotStarted`` when the recorder fails to start, or any error thrown while configuring + /// the audio session or creating the recorder. + func start() async throws { + guard await AVAudioApplication.requestRecordPermission() else { + throw AudioRecordingError.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 AudioRecordingError.captureNotStarted + } + + self.recorder = recorder + } + + /// Pauses the ongoing recording. + /// + /// - Throws: ``AudioRecordingError/noOngoingRecording`` when no recording is in progress. + func pause() async throws { + guard let recorder else { + throw AudioRecordingError.noOngoingRecording + } + + recorder.pause() + } + + /// Resumes a paused recording. + /// + /// - Throws: ``AudioRecordingError/noOngoingRecording`` when no recording is in progress, or + /// ``AudioRecordingError/captureNotStarted`` when the recorder fails to resume. + func resume() async throws { + guard let recorder else { + throw AudioRecordingError.noOngoingRecording + } + guard recorder.record() else { + throw AudioRecordingError.captureNotStarted + } + } + + /// Stops the recording, deleting the temporary audio file after its contents are read. + /// + /// - Returns: The audio captured since the recording started. + /// - Throws: ``AudioRecordingError/noOngoingRecording`` when no recording is in progress, or any error thrown + /// while reading the recorded audio file. + func stop() async throws -> Data { + guard let recorder else { + throw AudioRecordingError.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 ``AudioRecordingService``. +enum AudioRecordingError: 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. + 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") + } +} diff --git a/Apps/Attendi/ContentView.swift b/Apps/Attendi/ContentView.swift index 3c432d9..d674be2 100644 --- a/Apps/Attendi/ContentView.swift +++ b/Apps/Attendi/ContentView.swift @@ -8,10 +8,10 @@ struct ContentView: View { // MARK: Body - /// The content of the view: the recording feature's view, attached to the simulated recording and transcribing services. + /// The content of the view: the recording feature's view, attached to the microphone-backed recording service and the simulated transcribing service. var body: some View { RecordingView( - recorder: SimulatedRecordingService(), + recorder: AudioRecordingService(), transcriber: SimulatedTranscribingService() ) } diff --git a/Attendi.xcodeproj/project.pbxproj b/Attendi.xcodeproj/project.pbxproj index 4ffb34c..8cc2fef 100644 --- a/Attendi.xcodeproj/project.pbxproj +++ b/Attendi.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ membershipExceptions = ( Attendi/Assets.xcassets, Attendi/AttendiApp.swift, + Attendi/AudioRecordingService.swift, Attendi/ContentView.swift, ); target = 02870A0C2FF7EB610079EA3A /* Attendi */;