Integrated the RecordingService and the TranscribingService protocols into the RecordingView view in the Recording package target.
This commit is contained in:
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Void, Never>?
|
||||
|
||||
/// 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()
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user