114 lines
4.9 KiB
Swift
114 lines
4.9 KiB
Swift
// DictationProgress.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Staged, time-estimated progress for the dictation wait after the user stops
|
|
// recording. The host only reports two milestones to the keyboard (`rawReady`
|
|
// when ASR finishes, `final` when polish finishes) and polish is a single
|
|
// non-streaming request, so the fill between milestones is an estimate: it
|
|
// eases toward a ceiling it never reaches, and only the real `final` result
|
|
// completes the bar.
|
|
|
|
import Foundation
|
|
|
|
/// Timestamps of one dictation wait, owned by `KeyboardState`.
|
|
public struct DictationProgressTimeline: Equatable, Sendable {
|
|
public let utteranceID: UUID
|
|
public let transcribingStartedAt: TimeInterval
|
|
public private(set) var polishingStartedAt: TimeInterval?
|
|
/// Raw transcript length when polish began; scales the expected duration.
|
|
public private(set) var polishCharacterCount: Int = 0
|
|
public private(set) var expectedPolishSeconds: TimeInterval = 0
|
|
|
|
public init(utteranceID: UUID, transcribingStartedAt: TimeInterval) {
|
|
self.utteranceID = utteranceID
|
|
self.transcribingStartedAt = transcribingStartedAt
|
|
}
|
|
|
|
/// Records the ASR → polish milestone once; later `rawReady` repeats are ignored.
|
|
public mutating func markPolishing(
|
|
at time: TimeInterval,
|
|
characterCount: Int,
|
|
expectedSeconds: TimeInterval
|
|
) {
|
|
guard polishingStartedAt == nil else { return }
|
|
polishingStartedAt = time
|
|
polishCharacterCount = characterCount
|
|
expectedPolishSeconds = expectedSeconds
|
|
}
|
|
}
|
|
|
|
public enum DictationProgressEstimator {
|
|
/// Asymptote of the ASR stage; the bar never passes it before `rawReady`.
|
|
static let transcribingCeiling = 0.25
|
|
/// Time constant of the ASR stage curve (local drain is usually < 1 s).
|
|
static let transcribingTimeConstant: TimeInterval = 1.0
|
|
/// Where the polish stage starts, so `rawReady` reads as a visible step.
|
|
static let polishingFloor = 0.35
|
|
/// Asymptote of the polish stage; only the real result fills to 100 %.
|
|
static let polishingCeiling = 0.95
|
|
/// Duration of the ease from the ASR curve into the polish curve.
|
|
static let stageBlendSeconds: TimeInterval = 0.25
|
|
|
|
/// Fill fraction in `0...polishingCeiling` for `timeline` at `now`.
|
|
public static func fraction(
|
|
for timeline: DictationProgressTimeline,
|
|
at now: TimeInterval
|
|
) -> Double {
|
|
guard let polishStart = timeline.polishingStartedAt else {
|
|
return transcribingFraction(elapsed: now - timeline.transcribingStartedAt)
|
|
}
|
|
let atMilestone = transcribingFraction(
|
|
elapsed: polishStart - timeline.transcribingStartedAt
|
|
)
|
|
let elapsed = max(0, now - polishStart)
|
|
// At `elapsed == expected` the polish curve covers ~86 % of its range.
|
|
let timeConstant = max(timeline.expectedPolishSeconds, 0.5) / 2
|
|
let polishing = polishingFloor
|
|
+ (polishingCeiling - polishingFloor) * (1 - exp(-elapsed / timeConstant))
|
|
let blend = min(elapsed / stageBlendSeconds, 1)
|
|
return atMilestone + (polishing - atMilestone) * blend
|
|
}
|
|
|
|
static func transcribingFraction(elapsed: TimeInterval) -> Double {
|
|
transcribingCeiling * (1 - exp(-max(0, elapsed) / transcribingTimeConstant))
|
|
}
|
|
}
|
|
|
|
/// Learns this device's polish latency so the estimate tracks the user's
|
|
/// provider and network. Stored in the extension's own defaults; it is a UI
|
|
/// hint, not a setting, so it is neither synced nor shared with the host.
|
|
public struct DictationPolishLatencyCalibration {
|
|
static let overheadKey = "keyboard.dictationProgress.polishOverheadSeconds"
|
|
static let defaultOverheadSeconds: TimeInterval = 1.5
|
|
static let secondsPerCharacter: TimeInterval = 0.012
|
|
static let overheadRange: ClosedRange<TimeInterval> = 0.5...15
|
|
/// Weight of the newest observation in the moving average.
|
|
static let smoothing = 0.3
|
|
|
|
private let defaults: UserDefaults
|
|
|
|
public init(defaults: UserDefaults = .standard) {
|
|
self.defaults = defaults
|
|
}
|
|
|
|
public func expectedSeconds(forCharacterCount count: Int) -> TimeInterval {
|
|
overheadSeconds + Double(max(0, count)) * Self.secondsPerCharacter
|
|
}
|
|
|
|
public func record(observedSeconds: TimeInterval, characterCount: Int) {
|
|
guard observedSeconds.isFinite, observedSeconds > 0 else { return }
|
|
let observedOverhead = observedSeconds
|
|
- Double(max(0, characterCount)) * Self.secondsPerCharacter
|
|
let blended = overheadSeconds * (1 - Self.smoothing) + observedOverhead * Self.smoothing
|
|
defaults.set(
|
|
min(max(blended, Self.overheadRange.lowerBound), Self.overheadRange.upperBound),
|
|
forKey: Self.overheadKey
|
|
)
|
|
}
|
|
|
|
var overheadSeconds: TimeInterval {
|
|
let stored = defaults.double(forKey: Self.overheadKey)
|
|
return stored > 0 ? stored : Self.defaultOverheadSeconds
|
|
}
|
|
}
|