Renamed the RecordingService protocol in the Recording package target as Capturing.

This commit is contained in:
2026-07-04 13:41:56 +02:00
parent 69e30a9947
commit be130eb05f
8 changed files with 57 additions and 57 deletions
@@ -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()
}
+1 -1
View File
@@ -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
@@ -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
@@ -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.
@@ -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?