feat(keyboard): show dictation progress as a fill on the microphone capsule
This commit is contained in:
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- **Dictation progress on the microphone**: while a dictation is being recognized and polished, the microphone capsule fills from left to right in the brand colour instead of showing a spinner. The fill steps forward when recognition finishes, slows as it nears the end, and completes only when the text is inserted; the polish estimate adapts to how long polishing usually takes on this device. / **麦克风上的听写进度**:听写识别与润色期间,麦克风胶囊改为从左到右填充品牌色,取代原来的转圈。识别完成时进度向前跳一段,越接近结束走得越慢,只有文字真正插入时才填满;润色耗时的预估会按这台设备的实际情况自动校准。
|
||||||
- **Messaging shift to AI voice keyboard / voice double / agentic skills**: README and the product page now lead with “Say it. It’s typed. Speaks like you. Acts for you.” — the personalization story (learned style, three-tone replies) moves ahead of typing, clipboard skills are framed as agentic actions, and typing becomes the fallback capability. / **宣传定位转向 AI 语音键盘 / 嘴替 / Agentic 技能**:README 与产品页改用「开口即文字。会说你的话,会办你的事。」定位;个人化卖点(专属风格、三语气回复)提前,剪贴板技能表述升级为 Agentic 行动,打字退为兜底能力。
|
- **Messaging shift to AI voice keyboard / voice double / agentic skills**: README and the product page now lead with “Say it. It’s typed. Speaks like you. Acts for you.” — the personalization story (learned style, three-tone replies) moves ahead of typing, clipboard skills are framed as agentic actions, and typing becomes the fallback capability. / **宣传定位转向 AI 语音键盘 / 嘴替 / Agentic 技能**:README 与产品页改用「开口即文字。会说你的话,会办你的事。」定位;个人化卖点(专属风格、三语气回复)提前,剪贴板技能表述升级为 Agentic 行动,打字退为兜底能力。
|
||||||
- **Product page redesign**: rebuild the GitHub Pages home as a dark product stage with the new Icon Composer app icon, the three-device hero visual, and real on-device videos and UI cards replacing hand-drawn mockups; add 2.1 selling points (personal style, three-tone replies, monthly usage calendar, EventKit skills export, guided trials and credit rewards), a TestFlight link, and self-hosted inline SVG icons; bump page references to 2.1.0. / **产品页重设计**:GitHub Pages 主页改为深色产品舞台,启用 Icon Composer 新图标与三端同框主视觉,以实机视频与真实界面卡片取代手绘示意;补齐 2.1 卖点(专属风格、三语气个性回复、月度用量日历、EventKit 技能写入、引导体验与积分奖励),新增 TestFlight 入口,图标改为自托管内联 SVG;页面版本号更新至 2.1.0。
|
- **Product page redesign**: rebuild the GitHub Pages home as a dark product stage with the new Icon Composer app icon, the three-device hero visual, and real on-device videos and UI cards replacing hand-drawn mockups; add 2.1 selling points (personal style, three-tone replies, monthly usage calendar, EventKit skills export, guided trials and credit rewards), a TestFlight link, and self-hosted inline SVG icons; bump page references to 2.1.0. / **产品页重设计**:GitHub Pages 主页改为深色产品舞台,启用 Icon Composer 新图标与三端同框主视觉,以实机视频与真实界面卡片取代手绘示意;补齐 2.1 卖点(专属风格、三语气个性回复、月度用量日历、EventKit 技能写入、引导体验与积分奖励),新增 TestFlight 入口,图标改为自托管内联 SVG;页面版本号更新至 2.1.0。
|
||||||
- **Host-only analytics recording**: the keyboard extension no longer opens the analytics or keyboard-usage SQLite queues. The host app is the only process that records and uploads events, so hiding the keyboard cannot hold WAL locks and get killed as `0xdead10cc`. Keyboard-activated and per-keystroke usage events are no longer collected. / **仅主 App 记埋点**:键盘扩展不再打开统计与键盘用量 SQLite 队列;开库、记账与上传只在主 App 进行,收起键盘时不会因 WAL 锁被系统以 `0xdead10cc` 终止。不再采集「键盘激活」和逐次按键用量。
|
- **Host-only analytics recording**: the keyboard extension no longer opens the analytics or keyboard-usage SQLite queues. The host app is the only process that records and uploads events, so hiding the keyboard cannot hold WAL locks and get killed as `0xdead10cc`. Keyboard-activated and per-keystroke usage events are no longer collected. / **仅主 App 记埋点**:键盘扩展不再打开统计与键盘用量 SQLite 队列;开库、记账与上传只在主 App 进行,收起键盘时不会因 WAL 锁被系统以 `0xdead10cc` 终止。不再采集「键盘激活」和逐次按键用量。
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
private var currentUtteranceId: UUID?
|
private var currentUtteranceId: UUID?
|
||||||
private var currentStartDeadlineAt: TimeInterval?
|
private var currentStartDeadlineAt: TimeInterval?
|
||||||
private var currentUtteranceRequest: FlowUtteranceRequest?
|
private var currentUtteranceRequest: FlowUtteranceRequest?
|
||||||
|
private let polishLatencyCalibration = DictationPolishLatencyCalibration()
|
||||||
private var audioPrimeID: UUID?
|
private var audioPrimeID: UUID?
|
||||||
private var audioPrimeCancelTask: Task<Void, Never>?
|
private var audioPrimeCancelTask: Task<Void, Never>?
|
||||||
private var editHostConfirmed = false
|
private var editHostConfirmed = false
|
||||||
@@ -471,6 +472,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
if state.lastTranscript.isEmpty {
|
if state.lastTranscript.isEmpty {
|
||||||
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
||||||
}
|
}
|
||||||
|
beginDictationProgressIfNeeded()
|
||||||
startFlowResultWatchdog()
|
startFlowResultWatchdog()
|
||||||
traceState("adoptHostBusy.processing", extra: "session=\(sessionId)")
|
traceState("adoptHostBusy.processing", extra: "session=\(sessionId)")
|
||||||
}
|
}
|
||||||
@@ -801,6 +803,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
finishLocalCancel()
|
finishLocalCancel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
state.dictationProgress = nil
|
||||||
let inserted = registerCancelledUtterance(utteranceID, kind: kind)
|
let inserted = registerCancelledUtterance(utteranceID, kind: kind)
|
||||||
if !inserted, isAwaitingFlowResult {
|
if !inserted, isAwaitingFlowResult {
|
||||||
state.phase = .processing
|
state.phase = .processing
|
||||||
@@ -1101,6 +1104,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
onAIRecognitionStarted(currentUtteranceId)
|
onAIRecognitionStarted(currentUtteranceId)
|
||||||
} else if currentUtteranceRequest?.isEdit != true {
|
} else if currentUtteranceRequest?.isEdit != true {
|
||||||
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
|
||||||
|
beginDictationProgressIfNeeded()
|
||||||
}
|
}
|
||||||
startFlowResultWatchdog()
|
startFlowResultWatchdog()
|
||||||
recomputeMicVoiceAvailability()
|
recomputeMicVoiceAvailability()
|
||||||
@@ -2152,6 +2156,8 @@ final class KeyboardFlowCoordinator {
|
|||||||
state.lastTranscript = partial
|
state.lastTranscript = partial
|
||||||
if result.resolvedUtteranceMode == .aiQuestion {
|
if result.resolvedUtteranceMode == .aiQuestion {
|
||||||
onAITranscript(partial, result.utteranceId, result.status)
|
onAITranscript(partial, result.utteranceId, result.status)
|
||||||
|
} else if result.status == .rawReady {
|
||||||
|
markDictationPolishing(characterCount: partial.count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -2159,6 +2165,43 @@ final class KeyboardFlowCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Dictation progress
|
||||||
|
|
||||||
|
/// Starts the microphone progress fill for a dictation wait. Keeps an
|
||||||
|
/// existing timeline for the same utterance so re-adoption does not rewind.
|
||||||
|
private func beginDictationProgressIfNeeded() {
|
||||||
|
guard let utteranceID = currentUtteranceId,
|
||||||
|
currentUtteranceRequest?.isAIQuestion != true,
|
||||||
|
currentUtteranceRequest?.isEdit != true,
|
||||||
|
state.dictationProgress?.utteranceID != utteranceID else { return }
|
||||||
|
state.dictationProgress = DictationProgressTimeline(
|
||||||
|
utteranceID: utteranceID,
|
||||||
|
transcribingStartedAt: Date().timeIntervalSince1970
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markDictationPolishing(characterCount: Int) {
|
||||||
|
guard var timeline = state.dictationProgress,
|
||||||
|
timeline.polishingStartedAt == nil else { return }
|
||||||
|
timeline.markPolishing(
|
||||||
|
at: Date().timeIntervalSince1970,
|
||||||
|
characterCount: characterCount,
|
||||||
|
expectedSeconds: polishLatencyCalibration.expectedSeconds(
|
||||||
|
forCharacterCount: characterCount
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state.dictationProgress = timeline
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordDictationPolishLatency() {
|
||||||
|
guard let timeline = state.dictationProgress,
|
||||||
|
let polishStart = timeline.polishingStartedAt else { return }
|
||||||
|
polishLatencyCalibration.record(
|
||||||
|
observedSeconds: Date().timeIntervalSince1970 - polishStart,
|
||||||
|
characterCount: timeline.polishCharacterCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private func startFlowResultWatchdog(
|
private func startFlowResultWatchdog(
|
||||||
purpose: ResultWatchdogPurpose = .delivery
|
purpose: ResultWatchdogPurpose = .delivery
|
||||||
) {
|
) {
|
||||||
@@ -2212,6 +2255,7 @@ final class KeyboardFlowCoordinator {
|
|||||||
self.completeAIResult(result)
|
self.completeAIResult(result)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
self.recordDictationPolishLatency()
|
||||||
self.textInserter.handleFlowTranscript(
|
self.textInserter.handleFlowTranscript(
|
||||||
TranscriptionDelivery(
|
TranscriptionDelivery(
|
||||||
text: text,
|
text: text,
|
||||||
|
|||||||
@@ -1048,6 +1048,9 @@ struct AIKeyboardView: View {
|
|||||||
.regular.tint(microphoneTint).interactive(),
|
.regular.tint(microphoneTint).interactive(),
|
||||||
in: Capsule()
|
in: Capsule()
|
||||||
)
|
)
|
||||||
|
if let timeline = activeDictationProgress {
|
||||||
|
dictationProgressFill(timeline)
|
||||||
|
}
|
||||||
microphoneContent
|
microphoneContent
|
||||||
}
|
}
|
||||||
.frame(width: primaryButtonWidth, height: primaryButtonHeight)
|
.frame(width: primaryButtonWidth, height: primaryButtonHeight)
|
||||||
@@ -1096,11 +1099,41 @@ struct AIKeyboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dictation wait rendered as a fill; AI phases keep their own spinner.
|
||||||
|
private var activeDictationProgress: DictationProgressTimeline? {
|
||||||
|
guard state.phase == .processing, !state.aiSession.isBusy else { return nil }
|
||||||
|
return state.dictationProgress
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leading-edge fill in the brand colour. The real result swaps the
|
||||||
|
/// capsule to the solid accent success state, which reads as reaching 100 %.
|
||||||
|
private func dictationProgressFill(_ timeline: DictationProgressTimeline) -> some View {
|
||||||
|
TimelineView(.animation) { context in
|
||||||
|
let fraction = DictationProgressEstimator.fraction(
|
||||||
|
for: timeline,
|
||||||
|
at: context.date.timeIntervalSince1970
|
||||||
|
)
|
||||||
|
GeometryReader { proxy in
|
||||||
|
Rectangle()
|
||||||
|
.fill(palette.accent)
|
||||||
|
.frame(width: proxy.size.width * fraction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(Capsule())
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var ordinaryMicrophoneContent: some View {
|
private var ordinaryMicrophoneContent: some View {
|
||||||
switch state.phase {
|
switch state.phase {
|
||||||
case .recording:
|
case .recording:
|
||||||
waveform(color: OSGColor.recordingWaveform)
|
waveform(color: OSGColor.recordingWaveform)
|
||||||
|
case .processing where activeDictationProgress != nil:
|
||||||
|
Image(systemName: "ellipsis")
|
||||||
|
.font(.system(size: 25, weight: .bold))
|
||||||
|
.foregroundStyle(OSGColor.fixedLightContent)
|
||||||
|
.symbolEffect(.variableColor.iterative, options: .repeating)
|
||||||
case .requestingPermissions, .processing:
|
case .requestingPermissions, .processing:
|
||||||
ProgressView().tint(OSGColor.fixedLightContent)
|
ProgressView().tint(OSGColor.fixedLightContent)
|
||||||
case .error:
|
case .error:
|
||||||
@@ -1146,6 +1179,9 @@ struct AIKeyboardView: View {
|
|||||||
switch state.phase {
|
switch state.phase {
|
||||||
case .recording:
|
case .recording:
|
||||||
return palette.recordRed
|
return palette.recordRed
|
||||||
|
case .processing where activeDictationProgress != nil:
|
||||||
|
// Unfilled track of the progress fill.
|
||||||
|
return palette.accent.opacity(0.35)
|
||||||
case .requestingPermissions, .processing:
|
case .requestingPermissions, .processing:
|
||||||
return palette.recordRed.opacity(0.72)
|
return palette.recordRed.opacity(0.72)
|
||||||
case .error, .denied:
|
case .error, .denied:
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// DictationProgressTests.swift
|
||||||
|
// OSGKeyboard · Keyboard Extension Tests
|
||||||
|
//
|
||||||
|
// Staged microphone progress fill: monotonic, never completes on its own,
|
||||||
|
// steps forward at the ASR → polish milestone, and learns polish latency.
|
||||||
|
|
||||||
|
@testable import OSGKeyboardShared
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
final class DictationProgressTests: XCTestCase {
|
||||||
|
private let start: TimeInterval = 1_000
|
||||||
|
|
||||||
|
private func timeline(polishingAfter seconds: TimeInterval? = nil,
|
||||||
|
expected: TimeInterval = 3) -> DictationProgressTimeline {
|
||||||
|
var timeline = DictationProgressTimeline(utteranceID: UUID(), transcribingStartedAt: start)
|
||||||
|
if let seconds {
|
||||||
|
timeline.markPolishing(at: start + seconds, characterCount: 40, expectedSeconds: expected)
|
||||||
|
}
|
||||||
|
return timeline
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTranscribingStaysBelowCeiling() {
|
||||||
|
let ceiling = DictationProgressEstimator.transcribingCeiling
|
||||||
|
XCTAssertLessThan(DictationProgressEstimator.fraction(for: timeline(), at: start + 3), ceiling)
|
||||||
|
// Long waits converge onto the ceiling (exp underflows to exactly it), never past it.
|
||||||
|
XCTAssertLessThanOrEqual(DictationProgressEstimator.fraction(for: timeline(), at: start + 60), ceiling)
|
||||||
|
XCTAssertEqual(DictationProgressEstimator.fraction(for: timeline(), at: start), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testFractionIsMonotonicAcrossMilestone() {
|
||||||
|
let subject = timeline(polishingAfter: 0.8)
|
||||||
|
var previous = -1.0
|
||||||
|
for step in 0...600 {
|
||||||
|
let value = DictationProgressEstimator.fraction(for: subject, at: start + Double(step) * 0.05)
|
||||||
|
XCTAssertGreaterThanOrEqual(value, previous)
|
||||||
|
previous = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPolishingStepsPastFloorAndNeverCompletes() {
|
||||||
|
let subject = timeline(polishingAfter: 0.8)
|
||||||
|
let afterBlend = DictationProgressEstimator.fraction(for: subject, at: start + 0.8 + 0.3)
|
||||||
|
XCTAssertGreaterThanOrEqual(afterBlend, DictationProgressEstimator.polishingFloor)
|
||||||
|
let muchLater = DictationProgressEstimator.fraction(for: subject, at: start + 600)
|
||||||
|
XCTAssertLessThan(muchLater, 1)
|
||||||
|
XCTAssertLessThanOrEqual(muchLater, DictationProgressEstimator.polishingCeiling)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRepeatedRawReadyKeepsFirstMilestone() {
|
||||||
|
var subject = timeline(polishingAfter: 1)
|
||||||
|
subject.markPolishing(at: start + 5, characterCount: 999, expectedSeconds: 30)
|
||||||
|
XCTAssertEqual(subject.polishingStartedAt, start + 1)
|
||||||
|
XCTAssertEqual(subject.polishCharacterCount, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCalibrationMovesTowardObservedLatencyWithinBounds() throws {
|
||||||
|
let suite = "DictationProgressTests.\(UUID().uuidString)"
|
||||||
|
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
|
||||||
|
defer { defaults.removePersistentDomain(forName: suite) }
|
||||||
|
let calibration = DictationPolishLatencyCalibration(defaults: defaults)
|
||||||
|
|
||||||
|
let initial = calibration.expectedSeconds(forCharacterCount: 100)
|
||||||
|
calibration.record(observedSeconds: 8, characterCount: 100)
|
||||||
|
XCTAssertGreaterThan(calibration.expectedSeconds(forCharacterCount: 100), initial)
|
||||||
|
|
||||||
|
for _ in 0..<50 { calibration.record(observedSeconds: 0.01, characterCount: 0) }
|
||||||
|
XCTAssertEqual(
|
||||||
|
calibration.overheadSeconds,
|
||||||
|
DictationPolishLatencyCalibration.overheadRange.lowerBound,
|
||||||
|
accuracy: 0.001
|
||||||
|
)
|
||||||
|
calibration.record(observedSeconds: -1, characterCount: 0)
|
||||||
|
XCTAssertEqual(
|
||||||
|
calibration.overheadSeconds,
|
||||||
|
DictationPolishLatencyCalibration.overheadRange.lowerBound,
|
||||||
|
accuracy: 0.001
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func testTimelineClearsWhenProcessingEnds() {
|
||||||
|
let state = KeyboardState()
|
||||||
|
state.phase = .processing
|
||||||
|
state.dictationProgress = timeline()
|
||||||
|
state.phase = .processing
|
||||||
|
XCTAssertNotNil(state.dictationProgress)
|
||||||
|
state.phase = .idle
|
||||||
|
XCTAssertNil(state.dictationProgress)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,7 +89,17 @@ public final class KeyboardState: ObservableObject {
|
|||||||
public var id: String { rawValue }
|
public var id: String { rawValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Published public var phase: Phase = .idle
|
@Published public var phase: Phase = .idle {
|
||||||
|
didSet {
|
||||||
|
// A timeline only describes the current processing wait.
|
||||||
|
if phase != .processing, dictationProgress != nil {
|
||||||
|
dictationProgress = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Dictation wait shown as the microphone capsule's progress fill.
|
||||||
|
/// Nil outside dictation processing (AI, edit, and cancellation waits).
|
||||||
|
@Published public var dictationProgress: DictationProgressTimeline?
|
||||||
/// Active chrome. Forced to `.voice` while recording / processing.
|
/// Active chrome. Forced to `.voice` while recording / processing.
|
||||||
@Published public var surface: Surface = .voice
|
@Published public var surface: Surface = .voice
|
||||||
/// Surface the user was on when auto mode switched them to `.voice` to show
|
/// Surface the user was on when auto mode switched them to `.voice` to show
|
||||||
|
|||||||
@@ -174,6 +174,7 @@
|
|||||||
"OSGKeyboardExtTests/CandidatePanelExpandTests",
|
"OSGKeyboardExtTests/CandidatePanelExpandTests",
|
||||||
"OSGKeyboardExtTests/ClipboardSuggestionLifecycleTests",
|
"OSGKeyboardExtTests/ClipboardSuggestionLifecycleTests",
|
||||||
"OSGKeyboardExtTests/KeyboardStateTests",
|
"OSGKeyboardExtTests/KeyboardStateTests",
|
||||||
|
"OSGKeyboardExtTests/DictationProgressTests",
|
||||||
"OSGKeyboardExtTests/KeyboardUsageTypingTests",
|
"OSGKeyboardExtTests/KeyboardUsageTypingTests",
|
||||||
"OSGKeyboardExtTests/KeyboardSurfaceStateTests",
|
"OSGKeyboardExtTests/KeyboardSurfaceStateTests",
|
||||||
"OSGKeyboardExtTests/KeyHitTestingTests",
|
"OSGKeyboardExtTests/KeyHitTestingTests",
|
||||||
|
|||||||
Reference in New Issue
Block a user