265 lines
8.6 KiB
Swift
265 lines
8.6 KiB
Swift
import Observation
|
|
import SwiftUI
|
|
|
|
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. 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 {
|
|
|
|
// MARK: Properties
|
|
|
|
/// The current state of the recording flow.
|
|
var state: State = .notRecording
|
|
|
|
/// 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.
|
|
var iconMain: ImageResource {
|
|
switch state {
|
|
case .notRecording, .paused, .processing: .Icon.record
|
|
case .recording: .Icon.pause
|
|
}
|
|
}
|
|
|
|
/// The image resource for the send button while paused, or `nil` when the button should show a progress indicator instead.
|
|
var iconSend: ImageResource? {
|
|
switch state {
|
|
case .paused: .Icon.send
|
|
default: nil
|
|
}
|
|
}
|
|
|
|
/// Whether the recorded input is currently being processed.
|
|
var isProcessing: Bool {
|
|
state == .processing
|
|
}
|
|
|
|
/// Whether the main button should be disabled.
|
|
var shouldDisableMain: Bool {
|
|
state == .processing
|
|
}
|
|
|
|
/// Whether the send button should be visible.
|
|
var shouldShowSend: Bool {
|
|
state != .notRecording
|
|
&& state != .recording
|
|
}
|
|
|
|
/// Whether the timer label should be visible.
|
|
var shouldShowTimer: Bool {
|
|
state != .notRecording
|
|
}
|
|
|
|
/// The elapsed recording time, formatted as `mm:ss` for the timer label.
|
|
var textTimer: String {
|
|
Duration
|
|
.seconds(elapsedSeconds)
|
|
.formatted(.time(pattern: .minuteSecond(padMinuteToLength: 2)))
|
|
}
|
|
|
|
// MARK: Methods
|
|
|
|
/// Handles a press of the main button.
|
|
///
|
|
/// Starts a recording when idle, pauses an ongoing recording, or resumes
|
|
/// a paused one. Does nothing while the input is being processed.
|
|
func pressedMain() {
|
|
guard state != .processing else {
|
|
return
|
|
}
|
|
|
|
switch state {
|
|
case .notRecording:
|
|
state = .recording
|
|
case .recording:
|
|
state = .paused
|
|
case .paused:
|
|
state = .recording
|
|
case .processing:
|
|
break
|
|
}
|
|
}
|
|
|
|
/// Handles a press of the send button.
|
|
///
|
|
/// Moves a paused recording into processing. Does nothing in any other state.
|
|
func pressedSend() {
|
|
guard state == .paused else {
|
|
return
|
|
}
|
|
|
|
switch state {
|
|
case .paused:
|
|
state = .processing
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
/// Reacts to a change of ``state``, expected to be called from the view whenever it observes one.
|
|
///
|
|
/// 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.
|
|
func updatedState(
|
|
shouldRestartTimer: Bool
|
|
) {
|
|
switch state {
|
|
case .recording:
|
|
startTimer(shouldRestartTimer)
|
|
startRecorder(shouldRestartTimer)
|
|
case .paused:
|
|
stopTimer()
|
|
|
|
Task {
|
|
try? await recorder.pause()
|
|
}
|
|
case .processing:
|
|
stopTimer()
|
|
|
|
Task {
|
|
await processInput()
|
|
}
|
|
case .notRecording:
|
|
stopTimer()
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private extension RecordingView.Model {
|
|
|
|
// MARK: Methods
|
|
|
|
/// 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
|
|
}
|
|
|
|
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`` 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()
|
|
taskTimer = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(1))
|
|
|
|
guard let self, !Task.isCancelled else {
|
|
return
|
|
}
|
|
|
|
self.elapsedSeconds += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Stops the timer task, if any, keeping ``elapsedSeconds`` at its current value.
|
|
func stopTimer() {
|
|
taskTimer?.cancel()
|
|
taskTimer = nil
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - States
|
|
|
|
extension RecordingView.Model {
|
|
/// The states of the recording flow.
|
|
enum State {
|
|
/// No recording is in progress.
|
|
case notRecording
|
|
/// A recording is in progress and the timer is running.
|
|
case recording
|
|
/// The recording is paused, and can be either resumed or sent.
|
|
case paused
|
|
/// The recorded input is being processed.
|
|
case processing
|
|
}
|
|
}
|