Implemented the resumed interrupted recordings and allowed processing discards on the RecordingViewModel view model in the Recording package target.
This commit is contained in:
@@ -5,9 +5,13 @@ 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
|
||||
/// ``Capturing`` and ``Transcribing`` attached at initialization; when either of them fails, the model falls back to the not-recording state.
|
||||
/// The model implements the recording flow as a ``State`` machine: it exposes the visibility, icons, accessibility labels, and alert
|
||||
/// message 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 ``Capturing`` and ``Transcribing`` attached at
|
||||
/// initialization; when either of them fails, the model falls back to the not-recording state and publishes the failure in ``error``.
|
||||
///
|
||||
/// A recording paused by a system interruption of the capture resumes by itself once the interruption ends with the system's
|
||||
/// resume hint, and a recording already being processed can still be discarded, cancelling its in-flight transcription.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class Model {
|
||||
@@ -33,7 +37,7 @@ extension RecordingView {
|
||||
/// The instant the current recording stretch started, or `nil` while not recording.
|
||||
@ObservationIgnored
|
||||
private var anchor: ContinuousClock.Instant?
|
||||
|
||||
|
||||
/// The service that captures the audio from a microphone.
|
||||
@ObservationIgnored
|
||||
private let capturer: any Capturing
|
||||
@@ -42,6 +46,10 @@ extension RecordingView {
|
||||
@ObservationIgnored
|
||||
private let clock = ContinuousClock()
|
||||
|
||||
/// Whether the recording was paused by a system interruption of the audio capture, as opposed to by the user.
|
||||
@ObservationIgnored
|
||||
private var isPausedByInterruption = false
|
||||
|
||||
/// The binding to the locale of the spoken language to transcribe.
|
||||
@ObservationIgnored
|
||||
private let locale: Binding<Locale>
|
||||
@@ -103,6 +111,22 @@ extension RecordingView {
|
||||
state == .processing
|
||||
}
|
||||
|
||||
/// The accessibility label for the main button, matching its action in the current state.
|
||||
var labelMain: LocalizedStringResource {
|
||||
switch state {
|
||||
case .notRecording, .processing: .viewRecordingButtonMainLabelRecord
|
||||
case .recording: .viewRecordingButtonMainLabelPause
|
||||
case .paused: .viewRecordingButtonMainLabelResume
|
||||
}
|
||||
}
|
||||
|
||||
/// The accessibility label for the send button: the sending action while paused, the ongoing processing otherwise.
|
||||
var labelSend: LocalizedStringResource {
|
||||
isProcessing
|
||||
? .viewRecordingButtonSendLabelProcessing
|
||||
: .viewRecordingButtonSendLabelSend
|
||||
}
|
||||
|
||||
/// Whether the main button should be disabled.
|
||||
var shouldDisableMain: Bool {
|
||||
state == .processing
|
||||
@@ -119,6 +143,17 @@ extension RecordingView {
|
||||
state != .notRecording
|
||||
}
|
||||
|
||||
/// The localized message describing ``error`` for the alert, or `nil` when no error occurred.
|
||||
var textAlertMessage: LocalizedStringResource? {
|
||||
switch error {
|
||||
case .assetsUnavailable: .viewRecordingAlertErrorMessageAssets
|
||||
case .captureFailed: .viewRecordingAlertErrorMessageCapture
|
||||
case .permissionDenied: .viewRecordingAlertErrorMessagePermission
|
||||
case .transcriptionFailed: .viewRecordingAlertErrorMessageTranscription
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The elapsed recording time, formatted as `mm:ss` for the timer label.
|
||||
var textTimer: String {
|
||||
Duration
|
||||
@@ -140,26 +175,45 @@ extension RecordingView {
|
||||
|
||||
/// Handles a press of the discard button.
|
||||
///
|
||||
/// Throws away a paused recording: it resets the timer, returns to the not-recording state, and stops the audio capture,
|
||||
/// deleting the captured audio file. Does nothing in any other state.
|
||||
/// Throws away a paused recording — or one already being processed, cancelling its in-flight transcription: it resets the
|
||||
/// timer, returns to the not-recording state, and stops the audio capture, deleting the captured audio file. Does nothing in
|
||||
/// any other state.
|
||||
func pressedDiscard() {
|
||||
guard state == .paused else {
|
||||
return
|
||||
}
|
||||
isPausedByInterruption = false
|
||||
|
||||
state = .notRecording
|
||||
switch state {
|
||||
case .paused:
|
||||
state = .notRecording
|
||||
|
||||
stopTimer()
|
||||
stopTimer()
|
||||
|
||||
accumulated = .zero
|
||||
elapsedSeconds = 0
|
||||
accumulated = .zero
|
||||
elapsedSeconds = 0
|
||||
|
||||
enqueueCapturer {
|
||||
guard let audio = try? await self.capturer.stop() else {
|
||||
return
|
||||
enqueueCapturer {
|
||||
guard let audio = try? await self.capturer.stop() else {
|
||||
return
|
||||
}
|
||||
|
||||
try? FileManager.default.removeItem(at: audio)
|
||||
}
|
||||
case .processing:
|
||||
taskCapturer?.cancel()
|
||||
|
||||
try? FileManager.default.removeItem(at: audio)
|
||||
state = .notRecording
|
||||
|
||||
accumulated = .zero
|
||||
elapsedSeconds = 0
|
||||
|
||||
enqueueCapturer {
|
||||
guard let audio = try? await self.capturer.stop() else {
|
||||
return
|
||||
}
|
||||
|
||||
try? FileManager.default.removeItem(at: audio)
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +222,8 @@ extension RecordingView {
|
||||
/// Starts a recording when idle, pauses an ongoing recording, or resumes a paused one — starting and stopping the timer
|
||||
/// and the audio capture accordingly. Does nothing while the input is being processed.
|
||||
func pressedMain() {
|
||||
isPausedByInterruption = false
|
||||
|
||||
switch state {
|
||||
case .notRecording:
|
||||
state = .recording
|
||||
@@ -178,10 +234,7 @@ extension RecordingView {
|
||||
case .recording:
|
||||
pauseRecording()
|
||||
case .paused:
|
||||
state = .recording
|
||||
|
||||
startTimer(false)
|
||||
startCapturer(false)
|
||||
resumeRecording()
|
||||
case .processing:
|
||||
break
|
||||
}
|
||||
@@ -196,6 +249,8 @@ extension RecordingView {
|
||||
///
|
||||
/// Moves a paused recording into processing, stopping the timer and kicking off ``processInput()``. Does nothing in any other state.
|
||||
func pressedSend() {
|
||||
isPausedByInterruption = false
|
||||
|
||||
switch state {
|
||||
case .paused:
|
||||
state = .processing
|
||||
@@ -232,7 +287,8 @@ private extension RecordingView.Model {
|
||||
|
||||
/// Processes the recorded input: it stops the audio capture, transcribes the captured audio, and publishes the result in ``transcription``;
|
||||
/// when finished — or when either service fails, in which case the failure is published in ``error`` — it resets ``elapsedSeconds``
|
||||
/// and returns the model to the not-recording state.
|
||||
/// and returns the model to the not-recording state. A processing cancelled through the discard button publishes nothing:
|
||||
/// the discard already reset the model.
|
||||
func processInput() async {
|
||||
guard state == .processing else {
|
||||
return
|
||||
@@ -242,21 +298,46 @@ private extension RecordingView.Model {
|
||||
let audio = try await capturer.stop()
|
||||
|
||||
do {
|
||||
transcription = try await transcribe(
|
||||
let transcription = try await transcribe(
|
||||
audio,
|
||||
locale: locale.wrappedValue
|
||||
)
|
||||
} catch {
|
||||
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
self.transcription = transcription
|
||||
}
|
||||
catch is CancellationError {
|
||||
return
|
||||
}
|
||||
catch {
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
transcription = nil
|
||||
self.error = error as? AudioTranscribingError == .assetsNotInstalled
|
||||
self.error =
|
||||
error as? AudioTranscribingError == .assetsNotInstalled
|
||||
? .assetsUnavailable
|
||||
: .transcriptionFailed
|
||||
}
|
||||
} catch is CancellationError {
|
||||
return
|
||||
} catch {
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
transcription = nil
|
||||
self.error = .captureFailed
|
||||
}
|
||||
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
|
||||
accumulated = .zero
|
||||
elapsedSeconds = 0
|
||||
state = .notRecording
|
||||
@@ -276,7 +357,8 @@ private extension RecordingView.Model {
|
||||
}
|
||||
|
||||
/// Starts listening to the events emitted by the capturing service, unless already listening: an interruption of the capture pauses
|
||||
/// an ongoing recording, exactly like a press of the main button would.
|
||||
/// an ongoing recording, exactly like a press of the main button would, and the end of an interruption resumes the recording it
|
||||
/// paused — but only when the system hints the capture may resume, and never one the user paused themselves.
|
||||
func listenToEvents() {
|
||||
guard taskEvents == nil else {
|
||||
return
|
||||
@@ -291,8 +373,16 @@ private extension RecordingView.Model {
|
||||
switch event {
|
||||
case .interrupted:
|
||||
if state == .recording {
|
||||
isPausedByInterruption = true
|
||||
|
||||
pauseRecording()
|
||||
}
|
||||
case .interruptionEnded(let shouldResume):
|
||||
if shouldResume, state == .paused, isPausedByInterruption {
|
||||
resumeRecording()
|
||||
}
|
||||
|
||||
isPausedByInterruption = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -309,6 +399,14 @@ private extension RecordingView.Model {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resumes the paused recording: it restarts the timer, without resetting it, and resumes the audio capture.
|
||||
func resumeRecording() {
|
||||
state = .recording
|
||||
|
||||
startTimer(false)
|
||||
startCapturer(false)
|
||||
}
|
||||
|
||||
/// Starts the audio capture through the attached ``Capturing``, falling back to the not-recording state and publishing the failure
|
||||
/// in ``error`` when the service fails.
|
||||
///
|
||||
@@ -325,7 +423,8 @@ private extension RecordingView.Model {
|
||||
self.stopTimer()
|
||||
|
||||
self.state = .notRecording
|
||||
self.error = error as? AudioCapturingError == .permissionNotGranted
|
||||
self.error =
|
||||
error as? AudioCapturingError == .permissionNotGranted
|
||||
? .permissionDenied
|
||||
: .captureFailed
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user