Implemented the resumed interrupted recordings and allowed processing discards on the RecordingViewModel view model in the Recording package target.

This commit is contained in:
2026-07-05 16:16:53 +02:00
parent 8576d6bac6
commit c2ac476257
14 changed files with 563 additions and 309 deletions
@@ -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
}