Integrated the RecordingService and the TranscribingService protocols into the RecordingView view in the Recording package target.

This commit is contained in:
2026-07-03 21:37:40 +02:00
parent 31b778a36e
commit 376be4940b
4 changed files with 316 additions and 21 deletions
@@ -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()