From 69e30a99478f3381854c57169f949dccdb94f458 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Sat, 4 Jul 2026 13:25:15 +0200 Subject: [PATCH] Improve the recording time with a monotonic clock to avoid timer drift on the RecordingViewModel view model in the Recording package target. --- .gitignore | 1 + .../View Models/ContentViewModel.swift | 8 +-- .../View Models/RecordingViewModel.swift | 54 ++++++++++++++++--- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index dc447df..81ab3da 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ xcuserdata/ ## Build generated .build/ DerivedData/ +Products/ ## Various settings *.pbxuser diff --git a/Apps/Attendi/View Models/ContentViewModel.swift b/Apps/Attendi/View Models/ContentViewModel.swift index 7f4470e..cc89d41 100644 --- a/Apps/Attendi/View Models/ContentViewModel.swift +++ b/Apps/Attendi/View Models/ContentViewModel.swift @@ -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 } } diff --git a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift index 2e0b5a4..bc5c4cf 100644 --- a/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift +++ b/Packages/Features/Sources/Recording/View Models/RecordingViewModel.swift @@ -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 @@ -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? @@ -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 }