From 376be4940b9ca11506120b7b1f7d194ae3ab59c3 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Fri, 3 Jul 2026 21:33:46 +0200 Subject: [PATCH] Integrated the RecordingService and the TranscribingService protocols into the RecordingView view in the Recording package target. --- Apps/Attendi/ContentView.swift | 7 +- .../Sources/Recording/RecordingView.swift | 21 +- .../Recording/RecordingViewModel.swift | 85 ++++++- .../Recording/RecordingViewModelTests.swift | 224 +++++++++++++++++- 4 files changed, 316 insertions(+), 21 deletions(-) diff --git a/Apps/Attendi/ContentView.swift b/Apps/Attendi/ContentView.swift index 78d26f4..3c432d9 100644 --- a/Apps/Attendi/ContentView.swift +++ b/Apps/Attendi/ContentView.swift @@ -8,9 +8,12 @@ struct ContentView: View { // MARK: Body - /// The content of the view: the recording feature's view. + /// The content of the view: the recording feature's view, attached to the simulated recording and transcribing services. var body: some View { - RecordingView() + RecordingView( + recorder: SimulatedRecordingService(), + transcriber: SimulatedTranscribingService() + ) } } diff --git a/Packages/Features/Sources/Recording/RecordingView.swift b/Packages/Features/Sources/Recording/RecordingView.swift index 46a50c2..debb802 100644 --- a/Packages/Features/Sources/Recording/RecordingView.swift +++ b/Packages/Features/Sources/Recording/RecordingView.swift @@ -15,9 +15,19 @@ public struct RecordingView: View { // MARK: Initializers - /// Creates a recording view in the not-recording state. - public init() { - self.model = .init() + /// 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. + /// - transcriber: The service that transcribes the recorded audio into text. + public init( + recorder: any RecordingService, + transcriber: any TranscribingService + ) { + self.model = .init( + recorder: recorder, + transcriber: transcriber + ) } // MARK: Body @@ -98,5 +108,8 @@ private enum Constant { #Preview( "Recording view" ) { - RecordingView() + RecordingView( + recorder: SimulatedRecordingService(), + transcriber: SimulatedTranscribingService() + ) } diff --git a/Packages/Features/Sources/Recording/RecordingViewModel.swift b/Packages/Features/Sources/Recording/RecordingViewModel.swift index 3a00f5e..3a90a1e 100644 --- a/Packages/Features/Sources/Recording/RecordingViewModel.swift +++ b/Packages/Features/Sources/Recording/RecordingViewModel.swift @@ -6,7 +6,8 @@ extension RecordingView { /// The observable model that drives ``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. + /// elapsed recording time, and processes the recorded input once it is sent. The audio capture and its transcription are delegated to the + /// ``RecordingService`` and ``TranscribingService`` attached at initialization; when either of them fails, the model falls back to the not-recording state. @MainActor @Observable final class Model { @@ -18,11 +19,37 @@ extension RecordingView { /// The number of seconds spent recording, excluding any time spent paused. private(set) var elapsedSeconds: Int = 0 + + /// The transcription of the last processed recording, or `nil` when none has been processed yet. + private(set) var textTranscription: String? + + /// The service that captures the audio from a microphone. + @ObservationIgnored + private let recorder: any RecordingService /// The task that increments ``elapsedSeconds`` every second while recording. @ObservationIgnored private var taskTimer: Task? + /// The service that transcribes the recorded audio into text. + @ObservationIgnored + private let transcriber: any TranscribingService + + // MARK: Initializers + + /// Creates a model attached to the given recording and transcribing services. + /// + /// - Parameters: + /// - recorder: The service that captures the audio from a microphone. Defaults to ``SimulatedRecordingService``. + /// - transcriber: The service that transcribes the recorded audio into text. Defaults to ``SimulatedTranscribingService``. + init( + recorder: any RecordingService = SimulatedRecordingService(), + transcriber: any TranscribingService = SimulatedTranscribingService() + ) { + self.recorder = recorder + self.transcriber = transcriber + } + // MARK: Computed /// The image resource for the main button: a pause icon while recording, a record icon otherwise. @@ -110,7 +137,8 @@ extension RecordingView { /// Reacts to a change of ``state``, expected to be called from the view whenever it observes one. /// - /// Starts the timer when a recording begins or resumes, stops it in every other state, and kicks off ``processInput()`` when the input is sent. + /// Starts the timer and the audio capture when a recording begins or resumes, stops the timer in every other state — pausing the capture + /// while paused — and kicks off ``processInput()`` when the input is sent. /// /// - Parameter shouldRestartTimer: Whether ``elapsedSeconds`` should be reset to zero before the timer starts, which is the case for /// a new recording as opposed to one resuming from a pause. @@ -120,14 +148,21 @@ extension RecordingView { switch state { case .recording: startTimer(shouldRestartTimer) - default: + startRecorder(shouldRestartTimer) + case .paused: stopTimer() - if state == .processing { - Task { - await processInput() - } + Task { + try? await recorder.pause() } + case .processing: + stopTimer() + + Task { + await processInput() + } + case .notRecording: + stopTimer() } } @@ -140,28 +175,54 @@ private extension RecordingView.Model { // MARK: Methods - /// Processes the recorded input, resetting ``elapsedSeconds`` and returning the model to the not-recording state when finished. - /// - /// Currently a placeholder that simulates the work with a two-second delay. + /// Processes the recorded input: it stops the audio capture, transcribes the captured audio, and publishes the result in ``textTranscription``; + /// when finished — or when either service fails — it resets ``elapsedSeconds`` and returns the model to the not-recording state. func processInput() async { guard state == .processing else { return } - try? await Task.sleep(for: .seconds(2)) + do { + let audio = try await recorder.stop() + + textTranscription = try await transcriber.transcribe(audio) + } catch { + textTranscription = nil + } elapsedSeconds = 0 state = .notRecording } + /// Starts the audio capture through the attached ``RecordingService``, 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( + _ isNewRecording: Bool + ) { + Task { + do { + isNewRecording + ? try await recorder.start() + : try await recorder.resume() + } catch { + stopTimer() + + state = .notRecording + } + } + } + /// Starts the timer task, which increments ``elapsedSeconds`` once per second until it is cancelled. Any previously running timer task is cancelled first. /// - /// - Parameter shouldRestartTimer: Whether ``elapsedSeconds`` should be reset to zero before the timer starts. + /// - Parameter shouldRestartTimer: Whether ``elapsedSeconds`` and ``textTranscription`` should be cleared before the timer starts, + /// which is the case for a new recording as opposed to one resuming from a pause. func startTimer( _ shouldRestartTimer: Bool ) { if shouldRestartTimer { elapsedSeconds = 0 + textTranscription = nil } taskTimer?.cancel() diff --git a/Packages/Features/Tests/Recording/RecordingViewModelTests.swift b/Packages/Features/Tests/Recording/RecordingViewModelTests.swift index 0a20684..a1ba8ec 100644 --- a/Packages/Features/Tests/Recording/RecordingViewModelTests.swift +++ b/Packages/Features/Tests/Recording/RecordingViewModelTests.swift @@ -20,6 +20,7 @@ struct RecordingViewModelTests { #expect(model.state == .notRecording) #expect(model.elapsedSeconds == 0) #expect(model.textTimer == "00:00") + #expect(model.textTranscription == nil) } } @@ -248,14 +249,107 @@ struct RecordingViewModelTests { } + // MARK: Recorder + + @MainActor + @Suite("Recorder") + struct Recorder { + + @Test func `starts the capture for a new recording`() async throws { + let recorder = RecordingServiceMock() + let model = Model(recorder: recorder) + + model.state = .recording + model.updatedState(shouldRestartTimer: true) + + try await Task.sleep(for: .seconds(0.1)) + + #expect(recorder.countStart == 1) + #expect(recorder.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) + + model.state = .recording + model.updatedState(shouldRestartTimer: true) + + model.state = .paused + model.updatedState(shouldRestartTimer: false) + + try await Task.sleep(for: .seconds(0.1)) + + #expect(recorder.countPause == 1) + } + + @Test func `resumes the capture after a pause`() async throws { + let recorder = RecordingServiceMock() + let model = Model(recorder: recorder) + + model.state = .recording + model.updatedState(shouldRestartTimer: true) + + model.state = .paused + model.updatedState(shouldRestartTimer: false) + + model.state = .recording + model.updatedState(shouldRestartTimer: false) + + try await Task.sleep(for: .seconds(0.1)) + + #expect(recorder.countStart == 1) + #expect(recorder.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 model = Model( + recorder: recorder, + transcriber: TranscribingServiceMock() + ) + + model.state = .processing + model.updatedState(shouldRestartTimer: false) + + try await Task.sleep(for: .seconds(0.5)) + + #expect(recorder.countStop == 1) + } + + @Test func `falls back to not recording when the capture fails`() async throws { + let recorder = RecordingServiceMock() + + recorder.error = ErrorMock() + + let model = Model(recorder: recorder) + + model.state = .recording + model.updatedState(shouldRestartTimer: true) + + try await Task.sleep(for: .seconds(0.1)) + + #expect(model.state == .notRecording) + } + + } + // MARK: Processing @MainActor @Suite("Processing") struct Processing { - @Test func `returns to not recording with a reset timer`() async throws { - let model = Model() + @Test func `returns to not recording with a reset timer and a transcription`() async throws { + let model = Model( + transcriber: TranscribingServiceMock() + ) model.state = .recording model.updatedState(shouldRestartTimer: true) @@ -270,12 +364,136 @@ struct RecordingViewModelTests { model.state = .processing model.updatedState(shouldRestartTimer: false) - try await Task.sleep(for: .seconds(2.5)) + try await Task.sleep(for: .seconds(0.5)) #expect(model.state == .notRecording) #expect(model.elapsedSeconds == 0) + #expect(model.textTranscription != nil) + } + + @Test func `clears the transcription when the transcriber fails`() async throws { + let transcriber = TranscribingServiceMock() + + transcriber.error = ErrorMock() + + let model = Model( + transcriber: transcriber + ) + + model.state = .processing + model.updatedState(shouldRestartTimer: false) + + try await Task.sleep(for: .seconds(0.5)) + + #expect(model.state == .notRecording) + #expect(model.elapsedSeconds == 0) + #expect(model.textTranscription == nil) + } + + @Test func `clears the transcription when a new recording starts`() async throws { + let model = Model( + transcriber: TranscribingServiceMock() + ) + + model.state = .processing + model.updatedState(shouldRestartTimer: false) + + try await Task.sleep(for: .seconds(0.5)) + + #expect(model.textTranscription != nil) + + model.state = .recording + model.updatedState(shouldRestartTimer: true) + + #expect(model.textTranscription == nil) + + model.state = .notRecording + model.updatedState(shouldRestartTimer: false) } } } + +// MARK: - Mocks + +/// A recording service that counts its invocations and can be configured to fail. +@MainActor +private final class RecordingServiceMock: RecordingService { + + /// The error the service throws from every method, or `nil` when it should succeed. + var error: Error? + + /// The number of times ``start()`` has been called. + private(set) var countStart = 0 + + /// The number of times ``pause()`` has been called. + private(set) var countPause = 0 + + /// The number of times ``resume()`` has been called. + private(set) var countResume = 0 + + /// The number of times ``stop()`` has been called. + private(set) var countStop = 0 + + func start() async throws { + countStart += 1 + + try throwConfiguredError() + } + + func pause() async throws { + countPause += 1 + + try throwConfiguredError() + } + + func resume() async throws { + countResume += 1 + + try throwConfiguredError() + } + + func stop() async throws -> Data { + countStop += 1 + + try throwConfiguredError() + + return .init() + } + + private func throwConfiguredError() throws { + if let error { + throw error + } + } + +} + +/// A transcribing service with a configurable delay, output, and failure. +@MainActor +private final class TranscribingServiceMock: TranscribingService { + + /// The duration of the simulated transcription work. + var delay: Duration = .seconds(0.2) + + /// The error the service throws, or `nil` when it should succeed. + var error: Error? + + /// The transcription the service returns. + var transcription = "This is a mocked transcription." + + func transcribe(_ audio: Data) async throws -> String { + try await Task.sleep(for: delay) + + if let error { + throw error + } + + return transcription + } + +} + +/// An error to configure the service mocks with. +private struct ErrorMock: Error {}