Improve the recording time with a monotonic clock to avoid timer drift on the RecordingViewModel view model in the Recording package target.

This commit is contained in:
2026-07-04 13:25:15 +02:00
parent 3c542ee033
commit 69e30a9947
3 changed files with 48 additions and 15 deletions
+1
View File
@@ -9,6 +9,7 @@ xcuserdata/
## Build generated ## Build generated
.build/ .build/
DerivedData/ DerivedData/
Products/
## Various settings ## Various settings
*.pbxuser *.pbxuser
@@ -51,13 +51,7 @@ extension ContentView {
/// supported equivalent of its current value so the locale picker starts with a valid selection. /// supported equivalent of its current value so the locale picker starts with a valid selection.
func load() async { func load() async {
locales = await SpeechTranscriber.supportedLocales.sorted { locales = await SpeechTranscriber.supportedLocales.sorted {
name(for: $0) < name(for: $1) name(for: $0).localizedStandardCompare(name(for: $1)) == .orderedAscending
}
if let equivalent = await SpeechTranscriber.supportedLocale(
equivalentTo: locale
) {
locale = equivalent
} }
} }
@@ -23,6 +23,18 @@ extension RecordingView {
/// The transcription of the last processed recording, or `nil` when none has been processed yet. /// The transcription of the last processed recording, or `nil` when none has been processed yet.
private(set) var transcription: Transcription? private(set) var transcription: Transcription?
/// The recording time accumulated by earlier recording stretches, up to the last pause.
@ObservationIgnored
private var accumulated: Duration = .zero
/// The instant the current recording stretch started, or `nil` while not recording.
@ObservationIgnored
private var anchor: ContinuousClock.Instant?
/// The clock that measures the recording time.
@ObservationIgnored
private let clock = ContinuousClock()
/// The binding to the locale of the spoken language to transcribe. /// The binding to the locale of the spoken language to transcribe.
@ObservationIgnored @ObservationIgnored
private let locale: Binding<Locale> private let locale: Binding<Locale>
@@ -31,7 +43,7 @@ extension RecordingView {
@ObservationIgnored @ObservationIgnored
private let recorder: any RecordingService private let recorder: any RecordingService
/// The task that increments ``elapsedSeconds`` every second while recording. /// The task that updates ``elapsedSeconds`` from the measured recording time once per second while recording.
@ObservationIgnored @ObservationIgnored
private var taskTimer: Task<Void, Never>? private var taskTimer: Task<Void, Never>?
@@ -180,6 +192,17 @@ extension RecordingView {
private extension RecordingView.Model { private extension RecordingView.Model {
// MARK: Computed
/// The time spent recording so far, excluding any time spent paused, as measured by the clock.
var elapsed: Duration {
guard let anchor else {
return accumulated
}
return accumulated + anchor.duration(to: clock.now)
}
// MARK: Methods // MARK: Methods
/// Processes the recorded input: it stops the audio capture, transcribes the captured audio, and publishes the result in ``transcription``; /// Processes the recorded input: it stops the audio capture, transcribes the captured audio, and publishes the result in ``transcription``;
@@ -200,6 +223,7 @@ private extension RecordingView.Model {
transcription = nil transcription = nil
} }
accumulated = .zero
elapsedSeconds = 0 elapsedSeconds = 0
state = .notRecording state = .notRecording
} }
@@ -223,34 +247,48 @@ private extension RecordingView.Model {
} }
} }
/// Starts the timer task, which increments ``elapsedSeconds`` once per second until it is cancelled. Any previously running timer task is cancelled first. /// Starts the timer task, which updates ``elapsedSeconds`` from the time measured by the clock on every whole second until it is
/// cancelled, so scheduling latency never accumulates as drift. Any previously running timer task is cancelled first.
/// ///
/// - Parameter shouldRestartTimer: Whether ``elapsedSeconds`` and ``transcription`` should be cleared before the timer starts, /// - Parameter shouldRestartTimer: Whether the measured recording time, ``elapsedSeconds``, and ``transcription`` should be
/// which is the case for a new recording as opposed to one resuming from a pause. /// cleared before the timer starts, which is the case for a new recording as opposed to one resuming from a pause.
func startTimer( func startTimer(
_ shouldRestartTimer: Bool _ shouldRestartTimer: Bool
) { ) {
if shouldRestartTimer { if shouldRestartTimer {
accumulated = .zero
elapsedSeconds = 0 elapsedSeconds = 0
transcription = nil transcription = nil
} }
anchor = clock.now
taskTimer?.cancel() taskTimer?.cancel()
taskTimer = Task { [weak self] in taskTimer = Task { [weak self] in
while !Task.isCancelled { while !Task.isCancelled {
try? await Task.sleep(for: .seconds(1))
guard let self, !Task.isCancelled else { guard let self, !Task.isCancelled else {
return return
} }
self.elapsedSeconds += 1 let elapsed = self.elapsed
let seconds = elapsed.components.seconds
self.elapsedSeconds = Int(seconds)
try? await Task.sleep(for: .seconds(seconds + 1) - elapsed)
} }
} }
} }
/// Stops the timer task, if any, keeping ``elapsedSeconds`` at its current value. /// Stops the timer task, if any, folding the current recording stretch into the accumulated recording time so ``elapsedSeconds``
/// keeps its value.
func stopTimer() { func stopTimer() {
if let anchor {
accumulated += anchor.duration(to: clock.now)
self.anchor = nil
}
taskTimer?.cancel() taskTimer?.cancel()
taskTimer = nil taskTimer = nil
} }