Renamed the RecordingService protocol in the Recording package target as Capturing.
This commit is contained in:
@@ -28,7 +28,7 @@ extension ContentView {
|
||||
|
||||
/// The service that captures the audio from the device's microphone.
|
||||
@ObservationIgnored
|
||||
let recorder: AudioRecordingService
|
||||
let capturer: AudioCapturing
|
||||
|
||||
/// The service that transcribes the recorded audio into text on device.
|
||||
@ObservationIgnored
|
||||
@@ -41,7 +41,7 @@ extension ContentView {
|
||||
init() {
|
||||
self.locale = .current
|
||||
self.locales = []
|
||||
self.recorder = .init()
|
||||
self.capturer = .init()
|
||||
self.transcriber = .init()
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ struct ContentView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
RecordingView(
|
||||
recorder: model.recorder,
|
||||
capturer: model.capturer,
|
||||
transcriber: model.transcriber,
|
||||
locale: $model.locale,
|
||||
) { transcription in
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import Foundation
|
||||
///
|
||||
/// ``RecordingView`` attaches the service to its model at initialization, so the audio capture can be swapped without touching the feature's
|
||||
/// state machine — for example, with a real microphone backend in the app, or with a mock in unit tests.
|
||||
public protocol RecordingService: Sendable {
|
||||
public protocol Capturing: Sendable {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
+15
-15
@@ -6,7 +6,7 @@ import AVFoundation
|
||||
/// 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 AudioRecordingService: RecordingService {
|
||||
public final class AudioCapturing: Capturing {
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
@@ -22,12 +22,12 @@ public final class AudioRecordingService: RecordingService {
|
||||
|
||||
/// 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
|
||||
/// - 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 AudioRecordingError.permissionNotGranted
|
||||
throw AudioCapturingError.permissionNotGranted
|
||||
}
|
||||
|
||||
#if os(iOS) || os(visionOS)
|
||||
@@ -43,7 +43,7 @@ public final class AudioRecordingService: RecordingService {
|
||||
)
|
||||
|
||||
guard recorder.record() else {
|
||||
throw AudioRecordingError.captureNotStarted
|
||||
throw AudioCapturingError.captureNotStarted
|
||||
}
|
||||
|
||||
self.recorder = recorder
|
||||
@@ -51,10 +51,10 @@ public final class AudioRecordingService: RecordingService {
|
||||
|
||||
/// Pauses the ongoing recording.
|
||||
///
|
||||
/// - Throws: ``AudioRecordingError/noOngoingRecording`` when no recording is in progress.
|
||||
/// - Throws: ``AudioCapturingError/noOngoingRecording`` when no recording is in progress.
|
||||
public func pause() async throws {
|
||||
guard let recorder else {
|
||||
throw AudioRecordingError.noOngoingRecording
|
||||
throw AudioCapturingError.noOngoingRecording
|
||||
}
|
||||
|
||||
recorder.pause()
|
||||
@@ -62,25 +62,25 @@ public final class AudioRecordingService: RecordingService {
|
||||
|
||||
/// Resumes a paused recording.
|
||||
///
|
||||
/// - Throws: ``AudioRecordingError/noOngoingRecording`` when no recording is in progress, or
|
||||
/// ``AudioRecordingError/captureNotStarted`` when the recorder fails to resume.
|
||||
/// - 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 AudioRecordingError.noOngoingRecording
|
||||
throw AudioCapturingError.noOngoingRecording
|
||||
}
|
||||
guard recorder.record() else {
|
||||
throw AudioRecordingError.captureNotStarted
|
||||
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: ``AudioRecordingError/noOngoingRecording`` when no recording is in progress, or any error thrown
|
||||
/// - 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 AudioRecordingError.noOngoingRecording
|
||||
throw AudioCapturingError.noOngoingRecording
|
||||
}
|
||||
|
||||
recorder.stop()
|
||||
@@ -102,8 +102,8 @@ public final class AudioRecordingService: RecordingService {
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
/// The errors thrown by ``AudioRecordingService``.
|
||||
public enum AudioRecordingError: Error {
|
||||
/// 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.
|
||||
+1
-1
@@ -4,7 +4,7 @@ import Foundation
|
||||
///
|
||||
/// The service is internal on purpose: it only backs the feature's previews and the default values of its model, and is not part of the
|
||||
/// package's public interface.
|
||||
struct DummyRecordingService: RecordingService {
|
||||
struct DummyCapturing: Capturing {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
@@ -7,7 +7,7 @@ extension RecordingView {
|
||||
///
|
||||
/// The model implements the recording flow as a ``State`` machine: it exposes the visibility and icon of the view's controls for the current state, counts the
|
||||
/// elapsed recording time, and processes the recorded input once it is sent. The audio capture and its transcription are delegated to the
|
||||
/// ``RecordingService`` and ``Transcribing`` attached at initialization; when either of them fails, the model falls back to the not-recording state.
|
||||
/// ``Capturing`` and ``Transcribing`` attached at initialization; when either of them fails, the model falls back to the not-recording state.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Model {
|
||||
@@ -41,7 +41,7 @@ extension RecordingView {
|
||||
|
||||
/// The service that captures the audio from a microphone.
|
||||
@ObservationIgnored
|
||||
private let recorder: any RecordingService
|
||||
private let capturer: any Capturing
|
||||
|
||||
/// The task that updates ``elapsedSeconds`` from the measured recording time once per second while recording.
|
||||
@ObservationIgnored
|
||||
@@ -56,15 +56,15 @@ extension RecordingView {
|
||||
/// Creates a model attached to the given recording and transcribing services.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - recorder: The service that captures the audio from a microphone. Defaults to ``DummyRecordingService``.
|
||||
/// - capturer: The service that captures the audio from a microphone. Defaults to ``DummyCapturing``.
|
||||
/// - transcribe: The callable service that transcribes the recorded audio into text. Defaults to ``DummyTranscribing``.
|
||||
/// - locale: The binding to the locale of the spoken language to transcribe. Defaults to a constant binding to the user's current locale.
|
||||
init(
|
||||
recorder: any RecordingService = DummyRecordingService(),
|
||||
capturer: any Capturing = DummyCapturing(),
|
||||
transcribe: any Transcribing = DummyTranscribing(),
|
||||
locale: Binding<Locale> = .constant(.current)
|
||||
) {
|
||||
self.recorder = recorder
|
||||
self.capturer = capturer
|
||||
self.transcribe = transcribe
|
||||
self.locale = locale
|
||||
}
|
||||
@@ -167,12 +167,12 @@ extension RecordingView {
|
||||
switch state {
|
||||
case .recording:
|
||||
startTimer(shouldRestartTimer)
|
||||
startRecorder(shouldRestartTimer)
|
||||
startCapturer(shouldRestartTimer)
|
||||
case .paused:
|
||||
stopTimer()
|
||||
|
||||
Task {
|
||||
try? await recorder.pause()
|
||||
try? await capturer.pause()
|
||||
}
|
||||
case .processing:
|
||||
stopTimer()
|
||||
@@ -213,7 +213,7 @@ private extension RecordingView.Model {
|
||||
}
|
||||
|
||||
do {
|
||||
let audio = try await recorder.stop()
|
||||
let audio = try await capturer.stop()
|
||||
|
||||
transcription = try await transcribe(
|
||||
audio,
|
||||
@@ -228,17 +228,17 @@ private extension RecordingView.Model {
|
||||
state = .notRecording
|
||||
}
|
||||
|
||||
/// Starts the audio capture through the attached ``RecordingService``, falling back to the not-recording state when the service fails.
|
||||
/// Starts the audio capture through the attached ``Capturing``, falling back to the not-recording state when the service fails.
|
||||
///
|
||||
/// - Parameter isNewRecording: Whether a new recording should be started, as opposed to a paused one being resumed.
|
||||
func startRecorder(
|
||||
func startCapturer(
|
||||
_ isNewRecording: Bool
|
||||
) {
|
||||
Task {
|
||||
do {
|
||||
isNewRecording
|
||||
? try await recorder.start()
|
||||
: try await recorder.resume()
|
||||
? try await capturer.start()
|
||||
: try await capturer.resume()
|
||||
} catch {
|
||||
stopTimer()
|
||||
|
||||
|
||||
@@ -21,18 +21,18 @@ public struct RecordingView: View {
|
||||
/// Creates a recording view in the not-recording state, attached to the given recording and transcribing services.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - recorder: The service that captures the audio from a microphone.
|
||||
/// - capturer: The service that captures the audio from a microphone.
|
||||
/// - transcriber: The service that transcribes the recorded audio into text.
|
||||
/// - locale: The binding to the locale of the spoken language to transcribe.
|
||||
/// - onTranscription: The closure invoked with the transcription of every processed recording. Defaults to a closure that does nothing.
|
||||
public init(
|
||||
recorder: any RecordingService,
|
||||
capturer: any Capturing,
|
||||
transcriber: any Transcribing,
|
||||
locale: Binding<Locale>,
|
||||
onTranscription: @escaping (Transcription) -> Void
|
||||
) {
|
||||
self.model = .init(
|
||||
recorder: recorder,
|
||||
capturer: capturer,
|
||||
transcribe: transcriber,
|
||||
locale: locale
|
||||
)
|
||||
@@ -132,7 +132,7 @@ private enum Constant {
|
||||
"Recording view"
|
||||
) {
|
||||
RecordingView(
|
||||
recorder: DummyRecordingService(),
|
||||
capturer: DummyCapturing(),
|
||||
transcriber: DummyTranscribing(),
|
||||
locale: .constant(.current)
|
||||
) { _ in
|
||||
|
||||
@@ -249,31 +249,31 @@ struct RecordingViewModelTests {
|
||||
|
||||
}
|
||||
|
||||
// MARK: Recorder
|
||||
// MARK: Capturer
|
||||
|
||||
@MainActor
|
||||
@Suite("Recorder")
|
||||
struct Recorder {
|
||||
@Suite("Capturer")
|
||||
struct Capturer {
|
||||
|
||||
@Test func `starts the capture for a new recording`() async throws {
|
||||
let recorder = RecordingServiceMock()
|
||||
let model = Model(recorder: recorder)
|
||||
let capturer = CapturingMock()
|
||||
let model = Model(capturer: capturer)
|
||||
|
||||
model.state = .recording
|
||||
model.updatedState(shouldRestartTimer: true)
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
#expect(recorder.countStart == 1)
|
||||
#expect(recorder.countResume == 0)
|
||||
#expect(capturer.countStart == 1)
|
||||
#expect(capturer.countResume == 0)
|
||||
|
||||
model.state = .notRecording
|
||||
model.updatedState(shouldRestartTimer: false)
|
||||
}
|
||||
|
||||
@Test func `pauses the capture while paused`() async throws {
|
||||
let recorder = RecordingServiceMock()
|
||||
let model = Model(recorder: recorder)
|
||||
let capturer = CapturingMock()
|
||||
let model = Model(capturer: capturer)
|
||||
|
||||
model.state = .recording
|
||||
model.updatedState(shouldRestartTimer: true)
|
||||
@@ -283,12 +283,12 @@ struct RecordingViewModelTests {
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
#expect(recorder.countPause == 1)
|
||||
#expect(capturer.countPause == 1)
|
||||
}
|
||||
|
||||
@Test func `resumes the capture after a pause`() async throws {
|
||||
let recorder = RecordingServiceMock()
|
||||
let model = Model(recorder: recorder)
|
||||
let capturer = CapturingMock()
|
||||
let model = Model(capturer: capturer)
|
||||
|
||||
model.state = .recording
|
||||
model.updatedState(shouldRestartTimer: true)
|
||||
@@ -301,17 +301,17 @@ struct RecordingViewModelTests {
|
||||
|
||||
try await Task.sleep(for: .seconds(0.1))
|
||||
|
||||
#expect(recorder.countStart == 1)
|
||||
#expect(recorder.countResume == 1)
|
||||
#expect(capturer.countStart == 1)
|
||||
#expect(capturer.countResume == 1)
|
||||
|
||||
model.state = .notRecording
|
||||
model.updatedState(shouldRestartTimer: false)
|
||||
}
|
||||
|
||||
@Test func `stops the capture when the input is sent`() async throws {
|
||||
let recorder = RecordingServiceMock()
|
||||
let capturer = CapturingMock()
|
||||
let model = Model(
|
||||
recorder: recorder,
|
||||
capturer: capturer,
|
||||
transcribe: TranscribingMock()
|
||||
)
|
||||
|
||||
@@ -320,15 +320,15 @@ struct RecordingViewModelTests {
|
||||
|
||||
try await Task.sleep(for: .seconds(0.5))
|
||||
|
||||
#expect(recorder.countStop == 1)
|
||||
#expect(capturer.countStop == 1)
|
||||
}
|
||||
|
||||
@Test func `falls back to not recording when the capture fails`() async throws {
|
||||
let recorder = RecordingServiceMock()
|
||||
let capturer = CapturingMock()
|
||||
|
||||
recorder.error = ErrorMock()
|
||||
capturer.error = ErrorMock()
|
||||
|
||||
let model = Model(recorder: recorder)
|
||||
let model = Model(capturer: capturer)
|
||||
|
||||
model.state = .recording
|
||||
model.updatedState(shouldRestartTimer: true)
|
||||
@@ -419,7 +419,7 @@ struct RecordingViewModelTests {
|
||||
|
||||
/// A recording service that counts its invocations and can be configured to fail.
|
||||
@MainActor
|
||||
private final class RecordingServiceMock: RecordingService {
|
||||
private final class CapturingMock: Capturing {
|
||||
|
||||
/// The error the service throws from every method, or `nil` when it should succeed.
|
||||
var error: Error?
|
||||
|
||||
Reference in New Issue
Block a user