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/
DerivedData/
Products/
## Various settings
*.pbxuser
@@ -51,13 +51,7 @@ extension ContentView {
/// supported equivalent of its current value so the locale picker starts with a valid selection.
func load() async {
locales = await SpeechTranscriber.supportedLocales.sorted {
name(for: $0) < name(for: $1)
}
if let equivalent = await SpeechTranscriber.supportedLocale(
equivalentTo: locale
) {
locale = equivalent
name(for: $0).localizedStandardCompare(name(for: $1)) == .orderedAscending
}
}
@@ -23,6 +23,18 @@ extension RecordingView {
/// The transcription of the last processed recording, or `nil` when none has been processed yet.
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.
@ObservationIgnored
private let locale: Binding<Locale>
@@ -31,7 +43,7 @@ extension RecordingView {
@ObservationIgnored
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
private var taskTimer: Task<Void, Never>?
@@ -180,6 +192,17 @@ extension RecordingView {
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
/// 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
}
accumulated = .zero
elapsedSeconds = 0
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,
/// which is the case for a new recording as opposed to one resuming from a pause.
/// - Parameter shouldRestartTimer: Whether the measured recording time, ``elapsedSeconds``, and ``transcription`` 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 {
accumulated = .zero
elapsedSeconds = 0
transcription = nil
}
anchor = clock.now
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
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() {
if let anchor {
accumulated += anchor.duration(to: clock.now)
self.anchor = nil
}
taskTimer?.cancel()
taskTimer = nil
}